From 8547034e6d82e4d1184f35af2dbff67180d89dc8 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Wed, 26 Nov 2025 18:09:18 +0900 Subject: (김준회) dolce: 동기화 기능 추가, 로컬 다운로드, 삭제 추가, 동기화 dialog 개선 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/dolce-v2/dialogs/sync-items-dialog.tsx | 376 +++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 lib/dolce-v2/dialogs/sync-items-dialog.tsx (limited to 'lib/dolce-v2/dialogs/sync-items-dialog.tsx') 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([]); + const [otherRows, setOtherRows] = useState([]); + + // 선택된 Sync Item ID들 (파일 단위가 아니라 Sync Item 단위로 선택) + const [selectedSyncIds, setSelectedSyncIds] = useState>(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 ( + !isSyncing && onOpenChange(v)}> + + + Server Synchronization + + Upload locally saved items to the external server. + + + +
+ {/* 내 아이템 (동기화 대상) */} +
+
+

+ + My Pending Items ({new Set(myRows.map(r => r.syncId)).size} items, {myRows.length} files) +

+ +
+ +
+ + + + + 0 && selectedSyncIds.size === new Set(myRows.map(r => r.syncId)).size} + onCheckedChange={toggleSelectAll} + disabled={isSyncing || myRows.length === 0} + /> + + Drawing No + Drawing Name + Discipline + Rev + Kind + File Name + Size + Date + Status + + + + {isLoading ? ( + + + + + + ) : myRows.length === 0 ? ( + + + No pending items found. + + + ) : ( + myRows.map((row) => ( + + + toggleSelect(row.syncId)} + disabled={isSyncing || row.status === "success"} + /> + + {row.drawingNo || "-"} + {row.drawingName || "-"} + {row.discipline || "-"} + {row.revision || "-"} + {row.registerKind || "-"} + + + {row.fileName} + + {row.fileSize} + + {format(new Date(row.createdAt), "yyyy-MM-dd HH:mm")} + + + {row.status === "pending" && Pending} + {row.status === "syncing" && } + {row.status === "success" && } + {row.status === "error" && } + {row.errorMessage && row.status === "error" && ( + {row.errorMessage} + )} + + + )) + )} + +
+
+
+ + {/* 다른 사용자 아이템 (참고용) */} + {otherRows.length > 0 && ( +
+
+

+ + Other Users' Pending Items (Same Vendor) - {otherRows.length} files +

+
+ + + + + User + Drawing No + File Name + Size + Date + + + + {otherRows.map((row) => ( + + {row.userName} + {row.drawingNo} + + + {row.fileName} + + {row.fileSize} + + {format(new Date(row.createdAt), "yyyy-MM-dd HH:mm")} + + + ))} + +
+
+
+ )} +
+ + + + + +
+
+ ); +} -- cgit v1.2.3