summaryrefslogtreecommitdiff
path: root/lib/rfq-last
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-04 08:31:31 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-04 08:31:31 +0000
commitb67e36df49f067cbd5ba899f9fbcc755f38d4b4f (patch)
tree5a71c5960f90d988cd509e3ef26bff497a277661 /lib/rfq-last
parentb7f54b06c1ef9e619f5358fb0a5caad9703c8905 (diff)
(대표님, 최겸, 임수민) 작업사항 커밋
Diffstat (limited to 'lib/rfq-last')
-rw-r--r--lib/rfq-last/attachment/add-attachment-dialog.tsx365
-rw-r--r--lib/rfq-last/attachment/delete-attachments-dialog.tsx117
-rw-r--r--lib/rfq-last/attachment/rfq-attachments-table.tsx539
-rw-r--r--lib/rfq-last/attachment/update-revision-dialog.tsx216
-rw-r--r--lib/rfq-last/service.ts91
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx175
-rw-r--r--lib/rfq-last/table/rfq-table.tsx10
-rw-r--r--lib/rfq-last/validations.ts104
8 files changed, 1492 insertions, 125 deletions
diff --git a/lib/rfq-last/attachment/add-attachment-dialog.tsx b/lib/rfq-last/attachment/add-attachment-dialog.tsx
new file mode 100644
index 00000000..14baf7c7
--- /dev/null
+++ b/lib/rfq-last/attachment/add-attachment-dialog.tsx
@@ -0,0 +1,365 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Plus, X, FileIcon } from "lucide-react"
+import { toast } from "sonner"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Progress } from "@/components/ui/progress"
+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 { useRouter } from "next/navigation";
+
+const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
+const MAX_FILES = 10; // 최대 10개 파일
+
+const addAttachmentSchema = z.object({
+ description: z.string().optional(),
+ files: z
+ .array(z.instanceof(File))
+ .min(1, "최소 1개 이상의 파일을 선택해주세요.")
+ .max(MAX_FILES, `최대 ${MAX_FILES}개까지 업로드 가능합니다.`)
+ .refine(
+ (files) => files.every((file) => file.size <= MAX_FILE_SIZE),
+ `각 파일 크기는 100MB를 초과할 수 없습니다.`
+ ),
+})
+
+type AddAttachmentFormData = z.infer<typeof addAttachmentSchema>
+
+interface AddAttachmentDialogProps {
+ rfqId: number;
+ attachmentType: "구매" | "설계";
+ onSuccess?: () => void;
+}
+
+export function AddAttachmentDialog({
+ rfqId,
+ attachmentType,
+ onSuccess
+}: AddAttachmentDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [uploadProgress, setUploadProgress] = React.useState(0)
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+ const router = useRouter();
+
+ const form = useForm<AddAttachmentFormData>({
+ resolver: zodResolver(addAttachmentSchema),
+ defaultValues: {
+ description: "",
+ files: [],
+ },
+ })
+
+ // 파일 크기 포맷팅 함수
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+ };
+
+ // 파일 확장자 가져오기
+ const getFileExtension = (fileName: string) => {
+ return fileName.split('.').pop()?.toUpperCase() || 'FILE';
+ };
+
+ // 파일 추가 처리
+ const handleFilesAdd = (newFiles: FileList | null) => {
+ if (!newFiles) return;
+
+ const filesArray = Array.from(newFiles);
+ const totalFiles = selectedFiles.length + filesArray.length;
+
+ if (totalFiles > MAX_FILES) {
+ toast.error(`최대 ${MAX_FILES}개까지만 업로드할 수 있습니다.`);
+ return;
+ }
+
+ // 파일 크기 체크
+ const oversizedFiles = filesArray.filter(file => file.size > MAX_FILE_SIZE);
+ if (oversizedFiles.length > 0) {
+ toast.error(`다음 파일들이 100MB를 초과합니다: ${oversizedFiles.map(f => f.name).join(", ")}`);
+ return;
+ }
+
+ const updatedFiles = [...selectedFiles, ...filesArray];
+ setSelectedFiles(updatedFiles);
+ form.setValue("files", updatedFiles, { shouldValidate: true });
+ };
+
+
+ const onSubmit = async (data: AddAttachmentFormData) => {
+ setIsSubmitting(true);
+ setUploadProgress(0);
+
+ try {
+ const formData = new FormData();
+ formData.append("rfqId", rfqId.toString());
+ formData.append("attachmentType", attachmentType);
+ formData.append("description", data.description || "");
+
+ // 모든 파일 추가
+ data.files.forEach((file) => {
+ formData.append("files", file);
+ });
+
+ // 진행률 시뮬레이션
+ const progressInterval = setInterval(() => {
+ setUploadProgress((prev) => {
+ if (prev >= 90) {
+ clearInterval(progressInterval);
+ return 90;
+ }
+ return prev + 10;
+ });
+ }, 200);
+
+ const response = await fetch("/api/rfq-attachments/upload", {
+ method: "POST",
+ body: formData,
+ });
+
+ clearInterval(progressInterval);
+ setUploadProgress(100);
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || "파일 업로드 실패");
+ }
+
+ const result = await response.json();
+
+ if (result.success) {
+ toast.success(`${result.uploadedCount}개 파일이 성공적으로 업로드되었습니다`);
+ form.reset();
+ setSelectedFiles([]);
+ setOpen(false);
+
+ router.refresh()
+ onSuccess?.();
+ } else {
+ toast.error(result.message);
+ }
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ setUploadProgress(0);
+ }
+ };
+
+ // 다이얼로그 닫을 때 상태 초기화
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ form.reset();
+ setSelectedFiles([]);
+ setUploadProgress(0);
+ }
+ setOpen(newOpen);
+ };
+
+ const handleDropAccepted = (acceptedFiles: File[]) => {
+ const newFiles = [...selectedFiles, ...acceptedFiles]
+ setSelectedFiles(newFiles)
+ form.setValue('files', newFiles, { shouldValidate: true })
+ }
+
+ const removeFile = (index: number) => {
+ const updatedFiles = [...selectedFiles]
+ updatedFiles.splice(index, 1)
+ setSelectedFiles(updatedFiles)
+ form.setValue('files', updatedFiles, { shouldValidate: true })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button size="sm">
+ <Plus className="h-4 w-4 mr-2" />
+ 파일 업로드
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>새 첨부파일 추가</DialogTitle>
+ <DialogDescription>
+ {attachmentType} 문서를 업로드합니다. (파일당 최대 100MB, 최대 {MAX_FILES}개)
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
+ <div className="flex-1 overflow-y-auto px-1">
+ <div className="space-y-4 py-4">
+ <FormField
+ control={form.control}
+ name="files"
+ render={() => (
+ <FormItem>
+ <FormLabel>파일 첨부</FormLabel>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE} // 3GB
+ multiple={true}
+ onDropAccepted={handleDropAccepted}
+ disabled={isSubmitting}
+ >
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 또는 클릭하여 파일을 선택하세요
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ </Dropzone>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선택된 파일 목록 */}
+ {selectedFiles.length > 0 && (
+ <div className="space-y-2">
+ <FileList>
+ <FileListHeader>
+ <div className="flex justify-between items-center">
+ <span className="text-sm font-medium">
+ 선택된 파일 ({selectedFiles.length}/{MAX_FILES})
+ </span>
+ <span className="text-sm text-muted-foreground">
+ 총 {formatFileSize(selectedFiles.reduce((acc, file) => acc + file.size, 0))}
+ </span>
+ </div>
+ </FileListHeader>
+ {selectedFiles.map((file, index) => (
+ <FileListItem key={index}>
+ <FileListIcon>
+ <FileIcon className="h-4 w-4" />
+ </FileListIcon>
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>
+ {getFileExtension(file.name)} • {formatFileSize(file.size)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => removeFile(index)}
+ disabled={isSubmitting}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ )}
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명 (선택)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="첨부파일에 대한 설명을 입력하세요"
+ className="resize-none"
+ rows={3}
+ disabled={isSubmitting}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {isSubmitting && (
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>업로드 진행중...</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} />
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => handleOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || selectedFiles.length === 0}
+ >
+ {isSubmitting
+ ? `업로드 중... (${uploadProgress}%)`
+ : `${selectedFiles.length}개 파일 업로드`
+ }
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/attachment/delete-attachments-dialog.tsx b/lib/rfq-last/attachment/delete-attachments-dialog.tsx
new file mode 100644
index 00000000..c9041639
--- /dev/null
+++ b/lib/rfq-last/attachment/delete-attachments-dialog.tsx
@@ -0,0 +1,117 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Trash2 } from "lucide-react"
+import { toast } from "sonner"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+
+interface DeleteAttachmentsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ attachments: Array<{
+ id: number;
+ originalFileName?: string | null;
+ serialNo?: string | null;
+ }>;
+ onSuccess?: () => void;
+}
+
+export function DeleteAttachmentsDialog({
+ open,
+ onOpenChange,
+ attachments,
+ onSuccess,
+}: DeleteAttachmentsDialogProps) {
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ async function onDelete() {
+ setIsDeleting(true);
+
+ try {
+ // 여러 개 삭제 시 병렬 처리
+ const deletePromises = attachments.map(async (attachment) => {
+ const response = await fetch(`/api/rfq-attachments/${attachment.id}`, {
+ method: "DELETE",
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || "삭제 실패");
+ }
+
+ return response.json();
+ });
+
+ const results = await Promise.allSettled(deletePromises);
+
+ const failures = results.filter(r => r.status === 'rejected');
+ if (failures.length > 0) {
+ const firstError = failures[0];
+ if (firstError.status === 'rejected') {
+ toast.error(`일부 파일 삭제 실패: ${firstError.reason}`);
+ }
+ return;
+ }
+
+ onOpenChange(false);
+ toast.success(`${attachments.length}개 파일이 삭제되었습니다`);
+ onSuccess?.();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "파일 삭제 중 오류가 발생했습니다");
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>파일을 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{attachments.length}개</span>의 파일이
+ 영구적으로 삭제됩니다.
+ {attachments[0]?.originalFileName && (
+ <div className="mt-2 p-2 bg-muted rounded text-sm">
+ {attachments[0].originalFileName}
+ {attachments[0].serialNo && (
+ <span className="text-muted-foreground"> ({attachments[0].serialNo})</span>
+ )}
+ </div>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline" disabled={isDeleting}>
+ 취소
+ </Button>
+ </DialogClose>
+ <Button
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeleting}
+ >
+ {isDeleting && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx
new file mode 100644
index 00000000..a66e12a2
--- /dev/null
+++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx
@@ -0,0 +1,539 @@
+"use client";
+
+import * as React from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import {
+ Download,
+ FileText,
+ Upload,
+ RefreshCw,
+ Eye,
+ Trash2,
+ History,
+ Plus,
+ File,
+ FileImage,
+ FileSpreadsheet,
+ FileCode
+} from "lucide-react";
+import { format, formatDistanceToNow } from "date-fns";
+import { ko } from "date-fns/locale";
+import { type ColumnDef } from "@tanstack/react-table";
+import { Checkbox } from "@/components/ui/checkbox";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table";
+import { cn } from "@/lib/utils";
+import { getRfqLastAttachments } from "@/lib/rfq-last/service";
+import { downloadFile } from "@/lib/file-download";
+import { DeleteAttachmentsDialog } from "./delete-attachments-dialog";
+import { AddAttachmentDialog } from "./add-attachment-dialog";
+import { UpdateRevisionDialog } from "./update-revision-dialog";
+import { useQueryState ,parseAsStringEnum} from "nuqs";
+
+// 타입 정의
+interface RfqAttachment {
+ id: number;
+ attachmentType: "설계" | "구매";
+ serialNo: string | null;
+ rfqId: number;
+ currentRevision: string | null;
+ latestRevisionId: number | null;
+ description: string | null;
+ createdBy: number;
+ createdAt: Date;
+ updatedAt: Date;
+ fileName: string | null;
+ originalFileName: string | null;
+ filePath: string | null;
+ fileSize: number | null;
+ fileType: string | null;
+ revisionComment: string | null;
+ createdByName: string | null;
+}
+
+interface RfqAttachmentsTableProps {
+ rfqId: number;
+ initialDesignData: Awaited<ReturnType<typeof getRfqLastAttachments>>;
+ initialPurchaseData: Awaited<ReturnType<typeof getRfqLastAttachments>>;
+ className?: string;
+}
+
+// 파일 타입별 아이콘 반환
+const getFileIcon = (fileType: string | null) => {
+ if (!fileType) return <File className="h-4 w-4" />;
+
+ const type = fileType.toLowerCase();
+ if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) {
+ return <FileImage className="h-4 w-4 text-blue-500" />;
+ }
+ if (type.includes('excel') || type.includes('spreadsheet') || ['xls', 'xlsx'].includes(type)) {
+ return <FileSpreadsheet className="h-4 w-4 text-green-500" />;
+ }
+ if (type.includes('pdf')) {
+ return <FileText className="h-4 w-4 text-red-500" />;
+ }
+ if (type.includes('code') || ['js', 'ts', 'tsx', 'jsx', 'html', 'css'].includes(type)) {
+ return <FileCode className="h-4 w-4 text-purple-500" />;
+ }
+ return <File className="h-4 w-4 text-gray-500" />;
+};
+
+// 파일 크기 포맷팅
+const formatFileSize = (bytes: number | null) => {
+ if (!bytes) return "-";
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
+};
+
+export function RfqAttachmentsTable({
+ rfqId,
+ initialDesignData,
+ initialPurchaseData,
+ className
+}: RfqAttachmentsTableProps) {
+ const router = useRouter();
+ const [activeTab, setActiveTab] = useQueryState(
+ 'tab',
+ parseAsStringEnum(['설계', '구매'])
+ .withDefault('설계')
+ .withOptions({ shallow: false })
+ );
+
+ const [designData] = React.useState(initialDesignData);
+ const [purchaseData] = React.useState(initialPurchaseData);
+ const [selectedAttachment, setSelectedAttachment] = React.useState<RfqAttachment | null>(null);
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
+ const [updateRevisionDialogOpen, setUpdateRevisionDialogOpen] = React.useState(false);
+ const [isRefreshing, setIsRefreshing] = React.useState(false);
+
+ // 새로고침 (router.refresh 사용)
+ const handleRefresh = React.useCallback(() => {
+ setIsRefreshing(true);
+ router.refresh();
+ setTimeout(() => setIsRefreshing(false), 1000);
+ }, [router]);
+
+ // 액션 처리
+ const handleAction = React.useCallback(async (action: DataTableRowAction<RfqAttachment>) => {
+ const attachment = action.row.original;
+
+ switch (action.type) {
+ case "download":
+ if (attachment.filePath && attachment.originalFileName) {
+ await downloadFile(attachment.filePath, attachment.originalFileName, {
+ action: 'download',
+ showToast: true
+ });
+ }
+ break;
+
+ case "preview":
+ if (attachment.filePath && attachment.originalFileName) {
+ await downloadFile(attachment.filePath, attachment.originalFileName, {
+ action: 'preview',
+ showToast: true
+ });
+ }
+ break;
+
+ case "history":
+ // 리비전 이력 보기 - 별도 구현 필요
+ console.log("History:", attachment);
+ break;
+
+ case "update":
+ setSelectedAttachment(attachment);
+ setUpdateRevisionDialogOpen(true);
+ break;
+
+ case "delete":
+ setSelectedAttachment(attachment);
+ setDeleteDialogOpen(true);
+ break;
+ }
+ }, []);
+
+ // 컬럼 정의
+ const getAttachmentColumns = React.useCallback((
+ onAction: (action: DataTableRowAction<RfqAttachment>) => void
+ ): ColumnDef<RfqAttachment>[] => [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "serialNo",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="일련번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.serialNo || "-"}</span>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "originalFileName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="파일명" />,
+ cell: ({ row }) => {
+ const file = row.original;
+ return (
+ <div className="flex items-center gap-2">
+ {getFileIcon(file.fileType)}
+ <div className="flex flex-col">
+ <span className="text-sm font-medium truncate max-w-[250px]" title={file.originalFileName || ""}>
+ {file.originalFileName || file.fileName || "-"}
+ </span>
+ {file.fileName && file.fileName !== file.originalFileName && (
+ <span className="text-xs text-muted-foreground truncate max-w-[250px]">
+ ({file.fileName})
+ </span>
+ )}
+ </div>
+ </div>
+ );
+ },
+ size: 300,
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설명" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.description || ""}>
+ {row.original.description || "-"}
+ </div>
+ ),
+ size: 200,
+ },
+ {
+ accessorKey: "currentRevision",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리비전" />,
+ cell: ({ row }) => {
+ const revision = row.original.currentRevision;
+ return revision ? (
+ <Badge variant="outline" className="font-mono">
+ Rev. {revision}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "fileSize",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="크기" />,
+ cell: ({ row }) => (
+ <span className="text-sm text-muted-foreground">
+ {formatFileSize(row.original.fileSize)}
+ </span>
+ ),
+ size: 80,
+ },
+ {
+ accessorKey: "fileType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="타입" />,
+ cell: ({ row }) => {
+ const type = row.original.fileType;
+ return type ? (
+ <Badge variant="secondary" className="text-xs">
+ {type.toUpperCase()}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드자" />,
+ cell: ({ row }) => row.original.createdByName || "-",
+ size: 100,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드일" />,
+ cell: ({ row }) => {
+ const date = row.original.createdAt;
+ return date ? (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="text-sm cursor-help">
+ {format(new Date(date), "MM-dd HH:mm")}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{format(new Date(date), "yyyy년 MM월 dd일 HH시 mm분")}</p>
+ <p className="text-xs text-muted-foreground">
+ ({formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko })})
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ ) : (
+ "-"
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />,
+ cell: ({ row }) => {
+ const date = row.original.updatedAt;
+ return date ? format(new Date(date), "MM-dd HH:mm") : "-";
+ },
+ size: 100,
+ },
+ {
+ id: "actions",
+ header: "작업",
+ cell: ({ row }) => {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path>
+ </svg>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => onAction({ row, type: "download" })}>
+ <Download className="mr-2 h-4 w-4" />
+ 다운로드
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => onAction({ row, type: "preview" })}>
+ <Eye className="mr-2 h-4 w-4" />
+ 미리보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => onAction({ row, type: "history" })}>
+ <History className="mr-2 h-4 w-4" />
+ 리비전 이력
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => onAction({ row, type: "update" })}>
+ <Upload className="mr-2 h-4 w-4" />
+ 새 버전 업로드
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => onAction({ row, type: "delete" })}
+ className="text-red-600"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+ },
+ size: 60,
+ },
+ ], []);
+
+ const columns = React.useMemo(() => getAttachmentColumns(handleAction), [getAttachmentColumns, handleAction]);
+
+ const filterFields: DataTableFilterField<RfqAttachment>[] = [
+ { id: "serialNo", label: "일련번호" },
+ { id: "originalFileName", label: "파일명" },
+ { id: "description", label: "설명" },
+ { id: "createdByName", label: "업로드자" },
+ ];
+
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqAttachment>[] = [
+ { id: "serialNo", label: "일련번호", type: "text" },
+ { id: "originalFileName", label: "파일명", type: "text" },
+ { id: "description", label: "설명", type: "text" },
+ { id: "currentRevision", label: "리비전", type: "text" },
+ {
+ id: "fileType",
+ label: "파일 타입",
+ type: "select",
+ options: [
+ { label: "PDF", value: "pdf" },
+ { label: "Excel", value: "xlsx" },
+ { label: "Word", value: "docx" },
+ { label: "이미지", value: "image" },
+ { label: "기타", value: "other" },
+ ]
+ },
+ { id: "createdByName", label: "업로드자", type: "text" },
+ { id: "createdAt", label: "업로드일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ];
+
+ const { table: designTable } = useDataTable({
+ data: designData.data,
+ columns,
+ pageCount: designData.pageCount,
+ rowCount: designData.data.length,
+ filterFields,
+ enableAdvancedFilter: true,
+ // 설계 탭용 파라미터 prefix
+ paramPrefix: 'design_',
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ },
+ getRowId: (row) => String(row.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ const { table: purchaseTable } = useDataTable({
+ data: purchaseData.data,
+ columns,
+ pageCount: purchaseData.pageCount,
+ rowCount: purchaseData.data.length,
+ filterFields,
+ enableAdvancedFilter: true,
+ // 구매 탭용 파라미터 prefix
+ paramPrefix: 'purchase_',
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ },
+ getRowId: (row) => String(row.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+
+ React.useEffect(() => {
+ router.refresh();
+ }, [activeTab]);
+
+ return (
+ <div className={cn("w-full space-y-4", className)}>
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
+ <div className="flex items-center justify-between mb-4">
+ <TabsList>
+ <TabsTrigger value="설계">
+ 설계 첨부파일
+ <Badge variant="secondary" className="ml-2">
+ {designData.data.length}
+ </Badge>
+ </TabsTrigger>
+ <TabsTrigger value="구매">
+ 구매 첨부파일
+ <Badge variant="secondary" className="ml-2">
+ {purchaseData.data.length}
+ </Badge>
+ </TabsTrigger>
+ </TabsList>
+
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isRefreshing}
+ >
+ <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
+ 새로고침
+ </Button>
+
+ {/* 구매 탭에서만 파일 업로드 버튼 표시 */}
+ {activeTab === "구매" && (
+ <AddAttachmentDialog
+ rfqId={rfqId}
+ attachmentType="구매"
+ onSuccess={handleRefresh}
+ />
+ )}
+ </div>
+ </div>
+
+ <TabsContent value="설계" className="mt-0">
+ <Card>
+ <CardContent className="p-0">
+ <DataTable table={designTable}>
+ <DataTableAdvancedToolbar
+ table={designTable}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="구매" className="mt-0">
+ <Card>
+ <CardContent className="p-0">
+ <DataTable table={purchaseTable}>
+ <DataTableAdvancedToolbar
+ table={purchaseTable}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+
+ {/* 삭제 다이얼로그 */}
+ {selectedAttachment && (
+ <DeleteAttachmentsDialog
+ open={deleteDialogOpen}
+ onOpenChange={setDeleteDialogOpen}
+ attachments={[selectedAttachment]}
+ onSuccess={handleRefresh}
+ />
+ )}
+
+ {/* 새 버전 업로드 다이얼로그 */}
+ {selectedAttachment && (
+ <UpdateRevisionDialog
+ open={updateRevisionDialogOpen}
+ onOpenChange={setUpdateRevisionDialogOpen}
+ attachment={selectedAttachment}
+ onSuccess={handleRefresh}
+ />
+ )}
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/rfq-last/attachment/update-revision-dialog.tsx b/lib/rfq-last/attachment/update-revision-dialog.tsx
new file mode 100644
index 00000000..ce31da64
--- /dev/null
+++ b/lib/rfq-last/attachment/update-revision-dialog.tsx
@@ -0,0 +1,216 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Input } from "@/components/ui/input"
+import { Progress } from "@/components/ui/progress"
+
+const updateRevisionSchema = z.object({
+ revisionComment: z.string().min(1, "리비전 설명을 입력해주세요"),
+ file: z.instanceof(File, {
+ message: "새 버전 파일을 선택해주세요",
+ }).refine((file) => file.size <= 100 * 1024 * 1024, {
+ message: "파일 크기는 100MB를 초과할 수 없습니다.",
+ }),
+})
+
+type UpdateRevisionFormData = z.infer<typeof updateRevisionSchema>
+
+interface UpdateRevisionDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ attachment: {
+ id: number;
+ originalFileName?: string | null;
+ currentRevision?: string | null;
+ };
+ onSuccess?: () => void;
+}
+
+export function UpdateRevisionDialog({
+ open,
+ onOpenChange,
+ attachment,
+ onSuccess,
+}: UpdateRevisionDialogProps) {
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [uploadProgress, setUploadProgress] = React.useState(0)
+
+ const form = useForm<UpdateRevisionFormData>({
+ resolver: zodResolver(updateRevisionSchema),
+ defaultValues: {
+ revisionComment: "",
+ },
+ })
+
+ const onSubmit = async (data: UpdateRevisionFormData) => {
+ setIsSubmitting(true);
+ setUploadProgress(0);
+
+ try {
+ const formData = new FormData();
+ formData.append("attachmentId", attachment.id.toString());
+ formData.append("revisionComment", data.revisionComment);
+ formData.append("file", data.file);
+
+ // 진행률 시뮬레이션
+ setUploadProgress(30);
+
+ const response = await fetch("/api/rfq-attachments/revision", {
+ method: "POST",
+ body: formData,
+ });
+
+ setUploadProgress(70);
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || "리비전 업데이트 실패");
+ }
+
+ const result = await response.json();
+ setUploadProgress(100);
+
+ if (result.success) {
+ toast.success(result.message);
+ form.reset();
+ onOpenChange(false);
+ onSuccess?.();
+ } else {
+ toast.error(result.message);
+ }
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ setUploadProgress(0);
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>새 버전 업로드</DialogTitle>
+ <DialogDescription>
+ {attachment.originalFileName && (
+ <div className="mt-2">
+ <div className="text-sm font-medium">현재 파일:</div>
+ <div className="text-sm text-muted-foreground">
+ {attachment.originalFileName}
+ {attachment.currentRevision && ` (Rev. ${attachment.currentRevision})`}
+ </div>
+ </div>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="file"
+ render={({ field: { onChange, value, ...field } }) => (
+ <FormItem>
+ <FormLabel>새 버전 파일</FormLabel>
+ <FormControl>
+ <Input
+ type="file"
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.dwg,.dxf"
+ onChange={(e) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ onChange(file);
+ // 파일 크기 검증
+ if (file.size > 100 * 1024 * 1024) {
+ form.setError("file", {
+ message: "파일 크기는 100MB를 초과할 수 없습니다."
+ });
+ }
+ }
+ }}
+ disabled={isSubmitting}
+ {...field}
+ />
+ </FormControl>
+ {value && (
+ <p className="text-sm text-muted-foreground mt-1">
+ 선택된 파일: {value.name} ({(value.size / 1024 / 1024).toFixed(2)}MB)
+ </p>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="revisionComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>리비전 설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="변경사항을 설명해주세요"
+ className="resize-none"
+ rows={3}
+ disabled={isSubmitting}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {isSubmitting && (
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>업로드 진행중...</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} />
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "업로드 중..." : "업로드"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 0be8049b..ffeed1b1 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -167,14 +167,15 @@ export const findRfqLastById = async (id: number): Promise<RfqsLastView | null>
return rfq;
} catch (error) {
- throw new Error('Failed to fetch user');
+ throw new Error('Failed to fetch RFQ');
}
};
export async function getRfqLastAttachments(
input: GetRfqLastAttachmentsSchema,
- rfqId: number
+ rfqId: number,
+ attachmentType: "설계" | "구매"
) {
try {
const offset = (input.page - 1) * input.perPage
@@ -186,7 +187,7 @@ export async function getRfqLastAttachments(
joinOperator: input.joinOperator,
})
- // 전역 검색 (첨부파일 + 리비전 파일명 검색)
+ // 전역 검색
let globalWhere
if (input.search) {
const s = `%${input.search}%`
@@ -199,99 +200,41 @@ export async function getRfqLastAttachments(
)
}
- // 기본 필터
- let basicWhere
- if (input.attachmentType.length > 0 || input.fileType.length > 0) {
- basicWhere = and(
- input.attachmentType.length > 0
- ? inArray(rfqLastAttachments.attachmentType, input.attachmentType)
- : undefined,
- input.fileType.length > 0
- ? inArray(rfqLastAttachmentRevisions.fileType, input.fileType)
- : undefined
- )
+ // 파일 타입 필터
+ let fileTypeWhere
+ if (input.fileType && input.fileType.length > 0) {
+ fileTypeWhere = inArray(rfqLastAttachmentRevisions.fileType, input.fileType)
}
// 최종 WHERE 절
const finalWhere = and(
- eq(rfqLastAttachments.rfqId, rfqId), // RFQ ID 필수 조건
+ eq(rfqLastAttachments.rfqId, rfqId),
+ eq(rfqLastAttachments.attachmentType, attachmentType),
advancedWhere,
globalWhere,
- basicWhere
+ fileTypeWhere
)
- // 정렬 (메인 테이블 기준)
+ // 정렬
const orderBy = input.sort.length > 0
? input.sort.map((item) =>
- item.desc ? desc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) : asc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments])
+ item.desc
+ ? desc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments])
+ : asc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments])
)
: [desc(rfqLastAttachments.createdAt)]
- // 트랜잭션으로 데이터 조회
+ // 데이터 조회 (기존 코드와 동일)
const { data, total } = await db.transaction(async (tx) => {
- // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인)
- const data = await tx
- .select({
- // 첨부파일 메인 정보
- id: rfqLastAttachments.id,
- attachmentType: rfqLastAttachments.attachmentType,
- serialNo: rfqLastAttachments.serialNo,
- rfqId: rfqLastAttachments.rfqId,
- currentRevision: rfqLastAttachments.currentRevision,
- latestRevisionId: rfqLastAttachments.latestRevisionId,
- description: rfqLastAttachments.description,
- createdBy: rfqLastAttachments.createdBy,
- createdAt: rfqLastAttachments.createdAt,
- updatedAt: rfqLastAttachments.updatedAt,
-
- // 최신 리비전 파일 정보
- fileName: rfqLastAttachmentRevisions.fileName,
- originalFileName: rfqLastAttachmentRevisions.originalFileName,
- filePath: rfqLastAttachmentRevisions.filePath,
- fileSize: rfqLastAttachmentRevisions.fileSize,
- fileType: rfqLastAttachmentRevisions.fileType,
- revisionComment: rfqLastAttachmentRevisions.revisionComment,
-
- // 생성자 정보
- createdByName: users.name,
- })
- .from(rfqLastAttachments)
- .leftJoin(
- rfqLastAttachmentRevisions,
- and(
- eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id),
- eq(rfqLastAttachmentRevisions.isLatest, true)
- )
- )
- .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id))
- .where(finalWhere)
- .orderBy(...orderBy)
- .limit(input.perPage)
- .offset(offset)
-
- // 전체 개수 조회
- const totalResult = await tx
- .select({ count: count() })
- .from(rfqLastAttachments)
- .leftJoin(
- rfqLastAttachmentRevisions,
- eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id)
- )
- .where(finalWhere)
-
- const total = totalResult[0]?.count ?? 0
-
- return { data, total }
+ // ... 기존 조회 로직
})
const pageCount = Math.ceil(total / input.perPage)
-
return { data, pageCount }
} catch (err) {
console.error("getRfqAttachments error:", err)
return { data: [], pageCount: 0 }
}
-
}
// 사용자 목록 조회 (필터용)
export async function getPUsersForFilter() {
diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx
index 1b523adc..5f5efcb4 100644
--- a/lib/rfq-last/table/rfq-table-columns.tsx
+++ b/lib/rfq-last/table/rfq-table-columns.tsx
@@ -5,7 +5,7 @@ import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Eye, FileText, Send, Lock, LockOpen } from "lucide-react";
+import { Eye, FileText, Send, Lock, LockOpen,Clock, AlertTriangle, CheckCircle, XCircle, AlertCircle } from "lucide-react";
import {
Tooltip,
TooltipContent,
@@ -15,7 +15,7 @@ import {
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { RfqsLastView } from "@/db/schema";
import { DataTableRowAction } from "@/types/table";
-import { format } from "date-fns";
+import { format, differenceInDays } from "date-fns";
import { ko } from "date-fns/locale";
import { useRouter } from "next/navigation";
@@ -187,15 +187,15 @@ export function getRfqColumns({
// 자재그룹 (자재그룹명)
{
- accessorKey: "itemName",
+ accessorKey: "majorItemMaterialDescription",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />,
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-mono text-xs text-muted-foreground">
- {row.original.itemCode}
+ {row.original.majorItemMaterialCategory}
</span>
- <span className="max-w-[150px] truncate" title={row.original.itemName || ""}>
- {row.original.itemName || "-"}
+ <span className="max-w-[150px] truncate" title={row.original.majorItemMaterialDescription || ""}>
+ {row.original.majorItemMaterialDescription || "-"}
</span>
</div>
),
@@ -258,15 +258,54 @@ export function getRfqColumns({
const now = new Date();
const dueDate = new Date(date);
- const isOverdue = now > dueDate;
+ const daysLeft = differenceInDays(dueDate, now);
+
+ // 상태별 스타일과 아이콘 설정
+ let statusIcon;
+ let statusText;
+ let statusClass;
+
+ if (daysLeft < 0) {
+ // 마감일 지남
+ const daysOverdue = Math.abs(daysLeft);
+ statusIcon = <XCircle className="h-4 w-4" />;
+ statusText = `${daysOverdue}일 지남`;
+ statusClass = "text-red-600";
+ } else if (daysLeft === 0) {
+ // 오늘 마감
+ statusIcon = <AlertTriangle className="h-4 w-4" />;
+ statusText = "오늘 마감";
+ statusClass = "text-orange-600";
+ } else if (daysLeft <= 3) {
+ // 3일 이내 마감 임박
+ statusIcon = <AlertCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-amber-600";
+ } else if (daysLeft <= 7) {
+ // 일주일 이내
+ statusIcon = <Clock className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-blue-600";
+ } else {
+ // 여유 있음
+ statusIcon = <CheckCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-green-600";
+ }
return (
- <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
- {format(dueDate, "yyyy-MM-dd")}
- </span>
+ <div className="flex flex-col gap-1">
+ <span className="text-sm text-muted-foreground">
+ {format(dueDate, "yyyy-MM-dd")}
+ </span>
+ <div className={`flex items-center gap-1 ${statusClass}`}>
+ {statusIcon}
+ <span className="text-xs font-medium">{statusText}</span>
+ </div>
+ </div>
);
},
- size: 100,
+ size: 120, // 크기를 약간 늘림
},
// 설계담당자
@@ -494,15 +533,15 @@ export function getRfqColumns({
// 자재그룹 (자재그룹명)
{
- accessorKey: "itemName",
+ accessorKey: "majorItemMaterialDescription",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />,
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-mono text-xs text-muted-foreground">
- {row.original.itemCode}
+ {row.original.majorItemMaterialCategory}
</span>
- <span className="max-w-[150px] truncate" title={row.original.itemName || ""}>
- {row.original.itemName || "-"}
+ <span className="max-w-[150px] truncate" title={row.original.majorItemMaterialDescription || ""}>
+ {row.original.majorItemMaterialDescription || "-"}
</span>
</div>
),
@@ -584,15 +623,54 @@ export function getRfqColumns({
const now = new Date();
const dueDate = new Date(date);
- const isOverdue = now > dueDate;
+ const daysLeft = differenceInDays(dueDate, now);
+
+ // 상태별 스타일과 아이콘 설정
+ let statusIcon;
+ let statusText;
+ let statusClass;
+
+ if (daysLeft < 0) {
+ // 마감일 지남
+ const daysOverdue = Math.abs(daysLeft);
+ statusIcon = <XCircle className="h-4 w-4" />;
+ statusText = `${daysOverdue}일 지남`;
+ statusClass = "text-red-600";
+ } else if (daysLeft === 0) {
+ // 오늘 마감
+ statusIcon = <AlertTriangle className="h-4 w-4" />;
+ statusText = "오늘 마감";
+ statusClass = "text-orange-600";
+ } else if (daysLeft <= 3) {
+ // 3일 이내 마감 임박
+ statusIcon = <AlertCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-amber-600";
+ } else if (daysLeft <= 7) {
+ // 일주일 이내
+ statusIcon = <Clock className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-blue-600";
+ } else {
+ // 여유 있음
+ statusIcon = <CheckCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-green-600";
+ }
return (
- <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
- {format(dueDate, "yyyy-MM-dd")}
- </span>
+ <div className="flex flex-col gap-1">
+ <span className="text-sm text-muted-foreground">
+ {format(dueDate, "yyyy-MM-dd")}
+ </span>
+ <div className={`flex items-center gap-1 ${statusClass}`}>
+ {statusIcon}
+ <span className="text-xs font-medium">{statusText}</span>
+ </div>
+ </div>
);
},
- size: 100,
+ size: 120, // 크기를 약간 늘림
},
// 설계담당자
@@ -812,15 +890,15 @@ export function getRfqColumns({
// 자재그룹 (자재그룹명)
{
- accessorKey: "itemName",
+ accessorKey: "majorItemMaterialDescription",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재그룹 (자재그룹명)" />,
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-mono text-xs text-muted-foreground">
- {row.original.itemCode}
+ {row.original.majorItemMaterialCategory}
</span>
- <span className="max-w-[150px] truncate" title={row.original.itemName || ""}>
- {row.original.itemName || "-"}
+ <span className="max-w-[150px] truncate" title={row.original.majorItemMaterialDescription || ""}>
+ {row.original.majorItemMaterialDescription || "-"}
</span>
</div>
),
@@ -902,15 +980,54 @@ export function getRfqColumns({
const now = new Date();
const dueDate = new Date(date);
- const isOverdue = now > dueDate;
+ const daysLeft = differenceInDays(dueDate, now);
+
+ // 상태별 스타일과 아이콘 설정
+ let statusIcon;
+ let statusText;
+ let statusClass;
+
+ if (daysLeft < 0) {
+ // 마감일 지남
+ const daysOverdue = Math.abs(daysLeft);
+ statusIcon = <XCircle className="h-4 w-4" />;
+ statusText = `${daysOverdue}일 지남`;
+ statusClass = "text-red-600";
+ } else if (daysLeft === 0) {
+ // 오늘 마감
+ statusIcon = <AlertTriangle className="h-4 w-4" />;
+ statusText = "오늘 마감";
+ statusClass = "text-orange-600";
+ } else if (daysLeft <= 3) {
+ // 3일 이내 마감 임박
+ statusIcon = <AlertCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-amber-600";
+ } else if (daysLeft <= 7) {
+ // 일주일 이내
+ statusIcon = <Clock className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-blue-600";
+ } else {
+ // 여유 있음
+ statusIcon = <CheckCircle className="h-4 w-4" />;
+ statusText = `${daysLeft}일 남음`;
+ statusClass = "text-green-600";
+ }
return (
- <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
- {format(dueDate, "yyyy-MM-dd")}
- </span>
+ <div className="flex flex-col gap-1">
+ <span className="text-sm text-muted-foreground">
+ {format(dueDate, "yyyy-MM-dd")}
+ </span>
+ <div className={`flex items-center gap-1 ${statusClass}`}>
+ {statusIcon}
+ <span className="text-xs font-medium">{statusText}</span>
+ </div>
+ </div>
);
},
- size: 100,
+ size: 120, // 크기를 약간 늘림
},
// 구매담당자
diff --git a/lib/rfq-last/table/rfq-table.tsx b/lib/rfq-last/table/rfq-table.tsx
index e8db116b..974662d9 100644
--- a/lib/rfq-last/table/rfq-table.tsx
+++ b/lib/rfq-last/table/rfq-table.tsx
@@ -271,16 +271,16 @@ export function RfqTable({
{ id: "vendorCount", label: "업체수", type: "number" },
{ id: "dueDate", label: "마감일", type: "date" },
{ id: "rfqSendDate", label: "발송일", type: "date" },
- ...(rfqCategory === "general" || rfqCategory === "all" ? [
+ ...(rfqCategory === "general" ? [
{ id: "rfqType", label: "견적 유형", type: "text" },
{ id: "rfqTitle", label: "견적 제목", type: "text" },
] as DataTableAdvancedFilterField<RfqsLastView>[] : []),
- ...(rfqCategory === "itb" || rfqCategory === "all" ? [
+ ...(rfqCategory === "itb" ? [
{ id: "projectCompany", label: "프로젝트 회사", type: "text" },
{ id: "projectSite", label: "프로젝트 사이트", type: "text" },
{ id: "smCode", label: "SM 코드", type: "text" },
] as DataTableAdvancedFilterField<RfqsLastView>[] : []),
- ...(rfqCategory === "rfq" || rfqCategory === "all" ? [
+ ...(rfqCategory === "rfq" ? [
{ id: "prNumber", label: "PR 번호", type: "text" },
{ id: "prIssueDate", label: "PR 발행일", type: "date" },
{
@@ -387,12 +387,12 @@ export function RfqTable({
)}
</Button>
- {rfqCategory !== "all" && (
+
<Badge variant="outline" className="text-sm">
{rfqCategory === "general" ? "일반견적" :
rfqCategory === "itb" ? "ITB" : "RFQ"}
</Badge>
- )}
+
</div>
<div className="flex items-center gap-4">
diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts
index b133433f..34110141 100644
--- a/lib/rfq-last/validations.ts
+++ b/lib/rfq-last/validations.ts
@@ -66,22 +66,92 @@ import { RfqLastAttachments } from "@/db/schema";
>;
- export const searchParamsRfqAttachmentsCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<RfqLastAttachments>().withDefault([
- { id: "createdAt", desc: true },
- ]),
- // 기본 필터
- attachmentType: parseAsArrayOf(z.string()).withDefault([]),
- fileType: parseAsArrayOf(z.string()).withDefault([]),
- search: parseAsString.withDefault(""),
- // advanced filter
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- })
+// 공통 탭 파라미터
+export const searchParamsRfqTabCache = createSearchParamsCache({
+ tab: parseAsStringEnum(['design', 'purchase']).withDefault('design'),
+})
+
+// 설계 탭 전용 파라미터
+export const searchParamsRfqDesignCache = createSearchParamsCache({
+ design_page: parseAsInteger.withDefault(1),
+ design_perPage: parseAsInteger.withDefault(10),
+ design_sort: getSortingStateParser<RfqLastAttachments>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ design_search: parseAsString.withDefault(""),
+ design_fileType: parseAsArrayOf(z.string()).withDefault([]),
+ design_filters: getFiltersStateParser().withDefault([]),
+ design_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+})
+
+// 구매 탭 전용 파라미터
+export const searchParamsRfqPurchaseCache = createSearchParamsCache({
+ purchase_page: parseAsInteger.withDefault(1),
+ purchase_perPage: parseAsInteger.withDefault(10),
+ purchase_sort: getSortingStateParser<RfqLastAttachments>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ purchase_search: parseAsString.withDefault(""),
+ purchase_fileType: parseAsArrayOf(z.string()).withDefault([]),
+ purchase_filters: getFiltersStateParser().withDefault([]),
+ purchase_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+})
+
+// 통합 파라미터 캐시 (모든 파라미터를 한 번에 파싱)
+export const searchParamsRfqAttachmentsCache = createSearchParamsCache({
+ // 공통
+ tab: parseAsStringEnum(['design', 'purchase']).withDefault('design'),
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- // 스키마 타입들
- export type GetRfqLastAttachmentsSchema = Awaited<ReturnType<typeof searchParamsRfqAttachmentsCache.parse>>
+ // 설계 탭 파라미터
+ design_page: parseAsInteger.withDefault(1),
+ design_perPage: parseAsInteger.withDefault(10),
+ design_sort: getSortingStateParser<RfqLastAttachments>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ design_search: parseAsString.withDefault(""),
+ design_fileType: parseAsArrayOf(z.string()).withDefault([]),
+ design_filters: getFiltersStateParser().withDefault([]),
+ design_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 구매 탭 파라미터
+ purchase_page: parseAsInteger.withDefault(1),
+ purchase_perPage: parseAsInteger.withDefault(10),
+ purchase_sort: getSortingStateParser<RfqLastAttachments>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ purchase_search: parseAsString.withDefault(""),
+ purchase_fileType: parseAsArrayOf(z.string()).withDefault([]),
+ purchase_filters: getFiltersStateParser().withDefault([]),
+ purchase_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+})
+
+// 타입 정의
+export type GetRfqLastAttachmentsSchema = {
+ page: number
+ perPage: number
+ sort: Array<{ id: string; desc: boolean }>
+ search: string
+ fileType: string[]
+ filters: any[]
+ joinOperator: "and" | "or"
+ attachmentType?: string[]
+}
+
+// 헬퍼 함수: prefix가 붙은 파라미터를 일반 파라미터로 변환
+export function extractTabParams(
+ allParams: Awaited<ReturnType<typeof searchParamsRfqAttachmentsCache.parse>>,
+ tabPrefix: 'design' | 'purchase'
+): GetRfqLastAttachmentsSchema {
+ const prefix = `${tabPrefix}_`
+ return {
+ page: allParams[`${prefix}page` as keyof typeof allParams] as number,
+ perPage: allParams[`${prefix}perPage` as keyof typeof allParams] as number,
+ sort: allParams[`${prefix}sort` as keyof typeof allParams] as any,
+ search: allParams[`${prefix}search` as keyof typeof allParams] as string,
+ fileType: allParams[`${prefix}fileType` as keyof typeof allParams] as string[],
+ filters: allParams[`${prefix}filters` as keyof typeof allParams] as any[],
+ joinOperator: allParams[`${prefix}joinOperator` as keyof typeof allParams] as "and" | "or",
+ }
+} \ No newline at end of file