diff options
| -rw-r--r-- | app/[lng]/partners/(partners)/dolce-upload-v2/dolce-upload-page-v2.tsx | 719 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/dolce-upload-v2/page.tsx | 74 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx | 11 | ||||
| -rw-r--r-- | lib/dolce/actions.ts | 384 | ||||
| -rw-r--r-- | lib/dolce/dialogs/b4-bulk-upload-dialog-v2.tsx | 625 | ||||
| -rw-r--r-- | lib/dolce/dialogs/b4-bulk-upload-dialog.tsx | 87 | ||||
| -rw-r--r-- | lib/dolce/table/detail-drawing-columns.tsx | 96 | ||||
| -rw-r--r-- | lib/dolce/table/drawing-list-columns.tsx | 7 | ||||
| -rw-r--r-- | lib/dolce/table/drawing-list-table-v2.tsx | 273 |
9 files changed, 2154 insertions, 122 deletions
diff --git a/app/[lng]/partners/(partners)/dolce-upload-v2/dolce-upload-page-v2.tsx b/app/[lng]/partners/(partners)/dolce-upload-v2/dolce-upload-page-v2.tsx new file mode 100644 index 00000000..79f1b147 --- /dev/null +++ b/app/[lng]/partners/(partners)/dolce-upload-v2/dolce-upload-page-v2.tsx @@ -0,0 +1,719 @@ +"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, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; +import { + UnifiedDwgReceiptItem, + DetailDwgReceiptItem, + FileInfoItem, + fetchDwgReceiptList, + getVendorSessionInfo, + fetchVendorProjects, + fetchDetailDwgReceiptList, + fetchFileInfoList, +} from "@/lib/dolce/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: MatchBatchFileDwg API를 사용하지 않는 새로운 일괄 업로드 (DetailDwgReceiptMgmtEdit 사용) +import { B4BulkUploadDialogV2 } from "@/lib/dolce/dialogs/b4-bulk-upload-dialog-v2"; +// V1로 되돌리려면: 위 줄을 주석 처리하고 아래 줄의 주석을 해제하세요 +// import { B4BulkUploadDialog } from "@/lib/dolce/dialogs/b4-bulk-upload-dialog"; +import { AddDetailDrawingDialog } from "@/lib/dolce/dialogs/add-detail-drawing-dialog"; +import { UploadFilesToDetailDialog } from "@/lib/dolce/dialogs/upload-files-to-detail-dialog"; + +interface DolceUploadPageV2Props { + searchParams: { [key: string]: string | string[] | undefined }; +} + +export default function DolceUploadPageV2({ searchParams }: DolceUploadPageV2Props) { + 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 [isLoadingFiles, setIsLoadingFiles] = useState(false); + + // 다이얼로그 + const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = useState(false); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [uploadFilesDialogOpen, setUploadFilesDialogOpen] = useState(false); + + // 초기 데이터 로드 + 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")); + } 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]); + + // 상세도면 목록 로드 + const loadDetailDrawings = useCallback(async () => { + if (!selectedDrawing) { + setDetailDrawings([]); + setSelectedDetail(null); + return; + } + + try { + setIsLoadingDetails(true); + const data = await fetchDetailDwgReceiptList({ + project: selectedDrawing.ProjectNo, + drawingNo: selectedDrawing.DrawingNo, + discipline: selectedDrawing.Discipline, + drawingKind: selectedDrawing.DrawingKind, + userId: "", // 조회 시 모든 사용자의 상세도면을 보기 위해 빈 문자열 전달 + }); + setDetailDrawings(data); + + // 첫 번째 상세도면 자동 선택 + if (data.length > 0) { + setSelectedDetail(data[0]); + } else { + setSelectedDetail(null); + } + } catch (error) { + console.error("상세도면 로드 실패:", error); + toast.error(t("detailDialog.detailLoadError")); + setDetailDrawings([]); + setSelectedDetail(null); + } finally { + setIsLoadingDetails(false); + } + }, [selectedDrawing, t]); + + // 파일 목록 로드 + const loadFiles = useCallback(async () => { + if (!selectedDetail) { + setFiles([]); + return; + } + + try { + setIsLoadingFiles(true); + const data = await fetchFileInfoList(selectedDetail.UploadId); + setFiles(data); + } catch (error) { + console.error("파일 목록 로드 실패:", error); + toast.error(t("detailDialog.fileLoadError")); + setFiles([]); + } finally { + setIsLoadingFiles(false); + } + }, [selectedDetail, t]); + + // 초기 데이터 로드 + 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(); + }; + + // 상세도면 추가 완료 핸들러 + const handleAddComplete = () => { + setAddDialogOpen(false); + loadDetailDrawings(); + }; + + // 파일 업로드 완료 핸들러 + const handleUploadComplete = () => { + setUploadFilesDialogOpen(false); + loadFiles(); + }; + + // 파일 다운로드 핸들러 + const handleDownload = async (file: FileInfoItem) => { + try { + toast.info(t("detailDialog.downloadPreparing")); + + // 파일 생성자의 userId를 사용하여 다운로드 + const response = await fetch("/api/dolce/download", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + fileId: file.FileId, + userId: file.CreateUserId, // 파일 생성자의 ID 사용 + 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; + }); + + // B4인 경우 Document Type 필터 적용 + if (vendorInfo?.drawingKind === "B4" && documentType !== "ALL") { + result = result.filter((drawing) => { + // B4 타입 체크 + if (drawing.DrawingKind !== "B4") return false; + + // B4 도면의 DrawingMoveGbn 체크 + 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]); + + // RegisterId + UploadId 조합으로 고유 ID 생성 + const getDetailDrawingId = (detail: DetailDwgReceiptItem) => { + return `${detail.RegisterId}_${detail.UploadId}`; + }; + + // 도면 고유 ID 생성 + const getDrawingId = (drawing: UnifiedDwgReceiptItem) => { + return `${drawing.ProjectNo}_${drawing.DrawingNo}_${drawing.Discipline}`; + }; + + // B4인 경우 "도면입수"인 건만 상세도면 추가 및 파일 첨부 가능 + // B3인 경우 모든 건에 대해 가능 + const canAddDetailDrawing = vendorInfo && ( + vendorInfo.drawingKind === "B3" || + (vendorInfo.drawingKind === "B4" && selectedDrawing && 'DrawingMoveGbn' in selectedDrawing && selectedDrawing.DrawingMoveGbn === "도면입수") + ); + + const fileColumns = createFileListColumns({ onDownload: handleDownload, lng }); + + if (isLoading) { + return ( + <Card> + <CardHeader> + <Skeleton className="h-8 w-48" /> + <Skeleton className="h-4 w-96" /> + </CardHeader> + <CardContent className="space-y-4"> + <Skeleton className="h-32 w-full" /> + <Skeleton className="h-96 w-full" /> + </CardContent> + </Card> + ); + } + + 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> + )} + + {/* 안내 메시지 */} + {!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((project) => ( + <SelectItem key={project.code} value={project.code}> + {project.code} - {project.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)} + placeholder={t("filter.drawingNoPlaceholder")} + /> + </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)} + placeholder={t("filter.drawingNamePlaceholder")} + /> + </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)} + placeholder={t("filter.disciplinePlaceholder")} + /> + </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)} + placeholder={t("filter.managerPlaceholder")} + /> + </div> + + {/* B4(GTT) 전용: Document Type 필터 */} + {vendorInfo?.drawingKind === "B4" && ( + <div className="space-y-1"> + <Label className="text-xs">{t("filter.documentType")}</Label> + <Select value={documentType} onValueChange={(value) => setDocumentType(value 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> + {/* B4 벤더인 경우에만 일괄 업로드 버튼 표시 */} + {vendorInfo?.drawingKind === "B4" && ( + <Button + size="sm" + variant="default" + 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: "500px" }}> + <CardHeader className="py-3"> + <CardTitle className="text-base"> + {t("drawingList.title")} + {filteredDrawings.length > 0 && ` ${t("drawingList.count", { count: filteredDrawings.length })}`} + </CardTitle> + </CardHeader> + <CardContent className="p-0"> + {!projNo || !vendorInfo ? ( + <div className="flex items-center justify-center text-muted-foreground p-8" style={{ minHeight: "400px" }}> + <div className="text-center"> + <InfoIcon className="h-12 w-12 mx-auto mb-2 opacity-50" /> + <p>{t("page.selectProject")}</p> + </div> + </div> + ) : isRefreshing ? ( + <div className="flex items-center justify-center" style={{ minHeight: "400px" }}> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ) : ( + <DrawingListTableV2 + columns={ + vendorInfo.drawingKind === "B4" + ? (createGttDrawingListColumns({ documentType, lng, t }) as unknown as typeof drawingListColumns) + : (drawingListColumns(lng, t) as unknown as typeof drawingListColumns) + } + data={filteredDrawings} + onRowClick={handleDrawingClick} + selectedRow={selectedDrawing || undefined} + getRowId={getDrawingId} + maxHeight="calc(100vh - 600px)" + minHeight="400px" + defaultPageSize={10} + /> + )} + </CardContent> + </Card> + + {/* 하단: 상세도면리스트 + 파일리스트 - 항상 렌더링 */} + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0" style={{ minHeight: "500px" }}> + {/* 좌측: 상세도면 리스트 */} + <Card className="flex flex-col min-h-0"> + <CardHeader className="flex-row items-center justify-between py-3 flex-shrink-0"> + <CardTitle className="text-base"> + {t("detailDialog.detailListTitle")} + {selectedDrawing && ( + <span className="text-xs font-normal text-muted-foreground ml-2"> + {selectedDrawing.DrawingNo} + </span> + )} + </CardTitle> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleRefreshDetails} + disabled={!selectedDrawing || isLoadingDetails} + > + <RefreshCw className={`h-4 w-4 ${isLoadingDetails ? "animate-spin" : ""}`} /> + </Button> + {canAddDetailDrawing && ( + <Button + variant="default" + size="sm" + onClick={() => setAddDialogOpen(true)} + disabled={!selectedDrawing} + > + <Plus className="h-4 w-4 mr-2" /> + {t("detailDialog.addDetailButton")} + </Button> + )} + </div> + </CardHeader> + <CardContent className="flex-1 p-0 min-h-0"> + {!selectedDrawing ? ( + <div className="h-full flex items-center justify-center text-muted-foreground p-4" style={{ minHeight: "400px" }}> + <div className="text-center"> + <InfoIcon className="h-12 w-12 mx-auto mb-2 opacity-50" /> + <p>도면을 선택해주세요</p> + </div> + </div> + ) : isLoadingDetails ? ( + <div className="flex items-center justify-center h-full" style={{ minHeight: "400px" }}> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ) : ( + <DrawingListTableV2<DetailDwgReceiptItem, unknown> + columns={createDetailDrawingColumns(lng, t)} + data={detailDrawings} + onRowClick={setSelectedDetail} + selectedRow={selectedDetail || undefined} + getRowId={getDetailDrawingId} + maxHeight="calc(100vh - 600px)" + minHeight="400px" + defaultPageSize={10} + /> + )} + </CardContent> + </Card> + + {/* 우측: 첨부파일 리스트 */} + <Card className="flex flex-col min-h-0"> + <CardHeader className="flex-row items-center justify-between py-3 flex-shrink-0"> + <CardTitle className="text-base"> + {t("detailDialog.fileListTitle")} + {selectedDetail && ( + <span className="text-xs font-normal text-muted-foreground ml-2"> + Rev. {selectedDetail.DrawingRevNo} + </span> + )} + </CardTitle> + {selectedDetail && canAddDetailDrawing && ( + <Button + variant="default" + size="sm" + onClick={() => setUploadFilesDialogOpen(true)} + > + <Upload className="h-4 w-4 mr-2" /> + {t("detailDialog.uploadFilesButton")} + </Button> + )} + </CardHeader> + <CardContent className="flex-1 p-0 min-h-0"> + {!selectedDetail ? ( + <div className="h-full flex items-center justify-center text-muted-foreground p-4" style={{ minHeight: "400px" }}> + <div className="text-center"> + <InfoIcon className="h-12 w-12 mx-auto mb-2 opacity-50" /> + <p>{t("detailDialog.selectDetailDrawing")}</p> + </div> + </div> + ) : isLoadingFiles ? ( + <div className="flex items-center justify-center h-full" style={{ minHeight: "400px" }}> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ) : ( + <DrawingListTableV2 + columns={fileColumns} + data={files} + maxHeight="calc(100vh - 600px)" + minHeight="400px" + defaultPageSize={10} + /> + )} + </CardContent> + </Card> + </div> + + {/* B4 일괄 업로드 다이얼로그 (V2) */} + {/* V2: MatchBatchFileDwg API를 사용하지 않는 새로운 방식 */} + {vendorInfo && vendorInfo.drawingKind === "B4" && projNo && ( + <B4BulkUploadDialogV2 + open={bulkUploadDialogOpen} + onOpenChange={setBulkUploadDialogOpen} + projectNo={projNo} + userId={vendorInfo.userId} + userName={vendorInfo.userName} + userEmail={vendorInfo.email} + vendorCode={vendorInfo.vendorCode} + onUploadComplete={handleBulkUploadComplete} + lng={lng} + /> + )} + {/* V1로 되돌리려면: 위의 B4BulkUploadDialogV2를 B4BulkUploadDialog로 변경하세요 */} + + {/* 상세도면 추가 다이얼로그 */} + {vendorInfo && selectedDrawing && ( + <AddDetailDrawingDialog + 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 && ( + <UploadFilesToDetailDialog + open={uploadFilesDialogOpen} + onOpenChange={setUploadFilesDialogOpen} + uploadId={selectedDetail.UploadId} + drawingNo={selectedDetail.DrawingNo} + revNo={selectedDetail.DrawingRevNo} + userId={vendorInfo.userId} + onUploadComplete={handleUploadComplete} + lng={lng} + /> + )} + </div> + ); +} + diff --git a/app/[lng]/partners/(partners)/dolce-upload-v2/page.tsx b/app/[lng]/partners/(partners)/dolce-upload-v2/page.tsx new file mode 100644 index 00000000..6655606f --- /dev/null +++ b/app/[lng]/partners/(partners)/dolce-upload-v2/page.tsx @@ -0,0 +1,74 @@ +import { Suspense } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import DolceUploadPageV2 from "./dolce-upload-page-v2"; +import { Shell } from "@/components/shell"; + +export const metadata = { + title: "조선 벤더문서 업로드(DOLCE) V2", + 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 도면 업로드 V2" + : "DOLCE Drawing Upload V2"} + </h2> + <p className="text-muted-foreground"> + {lng === "ko" + ? "설계문서를 조회하고 업로드할 수 있습니다 (분할 레이아웃)" + : "View and upload design documents (Split Layout)"} + </p> + </div> + </div> + + {/* 메인 컨텐츠 */} + <Suspense fallback={<DolceUploadSkeleton />}> + <DolceUploadPageV2 searchParams={resolvedParams} /> + </Suspense> + </Shell> + ); +} + diff --git a/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx b/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx index 43800838..e03f6bc2 100644 --- a/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx +++ b/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx @@ -28,7 +28,10 @@ import { DrawingListTable } from "@/lib/dolce/table/drawing-list-table"; import { drawingListColumns } from "@/lib/dolce/table/drawing-list-columns"; import { createGttDrawingListColumns, DocumentType } from "@/lib/dolce/table/gtt-drawing-list-columns"; import { DetailDrawingDialog } from "@/lib/dolce/dialogs/detail-drawing-dialog"; -import { B4BulkUploadDialog } from "@/lib/dolce/dialogs/b4-bulk-upload-dialog"; +// V2: MatchBatchFileDwg API를 사용하지 않는 새로운 일괄 업로드 (DetailDwgReceiptMgmtEdit 사용) +import { B4BulkUploadDialogV2 } from "@/lib/dolce/dialogs/b4-bulk-upload-dialog-v2"; +// V1로 되돌리려면: 위 줄을 주석 처리하고 아래 줄의 주석을 해제하세요 +// import { B4BulkUploadDialog } from "@/lib/dolce/dialogs/b4-bulk-upload-dialog"; interface DolceUploadPageProps { searchParams: { [key: string]: string | string[] | undefined }; @@ -395,9 +398,10 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) /> )} - {/* B4 일괄 업로드 다이얼로그 */} + {/* B4 일괄 업로드 다이얼로그 (V2) */} + {/* V2: MatchBatchFileDwg API를 사용하지 않는 새로운 방식 */} {vendorInfo && vendorInfo.drawingKind === "B4" && projNo && ( - <B4BulkUploadDialog + <B4BulkUploadDialogV2 open={bulkUploadDialogOpen} onOpenChange={setBulkUploadDialogOpen} projectNo={projNo} @@ -409,6 +413,7 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) lng={lng} /> )} + {/* V1로 되돌리려면: 위의 B4BulkUploadDialogV2를 B4BulkUploadDialog로 변경하세요 */} </div> ); } diff --git a/lib/dolce/actions.ts b/lib/dolce/actions.ts index 77de430f..552a9a6a 100644 --- a/lib/dolce/actions.ts +++ b/lib/dolce/actions.ts @@ -684,6 +684,390 @@ export async function uploadFilesToDetailDrawing( } /** + * B4 매핑 정보 일괄 저장 (MatchBatchFileDwgEdit) + */ +export interface B4MappingSaveItem { + CGbn: string | null; + Category: string | null; + CheckBox: string; + DGbn: string | null; + DegreeGbn: string | null; + DeptGbn: string | null; + Discipline: string | null; + DrawingKind: string; + DrawingMoveGbn: string | null; + DrawingName: string | null; + DrawingNo: string; + DrawingUsage: string | null; + FileNm: string; + JGbn: string | null; + Manager: string | null; + MappingYN: "Y" | "N"; + NewOrNot: string | null; + ProjectNo: string; + RegisterGroup: number; + RegisterGroupId: number; + RegisterKindCode: string | null; + RegisterSerialNo: number; + RevNo: string | null; + SGbn: string | null; + UploadId: string; +} + +export async function saveB4MappingBatch( + mappingSaveLists: B4MappingSaveItem[], + userId: string +): Promise<number> { + try { + const response = await dolceApiCall<{ + MatchBatchFileDwgEditResult: number; + }>("MatchBatchFileDwgEdit", { + mappingSaveLists, + UserID: userId, + }); + + return response.MatchBatchFileDwgEditResult; + } catch (error) { + console.error("B4 매핑 정보 저장 실패:", error); + throw error; + } +} + +/** + * B4 파일명 파싱 (validateB4FileName과 동일한 로직) + * 형식: [버림] [문서번호토큰1] [문서번호토큰2] ... [리비전번호].[확장자] + * 예시: "testfile GTT DE 007 R01.pdf" → DrawingNo: "GTT-DE-007", RevNo: "R01" + */ +async function parseB4FileName(fileName: string): Promise<{ + valid: boolean; + drawingNo?: string; + revNo?: string; + error?: string; +}> { + try { + const lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex === -1) { + return { valid: false, error: "파일 확장자가 없습니다" }; + } + + const nameWithoutExt = fileName.substring(0, lastDotIndex); + const parts = nameWithoutExt.split(" ").filter((p) => p.trim() !== ""); + + if (parts.length < 3) { + return { + valid: false, + error: `공백이 최소 2개 있어야 합니다 (현재: ${parts.length - 1}개)`, + }; + } + + const revNo = parts[parts.length - 1]; + const drawingTokens = parts.slice(1, parts.length - 1); + const drawingNo = drawingTokens.join("-"); + + if (!drawingNo || !revNo) { + return { valid: false, error: "도면번호 또는 리비전번호가 비어있습니다" }; + } + + return { valid: true, drawingNo: drawingNo.trim(), revNo: revNo.trim() }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } +} + +/** + * B4 파일 일괄 업로드 V2 + * + * MatchBatchFileDwg/MatchBatchFileDwgEdit API 대신 + * DetailDwgReceiptMgmtEdit API와 업로드 서비스만 사용 + * + * 프로세스: + * 1. 파일명 파싱하여 DrawingNo, RevNo 추출 + * 2. 기존 도면 정보 조회 (fetchDwgReceiptList) + * 3. 기존 상세도면 조회 (fetchDetailDwgReceiptList) + * 4. 없으면 ADD, 있으면 기존 UploadId 사용 + * 5. 파일 업로드 (/api/dolce/upload-files) + */ +export async function bulkUploadB4FilesV2( + formData: FormData +): Promise<B4BulkUploadResult> { + try { + console.log("[V2] B4 일괄 업로드 시작"); + + // 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 registerKind = formData.get("registerKind") as string; + const fileCount = parseInt(formData.get("fileCount") as string); + + if (!projectNo || !userId || !userNm || !email || !vendorCode || !registerKind || !fileCount) { + throw new Error("필수 파라미터가 누락되었습니다"); + } + + console.log(`[V2] 프로젝트: ${projectNo}, 사용자: ${userId}, 파일 수: ${fileCount}`); + + const results: Array<{ + drawingNo: string; + revNo: string; + fileName: string; + success: boolean; + error?: string; + }> = []; + + let successCount = 0; + let failCount = 0; + + // 1단계: 파일 수집 및 파싱 + interface ParsedFile { + file: File; + drawingNo: string; + revNo: string; + fileName: string; + } + + const parsedFiles: ParsedFile[] = []; + + for (let i = 0; i < fileCount; i++) { + const file = formData.get(`file_${i}`) as File; + if (!file) continue; + + const parseResult = await parseB4FileName(file.name); + if (!parseResult.valid || !parseResult.drawingNo || !parseResult.revNo) { + results.push({ + drawingNo: "", + revNo: "", + fileName: file.name, + success: false, + error: parseResult.error || "파일명 파싱 실패", + }); + failCount++; + continue; + } + + parsedFiles.push({ + file, + drawingNo: parseResult.drawingNo, + revNo: parseResult.revNo, + fileName: file.name, + }); + } + + console.log(`[V2] 파싱 완료: ${parsedFiles.length}개 성공, ${failCount}개 실패`); + + // 2단계: DrawingNo별로 기본 도면 정보 조회 + const drawingNoSet = new Set(parsedFiles.map((f) => f.drawingNo)); + const drawingInfoMap = new Map<string, GttDwgReceiptItem>(); + + for (const drawingNo of drawingNoSet) { + try { + const dwgList = await fetchDwgReceiptList({ + project: projectNo, + drawingKind: "B4", + drawingMoveGbn: "도면입수", + drawingNo: drawingNo, + }); + + const dwgInfo = dwgList.find( + (d) => (d as GttDwgReceiptItem).DrawingNo === drawingNo + ) as GttDwgReceiptItem | undefined; + + if (dwgInfo) { + drawingInfoMap.set(drawingNo, dwgInfo); + console.log(`[V2] 도면 정보 조회 완료: ${drawingNo}`); + } else { + console.warn(`[V2] 도면 정보 없음: ${drawingNo}`); + } + } catch (error) { + console.error(`[V2] 도면 정보 조회 실패: ${drawingNo}`, error); + } + } + + // 3단계: DrawingNo + RevNo로 그룹화 + const uploadGroups = new Map< + string, + { + drawingNo: string; + revNo: string; + files: File[]; + drawingInfo?: GttDwgReceiptItem; + } + >(); + + for (const parsed of parsedFiles) { + const groupKey = `${parsed.drawingNo}_${parsed.revNo}`; + if (!uploadGroups.has(groupKey)) { + uploadGroups.set(groupKey, { + drawingNo: parsed.drawingNo, + revNo: parsed.revNo, + files: [], + drawingInfo: drawingInfoMap.get(parsed.drawingNo), + }); + } + uploadGroups.get(groupKey)!.files.push(parsed.file); + } + + console.log(`[V2] ${uploadGroups.size}개 그룹으로 묶임`); + + // 4단계: 각 그룹별로 처리 + for (const [groupKey, group] of uploadGroups.entries()) { + const { drawingNo, revNo, files, drawingInfo } = group; + + try { + console.log(`[V2] 그룹 처리 시작: ${groupKey} (${files.length}개 파일)`); + + // 도면 정보가 없으면 실패 + if (!drawingInfo) { + throw new Error(`도면 정보를 찾을 수 없습니다: ${drawingNo}`); + } + + // 4-1. 기존 상세도면 조회 + const detailDwgList = await fetchDetailDwgReceiptList({ + project: projectNo, + drawingNo: drawingNo, + discipline: drawingInfo.Discipline, + drawingKind: "B4", + userId: userId, + }); + + console.log(`[V2] 기존 상세도면: ${detailDwgList.length}개`); + + // 4-2. 해당 RevNo의 상세도면 찾기 + const existingDetail = detailDwgList.find( + (d) => d.DrawingRevNo === revNo + ); + + let uploadId: string; + + if (existingDetail) { + // 기존 상세도면이 있으면 해당 UploadId 사용 + uploadId = existingDetail.UploadId; + console.log(`[V2] 기존 상세도면 사용: ${revNo}, UploadId: ${uploadId}`); + } else { + // 4-3. 없으면 새로 생성 (ADD) + uploadId = crypto.randomUUID(); + + // 기존 상세도면이 있으면 거기서 Category 가져오기, 없으면 기본값 + const category = detailDwgList.length > 0 ? detailDwgList[0].Category : "NORM"; + const registerDesc = ""; + + console.log(`[V2] 새 상세도면 생성: ${revNo}, UploadId: ${uploadId}`); + + const addRequest: DetailDwgEditRequest = { + Mode: "ADD", + Status: "01", + RegisterId: 0, + ProjectNo: projectNo, + Discipline: drawingInfo.Discipline, + DrawingKind: "B4", + DrawingNo: drawingNo, + DrawingName: drawingInfo.DrawingName, + RegisterGroupId: drawingInfo.RegisterGroupId, + RegisterSerialNo: drawingInfo.RegisterGroup, + RegisterKind: registerKind, + DrawingRevNo: revNo, + Category: category, + Receiver: null, + Manager: drawingInfo.Manager || "970043", + RegisterDesc: registerDesc, + UploadId: uploadId, + RegCompanyCode: vendorCode, + }; + + await editDetailDwgReceipt({ + dwgList: [addRequest], + userId: userId, + userNm: userNm, + vendorCode: vendorCode, + email: email, + }); + + console.log(`[V2] 상세도면 ADD 완료: ${groupKey}`); + } + + // 4-4. 파일 업로드 + console.log(`[V2] 파일 업로드 시작: ${files.length}개 파일`); + + const uploadFormData = new FormData(); + uploadFormData.append("uploadId", uploadId); + uploadFormData.append("userId", userId); + uploadFormData.append("fileCount", String(files.length)); + + files.forEach((file, index) => { + uploadFormData.append(`file_${index}`, file); + }); + + // Next.js API Route 호출 (프록시) + const uploadResponse = await fetch("/api/dolce/upload-files", { + method: "POST", + body: uploadFormData, + }); + + if (!uploadResponse.ok) { + const errorData = await uploadResponse.json(); + throw new Error(errorData.error || `파일 업로드 실패: ${uploadResponse.status}`); + } + + const uploadResult = await uploadResponse.json(); + + if (!uploadResult.success) { + throw new Error(uploadResult.error || "파일 업로드 실패"); + } + + console.log(`[V2] 파일 업로드 완료: ${groupKey}`); + + // 성공 처리 + for (const file of files) { + results.push({ + drawingNo, + revNo, + fileName: file.name, + success: true, + }); + successCount++; + } + } catch (error) { + // 실패 처리 + const errorMessage = + error instanceof Error ? error.message : "알 수 없는 오류"; + + console.error(`[V2] 그룹 처리 실패: ${groupKey}`, error); + + for (const file of group.files) { + results.push({ + drawingNo: group.drawingNo, + revNo: group.revNo, + fileName: file.name, + success: false, + error: errorMessage, + }); + failCount++; + } + } + } + + console.log(`[V2] 일괄 업로드 완료: 성공 ${successCount}, 실패 ${failCount}`); + + return { + success: successCount > 0, + successCount, + failCount, + results, + }; + } catch (error) { + console.error("[V2] 일괄 업로드 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } +} + +/** * B4 파일 일괄 업로드 * 주의: formData를 사용하여 대용량 파일 처리 */ diff --git a/lib/dolce/dialogs/b4-bulk-upload-dialog-v2.tsx b/lib/dolce/dialogs/b4-bulk-upload-dialog-v2.tsx new file mode 100644 index 00000000..3207c00b --- /dev/null +++ b/lib/dolce/dialogs/b4-bulk-upload-dialog-v2.tsx @@ -0,0 +1,625 @@ +"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 { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +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 "./b4-upload-validation-dialog"; +import { + fetchDwgReceiptList, + bulkUploadB4FilesV2, + type B4BulkUploadResult, + type GttDwgReceiptItem, +} from "../actions"; + +interface B4BulkUploadDialogV2Props { + open: boolean; + onOpenChange: (open: boolean) => void; + projectNo: string; + userId: string; + userName: string; + userEmail: string; + vendorCode: string; + onUploadComplete?: () => void; + lng: string; +} + +type UploadStep = "settings" | "files" | "validation" | "uploading" | "complete"; + +export function B4BulkUploadDialogV2({ + open, + onOpenChange, + projectNo, + userId, + userName, + userEmail, + vendorCode, + onUploadComplete, + lng, +}: B4BulkUploadDialogV2Props) { + const { t } = useTranslation(lng, "dolce"); + const [currentStep, setCurrentStep] = useState<UploadStep>("settings"); + const [drawingUsage, setDrawingUsage] = useState<string>("REC"); + const [registerKind, setRegisterKind] = useState<string>(""); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [isUploading, setIsUploading] = useState(false); + const [validationResults, setValidationResults] = useState<FileValidationResult[]>([]); + const [showValidationDialog, setShowValidationDialog] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadResult, setUploadResult] = useState<B4BulkUploadResult | null>(null); + + // B4 GTT 옵션 + const drawingUsageOptions = [ + { value: "REC", label: t("bulkUpload.drawingUsageReceive") }, + ]; + + const registerKindOptionsMap: Record<string, Array<{ value: string; label: string }>> = { + REC: [ + { value: "RECP", label: t("bulkUpload.registerKindRecP") }, + { value: "RECW", label: t("bulkUpload.registerKindRecW") }, + ], + }; + + // 다이얼로그 닫을 때 초기화 + React.useEffect(() => { + if (!open) { + setCurrentStep("settings"); + setDrawingUsage("REC"); + setRegisterKind(""); + setSelectedFiles([]); + setValidationResults([]); + setShowValidationDialog(false); + setIsDragging(false); + setUploadProgress(0); + setUploadResult(null); + } + }, [open]); + + // 파일 선택 핸들러 + 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 핸들러 + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.currentTarget === e.target) { + 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)); + }; + + // 1단계 완료 (설정) + const handleSettingsNext = () => { + if (!registerKind) { + toast.error(t("bulkUpload.selectRegisterKindError")); + return; + } + setCurrentStep("files"); + }; + + // 2단계 완료 (파일 선택) + const handleFilesNext = () => { + if (selectedFiles.length === 0) { + toast.error(t("bulkUpload.selectFilesError")); + return; + } + setCurrentStep("validation"); + handleValidate(); + }; + + // 검증 시작 (V2: fetchDwgReceiptList 사용) + const handleValidate = async () => { + try { + console.log("[V2 Dialog] 검증 시작"); + + // 1단계: 파일명 파싱 + 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단계: DrawingNo별로 도면 정보 조회 + const drawingNoSet = new Set(parsedFiles.map((r) => r.parsed!.drawingNo)); + const drawingInfoMap = new Map<string, GttDwgReceiptItem>(); + + console.log(`[V2 Dialog] ${drawingNoSet.size}개 도면번호 조회`); + + for (const drawingNo of drawingNoSet) { + try { + const dwgList = await fetchDwgReceiptList({ + project: projectNo, + drawingKind: "B4", + drawingMoveGbn: "도면입수", + drawingNo: drawingNo, + }); + + // 해당 DrawingNo 찾기 + const dwgInfo = dwgList.find( + (d) => (d as GttDwgReceiptItem).DrawingNo === drawingNo + ) as GttDwgReceiptItem | undefined; + + if (dwgInfo) { + drawingInfoMap.set(drawingNo, dwgInfo); + console.log(`[V2 Dialog] 도면 정보 조회 완료: ${drawingNo}`); + } else { + console.log(`[V2 Dialog] 도면 정보 없음: ${drawingNo}`); + } + } catch (error) { + console.error(`[V2 Dialog] 도면 정보 조회 실패: ${drawingNo}`, error); + } + } + + // 3단계: 검증 결과 병합 + const finalResults: FileValidationResult[] = parseResults.map((parseResult) => { + if (!parseResult.valid || !parseResult.parsed) { + return parseResult; + } + + const drawingInfo = drawingInfoMap.get(parseResult.parsed.drawingNo); + + if (!drawingInfo) { + return { + ...parseResult, + mappingStatus: "not_found" as const, + error: t("validation.notRegistered"), + }; + } + + // DrawingMoveGbn이 "도면입수"가 아니면 업로드 불가 + if (drawingInfo.DrawingMoveGbn !== "도면입수") { + return { + ...parseResult, + mappingStatus: "not_found" as const, + error: t("validation.notGttDeliverables"), + }; + } + + // 업로드 가능 + return { + ...parseResult, + mappingStatus: "available" as const, + drawingName: drawingInfo.DrawingName || undefined, + registerGroupId: drawingInfo.RegisterGroupId, + }; + }); + + console.log("[V2 Dialog] 검증 완료"); + setValidationResults(finalResults); + setShowValidationDialog(true); + } catch (error) { + console.error("[V2 Dialog] 검증 실패:", error); + toast.error( + error instanceof Error ? error.message : t("bulkUpload.validationError") + ); + } + }; + + // 업로드 확인 (V2: bulkUploadB4FilesV2 사용) + const handleConfirmUpload = async (validFiles: FileValidationResult[]) => { + setIsUploading(true); + setCurrentStep("uploading"); + setShowValidationDialog(false); + + try { + console.log(`[V2 Dialog] 업로드 시작: ${validFiles.length}개 파일`); + + // 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", registerKind); + formData.append("fileCount", String(validFiles.length)); + + validFiles.forEach((fileResult, index) => { + formData.append(`file_${index}`, fileResult.file); + }); + + // 업로드 프로그레스 시뮬레이션 + const progressInterval = setInterval(() => { + setUploadProgress((prev) => { + if (prev >= 90) return 90; + return prev + 10; + }); + }, 500); + + // V2 함수 호출 + const result = await bulkUploadB4FilesV2(formData); + + clearInterval(progressInterval); + setUploadProgress(100); + + console.log("[V2 Dialog] 업로드 완료:", result); + + setUploadResult(result); + setCurrentStep("complete"); + + if (result.success) { + toast.success( + t("bulkUpload.uploadSuccessToast", { + successCount: result.successCount, + total: validFiles.length, + }) + ); + } else { + toast.error(result.error || t("bulkUpload.uploadError")); + } + } catch (error) { + console.error("[V2 Dialog] 업로드 실패:", error); + toast.error( + error instanceof Error ? error.message : t("bulkUpload.uploadError") + ); + setCurrentStep("files"); + } finally { + setIsUploading(false); + } + }; + + const registerKindOptions = drawingUsage + ? registerKindOptionsMap[drawingUsage] || [] + : []; + + const handleDrawingUsageChange = (value: string) => { + setDrawingUsage(value); + setRegisterKind(""); + }; + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>{t("bulkUpload.title")} (V2)</DialogTitle> + <DialogDescription> + {currentStep === "settings" && t("bulkUpload.stepSettings")} + {currentStep === "files" && t("bulkUpload.stepFiles")} + {currentStep === "validation" && t("bulkUpload.stepValidation")} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 1단계: 설정 입력 */} + {currentStep === "settings" && ( + <> + {/* 도면용도 선택 */} + <div className="space-y-2"> + <Label>{t("bulkUpload.drawingUsage")} *</Label> + <Select value={drawingUsage} onValueChange={handleDrawingUsageChange}> + <SelectTrigger> + <SelectValue placeholder={t("bulkUpload.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("bulkUpload.registerKind")} *</Label> + <Select + value={registerKind} + onValueChange={setRegisterKind} + disabled={!drawingUsage} + > + <SelectTrigger> + <SelectValue placeholder={t("bulkUpload.registerKindPlaceholder")} /> + </SelectTrigger> + <SelectContent> + {registerKindOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <p className="text-sm text-muted-foreground"> + {t("bulkUpload.registerKindNote")} + </p> + </div> + </> + )} + + {/* 2단계: 파일 선택 */} + {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-v2" + /> + <label + htmlFor="b4-file-upload-v2" + 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 transition-colors ${ + isDragging + ? "text-primary font-medium" + : "text-muted-foreground" + }`} + > + {isDragging + ? t("bulkUpload.fileDropHere") + : t("bulkUpload.fileSelectArea")} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {t("bulkUpload.fileTypes")} + </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" + > + <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={() => handleRemoveFile(index)} + > + {t("bulkUpload.removeFile")} + </Button> + </div> + ))} + </div> + </div> + )} + </> + )} + + {/* 3단계: 검증 중 표시 */} + {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> + )} + + {/* 4단계: 업로드 진행 중 */} + {currentStep === "uploading" && ( + <div className="space-y-6 py-8"> + <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">{t("bulkUpload.uploading")}</h3> + <p className="text-sm text-muted-foreground"> + {t("bulkUpload.uploadingWait")} + </p> + </div> + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span>{t("bulkUpload.uploadProgress")}</span> + <span>{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} className="h-2" /> + {uploadProgress >= 90 && uploadProgress < 100 && ( + <p className="text-xs text-muted-foreground text-center pt-2"> + {t("bulkUpload.uploadingToServer")} + </p> + )} + </div> + </div> + )} + + {/* 5단계: 업로드 완료 */} + {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">{t("bulkUpload.uploadComplete")}</h3> + <p className="text-sm text-muted-foreground"> + {t("bulkUpload.uploadSuccessMessage", { count: uploadResult.successCount })} + </p> + </div> + + {uploadResult.failCount && uploadResult.failCount > 0 && ( + <div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> + <p className="text-sm text-yellow-800 dark:text-yellow-200"> + {t("bulkUpload.uploadFailMessage", { count: uploadResult.failCount })} + </p> + </div> + )} + + <div className="flex justify-center"> + <Button + onClick={() => { + onOpenChange(false); + onUploadComplete?.(); + }} + > + {t("bulkUpload.confirmButton")} + </Button> + </div> + </div> + )} + </div> + + {/* 푸터 버튼 */} + {currentStep !== "uploading" && currentStep !== "complete" && currentStep !== "validation" && ( + <DialogFooter> + {currentStep === "settings" && ( + <> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + > + {t("bulkUpload.cancelButton")} + </Button> + <Button + onClick={handleSettingsNext} + disabled={!registerKind} + > + {t("bulkUpload.nextButton")} + <ChevronRight className="ml-2 h-4 w-4" /> + </Button> + </> + )} + + {currentStep === "files" && ( + <> + <Button + variant="outline" + onClick={() => setCurrentStep("settings")} + > + <ChevronLeft className="mr-2 h-4 w-4" /> + {t("bulkUpload.previousButton")} + </Button> + <Button + onClick={handleFilesNext} + disabled={selectedFiles.length === 0} + > + {t("bulkUpload.validateButton")} + <ChevronRight className="ml-2 h-4 w-4" /> + </Button> + </> + )} + </DialogFooter> + )} + </DialogContent> + </Dialog> + + {/* 검증 다이얼로그 */} + <B4UploadValidationDialog + open={showValidationDialog} + onOpenChange={(open) => { + setShowValidationDialog(open); + if (!open) { + onOpenChange(false); + } + }} + validationResults={validationResults} + onConfirmUpload={handleConfirmUpload} + isUploading={isUploading} + /> + </> + ); +} + diff --git a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx index 1be7f226..21647e63 100644 --- a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx +++ b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx @@ -30,9 +30,10 @@ import { } from "./b4-upload-validation-dialog"; import { checkB4MappingStatus, - editDetailDwgReceipt, + saveB4MappingBatch, type MappingCheckItem, type B4BulkUploadResult, + type B4MappingSaveItem, } from "../actions"; import { v4 as uuidv4 } from "uuid"; @@ -343,37 +344,54 @@ export function B4BulkUploadDialog({ console.log(`[B4 업로드] 그룹 ${groupKey} 파일 업로드 완료`); - // 3. 상세도면 등록 - await editDetailDwgReceipt({ - dwgList: [ - { - Mode: "ADD", - Status: "Draft", - RegisterId: 0, - ProjectNo: projectNo, - Discipline: "", - DrawingKind: "B4", - DrawingNo: drawingNo, - DrawingName: "", - RegisterGroupId: registerGroupId, - RegisterSerialNo: 0, - RegisterKind: registerKind, - DrawingRevNo: revNo, - Category: "TS", - Receiver: null, - Manager: "", - RegisterDesc: "", - UploadId: uploadId, - RegCompanyCode: vendorCode, - }, - ], - userId, - userNm: userName, - vendorCode, - email: userEmail, - }); + // 3. 매핑 현황 재조회 (MatchBatchFileDwg) + const mappingCheckResults = await checkB4MappingStatus(projectNo, [ + { + DrawingNo: drawingNo, + RevNo: revNo, + FileNm: files[0].fileName, + }, + ]); + + const mappingData = mappingCheckResults[0]; + if (!mappingData || mappingData.RegisterGroupId === 0) { + throw new Error(`매핑 정보를 찾을 수 없습니다: ${groupKey}`); + } + + console.log(`[B4 업로드] 그룹 ${groupKey} 매핑 정보 조회 완료`); + + // 4. 매핑 정보 저장 (MatchBatchFileDwgEdit) + const mappingSaveItem: B4MappingSaveItem = { + CGbn: mappingData.CGbn, + Category: mappingData.Category, + CheckBox: "0", + DGbn: mappingData.DGbn, + DegreeGbn: mappingData.DegreeGbn, + DeptGbn: mappingData.DeptGbn, + Discipline: mappingData.Discipline, + DrawingKind: "B4", + DrawingMoveGbn: "도면입수", + DrawingName: mappingData.DrawingName, + DrawingNo: drawingNo, + DrawingUsage: "입수용", + FileNm: files[0].fileName, + JGbn: mappingData.JGbn, + Manager: mappingData.Manager || "970043", + MappingYN: "Y", + NewOrNot: "N", + ProjectNo: projectNo, + RegisterGroup: 0, + RegisterGroupId: registerGroupId, + RegisterKindCode: registerKind, + RegisterSerialNo: mappingData.RegisterSerialNo, + RevNo: revNo, + SGbn: mappingData.SGbn, + UploadId: uploadId, + }; + + await saveB4MappingBatch([mappingSaveItem], userId); - console.log(`[B4 업로드] 그룹 ${groupKey} 상세도면 등록 완료`); + console.log(`[B4 업로드] 그룹 ${groupKey} 매핑 정보 저장 완료`); successCount += files.length; } catch (error) { @@ -685,7 +703,12 @@ export function B4BulkUploadDialog({ {/* 검증 다이얼로그 */} <B4UploadValidationDialog open={showValidationDialog} - onOpenChange={setShowValidationDialog} + onOpenChange={(open) => { + setShowValidationDialog(open); + if (!open) { + onOpenChange(false); // 검증 다이얼로그가 닫히면 메인 다이얼로그도 닫기 + } + }} validationResults={validationResults} onConfirmUpload={handleConfirmUpload} isUploading={isUploading} diff --git a/lib/dolce/table/detail-drawing-columns.tsx b/lib/dolce/table/detail-drawing-columns.tsx index 77d25953..c082333d 100644 --- a/lib/dolce/table/detail-drawing-columns.tsx +++ b/lib/dolce/table/detail-drawing-columns.tsx @@ -8,8 +8,8 @@ import { formatDolceDateTime } from "../utils/date-formatter"; const DRAWING_USAGE_MAP: Record<string, { ko: string; en: string }> = { APP: { ko: "승인용", en: "Approval" }, WOR: { ko: "작업용", en: "Working" }, - REC: { ko: "입수용 / GTT→SHI", en: "GTT→SHI" }, - SUB: { ko: "제출용 / SHI→GTT", en: "SHI→GTT" }, + REC: { ko: "입수용", en: "GTT→SHI" }, + SUB: { ko: "제출용", en: "SHI→GTT" }, }; const REGISTER_KIND_MAP: Record<string, { ko: string; en: string }> = { @@ -36,82 +36,6 @@ const translateRegisterKind = (code: string | null, lng: string): string => { return lng === "en" ? mapped.en : mapped.ko; }; -// 기본 컬럼 (기존 호환성 유지) -export const detailDrawingColumns: ColumnDef<DetailDwgReceiptItem>[] = [ - { - accessorKey: "RegisterSerialNo", - header: "일련번호", - minSize: 80, - cell: ({ row }) => { - return <div className="text-center">{row.getValue("RegisterSerialNo")}</div>; - }, - }, - { - accessorKey: "DrawingRevNo", - header: "Revision", - minSize: 100, - cell: ({ row }) => { - return <div className="font-medium">{row.getValue("DrawingRevNo")}</div>; - }, - }, - { - accessorKey: "Status", - header: "상태", - minSize: 120, - cell: ({ row }) => { - return <div className="text-center">{row.getValue("Status")}</div>; - }, - }, - { - accessorKey: "CategoryENM", - header: "카테고리", - minSize: 120, - cell: ({ row }) => { - const categoryENM = row.getValue("CategoryENM") as string; - const categoryNM = row.original.CategoryNM; - return <div>{categoryENM || categoryNM}</div>; - }, - }, - { - accessorKey: "DrawingUsageENM", - header: "도면용도", - minSize: 100, - cell: ({ row }) => { - const usageENM = row.getValue("DrawingUsageENM") as string | null; - const usageNM = row.original.DrawingUsageNM; - return <div>{usageENM || usageNM}</div>; - }, - }, - { - accessorKey: "RegisterKindENM", - header: "등록종류", - minSize: 180, - cell: ({ row }) => { - const kindENM = row.getValue("RegisterKindENM") as string | null; - const kindNM = row.original.RegisterKindNM; - return <div>{kindENM || kindNM}</div>; - }, - }, - { - accessorKey: "CreateUserNM", - header: "생성자", - minSize: 150, - cell: ({ row }) => { - const userENM = row.original.CreateUserENM; - const userNM = row.getValue("CreateUserNM") as string; - return <div>{userENM || userNM}</div>; - }, - }, - { - accessorKey: "CreateDt", - header: "생성일시", - minSize: 200, - cell: ({ row }) => { - return <div className="text-sm text-muted-foreground">{row.getValue("CreateDt")}</div>; - }, - }, -]; - // 다국어 지원 컬럼 생성 함수 export function createDetailDrawingColumns( lng: string, @@ -155,25 +79,25 @@ export function createDetailDrawingColumns( }, }, { - accessorKey: "DrawingUsageENM", + accessorKey: "DrawingUsage", header: t("detailDrawing.columns.drawingUsage"), minSize: 100, cell: ({ row }) => { - // API의 ENM이 제대로 번역되지 않아 코드 값으로 직접 변환 - const usageCode = row.getValue("DrawingUsageENM") as string | null; + // API 응답의 DrawingUsage는 코드값이므로 직접 매핑하여 번역 + const usageCode = row.getValue("DrawingUsage") as string; const translated = translateDrawingUsage(usageCode, lng); - return <div>{translated || usageCode || row.original.DrawingUsageNM}</div>; + return <div>{translated}</div>; }, }, { - accessorKey: "RegisterKindENM", + accessorKey: "RegisterKind", header: t("detailDrawing.columns.registerKind"), minSize: 180, cell: ({ row }) => { - // API의 ENM이 제대로 번역되지 않아 코드 값으로 직접 변환 - const kindCode = row.getValue("RegisterKindENM") as string | null; + // API 응답의 RegisterKind는 코드값이므로 직접 매핑하여 번역 + const kindCode = row.getValue("RegisterKind") as string; const translated = translateRegisterKind(kindCode, lng); - return <div>{translated || kindCode || row.original.RegisterKindNM}</div>; + return <div>{translated}</div>; }, }, { diff --git a/lib/dolce/table/drawing-list-columns.tsx b/lib/dolce/table/drawing-list-columns.tsx index 58631084..34055eff 100644 --- a/lib/dolce/table/drawing-list-columns.tsx +++ b/lib/dolce/table/drawing-list-columns.tsx @@ -15,6 +15,11 @@ export function drawingListColumns(lng: string, t: any): ColumnDef<DwgReceiptIte }, }, { + accessorKey: "RegisterGroupId", + header: "RegisterGroupId", + minSize: 150, + }, + { accessorKey: "DrawingName", header: t("drawingList.columns.drawingName"), minSize: 600, @@ -25,7 +30,7 @@ export function drawingListColumns(lng: string, t: any): ColumnDef<DwgReceiptIte { accessorKey: "Discipline", header: t("drawingList.columns.discipline"), - minSize: 80, + minSize: 120, }, { accessorKey: "Manager", diff --git a/lib/dolce/table/drawing-list-table-v2.tsx b/lib/dolce/table/drawing-list-table-v2.tsx new file mode 100644 index 00000000..2ee80f11 --- /dev/null +++ b/lib/dolce/table/drawing-list-table-v2.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getSortedRowModel, + SortingState, + getPaginationRowModel, +} from "@tanstack/react-table"; +import { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from "lucide-react"; + +// 도면 데이터의 기본 인터페이스 +interface DrawingData { + RegisterGroupId?: string | null; + DrawingNo?: string | null; + Discipline?: string | null; + CreateDt?: string | Date | null; +} + +interface DrawingListTableV2Props<TData extends DrawingData, TValue> { + columns: ColumnDef<TData, TValue>[]; + data: TData[]; + onRowClick?: (row: TData) => void; + selectedRow?: TData; + getRowId?: (row: TData) => string; + maxHeight?: string; // e.g., "45vh" + minHeight?: string; // e.g., "400px" + defaultPageSize?: number; +} + +export function DrawingListTableV2<TData extends DrawingData, TValue>({ + columns, + data, + onRowClick, + selectedRow, + getRowId, + maxHeight = "45vh", + minHeight = "400px", + defaultPageSize = 10, +}: DrawingListTableV2Props<TData, TValue>) { + const [sorting, setSorting] = useState<SortingState>([]); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(defaultPageSize); + + // 기본 getRowId 함수: RegisterGroupId + DrawingNo + Discipline + CreateDt 조합 + const defaultGetRowId = (row: TData): string => { + const registerId = row.RegisterGroupId || ''; + const drawingNo = row.DrawingNo || ''; + const discipline = row.Discipline || ''; + const createDt = row.CreateDt + ? (row.CreateDt instanceof Date ? row.CreateDt.toISOString() : String(row.CreateDt)) + : ''; + + return `${registerId}-${drawingNo}-${discipline}-${createDt}`; + }; + + const rowIdGetter = getRowId || defaultGetRowId; + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + onPaginationChange: (updater) => { + if (typeof updater === 'function') { + const newState = updater({ pageIndex, pageSize }); + setPageIndex(newState.pageIndex); + setPageSize(newState.pageSize); + } + }, + state: { + sorting, + pagination: { + pageIndex, + pageSize, + }, + }, + }); + + // 행이 선택되었는지 확인하는 함수 + const isRowSelected = (row: TData): boolean => { + if (!selectedRow) return false; + return rowIdGetter(row) === rowIdGetter(selectedRow); + }; + + const handlePageSizeChange = (value: string) => { + const newSize = value === "all" ? data.length : parseInt(value); + setPageSize(newSize); + setPageIndex(0); + }; + + return ( + <div className="flex flex-col h-full" style={{ minHeight }}> + {/* 테이블 영역 */} + <div + className="rounded-md border overflow-auto flex-1" + style={{ + maxHeight, + minHeight: data.length === 0 ? minHeight : undefined, + }} + > + <Table className="min-w-max"> + <TableHeader className="sticky top-0 z-10 bg-background"> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => { + const isSorted = header.column.getIsSorted(); + const canSort = header.column.getCanSort(); + + return ( + <TableHead + key={header.id} + style={{ minWidth: header.column.columnDef.minSize }} + className="bg-background" + > + {header.isPlaceholder ? null : ( + <div + className={`flex items-center gap-2 ${ + canSort ? "cursor-pointer select-none hover:text-primary" : "" + }`} + onClick={ + canSort + ? header.column.getToggleSortingHandler() + : undefined + } + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {canSort && ( + <span className="text-muted-foreground"> + {isSorted === "asc" ? ( + <ArrowUp className="h-4 w-4" /> + ) : isSorted === "desc" ? ( + <ArrowDown className="h-4 w-4" /> + ) : ( + <ArrowUpDown className="h-4 w-4 opacity-50" /> + )} + </span> + )} + </div> + )} + </TableHead> + ); + })} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const isSelected = isRowSelected(row.original); + return ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + onClick={() => onRowClick?.(row.original)} + className={`cursor-pointer transition-colors ${ + isSelected + ? "bg-accent hover:bg-accent" + : "hover:bg-muted/50" + }`} + > + {row.getVisibleCells().map((cell) => ( + <TableCell + key={cell.id} + style={{ minWidth: cell.column.columnDef.minSize }} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </TableCell> + ))} + </TableRow> + ); + }) + ) : ( + <TableRow> + <TableCell + colSpan={columns.length} + className="text-center text-muted-foreground" + style={{ height: "300px" }} + > + <div className="flex items-center justify-center h-full"> + 데이터가 없습니다. + </div> + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + + {/* 페이지네이션 컨트롤 - 항상 렌더링하여 높이 일관성 유지 */} + <div className="flex items-center justify-between py-2 px-2 border-t flex-shrink-0"> + {data.length > 0 ? ( + <> + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + 페이지 당 행 수: + </span> + <Select value={pageSize === data.length ? "all" : String(pageSize)} onValueChange={handlePageSizeChange}> + <SelectTrigger className="w-[100px] h-8"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="10">10</SelectItem> + <SelectItem value="20">20</SelectItem> + <SelectItem value="50">50</SelectItem> + <SelectItem value="all">전체</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + {table.getState().pagination.pageIndex * pageSize + 1}- + {Math.min( + (table.getState().pagination.pageIndex + 1) * pageSize, + data.length + )}{" "} + / {data.length} + </span> + <div className="flex gap-1"> + <Button + variant="outline" + size="sm" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <ChevronLeft className="h-4 w-4" /> + </Button> + <Button + variant="outline" + size="sm" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <ChevronRight className="h-4 w-4" /> + </Button> + </div> + </div> + </> + ) : ( + <div className="text-sm text-muted-foreground opacity-0"> + Placeholder for consistent height + </div> + )} + </div> + </div> + ); +} + |
