diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-13 03:12:10 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-13 03:12:10 +0000 |
| commit | 1c790b57447aff36820437d6f18a969de9b45baa (patch) | |
| tree | 5c8d58c70d1000363b252c98f037cea77a8511a4 /lib/rfqs-ship/table/ItemsDialog.tsx | |
| parent | 6480a7fd21313417e37494698d69d62a62428860 (diff) | |
(대표님) lib/rfq-ships
Diffstat (limited to 'lib/rfqs-ship/table/ItemsDialog.tsx')
| -rw-r--r-- | lib/rfqs-ship/table/ItemsDialog.tsx | 752 |
1 files changed, 752 insertions, 0 deletions
diff --git a/lib/rfqs-ship/table/ItemsDialog.tsx b/lib/rfqs-ship/table/ItemsDialog.tsx new file mode 100644 index 00000000..3d822499 --- /dev/null +++ b/lib/rfqs-ship/table/ItemsDialog.tsx @@ -0,0 +1,752 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandItem, + CommandGroup, + CommandEmpty +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Plus, Trash2, Save, X, AlertCircle, Eye } from "lucide-react" +import { toast } from "sonner" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Badge } from "@/components/ui/badge" + +import { createRfqItem, deleteRfqItem } from "../service" +import { RfqWithItemCount } from "@/db/schema/rfq" +import { RfqType } from "../validations" + +// Zod 스키마 - 수량은 string으로 받아서 나중에 변환 +const itemSchema = z.object({ + id: z.number().optional(), + itemCode: z.string().nonempty({ message: "아이템 코드를 선택해주세요" }), + description: z.string().optional(), + quantity: z.coerce.number().min(1, { message: "최소 수량은 1입니다" }).default(1), + uom: z.string().default("each"), +}); + +const itemsFormSchema = z.object({ + rfqId: z.number().int(), + items: z.array(itemSchema).min(1, { message: "최소 1개 이상의 아이템을 추가해주세요" }), +}); + +type ItemsFormSchema = z.infer<typeof itemsFormSchema>; + +interface RfqsItemsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfq: RfqWithItemCount | null; + defaultItems?: { + id?: number; + itemCode: string; + quantity?: number | null; + description?: string | null; + uom?: string | null; + }[]; + itemsList: { code: string | null; name: string }[]; + rfqType?: RfqType; +} + +export function RfqsItemsDialog({ + open, + onOpenChange, + rfq, + defaultItems = [], + itemsList, + rfqType +}: RfqsItemsDialogProps) { + const rfqId = rfq?.rfqId ?? 0; + + // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 + const isEditable = rfq?.status === "DRAFT"; + + // 초기 아이템 ID 목록을 추적하기 위한 상태 추가 + const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]); + + // 삭제된 아이템 ID를 저장하는 상태 추가 + const [deletedItemIds, setDeletedItemIds] = React.useState<number[]>([]); + + // 1) form + const form = useForm<ItemsFormSchema>({ + resolver: zodResolver(itemsFormSchema), + defaultValues: { + rfqId, + items: defaultItems.length > 0 ? defaultItems.map((it) => ({ + id: it.id, + quantity: it.quantity ?? 1, + uom: it.uom ?? "each", + itemCode: it.itemCode ?? "", + description: it.description ?? "", + })) : [{ itemCode: "", description: "", quantity: 1, uom: "each" }], + }, + mode: "onChange", // 입력 필드가 변경될 때마다 유효성 검사 + }); + + // 다이얼로그가 열릴 때마다 폼 초기화 및 초기 아이템 ID 저장 + React.useEffect(() => { + if (open) { + const initialItems = defaultItems.length > 0 + ? defaultItems.map((it) => ({ + id: it.id, + quantity: it.quantity ?? 1, + uom: it.uom ?? "each", + itemCode: it.itemCode ?? "", + description: it.description ?? "", + })) + : [{ itemCode: "", description: "", quantity: 1, uom: "each" }]; + + form.reset({ + rfqId, + items: initialItems, + }); + + // 초기 아이템 ID 목록 저장 + setInitialItemIds(defaultItems.map(item => item.id)); + + // 삭제된 아이템 목록 초기화 + setDeletedItemIds([]); + setHasUnsavedChanges(false); + } + }, [open, defaultItems, rfqId, form]); + + // 새로운 요소에 대한 ref 배열 + const inputRefs = React.useRef<Array<HTMLButtonElement | null>>([]); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false); + const [isExitDialogOpen, setIsExitDialogOpen] = React.useState(false); + + // 폼 변경 감지 - 편집 가능한 경우에만 변경 감지 + React.useEffect(() => { + if (!isEditable) return; + + const subscription = form.watch(() => { + setHasUnsavedChanges(true); + }); + return () => subscription.unsubscribe(); + }, [form, isEditable]); + + // 2) field array + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }); + + // 3) watch items array + const watchItems = form.watch("items"); + + // 4) Add item row with auto-focus + function handleAddItem() { + if (!isEditable) return; + + // 명시적으로 숫자 타입으로 지정 + append({ + itemCode: "", + description: "", + quantity: 1, + uom: "each" + }); + setHasUnsavedChanges(true); + + // 다음 렌더링 사이클에서 새로 추가된 항목에 포커스 + setTimeout(() => { + const newIndex = fields.length; + const button = inputRefs.current[newIndex]; + if (button) { + button.click(); + } + }, 100); + } + + // 항목 직접 삭제 - 기존 ID가 있을 경우 삭제 목록에 추가 + const handleRemoveItem = (index: number) => { + if (!isEditable) return; + + const itemToRemove = form.getValues().items[index]; + + // 기존 ID가 있는 아이템이라면 삭제 목록에 추가 + if (itemToRemove.id !== undefined) { + setDeletedItemIds(prev => [...prev, itemToRemove.id as number]); + } + + remove(index); + setHasUnsavedChanges(true); + + // 포커스 처리: 다음 항목이 있으면 다음 항목으로, 없으면 마지막 항목으로 + setTimeout(() => { + const nextIndex = Math.min(index, fields.length - 1); + if (nextIndex >= 0 && inputRefs.current[nextIndex]) { + inputRefs.current[nextIndex]?.click(); + } + }, 50); + }; + + // 다이얼로그 닫기 전 확인 + const handleDialogClose = (open: boolean) => { + if (!open && hasUnsavedChanges && isEditable) { + setIsExitDialogOpen(true); + } else { + onOpenChange(open); + } + }; + + // 필드 포커스 유틸리티 함수 + const focusField = (selector: string) => { + if (!isEditable) return; + + setTimeout(() => { + const element = document.querySelector(selector) as HTMLInputElement | null; + if (element) { + element.focus(); + } + }, 10); + }; + + // 5) Submit - 업데이트된 제출 로직 (생성/수정 + 삭제 처리) + async function onSubmit(data: ItemsFormSchema) { + if (!isEditable) return; + + try { + setIsSubmitting(true); + + // 각 아이템이 유효한지 확인 + const anyInvalidItems = data.items.some(item => !item.itemCode || item.quantity < 1); + + if (anyInvalidItems) { + toast.error("유효하지 않은 아이템이 있습니다. 모든 필드를 확인해주세요."); + setIsSubmitting(false); + return; + } + + // 1. 삭제 처리 - 삭제된 아이템 ID가 있으면 삭제 요청 + const deletePromises = deletedItemIds.map(id => + deleteRfqItem({ + id: id, + rfqId: rfqId, + rfqType: rfqType ?? RfqType.PURCHASE + }) + ); + + // 2. 생성/수정 처리 - 폼에 남아있는 아이템들 + const upsertPromises = data.items.map((item) => + createRfqItem({ + rfqId: rfqId, + itemCode: item.itemCode, + description: item.description, + // 명시적으로 숫자로 변환 + quantity: Number(item.quantity), + uom: item.uom, + rfqType: rfqType ?? RfqType.PURCHASE, + id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성 + }) + ); + + // 모든 요청 병렬 처리 + await Promise.all([...deletePromises, ...upsertPromises]); + + toast.success("RFQ 아이템이 성공적으로 저장되었습니다!"); + setHasUnsavedChanges(false); + onOpenChange(false); + } catch (err) { + toast.error(`오류가 발생했습니다: ${String(err)}`); + } finally { + setIsSubmitting(false); + } + } + + // 단축키 처리 - 편집 가능한 경우에만 단축키 활성화 + React.useEffect(() => { + if (!isEditable) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // Alt+N: 새 항목 추가 + if (e.altKey && e.key === 'n') { + e.preventDefault(); + handleAddItem(); + } + // Ctrl+S: 저장 + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + // Esc: 포커스된 팝오버 닫기 + if (e.key === 'Escape') { + document.querySelectorAll('[role="combobox"][aria-expanded="true"]').forEach( + (el) => (el as HTMLButtonElement).click() + ); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [form, isEditable]); + + return ( + <> + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="max-w-none w-[1200px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + {isEditable ? "RFQ 아이템 관리" : "RFQ 아이템 조회"} + <Badge variant="outline" className="ml-2"> + {rfq?.rfqCode || `RFQ #${rfqId}`} + </Badge> + {rfqType && ( + <Badge variant={rfqType === RfqType.PURCHASE ? "default" : "secondary"} className="ml-1"> + {rfqType === RfqType.PURCHASE ? "구매 RFQ" : "예산 RFQ"} + </Badge> + )} + {rfq?.status && ( + <Badge + variant={rfq.status === "DRAFT" ? "outline" : "secondary"} + className="ml-1" + > + {rfq.status} + </Badge> + )} + </DialogTitle> + <DialogDescription> + {isEditable + ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.') + : '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'} + </DialogDescription> + </DialogHeader> + <div className="overflow-x-auto w-full"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4"> + {/* 헤더 행 (라벨) */} + <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm"> + <div className="w-[250px] pl-3">아이템</div> + <div className="w-[400px] pl-2">설명</div> + <div className="w-[80px] pl-2 text-center">수량</div> + <div className="w-[80px] pl-2 text-center">단위</div> + {isEditable && <div className="w-[42px]"></div>} + </div> + + {/* 아이템 행들 */} + <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-3"> + {fields.map((field, index) => { + // 현재 row의 itemCode + const codeValue = watchItems[index]?.itemCode || ""; + // "이미" 사용된 코드를 모두 구함 + const usedCodes = watchItems + .map((it, i) => i === index ? null : it.itemCode) + .filter(Boolean) as string[]; + + // itemsList에서 "현재 선택한 code"만 예외적으로 허용하고, + // 다른 행에서 이미 사용한 code는 제거 + const filteredItems = (itemsList || []) + .filter((it) => { + if (!it.code) return false; + if (it.code === codeValue) return true; + return !usedCodes.includes(it.code); + }) + .map((it) => ({ + code: it.code ?? "", // fallback + name: it.name, + })); + + // 선택된 아이템 찾기 + const selected = filteredItems.find(it => it.code === codeValue); + + return ( + <div key={field.id} className="flex items-center gap-2 group hover:bg-gray-50 p-1 rounded-md transition-colors"> + {/* -- itemCode + Popover(Select) -- */} + {isEditable ? ( + // 전체 FormField 컴포넌트와 아이템 선택 로직 개선 + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => { + const [popoverOpen, setPopoverOpen] = React.useState(false); + const selected = filteredItems.find(it => it.code === field.value); + + return ( + <FormItem className="flex items-center gap-2 w-[250px]" style={{width:250}}> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + // 컴포넌트에 ref 전달 + ref={el => { + inputRefs.current[index] = el; + }} + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="flex items-center" + data-error={!!form.formState.errors.items?.[index]?.itemCode} + data-state={selected ? "filled" : "empty"} + style={{width:250}} + > + <div className="flex-1 overflow-hidden mr-2 text-left"> + <span className="block truncate" style={{width:200}}> + {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."} + </span> + </div> + <ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput placeholder="아이템 검색..." className="h-9" autoFocus /> + <CommandList> + <CommandEmpty>아이템을 찾을 수 없습니다.</CommandEmpty> + <CommandGroup> + {filteredItems.map((it) => { + const label = `${it.code} - ${it.name}`; + return ( + <CommandItem + key={it.code} + value={label} + onSelect={() => { + field.onChange(it.code); + setPopoverOpen(false); + // 자동으로 다음 필드로 포커스 이동 + focusField(`input[name="items.${index}.description"]`); + }} + > + <div className="flex-1 overflow-hidden"> + <span className="block truncate">{label}</span> + </div> + <Check + className={ + "ml-auto h-4 w-4" + + (it.code === field.value ? " opacity-100" : " opacity-0") + } + /> + </CommandItem> + ); + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + {form.formState.errors.items?.[index]?.itemCode && ( + <AlertCircle className="h-4 w-4 text-destructive" /> + )} + </FormItem> + ); + }} + /> + ) : ( + <div className="flex items-center w-[250px] pl-3"> + {selected ? `${selected.code} - ${selected.name}` : codeValue} + </div> + )} + + {/* ID 필드 추가 (숨김) */} + <FormField + control={form.control} + name={`items.${index}.id`} + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> + + {/* description */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.description`} + render={({ field }) => ( + <FormItem className="w-[400px]"> + <FormControl> + <Input + className="w-full" + placeholder="아이템 상세 정보" + {...field} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + focusField(`input[name="items.${index}.quantity"]`); + } + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + ) : ( + <div className="w-[400px] pl-2"> + {watchItems[index]?.description || ""} + </div> + )} + + {/* quantity */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem className="w-[80px] relative"> + <FormControl> + <Input + type="number" + className="w-full text-center" + min="1" + {...field} + // 값 변경 핸들러 개선 + onChange={(e) => { + const value = e.target.value === '' ? 1 : parseInt(e.target.value, 10); + field.onChange(isNaN(value) ? 1 : value); + }} + // 최소값 보장 (빈 문자열 방지) + onBlur={(e) => { + if (e.target.value === '' || parseInt(e.target.value, 10) < 1) { + field.onChange(1); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + focusField(`input[name="items.${index}.uom"]`); + } + }} + /> + </FormControl> + {form.formState.errors.items?.[index]?.quantity && ( + <AlertCircle className="h-4 w-4 text-destructive absolute right-2 top-2" /> + )} + </FormItem> + )} + /> + ) : ( + <div className="w-[80px] text-center"> + {watchItems[index]?.quantity} + </div> + )} + + {/* uom */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.uom`} + render={({ field }) => ( + <FormItem className="w-[80px]"> + <FormControl> + <Input + placeholder="each" + className="w-full text-center" + {...field} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + // 마지막 행이면 새로운 행 추가 + if (index === fields.length - 1) { + handleAddItem(); + } else { + // 아니면 다음 행의 아이템 선택으로 이동 + const button = inputRefs.current[index + 1]; + if (button) { + setTimeout(() => button.click(), 10); + } + } + } + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + ) : ( + <div className="w-[80px] text-center"> + {watchItems[index]?.uom || "each"} + </div> + )} + + {/* remove row - 편집 모드에서만 표시 */} + {isEditable && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => handleRemoveItem(index)} + className="group-hover:opacity-100 transition-opacity" + aria-label="아이템 삭제" + > + <Trash2 className="h-4 w-4 text-destructive" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>아이템 삭제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + ); + })} + </div> + + <div className="flex justify-between items-center pt-2 border-t"> + <div className="flex items-center gap-2"> + {isEditable ? ( + <> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button type="button" variant="outline" onClick={handleAddItem} className="gap-1"> + <Plus className="h-4 w-4" /> + 아이템 추가 + </Button> + </TooltipTrigger> + <TooltipContent side="bottom"> + <p>단축키: Alt+N</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + <span className="text-sm text-muted-foreground"> + {fields.length}개 아이템 + </span> + {deletedItemIds.length > 0 && ( + <span className="text-sm text-destructive"> + ({deletedItemIds.length}개 아이템 삭제 예정) + </span> + )} + </> + ) : ( + <span className="text-sm text-muted-foreground"> + {fields.length}개 아이템 + </span> + )} + </div> + + {isEditable && ( + <div className="text-xs text-muted-foreground"> + <span className="inline-flex items-center gap-1 mr-2"> + <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Tab</kbd> + <span>필드 간 이동</span> + </span> + <span className="inline-flex items-center gap-1"> + <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Enter</kbd> + <span>다음 필드로 이동</span> + </span> + </div> + )} + </div> + </div> + + <DialogFooter className="mt-6 gap-2"> + {isEditable ? ( + <> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button type="button" variant="outline" onClick={() => handleDialogClose(false)}> + <X className="mr-2 h-4 w-4" /> + 취소 + </Button> + </TooltipTrigger> + <TooltipContent>변경사항을 저장하지 않고 나가기</TooltipContent> + </Tooltip> + </TooltipProvider> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="submit" + disabled={isSubmitting || (!form.formState.isDirty && deletedItemIds.length === 0) || !form.formState.isValid} + > + {isSubmitting ? ( + <>처리 중...</> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + 저장 + </> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p>단축키: Ctrl+S</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </> + ) : ( + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + <X className="mr-2 h-4 w-4" /> + 닫기 + </Button> + )} + </DialogFooter> + </form> + </Form> + </div> + </DialogContent> + </Dialog> + + {/* 저장하지 않고 나가기 확인 다이얼로그 - 편집 모드에서만 활성화 */} + {isEditable && ( + <AlertDialog open={isExitDialogOpen} onOpenChange={setIsExitDialogOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>저장되지 않은 변경사항</AlertDialogTitle> + <AlertDialogDescription> + 저장되지 않은 변경사항이 있습니다. 그래도 나가시겠습니까? + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={() => { + setIsExitDialogOpen(false); + onOpenChange(false); + }}> + 저장하지 않고 나가기 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + )} + </> + ); +}
\ No newline at end of file |
