diff options
Diffstat (limited to 'lib/rfqs/table/ItemsDialog.tsx')
| -rw-r--r-- | lib/rfqs/table/ItemsDialog.tsx | 752 |
1 files changed, 0 insertions, 752 deletions
diff --git a/lib/rfqs/table/ItemsDialog.tsx b/lib/rfqs/table/ItemsDialog.tsx deleted file mode 100644 index 3d822499..00000000 --- a/lib/rfqs/table/ItemsDialog.tsx +++ /dev/null @@ -1,752 +0,0 @@ -"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 |
