diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-26 18:09:18 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-26 18:09:18 +0900 |
| commit | 8547034e6d82e4d1184f35af2dbff67180d89dc8 (patch) | |
| tree | 2e1835040f39adc7d0c410a108ebb558f9971a8b /lib/dolce-v2/dialogs/sync-items-dialog.tsx | |
| parent | 3131dce1f0c90d960f53bd384045b41023064bc4 (diff) | |
(김준회) dolce: 동기화 기능 추가, 로컬 다운로드, 삭제 추가, 동기화 dialog 개선 등
Diffstat (limited to 'lib/dolce-v2/dialogs/sync-items-dialog.tsx')
| -rw-r--r-- | lib/dolce-v2/dialogs/sync-items-dialog.tsx | 376 |
1 files changed, 376 insertions, 0 deletions
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> + ); +} |
