diff options
Diffstat (limited to 'components/documents')
| -rw-r--r-- | components/documents/RevisionForm.tsx | 115 | ||||
| -rw-r--r-- | components/documents/StageList.tsx | 256 | ||||
| -rw-r--r-- | components/documents/StageListfromSHI.tsx | 187 | ||||
| -rw-r--r-- | components/documents/add-document-dialog.tsx | 515 | ||||
| -rw-r--r-- | components/documents/document-container.tsx | 85 | ||||
| -rw-r--r-- | components/documents/project-swicher.tsx | 138 | ||||
| -rw-r--r-- | components/documents/vendor-docs.client.tsx | 80 | ||||
| -rw-r--r-- | components/documents/view-document-dialog.tsx | 226 |
8 files changed, 1602 insertions, 0 deletions
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<File | null>(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 ( + <div className="p-3"> + <h2 className="text-lg font-semibold">리비전 등록</h2> + <Separator className="my-2" /> + + <form onSubmit={handleSubmit} className="space-y-4"> + {/* Stage */} + <div> + <Label htmlFor="stage" className="mb-1"> + Stage + </Label> + <Input + id="stage" + type="text" + value={stage} + onChange={(e) => setStage(e.target.value)} + /> + </div> + + {/* Revision */} + <div> + <Label htmlFor="revision" className="mb-1"> + Revision + </Label> + <Input + id="revision" + type="text" + value={revision} + onChange={(e) => setRevision(e.target.value)} + /> + </div> + + {/* 계획일 */} + <div> + <Label htmlFor="planDate" className="mb-1"> + 계획일 + </Label> + <Input + id="planDate" + type="date" + value={planDate} + onChange={(e) => setPlanDate(e.target.value)} + /> + </div> + + {/* 실제일 */} + <div> + <Label htmlFor="actualDate" className="mb-1"> + 실제일 + </Label> + <Input + id="actualDate" + type="date" + value={actualDate} + onChange={(e) => setActualDate(e.target.value)} + /> + </div> + + {/* 파일 업로드 */} + <div> + <Label htmlFor="file" className="mb-1"> + 파일 업로드 + </Label> + <Input + id="file" + type="file" + onChange={(e) => setFile(e.target.files?.[0] ?? null)} + /> + </div> + + {/* 제출 버튼 */} + <Button type="submit" variant="default"> + 등록하기 + </Button> + </form> + </div> + ) +}
\ 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<Version[]>([]) + +console.log(versions) + + const [stageOptions, setStageOptions] = useState<string[]>([]) + + const [isLoading, setIsLoading] = useState<boolean>(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 ( + <> + <div className="flex items-center justify-between p-2"> + <h2 className="font-semibold text-base flex items-center gap-2"> + {/* <Building2 className="h-4 w-4 text-blue-600" /> */} + Document: {document.docNumber} {document.title} + </h2> + + + <div className="flex flex-row gap-2"> + {selectItems.length > 0 && <ViewDocumentDialog versions={versions}/>} + + + <AddDocumentDialog + stageOptions={stageOptions} + documentId={document.id} + documentNo={document.docNumber} + uploaderType="vendor" + onSuccess={() => { + // 새 데이터 생성 후 목록을 다시 불러오려면 + getDocumentVersionsByDocId(document.id).then((data) => { + setVersions(data.map(c => {{return {...c, selected: false}}})) + }) + }} + buttonLabel="업체 문서 추가" + /> + </div> + </div> + + <ScrollArea className="h-full p-2"> + {isLoading ? ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[40px]"></TableHead> + <TableHead className="w-[100px]"></TableHead> + <TableHead className="w-[100px]">Stage</TableHead> + <TableHead className="w-[100px]">Revision</TableHead> + <TableHead className="w-[150px]">첨부파일</TableHead> + <TableHead className="w-[150px]">생성일</TableHead> + <TableHead className="w-[120px]">계획일</TableHead> + <TableHead className="w-[120px]">실제일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {versions.length ? ( + versions.map((ver) => ( + <TableRow key={ver.id}> + <TableCell> + <Checkbox + checked={ver.selected} + onCheckedChange={(value) => { + 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" + /> + </TableCell> + <TableCell>{ver.uploaderType}</TableCell> + <TableCell>{ver.stage}</TableCell> + <TableCell>{ver.revision}</TableCell> + <TableCell> + <div className="flex flex-wrap gap-2"> + {ver.attachments && ver.attachments.length > 0 ? ( + ver.attachments.map((file) => ( + <TooltipProvider key={file.id}> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-4 w-4 p-0" + onClick={() => handleDownload(file.filePath, file.fileName)} + > + <FileIcon className={`h-5 w-5 ${getFileIconColor(file.fileName)}`} /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>{file.fileName || "Download file"}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )) + ) : ( + <Badge variant="outline" className="text-xs"> + 파일 없음 + </Badge> + )} + </div> + </TableCell> + <TableCell>{formatDate(ver.DocumentSubmitDate) ?? "-"}</TableCell> + <TableCell>{ver.planDate ?? "-"}</TableCell> + <TableCell>{ver.actualDate ?? "-"}</TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={7} className="text-center"> + 업체 문서가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + )} + </ScrollArea> + </> + ) +}
\ 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<Version[]>([]) + + useEffect(() => { + if (!document?.id) return + // shi 업로더 타입만 필터링 + getDocumentVersionsByDocId(document.id, ['shi']).then((data) => { + setVersions(data) + }) + }, [document]) + + // 스테이지 옵션 추출 + const stageOptions = React.useMemo(() => { + const stageSet = new Set<string>() + 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 ( + <> + <div className="flex items-center justify-between p-2"> + <h2 className="font-semibold text-base flex items-center gap-2"> + {/* <Building className="h-4 w-4 text-amber-600" /> */} + From 삼성중공업 ({document.docNumber} {document.title}) + </h2> + </div> + + <ScrollArea className="h-full p-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[100px]">Stage</TableHead> + <TableHead className="w-[100px]">Revision</TableHead> + <TableHead className="w-[150px]">첨부파일</TableHead> + <TableHead className="w-[100px]">상태</TableHead> + <TableHead className="w-[120px]">코멘트</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {versions.length ? ( + versions.map((ver) => ( + <TableRow key={ver.id}> + <TableCell>{ver.stage}</TableCell> + <TableCell>{ver.revision}</TableCell> + <TableCell> + <div className="flex flex-wrap gap-2"> + {ver.attachments && ver.attachments.length > 0 ? ( + ver.attachments.map((file) => ( + <TooltipProvider key={file.id}> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + onClick={() => handleDownload(file.filePath, file.fileName)} + > + <FileIcon className={`h-5 w-5 ${getFileIconColor(file.fileName)}`} /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>{file.fileName || "Download file"}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )) + ) : ( + <Badge variant="outline" className="text-xs"> + 파일 없음 + </Badge> + )} + </div> + </TableCell> + <TableCell> + {ver.status && ( + <Badge variant="outline" className="bg-amber-50 text-amber-800"> + {ver.status} + </Badge> + )} + </TableCell> + <TableCell>{ver.comment || "-"}</TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={5} className="text-center"> + 삼성중공업 문서가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </ScrollArea> + </> + ) +}
\ 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<typeof createDocumentVersionSchema> + +// 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<File[]>([]) + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const { toast } = useToast() + + const form = useForm<CreateDocumentVersionSchema>({ + 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: <Plus className="h-4 w-4" />, + buttonStyle: "bg-blue-600 hover:bg-blue-700", + badgeStyle: "bg-blue-100 text-blue-800", + title: "업체 문서 등록", + nameLabel: "업체 이름", + namePlaceholder: "예: 홍길동(ABC 업체)" + }, + client: { + icon: <User className="mr-2 h-4 w-4" />, + buttonStyle: "bg-amber-600 hover:bg-amber-700", + badgeStyle: "bg-amber-100 text-amber-800", + title: "고객사 문서 등록", + nameLabel: "담당자 이름", + namePlaceholder: "예: 김철수(고객사)" + }, + shi: { + icon: <Building className="mr-2 h-4 w-4" />, + 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 ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button + size="sm" + className="border-blue-200" + variant="outline" + > + {config.icon} + {buttonLabel} + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {config.title} + </DialogTitle> + <DialogDescription> + 스테이지/리비전을 입력하고 파일을 업로드하세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} encType="multipart/form-data"> + <div className="space-y-4 py-4"> + {/* stage */} + <FormField + control={form.control} + name="stage" + render={({ field }) => ( + <FormItem> + <FormLabel>Stage</FormLabel> + <FormControl> + {stageOptions.length > 0 ? ( + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select a stage" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {stageOptions.map((st) => ( + <SelectItem key={st} value={st}> + {st} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + ) : ( + <Input {...field} placeholder="예: Issued for Review" /> + )} + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* revision */} + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel>Revision</FormLabel> + <FormControl> + <Input {...field} placeholder="예: A, B, 1, 2..." /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* attachments - 드롭존 */} + <FormField + control={form.control} + name="attachments" + render={() => ( + <FormItem> + <FormLabel>파일 첨부</FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={true} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <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> + 또는 클릭하여 파일을 선택하세요. + 최대 크기: {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription className="text-xs text-muted-foreground"> + 여러 파일을 선택할 수 있습니다. + </FormDescription> + </> + )} + </Dropzone> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + + {selectedFiles.length > 0 && ( + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + <Badge variant="secondary"> + {selectedFiles.length}개 파일 + </Badge> + </div> + <ScrollArea> + <FileList className="max-h-[200px] gap-3"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction onClick={() => removeFile(index)} disabled={isUploading}> + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + + </div> + )} + + {/* 업로드 진행 상태 */} + {isUploading && ( + <div className="flex flex-col gap-1 mt-2"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm"> + {uploadProgress}% 업로드 중... + </span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + </div> + )} + + {/* 선택적 필드들 */} + {/* <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="uploaderName" + render={({ field }) => ( + <FormItem> + <FormLabel>{config.nameLabel} (선택)</FormLabel> + <FormControl> + <Input + {...field} + placeholder={config.namePlaceholder} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> */} + + {/* comment (optional) */} + {/* <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트 (선택)</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="파일에 대한 설명이나 코멘트를 입력하세요." + className="resize-none" + rows={2} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + // 파일 리스트 리셋 + setSelectedFiles([]); + form.reset({ + ...form.getValues(), + attachments: [] + }); + setOpen(false); + }} + disabled={isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isUploading || selectedFiles.length === 0 || !form.formState.isValid} + className={config.buttonStyle} + > + {isUploading ? "업로드 중..." : "등록"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/documents/document-container.tsx b/components/documents/document-container.tsx new file mode 100644 index 00000000..0a1a4a56 --- /dev/null +++ b/components/documents/document-container.tsx @@ -0,0 +1,85 @@ +"use client" + +import { useState } from "react" + +// shadcn/ui components +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable" + +import { cn } from "@/lib/utils" +import StageList from "./StageList" +import RevisionForm from "./RevisionForm" +import { getVendorDocumentLists } from "@/lib/vendor-document/service" +import { VendorDocumentsView } from "@/db/schema/vendorDocu" +import { DocumentListTable } from "@/lib/vendor-document/table/doc-table" +import StageSHIList from "./StageListfromSHI" + +interface DocumentContainerProps { + promises: Promise<[Awaited<ReturnType<typeof getVendorDocumentLists>>]> + selectedPackageId: number +} + +export default function DocumentContainer({ + promises, + selectedPackageId +}: DocumentContainerProps) { + // 선택된 문서를 이 state로 관리 + const [selectedDocument, setSelectedDocument] = useState<VendorDocumentsView | null>(null) + + // 패널 collapse 상태 + const [isTopCollapsed, setIsTopCollapsed] = useState(false) + const [isBottomLeftCollapsed, setIsBottomLeftCollapsed] = useState(false) + const [isBottomRightCollapsed, setIsBottomRightCollapsed] = useState(false) + + // 문서 선택 핸들러 + const handleSelectDocument = (document: VendorDocumentsView | null) => { + setSelectedDocument(document) + } + + return ( + // 명시적 높이 지정 + <div className="h-[calc(100vh-100px)] w-full"> + + <ResizablePanelGroup direction="vertical" className="h-full w-full"> + {/* 상단 패널 (문서 리스트 영역) */} + <ResizablePanel + defaultSize={65} + minSize={15} + maxSize={95} + collapsible + collapsedSize={10} + onCollapse={() => setIsTopCollapsed(true)} + onExpand={() => setIsTopCollapsed(false)} + className={cn("overflow-auto border-b", isTopCollapsed && "transition-all")} + > + <DocumentListTable + promises={promises} + selectedPackageId={selectedPackageId} + onSelectDocument={handleSelectDocument} + /> + </ResizablePanel> + + {/* 상/하 분할을 위한 핸들 */} + <ResizableHandle + withHandle + className="pointer-events-none data-[resize-handle]:pointer-events-auto" + /> + + <ResizablePanel minSize={0} defaultSize={35}> + + {selectedDocument ? ( + <StageList document={selectedDocument} /> + ) : ( + <div className="p-4 text-sm text-muted-foreground"> + 문서를 선택하면 이슈 스테이지가 표시됩니다. + </div> + )} + + </ResizablePanel> + </ResizablePanelGroup> + </div> + ) +}
\ No newline at end of file diff --git a/components/documents/project-swicher.tsx b/components/documents/project-swicher.tsx new file mode 100644 index 00000000..5c70ea88 --- /dev/null +++ b/components/documents/project-swicher.tsx @@ -0,0 +1,138 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface PackageItem { + itemId: number + itemName: string +} + +interface ContractInfo { + contractId: number + contractNo: string + contractName: string + packages: PackageItem[] +} + +export interface ProjectInfo { + projectId: number + projectCode: string + projectName: string + projectType: string + contracts: ContractInfo[] +} + +interface ProjectSwitcherProps { + isCollapsed: boolean + projects: ProjectInfo[] + + // 상위가 관리하는 "현재 선택된 contractId" + selectedContractId: number | null + + // 콜백: 사용자가 "어떤 contract"를 골랐는지 + onSelectContract: (projectId: number, contractId: number) => void +} + +/** + * ProjectSwitcher: + * - 프로젝트들(contracts 포함)을 그룹화하여 Select 표시 + * - 너무 긴 계약명 등을 ellipsis로 축약 + */ +export function ProjectSwitcher({ + isCollapsed, + projects, + selectedContractId, + onSelectContract, +}: ProjectSwitcherProps) { + // Select value = stringified contractId + const selectValue = selectedContractId ? String(selectedContractId) : "" + + // 현재 선택된 계약 정보를 찾기 + const selectedContract = React.useMemo(() => { + if (!selectedContractId) return null + for (const proj of projects) { + const found = proj.contracts.find((c) => c.contractId === selectedContractId) + if (found) { + return { ...found, projectId: proj.projectId } + } + } + return null + }, [projects, selectedContractId]) + + // Trigger Label => 계약 이름 or "Select a contract" + const triggerLabel = selectedContract?.contractName ?? "Select a contract" + + function handleValueChange(val: string) { + const contractId = Number(val) + let foundProjectId = 0 + + for (const proj of projects) { + const found = proj.contracts.find((c) => c.contractId === contractId) + if (found) { + foundProjectId = proj.projectId + break + } + } + onSelectContract(foundProjectId, contractId) + } + + return ( + <Select value={selectValue} onValueChange={handleValueChange}> + {/* + 아래 SelectTrigger에 max-w, whitespace-nowrap, overflow-hidden, text-ellipsis 적용 + 가로폭이 200px 넘어가면 "…" 으로 표시 + */} + <SelectTrigger + className={cn( + "flex items-center gap-2", + isCollapsed && "flex h-9 w-9 shrink-0 items-center justify-center p-0", + "max-w-[300px] whitespace-nowrap overflow-hidden text-ellipsis" + )} + aria-label="Select Contract" + > + <SelectValue placeholder="Select a contract"> + {/* 실제 표시부분에도 ellipsis 처리. */} + <span + className={cn( + "ml-2 block max-w-[250px] truncate", + isCollapsed && "hidden" + )} + > + {triggerLabel} + </span> + </SelectValue> + </SelectTrigger> + + <SelectContent> + {projects.map((project) => ( + <SelectGroup key={project.projectCode}> + {/* 프로젝트명 표시 */} + <SelectLabel> + {/* 필요하다면 projectCode만 보이도록 하는 등 조정 가능 */} + {project.projectName} + </SelectLabel> + {project.contracts.map((contract) => ( + <SelectItem + key={contract.contractId} + value={String(contract.contractId)} + > + {/* 계약명 + 계약번호 등 원하는 형식 */} + {contract.contractName} ({contract.contractNo}) + </SelectItem> + ))} + </SelectGroup> + ))} + </SelectContent> + </Select> + ) +}
\ No newline at end of file diff --git a/components/documents/vendor-docs.client.tsx b/components/documents/vendor-docs.client.tsx new file mode 100644 index 00000000..9bb7988c --- /dev/null +++ b/components/documents/vendor-docs.client.tsx @@ -0,0 +1,80 @@ +"use client" + +import * as React from "react" +import { useRouter, useParams } from "next/navigation" + +import DocumentContainer from "@/components/documents/document-container" +import { ProjectInfo, ProjectSwitcher } from "./project-swicher" + +interface VendorDocumentsClientProps { + projects: ProjectInfo[] + children: React.ReactNode +} + +export default function VendorDocumentsClient({ + projects, + children, +}: VendorDocumentsClientProps) { + const router = useRouter() + const params = useParams() + + // Get the contractId from route parameters + const contractIdFromUrl = React.useMemo(() => { + if (params?.contractId) { + const contractId = Array.isArray(params.contractId) + ? params.contractId[0] + : params.contractId + return Number(contractId) + } + return null + }, [params]) + + // Use the URL contractId as the selected contract + const [selectedContractId, setSelectedContractId] = React.useState<number | null>( + contractIdFromUrl + ) + + // Update selectedContractId when URL changes + React.useEffect(() => { + if (contractIdFromUrl) { + setSelectedContractId(contractIdFromUrl) + } + }, [contractIdFromUrl]) + + // Handle contract selection + function handleSelectContract(projectId: number, contractId: number) { + setSelectedContractId(contractId) + + // Navigate to the contract's documents page + router.push(`/partners/documents/${contractId}`, { scroll: false }) + } + + return ( + <> + {/* 상단 영역: 제목 왼쪽 / ProjectSwitcher 오른쪽 */} + <div className="flex items-center justify-between"> + {/* 왼쪽: 타이틀 & 설명 */} + <div> + <h2 className="text-2xl font-bold tracking-tight">Vendor Documents</h2> + <p className="text-muted-foreground"> + 문서리스트를 확인하고 리스트에 맞게 문서를 업로드하고 관리할 수 있으며 + 삼성중공업으로 전달할 수 있습니다. + </p> + </div> + + {/* 오른쪽: ProjectSwitcher */} + <ProjectSwitcher + isCollapsed={false} + projects={projects} + selectedContractId={selectedContractId} + onSelectContract={handleSelectContract} + /> + </div> + + {/* 문서 목록/테이블 영역 */} + <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow p-5"> + {children} + </section> + </> + ) +}
\ No newline at end of file diff --git a/components/documents/view-document-dialog.tsx b/components/documents/view-document-dialog.tsx new file mode 100644 index 00000000..752252ee --- /dev/null +++ b/components/documents/view-document-dialog.tsx @@ -0,0 +1,226 @@ +"use client" + +import * as React from "react" +import { WebViewerInstance } from "@pdftron/webviewer"; +import { + Dialog, DialogTrigger, DialogContent, DialogHeader, + DialogTitle, DialogDescription, DialogFooter +} from "@/components/ui/dialog" +import { Building2, FileIcon, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import fs from "fs" + +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 +} + +type ViewDocumentDialogProps = { + versions: Version[] +} + +export function ViewDocumentDialog({versions}: ViewDocumentDialogProps){ + const [open, setOpen] = React.useState(false) + + + return ( + <> + <Button + size="sm" + className="border-blue-200" + variant="outline" + onClick={() => setOpen(prev => !prev)} + > + 문서 보기 + </Button> + {open && <DocumentViewer + open={open} + setOpen={setOpen} + versions={versions} + /> + } + </> + ); +} + +function DocumentViewer({open, setOpen, versions}){ + const [instance, setInstance] = React.useState<null | WebViewerInstance>(null) + const [viwerLoading, setViewerLoading] = React.useState<boolean>(true) + const [fileSetLoading, setFileSetLoading] = React.useState<boolean>(true) + const viewer = React.useRef<HTMLDivElement>(null); + const initialized = React.useRef(false); + const isCancelled = React.useRef(false); // 초기화 중단용 flag + + const cleanupHtmlStyle = () => { + const htmlElement = document.documentElement; + + // 기존 style 속성 가져오기 + const originalStyle = htmlElement.getAttribute("style") || ""; + + // "color-scheme: light" 또는 "color-scheme: dark" 찾기 + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")); + + // 새로운 스타일 적용 (color-scheme만 유지) + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";"); + } else { + htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제 + } + + console.log("html style 삭제") + }; + + React.useEffect(() => { + if (open && !initialized.current) { + initialized.current = true; + isCancelled.current = false; // 다시 열릴 때는 false로 리셋 + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + console.log(isCancelled.current) + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)"); + + return; + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css:"/globals.css" + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + + + setInstance(instance); + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]); + instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]); + setViewerLoading(false); + + }); + }); + } + }); + } + + return async () => { + // cleanup 시에는 중단 flag 세움 + if(instance){ + await instance.UI.dispose() + } + await setTimeout(() => cleanupHtmlStyle(), 500) + }; + }, [open]); + + React.useEffect(() => { + const loadDocument = async () => { + + if(instance && versions.length > 0){ + const { UI } = instance; + + const optionsArray = [] + + versions.forEach(c => { + const {attachments} = c + attachments.forEach(c2 => { + const {fileName, filePath, fileType} = c2 + + const options = { + filename: fileName, + ...(fileType.includes("xlsx") && { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }), + }; + + optionsArray.push({ + filePath, + options + }) + }) + }) + + const tabIds = []; + + for (const option of optionsArray) { + const { filePath, options } = option; + const response = await fetch(filePath); + const blob = await response.blob(); + + const tab = await UI.TabManager.addTab(blob, options); + tabIds.push(tab); // 탭 ID 저장 + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]); + } + + setFileSetLoading(false) + } + } + loadDocument(); + }, [instance, versions]) + + + return ( + <Dialog open={open} onOpenChange={async (val) => { + console.log({val, fileSetLoading}) + if(!val && fileSetLoading){ + return; + } + + if (instance) { + try { + await instance.UI.dispose(); + setInstance(null); // 상태도 초기화 + + } catch (e) { + console.warn("dispose error", e); + } + } + + // cleanupHtmlStyle() + setViewerLoading(false); + setOpen(prev => !prev) + await setTimeout(() => cleanupHtmlStyle(), 1000) + }}> + <DialogContent className="w-[90vw] h-[90vh]" style={{maxWidth: "none"}}> + <DialogHeader className="h-[38px]"> + <DialogTitle> + 문서 미리보기 + </DialogTitle> + <DialogDescription> + 첨부파일 미리보기 + </DialogDescription> + </DialogHeader> + <div ref={viewer} style={{height: "calc(90vh - 20px - 38px - 1rem - 48px)"}}> + {viwerLoading && <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 뷰어 로딩 중...</p> + </div>} + </div> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
