summaryrefslogtreecommitdiff
path: root/components/documents
diff options
context:
space:
mode:
Diffstat (limited to 'components/documents')
-rw-r--r--components/documents/RevisionForm.tsx115
-rw-r--r--components/documents/StageList.tsx256
-rw-r--r--components/documents/StageListfromSHI.tsx187
-rw-r--r--components/documents/add-document-dialog.tsx515
-rw-r--r--components/documents/document-container.tsx85
-rw-r--r--components/documents/project-swicher.tsx138
-rw-r--r--components/documents/vendor-docs.client.tsx80
-rw-r--r--components/documents/view-document-dialog.tsx246
8 files changed, 1622 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..eaa09fad
--- /dev/null
+++ b/components/documents/view-document-dialog.tsx
@@ -0,0 +1,246 @@
+"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 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;
+}
+
+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} />
+ )}
+ </>
+ );
+}
+
+const DocumentViewer: React.FC<{
+ open: boolean;
+ setOpen: React.Dispatch<React.SetStateAction<boolean>>;
+ versions: Version[];
+}> = ({ 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: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ 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 () => {
+ // cleanup 시에는 중단 flag 세움
+ if (instance) {
+ instance.UI.dispose();
+ }
+ setTimeout(() => cleanupHtmlStyle(), 500);
+ };
+ }, [open]);
+
+ React.useEffect(() => {
+ const loadDocument = async () => {
+ if (instance && versions.length > 0) {
+ const { UI } = instance;
+
+ const optionsArray: any[] = [];
+
+ versions.forEach((c) => {
+ const { attachments } = c;
+ attachments.forEach((c2) => {
+ const { fileName, filePath, fileType } = c2;
+
+ const fileTypeCur = fileType ?? "";
+
+ const options = {
+ filename: fileName,
+ ...(fileTypeCur.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-[70vw] 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>
+ );
+};