summaryrefslogtreecommitdiff
path: root/lib/rfqs-tech/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfqs-tech/table')
-rw-r--r--lib/rfqs-tech/table/ItemsDialog.tsx754
-rw-r--r--lib/rfqs-tech/table/add-rfq-dialog.tsx295
-rw-r--r--lib/rfqs-tech/table/attachment-rfq-sheet.tsx426
-rw-r--r--lib/rfqs-tech/table/delete-rfqs-dialog.tsx149
-rw-r--r--lib/rfqs-tech/table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs-tech/table/feature-flags.tsx96
-rw-r--r--lib/rfqs-tech/table/rfqs-table-columns.tsx308
-rw-r--r--lib/rfqs-tech/table/rfqs-table-floating-bar.tsx338
-rw-r--r--lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx52
-rw-r--r--lib/rfqs-tech/table/rfqs-table.tsx254
-rw-r--r--lib/rfqs-tech/table/update-rfq-sheet.tsx243
11 files changed, 3023 insertions, 0 deletions
diff --git a/lib/rfqs-tech/table/ItemsDialog.tsx b/lib/rfqs-tech/table/ItemsDialog.tsx
new file mode 100644
index 00000000..022d6430
--- /dev/null
+++ b/lib/rfqs-tech/table/ItemsDialog.tsx
@@ -0,0 +1,754 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray, useWatch } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@/components/ui/form"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandItem,
+ CommandGroup,
+ CommandEmpty
+} from "@/components/ui/command"
+import { Check, ChevronsUpDown, Plus, Trash2, Save, X, AlertCircle, Eye } from "lucide-react"
+import { toast } from "sonner"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Badge } from "@/components/ui/badge"
+
+import { createRfqItem, deleteRfqItem } from "../service"
+import { RfqWithItemCount } from "@/db/schema/rfq"
+
+// Zod 스키마 - 수량은 string으로 받아서 나중에 변환
+const itemSchema = z.object({
+ id: z.number().optional(),
+ itemCode: z.string().nonempty({ message: "아이템 코드를 선택해주세요" }),
+ description: z.string().optional(),
+ quantity: z.coerce.number().min(1, { message: "최소 수량은 1입니다" }).default(1),
+ uom: z.string().default("each"),
+});
+
+const itemsFormSchema = z.object({
+ rfqId: z.number().int(),
+ items: z.array(itemSchema).min(1, { message: "최소 1개 이상의 아이템을 추가해주세요" }),
+});
+
+type ItemsFormSchema = z.infer<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;
+ itemList?: string;
+ subItemList?: string;
+ }[];
+}
+
+export function RfqsItemsDialog({
+ open,
+ onOpenChange,
+ rfq,
+ defaultItems = [],
+ itemsList,
+}: RfqsItemsDialogProps) {
+ const rfqId = rfq?.rfqId ?? 0;
+ console.log(itemsList)
+
+ // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능
+ const isEditable = rfq?.status === "DRAFT";
+
+ // 초기 아이템 ID 목록을 추적하기 위한 상태 추가
+ const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]);
+
+ // 삭제된 아이템 ID를 저장하는 상태 추가
+ const [deletedItemIds, setDeletedItemIds] = React.useState<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,
+ })
+ );
+
+ // 2. 생성/수정 처리 - 폼에 남아있는 아이템들
+ const upsertPromises = data.items.map((item) =>
+ createRfqItem({
+ rfqId: rfqId,
+ itemCode: item.itemCode,
+ description: item.description,
+ // 명시적으로 숫자로 변환
+ quantity: Number(item.quantity),
+ uom: item.uom,
+ id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성
+ })
+ );
+
+ // 모든 요청 병렬 처리
+ await Promise.all([...deletePromises, ...upsertPromises]);
+
+ toast.success("RFQ 아이템이 성공적으로 저장되었습니다!");
+ setHasUnsavedChanges(false);
+ onOpenChange(false);
+ } catch (err) {
+ toast.error(`오류가 발생했습니다: ${String(err)}`);
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ // 단축키 처리 - 편집 가능한 경우에만 단축키 활성화
+ React.useEffect(() => {
+ if (!isEditable) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Alt+N: 새 항목 추가
+ if (e.altKey && e.key === 'n') {
+ e.preventDefault();
+ handleAddItem();
+ }
+ // Ctrl+S: 저장
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+ e.preventDefault();
+ form.handleSubmit(onSubmit)();
+ }
+ // Esc: 포커스된 팝오버 닫기
+ if (e.key === 'Escape') {
+ document.querySelectorAll('[role="combobox"][aria-expanded="true"]').forEach(
+ (el) => (el as HTMLButtonElement).click()
+ );
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [form, isEditable]);
+
+ return (
+ <>
+ <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>
+
+ {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);
+ });
+
+ // 선택된 아이템 찾기
+ 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]" style={{width:250}}>
+ <FormControl>
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ 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 ? (
+ <>
+ <div>{selected.code}</div>
+ {(selected.itemList || selected.subItemList) && (
+ <div className="text-xs text-muted-foreground">
+ {selected.itemList}
+ {selected.subItemList && ` / ${selected.subItemList}`}
+ </div>
+ )}
+ </>
+ ) : "아이템 선택..."}
+ </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) => (
+ <CommandItem
+ key={it.code}
+ value={`${it.code} ${it.itemList || ''} ${it.subItemList || ''}`}
+ onSelect={() => {
+ field.onChange(it.code);
+ setPopoverOpen(false);
+ focusField(`input[name="items.${index}.description"]`);
+ }}
+ >
+ <div className="flex-1 overflow-hidden">
+ <div className="font-medium">{it.code}</div>
+ {(it.itemList || it.subItemList) && (
+ <div className="text-xs text-muted-foreground">
+ {it.itemList}
+ {it.subItemList && ` / ${it.subItemList}`}
+ </div>
+ )}
+ </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}` : 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-tech/table/add-rfq-dialog.tsx b/lib/rfqs-tech/table/add-rfq-dialog.tsx
new file mode 100644
index 00000000..acd3c34e
--- /dev/null
+++ b/lib/rfqs-tech/table/add-rfq-dialog.tsx
@@ -0,0 +1,295 @@
+"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 } from "../validations"
+import { createRfq, generateNextRfqCode } from "../service"
+import { type Project } from "../service"
+import { EstimateProjectSelector } from "@/components/BidProjectSelector"
+
+
+
+
+export function AddRfqDialog() {
+ const [open, setOpen] = React.useState(false)
+ const { data: session, status } = useSession()
+ 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]);
+
+
+
+ // RHF + Zod
+ const form = useForm<CreateRfqSchema>({
+ resolver: zodResolver(createRfqSchema),
+ defaultValues: {
+ rfqCode: "",
+ description: "",
+ projectId: undefined,
+ dueDate: new Date(),
+ status: "DRAFT",
+ // 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();
+
+ 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, form]);
+
+
+
+
+
+ const handleBidProjectSelect = (project: Project | null) => {
+ if (project === null) {
+ return;
+ }
+
+ form.setValue("bidProjectId", project.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
+ };
+
+ const result = await createRfq(submitData);
+ if (result.error) {
+ toast.error(`에러: ${result.error}`);
+ return;
+ }
+
+ toast.success("RFQ가 성공적으로 생성되었습니다.");
+ form.reset();
+
+ setOpen(false);
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset();
+ }
+ 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 RFQ
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New RFQ</DialogTitle>
+ <DialogDescription>
+ 새 RFQ 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+
+ {/* Project Selector */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Project</FormLabel>
+ <FormControl>
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleBidProjectSelect}
+ placeholder="견적 프로젝트 선택..."
+ />
+ </FormControl>
+ <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-tech/table/attachment-rfq-sheet.tsx b/lib/rfqs-tech/table/attachment-rfq-sheet.tsx
new file mode 100644
index 00000000..d06fae09
--- /dev/null
+++ b/lib/rfqs-tech/table/attachment-rfq-sheet.tsx
@@ -0,0 +1,426 @@
+"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,
+ 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 { 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[]
+ rfq: RfqWithItemCount | null
+ /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */
+ onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void
+}
+
+/**
+ * RfqAttachmentsSheet:
+ * - 기존 첨부 목록 (다운로드 + 삭제)
+ * - 새 파일 Dropzone
+ * - Save 시 processRfqAttachments(server action)
+ */
+export function RfqAttachmentsSheet({
+ defaultAttachments = [],
+ onAttachmentsUpdated,
+ rfq,
+ ...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
+ })
+
+ 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-tech/table/delete-rfqs-dialog.tsx b/lib/rfqs-tech/table/delete-rfqs-dialog.tsx
new file mode 100644
index 00000000..729bc526
--- /dev/null
+++ b/lib/rfqs-tech/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 { 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-tech/table/feature-flags-provider.tsx b/lib/rfqs-tech/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs-tech/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-tech/table/feature-flags.tsx b/lib/rfqs-tech/table/feature-flags.tsx
new file mode 100644
index 00000000..aaae6af2
--- /dev/null
+++ b/lib/rfqs-tech/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-tech/table/rfqs-table-columns.tsx b/lib/rfqs-tech/table/rfqs-table-columns.tsx
new file mode 100644
index 00000000..86660dc7
--- /dev/null
+++ b/lib/rfqs-tech/table/rfqs-table-columns.tsx
@@ -0,0 +1,308 @@
+"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"
+
+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
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ openItemsModal,
+ openAttachmentsSheet,
+ router,
+}: 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 }) {
+ // 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(
+ `/evcp/rfq/${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-tech/table/rfqs-table-floating-bar.tsx b/lib/rfqs-tech/table/rfqs-table-floating-bar.tsx
new file mode 100644
index 00000000..daef7e0b
--- /dev/null
+++ b/lib/rfqs-tech/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-tech/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx
new file mode 100644
index 00000000..15306ecf
--- /dev/null
+++ b/lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx
@@ -0,0 +1,52 @@
+"use client"
+
+import * as React from "react"
+import type { Table } from "@tanstack/react-table"
+import { Download } from "lucide-react"
+
+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"
+
+
+interface RfqsTableToolbarActionsProps {
+ table: Table<RfqWithItemCount>
+}
+
+export function RfqsTableToolbarActions({ table }: 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 />
+
+
+ {/** 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-tech/table/rfqs-table.tsx b/lib/rfqs-tech/table/rfqs-table.tsx
new file mode 100644
index 00000000..949f49e9
--- /dev/null
+++ b/lib/rfqs-tech/table/rfqs-table.tsx
@@ -0,0 +1,254 @@
+"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 { RfqWithItemCount, rfqs } from "@/db/schema/rfq"
+import { UpdateRfqSheet } from "./update-rfq-sheet"
+import { DeleteRfqsDialog } from "./delete-rfqs-dialog"
+import { RfqsTableToolbarActions } from "./rfqs-table-toolbar-actions"
+import { RfqsItemsDialog } from "./ItemsDialog"
+import { getAllOffshoreItems } from "@/lib/items-tech/service"
+import { RfqAttachmentsSheet } from "./attachment-rfq-sheet"
+import { useRouter } from "next/navigation"
+
+interface RfqsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getRfqs>>,
+ Awaited<ReturnType<typeof getRfqStatusCounts>>,
+ Awaited<ReturnType<typeof getAllOffshoreItems>>,
+ ]
+ >;
+}
+
+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 }: RfqsTableProps) {
+
+ 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 ?? "",
+ itemList: v.itemList ?? "",
+ subItemList: v.subItemList ?? "",
+ }));
+
+ 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]);
+
+
+
+ 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,
+ }), [setRowAction, router]);
+
+ /**
+ * 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} />
+ </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}
+ />
+
+ <RfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachDefault}
+ rfq={selectedRfq ?? null}
+ onAttachmentsUpdated={handleAttachmentsUpdated}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/table/update-rfq-sheet.tsx b/lib/rfqs-tech/table/update-rfq-sheet.tsx
new file mode 100644
index 00000000..9517bc89
--- /dev/null
+++ b/lib/rfqs-tech/table/update-rfq-sheet.tsx
@@ -0,0 +1,243 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import { updateRfqSchema, type UpdateRfqSchema } from "../validations"
+import { modifyRfq } from "../service"
+import { RfqWithItemCount } from "@/db/schema/rfq"
+import { useSession } from "next-auth/react"
+import { ProjectSelector } from "@/components/ProjectSelector"
+import { Project } from "../service"
+
+interface UpdateRfqSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ rfq: RfqWithItemCount | null
+}
+
+export function UpdateRfqSheet({ rfq, ...props }: UpdateRfqSheetProps) {
+ const { data: session } = useSession()
+ const userId = Number(session?.user?.id || 1)
+
+ // 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 | null) => {
+ if (project === null) {
+ return;
+ }
+ form.setValue("projectId", project.id);
+ };
+
+ async function onSubmit(input: UpdateRfqSchema) {
+ 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>
+ )}
+ />
+
+ {/* 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 */}
+ <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>
+ <SelectItem key="DRAFT" value="DRAFT" className="capitalize">
+ DRAFT
+ </SelectItem>
+ <SelectItem key="PUBLISHED" value="PUBLISHED" className="capitalize">
+ PUBLISHED
+ </SelectItem>
+ <SelectItem key="EVALUATION" value="EVALUATION" className="capitalize">
+ EVALUATION
+ </SelectItem>
+ <SelectItem key="AWARDED" value="AWARDED" className="capitalize">
+ AWARDED
+ </SelectItem>
+ </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>
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file