diff options
| -rw-r--r-- | .env.development | 5 | ||||
| -rw-r--r-- | .env.production | 5 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx | 585 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/dolce-upload-v3/page.tsx | 49 | ||||
| -rw-r--r-- | app/api/dolce/download/route.ts | 15 | ||||
| -rw-r--r-- | db/schema/dolce/dolce.ts | 89 | ||||
| -rw-r--r-- | db/schema/index.ts | 2 | ||||
| -rw-r--r-- | lib/bidding/detail/service.ts | 34 | ||||
| -rw-r--r-- | lib/dolce-v2/actions.ts | 605 | ||||
| -rw-r--r-- | lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx | 695 | ||||
| -rw-r--r-- | lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx | 372 | ||||
| -rw-r--r-- | lib/dolce-v2/dialogs/sync-items-dialog.tsx | 376 | ||||
| -rw-r--r-- | lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx | 247 | ||||
| -rw-r--r-- | lib/dolce-v2/sync-service.ts | 414 | ||||
| -rw-r--r-- | lib/dolce/actions.ts | 9 | ||||
| -rw-r--r-- | lib/dolce/table/detail-drawing-columns.tsx | 81 | ||||
| -rw-r--r-- | lib/dolce/table/drawing-list-table-v2.tsx | 2 | ||||
| -rw-r--r-- | lib/dolce/table/file-list-columns.tsx | 45 | ||||
| -rw-r--r-- | lib/dolce/table/gtt-drawing-list-columns.tsx | 23 |
19 files changed, 3562 insertions, 91 deletions
diff --git a/.env.development b/.env.development index 2dbf998a..29063c3c 100644 --- a/.env.development +++ b/.env.development @@ -192,4 +192,7 @@ EDP_MASTER_DATA_SYNC_CRON="0 23 * * *" REVALIDATION_SECRET="biwjeofijosdkfjoiwejfksdjf1" # 오픈 전, 벤더에게 특정 메뉴 보이지 않기, 운영 배포시 true로 설정할 것 (나준규프로 요청사항) -NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false
\ No newline at end of file +NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false + +# DOLCE Local Uplaod Directory +DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY="/evcp/data/dolce"
\ No newline at end of file diff --git a/.env.production b/.env.production index eade8da1..6e29b4dc 100644 --- a/.env.production +++ b/.env.production @@ -194,4 +194,7 @@ EDP_MASTER_DATA_SYNC_CRON="0 23 * * *" REVALIDATION_SECRET="biwjeofijosdkfjoiwejfksdjf1" # 오픈 전, 벤더에게 특정 메뉴 보이지 않기, 운영 배포시 true로 설정할 것 (나준규프로 요청사항) -NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false
\ No newline at end of file +NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN=false + +# DOLCE Local Uplaod Directory +DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY="/evcp/data/dolce"
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx b/app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx new file mode 100644 index 00000000..513cfe1e --- /dev/null +++ b/app/[lng]/partners/(partners)/dolce-upload-v3/dolce-upload-page-v3.tsx @@ -0,0 +1,585 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useParams } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { InfoIcon, RefreshCw, Search, Upload, Plus, Cloud } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; +import { + UnifiedDwgReceiptItem, + DetailDwgReceiptItem, + FileInfoItem, + fetchDwgReceiptList, + getVendorSessionInfo, + fetchVendorProjects, + fetchDetailDwgReceiptListV2, + fetchFileInfoListV2, + fetchPendingSyncItems, + deleteLocalFile, +} from "@/lib/dolce-v2/actions"; +import { DrawingListTableV2 } from "@/lib/dolce/table/drawing-list-table-v2"; +import { drawingListColumns } from "@/lib/dolce/table/drawing-list-columns"; +import { createGttDrawingListColumns, DocumentType } from "@/lib/dolce/table/gtt-drawing-list-columns"; +import { createDetailDrawingColumns } from "@/lib/dolce/table/detail-drawing-columns"; +import { createFileListColumns } from "@/lib/dolce/table/file-list-columns"; + +// 다이얼로그 (V2/V3) +import { B4BulkUploadDialogV3Sync } from "@/lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3"; +import { AddAndModifyDetailDrawingDialogV2 } from "@/lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2"; +import { UploadFilesToDetailDialogV2 } from "@/lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2"; +import { SyncItemsDialog } from "@/lib/dolce-v2/dialogs/sync-items-dialog"; + +interface DolceUploadPageV3Props { + searchParams: { [key: string]: string | string[] | undefined }; +} + +export default function DolceUploadPageV3({ searchParams }: DolceUploadPageV3Props) { + const params = useParams(); + const lng = params?.lng as string; + const { t } = useTranslation(lng, "dolce"); + + // URL에서 초기 프로젝트 코드 + const initialProjNo = (searchParams.projNo as string) || ""; + + // 상태 관리 + const [drawings, setDrawings] = useState<UnifiedDwgReceiptItem[]>([]); + const [projects, setProjects] = useState<Array<{ code: string; name: string }>>([]); + const [vendorInfo, setVendorInfo] = useState<{ + userId: string; + userName: string; + email: string; + vendorCode: string; + vendorName: string; + drawingKind: "B3" | "B4"; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState<string | null>(null); + + // 필터 상태 + const [projNo, setProjNo] = useState(initialProjNo); + const [drawingNo, setDrawingNo] = useState(""); + const [drawingName, setDrawingName] = useState(""); + const [discipline, setDiscipline] = useState(""); + const [manager, setManager] = useState(""); + const [documentType, setDocumentType] = useState<DocumentType>("ALL"); // B4 전용 + + // 선택된 도면 및 상세도면 + const [selectedDrawing, setSelectedDrawing] = useState<UnifiedDwgReceiptItem | null>(null); + const [detailDrawings, setDetailDrawings] = useState<DetailDwgReceiptItem[]>([]); + const [selectedDetail, setSelectedDetail] = useState<DetailDwgReceiptItem | null>(null); + const [files, setFiles] = useState<FileInfoItem[]>([]); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [, setIsLoadingFiles] = useState(false); + + // 다이얼로그 상태 + const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = useState(false); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [uploadFilesDialogOpen, setUploadFilesDialogOpen] = useState(false); + const [syncDialogOpen, setSyncDialogOpen] = useState(false); + + // 동기화 상태 + const [pendingSyncCount, setPendingSyncCount] = useState(0); + + // 미동기화 건수 확인 + const checkPendingSync = useCallback(async () => { + if (!projNo || !vendorInfo) return; + try { + const items = await fetchPendingSyncItems({ projectNo: projNo, userId: vendorInfo.userId }); + setPendingSyncCount(items.length); + } catch (e) { + console.error("Failed to check pending sync items", e); + } + }, [projNo, vendorInfo]); + + // 초기 데이터 로드 + const loadInitialData = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + + const [vendorInfoData, projectsData] = await Promise.all([ + getVendorSessionInfo(), + fetchVendorProjects(), + ]); + + setVendorInfo(vendorInfoData as typeof vendorInfo); + setProjects(projectsData); + + if (initialProjNo) { + const drawingsData = await fetchDwgReceiptList({ + project: initialProjNo, + drawingKind: vendorInfoData.drawingKind, + drawingVendor: vendorInfoData.drawingKind === "B3" ? vendorInfoData.vendorCode : "", + }); + setDrawings(drawingsData); + } + } catch (err) { + console.error("초기 데이터 로드 실패:", err); + setError(err instanceof Error ? err.message : t("page.initialLoadError")); + toast.error(t("page.initialLoadError")); + } finally { + setIsLoading(false); + } + }, [initialProjNo, t]); + + // 도면 목록 조회 + const loadDrawings = useCallback(async () => { + if (!projNo || !vendorInfo) return; + + try { + setIsRefreshing(true); + setError(null); + + const drawingsData = await fetchDwgReceiptList({ + project: projNo, + drawingKind: vendorInfo.drawingKind, + drawingVendor: vendorInfo.drawingKind === "B3" ? vendorInfo.vendorCode : "", + }); + + setDrawings(drawingsData); + toast.success(t("page.drawingLoadSuccess")); + + // 동기화 상태 체크 + checkPendingSync(); + + } catch (err) { + console.error("도면 로드 실패:", err); + setError(err instanceof Error ? err.message : t("page.drawingLoadError")); + toast.error(t("page.drawingLoadError")); + } finally { + setIsRefreshing(false); + } + }, [projNo, vendorInfo, t, checkPendingSync]); + + // 상세도면 목록 로드 (V2 API 사용) + const loadDetailDrawings = useCallback(async () => { + if (!selectedDrawing) { + setDetailDrawings([]); + setSelectedDetail(null); + return; + } + + try { + setIsLoadingDetails(true); + // V2: 로컬 임시 저장 건 포함 조회 + const data = await fetchDetailDwgReceiptListV2({ + project: selectedDrawing.ProjectNo, + drawingNo: selectedDrawing.DrawingNo, + discipline: selectedDrawing.Discipline, + drawingKind: selectedDrawing.DrawingKind, + userId: vendorInfo?.userId || "", + }); + setDetailDrawings(data); + + if (data.length > 0) { + setSelectedDetail(data[0]); + } else { + setSelectedDetail(null); + } + + // 동기화 상태 체크 + checkPendingSync(); + + } catch (error) { + console.error("상세도면 로드 실패:", error); + toast.error(t("detailDialog.detailLoadError")); + setDetailDrawings([]); + setSelectedDetail(null); + } finally { + setIsLoadingDetails(false); + } + }, [selectedDrawing, vendorInfo, t, checkPendingSync]); + + // 파일 목록 로드 (V2 API 사용) + const loadFiles = useCallback(async () => { + if (!selectedDetail) { + setFiles([]); + return; + } + + try { + setIsLoadingFiles(true); + // V2: 로컬 임시 파일 포함 조회 + const data = await fetchFileInfoListV2(selectedDetail.UploadId); + setFiles(data); + + // 동기화 상태 체크 + checkPendingSync(); + + } catch (error) { + console.error("파일 목록 로드 실패:", error); + toast.error(t("detailDialog.fileLoadError")); + setFiles([]); + } finally { + setIsLoadingFiles(false); + } + }, [selectedDetail, t, checkPendingSync]); + + // 동기화 완료 핸들러 + const handleSyncComplete = useCallback(() => { + checkPendingSync(); + if (selectedDrawing) loadDetailDrawings(); + if (selectedDetail) loadFiles(); + }, [checkPendingSync, selectedDrawing, selectedDetail, loadDetailDrawings, loadFiles]); + + // 초기 데이터 로드 + useEffect(() => { + loadInitialData(); + }, [loadInitialData]); + + // 프로젝트 변경 시 자동 검색 + useEffect(() => { + if (projNo && vendorInfo) { + loadDrawings(); + } + }, [projNo, vendorInfo, loadDrawings]); + + // 선택된 도면 변경 시 상세도면 로드 + useEffect(() => { + loadDetailDrawings(); + }, [selectedDrawing, loadDetailDrawings]); + + // 선택된 상세도면 변경 시 파일 목록 로드 + useEffect(() => { + loadFiles(); + }, [selectedDetail, loadFiles]); + + const handleDrawingClick = (drawing: UnifiedDwgReceiptItem) => { + setSelectedDrawing(drawing); + }; + + const handleSearch = () => { + loadDrawings(); + }; + + const handleRefresh = () => { + loadDrawings(); + }; + + const handleRefreshDetails = () => { + loadDetailDrawings(); + }; + + // 완료 핸들러들 + const handleBulkUploadComplete = () => { + loadDrawings(); + checkPendingSync(); + }; + const handleAddComplete = () => { + setAddDialogOpen(false); + loadDetailDrawings(); + checkPendingSync(); + }; + const handleUploadComplete = () => { + setUploadFilesDialogOpen(false); + loadFiles(); + checkPendingSync(); + }; + + const handleDeleteFile = async (file: FileInfoItem) => { + if (!confirm(lng === "ko" ? "정말로 파일을 삭제하시겠습니까?" : "Are you sure you want to delete this file?")) return; + + try { + const result = await deleteLocalFile(file.FileId); + if (result.success) { + toast.success(lng === "ko" ? "파일이 삭제되었습니다." : "File deleted."); + loadFiles(); + checkPendingSync(); + } else { + throw new Error(result.error); + } + } catch (e) { + console.error("File delete failed", e); + toast.error(lng === "ko" ? "파일 삭제 실패" : "File delete failed"); + } + }; + + const handleDownload = async (file: FileInfoItem) => { + try { + toast.info(t("detailDialog.downloadPreparing")); + const response = await fetch("/api/dolce/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileId: file.FileId, + userId: file.CreateUserId, + fileName: file.FileName, + }), + }); + + if (!response.ok) throw new Error(t("detailDialog.downloadError")); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = file.FileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success(t("detailDialog.downloadSuccess")); + } catch (error) { + console.error("파일 다운로드 실패:", error); + toast.error(t("detailDialog.downloadError")); + } + }; + + // 필터 로직 + const filteredDrawings = useMemo(() => { + let result = drawings.filter((drawing) => { + if (drawingNo && !drawing.DrawingNo.toLowerCase().includes(drawingNo.toLowerCase())) return false; + if (drawingName && !drawing.DrawingName.toLowerCase().includes(drawingName.toLowerCase())) return false; + if (discipline && !drawing.Discipline?.toLowerCase().includes(discipline.toLowerCase())) return false; + if (manager && !drawing.Manager.toLowerCase().includes(manager.toLowerCase()) && + !drawing.ManagerENM?.toLowerCase().includes(manager.toLowerCase())) return false; + return true; + }); + + if (vendorInfo?.drawingKind === "B4" && documentType !== "ALL") { + result = result.filter((drawing) => { + if (drawing.DrawingKind !== "B4") return false; + const gttDrawing = drawing as { DrawingMoveGbn?: string }; + if (documentType === "SHI_INPUT") return gttDrawing.DrawingMoveGbn === "도면제출"; + else if (documentType === "GTT_DELIVERABLES") return gttDrawing.DrawingMoveGbn === "도면입수"; + return true; + }); + } + return result; + }, [drawings, drawingNo, drawingName, discipline, manager, vendorInfo?.drawingKind, documentType]); + + const getDetailDrawingId = (detail: DetailDwgReceiptItem) => `${detail.RegisterId}_${detail.UploadId}`; + const getDrawingId = (drawing: UnifiedDwgReceiptItem) => `${drawing.ProjectNo}_${drawing.DrawingNo}_${drawing.Discipline}`; + + const canAddDetailDrawing = vendorInfo && ( + vendorInfo.drawingKind === "B3" || + (vendorInfo.drawingKind === "B4" && selectedDrawing && 'DrawingMoveGbn' in selectedDrawing && selectedDrawing.DrawingMoveGbn === "도면입수") + ); + + // 파일 리스트 컬럼 정의 + const fileColumns = createFileListColumns({ + onDownload: handleDownload, + onDelete: handleDeleteFile, + lng + }); + + if (isLoading) { + return ( + <div className="space-y-4"> + <Card><CardHeader><Skeleton className="h-8 w-48" /></CardHeader><CardContent><Skeleton className="h-32 w-full" /></CardContent></Card> + <Card><CardHeader><Skeleton className="h-8 w-48" /></CardHeader><CardContent><Skeleton className="h-96 w-full" /></CardContent></Card> + </div> + ); + } + + return ( + <div className="space-y-4 max-w-full overflow-x-hidden h-full flex flex-col"> + {error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>} + + {/* 헤더 및 Sync 컨트롤 */} + <div className="flex items-center justify-between"> + <div> + <h2 className="text-xl font-bold flex items-center gap-2"> + Dolce Upload V3 <span className="text-xs font-normal bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 px-2 py-1 rounded-full">Sync Enabled</span> + </h2> + </div> + <div className="flex items-center gap-2"> + <Button + size="sm" + variant={pendingSyncCount > 0 ? "default" : "outline"} + onClick={() => setSyncDialogOpen(true)} // 다이얼로그 열기 + disabled={pendingSyncCount === 0 || !projNo} + > + <Cloud className="h-4 w-4 mr-2" /> + Send to SHI + </Button> + </div> + </div> + + {!projNo && <Alert><InfoIcon className="h-4 w-4" /><AlertDescription>{t("page.selectProject")}</AlertDescription></Alert>} + + {/* 필터 카드 */} + <Card className="flex-shrink-0"> + <CardHeader className="py-3"><CardTitle className="text-base">{t("filter.title")}</CardTitle></CardHeader> + <CardContent className="py-3"> + <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3"> + <div className="space-y-1"> + <Label className="text-xs">{t("filter.project")}</Label> + <Select value={projNo} onValueChange={setProjNo}> + <SelectTrigger className="h-8"><SelectValue placeholder={t("filter.projectPlaceholder")} /></SelectTrigger> + <SelectContent> + {projects.map((p) => <SelectItem key={p.code} value={p.code}>{p.code} - {p.name}</SelectItem>)} + </SelectContent> + </Select> + </div> + {/* 기타 필터들 */} + <div className="space-y-1"><Label className="text-xs">{t("filter.drawingNo")}</Label><Input className="h-8" value={drawingNo} onChange={(e) => setDrawingNo(e.target.value)} /></div> + <div className="space-y-1"><Label className="text-xs">{t("filter.drawingName")}</Label><Input className="h-8" value={drawingName} onChange={(e) => setDrawingName(e.target.value)} /></div> + <div className="space-y-1"><Label className="text-xs">{t("filter.discipline")}</Label><Input className="h-8" value={discipline} onChange={(e) => setDiscipline(e.target.value)} /></div> + <div className="space-y-1"><Label className="text-xs">{t("filter.manager")}</Label><Input className="h-8" value={manager} onChange={(e) => setManager(e.target.value)} /></div> + {vendorInfo?.drawingKind === "B4" && ( + <div className="space-y-1"> + <Label className="text-xs">{t("filter.documentType")}</Label> + <Select value={documentType} onValueChange={(v) => setDocumentType(v as DocumentType)}> + <SelectTrigger className="h-8"><SelectValue /></SelectTrigger> + <SelectContent> + <SelectItem value="ALL">{t("filter.documentTypeAll")}</SelectItem> + <SelectItem value="GTT_DELIVERABLES">{t("filter.documentTypeGttDeliverables")}</SelectItem> + <SelectItem value="SHI_INPUT">{t("filter.documentTypeSHIInput")}</SelectItem> + </SelectContent> + </Select> + </div> + )} + </div> + <div className="flex gap-2 mt-3 justify-end"> + <Button size="sm" onClick={handleSearch} disabled={!projNo || isRefreshing}><Search className="h-4 w-4 mr-2" />{t("filter.searchButton")}</Button> + {vendorInfo?.drawingKind === "B4" && ( + <Button size="sm" onClick={() => setBulkUploadDialogOpen(true)} disabled={!projNo || isRefreshing}> + <Upload className="h-4 w-4 mr-2" />{t("filter.bulkUploadButton")} + </Button> + )} + <Button size="sm" variant="outline" onClick={handleRefresh} disabled={!projNo || isRefreshing}><RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} /></Button> + </div> + </CardContent> + </Card> + + {/* 메인 컨텐츠 영역 */} + <Card className="flex-shrink-0" style={{ minHeight: "400px" }}> + <CardHeader className="py-3"><CardTitle className="text-base">{t("drawingList.title")}</CardTitle></CardHeader> + <CardContent className="p-0"> + <DrawingListTableV2 + columns={ + vendorInfo?.drawingKind === "B4" + ? (createGttDrawingListColumns({ documentType, lng, t }) as unknown as typeof drawingListColumns as any) + : (drawingListColumns(lng, t) as unknown as typeof drawingListColumns as any) + } + data={filteredDrawings} + onRowClick={handleDrawingClick} + selectedRow={selectedDrawing || undefined} + getRowId={getDrawingId} + maxHeight="400px" + minHeight="300px" + /> + </CardContent> + </Card> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0"> + <Card className="flex flex-col min-h-0"> + <CardHeader className="flex-row items-center justify-between py-3"> + <CardTitle className="text-base">{t("detailDialog.detailListTitle")}</CardTitle> + <div className="flex gap-2"> + <Button variant="outline" size="sm" onClick={handleRefreshDetails} disabled={!selectedDrawing}><RefreshCw className={`h-4 w-4 ${isLoadingDetails ? "animate-spin" : ""}`} /></Button> + {canAddDetailDrawing && <Button size="sm" onClick={() => setAddDialogOpen(true)} disabled={!selectedDrawing}><Plus className="h-4 w-4 mr-2" />{t("detailDialog.addDetailButton")}</Button>} + </div> + </CardHeader> + <CardContent className="p-0 flex-1"> + <DrawingListTableV2 + columns={createDetailDrawingColumns(lng, t) as any} + data={detailDrawings} + onRowClick={setSelectedDetail as any} + selectedRow={selectedDetail || undefined} + getRowId={getDetailDrawingId} + maxHeight="400px" + minHeight="300px" + /> + </CardContent> + </Card> + + <Card className="flex flex-col min-h-0"> + <CardHeader className="flex-row items-center justify-between py-3"> + <CardTitle className="text-base">{t("detailDialog.fileListTitle")}</CardTitle> + {selectedDetail && canAddDetailDrawing && ( + <Button size="sm" onClick={() => setUploadFilesDialogOpen(true)}><Upload className="h-4 w-4 mr-2" />{t("detailDialog.uploadFilesButton")}</Button> + )} + </CardHeader> + <CardContent className="p-0 flex-1"> + <DrawingListTableV2 + columns={fileColumns as any} + data={files} + maxHeight="400px" + minHeight="300px" + /> + </CardContent> + </Card> + </div> + + {/* 다이얼로그 영역 */} + {vendorInfo && vendorInfo.drawingKind === "B4" && projNo && ( + <B4BulkUploadDialogV3Sync + open={bulkUploadDialogOpen} + onOpenChange={setBulkUploadDialogOpen} + projectNo={projNo} + userId={vendorInfo.userId} + userName={vendorInfo.userName} + userEmail={vendorInfo.email} + vendorCode={vendorInfo.vendorCode} + onUploadComplete={handleBulkUploadComplete} + lng={lng} + /> + )} + + {vendorInfo && selectedDrawing && ( + <AddAndModifyDetailDrawingDialogV2 + open={addDialogOpen} + onOpenChange={setAddDialogOpen} + drawing={selectedDrawing} + vendorCode={vendorInfo.vendorCode} + userId={vendorInfo.userId} + userName={vendorInfo.userName} + userEmail={vendorInfo.email} + onComplete={handleAddComplete} + drawingKind={vendorInfo.drawingKind} + lng={lng} + /> + )} + + {vendorInfo && selectedDetail && ( + <UploadFilesToDetailDialogV2 + open={uploadFilesDialogOpen} + onOpenChange={setUploadFilesDialogOpen} + uploadId={selectedDetail.UploadId} + drawingNo={selectedDetail.DrawingNo} + revNo={selectedDetail.DrawingRevNo} + // 추가된 props + drawingName={selectedDrawing?.DrawingName} + discipline={selectedDrawing?.Discipline} + registerKind={selectedDetail.RegisterKind} + + userId={vendorInfo.userId} + projectNo={projNo} + vendorCode={vendorInfo.vendorCode} // 추가: Vendor Code + onUploadComplete={handleUploadComplete} + lng={lng} + /> + )} + + {/* 동기화 다이얼로그 */} + {vendorInfo && projNo && ( + <SyncItemsDialog + open={syncDialogOpen} + onOpenChange={setSyncDialogOpen} + projectNo={projNo} + userId={vendorInfo.userId} + vendorCode={vendorInfo.vendorCode} + onSyncComplete={handleSyncComplete} + lng={lng} + /> + )} + </div> + ); +} diff --git a/app/[lng]/partners/(partners)/dolce-upload-v3/page.tsx b/app/[lng]/partners/(partners)/dolce-upload-v3/page.tsx new file mode 100644 index 00000000..f62f486b --- /dev/null +++ b/app/[lng]/partners/(partners)/dolce-upload-v3/page.tsx @@ -0,0 +1,49 @@ +import { Suspense } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import DolceUploadPageV3 from "./dolce-upload-page-v3"; +import { Shell } from "@/components/shell"; + +export const metadata = { + title: "조선 벤더문서 업로드(DOLCE) V3", + description: "조선 설계문서 업로드 및 관리 - 오프라인 동기화 지원", +}; + +function DolceUploadSkeleton() { + return ( + <div className="space-y-4"> + <Card><CardHeader><Skeleton className="h-8 w-48" /></CardHeader><CardContent><Skeleton className="h-32 w-full" /></CardContent></Card> + <Card><CardHeader><Skeleton className="h-8 w-48" /></CardHeader><CardContent><Skeleton className="h-96 w-full" /></CardContent></Card> + </div> + ); +} + +export default async function DolceUploadPageWrapper({ + params, + searchParams, +}: { + params: Promise<{ lng: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const { lng } = await params; + const resolvedParams = await searchParams; + + return ( + <Shell variant="fullscreen"> + <div className="flex items-center justify-between flex-shrink-0"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + {lng === "ko" ? "DOLCE 도면 업로드 V3 (동기화)" : "DOLCE Drawing Upload V3 (Sync)"} + </h2> + <p className="text-muted-foreground"> + {lng === "ko" ? "임시 저장 및 서버 동기화 기능을 지원합니다." : "Supports temporary save and server synchronization."} + </p> + </div> + </div> + + <Suspense fallback={<DolceUploadSkeleton />}> + <DolceUploadPageV3 searchParams={resolvedParams} /> + </Suspense> + </Shell> + ); +} diff --git a/app/api/dolce/download/route.ts b/app/api/dolce/download/route.ts index 9d5eb601..94fe35b4 100644 --- a/app/api/dolce/download/route.ts +++ b/app/api/dolce/download/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { downloadDolceFile } from "@/lib/dolce/actions"; +import { downloadDolceFile, downloadLocalFile } from "@/lib/dolce-v2/actions"; export async function POST(request: NextRequest) { try { @@ -15,6 +15,19 @@ export async function POST(request: NextRequest) { console.log("[DOLCE Download API] 요청:", { fileId, userId, fileName }); + // 로컬 파일 다운로드 처리 + if (String(fileId).startsWith("LOCAL_")) { + const { buffer, fileName: localFileName } = await downloadLocalFile(fileId); + + return new NextResponse(buffer, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(localFileName)}`, + "Content-Length": buffer.byteLength.toString(), + }, + }); + } + // DOLCE API를 통해 파일 다운로드 const { blob, fileName: downloadFileName } = await downloadDolceFile({ fileId, diff --git a/db/schema/dolce/dolce.ts b/db/schema/dolce/dolce.ts new file mode 100644 index 00000000..378d29d2 --- /dev/null +++ b/db/schema/dolce/dolce.ts @@ -0,0 +1,89 @@ +import { pgSchema, varchar, timestamp, jsonb, text, index, serial, boolean, uuid, integer } from "drizzle-orm/pg-core"; + +export const dolceSchema = pgSchema("dolce"); + +/** + * 돌체 스키마: 동기화 기능을 만들기 위해, 로컬에 임시 저장을 위한 퍼시스턴스 레이어를 구성 + * + * 전체적인 설명: + * 외부 시스템인 돌체는 문서관리 시스템임. + * 우리 시스템은 돌체와 API 를 통해, 벤더가 문서를 넣어줄 수 있게 하는 역할을 함. + * + * 특정 프로젝트에 B3, B4 라는 타입으로 문서를 관리함 + * B3는 일반 벤더, B4는 GTT 라는 특정 벤더에 대한 타입임. + * + * 전체적인 구조는 B3, B4가 유사함 + * - 도면리스트: 외부시스템에서 관리하므로, 우리는 받기만 함 + * + * - 상세도면리스트: 특정 도면에 소속된 개별 도면임. + * Revision을 관리하기 위해 생긴 레이어이며, 개별 도면에 연결된, 파일이 첨부된 실제 수발신하는 도면 단위라고 보면 됨. + * 외부시스템(DOLCE)에서 리스트를 API로 응답함. 개별 도면별로 응답함. + * 우리 시스템에서 벤더가 생성한다. (생성 API를 호출함) 단, 외부 시스템 측에서도 작업해 응답 결과를 다르게 줄 수도 있음. + * 우리 시스템에서 벤더가 수정할 수 있다. (수정 API를 호출함) 수정시에도 로컬 측에 저장하고, 동기화시 수정된 내용을 외부시스템에 전송한다. + * 우리 시스템에서 벤더가 상세도면을 생성했을 때, 우선 우리 시스템 DB에만 생성하고, API 응답 리스트에 로컬DB 임시저장값을 추가해서 보여줄 것임. + * + * - 파일리스트: 외부시스템에서 관리하며, 개별 상세도면에 첨부된 파일 리스트임. + * 외부시스템(DOLCE)에서 특정 상세도면의 파일 리스트를 API로 조회할 수 있음. 개별 상세도면에 할당된 uploadId 별로 파일리스트를 응답함. + * 우리 시스템에서 벤더가 파일을 추가하기도 함. 이 경우, 우선 우리 시스템 DB에 추가한 파일 정보를 임시 저장하고, API 응답 리스트에 로컬DB 임시저장값을 추가해서 보여줄 것임. + * + * 동기화 기능 + * - 우리 시스템에서 상세도면 추가 및 첨부파일을 추가했던 건들을 외부시스템에 전송해주는 기능임 + * - 임시 저장시 dolce에 생성/수정 요청을 할 수 있도록 필요한 정보를 저장해야 함. 이것은 스키마의 작성 기준임 + * - 동기화 하기 이전에, 우리 시스템에서 임시로 저장한 건들은 삭제할 수 있도록 함. (soft 삭제가 아니고 hard 삭제) + * + * 동기화 목록 구성 방법 + * - isSynced==false 인 건들로 목록을 구성한다. + * - 상세도면 추가 작업 건은, 상세도면+첨부파일을 하나의 동기화 건으로 구성한다. + * - 파일 추가 작업 건은, 개별 파일들을 동기화 건으로 구성한다. + * - B4 Bulk 업로드 건은, 개별 파일들을 동기화 건으로 구성한다. + * - 사용자는 본인이 임시 저장한 건들만을 선택해서 동기화할 수 있도록 한다. + * + * 사용자가 로컬에 파일 업로드하는 부분의 구현 + * 1. 환경변수 DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY="/evcp/data/dolce" 가 설정되어 있음. (없으면 경로 만들기) + * 2. 로컬에서 저장할 때는 파일 메타데이터를 별도로 저장 (로컬 파일 경로는 API 호출시 사용하지 않으나, 로컬 건들도 조회 및 다운로드가 가능해야 하므로) + * 3. 동기화되지 않아 로컬에만 있는 건들은 파일 다운로드시 로컬에서 다운로드 + * 4. 동기화 성공한 경우, 로컬에 저장된 파일은 삭제 + */ + +export const dolceSyncList = dolceSchema.table("dolce_sync_list", { + id: uuid("id").primaryKey().defaultRandom(), + + // [필수] 작업 유형 + // "ADD_DETAIL": 상세도면 추가 (메타데이터 + 파일) + // "MOD_DETAIL": 상세도면 수정 (메타데이터) + // "ADD_FILE": 기존 상세도면에 파일 추가 (파일) + // "B4_BULK": B4 일괄 업로드 + type: varchar("type", { length: 50 }).notNull(), + + // [중요] 조회 성능 및 필터링을 위한 메타데이터 컬럼 (JSONB에서 자주 쓰는 값 추출) + projectNo: varchar("project_no", { length: 50 }).notNull(), // 프로젝트별 조회용 + drawingNo: varchar("drawing_no", { length: 100 }), // 특정 도면 조회용 + uploadId: varchar("upload_id", { length: 100 }), // 업로드ID 기준 조회용 + + userId: varchar("user_id", { length: 50 }).notNull(), // 내 작업만 보기용 + userName: varchar("user_name", { length: 100 }), // [추가] UI 표시용 + vendorCode: varchar("vendor_code", { length: 50 }), // [추가] 벤더별 필터링용 + + // [데이터] + // 실제 API 호출에 필요한 Body 데이터 + 로컬 파일 경로 정보 포함 + // 예: { meta: { ...API Body... }, files: [{ localPath: "...", originalName: "..." }] } + payload: jsonb("payload").notNull(), + + // [상태] + isSynced: boolean("is_synced").default(false).notNull(), + syncAttempts: integer("sync_attempts").default(0).notNull(), + lastError: text("last_error"), // 실패 사유 + + // [응답] + responseCode: varchar("response_code", { length: 50 }), + response: text("response"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (table) => ({ + // 인덱스 설정: 프로젝트별 미동기화 건 조회 속도 향상 + projectIdx: index("dolce_sync_project_idx").on(table.projectNo, table.isSynced), + userIdx: index("dolce_sync_user_idx").on(table.userId, table.isSynced), + // [추가] 벤더별 조회 인덱스 + vendorIdx: index("dolce_sync_vendor_idx").on(table.projectNo, table.vendorCode, table.isSynced), +})); diff --git a/db/schema/index.ts b/db/schema/index.ts index 1155740b..df4ca424 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -85,3 +85,5 @@ export * from './avl/vendor-pool'; // === Email Logs 스키마 === export * from './emailLogs'; export * from './emailWhitelist'; +// Dolce 로컬 저장용 스키마 +export * from './dolce/dolce';
\ No newline at end of file diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index e49c0628..c9ad43ff 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -9,6 +9,7 @@ import { unstable_cache } from "@/lib/unstable-cache"; import { sendEmail } from '@/lib/mail/sendEmail' import { saveFile } from '@/lib/file-stroage' import { sendBiddingNoticeSms } from '@/lib/users/auth/passwordUtil' +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils' // userId를 user.name으로 변환하는 유틸리티 함수 async function getUserNameById(userId: string): Promise<string> { @@ -786,6 +787,7 @@ export async function markAsDisposal(biddingId: number, userId: string) { // 입찰 등록 ( 본입찰 초대 발송) export async function registerBidding(biddingId: number, userId: string) { + debugLog('registerBidding started', { biddingId, userId }) try { // 본입찰에서 개별적으로 추가한 업체들 조회 const selectedCompanies = await db @@ -800,6 +802,8 @@ export async function registerBidding(biddingId: number, userId: string) { eq(biddingCompanies.biddingId, biddingId) ) + debugLog('registerBidding: selectedCompanies fetched', { count: selectedCompanies.length, selectedCompanies }) + // 입찰 정보 조회 const biddingInfo = await db .select() @@ -808,14 +812,18 @@ export async function registerBidding(biddingId: number, userId: string) { .limit(1) if (biddingInfo.length === 0) { + debugError('registerBidding: Bidding info not found', { biddingId }) return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } } const bidding = biddingInfo[0] + debugLog('registerBidding: bidding info fetched', { bidding }) const userName = await getUserNameById(userId) + debugLog('registerBidding: userName fetched', { userName }) await db.transaction(async (tx) => { + debugLog('registerBidding: Transaction started') // 1. 입찰 상태를 오픈으로 변경 await tx .update(biddings) @@ -840,9 +848,11 @@ export async function registerBidding(biddingId: number, userId: string) { eq(biddingCompanies.companyId, company.companyId) )) } + debugLog('registerBidding: Transaction completed (status updated, companies invited)') }) // 3. 선정된 업체들에게 본입찰 초대 메일 발송 + debugLog('registerBidding: Sending emails...') for (const company of selectedCompanies) { if (company.contactEmail) { try { @@ -864,12 +874,14 @@ export async function registerBidding(biddingId: number, userId: string) { language: 'ko' } }) + debugLog(`registerBidding: Email sent to ${company.contactEmail}`) } catch (emailError) { - console.error(`Failed to send bidding invitation email to ${company.contactEmail}:`, emailError) + debugError(`Failed to send bidding invitation email to ${company.contactEmail}:`, emailError) } } } // 4. 입찰 공고 SMS 알림 전송 + debugLog('registerBidding: Sending SMS...') for (const company of selectedCompanies) { // biddingCompaniesContacts에서 모든 연락처 전화번호 조회 const contactInfos = await db @@ -890,12 +902,12 @@ export async function registerBidding(biddingId: number, userId: string) { try { const smsResult = await sendBiddingNoticeSms(contactPhone, bidding.title); if (smsResult.success) { - console.log(`입찰 공고 SMS 전송 성공: ${contactPhone} - ${bidding.title}`); + debugLog(`입찰 공고 SMS 전송 성공: ${contactPhone} - ${bidding.title}`); } else { - console.error(`입찰 공고 SMS 전송 실패: ${contactPhone} - ${smsResult.error}`); + debugError(`입찰 공고 SMS 전송 실패: ${contactPhone} - ${smsResult.error}`); } } catch (smsError) { - console.error(`Failed to send bidding notice SMS to ${contactPhone}:`, smsError) + debugError(`Failed to send bidding notice SMS to ${contactPhone}:`, smsError) } } } @@ -908,21 +920,25 @@ export async function registerBidding(biddingId: number, userId: string) { revalidateTag('pr-items') revalidatePath(`/evcp/bid/${biddingId}`) + debugSuccess(`registerBidding: Success. Invited ${selectedCompanies.length} companies.`) return { success: true, message: `입찰이 성공적으로 등록되었습니다. ${selectedCompanies.length}개 업체에 초대 메일을 발송했습니다.` } } catch (error) { - console.error('Failed to register bidding:', error) - return { success: false, error: '입찰 등록에 실패했습니다.' } + debugError('Failed to register bidding:', error) + return { success: false, error: `입찰 등록에 실패했습니다. ${error}` } } } // 업체 선정 사유 업데이트 export async function updateVendorSelectionReason(biddingId: number, selectedCompanyId: number, selectionReason: string, userId: string) { + debugLog('updateVendorSelectionReason started', { biddingId, selectedCompanyId, selectionReason, userId }) try { const userName = await getUserNameById(userId) + debugLog('updateVendorSelectionReason: userName fetched', { userName }) + // vendorSelectionResults 테이블에 삽입 또는 업데이트 await db .insert(vendorSelectionResults) @@ -946,13 +962,17 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom } }) + debugLog('updateVendorSelectionReason: DB updated') + // 캐시 무효화 revalidateTag(`bidding-${biddingId}`) revalidateTag('quotation-vendors') revalidatePath(`/evcp/bid/${biddingId}`) + + debugSuccess('updateVendorSelectionReason: Success') return { success: true, message: '업체 선정 사유가 성공적으로 업데이트되었습니다.' } } catch (error) { - console.error('Failed to update vendor selection reason:', error) + debugError('Failed to update vendor selection reason:', error) return { success: false, error: '업체 선정 사유 업데이트에 실패했습니다.' } } } diff --git a/lib/dolce-v2/actions.ts b/lib/dolce-v2/actions.ts new file mode 100644 index 00000000..020da94e --- /dev/null +++ b/lib/dolce-v2/actions.ts @@ -0,0 +1,605 @@ +"use server"; + +import { + fetchDwgReceiptList as fetchDwgOriginal, + fetchDetailDwgReceiptList as fetchDetailOriginal, + fetchFileInfoList as fetchFileOriginal, + getVendorSessionInfo as getVendorSessionInfoOriginal, + fetchVendorProjects as fetchVendorProjectsOriginal, + downloadDolceFile as downloadDolceFileOriginal, + // 타입들 재사용 + DwgReceiptItem, GttDwgReceiptItem, UnifiedDwgReceiptItem, DetailDwgReceiptItem, FileInfoItem, + DetailDwgEditRequest, B4MappingSaveItem +} from "@/lib/dolce/actions"; +import db from "@/db/db"; +import { dolceSyncList } from "@/db/schema/dolce/dolce"; +import { eq, and, desc } from "drizzle-orm"; +import { saveToLocalBuffer, syncItem, getLocalFile, deleteLocalItem, deleteLocalFileFromItem } from "./sync-service"; + +// 타입 재-export +export type { + DwgReceiptItem, GttDwgReceiptItem, UnifiedDwgReceiptItem, + DetailDwgReceiptItem, FileInfoItem, DetailDwgEditRequest +}; + +// Re-export 함수들을 명시적인 async 함수로 래핑 +export async function getVendorSessionInfo() { + return getVendorSessionInfoOriginal(); +} + +export async function fetchVendorProjects() { + return fetchVendorProjectsOriginal(); +} + +export async function downloadDolceFile(params: { + fileId: string; + userId: string; + fileName: string; +}) { + return downloadDolceFileOriginal(params); +} + +// ============================================================================ +// 조회 액션 (로컬 데이터 병합) +// ============================================================================ + +/** + * 1. 도면 리스트 조회 (변경 없음 - 도면 리스트 자체는 외부 시스템 기준) + */ +export async function fetchDwgReceiptList(params: { + project: string; + drawingKind: string; + drawingMoveGbn?: string; + drawingNo?: string; + drawingName?: string; + drawingVendor?: string; + discipline?: string; +}) { + return fetchDwgOriginal(params); +} + +/** + * 2. 상세도면 리스트 조회 (로컬 임시 저장 건 병합) + */ +export async function fetchDetailDwgReceiptListV2(params: { + project: string; + drawingNo: string; + discipline: string; + drawingKind: string; + userId?: string; +}): Promise<DetailDwgReceiptItem[]> { + // 1. 외부 API 조회 + const originalList = await fetchDetailOriginal(params); + + // 2. 로컬 DB 조회 (미동기화된 ADD_DETAIL 건) + // projectNo와 drawingNo로 필터링 + const localItems = await db.query.dolceSyncList.findMany({ + where: and( + eq(dolceSyncList.projectNo, params.project), + eq(dolceSyncList.drawingNo, params.drawingNo), + eq(dolceSyncList.type, "ADD_DETAIL"), + eq(dolceSyncList.isSynced, false) + ), + orderBy: [desc(dolceSyncList.createdAt)], + }); + + // 3. 로컬 데이터를 DetailDwgReceiptItem 형식으로 변환하여 추가 + const mergedList = [...originalList]; + + for (const item of localItems) { + const payload = item.payload as { meta: { dwgList: DetailDwgEditRequest[] } }; + const dwgRequest = payload.meta.dwgList[0]; // 보통 1개씩 요청함 + + if (dwgRequest) { + // 임시 객체 생성 + const tempItem: DetailDwgReceiptItem = { + Category: dwgRequest.Category, + CategoryENM: dwgRequest.Category, // 임시: 코드값 사용 + CategoryNM: dwgRequest.Category, // 임시 + CreateDt: item.createdAt.toISOString(), + CreateUserENM: "", + CreateUserId: item.userId, + CreateUserNM: "", // 이름은 별도 조회 필요하나 생략 + Discipline: dwgRequest.Discipline, + DrawingKind: dwgRequest.DrawingKind, + DrawingName: dwgRequest.DrawingName, + DrawingNo: dwgRequest.DrawingNo, + DrawingRevNo: dwgRequest.DrawingRevNo || "", + DrawingUsage: "TEMP", // 임시 표시 + DrawingUsageENM: "Temporary Saved", + DrawingUsageNM: "임시저장", + Manager: dwgRequest.Manager, + Mode: "ADD", + OFDC_NO: "", + ProjectNo: dwgRequest.ProjectNo, + Receiver: dwgRequest.Receiver, + RegCompanyCode: dwgRequest.RegCompanyCode, + RegCompanyENM: "", + RegCompanyNM: "", + RegisterDesc: dwgRequest.RegisterDesc, + RegisterGroup: dwgRequest.RegisterGroupId, + RegisterGroupId: dwgRequest.RegisterGroupId, + RegisterId: 0, // 임시 ID + RegisterKind: dwgRequest.RegisterKind, + RegisterKindENM: dwgRequest.RegisterKind, // 임시: 코드값 사용 + RegisterKindNM: dwgRequest.RegisterKind, + RegisterSerialNo: dwgRequest.RegisterSerialNo, + SHINote: null, + Status: "EVCP Saved", // 작성중 + UploadId: dwgRequest.UploadId, + }; + + // 리스트 상단에 추가 (혹은 날짜순 정렬) + mergedList.unshift(tempItem); + } + } + + return mergedList; +} + +/** + * 3. 파일 리스트 조회 (로컬 임시 저장 건 병합) + */ +export async function fetchFileInfoListV2(uploadId: string): Promise<FileInfoItem[]> { + // 1. 외부 API 조회 + const originalList = await fetchFileOriginal(uploadId); + + // 2. 로컬 DB 조회 (이 uploadId에 대해 추가된 파일들) + // ADD_DETAIL(새 도면 생성 시 파일) 또는 ADD_FILE(기존 도면에 파일 추가) 모두 해당될 수 있음. + // upload_id 컬럼으로 조회 + const localItems = await db.query.dolceSyncList.findMany({ + where: and( + eq(dolceSyncList.uploadId, uploadId), + eq(dolceSyncList.isSynced, false) + ), + }); + + const mergedList = [...originalList]; + + for (const item of localItems) { + const payload = item.payload as { files: Array<{ originalName: string, size: number, localPath: string }> }; + + if (payload.files && payload.files.length > 0) { + payload.files.forEach((file, index) => { + const tempFile: FileInfoItem = { + CreateDt: item.createdAt.toISOString(), + CreateUserId: item.userId, + Deleted: "False", + FileDescription: "Local Temp File", + FileId: `LOCAL_${item.id}_${index}`, // 로컬 파일 식별자 + FileName: file.originalName, + FileRelativePath: file.localPath, // 로컬 경로 (다운로드 시 구분 필요) + FileSeq: String(9999 + index), // 임시 시퀀스 + FileServerId: "LOCAL", + FileSize: String(file.size), + FileTitle: null, + FileWriteDT: item.createdAt.toISOString(), + OwnerUserId: item.userId, + SourceDrmYn: "N", + SystemId: "EVCP", + TableName: "Temp", + UploadId: uploadId, + UseYn: "True" + }; + mergedList.push(tempFile); + }); + } + } + + return mergedList; +} + +// ============================================================================ +// 저장 액션 (로컬 버퍼링) +// ============================================================================ + +/** + * 4. 상세도면 추가/수정 (로컬 저장) + */ +export async function editDetailDwgReceiptV2( + formData: FormData +): Promise<{ success: boolean, syncId: string }> { + try { + const dwgListJson = formData.get("dwgList") as string; + const userId = formData.get("userId") as string; + const userNm = formData.get("userNm") as string; + const vendorCode = formData.get("vendorCode") as string; + const email = formData.get("email") as string; + + if (!dwgListJson || !userId) { + throw new Error("Required parameters are missing"); + } + + const dwgList = JSON.parse(dwgListJson) as DetailDwgEditRequest[]; + const request = dwgList[0]; // 보통 1건 처리 + const type = request.Mode === "ADD" ? "ADD_DETAIL" : "MOD_DETAIL"; + + // FormData에서 파일 추출 + const files: File[] = []; + // file_0, file_1 ... 형식으로 전송된다고 가정 + const fileCount = parseInt((formData.get("fileCount") as string) || "0"); + + for (let i = 0; i < fileCount; i++) { + const file = formData.get(`file_${i}`); + if (file instanceof File) { + files.push(file); + } + } + + const savedItem = await saveToLocalBuffer({ + type, + projectNo: request.ProjectNo, + userId, + userName: userNm, // [추가] + vendorCode: vendorCode, // [추가] + drawingNo: request.DrawingNo, + uploadId: request.UploadId, + metaData: { + dwgList, + userId, + userNm, + vendorCode, + email + }, + files + }); + + return { success: true, syncId: savedItem.id }; + + } catch (error) { + console.error("상세도면 로컬 저장 실패:", error); + throw error; + } +} + +/** + * 5. 파일 추가 (로컬 저장) + */ +export async function uploadFilesToDetailDrawingV2( + formData: FormData +): Promise<{ success: boolean, syncId?: string, error?: string }> { + try { + const uploadId = formData.get("uploadId") as string; + const userId = formData.get("userId") as string; + const fileCount = parseInt(formData.get("fileCount") as string); + const projectNo = formData.get("projectNo") as string; + const vendorCode = formData.get("vendorCode") as string; + + // [추가] 메타데이터 추출 + const drawingNo = formData.get("drawingNo") as string; + const revNo = formData.get("revNo") as string; + const drawingName = formData.get("drawingName") as string; + const discipline = formData.get("discipline") as string; + const registerKind = formData.get("registerKind") as string; + + if (!uploadId || !userId) { + throw new Error("Required parameters are missing"); + } + + const files: File[] = []; + for (let i = 0; i < fileCount; i++) { + const file = formData.get(`file_${i}`) as File; + if (file) files.push(file); + } + + const savedItem = await saveToLocalBuffer({ + type: "ADD_FILE", + projectNo: projectNo || "UNKNOWN", + userId, + vendorCode, + drawingNo: drawingNo || undefined, // [추가] DB drawingNo 컬럼 저장 + uploadId, + metaData: { + uploadId, + userId, + vendorCode, + // [추가] + drawingNo, + revNo, + drawingName, + discipline, + registerKind + }, + files + }); + + return { success: true, syncId: savedItem.id }; + } catch (error) { + console.error("파일 로컬 저장 실패:", error); + return { success: false, error: error instanceof Error ? error.message : "Unknown error" }; + } +} + +/** + * 6. 동기화 실행 액션 + */ +export async function syncDolceItem(id: string) { + return syncItem(id); +} + +/** + * 7. 미동기화 아이템 목록 조회 (내것만 - Sync 버튼 카운트용) + */ +export async function fetchPendingSyncItems(params: { + projectNo: string; + userId: string; +}): Promise<Array<{ id: string; type: string; desc: string }>> { + try { + const items = await db.query.dolceSyncList.findMany({ + where: and( + eq(dolceSyncList.projectNo, params.projectNo), + eq(dolceSyncList.userId, params.userId), + eq(dolceSyncList.isSynced, false) + ), + orderBy: [desc(dolceSyncList.createdAt)], + }); + + return items.map((item) => { + let desc = ""; + + if (item.type === "ADD_DETAIL" || item.type === "MOD_DETAIL") { + desc = `${item.type === "ADD_DETAIL" ? "Add" : "Mod"} Drawing ${item.drawingNo}`; + } else if (item.type === "ADD_FILE") { + desc = `Add Files to ${item.uploadId}`; + } else if (item.type === "B4_BULK") { + desc = `Bulk Upload ${item.drawingNo}`; + } + + return { + id: item.id, + type: item.type, + desc, + }; + }); + } catch (error) { + console.error("미동기화 목록 조회 실패:", error); + return []; + } +} + +// 상세 동기화 정보 인터페이스 +export interface PendingSyncItemDetail { + id: string; // syncId + type: string; + createdAt: Date; + userId: string; + userName: string; + + // 도면 정보 + drawingNo: string; + drawingName: string; + discipline: string; + + // 상세도면 정보 + revision: string; + registerKind: string; // 접수종류 + + // 파일 정보 + files: Array<{ + name: string; + size: number; + }>; +} + +/** + * 8. 프로젝트 전체 미동기화 아이템 목록 조회 (동기화 다이얼로그용 - 상세 정보 포함) + */ +export async function fetchProjectPendingSyncItems(params: { + projectNo: string; + currentUserId: string; + currentVendorCode: string; +}): Promise<{ + myItems: PendingSyncItemDetail[]; + otherItems: PendingSyncItemDetail[]; +}> { + try { + // 1. 내 아이템 조회 (userId 이용) + const myItemsPromise = db.query.dolceSyncList.findMany({ + where: and( + eq(dolceSyncList.projectNo, params.projectNo), + eq(dolceSyncList.userId, params.currentUserId), + eq(dolceSyncList.isSynced, false) + ), + orderBy: [desc(dolceSyncList.createdAt)], + }); + + // 2. 같은 벤더의 다른 사용자 아이템 조회 (vendorCode 이용, userId 제외) + const otherItemsPromise = db.query.dolceSyncList.findMany({ + where: and( + eq(dolceSyncList.projectNo, params.projectNo), + eq(dolceSyncList.vendorCode, params.currentVendorCode), + eq(dolceSyncList.isSynced, false) + ), + orderBy: [desc(dolceSyncList.createdAt)], + }); + + const [myDbItems, otherDbItems] = await Promise.all([myItemsPromise, otherItemsPromise]); + + // 아이템 상세 정보 파싱 헬퍼 + const parseItem = (item: typeof dolceSyncList.$inferSelect): PendingSyncItemDetail => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload = item.payload as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const files = payload.files?.map((f: any) => ({ name: f.originalName, size: f.size })) || []; + const meta = payload.meta || {}; + + let drawingNo = item.drawingNo || ""; + let drawingName = ""; + let discipline = ""; + let revision = ""; + let registerKind = ""; + + if (item.type === "ADD_DETAIL" || item.type === "MOD_DETAIL") { + const dwgRequest = meta.dwgList?.[0]; + if (dwgRequest) { + drawingNo = dwgRequest.DrawingNo; + drawingName = dwgRequest.DrawingName; + discipline = dwgRequest.Discipline; + revision = dwgRequest.DrawingRevNo || ""; + registerKind = dwgRequest.RegisterKind || ""; + } + } else if (item.type === "ADD_FILE") { + // ADD_FILE의 경우 meta에 저장된 정보 사용 + drawingNo = meta.drawingNo || item.drawingNo || ""; + drawingName = meta.drawingName || ""; + discipline = meta.discipline || ""; + revision = meta.revNo || ""; + registerKind = meta.registerKind || ""; + } else if (item.type === "B4_BULK") { + // B4_BULK의 경우 첫 번째 매핑 정보 사용 (보통 같은 도면) + const firstMapping = meta.mappingSaveLists?.[0]; + if (firstMapping) { + drawingNo = firstMapping.DrawingNo; + drawingName = firstMapping.DrawingName; + discipline = firstMapping.Discipline; + revision = firstMapping.RevNo; + registerKind = firstMapping.RegisterKindCode || ""; + } + } + + return { + id: item.id, + type: item.type, + createdAt: item.createdAt, + userId: item.userId, + userName: item.userName || item.userId, + drawingNo, + drawingName, + discipline, + revision, + registerKind, + files, + }; + }; + + // 내 아이템 매핑 + const myItems = myDbItems.map(parseItem); + + // 다른 사용자 아이템 매핑 + const otherItems = otherDbItems + .filter(item => item.userId !== params.currentUserId) + .map(parseItem); + + return { myItems, otherItems }; + } catch (error) { + console.error("프로젝트 미동기화 목록 조회 실패:", error); + return { myItems: [], otherItems: [] }; + } +} + +// B4 일괄 업로드 (로컬 저장 버전) +// ============================================================================ + +/** + * B4 일괄 업로드 V3 (로컬 저장) + */ +export async function bulkUploadB4FilesV3( + formData: FormData +): Promise<{ success: boolean, syncIds: string[], error?: string }> { + try { + // FormData에서 메타데이터 추출 + const projectNo = formData.get("projectNo") as string; + const userId = formData.get("userId") as string; + const userNm = formData.get("userNm") as string; + const email = formData.get("email") as string; + const vendorCode = formData.get("vendorCode") as string; + const fileCount = parseInt(formData.get("fileCount") as string); + + const syncIds: string[] = []; + + // 그룹핑: UploadId 기준 (하나의 상세도면에 여러 파일이 들어갈 수 있음) + const groups = new Map<string, { + files: File[], + mappings: B4MappingSaveItem[], // 타입을 명시적으로 지정 + drawingNo: string + }>(); + + for (let i = 0; i < fileCount; i++) { + const file = formData.get(`file_${i}`) as File; + const mappingJson = formData.get(`mappingData_${i}`) as string; + + if (!file || !mappingJson) continue; + + const mapping = JSON.parse(mappingJson); + const uploadId = mapping.UploadId; + + if (!groups.has(uploadId)) { + groups.set(uploadId, { files: [], mappings: [], drawingNo: mapping.DrawingNo }); + } + + groups.get(uploadId)!.files.push(file); + groups.get(uploadId)!.mappings.push(mapping); + } + + // 각 그룹(상세도면 단위)별로 로컬 저장 + for (const [uploadId, group] of groups.entries()) { + const savedItem = await saveToLocalBuffer({ + type: "B4_BULK", + projectNo, + userId, + userName: userNm, // [추가] + vendorCode: vendorCode, // [추가] + drawingNo: group.drawingNo, + uploadId, + metaData: { + mappingSaveLists: group.mappings, + userInfo: { userId, userName: userNm, vendorCode, email } + }, + files: group.files + }); + syncIds.push(savedItem.id); + } + + return { success: true, syncIds }; + + } catch (error) { + console.error("B4 일괄 업로드 로컬 저장 실패:", error); + return { success: false, syncIds: [], error: error instanceof Error ? error.message : "Unknown error" }; + } +} + +/** + * 9. 로컬 파일 다운로드 (Buffer 반환) + */ +export async function downloadLocalFile(fileId: string) { + return getLocalFile(fileId); +} + +/** + * 10. 로컬 상세도면 삭제 + */ +export async function deleteLocalDetailDrawing(uploadId: string) { + try { + // Find the item by uploadId and type + // We assume one ADD_DETAIL per uploadId for now (as per prepareB4DetailDrawingsV2 logic) + const item = await db.query.dolceSyncList.findFirst({ + where: and( + eq(dolceSyncList.uploadId, uploadId), + eq(dolceSyncList.type, "ADD_DETAIL"), + eq(dolceSyncList.isSynced, false) + ) + }); + + if (item) { + await deleteLocalItem(item.id); + return { success: true }; + } + return { success: false, error: "Item not found" }; + } catch (e) { + console.error("Failed to delete local drawing", e); + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +/** + * 11. 로컬 파일 삭제 + */ +export async function deleteLocalFile(fileId: string) { + try { + await deleteLocalFileFromItem(fileId); + return { success: true }; + } catch (e) { + console.error("Failed to delete local file", e); + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} diff --git a/lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx b/lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx new file mode 100644 index 00000000..b8650b1a --- /dev/null +++ b/lib/dolce-v2/dialogs/add-and-modify-detail-drawing-dialog-v2.tsx @@ -0,0 +1,695 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Textarea } from "@/components/ui/textarea"; +import { Upload, X, FileIcon, Info, Loader2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; +import { + UnifiedDwgReceiptItem, + DetailDwgReceiptItem, + editDetailDwgReceiptV2, // V2 Action + deleteLocalDetailDrawing +} from "@/lib/dolce-v2/actions"; +import { v4 as uuidv4 } from "uuid"; +import { useFileUploadWithProgress } from "@/lib/dolce/hooks/use-file-upload-with-progress"; +// import { uploadFilesWithProgress } from "../utils/upload-with-progress"; // V2에서는 사용 안함 (Action에 포함) +import { + getB3DrawingUsageOptions, + getB3RegisterKindOptions, + getB4DrawingUsageOptions, + getB4RegisterKindOptions +} from "@/lib/dolce/utils/code-translator"; + +interface AddAndModifyDetailDrawingDialogV2Props { + open: boolean; + onOpenChange: (open: boolean) => void; + drawing: UnifiedDwgReceiptItem | null; + vendorCode: string; + userId: string; + userName: string; + userEmail: string; + onComplete: () => void; + drawingKind: "B3" | "B4"; + lng: string; + mode?: "add" | "edit"; + detailDrawing?: DetailDwgReceiptItem | null; +} + +export function AddAndModifyDetailDrawingDialogV2({ + open, + onOpenChange, + drawing, + vendorCode, + userId, + userName, + userEmail, + onComplete, + drawingKind, + lng, + mode = "add", + detailDrawing = null, +}: AddAndModifyDetailDrawingDialogV2Props) { + const { t } = useTranslation(lng, "dolce"); + const [drawingUsage, setDrawingUsage] = useState<string>(""); + const [registerKind, setRegisterKind] = useState<string>(""); + const [revision, setRevision] = useState<string>(""); + const [revisionError, setRevisionError] = useState<string>(""); + const [comment, setComment] = useState<string>(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Edit 모드일 때 초기값 설정 + useEffect(() => { + if (mode === "edit" && detailDrawing && open) { + setDrawingUsage(detailDrawing.DrawingUsage || ""); + setRegisterKind(detailDrawing.RegisterKind || ""); + setRevision(detailDrawing.DrawingRevNo || ""); + setComment(detailDrawing.RegisterDesc || ""); + } else if (mode === "add" && open) { + // Add 모드로 열릴 때는 초기화 + resetForm(); + } + }, [mode, detailDrawing, open]); + + // 옵션 생성 (다국어 지원) + const drawingUsageOptions = drawingKind === "B3" + ? getB3DrawingUsageOptions(lng) + : getB4DrawingUsageOptions(lng); + + const registerKindOptions = drawingKind === "B3" + ? getB3RegisterKindOptions(drawingUsage, lng).map(opt => ({ + ...opt, + revisionRule: lng === "ko" ? "예: A, B, C 또는 R00, R01, R02" : "e.g. A, B, C or R00, R01, R02" + })) + : getB4RegisterKindOptions(drawingUsage, lng).map(opt => ({ + ...opt, + revisionRule: lng === "ko" ? "예: R00, R01, R02, R03" : "e.g. R00, R01, R02, R03" + })); + + // 파일 업로드 훅 사용 + const { + files, + removeFile, + clearFiles, + getRootProps, + getInputProps, + isDragActive, + } = useFileUploadWithProgress(); + + // Revision 유효성 검증 함수 + const validateRevision = (value: string): string => { + if (!value.trim()) { + return t("addDetailDialog.revisionRequired"); + } + + const upperValue = value.toUpperCase().trim(); + + // A-Z 패턴 (단일 알파벳) + if (/^[A-Z]$/.test(upperValue)) { + return ""; + } + + // R00-R99 패턴 + if (/^R\d{2}$/.test(upperValue)) { + return ""; + } + + return t("addDetailDialog.revisionInvalidFormat"); + }; + + // Revision 입력 핸들러 + const handleRevisionChange = (value: string) => { + const processedValue = value.toUpperCase(); + setRevision(processedValue); + + // 값이 있을 때만 validation + if (processedValue.trim()) { + const error = validateRevision(processedValue); + setRevisionError(error); + } else { + setRevisionError(""); + } + }; + + // 폼 초기화 + const resetForm = () => { + setDrawingUsage(""); + setRegisterKind(""); + setRevision(""); + setRevisionError(""); + setComment(""); + clearFiles(); + }; + + // 제출 + const handleSubmit = async () => { + // 유효성 검사 + if (!registerKind) { + toast.error(t("addDetailDialog.selectRegisterKindError")); + return; + } + + if (drawingUsage !== "CMT") { + if (!revision.trim()) { + toast.error(t("addDetailDialog.selectRevisionError")); + setRevisionError(t("addDetailDialog.revisionRequired")); + return; + } + + // Revision 형식 검증 + const revisionValidationError = validateRevision(revision); + if (revisionValidationError) { + toast.error(revisionValidationError); + setRevisionError(revisionValidationError); + return; + } + } + + // Add 모드일 때만 파일 필수 + if (mode === "add") { + if (!drawing) return; + if (!drawingUsage) { + toast.error(t("addDetailDialog.selectDrawingUsageError")); + return; + } + if (files.length === 0) { + toast.error(t("addDetailDialog.selectFilesError")); + return; + } + } + + // Edit 모드일 때는 detailDrawing 필수 + if (mode === "edit" && !detailDrawing) { + toast.error(t("editDetailDialog.editError")); + return; + } + + try { + setIsSubmitting(true); + + // FormData 구성 + const formData = new FormData(); + formData.append("userId", userId); + formData.append("userNm", userName); + formData.append("vendorCode", vendorCode); + formData.append("email", userEmail); + + if (mode === "add" && drawing) { + const uploadId = uuidv4(); + + const dwgList = [ + { + Mode: "ADD", + Status: "Submitted", + RegisterId: 0, + ProjectNo: drawing.ProjectNo, + Discipline: drawing.Discipline, + DrawingKind: drawing.DrawingKind, + DrawingNo: drawing.DrawingNo, + DrawingName: drawing.DrawingName, + RegisterGroupId: drawing.RegisterGroupId, + RegisterSerialNo: 0, + RegisterKind: registerKind, + DrawingRevNo: drawingUsage === "CMT" ? null : revision, + Category: "TS", + Receiver: null, + Manager: "", + RegisterDesc: comment, + UploadId: uploadId, + RegCompanyCode: vendorCode, + }, + ]; + formData.append("dwgList", JSON.stringify(dwgList)); + + // 파일 추가 + formData.append("fileCount", String(files.length)); + files.forEach((file, index) => { + formData.append(`file_${index}`, file); + }); + + } else if (mode === "edit" && detailDrawing) { + const dwgList = [ + { + Mode: "MOD", + Status: detailDrawing.Status, + RegisterId: detailDrawing.RegisterId, + ProjectNo: detailDrawing.ProjectNo, + Discipline: detailDrawing.Discipline, + DrawingKind: detailDrawing.DrawingKind, + DrawingNo: detailDrawing.DrawingNo, + DrawingName: detailDrawing.DrawingName, + RegisterGroupId: detailDrawing.RegisterGroupId, + RegisterSerialNo: detailDrawing.RegisterSerialNo, + RegisterKind: registerKind, + DrawingRevNo: drawingUsage === "CMT" ? null : revision, + Category: detailDrawing.Category, + Receiver: detailDrawing.Receiver, + Manager: detailDrawing.Manager, + RegisterDesc: comment, + UploadId: detailDrawing.UploadId, + RegCompanyCode: detailDrawing.RegCompanyCode || vendorCode, + }, + ]; + formData.append("dwgList", JSON.stringify(dwgList)); + formData.append("fileCount", "0"); // 수정 시에는 메타데이터만 수정 (파일 수정은 별도) + } + + // Action 호출 + const result = await editDetailDwgReceiptV2(formData); + + if (result.success) { + toast.success(mode === "add" ? t("addDetailDialog.addSuccess") : t("editDetailDialog.editSuccess")); + resetForm(); + onComplete(); + onOpenChange(false); + } else { + throw new Error("Action failed"); + } + + } catch (error) { + console.error("상세도면 처리 실패:", error); + toast.error(mode === "add" ? t("addDetailDialog.addErrorMessage") : t("editDetailDialog.editErrorMessage")); + } finally { + setIsSubmitting(false); + } + }; + + // 삭제 핸들러 + const handleDelete = async () => { + if (!detailDrawing) return; + + if (!confirm(lng === "ko" ? "정말로 삭제하시겠습니까?" : "Are you sure you want to delete?")) return; + + try { + setIsSubmitting(true); + // uploadId만 있으면 됨 + const result = await deleteLocalDetailDrawing(detailDrawing.UploadId); + + if (result.success) { + toast.success(lng === "ko" ? "삭제되었습니다." : "Deleted successfully."); + onComplete(); + onOpenChange(false); + } else { + throw new Error(result.error); + } + } catch (error) { + console.error("삭제 실패:", error); + toast.error(lng === "ko" ? "삭제 실패" : "Delete failed"); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + // 유효성 검사 + if (!registerKind) { + toast.error(t("addDetailDialog.selectRegisterKindError")); + return; + } + + if (drawingUsage !== "CMT") { + if (!revision.trim()) { + toast.error(t("addDetailDialog.selectRevisionError")); + setRevisionError(t("addDetailDialog.revisionRequired")); + return; + } + + // Revision 형식 검증 + const revisionValidationError = validateRevision(revision); + if (revisionValidationError) { + toast.error(revisionValidationError); + setRevisionError(revisionValidationError); + return; + } + } + + // Add 모드일 때만 파일 필수 + if (mode === "add") { + if (!drawing) return; + if (!drawingUsage) { + toast.error(t("addDetailDialog.selectDrawingUsageError")); + return; + } + if (files.length === 0) { + toast.error(t("addDetailDialog.selectFilesError")); + return; + } + } + + // Edit 모드일 때는 detailDrawing 필수 + if (mode === "edit" && !detailDrawing) { + toast.error(t("editDetailDialog.editError")); + return; + } + + try { + setIsSubmitting(true); + + // FormData 구성 + const formData = new FormData(); + formData.append("userId", userId); + formData.append("userNm", userName); + formData.append("vendorCode", vendorCode); + formData.append("email", userEmail); + + if (mode === "add" && drawing) { + const uploadId = uuidv4(); + + const dwgList = [ + { + Mode: "ADD", + Status: "Submitted", + RegisterId: 0, + ProjectNo: drawing.ProjectNo, + Discipline: drawing.Discipline, + DrawingKind: drawing.DrawingKind, + DrawingNo: drawing.DrawingNo, + DrawingName: drawing.DrawingName, + RegisterGroupId: drawing.RegisterGroupId, + RegisterSerialNo: 0, + RegisterKind: registerKind, + DrawingRevNo: drawingUsage === "CMT" ? null : revision, + Category: "TS", + Receiver: null, + Manager: "", + RegisterDesc: comment, + UploadId: uploadId, + RegCompanyCode: vendorCode, + }, + ]; + formData.append("dwgList", JSON.stringify(dwgList)); + + // 파일 추가 + formData.append("fileCount", String(files.length)); + files.forEach((file, index) => { + formData.append(`file_${index}`, file); + }); + + } else if (mode === "edit" && detailDrawing) { + const dwgList = [ + { + Mode: "MOD", + Status: detailDrawing.Status, + RegisterId: detailDrawing.RegisterId, + ProjectNo: detailDrawing.ProjectNo, + Discipline: detailDrawing.Discipline, + DrawingKind: detailDrawing.DrawingKind, + DrawingNo: detailDrawing.DrawingNo, + DrawingName: detailDrawing.DrawingName, + RegisterGroupId: detailDrawing.RegisterGroupId, + RegisterSerialNo: detailDrawing.RegisterSerialNo, + RegisterKind: registerKind, + DrawingRevNo: drawingUsage === "CMT" ? null : revision, + Category: detailDrawing.Category, + Receiver: detailDrawing.Receiver, + Manager: detailDrawing.Manager, + RegisterDesc: comment, + UploadId: detailDrawing.UploadId, + RegCompanyCode: detailDrawing.RegCompanyCode || vendorCode, + }, + ]; + formData.append("dwgList", JSON.stringify(dwgList)); + formData.append("fileCount", "0"); // 수정 시에는 메타데이터만 수정 (파일 수정은 별도) + } + + // Action 호출 + const result = await editDetailDwgReceiptV2(formData); + + if (result.success) { + toast.success(mode === "add" ? t("addDetailDialog.addSuccess") : t("editDetailDialog.editSuccess")); + resetForm(); + onComplete(); + onOpenChange(false); + } else { + throw new Error("Action failed"); + } + + } catch (error) { + console.error("상세도면 처리 실패:", error); + toast.error(mode === "add" ? t("addDetailDialog.addErrorMessage") : t("editDetailDialog.editErrorMessage")); + } finally { + setIsSubmitting(false); + } + }; + + // DrawingUsage가 변경되면 RegisterKind 초기화 + const handleDrawingUsageChange = (value: string) => { + setDrawingUsage(value); + setRegisterKind(""); + setRevision(""); + setRevisionError(""); + }; + + // 선택된 RegisterKind의 Revision Rule + const revisionRule = registerKindOptions.find((opt) => opt.value === registerKind)?.revisionRule || ""; + + // 버튼 활성화 조건 + const isFormValid = mode === "add" + ? drawingUsage.trim() !== "" && + registerKind.trim() !== "" && + (drawingUsage === "CMT" || (revision.trim() !== "" && !revisionError)) && + files.length > 0 + : registerKind.trim() !== "" && + (drawingUsage === "CMT" || (revision.trim() !== "" && !revisionError)); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle> + {mode === "edit" ? t("editDetailDialog.title") : t("addDetailDialog.title")} + </DialogTitle> + </DialogHeader> + + <div className="space-y-6"> + {/* 도면 정보 표시 */} + {mode === "add" && drawing && ( + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + <div className="font-medium">{drawing.DrawingNo}</div> + <div className="text-sm text-muted-foreground">{drawing.DrawingName}</div> + </AlertDescription> + </Alert> + )} + + {mode === "edit" && detailDrawing && ( + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + <div className="font-medium">{detailDrawing.DrawingNo} - Rev. {detailDrawing.DrawingRevNo}</div> + <div className="text-sm text-muted-foreground">{detailDrawing.DrawingName}</div> + </AlertDescription> + </Alert> + )} + + {/* 도면용도 선택 (Add 모드에서만 표시) */} + {mode === "add" && ( + <div className="space-y-2"> + <Label>{t("addDetailDialog.drawingUsageLabel")}</Label> + <Select value={drawingUsage} onValueChange={handleDrawingUsageChange}> + <SelectTrigger> + <SelectValue placeholder={t("addDetailDialog.drawingUsagePlaceholder")} /> + </SelectTrigger> + <SelectContent> + {drawingUsageOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + + {/* 등록종류 선택 */} + <div className="space-y-2"> + <Label>{t("addDetailDialog.registerKindLabel")}</Label> + <Select + value={registerKind} + onValueChange={setRegisterKind} + disabled={mode === "add" && !drawingUsage} + > + <SelectTrigger> + <SelectValue placeholder={t("addDetailDialog.registerKindPlaceholder")} /> + </SelectTrigger> + <SelectContent> + {registerKindOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + {revisionRule && ( + <p className="text-sm text-muted-foreground"> + {t("addDetailDialog.revisionFormatPrefix")}{revisionRule} + </p> + )} + </div> + + {/* Revision 입력 */} + {drawingUsage !== "CMT" && ( + <div className="space-y-2"> + <Label>{t("addDetailDialog.revisionLabel")}</Label> + <Input + value={revision} + onChange={(e) => handleRevisionChange(e.target.value)} + placeholder={t("addDetailDialog.revisionPlaceholder")} + disabled={!registerKind} + className={revisionError ? "border-red-500 focus-visible:ring-red-500" : ""} + /> + {revisionError && ( + <p className="text-sm text-red-500 flex items-center gap-1"> + {revisionError} + </p> + )} + {!revisionError && revision && ( + <p className="text-sm text-green-600 flex items-center gap-1"> + {t("addDetailDialog.revisionValid")} + </p> + )} + </div> + )} + + {/* Comment 입력 */} + <div className="space-y-2"> + <Label>{t("addDetailDialog.commentLabel")}</Label> + <Textarea + value={comment} + onChange={(e) => setComment(e.target.value)} + placeholder={t("addDetailDialog.commentPlaceholder")} + rows={3} + className="resize-none" + /> + <p className="text-xs text-muted-foreground"> + {t("addDetailDialog.commentMaxLength")} + </p> + </div> + + {/* 파일 업로드 (Add 모드에서만 표시) */} + {mode === "add" && ( + <div className="space-y-2"> + <Label>{t("addDetailDialog.attachmentLabel")}</Label> + <div + {...getRootProps()} + className={` + border-2 border-dashed rounded-lg p-8 text-center cursor-pointer + transition-colors + ${isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25"} + ${files.length > 0 ? "py-4" : ""} + `} + > + <input {...getInputProps()} /> + {files.length === 0 ? ( + <div className="space-y-2"> + <Upload className="h-8 w-8 mx-auto text-muted-foreground" /> + <div> + <p className="text-sm font-medium"> + {t("addDetailDialog.dragDropText")} + </p> + <p className="text-xs text-muted-foreground"> + {t("addDetailDialog.fileInfo")} + </p> + </div> + </div> + ) : ( + <div className="space-y-2"> + <p className="text-sm font-medium"> + {t("addDetailDialog.filesSelected", { count: files.length })} + </p> + <p className="text-xs text-muted-foreground"> + {t("addDetailDialog.addMoreFiles")} + </p> + </div> + )} + </div> + + {/* 선택된 파일 목록 */} + {files.length > 0 && ( + <div className="space-y-2 mt-4"> + <div className="flex items-center justify-between mb-2"> + <h4 className="text-sm font-medium"> + {t("addDetailDialog.selectedFiles", { count: files.length })} + </h4> + <Button + variant="ghost" + size="sm" + onClick={clearFiles} + > + {t("addDetailDialog.removeAll")} + </Button> + </div> + <div className="max-h-60 overflow-y-auto space-y-2"> + {files.map((file, index) => ( + <div + key={index} + className="flex items-center gap-2 p-2 border rounded-lg bg-muted/50" + > + <FileIcon className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="flex-1 min-w-0"> + <p className="text-sm truncate">{file.name}</p> + <p className="text-xs text-muted-foreground"> + {(file.size / 1024 / 1024).toFixed(2)} MB + </p> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + )} + </div> + + <DialogFooter className="sm:justify-between"> + {mode === "edit" && detailDrawing?.Status === "EVCP Saved" && ( + <Button variant="destructive" onClick={handleDelete} disabled={isSubmitting} type="button"> + {isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />} + {lng === "ko" ? "삭제" : "Delete"} + </Button> + )} + <div className="flex gap-2 justify-end w-full"> + <Button variant="outline" onClick={handleCancel} disabled={isSubmitting}> + {t("addDetailDialog.cancelButton")} + </Button> + <Button onClick={handleSubmit} disabled={isSubmitting || !isFormValid}> + {isSubmitting + ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{t("addDetailDialog.processingButton")}</> + : mode === "edit" + ? t("editDetailDialog.updateButton") + : t("addDetailDialog.addButton") + } + </Button> + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx b/lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx new file mode 100644 index 00000000..5cce514c --- /dev/null +++ b/lib/dolce-v2/dialogs/b4-bulk-upload-dialog-v3.tsx @@ -0,0 +1,372 @@ +"use client"; + +import * as React from "react"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { FolderOpen, Loader2, ChevronRight, ChevronLeft, CheckCircle2 } from "lucide-react"; +import { toast } from "sonner"; +import { Progress } from "@/components/ui/progress"; +import { useTranslation } from "@/i18n/client"; +import { + validateB4FileName, + B4UploadValidationDialog, + type FileValidationResult, +} from "@/lib/dolce/dialogs/b4-upload-validation-dialog"; +import { + checkB4MappingStatus, + type MappingCheckResult, + type B4BulkUploadResult, +} from "@/lib/dolce/actions"; +import { bulkUploadB4FilesV3 } from "@/lib/dolce-v2/actions"; + +interface B4BulkUploadDialogV3SyncProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectNo: string; + userId: string; + userName: string; + userEmail: string; + vendorCode: string; + onUploadComplete?: () => void; + lng: string; +} + +type UploadStep = "files" | "validation" | "uploading" | "complete"; + +export function B4BulkUploadDialogV3Sync({ + open, + onOpenChange, + projectNo, + userId, + userName, + userEmail, + vendorCode, + onUploadComplete, + lng, +}: B4BulkUploadDialogV3SyncProps) { + const { t } = useTranslation(lng, "dolce"); + const [currentStep, setCurrentStep] = useState<UploadStep>("files"); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + const [validationResults, setValidationResults] = useState<FileValidationResult[]>([]); + const [mappingResultsMap, setMappingResultsMap] = useState<Map<string, MappingCheckResult>>(new Map()); + const [showValidationDialog, setShowValidationDialog] = useState(false); + const [isDragging, setIsDragging] = useState(false); + // const [uploadProgress, setUploadProgress] = useState(0); // 로컬 저장은 순식간이라 프로그레스 불필요 + const [uploadResult, setUploadResult] = useState<{ success: boolean, syncIds: string[], error?: string } | null>(null); + + // Reset on close + React.useEffect(() => { + if (!open) { + setCurrentStep("files"); + setSelectedFiles([]); + setValidationResults([]); + setMappingResultsMap(new Map()); + setShowValidationDialog(false); + setIsDragging(false); + setUploadResult(null); + } + }, [open]); + + // File Selection Handler (동일) + const handleFilesChange = (files: File[]) => { + if (files.length === 0) return; + const existingNames = new Set(selectedFiles.map((f) => f.name)); + const newFiles = files.filter((f) => !existingNames.has(f.name)); + if (newFiles.length === 0) { + toast.error(t("bulkUpload.duplicateFileError")); + return; + } + setSelectedFiles((prev) => [...prev, ...newFiles]); + toast.success(t("bulkUpload.filesSelectedSuccess", { count: newFiles.length })); + }; + + // Drag & Drop Handlers (생략 - 코드 길이 줄임) + const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; + const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "copy"; }; + const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const droppedFiles = Array.from(e.dataTransfer.files); if (droppedFiles.length > 0) handleFilesChange(droppedFiles); }; + const handleRemoveFile = (index: number) => { setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); }; + + // Step 1 Next: Validation (동일) + const handleFilesNext = () => { + if (selectedFiles.length === 0) { + toast.error(t("bulkUpload.selectFilesError")); + return; + } + setCurrentStep("validation"); + handleValidate(); + }; + + // Validation Process (V3 - 기존과 동일) + const handleValidate = async () => { + try { + // 1. Parse Filenames + const parseResults: FileValidationResult[] = selectedFiles.map((file) => { + const validation = validateB4FileName(file.name); + return { + file, + valid: validation.valid, + parsed: validation.parsed, + error: validation.error, + }; + }); + + const parsedFiles = parseResults.filter((r) => r.valid && r.parsed); + + if (parsedFiles.length === 0) { + setValidationResults(parseResults); + setShowValidationDialog(true); + return; + } + + // 2. Call MatchBatchFileDwg to check mapping status + const mappingCheckItems = parsedFiles.map((r) => ({ + DrawingNo: r.parsed!.drawingNo, + RevNo: r.parsed!.revNo, + FileNm: r.file.name, + })); + + const mappingResults = await checkB4MappingStatus(projectNo, mappingCheckItems); + + const newMappingResultsMap = new Map<string, MappingCheckResult>(); + mappingResults.forEach((result) => { + newMappingResultsMap.set(result.FileNm, result); + }); + setMappingResultsMap(newMappingResultsMap); + + // 3. Merge results + const finalResults: FileValidationResult[] = parseResults.map((parseResult) => { + if (!parseResult.valid || !parseResult.parsed) return parseResult; + const mappingResult = newMappingResultsMap.get(parseResult.file.name); + + if (!mappingResult) return { ...parseResult, mappingStatus: "not_found" as const, error: t("validation.notFound") }; + if (mappingResult.MappingYN !== "Y") return { ...parseResult, mappingStatus: "not_found" as const, error: t("validation.notRegistered") }; + if (mappingResult.DrawingMoveGbn !== "도면입수") return { ...parseResult, mappingStatus: "not_found" as const, error: t("validation.notGttDeliverables") }; + + return { + ...parseResult, + mappingStatus: "available" as const, + drawingName: mappingResult.DrawingName || undefined, + registerGroupId: mappingResult.RegisterGroupId, + }; + }); + + setValidationResults(finalResults); + setShowValidationDialog(true); + } catch (error) { + console.error("Validation failed:", error); + toast.error(error instanceof Error ? error.message : t("bulkUpload.validationError")); + setCurrentStep("files"); + } + }; + + // Confirm Upload & Save (V3 Sync - 수정됨) + const handleConfirmUpload = async (validFiles: FileValidationResult[]) => { + setIsUploading(true); + setCurrentStep("uploading"); + setShowValidationDialog(false); + + try { + // FormData 구성 + const formData = new FormData(); + formData.append("projectNo", projectNo); + formData.append("userId", userId); + formData.append("userNm", userName); + formData.append("email", userEmail); + formData.append("vendorCode", vendorCode); + formData.append("registerKind", ""); // B4는 mappingData에 있음, 혹은 필요하다면 추가 + formData.append("fileCount", String(validFiles.length)); + + validFiles.forEach((fileResult, index) => { + formData.append(`file_${index}`, fileResult.file); + + const mappingData = mappingResultsMap.get(fileResult.file.name); + if (mappingData) { + // UploadId가 없으면 생성 + if (!mappingData.UploadId) { + mappingData.UploadId = uuidv4(); // 임시 ID 생성 (서버에서 그룹핑용) + } + formData.append(`mappingData_${index}`, JSON.stringify(mappingData)); + } + }); + + // Action 호출 + const result = await bulkUploadB4FilesV3(formData); + + setUploadResult(result); + setCurrentStep("complete"); + + if (result.success) { + toast.success(t("bulkUpload.uploadSuccessToast", { successCount: validFiles.length, total: validFiles.length })); + } else { + toast.error(result.error || t("bulkUpload.uploadError")); + } + + } catch (error) { + console.error("Upload process failed:", error); + toast.error(error instanceof Error ? error.message : t("bulkUpload.uploadError")); + setCurrentStep("files"); + } finally { + setIsUploading(false); + } + }; + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>{t("bulkUpload.title")} (Offline)</DialogTitle> + <DialogDescription> + {currentStep === "files" && t("bulkUpload.stepFiles")} + {currentStep === "validation" && t("bulkUpload.stepValidation")} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* Step 1: Files */} + {currentStep === "files" && ( + <> + <div + className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 ${ + isDragging + ? "border-primary bg-primary/5 scale-[1.02]" + : "border-muted-foreground/30 hover:border-muted-foreground/50" + }`} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} + > + <input + type="file" + multiple + accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.dxf,.zip" + onChange={(e) => handleFilesChange(Array.from(e.target.files || []))} + className="hidden" + id="b4-file-upload-v3-sync" + /> + <label + htmlFor="b4-file-upload-v3-sync" + className="flex flex-col items-center justify-center cursor-pointer" + > + <FolderOpen + className={`h-12 w-12 mb-3 transition-colors ${ + isDragging ? "text-primary" : "text-muted-foreground" + }`} + /> + <p className="text-sm text-muted-foreground"> + {isDragging ? t("bulkUpload.fileDropHere") : t("bulkUpload.fileSelectArea")} + </p> + </label> + </div> + + {selectedFiles.length > 0 && ( + <div className="border rounded-lg p-4"> + <div className="flex items-center justify-between mb-3"> + <h4 className="text-sm font-medium"> + {t("bulkUpload.selectedFiles", { count: selectedFiles.length })} + </h4> + <Button variant="ghost" size="sm" onClick={() => setSelectedFiles([])}> + {t("bulkUpload.removeAll")} + </Button> + </div> + <div className="max-h-60 overflow-y-auto space-y-2"> + {selectedFiles.map((file, index) => ( + <div key={index} className="flex items-center justify-between p-2 rounded bg-muted/50"> + <p className="text-sm truncate">{file.name}</p> + <Button variant="ghost" size="sm" onClick={() => handleRemoveFile(index)}> + {t("bulkUpload.removeFile")} + </Button> + </div> + ))} + </div> + </div> + )} + </> + )} + + {/* Loading Indicator */} + {currentStep === "validation" && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-12 w-12 animate-spin text-primary mb-4" /> + <p className="text-sm text-muted-foreground">{t("bulkUpload.validating")}</p> + </div> + )} + + {/* Uploading (Saving locally) */} + {currentStep === "uploading" && ( + <div className="space-y-6 py-4"> + <div className="flex flex-col items-center"> + <Loader2 className="h-12 w-12 animate-spin text-primary mb-4" /> + <h3 className="text-lg font-semibold mb-2">Saving to Local...</h3> + <p className="text-sm text-muted-foreground">Please wait while we buffer your files.</p> + </div> + </div> + )} + + {/* Completion Screen */} + {currentStep === "complete" && uploadResult && ( + <div className="space-y-6 py-8"> + <div className="flex flex-col items-center"> + <CheckCircle2 className="h-16 w-16 text-green-500 mb-4" /> + <h3 className="text-lg font-semibold mb-2">Saved Locally</h3> + <p className="text-sm text-muted-foreground"> + {uploadResult.syncIds.length} items are ready to sync. + </p> + </div> + + <div className="flex justify-center"> + <Button onClick={() => { onOpenChange(false); onUploadComplete?.(); }}> + {t("bulkUpload.confirmButton")} + </Button> + </div> + </div> + )} + </div> + + {/* Footer */} + {currentStep !== "uploading" && currentStep !== "complete" && currentStep !== "validation" && ( + <DialogFooter> + {currentStep === "files" && ( + <> + <Button variant="outline" onClick={() => onOpenChange(false)}> + {t("bulkUpload.cancelButton")} + </Button> + <Button onClick={handleFilesNext} disabled={selectedFiles.length === 0}> + {t("bulkUpload.validateButton")} + <ChevronRight className="ml-2 h-4 w-4" /> + </Button> + </> + )} + </DialogFooter> + )} + </DialogContent> + </Dialog> + + {/* Validation Dialog */} + <B4UploadValidationDialog + open={showValidationDialog} + onOpenChange={(open) => { + setShowValidationDialog(open); + if (!open && currentStep !== "uploading" && currentStep !== "complete") { + setCurrentStep("files"); + } + }} + validationResults={validationResults} + onConfirmUpload={handleConfirmUpload} + isUploading={isUploading} + /> + </> + ); +} + diff --git a/lib/dolce-v2/dialogs/sync-items-dialog.tsx b/lib/dolce-v2/dialogs/sync-items-dialog.tsx new file mode 100644 index 00000000..93ea6ae6 --- /dev/null +++ b/lib/dolce-v2/dialogs/sync-items-dialog.tsx @@ -0,0 +1,376 @@ +"use client"; + +import * as React from "react"; +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Loader2, RefreshCw, CheckCircle2, XCircle, FileText, FolderInput } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; +import { fetchProjectPendingSyncItems, syncDolceItem, PendingSyncItemDetail } from "@/lib/dolce-v2/actions"; +import { format } from "date-fns"; + +interface SyncItemsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectNo: string; + userId: string; + vendorCode: string; + onSyncComplete: () => void; + lng: string; +} + +// UI 표시용 Row 타입 (파일 단위로 확장) +interface DisplayRow { + rowId: string; // 유니크 키 (syncId + fileIndex) + syncId: string; // Sync Item ID (체크박스 그룹핑용) + type: string; + createdAt: Date; + userName?: string; + status: "pending" | "syncing" | "success" | "error"; + errorMessage?: string; + + // 표시 정보 + drawingNo: string; + drawingName: string; + discipline: string; + revision: string; + registerKind: string; + fileName: string; + fileSize: string; +} + +export function SyncItemsDialog({ + open, + onOpenChange, + projectNo, + userId, + vendorCode, + onSyncComplete, + lng, +}: SyncItemsDialogProps) { + const { t } = useTranslation(lng, "dolce"); + const [isLoading, setIsLoading] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + + const [myRows, setMyRows] = useState<DisplayRow[]>([]); + const [otherRows, setOtherRows] = useState<DisplayRow[]>([]); + + // 선택된 Sync Item ID들 (파일 단위가 아니라 Sync Item 단위로 선택) + const [selectedSyncIds, setSelectedSyncIds] = useState<Set<string>>(new Set()); + + // 데이터 변환 헬퍼 + const convertToDisplayRows = (items: PendingSyncItemDetail[], defaultStatus: DisplayRow["status"] = "pending"): DisplayRow[] => { + return items.flatMap((item) => { + // 파일이 없으면 메타데이터만 있는 1개 행 생성 + if (!item.files || item.files.length === 0) { + return [{ + rowId: `${item.id}_meta`, + syncId: item.id, + type: item.type, + createdAt: item.createdAt, + userName: item.userName, + status: defaultStatus, + drawingNo: item.drawingNo, + drawingName: item.drawingName, + discipline: item.discipline, + revision: item.revision, + registerKind: item.registerKind, + fileName: "(Metadata Only)", + fileSize: "-", + }]; + } + + // 파일이 있으면 파일별로 행 생성 + return item.files.map((file, idx) => ({ + rowId: `${item.id}_file_${idx}`, + syncId: item.id, + type: item.type, + createdAt: item.createdAt, + userName: item.userName, + status: defaultStatus, + drawingNo: item.drawingNo, + drawingName: item.drawingName, + discipline: item.discipline, + revision: item.revision, + registerKind: item.registerKind, + fileName: file.name, + fileSize: (file.size / 1024 / 1024).toFixed(2) + " MB", + })); + }); + }; + + // 데이터 로드 + const loadData = async () => { + if (!open) return; + + setIsLoading(true); + try { + const { myItems, otherItems } = await fetchProjectPendingSyncItems({ + projectNo, + currentUserId: userId, + currentVendorCode: vendorCode, + }); + + setMyRows(convertToDisplayRows(myItems)); + setOtherRows(convertToDisplayRows(otherItems)); + + // 기본적으로 내 아이템 모두 선택 + setSelectedSyncIds(new Set(myItems.map(item => item.id))); + + } catch (error) { + console.error("Failed to load sync items:", error); + toast.error("Failed to load synchronization items."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (open) { + loadData(); + } + }, [open, projectNo, userId, vendorCode]); + + // 체크박스 핸들러 (Sync Item 단위로 토글) + const toggleSelect = (syncId: string) => { + const newSelected = new Set(selectedSyncIds); + if (newSelected.has(syncId)) { + newSelected.delete(syncId); + } else { + newSelected.add(syncId); + } + setSelectedSyncIds(newSelected); + }; + + const toggleSelectAll = () => { + // 현재 화면에 표시된 myRows에 포함된 모든 unique syncId 수집 + const allSyncIds = new Set(myRows.map(r => r.syncId)); + + if (selectedSyncIds.size === allSyncIds.size) { + setSelectedSyncIds(new Set()); + } else { + setSelectedSyncIds(allSyncIds); + } + }; + + // 동기화 실행 + const handleSync = async () => { + if (selectedSyncIds.size === 0) return; + + setIsSyncing(true); + + // 선택된 ID 목록 + const idsToSync = Array.from(selectedSyncIds); + let successCount = 0; + let failCount = 0; + + for (const id of idsToSync) { + // 상태: 동기화 중 (해당 syncId를 가진 모든 Row 업데이트) + setMyRows(prev => prev.map(r => r.syncId === id ? { ...r, status: "syncing" } : r)); + + try { + await syncDolceItem(id); + + // 상태: 성공 + setMyRows(prev => prev.map(r => r.syncId === id ? { ...r, status: "success" } : r)); + successCount++; + } catch (error) { + console.error(`Sync failed for ${id}:`, error); + // 상태: 실패 + setMyRows(prev => prev.map(r => r.syncId === id ? { ...r, status: "error", errorMessage: error instanceof Error ? error.message : "Unknown error" } : r)); + failCount++; + } + } + + setIsSyncing(false); + + if (successCount > 0) { + toast.success(`Successfully synced ${successCount} items.`); + onSyncComplete(); // 부모에게 알림 (카운트 갱신 등) + } + + if (failCount > 0) { + toast.error(`Failed to sync ${failCount} items. Check the list for details.`); + } + }; + + return ( + <Dialog open={open} onOpenChange={(v) => !isSyncing && onOpenChange(v)}> + <DialogContent className="max-w-[90vw] w-[90vw] h-[90vh] max-h-[90vh] flex flex-col p-0 gap-0"> + <DialogHeader className="p-6 border-b flex-shrink-0"> + <DialogTitle>Server Synchronization</DialogTitle> + <DialogDescription> + Upload locally saved items to the external server. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden flex flex-col p-6 gap-6 bg-muted/10"> + {/* 내 아이템 (동기화 대상) */} + <div className="flex-1 flex flex-col min-h-0 border rounded-md bg-background shadow-sm"> + <div className="p-3 border-b flex items-center justify-between bg-muted/20"> + <h3 className="font-semibold text-sm flex items-center gap-2"> + <FolderInput className="h-4 w-4" /> + My Pending Items ({new Set(myRows.map(r => r.syncId)).size} items, {myRows.length} files) + </h3> + <Button variant="ghost" size="sm" onClick={loadData} disabled={isSyncing || isLoading}> + <RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} /> + </Button> + </div> + + <div className="flex-1 overflow-auto relative"> + <Table> + <TableHeader className="sticky top-0 z-10 bg-background"> + <TableRow> + <TableHead className="w-[40px]"> + <Checkbox + checked={myRows.length > 0 && selectedSyncIds.size === new Set(myRows.map(r => r.syncId)).size} + onCheckedChange={toggleSelectAll} + disabled={isSyncing || myRows.length === 0} + /> + </TableHead> + <TableHead className="w-[150px]">Drawing No</TableHead> + <TableHead className="min-w-[200px]">Drawing Name</TableHead> + <TableHead className="w-[100px]">Discipline</TableHead> + <TableHead className="w-[80px]">Rev</TableHead> + <TableHead className="w-[100px]">Kind</TableHead> + <TableHead className="min-w-[200px]">File Name</TableHead> + <TableHead className="w-[100px]">Size</TableHead> + <TableHead className="w-[140px]">Date</TableHead> + <TableHead className="w-[100px]">Status</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {isLoading ? ( + <TableRow> + <TableCell colSpan={10} className="text-center py-8"> + <Loader2 className="h-6 w-6 animate-spin mx-auto" /> + </TableCell> + </TableRow> + ) : myRows.length === 0 ? ( + <TableRow> + <TableCell colSpan={10} className="text-center py-8 text-muted-foreground"> + No pending items found. + </TableCell> + </TableRow> + ) : ( + myRows.map((row) => ( + <TableRow key={row.rowId} className="hover:bg-muted/5"> + <TableCell> + <Checkbox + checked={selectedSyncIds.has(row.syncId)} + onCheckedChange={() => toggleSelect(row.syncId)} + disabled={isSyncing || row.status === "success"} + /> + </TableCell> + <TableCell className="font-medium text-xs">{row.drawingNo || "-"}</TableCell> + <TableCell className="text-xs truncate max-w-[200px]" title={row.drawingName}>{row.drawingName || "-"}</TableCell> + <TableCell className="text-xs">{row.discipline || "-"}</TableCell> + <TableCell className="text-xs">{row.revision || "-"}</TableCell> + <TableCell className="text-xs">{row.registerKind || "-"}</TableCell> + <TableCell className="text-xs flex items-center gap-2"> + <FileText className="h-3 w-3 text-muted-foreground" /> + <span className="truncate max-w-[200px]" title={row.fileName}>{row.fileName}</span> + </TableCell> + <TableCell className="text-xs text-muted-foreground">{row.fileSize}</TableCell> + <TableCell className="text-xs text-muted-foreground"> + {format(new Date(row.createdAt), "yyyy-MM-dd HH:mm")} + </TableCell> + <TableCell> + {row.status === "pending" && <span className="text-muted-foreground text-xs">Pending</span>} + {row.status === "syncing" && <Loader2 className="h-4 w-4 animate-spin text-primary" />} + {row.status === "success" && <CheckCircle2 className="h-4 w-4 text-green-500" />} + {row.status === "error" && <XCircle className="h-4 w-4 text-destructive" />} + {row.errorMessage && row.status === "error" && ( + <span className="sr-only">{row.errorMessage}</span> + )} + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </div> + </div> + + {/* 다른 사용자 아이템 (참고용) */} + {otherRows.length > 0 && ( + <div className="h-1/3 flex flex-col min-h-0 border rounded-md bg-background shadow-sm opacity-90"> + <div className="p-3 border-b bg-muted/20"> + <h3 className="font-semibold text-sm text-muted-foreground flex items-center gap-2"> + <FolderInput className="h-4 w-4" /> + Other Users' Pending Items (Same Vendor) - {otherRows.length} files + </h3> + </div> + <ScrollArea className="flex-1"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">User</TableHead> + <TableHead className="w-[150px]">Drawing No</TableHead> + <TableHead className="min-w-[200px]">File Name</TableHead> + <TableHead className="w-[100px]">Size</TableHead> + <TableHead className="w-[140px]">Date</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {otherRows.map((row) => ( + <TableRow key={row.rowId}> + <TableCell className="text-xs font-medium">{row.userName}</TableCell> + <TableCell className="text-xs">{row.drawingNo}</TableCell> + <TableCell className="text-xs flex items-center gap-2"> + <FileText className="h-3 w-3 text-muted-foreground" /> + <span className="truncate max-w-[200px]" title={row.fileName}>{row.fileName}</span> + </TableCell> + <TableCell className="text-xs text-muted-foreground">{row.fileSize}</TableCell> + <TableCell className="text-xs text-muted-foreground"> + {format(new Date(row.createdAt), "yyyy-MM-dd HH:mm")} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </ScrollArea> + </div> + )} + </div> + + <DialogFooter className="p-6 border-t flex-shrink-0"> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSyncing}> + Close + </Button> + <Button onClick={handleSync} disabled={isSyncing || selectedSyncIds.size === 0}> + {isSyncing ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Syncing... + </> + ) : ( + <> + <RefreshCw className="mr-2 h-4 w-4" /> + Sync Selected ({selectedSyncIds.size} items) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx b/lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx new file mode 100644 index 00000000..c59f6d78 --- /dev/null +++ b/lib/dolce-v2/dialogs/upload-files-to-detail-dialog-v2.tsx @@ -0,0 +1,247 @@ +"use client"; + +import * as React from "react"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Upload, FolderOpen, Loader2, X, FileText, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; +import { useFileUploadWithProgress } from "@/lib/dolce/hooks/use-file-upload-with-progress"; +import { uploadFilesToDetailDrawingV2 } from "@/lib/dolce-v2/actions"; + +interface UploadFilesToDetailDialogV2Props { + open: boolean; + onOpenChange: (open: boolean) => void; + uploadId: string; + drawingNo: string; + revNo: string; + // [추가] 메타데이터 저장을 위한 추가 정보 + drawingName?: string; + discipline?: string; + registerKind?: string; + + userId: string; + projectNo?: string; // V2에서는 projectNo 필요 (Sync List 조회 인덱스용) + vendorCode?: string; // V2: 동기화 필터링용 + onUploadComplete?: () => void; + lng: string; +} + +export function UploadFilesToDetailDialogV2({ + open, + onOpenChange, + uploadId, + drawingNo, + revNo, + drawingName, + discipline, + registerKind, + userId, + projectNo, + vendorCode, + onUploadComplete, + lng, +}: UploadFilesToDetailDialogV2Props) { + const { t } = useTranslation(lng, "dolce"); + const [isUploading, setIsUploading] = useState(false); + + // 파일 업로드 훅 사용 (UI용) + const { + files: selectedFiles, + removeFile, + clearFiles, + getRootProps, + getInputProps, + isDragActive, + } = useFileUploadWithProgress(); + + // 다이얼로그 닫을 때 초기화 + React.useEffect(() => { + if (!open) { + clearFiles(); + } + }, [open, clearFiles]); + + // 업로드 처리 + const handleUpload = async () => { + if (selectedFiles.length === 0) { + toast.error(t("uploadFilesDialog.selectFilesError")); + return; + } + + setIsUploading(true); + + try { + const formData = new FormData(); + formData.append("uploadId", uploadId); + formData.append("userId", userId); + formData.append("fileCount", String(selectedFiles.length)); + if (projectNo) formData.append("projectNo", projectNo); + if (vendorCode) formData.append("vendorCode", vendorCode); + + // 메타데이터 추가 + formData.append("drawingNo", drawingNo); + formData.append("revNo", revNo); + if (drawingName) formData.append("drawingName", drawingName); + if (discipline) formData.append("discipline", discipline); + if (registerKind) formData.append("registerKind", registerKind); + + selectedFiles.forEach((file, index) => { + formData.append(`file_${index}`, file); + }); + + const result = await uploadFilesToDetailDrawingV2(formData); + + if (result.success) { + toast.success(t("uploadFilesDialog.uploadSuccess", { count: selectedFiles.length })); + onOpenChange(false); + onUploadComplete?.(); + } else { + toast.error(result.error || t("uploadFilesDialog.uploadError")); + } + } catch (error) { + console.error("업로드 실패:", error); + toast.error( + error instanceof Error ? error.message : t("uploadFilesDialog.uploadErrorMessage") + ); + } finally { + setIsUploading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>{t("uploadFilesDialog.title")}</DialogTitle> + <DialogDescription> + {t("uploadFilesDialog.description", { drawingNo, revNo })} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 안내 메시지 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {t("uploadFilesDialog.alertMessage")} + </AlertDescription> + </Alert> + + {/* 파일 선택 영역 */} + <div + {...getRootProps()} + className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 cursor-pointer ${ + isDragActive + ? "border-primary bg-primary/5 scale-[1.02]" + : "border-muted-foreground/30 hover:border-muted-foreground/50" + }`} + > + <input {...getInputProps()} /> + <div className="flex flex-col items-center justify-center"> + <FolderOpen + className={`h-12 w-12 mb-3 transition-colors ${ + isDragActive ? "text-primary" : "text-muted-foreground" + }`} + /> + <p + className={`text-sm transition-colors ${ + isDragActive + ? "text-primary font-medium" + : "text-muted-foreground" + }`} + > + {isDragActive + ? t("uploadFilesDialog.dropHereText") + : t("uploadFilesDialog.dragDropText")} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {t("uploadFilesDialog.fileInfo")} + </p> + </div> + </div> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( + <div className="border rounded-lg p-4"> + <> + <div className="flex items-center justify-between mb-3"> + <h4 className="text-sm font-medium"> + {t("uploadFilesDialog.selectedFiles", { count: selectedFiles.length })} + </h4> + <Button + variant="ghost" + size="sm" + onClick={clearFiles} + > + {t("uploadFilesDialog.removeAll")} + </Button> + </div> + <div className="max-h-60 overflow-y-auto space-y-2"> + {selectedFiles.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-2 rounded bg-muted/50" + > + <div className="flex items-center gap-2 flex-1 min-w-0"> + <FileText className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="flex-1 min-w-0"> + <p className="text-sm truncate">{file.name}</p> + <p className="text-xs text-muted-foreground"> + {(file.size / 1024 / 1024).toFixed(2)} MB + </p> + </div> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </> + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + {t("uploadFilesDialog.cancelButton")} + </Button> + <Button + onClick={handleUpload} + disabled={selectedFiles.length === 0 || isUploading} + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + {t("uploadFilesDialog.uploadingButton")} + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + {t("uploadFilesDialog.uploadButton", { count: selectedFiles.length })} + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/lib/dolce-v2/sync-service.ts b/lib/dolce-v2/sync-service.ts new file mode 100644 index 00000000..ea56b239 --- /dev/null +++ b/lib/dolce-v2/sync-service.ts @@ -0,0 +1,414 @@ +"use server"; + +import fs from "fs/promises"; +import path from "path"; +import { v4 as uuidv4 } from "uuid"; +import db from "@/db/db"; +import { dolceSyncList } from "@/db/schema/dolce/dolce"; +import { eq, and } from "drizzle-orm"; +import { + dolceApiCall, + uploadFilesToDetailDrawing as apiUploadFiles, + saveB4MappingBatch as apiSaveB4Mapping, + DetailDwgEditRequest, + B4MappingSaveItem +} from "@/lib/dolce/actions"; // 기존 API 호출 로직 재사용 (타입 등) + +const LOCAL_UPLOAD_DIR = process.env.DOLCE_LOCAL_UPLOAD_ABSOLUTE_DIRECTORY || "/evcp/data/dolce"; + +// 파일 저장 결과 인터페이스 +interface SavedFile { + originalName: string; + localPath: string; + size: number; + mimeType?: string; +} + +/** + * 로컬 디렉토리 준비 + */ +async function ensureUploadDir() { + try { + await fs.access(LOCAL_UPLOAD_DIR); + } catch { + await fs.mkdir(LOCAL_UPLOAD_DIR, { recursive: true }); + } +} + +/** + * 로컬에 파일 저장 + */ +async function saveFileToLocal(file: File): Promise<SavedFile> { + await ensureUploadDir(); + + const buffer = Buffer.from(await file.arrayBuffer()); + const uniqueName = `${uuidv4()}_${file.name}`; + const localPath = path.join(LOCAL_UPLOAD_DIR, uniqueName); + + await fs.writeFile(localPath, buffer); + + return { + originalName: file.name, + localPath, + size: file.size, + mimeType: file.type, + }; +} + +/** + * 동기화 아이템 DB 저장 (버퍼링) + */ +export async function saveToLocalBuffer(params: { + type: "ADD_DETAIL" | "MOD_DETAIL" | "ADD_FILE" | "B4_BULK"; + projectNo: string; + userId: string; + userName?: string; // [추가] + vendorCode?: string; // [추가] + drawingNo?: string; + uploadId?: string; // 상세도면 추가/수정/파일추가 시 필수 + metaData: any; // API 호출에 필요한 데이터 + files?: File[]; // 업로드할 파일들 (있으면 로컬 저장) +}) { + const { type, projectNo, userId, userName, vendorCode, drawingNo, uploadId, metaData, files } = params; + + // 1. 파일 로컬 저장 처리 + const savedFiles: SavedFile[] = []; + if (files && files.length > 0) { + for (const file of files) { + const saved = await saveFileToLocal(file); + savedFiles.push(saved); + } + } + + // 2. Payload 구성 + const payload = { + meta: metaData, + files: savedFiles, + }; + + // 3. DB 저장 + const [inserted] = await db.insert(dolceSyncList).values({ + type, + projectNo, + drawingNo, + uploadId, + userId, + userName, // [추가] + vendorCode, // [추가] + payload, + isSynced: false, + }).returning(); + + return inserted; +} + +/** + * 개별 아이템 동기화 실행 + */ +export async function syncItem(id: string) { + // 1. 아이템 조회 + const item = await db.query.dolceSyncList.findFirst({ + where: eq(dolceSyncList.id, id), + }); + + if (!item) throw new Error("Item not found"); + if (item.isSynced) return { success: true, message: "Already synced" }; + + const payload = item.payload as { meta: any; files: SavedFile[] }; + const { meta, files } = payload; + + try { + // 2. 타입별 API 호출 수행 + if (item.type === "ADD_DETAIL" || item.type === "MOD_DETAIL") { + // 상세도면 추가/수정 + // meta: { dwgList: DetailDwgEditRequest[], userId, userNm, vendorCode, email } + + // 상세도면 메타데이터 전송 + await dolceApiCall("DetailDwgReceiptMgmtEdit", { + DwgList: meta.dwgList, + UserID: meta.userId, + UserNM: meta.userNm, + VENDORCODE: meta.vendorCode, + EMAIL: meta.email, + }); + + // 파일이 있다면 전송 (ADD_DETAIL의 경우) + if (files && files.length > 0) { + // uploadId는 meta.dwgList[0].UploadId 에 있다고 가정 + const uploadId = meta.dwgList[0]?.UploadId; + if (uploadId) { + await uploadLocalFiles(uploadId, meta.userId, files); + } + } + + } else if (item.type === "ADD_FILE") { + // 파일 추가 + // meta: { uploadId, userId } + await uploadLocalFiles(meta.uploadId, meta.userId, files); + + } else if (item.type === "B4_BULK") { + // B4 일괄 업로드 (메타데이터 + 파일) + // meta: { mappingSaveLists: B4MappingSaveItem[], userInfo: {...} } + + // 파일 먼저 업로드 (각 파일별로 uploadId가 다를 수 있음 - payload 구조에 따라 다름) + // B4 Bulk의 경우, meta.mappingSaveLists에 UploadId가 있고, files와 1:1 매칭되거나 그룹핑되어야 함. + // 여기서는 복잡도를 줄이기 위해, payload.files 순서와 mappingSaveLists 순서가 같거나 + // meta 정보 안에 파일 매핑 정보가 있다고 가정해야 함. + + // *설계 단순화*: B4 Bulk의 경우 파일별로 saveToLocalBuffer를 따로 부르지 않고 한방에 불렀다면, + // 여기서 순회하며 처리. + + // 1. 파일 업로드 + // B4 일괄 업로드 로직은 파일 업로드 -> 결과 수신 -> 매핑 저장 순서임. + // 하지만 여기서는 이미 메타데이터가 만들어져 있으므로, + // 파일 업로드(UploadId 기준) -> 매핑 저장 순으로 진행. + + // 파일마다 UploadId가 다를 수 있으므로 Grouping 필요 + const fileMap = new Map<string, SavedFile>(); + files.forEach(f => fileMap.set(f.originalName, f)); + + // UploadId별 파일 그룹핑 + const uploadGroups = new Map<string, { userId: string; files: SavedFile[] }>(); + + for (const mapping of meta.mappingSaveLists as B4MappingSaveItem[]) { + if (!uploadGroups.has(mapping.UploadId)) { + uploadGroups.set(mapping.UploadId, { userId: meta.userInfo.userId, files: [] }); + } + const savedFile = fileMap.get(mapping.FileNm); + if (savedFile) { + uploadGroups.get(mapping.UploadId)!.files.push(savedFile); + } + } + + // 그룹별 파일 업로드 수행 + for (const [uploadId, group] of uploadGroups.entries()) { + if (group.files.length > 0) { + await uploadLocalFiles(uploadId, group.userId, group.files); + } + } + + // 2. 매핑 정보 저장 + await apiSaveB4Mapping(meta.mappingSaveLists, meta.userInfo); + } + + // 3. 성공 처리 (DB 업데이트 + 로컬 파일 삭제) + await db.update(dolceSyncList) + .set({ + isSynced: true, + syncAttempts: (item.syncAttempts || 0) + 1, + responseCode: "200", + response: "Success", + updatedAt: new Date() + }) + .where(eq(dolceSyncList.id, id)); + + // 로컬 파일 삭제 + if (files && files.length > 0) { + for (const file of files) { + try { + await fs.unlink(file.localPath); + } catch (e) { + console.error(`Failed to delete local file: ${file.localPath}`, e); + } + } + } + + return { success: true }; + + } catch (error) { + console.error(`Sync failed for item ${id}:`, error); + + // 실패 처리 + await db.update(dolceSyncList) + .set({ + syncAttempts: (item.syncAttempts || 0) + 1, + lastError: error instanceof Error ? error.message : "Unknown error", + updatedAt: new Date() + }) + .where(eq(dolceSyncList.id, id)); + + throw error; + } +} + +/** + * 로컬 파일들을 실제 서버로 업로드하는 헬퍼 함수 + * (기존 uploadFilesToDetailDrawing 로직을 로컬 파일용으로 변형) + */ +async function uploadLocalFiles(uploadId: string, userId: string, files: SavedFile[]) { + // 1. 기존 파일 시퀀스 확인 등은 생략하고 바로 PWPUploadService 호출 + // (기존 API 액션 재사용이 어려우므로 여기서 fetch로 직접 구현) + + // 기존 파일 개수 조회 (Seq 생성을 위해) + const existingFiles = await dolceApiCall<{ + FileInfoListResult: Array<{ FileSeq: string }>; + }>("FileInfoList", { + uploadId: uploadId, + }); + const startSeq = existingFiles.FileInfoListResult.length + 1; + + const uploadResults = []; + const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111"; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const fileId = uuidv4(); + + // 로컬 파일 읽기 + const fileBuffer = await fs.readFile(file.localPath); + + // 업로드 API 호출 + const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: fileBuffer, + }); + + if (!uploadResponse.ok) throw new Error(`File upload failed: ${uploadResponse.status}`); + + const fileRelativePath = await uploadResponse.text(); + + uploadResults.push({ + FileId: fileId, + UploadId: uploadId, + FileSeq: startSeq + i, + FileName: file.originalName, + FileRelativePath: fileRelativePath, + FileSize: file.size, + FileCreateDT: new Date().toISOString(), + FileWriteDT: new Date().toISOString(), + OwnerUserId: userId, + }); + } + + // 결과 통보 + const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; + const resultResponse = await fetch(resultServiceUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(uploadResults), + }); + + if (!resultResponse.ok) throw new Error("Upload notification failed"); + + const resultText = await resultResponse.text(); + if (resultText !== "Success") throw new Error(`Upload notification failed: ${resultText}`); +} + +/** + * 로컬 파일 다운로드 (View용) + */ +export async function getLocalFile(fileId: string): Promise<{ buffer: Buffer; fileName: string }> { + // Format: LOCAL_{id}_{index} + const parts = fileId.replace("LOCAL_", "").split("_"); + if (parts.length < 2) throw new Error("Invalid file ID format"); + + const id = parts[0]; + const index = parseInt(parts[1]); + + const item = await db.query.dolceSyncList.findFirst({ + where: eq(dolceSyncList.id, id), + }); + + if (!item) throw new Error("Item not found"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload = item.payload as { files: SavedFile[]; meta: any }; + if (!payload.files || !payload.files[index]) { + throw new Error("File not found in item"); + } + + const file = payload.files[index]; + + try { + const buffer = await fs.readFile(file.localPath); + return { + buffer, + fileName: file.originalName + }; + } catch (e) { + console.error(`Failed to read local file: ${file.localPath}`, e); + throw new Error("Failed to read local file"); + } +} + +/** + * 로컬 아이템 삭제 (상세도면 삭제용) + * 관련 파일도 로컬 디스크에서 삭제 + */ +export async function deleteLocalItem(id: string) { + const item = await db.query.dolceSyncList.findFirst({ + where: eq(dolceSyncList.id, id), + }); + + if (!item) return; + + // Delete files from disk + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload = item.payload as { files?: SavedFile[] }; + if (payload.files) { + for (const file of payload.files) { + try { + await fs.unlink(file.localPath); + } catch (e) { + console.warn(`Failed to delete local file: ${file.localPath}`, e); + } + } + } + + // Delete DB entry + await db.delete(dolceSyncList).where(eq(dolceSyncList.id, id)); +} + +/** + * 로컬 파일 삭제 (개별 파일 삭제용) + */ +export async function deleteLocalFileFromItem(fileId: string) { + // Format: LOCAL_{id}_{index} + const parts = fileId.replace("LOCAL_", "").split("_"); + if (parts.length < 2) throw new Error("Invalid file ID format"); + + const id = parts[0]; + const index = parseInt(parts[1]); + + const item = await db.query.dolceSyncList.findFirst({ + where: eq(dolceSyncList.id, id), + }); + + if (!item) throw new Error("Item not found"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload = item.payload as { files: SavedFile[]; meta: any }; + if (!payload.files || !payload.files[index]) { + return; + } + + // Delete file from disk + const fileToDelete = payload.files[index]; + try { + await fs.unlink(fileToDelete.localPath); + } catch (e) { + console.warn(`Failed to delete local file: ${fileToDelete.localPath}`, e); + } + + // Remove from payload + const newFiles = [...payload.files]; + newFiles.splice(index, 1); // Remove at index + + // Update DB + await db.update(dolceSyncList) + .set({ + payload: { + ...payload, + files: newFiles + }, + updatedAt: new Date() + }) + .where(eq(dolceSyncList.id, id)); + + // If no files left and it was ADD_FILE type, delete the item + if (newFiles.length === 0 && item.type === "ADD_FILE") { + await db.delete(dolceSyncList).where(eq(dolceSyncList.id, id)); + } +} diff --git a/lib/dolce/actions.ts b/lib/dolce/actions.ts index cd276fac..5590ce8c 100644 --- a/lib/dolce/actions.ts +++ b/lib/dolce/actions.ts @@ -67,6 +67,10 @@ export interface GttDwgReceiptItem { RegisterGroupId: number; SGbn: string | null; SHIDrawingNo: string | null; + // Added ENM fields + CategoryENM?: string; + DrawingUsageENM?: string; + RegisterKindENM?: string; } // 통합 도면 아이템 타입 @@ -158,7 +162,7 @@ export interface DetailDwgEditRequest { // 유틸리티 함수 // ============================================================================ -async function dolceApiCall<T>(endpoint: string, body: Record<string, unknown>): Promise<T> { +export async function dolceApiCall<T>(endpoint: string, body: Record<string, unknown>): Promise<T> { const url = `${DOLCE_API_URL}/Services/VDCSWebService.svc/${endpoint}`; console.log(`[DOLCE API] Calling ${endpoint}:`, JSON.stringify(body, null, 2)); @@ -746,7 +750,7 @@ export async function saveB4MappingBatch( * 형식: [버림] [문서번호토큰1] [문서번호토큰2] ... [리비전번호].[확장자] * 예시: "testfile GTT DE 007 R01.pdf" → DrawingNo: "GTT-DE-007", RevNo: "R01" */ -async function parseB4FileName(fileName: string): Promise<{ +export async function parseB4FileName(fileName: string): Promise<{ valid: boolean; drawingNo?: string; revNo?: string; @@ -1497,4 +1501,3 @@ export async function bulkUploadB4Files( }; } } - diff --git a/lib/dolce/table/detail-drawing-columns.tsx b/lib/dolce/table/detail-drawing-columns.tsx index 747173af..30290817 100644 --- a/lib/dolce/table/detail-drawing-columns.tsx +++ b/lib/dolce/table/detail-drawing-columns.tsx @@ -13,54 +13,6 @@ import { import { Button } from "@/components/ui/button"; import { Edit } from "lucide-react"; -// DOLCE API ENM 필드가 제대로 번역되지 않아 직접 매핑 -const DRAWING_USAGE_MAP: Record<string, { ko: string; en: string }> = { - APP: { ko: "승인용", en: "Approval" }, - WOR: { ko: "작업용", en: "Working" }, - // 참조용은 eVCP에서 사용하지 않으나 추가해둠 - REF: { ko: "참조용", en: "Reference" }, - REC: { ko: "입수용", en: "GTT→SHI" }, - SUB: { ko: "제출용", en: "SHI→GTT" }, - CMT: { ko: "코멘트", en: "Comment" }, -}; - -const REGISTER_KIND_MAP: Record<string, { ko: string; en: string }> = { - APPR: { ko: "승인 제출용 도면(Full)", en: "For Approval(Full)" }, - APPP: { ko: "승인 제출용 도면(Partial)", en: "For Approval(Partial)" }, - WORK: { ko: "작업용 입수도면(Full)", en: "For Working(Full)" }, - WORP: { ko: "작업용 입수도면(Partial)", en: "For Working(Partial)" }, - RECW: { ko: "Working 도면입수(GTT→SHI)", en: "Working Dwg(GTT→SHI)" }, - RECP: { ko: "Pre. 도면입수(GTT→SHI)", en: "Pre. Dwg(GTT→SHI)" }, - GSUB: { ko: "SHI 입력 문서 (SHI→GTT)", en: "SHI Input Document(SHI→GTT)" }, - CMTQ: { ko: "Comment-TQ", en: "Comment-TQ" }, - CMTM: { ko: "MARK-UP", en: "MARK-UP" }, - // FMEA는 미사용 코드 - "FMEA-R1": { ko: "FMEA 1st Receipt", en: "FMEA 1st Receipt" }, - "FMEA-R2": { ko: "FMEA 2nd Receipt", en: "FMEA 2nd Receipt" }, - "FMEA-1": { ko: "FMEA 1st Submission", en: "FMEA 1st Submission" }, - "FMEA-2": { ko: "FMEA 2nd Submission", en: "FMEA 2nd Submission" }, - // 참조용 도면도 미사용 코드, 번역 받아야 적용 가능 - PREF: { ko: "본선용 참조도면", en: "본선용 참조도면" }, - RREF: { ko: "실적선 참조도면", en: "실적선 참조도면" }, - "PREP-P": { ko: "본선용 참조도면(Partial)", en: "본선용 참조도면(Partial)" }, - "RREP-P": { ko: "실적선 참조도면(Partial)", en: "실적선 참조도면(Partial)" }, -}; - -// 카테고리는 API에서 ENM이 제공되는 것으로 가정 (필요시 추가) -const translateDrawingUsage = (code: string | null, lng: string): string => { - if (!code) return ""; - const mapped = DRAWING_USAGE_MAP[code]; - if (!mapped) return code; - return lng === "en" ? mapped.en : mapped.ko; -}; - -const translateRegisterKind = (code: string | null, lng: string): string => { - if (!code) return ""; - const mapped = REGISTER_KIND_MAP[code]; - if (!mapped) return code; - return lng === "en" ? mapped.en : mapped.ko; -}; - // Comment Dialog Component function CommentDialog({ comment }: { comment: string }) { const [open, setOpen] = useState(false); @@ -99,9 +51,14 @@ export function createDetailDrawingColumns( { accessorKey: "RegisterSerialNo", header: t("detailDrawing.columns.serialNo"), - minSize: 80, + minSize: 120, cell: ({ row }) => { - return <div className="text-center">{row.getValue("RegisterSerialNo")}</div>; + const val = row.getValue("RegisterSerialNo") as number; + const status = row.getValue("Status") as string; + if (val === 0 || status === "EVCP Saved") { + return <div className="text-center">-</div>; + } + return <div className="text-center">{val}</div>; }, }, { @@ -125,10 +82,8 @@ export function createDetailDrawingColumns( header: t("detailDrawing.columns.category"), minSize: 120, cell: ({ row }) => { - const categoryENM = row.getValue("CategoryENM") as string; - const categoryNM = row.original.CategoryNM; - // 영어인 경우 ENM, 한국어인 경우 NM 사용 - return <div>{lng === "en" ? (categoryENM || categoryNM) : (categoryNM || categoryENM)}</div>; + // 항상 CategoryENM 필드를 보여줌 + return <div>{row.getValue("CategoryENM")}</div>; }, }, // RegisterKind 로 DrawingUsage를 알 수 있으므로 주석 처리 @@ -144,14 +99,12 @@ export function createDetailDrawingColumns( // }, // }, { - accessorKey: "RegisterKind", + accessorKey: "RegisterKindENM", header: t("detailDrawing.columns.registerKind"), minSize: 180, cell: ({ row }) => { - // API 응답의 RegisterKind는 코드값이므로 직접 매핑하여 번역 - const kindCode = row.getValue("RegisterKind") as string; - const translated = translateRegisterKind(kindCode, lng); - return <div>{translated}</div>; + // 항상 RegisterKindENM 필드를 보여줌 + return <div>{row.getValue("RegisterKindENM")}</div>; }, }, { @@ -193,12 +146,18 @@ export function createDetailDrawingColumns( const isEditable = status === "Submitted"; return ( - <div className="flex items-center justify-center"> + <div + className="flex items-center justify-center" + onClick={(e) => e.stopPropagation()} + > <Button variant="ghost" size="sm" disabled={!isEditable || !onEdit} - onClick={() => onEdit && onEdit(row.original)} + onClick={(e) => { + e.stopPropagation(); + if (onEdit) onEdit(row.original); + }} title={!isEditable ? t("editDetailDialog.statusSubmittedOnly") : t("editDetailDialog.editButton")} > <Edit className="h-4 w-4" /> diff --git a/lib/dolce/table/drawing-list-table-v2.tsx b/lib/dolce/table/drawing-list-table-v2.tsx index 2ee80f11..420ed672 100644 --- a/lib/dolce/table/drawing-list-table-v2.tsx +++ b/lib/dolce/table/drawing-list-table-v2.tsx @@ -30,7 +30,7 @@ import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from "luci // 도면 데이터의 기본 인터페이스 interface DrawingData { - RegisterGroupId?: string | null; + RegisterGroupId?: string | number | null; DrawingNo?: string | null; Discipline?: string | null; CreateDt?: string | Date | null; diff --git a/lib/dolce/table/file-list-columns.tsx b/lib/dolce/table/file-list-columns.tsx index 36a579a3..38b1f1c9 100644 --- a/lib/dolce/table/file-list-columns.tsx +++ b/lib/dolce/table/file-list-columns.tsx @@ -3,16 +3,18 @@ import { ColumnDef } from "@tanstack/react-table"; import { FileInfoItem } from "../actions"; import { Button } from "@/components/ui/button"; -import { Download } from "lucide-react"; +import { Download, Trash2 } from "lucide-react"; import { formatDolceDateTime } from "../utils/date-formatter"; interface FileListColumnsProps { onDownload: (file: FileInfoItem) => void; + onDelete?: (file: FileInfoItem) => void; lng?: string; } export const createFileListColumns = ({ onDownload, + onDelete, lng = "ko", }: FileListColumnsProps): ColumnDef<FileInfoItem>[] => [ { @@ -20,6 +22,9 @@ export const createFileListColumns = ({ header: lng === "ko" ? "순번" : "No.", minSize: 80, cell: ({ row }) => { + if (row.original.FileServerId === "LOCAL") { + return <div className="text-center">-</div>; + } return <div className="text-center">{row.getValue("FileSeq")}</div>; }, }, @@ -55,18 +60,36 @@ export const createFileListColumns = ({ }, { id: "actions", - header: lng === "ko" ? "다운로드" : "Download", - minSize: 120, + header: "Action", + minSize: 160, cell: ({ row }) => { + const isLocal = row.original.FileServerId === "LOCAL"; return ( - <Button - variant="outline" - size="sm" - onClick={() => onDownload(row.original)} - > - <Download className="h-4 w-4 mr-2" /> - {lng === "ko" ? "다운로드" : "Download"} - </Button> + <div className="flex gap-2 items-center justify-center"> + <Button + variant="outline" + size="sm" + onClick={(e) => { + e.stopPropagation(); + onDownload(row.original); + }} + > + <Download className="h-4 w-4 mr-2" /> + {lng === "ko" ? "다운로드" : "Download"} + </Button> + {isLocal && onDelete && ( + <Button + variant="destructive" + size="sm" + onClick={(e) => { + e.stopPropagation(); + onDelete(row.original); + }} + > + <Trash2 className="h-4 w-4" /> + </Button> + )} + </div> ); }, }, diff --git a/lib/dolce/table/gtt-drawing-list-columns.tsx b/lib/dolce/table/gtt-drawing-list-columns.tsx index 093fc10c..da29c910 100644 --- a/lib/dolce/table/gtt-drawing-list-columns.tsx +++ b/lib/dolce/table/gtt-drawing-list-columns.tsx @@ -2,7 +2,6 @@ import { ColumnDef } from "@tanstack/react-table"; import { GttDwgReceiptItem } from "../actions"; -import { translateDrawingMoveGbn } from "../utils/code-translator"; import { formatDolceDateYYYYMMDD, formatDolceDateTime } from "../utils/date-formatter"; // Document Type 필터 @@ -52,12 +51,27 @@ export function createGttDrawingListColumns({ }, }, { - accessorKey: "DrawingMoveGbn", + accessorKey: "CategoryENM", header: t("drawingList.columns.category"), minSize: 120, cell: ({ row }) => { - const value = row.getValue("DrawingMoveGbn") as string; - return <div>{translateDrawingMoveGbn(value, lng)}</div>; + return <div>{row.getValue("CategoryENM")}</div>; + }, + }, + { + accessorKey: "DrawingUsageENM", + header: t("detailDrawing.columns.drawingUsage"), + minSize: 120, + cell: ({ row }) => { + return <div>{row.getValue("DrawingUsageENM")}</div>; + }, + }, + { + accessorKey: "RegisterKindENM", + header: t("detailDrawing.columns.registerKind"), + minSize: 180, + cell: ({ row }) => { + return <div>{row.getValue("RegisterKindENM")}</div>; }, }, ]; @@ -168,4 +182,3 @@ export function createGttDrawingListColumns({ return [...baseColumns, ...dateColumns, ...endColumns]; } - |
