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<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>
+  );
+}
-- 
cgit v1.2.3

