diff options
Diffstat (limited to 'lib/b-rfq/summary-table')
| -rw-r--r-- | lib/b-rfq/summary-table/add-new-rfq-dialog.tsx | 523 | ||||
| -rw-r--r-- | lib/b-rfq/summary-table/summary-rfq-columns.tsx | 499 | ||||
| -rw-r--r-- | lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx | 617 | ||||
| -rw-r--r-- | lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx | 68 | ||||
| -rw-r--r-- | lib/b-rfq/summary-table/summary-rfq-table.tsx | 285 |
5 files changed, 1992 insertions, 0 deletions
diff --git a/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx b/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx new file mode 100644 index 00000000..2333d9cf --- /dev/null +++ b/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx @@ -0,0 +1,523 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { format } from "date-fns" +import { CalendarIcon, Plus, Loader2, Eye } from "lucide-react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Calendar } from "@/components/ui/calendar" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { ProjectSelector } from "@/components/ProjectSelector" +import { createRfqAction, previewNextRfqCode } from "../service" + +export type Project = { + id: number; + projectCode: string; + projectName: string; +} + +// 클라이언트 폼 스키마 (projectId 필수로 변경) +const createRfqSchema = z.object({ + projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경 + dueDate: z.date({ + required_error: "마감일을 선택해주세요", + }), + picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"), + picName: z.string().optional(), + engPicName: z.string().optional(), + packageNo: z.string().min(1, "패키지 번호를 입력해주세요"), + packageName: z.string().min(1, "패키지명을 입력해주세요"), + remark: z.string().optional(), + projectCompany: z.string().optional(), + projectFlag: z.string().optional(), + projectSite: z.string().optional(), +}) + +type CreateRfqFormValues = z.infer<typeof createRfqSchema> + +interface CreateRfqDialogProps { + onSuccess?: () => void; +} + +export function CreateRfqDialog({ onSuccess }: CreateRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [previewCode, setPreviewCode] = React.useState<string>("") + const [isLoadingPreview, setIsLoadingPreview] = React.useState(false) + const router = useRouter() + const { data: session } = useSession() + + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null; + }, [session]); + + const form = useForm<CreateRfqFormValues>({ + resolver: zodResolver(createRfqSchema), + defaultValues: { + projectId: undefined, + dueDate: undefined, + picCode: "", + picName: "", + engPicName: "", + packageNo: "", + packageName: "", + remark: "", + projectCompany: "", + projectFlag: "", + projectSite: "", + }, + }) + + // picCode 변경 시 미리보기 업데이트 + const watchedPicCode = form.watch("picCode") + + React.useEffect(() => { + if (watchedPicCode && watchedPicCode.length > 0) { + setIsLoadingPreview(true) + const timer = setTimeout(async () => { + try { + const preview = await previewNextRfqCode(watchedPicCode) + setPreviewCode(preview) + } catch (error) { + console.error("미리보기 오류:", error) + setPreviewCode("") + } finally { + setIsLoadingPreview(false) + } + }, 500) // 500ms 디바운스 + + return () => clearTimeout(timer) + } else { + setPreviewCode("") + } + }, [watchedPicCode]) + + // 다이얼로그 열림/닫힘 처리 및 폼 리셋 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + + // 다이얼로그가 닫힐 때 폼과 상태 초기화 + if (!newOpen) { + form.reset() + setPreviewCode("") + setIsLoadingPreview(false) + } + } + + const handleCancel = () => { + form.reset() + setOpen(false) + } + + + const onSubmit = async (data: CreateRfqFormValues) => { + if (!userId) { + toast.error("로그인이 필요합니다") + return + } + + setIsLoading(true) + + try { + // 서버 액션 호출 - Date 객체를 직접 전달 + const result = await createRfqAction({ + projectId: data.projectId, // 이제 항상 값이 있음 + dueDate: data.dueDate, // Date 객체 직접 전달 + picCode: data.picCode, + picName: data.picName || "", + engPicName: data.engPicName || "", + packageNo: data.packageNo, + packageName: data.packageName, + remark: data.remark || "", + projectCompany: data.projectCompany || "", + projectFlag: data.projectFlag || "", + projectSite: data.projectSite || "", + createdBy: userId, + updatedBy: userId, + }) + + if (result.success) { + toast.success(result.message, { + description: `RFQ 코드: ${result.data?.rfqCode}`, + }) + + // 다이얼로그 닫기 (handleOpenChange에서 리셋 처리됨) + setOpen(false) + + // 성공 콜백 실행 + if (onSuccess) { + onSuccess() + } + + } else { + toast.error(result.error || "RFQ 생성에 실패했습니다") + } + + } catch (error) { + console.error('RFQ 생성 오류:', error) + toast.error("RFQ 생성에 실패했습니다", { + description: "알 수 없는 오류가 발생했습니다", + }) + } finally { + setIsLoading(false) + } + } + + const handleProjectSelect = (project: Project | null) => { + if (project === null) { + form.setValue("projectId", undefined as any); // 타입 에러 방지 + return; + } + form.setValue("projectId", project.id); + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button size="sm" variant="outline"> + <Plus className="mr-2 h-4 w-4" /> + 새 RFQ + </Button> + </DialogTrigger> + <DialogContent className="max-w-3xl h-[90vh] flex flex-col"> + {/* 고정된 헤더 */} + <DialogHeader className="flex-shrink-0"> + <DialogTitle>새 RFQ 생성</DialogTitle> + <DialogDescription> + 새로운 RFQ를 생성합니다. 필수 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + + {/* 스크롤 가능한 컨텐츠 영역 */} + <div className="flex-1 overflow-y-auto px-1"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2"> + + {/* 프로젝트 선택 (필수) */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel> + 프로젝트 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 마감일 (필수) */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel> + 마감일 <span className="text-red-500">*</span> + </FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "yyyy-MM-dd") + ) : ( + <span>마감일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구매 담당자 코드 (필수) + 미리보기 */} + <FormField + control={form.control} + name="picCode" + render={({ field }) => ( + <FormItem> + <FormLabel> + 구매 담당자 코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <div className="space-y-2"> + <Input + placeholder="예: P001, P002, MGR01 등" + {...field} + /> + {/* RFQ 코드 미리보기 */} + {previewCode && ( + <div className="flex items-center gap-2 p-2 bg-muted rounded-md"> + <Eye className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground"> + 생성될 RFQ 코드: + </span> + <Badge variant="outline" className="font-mono"> + {isLoadingPreview ? "생성 중..." : previewCode} + </Badge> + </div> + )} + </div> + </FormControl> + <FormDescription> + RFQ 코드는 N + 담당자코드 + 시리얼번호(5자리) 형식으로 자동 생성됩니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 담당자 정보 (두 개 나란히) */} + <div className="space-y-3"> + <h4 className="text-sm font-medium">담당자 정보</h4> + <div className="grid grid-cols-2 gap-4"> + {/* 구매 담당자 */} + <FormField + control={form.control} + name="picName" + render={({ field }) => ( + <FormItem> + <FormLabel>구매 담당자명</FormLabel> + <FormControl> + <Input + placeholder="구매 담당자명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 설계 담당자 */} + <FormField + control={form.control} + name="engPicName" + render={({ field }) => ( + <FormItem> + <FormLabel>설계 담당자명</FormLabel> + <FormControl> + <Input + placeholder="설계 담당자명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* 패키지 정보 (두 개 나란히) - 필수 */} + <div className="space-y-3"> + <h4 className="text-sm font-medium">패키지 정보</h4> + <div className="grid grid-cols-2 gap-4"> + {/* 패키지 번호 (필수) */} + <FormField + control={form.control} + name="packageNo" + render={({ field }) => ( + <FormItem> + <FormLabel> + 패키지 번호 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="패키지 번호" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지명 (필수) */} + <FormField + control={form.control} + name="packageName" + render={({ field }) => ( + <FormItem> + <FormLabel> + 패키지명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="패키지명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* 프로젝트 상세 정보 */} + <div className="space-y-3"> + <h4 className="text-sm font-medium">프로젝트 상세 정보</h4> + <div className="grid grid-cols-1 gap-3"> + <FormField + control={form.control} + name="projectCompany" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 회사</FormLabel> + <FormControl> + <Input + placeholder="프로젝트 회사명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-3"> + <FormField + control={form.control} + name="projectFlag" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 플래그</FormLabel> + <FormControl> + <Input + placeholder="프로젝트 플래그" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="projectSite" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 사이트</FormLabel> + <FormControl> + <Input + placeholder="프로젝트 사이트" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + </div> + + {/* 비고 */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + placeholder="추가 비고사항을 입력하세요" + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + </div> + + {/* 고정된 푸터 */} + <DialogFooter className="flex-shrink-0"> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "RFQ 생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-columns.tsx b/lib/b-rfq/summary-table/summary-rfq-columns.tsx new file mode 100644 index 00000000..f620858a --- /dev/null +++ b/lib/b-rfq/summary-table/summary-rfq-columns.tsx @@ -0,0 +1,499 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, Eye, Calendar, AlertTriangle, CheckCircle2, Clock, FileText } from "lucide-react" + +import { formatDate, cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Progress } from "@/components/ui/progress" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useRouter } from "next/navigation" +import { RfqDashboardView } from "@/db/schema" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetRFQColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqDashboardView> | null>>; + router: NextRouter; +} + +// 상태에 따른 Badge 변형 결정 함수 +function getStatusBadge(status: string) { + switch (status) { + case "DRAFT": + return { variant: "outline" as const, label: "초안" }; + case "Doc. Received": + return { variant: "secondary" as const, label: "문서접수" }; + case "PIC Assigned": + return { variant: "secondary" as const, label: "담당자배정" }; + case "Doc. Confirmed": + return { variant: "default" as const, label: "문서확인" }; + case "Init. RFQ Sent": + return { variant: "default" as const, label: "초기RFQ발송" }; + case "Init. RFQ Answered": + return { variant: "default" as const, label: "초기RFQ회신" }; + case "TBE started": + return { variant: "secondary" as const, label: "TBE시작" }; + case "TBE finished": + return { variant: "secondary" as const, label: "TBE완료" }; + case "Final RFQ Sent": + return { variant: "default" as const, label: "최종RFQ발송" }; + case "Quotation Received": + return { variant: "default" as const, label: "견적접수" }; + case "Vendor Selected": + return { variant: "success" as const, label: "업체선정" }; + default: + return { variant: "outline" as const, label: status }; + } +} + +function getProgressBadge(progress: number) { + if (progress >= 100) { + return { variant: "success" as const, label: "완료" }; + } else if (progress >= 70) { + return { variant: "default" as const, label: "진행중" }; + } else if (progress >= 30) { + return { variant: "secondary" as const, label: "초기진행" }; + } else { + return { variant: "outline" as const, label: "시작" }; + } +} + +function getUrgencyLevel(daysToDeadline: number): "high" | "medium" | "low" { + if (daysToDeadline <= 3) return "high"; + if (daysToDeadline <= 7) return "medium"; + return "low"; +} + +export function getRFQColumns({ setRowAction, router }: GetRFQColumnsProps): ColumnDef<RfqDashboardView>[] { + + // Select 컬럼 + const selectColumn: ColumnDef<RfqDashboardView> = { + 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, + }; + + // RFQ 코드 컬럼 + const rfqCodeColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 코드" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-medium">{row.getValue("rfqCode")}</span> + {row.original.description && ( + <span className="text-xs text-muted-foreground truncate max-w-[200px]"> + {row.original.description} + </span> + )} + </div> + ), + }; + + // 프로젝트 정보 컬럼 + const projectColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "projectName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트" /> + ), + cell: ({ row }) => { + const projectName = row.original.projectName; + const projectCode = row.original.projectCode; + + if (!projectName) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex flex-col"> + <span className="font-medium">{projectName}</span> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + {projectCode && <span>{projectCode}</span>} + </div> + </div> + ); + }, + }; + + // 패키지 정보 컬럼 + const packageColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "packageNo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="패키지" /> + ), + cell: ({ row }) => { + const packageNo = row.original.packageNo; + const packageName = row.original.packageName; + + if (!packageNo) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex flex-col"> + <span className="font-medium">{packageNo}</span> + {packageName && ( + <span className="text-xs text-muted-foreground truncate max-w-[150px]"> + {packageName} + </span> + )} + </div> + ); + }, + }; + + const updatedColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "updatedBy", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated By" /> + ), + cell: ({ row }) => { + const updatedByName = row.original.updatedByName; + const updatedByEmail = row.original.updatedByEmail; + + if (!updatedByName) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex flex-col"> + <span className="font-medium">{updatedByName}</span> + {updatedByEmail && ( + <span className="text-xs text-muted-foreground truncate max-w-[150px]"> + {updatedByEmail} + </span> + )} + </div> + ); + }, + }; + + + // 상태 컬럼 + const statusColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const statusBadge = getStatusBadge(row.original.status); + return <Badge variant={statusBadge.variant}>{statusBadge.label}</Badge>; + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }; + + // 진행률 컬럼 + const progressColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "overallProgress", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="진행률" /> + ), + cell: ({ row }) => { + const progress = row.original.overallProgress; + const progressBadge = getProgressBadge(progress); + + return ( + <div className="flex flex-col gap-1 min-w-[120px]"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">{progress}%</span> + <Badge variant={progressBadge.variant} className="text-xs"> + {progressBadge.label} + </Badge> + </div> + <Progress value={progress} className="h-2" /> + </div> + ); + }, + }; + + // 마감일 컬럼 + const dueDateColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "dueDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="마감일" /> + ), + cell: ({ row }) => { + const dueDate = row.original.dueDate; + const daysToDeadline = row.original.daysToDeadline; + const urgencyLevel = getUrgencyLevel(daysToDeadline); + + if (!dueDate) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex flex-col"> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span>{formatDate(dueDate, 'KR')}</span> + </div> + <div className="flex items-center gap-1 text-xs"> + {urgencyLevel === "high" && ( + <AlertTriangle className="h-3 w-3 text-red-500" /> + )} + {urgencyLevel === "medium" && ( + <Clock className="h-3 w-3 text-yellow-500" /> + )} + {urgencyLevel === "low" && ( + <CheckCircle2 className="h-3 w-3 text-green-500" /> + )} + <span className={cn( + urgencyLevel === "high" && "text-red-500", + urgencyLevel === "medium" && "text-yellow-600", + urgencyLevel === "low" && "text-green-600" + )}> + {daysToDeadline > 0 ? `${daysToDeadline}일 남음` : + daysToDeadline === 0 ? "오늘 마감" : + `${Math.abs(daysToDeadline)}일 지남`} + </span> + </div> + </div> + ); + }, + }; + + // 담당자 컬럼 + const picColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "picName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구매 담당자" /> + ), + cell: ({ row }) => { + const picName = row.original.picName; + return picName ? ( + <span>{picName}</span> + ) : ( + <span className="text-muted-foreground">미배정</span> + ); + }, + }; + + const engPicColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "engPicName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설계 담당자" /> + ), + cell: ({ row }) => { + const picName = row.original.engPicName; + return picName ? ( + <span>{picName}</span> + ) : ( + <span className="text-muted-foreground">미배정</span> + ); + }, + }; + + + const pjtCompanyColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "projectCompany", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 Company" /> + ), + cell: ({ row }) => { + const projectCompany = row.original.projectCompany; + return projectCompany ? ( + <span>{projectCompany}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + }; + + const pjtFlagColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "projectFlag", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 Flag" /> + ), + cell: ({ row }) => { + const projectFlag = row.original.projectFlag; + return projectFlag ? ( + <span>{projectFlag}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + }; + + + const pjtSiteColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "projectSite", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 Site" /> + ), + cell: ({ row }) => { + const projectSite = row.original.projectSite; + return projectSite ? ( + <span>{projectSite}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + }; + const remarkColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "remark", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="비고" /> + ), + cell: ({ row }) => { + const remark = row.original.remark; + return remark ? ( + <span>{remark}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + }; + + // 첨부파일 수 컬럼 + const attachmentColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "totalAttachments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="첨부파일" /> + ), + cell: ({ row }) => { + const count = row.original.totalAttachments; + return ( + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span>{count}</span> + </div> + ); + }, + }; + + // 벤더 현황 컬럼 + const vendorStatusColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "initialVendorCount", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="벤더 현황" /> + ), + cell: ({ row }) => { + const initial = row.original.initialVendorCount; + const final = row.original.finalVendorCount; + const initialRate = row.original.initialResponseRate; + const finalRate = row.original.finalResponseRate; + + return ( + <div className="flex flex-col gap-1 text-xs"> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">초기:</span> + <span>{initial}개사 ({initialRate}%)</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">최종:</span> + <span>{final}개사 ({finalRate}%)</span> + </div> + </div> + ); + }, + }; + + // 생성일 컬럼 + const createdAtColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + cell: ({ row }) => { + const dateVal = row.original.createdAt as Date; + return formatDate(dateVal, 'KR'); + }, + }; + + const updatedAtColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일" /> + ), + cell: ({ row }) => { + const dateVal = row.original.updatedAt as Date; + return formatDate(dateVal, 'KR'); + }, + }; + + // Actions 컬럼 + const actionsColumn: ColumnDef<RfqDashboardView> = { + id: "detail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상세내용" /> + ), + // enableHiding: false, + cell: function Cell({ row }) { + const rfq = row.original; + const detailUrl = `/b-rfq/${rfq.rfqId}/initial`; + + return ( + + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + onClick={() => router.push(detailUrl)} + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + ); + }, + size: 40, + }; + + return [ + selectColumn, + rfqCodeColumn, + projectColumn, + packageColumn, + statusColumn, + picColumn, + progressColumn, + dueDateColumn, + actionsColumn, + + engPicColumn, + + pjtCompanyColumn, + pjtFlagColumn, + pjtSiteColumn, + + attachmentColumn, + vendorStatusColumn, + createdAtColumn, + + updatedAtColumn, + updatedColumn, + remarkColumn + ]; +}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx b/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx new file mode 100644 index 00000000..ff3bc132 --- /dev/null +++ b/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx @@ -0,0 +1,617 @@ +"use client" + +import { useEffect, useTransition, useState, useRef } from "react" +import { useRouter, useParams } from "next/navigation" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Search, X } from "lucide-react" +import { customAlphabet } from "nanoid" +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { getFiltersStateParser } from "@/lib/parsers" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// RFQ 필터 스키마 정의 +const rfqFilterSchema = z.object({ + rfqCode: z.string().optional(), + projectCode: z.string().optional(), + picName: z.string().optional(), + packageNo: z.string().optional(), + packageName: z.string().optional(), + status: z.string().optional(), +}) + +// RFQ 상태 옵션 정의 +const rfqStatusOptions = [ + { value: "DRAFT", label: "초안" }, + { value: "Doc. Received", label: "문서접수" }, + { value: "PIC Assigned", label: "담당자배정" }, + { value: "Doc. Confirmed", label: "문서확인" }, + { value: "Init. RFQ Sent", label: "초기RFQ발송" }, + { value: "Init. RFQ Answered", label: "초기RFQ회신" }, + { value: "TBE started", label: "TBE시작" }, + { value: "TBE finished", label: "TBE완료" }, + { value: "Final RFQ Sent", label: "최종RFQ발송" }, + { value: "Quotation Received", label: "견적접수" }, + { value: "Vendor Selected", label: "업체선정" }, +] + +type RFQFilterFormValues = z.infer<typeof rfqFilterSchema> + +interface RFQFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +export function RFQFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: RFQFilterSheetProps) { + const router = useRouter() + const params = useParams(); + const lng = params ? (params.lng as string) : 'ko'; + + const [isPending, startTransition] = useTransition() + + // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 + const [isInitializing, setIsInitializing] = useState(false) + // 마지막으로 적용된 필터를 추적하기 위한 ref + const lastAppliedFilters = useRef<string>("") + + // nuqs로 URL 상태 관리 + const [filters, setFilters] = useQueryState( + "basicFilters", + getFiltersStateParser().withDefault([]) + ) + + // joinOperator 설정 + const [joinOperator, setJoinOperator] = useQueryState( + "basicJoinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 현재 URL의 페이지 파라미터도 가져옴 + const [page, setPage] = useQueryState("page", { defaultValue: "1" }) + + // 폼 상태 초기화 + const form = useForm<RFQFilterFormValues>({ + resolver: zodResolver(rfqFilterSchema), + defaultValues: { + rfqCode: "", + projectCode: "", + picName: "", + packageNo: "", + packageName: "", + status: "", + }, + }) + + // URL 필터에서 초기 폼 상태 설정 + useEffect(() => { + // 현재 필터를 문자열로 직렬화 + const currentFiltersString = JSON.stringify(filters); + + // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 + if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { + setIsInitializing(true); + + const formValues = { ...form.getValues() }; + let formUpdated = false; + + filters.forEach(filter => { + if (filter.id in formValues) { + // @ts-ignore - 동적 필드 접근 + formValues[filter.id] = filter.value; + formUpdated = true; + } + }); + + // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 + if (formUpdated) { + form.reset(formValues); + lastAppliedFilters.current = currentFiltersString; + } + + setIsInitializing(false); + } + }, [filters, isOpen]) + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + + // 폼 제출 핸들러 + async function onSubmit(data: RFQFilterFormValues) { + // 초기화 중이면 제출 방지 + if (isInitializing) return; + + startTransition(async () => { + try { + // 필터 배열 생성 + const newFilters = [] + + if (data.rfqCode?.trim()) { + newFilters.push({ + id: "rfqCode", + value: data.rfqCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.projectCode?.trim()) { + newFilters.push({ + id: "projectCode", + value: data.projectCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.picName?.trim()) { + newFilters.push({ + id: "picName", + value: data.picName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.packageNo?.trim()) { + newFilters.push({ + id: "packageNo", + value: data.packageNo.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.packageName?.trim()) { + newFilters.push({ + id: "packageName", + value: data.packageName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // 수동으로 URL 업데이트 (nuqs 대신) + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 기존 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('rfqBasicFilters'); + params.delete('basicJoinOperator'); + params.delete('rfqBasicJoinOperator'); + params.delete('page'); + + // 새로운 필터 추가 + if (newFilters.length > 0) { + params.set('basicFilters', JSON.stringify(newFilters)); + params.set('basicJoinOperator', joinOperator); + } + + // 페이지를 1로 설정 + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("New RFQ Filter URL:", newUrl); + + // 페이지 완전 새로고침으로 서버 렌더링 강제 + window.location.href = newUrl; + + // 마지막 적용된 필터 업데이트 + lastAppliedFilters.current = JSON.stringify(newFilters); + + // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) + if (onSearch) { + console.log("Calling RFQ onSearch..."); + onSearch(); + } + + console.log("=== RFQ Filter Submit Complete ==="); + } catch (error) { + console.error("RFQ 필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + rfqCode: "", + projectCode: "", + picName: "", + packageNo: "", + packageName: "", + status: "", + }); + + console.log("=== RFQ Filter Reset Debug ==="); + console.log("Current URL before reset:", window.location.href); + + // 수동으로 URL 초기화 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('rfqBasicFilters'); + params.delete('basicJoinOperator'); + params.delete('rfqBasicJoinOperator'); + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("Reset URL:", newUrl); + + // 페이지 완전 새로고침 + window.location.href = newUrl; + + // 마지막 적용된 필터 초기화 + lastAppliedFilters.current = ""; + + console.log("RFQ 필터 초기화 완료"); + setIsInitializing(false); + } catch (error) { + console.error("RFQ 필터 초기화 오류:", error); + setIsInitializing(false); + } + } + + // Don't render if not open (for side panel use) + if (!isOpen) { + return null; + } + + return ( + <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> + {/* Filter Panel Header */} + <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> + <h3 className="text-lg font-semibold whitespace-nowrap">RFQ 검색 필터</h3> + <div className="flex items-center gap-2"> + {getActiveFilterCount() > 0 && ( + <Badge variant="secondary" className="px-2 py-1"> + {getActiveFilterCount()}개 필터 적용됨 + </Badge> + )} + </div> + </div> + + {/* Join Operator Selection */} + <div className="px-6 shrink-0"> + <label className="text-sm font-medium">조건 결합 방식</label> + <Select + value={joinOperator} + onValueChange={(value: "and" | "or") => setJoinOperator(value)} + disabled={isInitializing} + > + <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectValue placeholder="조건 결합 방식" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> + <SelectItem value="or">하나라도 충족 (OR)</SelectItem> + </SelectContent> + </Select> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> + {/* Scrollable content area */} + <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> + <div className="space-y-4 pt-2"> + + {/* RFQ 코드 */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ 코드</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="RFQ 코드 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("rfqCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트 코드 */} + <FormField + control={form.control} + name="projectCode" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 코드</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="프로젝트 코드 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("projectCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 담당자명 */} + <FormField + control={form.control} + name="picName" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자명</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="담당자명 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("picName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지 번호 */} + <FormField + control={form.control} + name="packageNo" + render={({ field }) => ( + <FormItem> + <FormLabel>패키지 번호</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="패키지 번호 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("packageNo", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지명 */} + <FormField + control={form.control} + name="packageName" + render={({ field }) => ( + <FormItem> + <FormLabel>패키지명</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="패키지명 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("packageName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* RFQ 상태 */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ 상태</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="RFQ 상태 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("status", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {rfqStatusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* Fixed buttons at bottom */} + <div className="p-4 shrink-0"> + <div className="flex gap-2 justify-end"> + <Button + type="button" + variant="outline" + onClick={handleReset} + disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + className="px-4" + > + 초기화 + </Button> + <Button + type="submit" + variant="samsung" + disabled={isPending || isLoading || isInitializing} + className="px-4" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? "조회 중..." : "조회"} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx b/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx new file mode 100644 index 00000000..8ba95ce6 --- /dev/null +++ b/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx @@ -0,0 +1,68 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, FileText, Mail, Search } from "lucide-react" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { RfqDashboardView } from "@/db/schema" +import { CreateRfqDialog } from "./add-new-rfq-dialog" + +interface RFQTableToolbarActionsProps { + table: Table<RfqDashboardView> +} + +export function RFQTableToolbarActions({ table }: RFQTableToolbarActionsProps) { + const router = useRouter() + + // 선택된 행 정보 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedCount = selectedRows.length + const isSingleSelected = selectedCount === 1 + + // RFQ 문서 확인 핸들러 + const handleDocumentCheck = () => { + if (isSingleSelected) { + const selectedRfq = selectedRows[0].original + const rfqId = selectedRfq.rfqId + + // RFQ 첨부문서 확인 페이지로 이동 + router.push(`/b-rfq/${rfqId}`) + } + } + + // 테이블 새로고침 핸들러 + const handleRefresh = () => { + // 페이지 새로고침 또는 데이터 다시 fetch + router.refresh() + } + + return ( + <div className="flex items-center gap-2"> + {/* 새 RFQ 생성 다이얼로그 */} + <CreateRfqDialog onSuccess={handleRefresh} /> + + {/* RFQ 문서 확인 버튼 - 단일 선택시만 활성화 */} + <Button + size="sm" + variant="outline" + onClick={handleDocumentCheck} + disabled={!isSingleSelected} + className="flex items-center" + > + <Search className="mr-2 h-4 w-4" /> + RFQ 문서 확인 + </Button> + + + </div> + ) +} diff --git a/lib/b-rfq/summary-table/summary-rfq-table.tsx b/lib/b-rfq/summary-table/summary-rfq-table.tsx new file mode 100644 index 00000000..83d50685 --- /dev/null +++ b/lib/b-rfq/summary-table/summary-rfq-table.tsx @@ -0,0 +1,285 @@ +"use client" + +import * as React from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +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 { getRFQDashboard } from "../service" +import { cn } from "@/lib/utils" +import { useTablePresets } from "@/components/data-table/use-table-presets" +import { TablePresetManager } from "@/components/data-table/data-table-preset" +import { useMemo } from "react" +import { getRFQColumns } from "./summary-rfq-columns" +import { RfqDashboardView } from "@/db/schema" +import { RFQTableToolbarActions } from "./summary-rfq-table-toolbar-actions" +import { RFQFilterSheet } from "./summary-rfq-filter-sheet" + +interface RFQDashboardTableProps { + promises: Promise<[Awaited<ReturnType<typeof getRFQDashboard>>]> + className?: string +} + +export function RFQDashboardTable({ promises, className }: RFQDashboardTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDashboardView> | null>(null) + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + + const router = useRouter() + const searchParams = useSearchParams() + + const containerRef = React.useRef<HTMLDivElement>(null) + const [containerTop, setContainerTop] = React.useState(0) + + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setContainerTop(rect.top) + } + }, []) + + React.useEffect(() => { + updateContainerBounds() + + const handleResize = () => { + updateContainerBounds() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', updateContainerBounds) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', updateContainerBounds) + } + }, [updateContainerBounds]) + + const [promiseData] = React.use(promises) + const tableData = promiseData + + console.log("RFQ Dashboard Table Data:", { + dataLength: tableData.data?.length, + pageCount: tableData.pageCount, + total: tableData.total, + sampleData: tableData.data?.[0] + }) + + const initialSettings = React.useMemo(() => ({ + page: parseInt(searchParams.get('page') || '1'), + perPage: parseInt(searchParams.get('perPage') || '10'), + sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }], + filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], + joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", + basicFilters: searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters') ? + JSON.parse(searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters')!) : [], + basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", + search: searchParams.get('search') || '', + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, + groupBy: [], + expandedRows: [] + }), [searchParams]) + + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + updateClientState, + getCurrentSettings, + } = useTablePresets<RfqDashboardView>('rfq-dashboard-table', initialSettings) + + const columns = React.useMemo( + () => getRFQColumns({ setRowAction, router }), + [setRowAction, router] + ) + + const filterFields: DataTableFilterField<RfqDashboardView>[] = [ + { id: "rfqCode", label: "RFQ 코드" }, + { id: "projectName", label: "프로젝트" }, + { id: "status", label: "상태" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<RfqDashboardView>[] = [ + { id: "rfqCode", label: "RFQ 코드", type: "text" }, + { id: "description", label: "설명", type: "text" }, + { id: "projectName", label: "프로젝트명", type: "text" }, + { id: "projectCode", label: "프로젝트 코드", type: "text" }, + { id: "packageNo", label: "패키지 번호", type: "text" }, + { id: "packageName", label: "패키지명", type: "text" }, + { id: "picName", label: "담당자", type: "text" }, + { id: "status", label: "상태", type: "select", options: [ + { label: "초안", value: "DRAFT" }, + { label: "문서접수", value: "Doc. Received" }, + { label: "담당자배정", value: "PIC Assigned" }, + { label: "문서확인", value: "Doc. Confirmed" }, + { label: "초기RFQ발송", value: "Init. RFQ Sent" }, + { label: "초기RFQ회신", value: "Init. RFQ Answered" }, + { label: "TBE시작", value: "TBE started" }, + { label: "TBE완료", value: "TBE finished" }, + { label: "최종RFQ발송", value: "Final RFQ Sent" }, + { label: "견적접수", value: "Quotation Received" }, + { label: "업체선정", value: "Vendor Selected" }, + ]}, + { id: "overallProgress", label: "진행률", type: "number" }, + { id: "dueDate", label: "마감일", type: "date" }, + { id: "createdAt", label: "생성일", type: "date" }, + ] + + const currentSettings = useMemo(() => { + return getCurrentSettings() + }, [getCurrentSettings]) + + const initialState = useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (originalRow) => String(originalRow.rfqId), + shallow: false, + clearOnDefault: true, + }) + + const handleSearch = () => { + setIsFilterPanelOpen(false) + } + + const getActiveBasicFilterCount = () => { + try { + const basicFilters = searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + return 0 + } + } + + const FILTER_PANEL_WIDTH = 400; + + return ( + <> + {/* Filter Panel */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + <div className="h-full"> + <RFQFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> + </div> + </div> + + {/* Main Content Container */} + <div + ref={containerRef} + className={cn("relative w-full overflow-hidden", className)} + > + <div className="flex w-full h-full"> + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' + }} + > + {/* Header Bar */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <div className="flex items-center gap-3"> + <Button + variant="outline" + size="sm" + type='button' + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + </div> + + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || tableData.data.length}건</span> + )} + </div> + </div> + + {/* Table Content Area */} + <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}> + <div className="h-full w-full"> + <DataTable table={table} className="h-full"> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <TablePresetManager<RfqDashboardView> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <RFQTableToolbarActions table={table} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </div> + </div> + </div> + </div> + </div> + </> + ) +}
\ No newline at end of file |
