"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" // 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; 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; itemList?: string; subItemList?: string; }[]; } export function RfqsItemsDialog({ open, onOpenChange, rfq, defaultItems = [], itemsList, }: RfqsItemsDialogProps) { const rfqId = rfq?.rfqId ?? 0; console.log(itemsList) // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 const isEditable = rfq?.status === "DRAFT"; // 초기 아이템 ID 목록을 추적하기 위한 상태 추가 const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]); // 삭제된 아이템 ID를 저장하는 상태 추가 const [deletedItemIds, setDeletedItemIds] = React.useState([]); // 1) form const form = useForm({ 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>([]); 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, }) ); // 2. 생성/수정 처리 - 폼에 남아있는 아이템들 const upsertPromises = data.items.map((item) => createRfqItem({ rfqId: rfqId, itemCode: item.itemCode, description: item.description, // 명시적으로 숫자로 변환 quantity: Number(item.quantity), uom: item.uom, 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 ( <> {isEditable ? "RFQ 아이템 관리" : "RFQ 아이템 조회"} {rfq?.rfqCode || `RFQ #${rfqId}`} {rfq?.status && ( {rfq.status} )} {isEditable ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.') : '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'}
{/* 헤더 행 (라벨) */}
아이템
설명
수량
단위
{isEditable &&
}
{/* 아이템 행들 */}
{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); }); // 선택된 아이템 찾기 const selected = filteredItems.find(it => it.code === codeValue); return (
{/* -- itemCode + Popover(Select) -- */} {isEditable ? ( { const [popoverOpen, setPopoverOpen] = React.useState(false); const selected = filteredItems.find(it => it.code === field.value); return ( 아이템을 찾을 수 없습니다. {filteredItems.map((it) => ( { field.onChange(it.code); setPopoverOpen(false); focusField(`input[name="items.${index}.description"]`); }} >
{it.code}
{(it.itemList || it.subItemList) && (
{it.itemList} {it.subItemList && ` / ${it.subItemList}`}
)}
))}
{form.formState.errors.items?.[index]?.itemCode && ( )}
); }} /> ) : (
{selected ? `${selected.code}` : codeValue}
)} {/* ID 필드 추가 (숨김) */} ( )} /> {/* description */} {isEditable ? ( ( { if (e.key === 'Enter') { e.preventDefault(); focusField(`input[name="items.${index}.quantity"]`); } }} /> )} /> ) : (
{watchItems[index]?.description || ""}
)} {/* quantity */} {isEditable ? ( ( { 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"]`); } }} /> {form.formState.errors.items?.[index]?.quantity && ( )} )} /> ) : (
{watchItems[index]?.quantity}
)} {/* uom */} {isEditable ? ( ( { 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); } } } }} /> )} /> ) : (
{watchItems[index]?.uom || "each"}
)} {/* remove row - 편집 모드에서만 표시 */} {isEditable && (

아이템 삭제

)}
); })}
{isEditable ? ( <>

단축키: Alt+N

{fields.length}개 아이템 {deletedItemIds.length > 0 && ( ({deletedItemIds.length}개 아이템 삭제 예정) )} ) : ( {fields.length}개 아이템 )}
{isEditable && (
Tab 필드 간 이동 Enter 다음 필드로 이동
)}
{isEditable ? ( <> 변경사항을 저장하지 않고 나가기

단축키: Ctrl+S

) : ( )}
{/* 저장하지 않고 나가기 확인 다이얼로그 - 편집 모드에서만 활성화 */} {isEditable && ( 저장되지 않은 변경사항 저장되지 않은 변경사항이 있습니다. 그래도 나가시겠습니까? 취소 { setIsExitDialogOpen(false); onOpenChange(false); }}> 저장하지 않고 나가기 )} ); }