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, 0 insertions, 3023 deletions
diff --git a/lib/rfqs-tech/table/ItemsDialog.tsx b/lib/rfqs-tech/table/ItemsDialog.tsx
deleted file mode 100644
index 022d6430..00000000
--- a/lib/rfqs-tech/table/ItemsDialog.tsx
+++ /dev/null
@@ -1,754 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm, useFieldArray, useWatch } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage
-} from "@/components/ui/form"
-import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
-import {
- Command,
- CommandInput,
- CommandList,
- CommandItem,
- CommandGroup,
- CommandEmpty
-} from "@/components/ui/command"
-import { Check, ChevronsUpDown, Plus, Trash2, Save, X, AlertCircle, Eye } from "lucide-react"
-import { toast } from "sonner"
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { Badge } from "@/components/ui/badge"
-
-import { createRfqItem, deleteRfqItem } from "../service"
-import { RfqWithItemCount } from "@/db/schema/rfq"
-
-// 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
deleted file mode 100644
index acd3c34e..00000000
--- a/lib/rfqs-tech/table/add-rfq-dialog.tsx
+++ /dev/null
@@ -1,295 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { toast } from "sonner"
-
-import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
-
-import { useSession } from "next-auth/react"
-import { createRfqSchema, type CreateRfqSchema } 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
deleted file mode 100644
index d06fae09..00000000
--- a/lib/rfqs-tech/table/attachment-rfq-sheet.tsx
+++ /dev/null
@@ -1,426 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { z } from "zod"
-import { useForm, useFieldArray } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
- SheetDescription,
- SheetFooter,
- SheetClose,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription
-} from "@/components/ui/form"
-import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
-import { useToast } from "@/hooks/use-toast"
-import { Badge } from "@/components/ui/badge"
-
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- 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
deleted file mode 100644
index 729bc526..00000000
--- a/lib/rfqs-tech/table/delete-rfqs-dialog.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { 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
deleted file mode 100644
index 81131894..00000000
--- a/lib/rfqs-tech/table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
diff --git a/lib/rfqs-tech/table/feature-flags.tsx b/lib/rfqs-tech/table/feature-flags.tsx
deleted file mode 100644
index aaae6af2..00000000
--- a/lib/rfqs-tech/table/feature-flags.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface TasksTableContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const TasksTableContext = React.createContext<TasksTableContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useTasksTable() {
- const context = React.useContext(TasksTableContext)
- if (!context) {
- throw new Error("useTasksTable must be used within a TasksTableProvider")
- }
- return context
-}
-
-export function TasksTableProvider({ children }: React.PropsWithChildren) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "featureFlags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- }
- )
-
- return (
- <TasksTableContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit"
- >
- {dataTableConfig.featureFlags.map((flag) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className="whitespace-nowrap px-3 text-xs"
- asChild
- >
- <TooltipTrigger>
- <flag.icon
- className="mr-2 size-3.5 shrink-0"
- aria-hidden="true"
- />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </TasksTableContext.Provider>
- )
-}
diff --git a/lib/rfqs-tech/table/rfqs-table-columns.tsx b/lib/rfqs-tech/table/rfqs-table-columns.tsx
deleted file mode 100644
index 03089341..00000000
--- a/lib/rfqs-tech/table/rfqs-table-columns.tsx
+++ /dev/null
@@ -1,308 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, Paperclip, Package } from "lucide-react"
-import { toast } from "sonner"
-
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-
-import { getRFQStatusIcon } from "@/lib/tasks/utils"
-import { rfqsColumnsConfig } from "@/config/rfqsColumnsConfig"
-import { RfqWithItemCount } from "@/db/schema/rfq"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { useRouter } from "next/navigation"
-
-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-tech/${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
deleted file mode 100644
index daef7e0b..00000000
--- a/lib/rfqs-tech/table/rfqs-table-floating-bar.tsx
+++ /dev/null
@@ -1,338 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Table } from "@tanstack/react-table"
-import { toast } from "sonner"
-import { Calendar, type CalendarProps } from "@/components/ui/calendar"
-import { Button } from "@/components/ui/button"
-import { Portal } from "@/components/ui/portal"
-import {
- Select,
- SelectTrigger,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectValue,
-} from "@/components/ui/select"
-import { Separator } from "@/components/ui/separator"
-import {
- Tooltip,
- TooltipTrigger,
- TooltipContent,
-} from "@/components/ui/tooltip"
-import { Kbd } from "@/components/kbd"
-import { ActionConfirmDialog } from "@/components/ui/action-dialog"
-
-import { ArrowUp, CheckCircle2, Download, Loader, Trash2, X, CalendarIcon } from "lucide-react"
-
-import { exportTableToExcel } from "@/lib/export"
-
-import { RfqWithItemCount, rfqs } from "@/db/schema/rfq"
-import { modifyRfqs, removeRfqs } from "../service"
-
-interface RfqsTableFloatingBarProps {
- table: Table<RfqWithItemCount>
-}
-
-/**
- * 추가된 로직:
- * - 달력(캘린더) 아이콘 버튼
- * - 눌렀을 때 Popover로 Calendar 표시
- * - 날짜 선택 시 Confirm 다이얼로그 → modifyRfqs({ dueDate })
- */
-export function RfqsTableFloatingBar({ table }: RfqsTableFloatingBarProps) {
- const rows = table.getFilteredSelectedRowModel().rows
- const [isPending, startTransition] = React.useTransition()
- const [action, setAction] = React.useState<"update-status" | "export" | "delete" | "update-dueDate">()
- const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
-
- const [confirmProps, setConfirmProps] = React.useState<{
- title: string
- description?: string
- onConfirm: () => Promise<void> | void
- }>({
- title: "",
- description: "",
- onConfirm: () => {},
- })
-
- // 캘린더 Popover 열림 여부
- const [calendarOpen, setCalendarOpen] = React.useState(false)
- const [selectedDate, setSelectedDate] = React.useState<Date | null>(null)
-
- // Clear selection on Escape key press
- React.useEffect(() => {
- function handleKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") {
- table.toggleAllRowsSelected(false)
- }
- }
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [table])
-
- function handleDeleteConfirm() {
- setAction("delete")
- setConfirmProps({
- title: `Delete ${rows.length} RFQ${rows.length > 1 ? "s" : ""}?`,
- description: "This action cannot be undone.",
- onConfirm: async () => {
- startTransition(async () => {
- const { error } = await removeRfqs({
- ids: rows.map((row) => row.original.rfqId),
- })
- if (error) {
- toast.error(error)
- return
- }
- toast.success("RFQs deleted")
- table.toggleAllRowsSelected(false)
- setConfirmDialogOpen(false)
- })
- },
- })
- setConfirmDialogOpen(true)
- }
-
- function handleSelectStatus(newStatus: RfqWithItemCount["status"]) {
- setAction("update-status")
- setConfirmProps({
- title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`,
- description: "This action will override their current status.",
- onConfirm: async () => {
- startTransition(async () => {
- const { error } = await modifyRfqs({
- ids: rows.map((row) => row.original.rfqId),
- status: newStatus as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED",
- })
- if (error) {
- toast.error(error)
- return
- }
- toast.success("RFQs updated")
- setConfirmDialogOpen(false)
- })
- },
- })
- setConfirmDialogOpen(true)
- }
-
- // 1) 달력에서 날짜를 선택했을 때 → Confirm 다이얼로그
- function handleDueDateSelect(newDate: Date) {
- setAction("update-dueDate")
-
- setConfirmProps({
- title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} Due Date to ${newDate.toDateString()}?`,
- description: "This action will override their current due date.",
- onConfirm: async () => {
- startTransition(async () => {
- const { error } = await modifyRfqs({
- ids: rows.map((r) => r.original.rfqId),
- dueDate: newDate,
- })
- if (error) {
- toast.error(error)
- return
- }
- toast.success("Due date updated")
- setConfirmDialogOpen(false)
- setCalendarOpen(false)
- })
- },
- })
- setConfirmDialogOpen(true)
- }
-
- // 2) Export
- function handleExport() {
- setAction("export")
- startTransition(() => {
- exportTableToExcel(table, {
- excludeColumns: ["select", "actions"],
- onlySelected: true,
- })
- })
- }
-
- // Floating bar UI
- return (
- <Portal>
- <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5">
- <div className="w-full overflow-x-auto">
- <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
- {/* Selection Info + Clear */}
- <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
- <span className="whitespace-nowrap text-xs">
- {rows.length} selected
- </span>
- <Separator orientation="vertical" className="ml-2 mr-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- className="size-5 hover:border"
- onClick={() => table.toggleAllRowsSelected(false)}
- >
- <X className="size-3.5 shrink-0" aria-hidden="true" />
- </Button>
- </TooltipTrigger>
- <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
- <p className="mr-2">Clear selection</p>
- <Kbd abbrTitle="Escape" variant="outline">
- Esc
- </Kbd>
- </TooltipContent>
- </Tooltip>
- </div>
-
- <Separator orientation="vertical" className="hidden h-5 sm:block" />
-
- <div className="flex items-center gap-1.5">
- {/* 1) Status Update */}
- <Select
- onValueChange={(value: RfqWithItemCount["status"]) => handleSelectStatus(value)}
- >
- <Tooltip>
- <SelectTrigger asChild>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
- disabled={isPending}
- >
- {isPending && action === "update-status" ? (
- <Loader className="size-3.5 animate-spin" aria-hidden="true" />
- ) : (
- <CheckCircle2 className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- </SelectTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Update status</p>
- </TooltipContent>
- </Tooltip>
- <SelectContent align="center">
- <SelectGroup>
- {rfqs.status.enumValues.map((status) => (
- <SelectItem key={status} value={status} className="capitalize">
- {status}
- </SelectItem>
- ))}
- </SelectGroup>
- </SelectContent>
- </Select>
-
- {/* 2) Due Date Update: Calendar Popover */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- disabled={isPending}
- onClick={() => setCalendarOpen((open) => !open)}
- >
- {isPending && action === "update-dueDate" ? (
- <Loader className="size-3.5 animate-spin" aria-hidden="true" />
- ) : (
- <CalendarIcon className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Update Due Date</p>
- </TooltipContent>
- </Tooltip>
-
- {/* Calendar Popover (간단 구현) */}
- {calendarOpen && (
- <div className="absolute bottom-16 z-50 rounded-md border bg-background p-2 shadow">
- <Calendar
- mode="single"
- selected={selectedDate || new Date()}
- onSelect={(date) => {
- if (date) {
- setSelectedDate(date)
- handleDueDateSelect(date)
- }
- }}
- initialFocus
- />
- </div>
- )}
-
- {/* 3) Export */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- onClick={handleExport}
- disabled={isPending}
- >
- {isPending && action === "export" ? (
- <Loader className="size-3.5 animate-spin" aria-hidden="true" />
- ) : (
- <Download className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Export tasks</p>
- </TooltipContent>
- </Tooltip>
-
- {/* 4) Delete */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- onClick={handleDeleteConfirm}
- disabled={isPending}
- >
- {isPending && action === "delete" ? (
- <Loader className="size-3.5 animate-spin" aria-hidden="true" />
- ) : (
- <Trash2 className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Delete tasks</p>
- </TooltipContent>
- </Tooltip>
- </div>
- </div>
- </div>
- </div>
-
- {/* 공용 Confirm Dialog */}
- <ActionConfirmDialog
- open={confirmDialogOpen}
- onOpenChange={setConfirmDialogOpen}
- title={confirmProps.title}
- description={confirmProps.description}
- onConfirm={confirmProps.onConfirm}
- isLoading={
- isPending && (action === "delete" || action === "update-status" || action === "update-dueDate")
- }
- confirmLabel={
- action === "delete"
- ? "Delete"
- : action === "update-status"
- ? "Update"
- : action === "update-dueDate"
- ? "Update"
- : "Confirm"
- }
- confirmVariant={action === "delete" ? "destructive" : "default"}
- />
- </Portal>
- )
-} \ No newline at end of file
diff --git a/lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx
deleted file mode 100644
index 15306ecf..00000000
--- a/lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"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
deleted file mode 100644
index 949f49e9..00000000
--- a/lib/rfqs-tech/table/rfqs-table.tsx
+++ /dev/null
@@ -1,254 +0,0 @@
-"use client"
-
-import * as React from "react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-
-import { getRFQStatusIcon } from "@/lib/tasks/utils"
-import { useFeatureFlags } from "./feature-flags-provider"
-import { getColumns } from "./rfqs-table-columns"
-import { fetchRfqAttachments, fetchRfqItems, getRfqs, getRfqStatusCounts } from "../service"
-import { 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
deleted file mode 100644
index 9517bc89..00000000
--- a/lib/rfqs-tech/table/update-rfq-sheet.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-"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