summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-24 22:41:52 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-24 22:41:52 +0900
commit25b2561bf17128b96f023c977efb5cb51da0b4aa (patch)
treea0154c20a12d2dee8d5acddec5a66e56b7f07e0b
parent7010b6a8c4d05cfb670aec6048f225db21c8c092 (diff)
(김준회) dolce: 기존 레이아웃과 유사한 v2 추가, bulk-upload를 MatchBatchDwgFile 사용하지 않도록 변경 (해당 API 동작 이상함)
-rw-r--r--app/[lng]/partners/(partners)/dolce-upload-v2/dolce-upload-page-v2.tsx719
-rw-r--r--app/[lng]/partners/(partners)/dolce-upload-v2/page.tsx74
-rw-r--r--app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx11
-rw-r--r--lib/dolce/actions.ts384
-rw-r--r--lib/dolce/dialogs/b4-bulk-upload-dialog-v2.tsx625
-rw-r--r--lib/dolce/dialogs/b4-bulk-upload-dialog.tsx87
-rw-r--r--lib/dolce/table/detail-drawing-columns.tsx96
-rw-r--r--lib/dolce/table/drawing-list-columns.tsx7
-rw-r--r--lib/dolce/table/drawing-list-table-v2.tsx273
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>
+ );
+}
+