From 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 25 Mar 2025 15:55:45 +0900 Subject: initial commit --- components/documents/RevisionForm.tsx | 115 ++++++ components/documents/StageList.tsx | 256 +++++++++++++ components/documents/StageListfromSHI.tsx | 187 ++++++++++ components/documents/add-document-dialog.tsx | 515 ++++++++++++++++++++++++++ components/documents/document-container.tsx | 85 +++++ components/documents/project-swicher.tsx | 138 +++++++ components/documents/vendor-docs.client.tsx | 80 ++++ components/documents/view-document-dialog.tsx | 226 +++++++++++ 8 files changed, 1602 insertions(+) create mode 100644 components/documents/RevisionForm.tsx create mode 100644 components/documents/StageList.tsx create mode 100644 components/documents/StageListfromSHI.tsx create mode 100644 components/documents/add-document-dialog.tsx create mode 100644 components/documents/document-container.tsx create mode 100644 components/documents/project-swicher.tsx create mode 100644 components/documents/vendor-docs.client.tsx create mode 100644 components/documents/view-document-dialog.tsx (limited to 'components/documents') diff --git a/components/documents/RevisionForm.tsx b/components/documents/RevisionForm.tsx new file mode 100644 index 00000000..9eea04c5 --- /dev/null +++ b/components/documents/RevisionForm.tsx @@ -0,0 +1,115 @@ +"use client" + +import React, { useState } from "react" + +// shadcn/ui Components +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" + +type RevisionFormProps = { + document: any +} + +export default function RevisionForm({ document }: RevisionFormProps) { + const [stage, setStage] = useState("") + const [revision, setRevision] = useState("") + const [planDate, setPlanDate] = useState("") + const [actualDate, setActualDate] = useState("") + const [file, setFile] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!document?.id) return + // server action 호출 예시 + // await createDocumentVersion({ + // documentId: document.id, + // stage, + // revision, + // planDate, + // actualDate, + // file, + // }); + alert("리비전이 등록되었습니다.") + // 이후 상태 초기화나 revalidation 등 필요에 따라 처리 + } + + return ( +
+

리비전 등록

+ + +
+ {/* Stage */} +
+ + setStage(e.target.value)} + /> +
+ + {/* Revision */} +
+ + setRevision(e.target.value)} + /> +
+ + {/* 계획일 */} +
+ + setPlanDate(e.target.value)} + /> +
+ + {/* 실제일 */} +
+ + setActualDate(e.target.value)} + /> +
+ + {/* 파일 업로드 */} +
+ + setFile(e.target.files?.[0] ?? null)} + /> +
+ + {/* 제출 버튼 */} + +
+
+ ) +} \ No newline at end of file diff --git a/components/documents/StageList.tsx b/components/documents/StageList.tsx new file mode 100644 index 00000000..81f8a5ca --- /dev/null +++ b/components/documents/StageList.tsx @@ -0,0 +1,256 @@ +"use client" + +import React, { useEffect, useState, useMemo } from "react" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip" +import { Building2, FileIcon, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { AddDocumentDialog } from "./add-document-dialog" +import { ViewDocumentDialog } from "./view-document-dialog" +import { getDocumentVersionsByDocId, getStageNamesByDocumentId } from "@/lib/vendor-document/service" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { formatDate } from "@/lib/utils" + +type StageListProps = { + document: { + id: number + docNumber: string + title: string + // ... + } +} + +// 인터페이스 +interface Attachment { + id: number + fileName: string + filePath: string + fileType?: string +} + +interface Version { + id: number + stage: string + revision: string + uploaderType: string + uploaderName: string | null + comment: string | null + status: string | null + planDate: string | null + actualDate: string | null + approvedDate: string | null + DocumentSubmitDate: Date + attachments: Attachment[] + selected?: boolean +} + +export default function StageList({ document }: StageListProps) { + const [versions, setVersions] = useState([]) + +console.log(versions) + + const [stageOptions, setStageOptions] = useState([]) + + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!document?.id) return + + // 로딩 상태 시작 + setIsLoading(true) + + // 데이터 로딩 프로미스들 + const loadVersions = getDocumentVersionsByDocId(document.id) + .then((data) => { + setVersions(data.map(c => {{return {...c, selected: false}}})) + }) + .catch((error) => { + console.error("Failed to load document versions:", error) + }) + + const loadStageOptions = getStageNamesByDocumentId(document.id) + .then((stageNames) => { + setStageOptions(stageNames) + }) + .catch((error) => { + console.error("Failed to load stage options:", error) + }) + + // 모든 데이터 로딩이 완료되면 로딩 상태 종료 + Promise.all([loadVersions, loadStageOptions]) + .finally(() => { + setIsLoading(false) + }) + }, [document]) + + // Handle file download with original filename + const handleDownload = (attachmentPath: string, fileName: string) => { + if (attachmentPath) { + // Use window.document to avoid collision with the document prop + const link = window.document.createElement('a'); + link.href = attachmentPath; + link.download = fileName || 'download'; // Use the original filename or a default + window.document.body.appendChild(link); + link.click(); + window.document.body.removeChild(link); + } + } + + // 파일 확장자에 따른 아이콘 색상 반환 + const getFileIconColor = (fileName: string) => { + const ext = fileName.split('.').pop()?.toLowerCase(); + + switch(ext) { + case 'pdf': + return 'text-red-500'; + case 'doc': + case 'docx': + return 'text-blue-500'; + case 'xls': + case 'xlsx': + return 'text-green-500'; + case 'dwg': + return 'text-amber-500'; + default: + return 'text-gray-500'; + } + } + + const selectItems = useMemo(() => { + return versions.filter(c => c.selected && c.attachments && c.attachments.length > 0) + }, [versions]) + + return ( + <> +
+

+ {/* */} + Document: {document.docNumber} {document.title} +

+ + +
+ {selectItems.length > 0 && } + + + { + // 새 데이터 생성 후 목록을 다시 불러오려면 + getDocumentVersionsByDocId(document.id).then((data) => { + setVersions(data.map(c => {{return {...c, selected: false}}})) + }) + }} + buttonLabel="업체 문서 추가" + /> +
+
+ + + {isLoading ? ( +
+ +

문서 로딩 중...

+
+ ) : ( + + + + + + Stage + Revision + 첨부파일 + 생성일 + 계획일 + 실제일 + + + + {versions.length ? ( + versions.map((ver) => ( + + + { + setVersions(prev => prev.map(c => { + if(c.id === ver.id){ + return {...c, selected: !c.selected} + } + + return {...c} + })) + }} + aria-label="Select row" + className="translate-y-0.5" + /> + + {ver.uploaderType} + {ver.stage} + {ver.revision} + +
+ {ver.attachments && ver.attachments.length > 0 ? ( + ver.attachments.map((file) => ( + + + + + + +

{file.fileName || "Download file"}

+
+
+
+ )) + ) : ( + + 파일 없음 + + )} +
+
+ {formatDate(ver.DocumentSubmitDate) ?? "-"} + {ver.planDate ?? "-"} + {ver.actualDate ?? "-"} +
+ )) + ) : ( + + + 업체 문서가 없습니다. + + + )} +
+
+ )} +
+ + ) +} \ No newline at end of file diff --git a/components/documents/StageListfromSHI.tsx b/components/documents/StageListfromSHI.tsx new file mode 100644 index 00000000..9c3c662c --- /dev/null +++ b/components/documents/StageListfromSHI.tsx @@ -0,0 +1,187 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip" +import { FileIcon, Building } from "lucide-react" +import { Button } from "@/components/ui/button" +import { getDocumentVersionsByDocId } from "@/lib/vendor-document/service" +import { Badge } from "@/components/ui/badge" + +type StageListProps = { + document: { + id: number + docNumber: string + title: string + // ... + } +} + +// 인터페이스 +interface Attachment { + id: number + fileName: string + filePath: string + fileType?: string +} + +interface Version { + id: number + stage: string + revision: string + uploaderType: string + uploaderName: string | null + comment: string | null + status: string | null + planDate: string | null + actualDate: string | null + approvedDate: string | null + attachments: Attachment[] +} + +export default function StageSHIList({ document }: StageListProps) { + const [versions, setVersions] = useState([]) + + useEffect(() => { + if (!document?.id) return + // shi 업로더 타입만 필터링 + getDocumentVersionsByDocId(document.id, ['shi']).then((data) => { + setVersions(data) + }) + }, [document]) + + // 스테이지 옵션 추출 + const stageOptions = React.useMemo(() => { + const stageSet = new Set() + for (const v of versions) { + if (v.stage) { + stageSet.add(v.stage) + } + } + return Array.from(stageSet) + }, [versions]) + + // Handle file download with original filename + const handleDownload = (attachmentPath: string, fileName: string) => { + if (attachmentPath) { + // Use window.document to avoid collision with the document prop + const link = window.document.createElement('a'); + link.href = attachmentPath; + link.download = fileName || 'download'; // Use the original filename or a default + window.document.body.appendChild(link); + link.click(); + window.document.body.removeChild(link); + } + } + + // 파일 확장자에 따른 아이콘 색상 반환 + const getFileIconColor = (fileName: string) => { + const ext = fileName.split('.').pop()?.toLowerCase(); + + switch(ext) { + case 'pdf': + return 'text-red-500'; + case 'doc': + case 'docx': + return 'text-blue-500'; + case 'xls': + case 'xlsx': + return 'text-green-500'; + case 'dwg': + return 'text-amber-500'; + default: + return 'text-gray-500'; + } + } + + return ( + <> +
+

+ {/* */} + From 삼성중공업 ({document.docNumber} {document.title}) +

+
+ + + + + + Stage + Revision + 첨부파일 + 상태 + 코멘트 + + + + {versions.length ? ( + versions.map((ver) => ( + + {ver.stage} + {ver.revision} + +
+ {ver.attachments && ver.attachments.length > 0 ? ( + ver.attachments.map((file) => ( + + + + + + +

{file.fileName || "Download file"}

+
+
+
+ )) + ) : ( + + 파일 없음 + + )} +
+
+ + {ver.status && ( + + {ver.status} + + )} + + {ver.comment || "-"} +
+ )) + ) : ( + + + 삼성중공업 문서가 없습니다. + + + )} +
+
+
+ + ) +} \ No newline at end of file diff --git a/components/documents/add-document-dialog.tsx b/components/documents/add-document-dialog.tsx new file mode 100644 index 00000000..15c1e021 --- /dev/null +++ b/components/documents/add-document-dialog.tsx @@ -0,0 +1,515 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +import { + Dialog, DialogTrigger, DialogContent, DialogHeader, + DialogTitle, DialogDescription, DialogFooter +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription +} from "@/components/ui/form" +import { + Select, SelectContent, SelectGroup, + SelectItem, SelectTrigger, SelectValue +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { createRevisionAction } from "@/lib/vendor-document/service" +import { useToast } from "@/hooks/use-toast" +import { FilePlus, X, Loader2, Building2, User, Building, Plus } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import prettyBytes from "pretty-bytes" +import { ScrollArea } from "../ui/scroll-area" + +// 최대 파일 크기 설정 (3000MB) +const MAX_FILE_SIZE = 3e9 + +// zod 스키마 +export const createDocumentVersionSchema = z.object({ + attachments: z.array(z.instanceof(File)).min(1, "At least one file is required"), + stage: z.string().min(1, "Stage is required"), + revision: z.string().min(1, "Revision is required"), + uploaderType: z.enum(["vendor", "client", "shi"]).default("vendor"), + uploaderName: z.string().optional(), + comment: z.string().optional(), +}) +export type CreateDocumentVersionSchema = z.infer + +// First, let's add a function to generate filenames based on your pattern +const generateFileName = ( + documentNo: string, + stage: string, + revision: string, + originalFileName: string, + index: number, + totalFiles: number +) => { + // Get the file extension + const extension = originalFileName.split('.').pop() || ''; + + // Base name without extension + const baseName = `${documentNo}_${stage}_${revision}`; + + // For multiple files, add a suffix + if (totalFiles > 1) { + return `${baseName}_${index + 1}.${extension}`; + } + + // For a single file, no suffix needed + return `${baseName}.${extension}`; +}; + +// AddDocumentDialog Props +interface AddDocumentDialogProps { + onSuccess?: () => void + stageOptions?: string[] + documentId: number + documentNo: string + // 업로더 타입 + uploaderType?: "vendor" | "client" | "shi" + // 버튼 라벨 추가 + buttonLabel?: string +} + +export function AddDocumentDialog({ + onSuccess, + stageOptions = [], + documentId, + documentNo, + uploaderType = "vendor", + buttonLabel = "Add Document", +}: AddDocumentDialogProps) { + const [open, setOpen] = React.useState(false) + const [selectedFiles, setSelectedFiles] = React.useState([]) + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const { toast } = useToast() + + const form = useForm({ + resolver: zodResolver(createDocumentVersionSchema), + defaultValues: { + stage: "", + revision: "", + attachments: [], + uploaderType, + uploaderName: "", + comment: "", + }, + }) + + // 업로더 타입이 바뀌면 폼 값도 업데이트 + React.useEffect(() => { + form.setValue('uploaderType', uploaderType); + }, [uploaderType, form]); + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles]; + setSelectedFiles(newFiles); + form.setValue('attachments', newFiles, { shouldValidate: true }); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rejection.file.name}: ${rejection.errors[0]?.message || "Upload failed"}`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('attachments', updatedFiles, { shouldValidate: true }) + } + + // Submit + async function onSubmit(data: CreateDocumentVersionSchema) { + setIsUploading(true) + setUploadProgress(0) + + try { + // 각 파일별로 별도 요청 + const totalFiles = data.attachments.length; + let successCount = 0; + + for (let i = 0; i < totalFiles; i++) { + const file = data.attachments[i]; + + const newFileName = generateFileName( + documentNo, + data.stage, + data.revision, + file.name, + i, + totalFiles + ); + + const fData = new FormData(); + fData.append("documentId", String(documentId)); + fData.append("stage", data.stage); + fData.append("revision", data.revision); + fData.append("uploaderType", data.uploaderType); + + if (data.uploaderName) { + fData.append("uploaderName", data.uploaderName); + } + + if (data.comment) { + fData.append("comment", data.comment); + } + + fData.append("attachment", file); + fData.append("customFileName", newFileName); + + // 각 파일 업로드를 요청 + await createRevisionAction(fData); + + // 진행 상황 업데이트 + successCount++; + setUploadProgress(Math.round((successCount / totalFiles) * 100)); + } + + // 성공 메시지 + toast({ + title: "Success", + description: `${successCount} 파일이 업로드되었습니다.`, + }); + + // 폼 초기화 + form.reset(); + setSelectedFiles([]); + setOpen(false); + + // 콜백 실행 + if (onSuccess) { + onSuccess(); + } + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset(); + form.setValue('uploaderType', uploaderType); + setSelectedFiles([]); + setIsUploading(false); + setUploadProgress(0); + } + setOpen(nextOpen); + } + + // 업로더 타입에 따른 UI 설정 + const uploaderConfig = { + vendor: { + icon: , + buttonStyle: "bg-blue-600 hover:bg-blue-700", + badgeStyle: "bg-blue-100 text-blue-800", + title: "업체 문서 등록", + nameLabel: "업체 이름", + namePlaceholder: "예: 홍길동(ABC 업체)" + }, + client: { + icon: , + buttonStyle: "bg-amber-600 hover:bg-amber-700", + badgeStyle: "bg-amber-100 text-amber-800", + title: "고객사 문서 등록", + nameLabel: "담당자 이름", + namePlaceholder: "예: 김철수(고객사)" + }, + shi: { + icon: , + buttonStyle: "bg-purple-600 hover:bg-purple-700", + badgeStyle: "bg-purple-100 text-purple-800", + title: "삼성중공업 문서 등록", + nameLabel: "담당자 이름", + namePlaceholder: "예: 이영희(삼성중공업)" + } + } + + const config = uploaderConfig[uploaderType as keyof typeof uploaderConfig]; + + return ( + + + + + + + + + {config.title} + + + 스테이지/리비전을 입력하고 파일을 업로드하세요. + + + +
+ +
+ {/* stage */} + ( + + Stage + + {stageOptions.length > 0 ? ( + + ) : ( + + )} + + + + )} + /> + + {/* revision */} + ( + + Revision + + + + + + )} + /> + + {/* attachments - 드롭존 */} + ( + + 파일 첨부 + + {({ maxSize }) => ( + <> + + + + +
+ +
+ 파일을 여기에 드롭하세요 + + 또는 클릭하여 파일을 선택하세요. + 최대 크기: {maxSize ? prettyBytes(maxSize) : "무제한"} + +
+
+
+ + 여러 파일을 선택할 수 있습니다. + + + )} +
+ +
+ )} + /> + + {/* 선택된 파일 목록 */} + + {selectedFiles.length > 0 && ( +
+
+
+ 선택된 파일 ({selectedFiles.length}) +
+ + {selectedFiles.length}개 파일 + +
+ + + {selectedFiles.map((file, index) => ( + + + + + {file.name} + + {prettyBytes(file.size)} + + + removeFile(index)} disabled={isUploading}> + + Remove + + + + ))} + + + +
+ )} + + {/* 업로드 진행 상태 */} + {isUploading && ( +
+
+ + + {uploadProgress}% 업로드 중... + +
+
+
+
+
+ )} + + {/* 선택적 필드들 */} + {/*
+ ( + + {config.nameLabel} (선택) + + + + + + )} + /> +
*/} + + {/* comment (optional) */} + {/* ( + + 코멘트 (선택) + +