summaryrefslogtreecommitdiff
path: root/lib/dolce-v2/dialogs/sync-items-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/dolce-v2/dialogs/sync-items-dialog.tsx')
-rw-r--r--lib/dolce-v2/dialogs/sync-items-dialog.tsx376
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>
+ );
+}