diff options
Diffstat (limited to 'lib/rfqs/table')
| -rw-r--r-- | lib/rfqs/table/BudgetaryRfqSelector.tsx | 261 | ||||
| -rw-r--r-- | lib/rfqs/table/ItemsDialog.tsx | 744 | ||||
| -rw-r--r-- | lib/rfqs/table/add-rfq-dialog.tsx | 349 | ||||
| -rw-r--r-- | lib/rfqs/table/attachment-rfq-sheet.tsx | 430 | ||||
| -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 | 264 | ||||
| -rw-r--r-- | lib/rfqs/table/update-rfq-sheet.tsx | 283 |
12 files changed, 3392 insertions, 0 deletions
diff --git a/lib/rfqs/table/BudgetaryRfqSelector.tsx b/lib/rfqs/table/BudgetaryRfqSelector.tsx new file mode 100644 index 00000000..cea53c1d --- /dev/null +++ b/lib/rfqs/table/BudgetaryRfqSelector.tsx @@ -0,0 +1,261 @@ +"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" + +interface BudgetaryRfqSelectorProps { + selectedRfqId?: number; + onRfqSelect: (rfq: BudgetaryRfq | null) => void; + placeholder?: string; +} + +export function BudgetaryRfqSelector({ + selectedRfqId, + onRfqSelect, + placeholder = "Budgetary RFQ 선택..." +}: BudgetaryRfqSelectorProps) { + const [searchTerm, setSearchTerm] = React.useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 300); + + const [open, setOpen] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]); + const [selectedRfq, setSelectedRfq] = React.useState<BudgetaryRfq | 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); + + // 초기 선택된 RFQ가 있을 경우 로드 + React.useEffect(() => { + if (selectedRfqId && open) { + const loadSelectedRfq = async () => { + try { + const result = await getBudgetaryRfqs({ + limit: 1, + // null을 undefined로 변환하여 타입 오류 해결 + projectId: selectedRfq?.projectId ?? undefined + }); + + if ('rfqs' in result && result.rfqs) { + // 옵셔널 체이닝 또는 조건부 검사로 undefined 체크 + const foundRfq = result.rfqs.find(rfq => rfq.id === selectedRfqId); + if (foundRfq) { + setSelectedRfq(foundRfq); + } + } + } catch (error) { + console.error("선택된 RFQ 로드 오류:", error); + } + }; + + if (!selectedRfq || selectedRfq.id !== selectedRfqId) { + loadSelectedRfq(); + } + } + }, [selectedRfqId, open, selectedRfq]); + + // 검색어 변경 시 데이터 리셋 및 재로드 + React.useEffect(() => { + if (open) { + setPage(1); + setHasMore(true); + setBudgetaryRfqs([]); + loadBudgetaryRfqs(1, true); + } + }, [debouncedSearchTerm, open]); + + // 데이터 로드 함수 + const loadBudgetaryRfqs = async (pageToLoad: number, reset = false) => { + if (!open) return; + + setLoading(true); + try { + const limit = 20; // 한 번에 로드할 항목 수 + const result = await getBudgetaryRfqs({ + search: debouncedSearchTerm, + limit, + offset: (pageToLoad - 1) * limit, + }); + + if ('rfqs' in result && result.rfqs) { + if (reset) { + setBudgetaryRfqs(result.rfqs); + } else { + setBudgetaryRfqs(prev => [...prev, ...result.rfqs]); + } + + setTotalCount(result.totalCount); + setHasMore(result.rfqs.length === limit && (pageToLoad * limit) < result.totalCount); + setPage(pageToLoad); + } + } catch (error) { + console.error("Budgetary RFQs 로드 오류:", error); + } finally { + setLoading(false); + } + }; + + // 무한 스크롤 처리 + const handleScroll = () => { + if (listRef.current) { + const { scrollTop, scrollHeight, clientHeight } = listRef.current; + + // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드 + if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) { + loadBudgetaryRfqs(page + 1); + } + } + }; + + // RFQ를 프로젝트별로 그룹화하는 함수 + const groupRfqsByProject = (rfqs: BudgetaryRfq[]) => { + const groups: Record<string, { + projectId: number | null; + projectCode: string | null; + projectName: string | null; + rfqs: BudgetaryRfq[]; + }> = {}; + + // '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(budgetaryRfqs); + }, [budgetaryRfqs]); + + // RFQ 선택 처리 + const handleRfqSelect = (rfq: BudgetaryRfq | null) => { + setSelectedRfq(rfq); + onRfqSelect(rfq); + setOpen(false); + }; + + 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="Budgetary RFQ 코드/설명/프로젝트 검색..." + 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" + )} + /> + <span className="font-medium">{rfq.rfqCode || ""}</span> + <span className="ml-2 text-gray-500 truncate"> + - {rfq.description || ""} + </span> + </CommandItem> + ))} + </CommandGroup> + ))} + + {loading && ( + <div className="py-2 text-center"> + <Loader className="h-4 w-4 animate-spin mx-auto" /> + </div> + )} + + {!loading && !hasMore && budgetaryRfqs.length > 0 && ( + <div className="py-2 text-center text-sm text-muted-foreground"> + 총 {totalCount}개 중 {budgetaryRfqs.length}개 표시됨 + </div> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file diff --git a/lib/rfqs/table/ItemsDialog.tsx b/lib/rfqs/table/ItemsDialog.tsx new file mode 100644 index 00000000..f1dbf90e --- /dev/null +++ b/lib/rfqs/table/ItemsDialog.tsx @@ -0,0 +1,744 @@ +"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 + 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]"> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + // 컴포넌트에 ref 전달 + ref={el => { + inputRefs.current[index] = el; + }} + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + data-error={!!form.formState.errors.items?.[index]?.itemCode} + data-state={selected ? "filled" : "empty"} + > + {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."} + <ChevronsUpDown className="ml-2 h-4 w-4 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"]`); + }} + > + {label} + <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/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx new file mode 100644 index 00000000..1d824bc0 --- /dev/null +++ b/lib/rfqs/table/add-rfq-dialog.tsx @@ -0,0 +1,349 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown } from "lucide-react" +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 { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" + +import { useSession } from "next-auth/react" +import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations" +import { createRfq, getBudgetaryRfqs } from "../service" +import { ProjectSelector } from "@/components/ProjectSelector" +import { type Project } from "../service" +import { cn } from "@/lib/utils" +import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector" +import { type BudgetaryRfq as ServiceBudgetaryRfq } from "../service"; + +// 부모 RFQ 정보 타입 정의 +interface BudgetaryRfq { + id: number; + rfqCode: string; + description: 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 [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]) + const [isLoadingBudgetary, setIsLoadingBudgetary] = React.useState(false) + const [budgetarySearchOpen, setBudgetarySearchOpen] = React.useState(false) + const [budgetarySearchTerm, setBudgetarySearchTerm] = React.useState("") + const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null) + + // 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; + + // Debug logging - remove in production + console.log("Session status:", status); + console.log("Session data:", session); + console.log("User ID:", id); + + return id; + }, [session, status]); + + // RfqType에 따른 타이틀 생성 + const getTitle = () => { + return rfqType === RfqType.PURCHASE + ? "Purchase RFQ" + : "Budgetary RFQ"; + }; + + // 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]); + + // Budgetary RFQ 목록 로드 (Purchase RFQ 생성 시만) + React.useEffect(() => { + if (rfqType === RfqType.PURCHASE && open) { + const loadBudgetaryRfqs = async () => { + setIsLoadingBudgetary(true); + try { + const result = await getBudgetaryRfqs(); + if ('rfqs' in result) { + setBudgetaryRfqs(result.rfqs as unknown as BudgetaryRfq[]); + } else if ('error' in result) { + console.error("Budgetary RFQs 로드 오류:", result.error); + } + } catch (error) { + console.error("Budgetary RFQs 로드 오류:", error); + } finally { + setIsLoadingBudgetary(false); + } + }; + + loadBudgetaryRfqs(); + } + }, [rfqType, open]); + + // 검색어로 필터링된 Budgetary RFQ 목록 + const filteredBudgetaryRfqs = React.useMemo(() => { + if (!budgetarySearchTerm.trim()) return budgetaryRfqs; + + const lowerSearch = budgetarySearchTerm.toLowerCase(); + return budgetaryRfqs.filter( + rfq => + rfq.rfqCode.toLowerCase().includes(lowerSearch) || + (rfq.description && rfq.description.toLowerCase().includes(lowerSearch)) + ); + }, [budgetaryRfqs, budgetarySearchTerm]); + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project) => { + form.setValue("projectId", project.id); + }; + + // Budgetary RFQ 선택 처리 + const handleBudgetaryRfqSelect = (rfq: BudgetaryRfq) => { + setSelectedBudgetaryRfq(rfq); + form.setValue("parentRfqId", rfq.id); + setBudgetarySearchOpen(false); + }; + + 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(); + setSelectedBudgetaryRfq(null); + setOpen(false); + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset(); + setSelectedBudgetaryRfq(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>; + } + + 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> 버튼을 누르세요. + </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> + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */} + {rfqType === RfqType.PURCHASE && ( + <FormField + control={form.control} + name="parentRfqId" + render={({ field }) => ( + <FormItem> + <FormLabel>Budgetary RFQ (Optional)</FormLabel> + <FormControl> + <BudgetaryRfqSelector + selectedRfqId={field.value as number | undefined} + onRfqSelect={(rfq) => { + setSelectedBudgetaryRfq(rfq as any); + form.setValue("parentRfqId", rfq?.id); + }} + placeholder="Budgetary RFQ 선택..." + /> + </FormControl> + <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>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) { + field.onChange(new Date(val + "T00:00:00")) + } + }} + /> + </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 new file mode 100644 index 00000000..57a170e1 --- /dev/null +++ b/lib/rfqs/table/attachment-rfq-sheet.tsx @@ -0,0 +1,430 @@ +"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 { Trash2, Plus, 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, + FileListSize, +} from "@/components/ui/file-list" + +import prettyBytes from "pretty-bytes" +import { processRfqAttachments } from "../service" +import { format } from "path" +import { formatDate } from "@/lib/utils" +import { RfqType } from "../validations" +import { RfqWithItemCount } from "@/db/schema/rfq" + +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: any[]) { + // 편집 불가능한 상태에서는 무시 + 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)} + </span> + )} + </div> + <div className="flex items-center gap-2"> + {/* 1) Download button (if filePath) */} + {field.filePath && ( + <a + href={`/api/rfq-download?path=${encodeURIComponent(field.filePath)}`} + download={field.fileName} + className="text-sm" + > + <Button variant="ghost" size="icon" type="button"> + <Download className="h-4 w-4" /> + </Button> + </a> + )} + {/* 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 new file mode 100644 index 00000000..09596bc7 --- /dev/null +++ b/lib/rfqs/table/delete-rfqs-dialog.tsx @@ -0,0 +1,149 @@ +"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 new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/rfqs/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"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 new file mode 100644 index 00000000..aaae6af2 --- /dev/null +++ b/lib/rfqs/table/feature-flags.tsx @@ -0,0 +1,96 @@ +"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 new file mode 100644 index 00000000..98df3bc8 --- /dev/null +++ b/lib/rfqs/table/rfqs-table-columns.tsx @@ -0,0 +1,315 @@ +"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) + } + + 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 new file mode 100644 index 00000000..daef7e0b --- /dev/null +++ b/lib/rfqs/table/rfqs-table-floating-bar.tsx @@ -0,0 +1,338 @@ +"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 new file mode 100644 index 00000000..6402e625 --- /dev/null +++ b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx @@ -0,0 +1,55 @@ +"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 new file mode 100644 index 00000000..db5c31e7 --- /dev/null +++ b/lib/rfqs/table/rfqs-table.tsx @@ -0,0 +1,264 @@ +"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 ( + <> + <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} + rfqType={rfqType} + /> + + <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} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/table/update-rfq-sheet.tsx b/lib/rfqs/table/update-rfq-sheet.tsx new file mode 100644 index 00000000..769f25e7 --- /dev/null +++ b/lib/rfqs/table/update-rfq-sheet.tsx @@ -0,0 +1,283 @@ +"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 } from "../service" +import { ProjectSelector } from "@/components/ProjectSelector" +import { type Project } from "../service" +import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector" + +interface UpdateRfqSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + rfq: RfqWithItemCount | null + rfqType?: RfqType; +} + + +interface BudgetaryRfq { + id: number; + rfqCode: string; + description: string | null; +} + + +export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: UpdateRfqSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const { data: session } = useSession() + const userId = Number(session?.user?.id || 1) + const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null) + + // 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 + dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환 + status: rfq?.status ?? "DRAFT", + createdBy: rfq?.createdBy ?? userId, + }, + }); + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project) => { + form.setValue("projectId", project.id); + }; + + async function onSubmit(input: UpdateRfqSchema) { + startUpdateTransition(async () => { + if (!rfq) return + + const { error } = await modifyRfq({ + ...input, + }) + + 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 RFQ</SheetTitle> + <SheetDescription> + Update the RFQ details and save the changes + </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} /> + )} + /> + + {/* 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> + )} + /> + + {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */} + {rfqType === RfqType.PURCHASE && ( + <FormField + control={form.control} + name="parentRfqId" + render={({ field }) => ( + <FormItem> + <FormLabel>Budgetary RFQ (Optional)</FormLabel> + <FormControl> + <BudgetaryRfqSelector + selectedRfqId={field.value as number | undefined} + onRfqSelect={(rfq) => { + setSelectedBudgetaryRfq(rfq as any); + form.setValue("parentRfqId", rfq?.id); + }} + placeholder="Budgetary RFQ 선택..." + /> + </FormControl> + <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 |
