diff options
Diffstat (limited to 'lib/rfqs/table')
| -rw-r--r-- | lib/rfqs/table/ItemsDialog.tsx | 752 | ||||
| -rw-r--r-- | lib/rfqs/table/ParentRfqSelector.tsx | 307 | ||||
| -rw-r--r-- | lib/rfqs/table/add-rfq-dialog.tsx | 468 | ||||
| -rw-r--r-- | lib/rfqs/table/attachment-rfq-sheet.tsx | 429 | ||||
| -rw-r--r-- | lib/rfqs/table/delete-rfqs-dialog.tsx | 149 | ||||
| -rw-r--r-- | lib/rfqs/table/feature-flags-provider.tsx | 108 | ||||
| -rw-r--r-- | lib/rfqs/table/feature-flags.tsx | 96 | ||||
| -rw-r--r-- | lib/rfqs/table/rfqs-table-columns.tsx | 315 | ||||
| -rw-r--r-- | lib/rfqs/table/rfqs-table-floating-bar.tsx | 338 | ||||
| -rw-r--r-- | lib/rfqs/table/rfqs-table-toolbar-actions.tsx | 55 | ||||
| -rw-r--r-- | lib/rfqs/table/rfqs-table.tsx | 263 | ||||
| -rw-r--r-- | lib/rfqs/table/update-rfq-sheet.tsx | 406 |
12 files changed, 0 insertions, 3686 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 diff --git a/lib/rfqs/table/ParentRfqSelector.tsx b/lib/rfqs/table/ParentRfqSelector.tsx deleted file mode 100644 index 0edb1233..00000000 --- a/lib/rfqs/table/ParentRfqSelector.tsx +++ /dev/null @@ -1,307 +0,0 @@ -"use client" - -import * as React from "react" -import { Check, ChevronsUpDown, Loader } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -import { cn } from "@/lib/utils" -import { useDebounce } from "@/hooks/use-debounce" -import { getBudgetaryRfqs, type BudgetaryRfq } from "../service" -import { RfqType } from "../validations" - -// ParentRfq 타입 정의 (서비스의 BudgetaryRfq와 호환되어야 함) -interface ParentRfq { - id: number; - rfqCode: string; - description: string | null; - rfqType: RfqType; - projectId: number | null; - projectCode: string | null; - projectName: string | null; -} - -interface ParentRfqSelectorProps { - selectedRfqId?: number; - onRfqSelect: (rfq: ParentRfq | null) => void; - rfqType: RfqType; // 현재 생성 중인 RFQ 타입 - parentRfqTypes: RfqType[]; // 선택 가능한 부모 RFQ 타입 목록 - placeholder?: string; -} - -export function ParentRfqSelector({ - selectedRfqId, - onRfqSelect, - rfqType, - parentRfqTypes, - placeholder = "부모 RFQ 선택..." -}: ParentRfqSelectorProps) { - const [searchTerm, setSearchTerm] = React.useState(""); - const debouncedSearchTerm = useDebounce(searchTerm, 300); - - const [open, setOpen] = React.useState(false); - const [loading, setLoading] = React.useState(false); - const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]); - const [selectedRfq, setSelectedRfq] = React.useState<ParentRfq | null>(null); - const [page, setPage] = React.useState(1); - const [hasMore, setHasMore] = React.useState(true); - const [totalCount, setTotalCount] = React.useState(0); - - const listRef = React.useRef<HTMLDivElement>(null); - - // 타입별로 적절한 검색 placeholder 생성 - const getSearchPlaceholder = () => { - if (rfqType === RfqType.PURCHASE) { - return "BUDGETARY/PURCHASE_BUDGETARY RFQ 검색..."; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "BUDGETARY RFQ 검색..."; - } - return "RFQ 코드/설명/프로젝트 검색..."; - }; - - // 초기 선택된 RFQ가 있을 경우 로드 - React.useEffect(() => { - if (selectedRfqId && open) { - const loadSelectedRfq = async () => { - try { - // 단일 RFQ를 id로 조회하는 API 호출 - const result = await getBudgetaryRfqs({ - limit: 1, - rfqId: selectedRfqId - }); - - if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) { - setSelectedRfq(result.rfqs[0] as unknown as ParentRfq); - } - } catch (error) { - console.error("선택된 RFQ 로드 오류:", error); - } - }; - - if (!selectedRfq || selectedRfq.id !== selectedRfqId) { - loadSelectedRfq(); - } - } - }, [selectedRfqId, open, selectedRfq]); - - // 검색어 변경 시 데이터 리셋 및 재로드 - React.useEffect(() => { - if (open) { - setPage(1); - setHasMore(true); - setParentRfqs([]); - loadParentRfqs(1, true); - } - }, [debouncedSearchTerm, open, parentRfqTypes]); - - // 데이터 로드 함수 - const loadParentRfqs = async (pageToLoad: number, reset = false) => { - if (!open || parentRfqTypes.length === 0) return; - - setLoading(true); - try { - const limit = 20; // 한 번에 로드할 항목 수 - const result = await getBudgetaryRfqs({ - search: debouncedSearchTerm, - limit, - offset: (pageToLoad - 1) * limit, - rfqTypes: parentRfqTypes // 현재 RFQ 타입에 맞는 부모 RFQ 타입들로 필터링 - }); - - if ('rfqs' in result && result.rfqs) { - if (reset) { - setParentRfqs(result.rfqs as unknown as ParentRfq[]); - } else { - setParentRfqs(prev => [...prev, ...(result.rfqs as unknown as ParentRfq[])]); - } - - setTotalCount(result.totalCount); - setHasMore(result.rfqs.length === limit && (pageToLoad * limit) < result.totalCount); - setPage(pageToLoad); - } - } catch (error) { - console.error("부모 RFQ 로드 오류:", error); - } finally { - setLoading(false); - } - }; - - // 무한 스크롤 처리 - const handleScroll = () => { - if (listRef.current) { - const { scrollTop, scrollHeight, clientHeight } = listRef.current; - - // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드 - if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) { - loadParentRfqs(page + 1); - } - } - }; - - // RFQ를 프로젝트별로 그룹화하는 함수 - const groupRfqsByProject = (rfqs: ParentRfq[]) => { - const groups: Record<string, { - projectId: number | null; - projectCode: string | null; - projectName: string | null; - rfqs: ParentRfq[]; - }> = {}; - - // 'No Project' 그룹 기본 생성 - groups['no-project'] = { - projectId: null, - projectCode: null, - projectName: null, - rfqs: [] - }; - - // 프로젝트별로 RFQ 그룹화 - rfqs.forEach(rfq => { - const key = rfq.projectId ? `project-${rfq.projectId}` : 'no-project'; - - if (!groups[key] && rfq.projectId) { - groups[key] = { - projectId: rfq.projectId, - projectCode: rfq.projectCode, - projectName: rfq.projectName, - rfqs: [] - }; - } - - groups[key].rfqs.push(rfq); - }); - - // 필터링된 결과가 있는 그룹만 남기기 - return Object.values(groups).filter(group => group.rfqs.length > 0); - }; - - // 그룹화된 RFQ 목록 - const groupedRfqs = React.useMemo(() => { - return groupRfqsByProject(parentRfqs); - }, [parentRfqs]); - - // RFQ 선택 처리 - const handleRfqSelect = (rfq: ParentRfq | null) => { - setSelectedRfq(rfq); - onRfqSelect(rfq); - setOpen(false); - }; - - // RFQ 타입에 따른 표시 형식 - const getRfqTypeLabel = (type: RfqType) => { - switch(type) { - case RfqType.BUDGETARY: - return "BUDGETARY"; - case RfqType.PURCHASE_BUDGETARY: - return "PURCHASE_BUDGETARY"; - case RfqType.PURCHASE: - return "PURCHASE"; - default: - return type; - } - }; - - return ( - <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> - <Button - variant="outline" - role="combobox" - aria-expanded={open} - className="w-full justify-between" - > - {selectedRfq - ? `${selectedRfq.rfqCode || ""} - ${selectedRfq.description || ""}` - : placeholder} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput - placeholder={getSearchPlaceholder()} - value={searchTerm} - onValueChange={setSearchTerm} - /> - <CommandList - className="max-h-[300px]" - ref={listRef} - onScroll={handleScroll} - > - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> - - <CommandGroup> - <CommandItem - value="none" - onSelect={() => handleRfqSelect(null)} - > - <Check - className={cn( - "mr-2 h-4 w-4", - !selectedRfq - ? "opacity-100" - : "opacity-0" - )} - /> - <span className="font-medium">선택 안함</span> - </CommandItem> - </CommandGroup> - - {groupedRfqs.map((group, index) => ( - <CommandGroup - key={`group-${group.projectId || index}`} - heading={ - group.projectId - ? `${group.projectCode || ""} - ${group.projectName || ""}` - : "프로젝트 없음" - } - > - {group.rfqs.map((rfq) => ( - <CommandItem - key={rfq.id} - value={`${rfq.rfqCode || ""} ${rfq.description || ""}`} - onSelect={() => handleRfqSelect(rfq)} - > - <Check - className={cn( - "mr-2 h-4 w-4", - selectedRfq?.id === rfq.id - ? "opacity-100" - : "opacity-0" - )} - /> - <div className="flex flex-col"> - <div className="flex items-center"> - <span className="font-medium">{rfq.rfqCode || ""}</span> - <span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-700"> - {getRfqTypeLabel(rfq.rfqType)} - </span> - </div> - {rfq.description && ( - <span className="text-sm text-gray-500 truncate"> - {rfq.description} - </span> - )} - </div> - </CommandItem> - ))} - </CommandGroup> - ))} - - {loading && ( - <div className="py-2 text-center"> - <Loader className="h-4 w-4 animate-spin mx-auto" /> - </div> - )} - - {!loading && !hasMore && parentRfqs.length > 0 && ( - <div className="py-2 text-center text-sm text-muted-foreground"> - 총 {totalCount}개 중 {parentRfqs.length}개 표시됨 - </div> - )} - </CommandList> - </Command> - </PopoverContent> - </Popover> - ); -}
\ No newline at end of file diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx deleted file mode 100644 index 67561b4f..00000000 --- a/lib/rfqs/table/add-rfq-dialog.tsx +++ /dev/null @@ -1,468 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { toast } from "sonner" - -import { Dialog, DialogTrigger, 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 { useSession } from "next-auth/react" -import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations" -import { createRfq, generateNextRfqCode, getBudgetaryRfqs } from "../service" -import { ProjectSelector } from "@/components/ProjectSelector" -import { type Project } from "../service" -import { ParentRfqSelector } from "./ParentRfqSelector" -import { EstimateProjectSelector } from "@/components/BidProjectSelector" - -// 부모 RFQ 정보 타입 정의 -interface ParentRfq { - id: number; - rfqCode: string; - description: string | null; - rfqType: RfqType; - projectId: number | null; - projectCode: string | null; - projectName: string | null; -} - -interface AddRfqDialogProps { - rfqType?: RfqType; -} - -export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) { - const [open, setOpen] = React.useState(false) - const { data: session, status } = useSession() - const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]) - const [isLoadingParents, setIsLoadingParents] = React.useState(false) - const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null) - const [isLoadingRfqCode, setIsLoadingRfqCode] = React.useState(false) - - // Get the user ID safely, ensuring it's a valid number - const userId = React.useMemo(() => { - const id = session?.user?.id ? Number(session.user.id) : null; - - return id; - }, [session, status]); - - // RfqType에 따른 타이틀 생성 - const getTitle = () => { - switch (rfqType) { - case RfqType.PURCHASE: - return "Purchase RFQ"; - case RfqType.BUDGETARY: - return "Budgetary RFQ"; - case RfqType.PURCHASE_BUDGETARY: - return "Purchase Budgetary RFQ"; - default: - return "RFQ"; - } - }; - - // RfqType 설명 가져오기 - const getTypeDescription = () => { - switch (rfqType) { - case RfqType.PURCHASE: - return "실제 구매 발주 전에 가격을 요청"; - case RfqType.BUDGETARY: - return "기술영업 단계에서 입찰가 산정을 위한 견적 요청"; - case RfqType.PURCHASE_BUDGETARY: - return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청"; - default: - return ""; - } - }; - - // RHF + Zod - const form = useForm<CreateRfqSchema>({ - resolver: zodResolver(createRfqSchema), - defaultValues: { - rfqCode: "", - description: "", - projectId: undefined, - parentRfqId: undefined, - dueDate: new Date(), - status: "DRAFT", - rfqType: rfqType, - // Don't set createdBy yet - we'll set it when the form is submitted - createdBy: undefined, - }, - }); - - // Update form values when session loads - React.useEffect(() => { - if (status === "authenticated" && userId) { - form.setValue("createdBy", userId); - } - }, [status, userId, form]); - - // 다이얼로그가 열릴 때 자동으로 RFQ 코드 생성 - React.useEffect(() => { - if (open) { - const generateRfqCode = async () => { - setIsLoadingRfqCode(true); - try { - // 서버 액션 호출 - const result = await generateNextRfqCode(rfqType); - - if (result.error) { - toast.error(`RFQ 코드 생성 실패: ${result.error}`); - return; - } - - // 생성된 코드를 폼에 설정 - form.setValue("rfqCode", result.code); - } catch (error) { - console.error("RFQ 코드 생성 오류:", error); - toast.error("RFQ 코드 생성에 실패했습니다"); - } finally { - setIsLoadingRfqCode(false); - } - }; - - generateRfqCode(); - } - }, [open, rfqType, form]); - - // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 - const getParentRfqTypes = (): RfqType[] => { - switch (rfqType) { - case RfqType.PURCHASE: - // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 - return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; - case RfqType.PURCHASE_BUDGETARY: - // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음 - return [RfqType.BUDGETARY]; - default: - return []; - } - }; - - // 선택 가능한 부모 RFQ 목록 로드 - React.useEffect(() => { - if ((rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY) && open) { - const loadParentRfqs = async () => { - setIsLoadingParents(true); - try { - // 현재 RFQ 타입에 따라 선택 가능한, 부모가 될 수 있는 RFQ 타입들 가져오기 - const parentTypes = getParentRfqTypes(); - - // 부모 RFQ 타입이 있을 때만 API 호출 - if (parentTypes.length > 0) { - const result = await getBudgetaryRfqs({ - rfqTypes: parentTypes // 서비스에 rfqTypes 파라미터 추가 필요 - }); - - if ('rfqs' in result) { - setParentRfqs(result.rfqs as unknown as ParentRfq[]); - } else if ('error' in result) { - console.error("부모 RFQ 로드 오류:", result.error); - } - } - } catch (error) { - console.error("부모 RFQ 로드 오류:", error); - } finally { - setIsLoadingParents(false); - } - }; - - loadParentRfqs(); - } - }, [rfqType, open]); - - // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project | null) => { - if (project === null) { - return; - } - - form.setValue("projectId", project.id); - }; - - const handleBidProjectSelect = (project: Project | null) => { - if (project === null) { - return; - } - - form.setValue("bidProjectId", project.id); - }; - - // 부모 RFQ 선택 처리 - const handleParentRfqSelect = (rfq: ParentRfq | null) => { - setSelectedParentRfq(rfq); - form.setValue("parentRfqId", rfq?.id); - }; - - async function onSubmit(data: CreateRfqSchema) { - // Check if user is authenticated before submitting - if (status !== "authenticated" || !userId) { - toast.error("사용자 인증이 필요합니다. 다시 로그인해주세요."); - return; - } - - // Make sure createdBy is set with the current user ID - const submitData = { - ...data, - createdBy: userId - }; - - console.log("Submitting form data:", submitData); - - const result = await createRfq(submitData); - if (result.error) { - toast.error(`에러: ${result.error}`); - return; - } - - toast.success("RFQ가 성공적으로 생성되었습니다."); - form.reset(); - setSelectedParentRfq(null); - setOpen(false); - } - - function handleDialogOpenChange(nextOpen: boolean) { - if (!nextOpen) { - form.reset(); - setSelectedParentRfq(null); - } - setOpen(nextOpen); - } - - // Return a message or disabled state if user is not authenticated - if (status === "loading") { - return <Button variant="outline" size="sm" disabled>Loading...</Button>; - } - - // 타입에 따라 부모 RFQ 선택 필드를 보여줄지 결정 - const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; - const shouldShowEstimateSelector = rfqType === RfqType.BUDGETARY; - - // 부모 RFQ 선택기 레이블 및 설명 가져오기 - const getParentRfqSelectorLabel = () => { - if (rfqType === RfqType.PURCHASE) { - return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)"; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "부모 RFQ (BUDGETARY)"; - } - return "부모 RFQ"; - }; - - const getParentRfqDescription = () => { - if (rfqType === RfqType.PURCHASE) { - return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다."; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다."; - } - return ""; - }; - - return ( - <Dialog open={open} onOpenChange={handleDialogOpenChange}> - {/* 모달을 열기 위한 버튼 */} - <DialogTrigger asChild> - <Button variant="default" size="sm"> - Add {getTitle()} - </Button> - </DialogTrigger> - - <DialogContent> - <DialogHeader> - <DialogTitle>Create New {getTitle()}</DialogTitle> - <DialogDescription> - 새 {getTitle()} 정보를 입력하고 <b>Create</b> 버튼을 누르세요. - <div className="mt-1 text-xs text-muted-foreground"> - {getTypeDescription()} - </div> - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="space-y-4 py-4"> - {/* rfqType - hidden field */} - <FormField - control={form.control} - name="rfqType" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - {/* Project Selector */} - <FormField - control={form.control} - name="projectId" - render={({ field }) => ( - <FormItem> - <FormLabel>Project</FormLabel> - <FormControl> - - {shouldShowEstimateSelector ? - <EstimateProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleBidProjectSelect} - placeholder="견적 프로젝트 선택..." - /> : - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - />} - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */} - {shouldShowParentRfqSelector && ( - <FormField - control={form.control} - name="parentRfqId" - render={({ field }) => ( - <FormItem> - <FormLabel>{getParentRfqSelectorLabel()}</FormLabel> - <FormControl> - <ParentRfqSelector - selectedRfqId={field.value as number | undefined} - onRfqSelect={handleParentRfqSelect} - rfqType={rfqType} - parentRfqTypes={getParentRfqTypes()} - placeholder={ - rfqType === RfqType.PURCHASE - ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." - : "BUDGETARY RFQ 선택..." - } - /> - </FormControl> - <div className="text-xs text-muted-foreground mt-1"> - {getParentRfqDescription()} - </div> - <FormMessage /> - </FormItem> - )} - /> - )} - - {/* rfqCode - 자동 생성되고 읽기 전용 */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Code</FormLabel> - <FormControl> - <div className="flex"> - <Input - placeholder="자동으로 생성 중..." - {...field} - disabled={true} - className="bg-muted" - /> - {isLoadingRfqCode && ( - <div className="ml-2 flex items-center"> - <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div> - </div> - )} - </div> - </FormControl> - <div className="text-xs text-muted-foreground mt-1"> - RFQ 타입과 현재 날짜를 기준으로 자동 생성됩니다 - </div> - <FormMessage /> - </FormItem> - )} - /> - - {/* description */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Description</FormLabel> - <FormControl> - <Input placeholder="e.g. 설명을 입력하세요" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* dueDate */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem> - <FormLabel>Due Date</FormLabel> - <FormControl> - <Input - type="date" - value={field.value ? field.value.toISOString().slice(0, 10) : ""} - onChange={(e) => { - const val = e.target.value - if (val) { - const date = new Date(val); - // 날짜 1일씩 밀리는 문제로 우선 KTC로 입력 - // 추후 아래와 같이 수정 - // 1. 해당 유저 타임존 값으로 입력 - // 2. DB에는 UTC 타임존 값으로 저장 - // 3. 출력시 유저별 타임존 값으로 변환해 출력 - // 4. 어떤 타임존으로 나오는지도 함께 렌더링 - // field.onChange(new Date(val + "T00:00:00")) - field.onChange(date); - } - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* status (Read-only) */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <FormControl> - <Input - disabled - className="capitalize" - {...field} - onChange={() => { }} // Prevent changes - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => setOpen(false)} - > - Cancel - </Button> - <Button - type="submit" - disabled={form.formState.isSubmitting || status !== "authenticated"} - > - Create - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/table/attachment-rfq-sheet.tsx b/lib/rfqs/table/attachment-rfq-sheet.tsx deleted file mode 100644 index fdfb5e9a..00000000 --- a/lib/rfqs/table/attachment-rfq-sheet.tsx +++ /dev/null @@ -1,429 +0,0 @@ -"use client" - -import * as React from "react" -import { z } from "zod" -import { useForm, useFieldArray } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" - -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter, - SheetClose, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription -} from "@/components/ui/form" -import { Loader, Download, X, Eye, AlertCircle } from "lucide-react" -import { useToast } from "@/hooks/use-toast" -import { Badge } from "@/components/ui/badge" - -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, -} from "@/components/ui/file-list" - -import prettyBytes from "pretty-bytes" -import { processRfqAttachments } from "../service" -import { formatDate } from "@/lib/utils" -import { RfqType } from "../validations" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { quickDownload } from "@/lib/file-download" -import { type FileRejection } from "react-dropzone" - -const MAX_FILE_SIZE = 6e8 // 600MB - -/** 기존 첨부 파일 정보 */ -interface ExistingAttachment { - id: number - fileName: string - filePath: string - createdAt?: Date // or Date - vendorId?: number | null - size?: number -} - -/** 새로 업로드할 파일 */ -const newUploadSchema = z.object({ - fileObj: z.any().optional(), // 실제 File -}) - -/** 기존 첨부 (react-hook-form에서 관리) */ -const existingAttachSchema = z.object({ - id: z.number(), - fileName: z.string(), - filePath: z.string(), - vendorId: z.number().nullable().optional(), - createdAt: z.custom<Date>().optional(), // or use z.any().optional() - size: z.number().optional(), -}) - -/** RHF 폼 전체 스키마 */ -const attachmentsFormSchema = z.object({ - rfqId: z.number().int(), - existing: z.array(existingAttachSchema), - newUploads: z.array(newUploadSchema), -}) - -type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema> - -interface RfqAttachmentsSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - defaultAttachments?: ExistingAttachment[] - rfqType?: RfqType - rfq: RfqWithItemCount | null - /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */ - onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void -} - -/** - * RfqAttachmentsSheet: - * - 기존 첨부 목록 (다운로드 + 삭제) - * - 새 파일 Dropzone - * - Save 시 processRfqAttachments(server action) - */ -export function RfqAttachmentsSheet({ - defaultAttachments = [], - onAttachmentsUpdated, - rfq, - rfqType, - ...props -}: RfqAttachmentsSheetProps) { - const { toast } = useToast() - const [isPending, startUpdate] = React.useTransition() - const rfqId = rfq?.rfqId ?? 0; - - // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 - const isEditable = rfq?.status === "DRAFT"; - - // React Hook Form - const form = useForm<AttachmentsFormValues>({ - resolver: zodResolver(attachmentsFormSchema), - defaultValues: { - rfqId, - existing: [], - newUploads: [], - }, - }) - - const { reset, control, handleSubmit } = form - - // defaultAttachments가 바뀔 때마다, RHF 상태를 reset - React.useEffect(() => { - reset({ - rfqId, - existing: defaultAttachments.map((att) => ({ - ...att, - vendorId: att.vendorId ?? null, - size: att.size ?? undefined, - })), - newUploads: [], - }) - }, [rfqId, defaultAttachments, reset]) - - // Field Arrays - const { - fields: existingFields, - remove: removeExisting, - } = useFieldArray({ control, name: "existing" }) - - const { - fields: newUploadFields, - append: appendNewUpload, - remove: removeNewUpload, - } = useFieldArray({ control, name: "newUploads" }) - - // 기존 첨부 항목 중 삭제된 것 찾기 - function findRemovedExistingIds(data: AttachmentsFormValues): number[] { - const finalIds = data.existing.map((att) => att.id) - const originalIds = defaultAttachments.map((att) => att.id) - return originalIds.filter((id) => !finalIds.includes(id)) - } - - async function onSubmit(data: AttachmentsFormValues) { - // 편집 불가능한 상태에서는 제출 방지 - if (!isEditable) return; - - startUpdate(async () => { - try { - const removedExistingIds = findRemovedExistingIds(data) - const newFiles = data.newUploads - .map((it) => it.fileObj) - .filter((f): f is File => !!f) - - // 서버 액션 - const res = await processRfqAttachments({ - rfqId, - removedExistingIds, - newFiles, - vendorId: null, // vendor ID if needed - rfqType - }) - - if (!res.ok) throw new Error(res.error ?? "Unknown error") - - const newCount = res.updatedItemCount ?? 0 - - toast({ - variant: "default", - title: "Success", - description: "File(s) updated", - }) - - // 상위 테이블 등에 itemCount 업데이트 - onAttachmentsUpdated?.(rfqId, newCount) - - // 모달 닫기 - props.onOpenChange?.(false) - } catch (err) { - toast({ - variant: "destructive", - title: "Error", - description: String(err), - }) - } - }) - } - - /** 기존 첨부 - X 버튼 */ - function handleRemoveExisting(idx: number) { - // 편집 불가능한 상태에서는 삭제 방지 - if (!isEditable) return; - removeExisting(idx) - } - - /** 드롭존에서 파일 받기 */ - function handleDropAccepted(acceptedFiles: File[]) { - // 편집 불가능한 상태에서는 파일 추가 방지 - if (!isEditable) return; - const mapped = acceptedFiles.map((file) => ({ fileObj: file })) - appendNewUpload(mapped) - } - - /** 드롭존에서 파일 거부(에러) */ - function handleDropRejected(fileRejections: FileRejection[]) { - // 편집 불가능한 상태에서는 무시 - if (!isEditable) return; - - fileRejections.forEach((rej) => { - toast({ - variant: "destructive", - title: "File Error", - description: rej.file.name + " not accepted", - }) - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> - <SheetHeader> - <SheetTitle className="flex items-center gap-2"> - {isEditable ? "Manage Attachments" : "View Attachments"} - {rfq?.status && ( - <Badge - variant={rfq.status === "DRAFT" ? "outline" : "secondary"} - className="ml-1" - > - {rfq.status} - </Badge> - )} - </SheetTitle> - <SheetDescription> - {`RFQ ${rfq?.rfqCode} - `} - {isEditable ? '파일 첨부/삭제' : '첨부 파일 보기'} - {!isEditable && ( - <div className="mt-1 text-xs flex items-center gap-1 text-amber-600"> - <AlertCircle className="h-3 w-3" /> - <span>드래프트 상태가 아닌 RFQ는 첨부파일을 수정할 수 없습니다.</span> - </div> - )} - </SheetDescription> - </SheetHeader> - - <Form {...form}> - <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> - {/* 1) 기존 첨부 목록 */} - <div className="space-y-2"> - <p className="font-semibold text-sm">Existing Attachments</p> - {existingFields.length === 0 && ( - <p className="text-sm text-muted-foreground">No existing attachments</p> - )} - {existingFields.map((field, index) => { - const vendorLabel = field.vendorId ? "(Vendor)" : "(Internal)" - return ( - <div - key={field.id} - className="flex items-center justify-between rounded border p-2" - > - <div className="flex flex-col text-sm"> - <span className="font-medium"> - {field.fileName} {vendorLabel} - </span> - {field.size && ( - <span className="text-xs text-muted-foreground"> - {Math.round(field.size / 1024)} KB - </span> - )} - {field.createdAt && ( - <span className="text-xs text-muted-foreground"> - Created at {formatDate(field.createdAt, "KR")} - </span> - )} - </div> - <div className="flex items-center gap-2"> - {/* 1) Download button (if filePath) */} - {field.filePath && ( - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => quickDownload(field.filePath, field.fileName)} - > - <Download className="h-4 w-4" /> - </Button> - )} - {/* 2) Remove button - 편집 가능할 때만 표시 */} - {isEditable && ( - <Button - type="button" - variant="ghost" - size="icon" - onClick={() => handleRemoveExisting(index)} - > - <X className="h-4 w-4" /> - </Button> - )} - </div> - </div> - ) - })} - </div> - - {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} - {isEditable ? ( - <> - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={handleDropRejected} - > - {({ maxSize }) => ( - <FormField - control={control} - name="newUploads" // not actually used for storing each file detail - render={() => ( - <FormItem> - <FormLabel>Drop Files Here</FormLabel> - <DropzoneZone className="flex justify-center"> - <FormControl> - <DropzoneInput /> - </FormControl> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to upload</DropzoneTitle> - <DropzoneDescription> - Max size: {maxSize ? prettyBytes(maxSize) : "??? MB"} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - <FormDescription>Alternatively, click browse.</FormDescription> - <FormMessage /> - </FormItem> - )} - /> - )} - </Dropzone> - - {/* newUpload fields -> FileList */} - {newUploadFields.length > 0 && ( - <div className="grid gap-4"> - <h6 className="font-semibold leading-none tracking-tight"> - {`Files (${newUploadFields.length})`} - </h6> - <FileList> - {newUploadFields.map((field, idx) => { - const fileObj = form.getValues(`newUploads.${idx}.fileObj`) - if (!fileObj) return null - - const fileName = fileObj.name - const fileSize = fileObj.size - return ( - <FileListItem key={field.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{fileName}</FileListName> - <FileListDescription> - {`${prettyBytes(fileSize)}`} - </FileListDescription> - </FileListInfo> - <FileListAction onClick={() => removeNewUpload(idx)}> - <X /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ) - })} - </FileList> - </div> - )} - </> - ) : ( - <div className="p-3 bg-muted rounded-md flex items-center justify-center"> - <div className="text-center text-sm text-muted-foreground"> - <Eye className="h-4 w-4 mx-auto mb-2" /> - <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p> - </div> - </div> - )} - - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - {isEditable ? "Cancel" : "Close"} - </Button> - </SheetClose> - {isEditable && ( - <Button - type="submit" - disabled={isPending || (form.getValues().newUploads.length === 0 && defaultAttachments.length === form.getValues().existing.length)} - > - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - )} - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/table/delete-rfqs-dialog.tsx b/lib/rfqs/table/delete-rfqs-dialog.tsx deleted file mode 100644 index 09596bc7..00000000 --- a/lib/rfqs/table/delete-rfqs-dialog.tsx +++ /dev/null @@ -1,149 +0,0 @@ -"use client" - -import * as React from "react" -import { type Row } from "@tanstack/react-table" -import { Loader, Trash } from "lucide-react" -import { toast } from "sonner" - -import { useMediaQuery } from "@/hooks/use-media-query" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" - -import { Rfq, RfqWithItemCount } from "@/db/schema/rfq" -import { removeRfqs } from "../service" - -interface DeleteRfqsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqs: Row<RfqWithItemCount>["original"][] - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteRfqsDialog({ - rfqs, - showTrigger = true, - onSuccess, - ...props -}: DeleteRfqsDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - startDeleteTransition(async () => { - const { error } = await removeRfqs({ - ids: rfqs.map((rfq) => rfq.rfqId), - }) - - if (error) { - toast.error(error) - return - } - - props.onOpenChange?.(false) - toast.success("Tasks deleted") - onSuccess?.() - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - Delete ({rfqs.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>Are you absolutely sure?</DialogTitle> - <DialogDescription> - This action cannot be undone. This will permanently delete your{" "} - <span className="font-medium">{rfqs.length}</span> - {rfqs.length === 1 ? " task" : " rfqs"} from our servers. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> - </DialogClose> - <Button - aria-label="Delete selected rows" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Delete - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - Delete ({rfqs.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>Are you absolutely sure?</DrawerTitle> - <DrawerDescription> - This action cannot be undone. This will permanently delete your{" "} - <span className="font-medium">{rfqs.length}</span> - {rfqs.length === 1 ? " task" : " rfqs"} from our servers. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">Cancel</Button> - </DrawerClose> - <Button - aria-label="Delete selected rows" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Delete - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -} diff --git a/lib/rfqs/table/feature-flags-provider.tsx b/lib/rfqs/table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/rfqs/table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { cn } from "@/lib/utils" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface FeatureFlagsContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useFeatureFlags() { - const context = React.useContext(FeatureFlagsContext) - if (!context) { - throw new Error( - "useFeatureFlags must be used within a FeatureFlagsProvider" - ) - } - return context -} - -interface FeatureFlagsProviderProps { - children: React.ReactNode -} - -export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { - const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "flags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - shallow: false, - } - ) - - return ( - <FeatureFlagsContext.Provider - value={{ - featureFlags, - setFeatureFlags: (value) => void setFeatureFlags(value), - }} - > - <div className="w-full overflow-x-auto"> - <ToggleGroup - type="multiple" - variant="outline" - size="sm" - value={featureFlags} - onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit gap-0" - > - {dataTableConfig.featureFlags.map((flag, index) => ( - <Tooltip key={flag.value}> - <ToggleGroupItem - value={flag.value} - className={cn( - "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", - { - "rounded-l-sm border-r-0": index === 0, - "rounded-r-sm": - index === dataTableConfig.featureFlags.length - 1, - } - )} - asChild - > - <TooltipTrigger> - <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> - {flag.label} - </TooltipTrigger> - </ToggleGroupItem> - <TooltipContent - align="start" - side="bottom" - sideOffset={6} - className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" - > - <div>{flag.tooltipTitle}</div> - <div className="text-xs text-muted-foreground"> - {flag.tooltipDescription} - </div> - </TooltipContent> - </Tooltip> - ))} - </ToggleGroup> - </div> - {children} - </FeatureFlagsContext.Provider> - ) -} diff --git a/lib/rfqs/table/feature-flags.tsx b/lib/rfqs/table/feature-flags.tsx deleted file mode 100644 index aaae6af2..00000000 --- a/lib/rfqs/table/feature-flags.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface TasksTableContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const TasksTableContext = React.createContext<TasksTableContextProps>({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useTasksTable() { - const context = React.useContext(TasksTableContext) - if (!context) { - throw new Error("useTasksTable must be used within a TasksTableProvider") - } - return context -} - -export function TasksTableProvider({ children }: React.PropsWithChildren) { - const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "featureFlags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - } - ) - - return ( - <TasksTableContext.Provider - value={{ - featureFlags, - setFeatureFlags: (value) => void setFeatureFlags(value), - }} - > - <div className="w-full overflow-x-auto"> - <ToggleGroup - type="multiple" - variant="outline" - size="sm" - value={featureFlags} - onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit" - > - {dataTableConfig.featureFlags.map((flag) => ( - <Tooltip key={flag.value}> - <ToggleGroupItem - value={flag.value} - className="whitespace-nowrap px-3 text-xs" - asChild - > - <TooltipTrigger> - <flag.icon - className="mr-2 size-3.5 shrink-0" - aria-hidden="true" - /> - {flag.label} - </TooltipTrigger> - </ToggleGroupItem> - <TooltipContent - align="start" - side="bottom" - sideOffset={6} - className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" - > - <div>{flag.tooltipTitle}</div> - <div className="text-xs text-muted-foreground"> - {flag.tooltipDescription} - </div> - </TooltipContent> - </Tooltip> - ))} - </ToggleGroup> - </div> - {children} - </TasksTableContext.Provider> - ) -} diff --git a/lib/rfqs/table/rfqs-table-columns.tsx b/lib/rfqs/table/rfqs-table-columns.tsx deleted file mode 100644 index 5c09fcf0..00000000 --- a/lib/rfqs/table/rfqs-table-columns.tsx +++ /dev/null @@ -1,315 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis, Paperclip, Package } from "lucide-react" -import { toast } from "sonner" - -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" - -import { getRFQStatusIcon } from "@/lib/tasks/utils" -import { rfqsColumnsConfig } from "@/config/rfqsColumnsConfig" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" -import { RfqType } from "../validations" - -type NextRouter = ReturnType<typeof useRouter>; - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<RfqWithItemCount> | null> - > - openItemsModal: (rfqId: number) => void - openAttachmentsSheet: (rfqId: number) => void - router: NextRouter - rfqType?: RfqType -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - openItemsModal, - openAttachmentsSheet, - router, - rfqType, -}: GetColumnsProps): ColumnDef<RfqWithItemCount>[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<RfqWithItemCount> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) actions 컬럼 (Dropdown 메뉴) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<RfqWithItemCount> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - // Proceed 버튼 클릭 시 호출되는 함수 - const handleProceed = () => { - const rfq = row.original - const itemCount = Number(rfq.itemCount || 0) - const attachCount = Number(rfq.attachCount || 0) - - // 아이템과 첨부파일이 모두 0보다 커야 진행 가능 - if (itemCount > 0 && attachCount > 0) { - router.push( - rfqType === RfqType.PURCHASE - ? `/evcp/rfq/${rfq.rfqId}` - : `/evcp/budgetary/${rfq.rfqId}` - ) - } else { - // 조건을 충족하지 않는 경우 토스트 알림 표시 - if (itemCount === 0 && attachCount === 0) { - toast.error("아이템과 첨부파일을 먼저 추가해주세요.") - } else if (itemCount === 0) { - toast.error("아이템을 먼저 추가해주세요.") - } else { - toast.error("첨부파일을 먼저 추가해주세요.") - } - } - } - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem onSelect={handleProceed}> - {row.original.status ==="DRAFT"?"Proceed":"View Detail"} - <DropdownMenuShortcut>↵</DropdownMenuShortcut> - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - Delete - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } - - // ---------------------------------------------------------------- - // 3) itemsColumn (아이템 개수 표시: 아이콘 + Badge) - // ---------------------------------------------------------------- - const itemsColumn: ColumnDef<RfqWithItemCount> = { - id: "items", - header: "Items", - cell: ({ row }) => { - const rfq = row.original - const itemCount = rfq.itemCount || 0 - - const handleClick = () => { - openItemsModal(rfq.rfqId) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - itemCount > 0 ? `View ${itemCount} items` : "Add items" - } - > - <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {itemCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {itemCount} - </Badge> - )} - <span className="sr-only"> - {itemCount > 0 ? `${itemCount} Items` : "Add Items"} - </span> - </Button> - ) - }, - enableSorting: false, - size: 60, - } - - // ---------------------------------------------------------------- - // 4) attachmentsColumn (첨부파일 개수 표시: 아이콘 + Badge) - // ---------------------------------------------------------------- - const attachmentsColumn: ColumnDef<RfqWithItemCount> = { - id: "attachments", - header: "Attachments", - cell: ({ row }) => { - const fileCount = row.original.attachCount ?? 0 - - const handleClick = () => { - openAttachmentsSheet(row.original.rfqId) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - fileCount > 0 ? `View ${fileCount} files` : "Add files" - } - > - <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {fileCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {fileCount} - </Badge> - )} - <span className="sr-only"> - {fileCount > 0 ? `${fileCount} Files` : "Add Files"} - </span> - </Button> - ) - }, - enableSorting: false, - size: 60, - } - - // ---------------------------------------------------------------- - // 5) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<RfqWithItemCount>[]> = {} - - rfqsColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef<RfqWithItemCount> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - cell: ({ row, cell }) => { - if (cfg.id === "status") { - const statusVal = row.original.status - if (!statusVal) return null - const Icon = getRFQStatusIcon( - statusVal as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED" - ) - return ( - <div className="flex w-[6.25rem] items-center"> - <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> - <span className="capitalize">{statusVal}</span> - </div> - ) - } - - if (cfg.id === "createdAt" || cfg.id === "updatedAt") { - const dateVal = cell.getValue() as Date - return formatDate(dateVal, "KR") - } - - return row.getValue(cfg.id) ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap -> nestedColumns - const nestedColumns: ColumnDef<RfqWithItemCount>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - attachmentsColumn, // 첨부파일 - actionsColumn, - itemsColumn, // 아이템 - ] -}
\ No newline at end of file diff --git a/lib/rfqs/table/rfqs-table-floating-bar.tsx b/lib/rfqs/table/rfqs-table-floating-bar.tsx deleted file mode 100644 index daef7e0b..00000000 --- a/lib/rfqs/table/rfqs-table-floating-bar.tsx +++ /dev/null @@ -1,338 +0,0 @@ -"use client" - -import * as React from "react" -import { Table } from "@tanstack/react-table" -import { toast } from "sonner" -import { Calendar, type CalendarProps } from "@/components/ui/calendar" -import { Button } from "@/components/ui/button" -import { Portal } from "@/components/ui/portal" -import { - Select, - SelectTrigger, - SelectContent, - SelectGroup, - SelectItem, - SelectValue, -} from "@/components/ui/select" -import { Separator } from "@/components/ui/separator" -import { - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip" -import { Kbd } from "@/components/kbd" -import { ActionConfirmDialog } from "@/components/ui/action-dialog" - -import { ArrowUp, CheckCircle2, Download, Loader, Trash2, X, CalendarIcon } from "lucide-react" - -import { exportTableToExcel } from "@/lib/export" - -import { RfqWithItemCount, rfqs } from "@/db/schema/rfq" -import { modifyRfqs, removeRfqs } from "../service" - -interface RfqsTableFloatingBarProps { - table: Table<RfqWithItemCount> -} - -/** - * 추가된 로직: - * - 달력(캘린더) 아이콘 버튼 - * - 눌렀을 때 Popover로 Calendar 표시 - * - 날짜 선택 시 Confirm 다이얼로그 → modifyRfqs({ dueDate }) - */ -export function RfqsTableFloatingBar({ table }: RfqsTableFloatingBarProps) { - const rows = table.getFilteredSelectedRowModel().rows - const [isPending, startTransition] = React.useTransition() - const [action, setAction] = React.useState<"update-status" | "export" | "delete" | "update-dueDate">() - const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) - - const [confirmProps, setConfirmProps] = React.useState<{ - title: string - description?: string - onConfirm: () => Promise<void> | void - }>({ - title: "", - description: "", - onConfirm: () => {}, - }) - - // 캘린더 Popover 열림 여부 - const [calendarOpen, setCalendarOpen] = React.useState(false) - const [selectedDate, setSelectedDate] = React.useState<Date | null>(null) - - // Clear selection on Escape key press - React.useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - table.toggleAllRowsSelected(false) - } - } - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [table]) - - function handleDeleteConfirm() { - setAction("delete") - setConfirmProps({ - title: `Delete ${rows.length} RFQ${rows.length > 1 ? "s" : ""}?`, - description: "This action cannot be undone.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await removeRfqs({ - ids: rows.map((row) => row.original.rfqId), - }) - if (error) { - toast.error(error) - return - } - toast.success("RFQs deleted") - table.toggleAllRowsSelected(false) - setConfirmDialogOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - function handleSelectStatus(newStatus: RfqWithItemCount["status"]) { - setAction("update-status") - setConfirmProps({ - title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, - description: "This action will override their current status.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await modifyRfqs({ - ids: rows.map((row) => row.original.rfqId), - status: newStatus as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", - }) - if (error) { - toast.error(error) - return - } - toast.success("RFQs updated") - setConfirmDialogOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - // 1) 달력에서 날짜를 선택했을 때 → Confirm 다이얼로그 - function handleDueDateSelect(newDate: Date) { - setAction("update-dueDate") - - setConfirmProps({ - title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} Due Date to ${newDate.toDateString()}?`, - description: "This action will override their current due date.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await modifyRfqs({ - ids: rows.map((r) => r.original.rfqId), - dueDate: newDate, - }) - if (error) { - toast.error(error) - return - } - toast.success("Due date updated") - setConfirmDialogOpen(false) - setCalendarOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - // 2) Export - function handleExport() { - setAction("export") - startTransition(() => { - exportTableToExcel(table, { - excludeColumns: ["select", "actions"], - onlySelected: true, - }) - }) - } - - // Floating bar UI - return ( - <Portal> - <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5"> - <div className="w-full overflow-x-auto"> - <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> - {/* Selection Info + Clear */} - <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> - <span className="whitespace-nowrap text-xs"> - {rows.length} selected - </span> - <Separator orientation="vertical" className="ml-2 mr-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="size-5 hover:border" - onClick={() => table.toggleAllRowsSelected(false)} - > - <X className="size-3.5 shrink-0" aria-hidden="true" /> - </Button> - </TooltipTrigger> - <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> - <p className="mr-2">Clear selection</p> - <Kbd abbrTitle="Escape" variant="outline"> - Esc - </Kbd> - </TooltipContent> - </Tooltip> - </div> - - <Separator orientation="vertical" className="hidden h-5 sm:block" /> - - <div className="flex items-center gap-1.5"> - {/* 1) Status Update */} - <Select - onValueChange={(value: RfqWithItemCount["status"]) => handleSelectStatus(value)} - > - <Tooltip> - <SelectTrigger asChild> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" - disabled={isPending} - > - {isPending && action === "update-status" ? ( - <Loader className="size-3.5 animate-spin" aria-hidden="true" /> - ) : ( - <CheckCircle2 className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - </SelectTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Update status</p> - </TooltipContent> - </Tooltip> - <SelectContent align="center"> - <SelectGroup> - {rfqs.status.enumValues.map((status) => ( - <SelectItem key={status} value={status} className="capitalize"> - {status} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> - - {/* 2) Due Date Update: Calendar Popover */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - disabled={isPending} - onClick={() => setCalendarOpen((open) => !open)} - > - {isPending && action === "update-dueDate" ? ( - <Loader className="size-3.5 animate-spin" aria-hidden="true" /> - ) : ( - <CalendarIcon className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Update Due Date</p> - </TooltipContent> - </Tooltip> - - {/* Calendar Popover (간단 구현) */} - {calendarOpen && ( - <div className="absolute bottom-16 z-50 rounded-md border bg-background p-2 shadow"> - <Calendar - mode="single" - selected={selectedDate || new Date()} - onSelect={(date) => { - if (date) { - setSelectedDate(date) - handleDueDateSelect(date) - } - }} - initialFocus - /> - </div> - )} - - {/* 3) Export */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={handleExport} - disabled={isPending} - > - {isPending && action === "export" ? ( - <Loader className="size-3.5 animate-spin" aria-hidden="true" /> - ) : ( - <Download className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Export tasks</p> - </TooltipContent> - </Tooltip> - - {/* 4) Delete */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={handleDeleteConfirm} - disabled={isPending} - > - {isPending && action === "delete" ? ( - <Loader className="size-3.5 animate-spin" aria-hidden="true" /> - ) : ( - <Trash2 className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Delete tasks</p> - </TooltipContent> - </Tooltip> - </div> - </div> - </div> - </div> - - {/* 공용 Confirm Dialog */} - <ActionConfirmDialog - open={confirmDialogOpen} - onOpenChange={setConfirmDialogOpen} - title={confirmProps.title} - description={confirmProps.description} - onConfirm={confirmProps.onConfirm} - isLoading={ - isPending && (action === "delete" || action === "update-status" || action === "update-dueDate") - } - confirmLabel={ - action === "delete" - ? "Delete" - : action === "update-status" - ? "Update" - : action === "update-dueDate" - ? "Update" - : "Confirm" - } - confirmVariant={action === "delete" ? "destructive" : "default"} - /> - </Portal> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx deleted file mode 100644 index 6402e625..00000000 --- a/lib/rfqs/table/rfqs-table-toolbar-actions.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { DeleteRfqsDialog } from "./delete-rfqs-dialog" -import { AddRfqDialog } from "./add-rfq-dialog" -import { RfqType } from "../validations" - - -interface RfqsTableToolbarActionsProps { - table: Table<RfqWithItemCount> - rfqType?: RfqType; -} - -export function RfqsTableToolbarActions({ table , rfqType = RfqType.PURCHASE}: RfqsTableToolbarActionsProps) { - return ( - <div className="flex items-center gap-2"> - {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - <DeleteRfqsDialog - rfqs={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} - - {/** 2) 새 Task 추가 다이얼로그 */} - <AddRfqDialog rfqType={rfqType} /> - - - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx deleted file mode 100644 index 287f1d53..00000000 --- a/lib/rfqs/table/rfqs-table.tsx +++ /dev/null @@ -1,263 +0,0 @@ -"use client" - -import * as React from "react" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { toSentenceCase } from "@/lib/utils" -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" - -import { getRFQStatusIcon } from "@/lib/tasks/utils" -import { useFeatureFlags } from "./feature-flags-provider" -import { getColumns } from "./rfqs-table-columns" -import { fetchRfqAttachments, fetchRfqItems, getRfqs, getRfqStatusCounts } from "../service" -import { RfqItem, RfqWithItemCount, rfqs } from "@/db/schema/rfq" -import { RfqsTableFloatingBar } from "./rfqs-table-floating-bar" -import { UpdateRfqSheet } from "./update-rfq-sheet" -import { DeleteRfqsDialog } from "./delete-rfqs-dialog" -import { RfqsTableToolbarActions } from "./rfqs-table-toolbar-actions" -import { RfqsItemsDialog } from "./ItemsDialog" -import { getAllItems } from "@/lib/items/service" -import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" -import { useRouter } from "next/navigation" -import { RfqType } from "../validations" - -interface RfqsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getRfqs>>, - Awaited<ReturnType<typeof getRfqStatusCounts>>, - Awaited<ReturnType<typeof getAllItems>>, - ] - >; - rfqType?: RfqType; // rfqType props 추가 -} - -export interface ExistingAttachment { - id: number; - fileName: string; - filePath: string; - createdAt?: Date; - vendorId?: number | null; - size?: number; -} - -export interface ExistingItem { - id?: number; - itemCode: string; - description: string | null; - quantity: number | null; - uom: string | null; -} - -export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTableProps) { - const { featureFlags } = useFeatureFlags() - - const [{ data, pageCount }, statusCounts, items] = React.use(promises) - const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) - const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) - const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) - const [itemsDefault, setItemsDefault] = React.useState<ExistingItem[]>([]) - - const router = useRouter() - - const itemsList = items?.map((v) => ({ - code: v.itemCode ?? "", - name: v.itemName ?? "", - })); - - const [rowAction, setRowAction] = - React.useState<DataTableRowAction<RfqWithItemCount> | null>(null) - - const [rowData, setRowData] = React.useState<RfqWithItemCount[]>(() => data) - - const [itemsModalOpen, setItemsModalOpen] = React.useState(false); - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null); - - - const selectedRfq = React.useMemo(() => { - return rowData.find(row => row.rfqId === selectedRfqId) || null; - }, [rowData, selectedRfqId]); - - // rfqType에 따른 제목 계산 - const getRfqTypeTitle = () => { - return rfqType === RfqType.PURCHASE ? "Purchase RFQ" : "Budgetary RFQ"; - }; - - async function openItemsModal(rfqId: number) { - const itemList = await fetchRfqItems(rfqId) - setItemsDefault(itemList) - setSelectedRfqId(rfqId); - setItemsModalOpen(true); - } - - async function openAttachmentsSheet(rfqId: number) { - // 4.1) Fetch current attachments from server (server action) - const list = await fetchRfqAttachments(rfqId) // returns ExistingAttachment[] - setAttachDefault(list) - setSelectedRfqIdForAttachments(rfqId) - setAttachmentsOpen(true) - setSelectedRfqId(rfqId); - } - - function handleAttachmentsUpdated(rfqId: number, newCount: number, newList?: ExistingAttachment[]) { - // 5.1) update rowData itemCount - setRowData(prev => - prev.map(r => - r.rfqId === rfqId - ? { ...r, itemCount: newCount } - : r - ) - ) - // 5.2) if newList is provided, store it - if (newList) { - setAttachDefault(newList) - } - } - - const columns = React.useMemo(() => getColumns({ - setRowAction, router, - // we pass openItemsModal as a prop so the itemsColumn can call it - openItemsModal, - openAttachmentsSheet, - rfqType - }), [setRowAction, router, rfqType]); - - /** - * This component can render either a faceted filter or a search filter based on the `options` prop. - */ - const filterFields: DataTableFilterField<RfqWithItemCount>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - placeholder: "Filter RFQ Code...", - }, - { - id: "status", - label: "Status", - options: rfqs.status.enumValues?.map((status) => { - // 명시적으로 status를 허용된 리터럴 타입으로 변환 - const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; - return { - label: toSentenceCase(s), - value: s, - icon: getRFQStatusIcon(s), - count: statusCounts[s], - }; - }), - - } - ] - - /** - * Advanced filter fields for the data table. - */ - const advancedFilterFields: DataTableAdvancedFilterField<RfqWithItemCount>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - type: "text", - }, - { - id: "description", - label: "Description", - type: "text", - }, - { - id: "projectCode", - label: "Project Code", - type: "text", - }, - { - id: "dueDate", - label: "Due Date", - type: "date", - }, - { - id: "status", - label: "Status", - type: "multi-select", - options: rfqs.status.enumValues?.map((status) => { - // 명시적으로 status를 허용된 리터럴 타입으로 변환 - const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; - return { - label: toSentenceCase(s), - value: s, - icon: getRFQStatusIcon(s), - count: statusCounts[s], - }; - }), - - }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.rfqId), - shallow: false, - clearOnDefault: true, - }) - - return ( - <div style={{ maxWidth: '100vw' }}> - <DataTable - table={table} - // floatingBar={<RfqsTableFloatingBar table={table} />} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <RfqsTableToolbarActions table={table} rfqType={rfqType} /> - </DataTableAdvancedToolbar> - </DataTable> - - <UpdateRfqSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - rfq={rowAction?.row.original ?? null} - /> - - <DeleteRfqsDialog - open={rowAction?.type === "delete"} - onOpenChange={() => setRowAction(null)} - rfqs={rowAction?.row.original ? [rowAction?.row.original] : []} - showTrigger={false} - onSuccess={() => rowAction?.row.toggleSelected(false)} - /> - - <RfqsItemsDialog - open={itemsModalOpen} - onOpenChange={setItemsModalOpen} - rfq={selectedRfq ?? null} - itemsList={itemsList} - defaultItems={itemsDefault} - rfqType={rfqType} - /> - - <RfqAttachmentsSheet - open={attachmentsOpen} - onOpenChange={setAttachmentsOpen} - defaultAttachments={attachDefault} - rfqType={rfqType} - rfq={selectedRfq ?? null} - onAttachmentsUpdated={handleAttachmentsUpdated} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs/table/update-rfq-sheet.tsx b/lib/rfqs/table/update-rfq-sheet.tsx deleted file mode 100644 index 22ca2c37..00000000 --- a/lib/rfqs/table/update-rfq-sheet.tsx +++ /dev/null @@ -1,406 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader } from "lucide-react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { useSession } from "next-auth/react" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Input } from "@/components/ui/input" - -import { Rfq, RfqWithItemCount } from "@/db/schema/rfq" -import { RfqType, updateRfqSchema, type UpdateRfqSchema } from "../validations" -import { modifyRfq, getBudgetaryRfqs } from "../service" -import { ProjectSelector } from "@/components/ProjectSelector" -import { type Project } from "../service" -import { ParentRfqSelector } from "./ParentRfqSelector" - -interface UpdateRfqSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - rfq: RfqWithItemCount | null -} - -// 부모 RFQ 정보 타입 정의 -interface ParentRfq { - id: number; - rfqCode: string; - description: string | null; - rfqType: RfqType; - projectId: number | null; - projectCode: string | null; - projectName: string | null; -} - -export function UpdateRfqSheet({ rfq, ...props }: UpdateRfqSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const { data: session } = useSession() - const userId = Number(session?.user?.id || 1) - const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null) - - // RFQ의 타입 가져오기 - const rfqType = rfq?.rfqType || RfqType.PURCHASE; - - // 초기 부모 RFQ ID 가져오기 - const initialParentRfqId = rfq?.parentRfqId; - - // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 - const getParentRfqTypes = (): RfqType[] => { - switch(rfqType) { - case RfqType.PURCHASE: - // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 - return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; - case RfqType.PURCHASE_BUDGETARY: - // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음 - return [RfqType.BUDGETARY]; - default: - return []; - } - }; - - // 부모 RFQ 타입들 - const parentRfqTypes = getParentRfqTypes(); - - // 부모 RFQ를 보여줄지 결정 - const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; - - // 타입에 따른 타이틀 생성 - const getTypeTitle = () => { - switch(rfqType) { - case RfqType.PURCHASE: - return "Purchase RFQ"; - case RfqType.BUDGETARY: - return "Budgetary RFQ"; - case RfqType.PURCHASE_BUDGETARY: - return "Purchase Budgetary RFQ"; - default: - return "RFQ"; - } - }; - - // 타입 설명 가져오기 - const getTypeDescription = () => { - switch(rfqType) { - case RfqType.PURCHASE: - return "실제 구매 발주 전에 가격을 요청"; - case RfqType.BUDGETARY: - return "기술영업 단계에서 입찰가 산정을 위한 견적 요청"; - case RfqType.PURCHASE_BUDGETARY: - return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청"; - default: - return ""; - } - }; - - // 부모 RFQ 선택기 레이블 및 설명 가져오기 - const getParentRfqSelectorLabel = () => { - if (rfqType === RfqType.PURCHASE) { - return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)"; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "부모 RFQ (BUDGETARY)"; - } - return "부모 RFQ"; - }; - - const getParentRfqDescription = () => { - if (rfqType === RfqType.PURCHASE) { - return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다."; - } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { - return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다."; - } - return ""; - }; - - // 초기 부모 RFQ 로드 - React.useEffect(() => { - if (initialParentRfqId && shouldShowParentRfqSelector) { - const loadInitialParentRfq = async () => { - try { - const result = await getBudgetaryRfqs({ - rfqId: initialParentRfqId - }); - - if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) { - setSelectedParentRfq(result.rfqs[0] as unknown as ParentRfq); - } - } catch (error) { - console.error("부모 RFQ 로드 오류:", error); - } - }; - - loadInitialParentRfq(); - } - }, [initialParentRfqId, shouldShowParentRfqSelector]); - - // RHF setup - const form = useForm<UpdateRfqSchema>({ - resolver: zodResolver(updateRfqSchema), - defaultValues: { - id: rfq?.rfqId ?? 0, // PK - rfqCode: rfq?.rfqCode ?? "", - description: rfq?.description ?? "", - projectId: rfq?.projectId, // 프로젝트 ID - parentRfqId: rfq?.parentRfqId, // 부모 RFQ ID - dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환 - status: rfq?.status ?? "DRAFT", - createdBy: rfq?.createdBy ?? userId, - }, - }); - - // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project | null) => { - if (project === null) { - return; - } - form.setValue("projectId", project.id); - }; - - // 부모 RFQ 선택 처리 - const handleParentRfqSelect = (rfq: ParentRfq | null) => { - setSelectedParentRfq(rfq); - form.setValue("parentRfqId", rfq?.id); - }; - - async function onSubmit(input: UpdateRfqSchema) { - startUpdateTransition(async () => { - if (!rfq) return - - const { error } = await modifyRfq({ - ...input, - rfqType: rfqType as RfqType, - - }) - - if (error) { - toast.error(error) - return - } - - form.reset() - props.onOpenChange?.(false) // close the sheet - toast.success("RFQ updated!") - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>Update {getTypeTitle()}</SheetTitle> - <SheetDescription> - Update the {getTypeTitle()} details and save the changes - <div className="mt-1 text-xs text-muted-foreground"> - {getTypeDescription()} - </div> - </SheetDescription> - </SheetHeader> - - {/* RHF Form */} - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - - {/* Hidden or code-based id field */} - <FormField - control={form.control} - name="id" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - {/* Hidden rfqType field */} - {/* <FormField - control={form.control} - name="rfqType" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> */} - - {/* Project Selector - 재사용 컴포넌트 사용 */} - <FormField - control={form.control} - name="projectId" - render={({ field }) => ( - <FormItem> - <FormLabel>Project</FormLabel> - <FormControl> - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */} - {shouldShowParentRfqSelector && ( - <FormField - control={form.control} - name="parentRfqId" - render={({ field }) => ( - <FormItem> - <FormLabel>{getParentRfqSelectorLabel()}</FormLabel> - <FormControl> - <ParentRfqSelector - selectedRfqId={field.value as number | undefined} - onRfqSelect={handleParentRfqSelect} - rfqType={rfqType} - parentRfqTypes={parentRfqTypes} - placeholder={ - rfqType === RfqType.PURCHASE - ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." - : "BUDGETARY RFQ 선택..." - } - /> - </FormControl> - <div className="text-xs text-muted-foreground mt-1"> - {getParentRfqDescription()} - </div> - <FormMessage /> - </FormItem> - )} - /> - )} - - {/* rfqCode */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Code</FormLabel> - <FormControl> - <Input placeholder="e.g. RFQ-2025-001" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* description */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>Description</FormLabel> - <FormControl> - <Input placeholder="Description" {...field} value={field.value || ""} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* dueDate (type="date") */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem> - <FormLabel>Due Date</FormLabel> - <FormControl> - <Input - type="date" - // convert Date -> yyyy-mm-dd - value={field.value ? field.value.toISOString().slice(0, 10) : ""} - onChange={(e) => { - const val = e.target.value - field.onChange(val ? new Date(val + "T00:00:00") : undefined) - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* status (Select) */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <FormControl> - <Select - onValueChange={field.onChange} - value={field.value ?? "DRAFT"} - > - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select status" /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - {["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((item) => ( - <SelectItem key={item} value={item} className="capitalize"> - {item} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* createdBy (hidden or read-only) */} - <FormField - control={form.control} - name="createdBy" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isUpdatePending}> - {isUpdatePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file |
