From 9ecdfb23fe3df6a5df86782385002c562dfc1198 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 19 Sep 2025 07:51:27 +0000 Subject: (대표님) rfq 히스토리, swp 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 4 +- .env.production | 4 +- README.md | 2 +- .../(evcp)/vendors/[id]/info/rfq-history/page.tsx | 17 +- .../partners/(partners)/document-upload/page.tsx | 154 ++ app/api/stage-submissions/[id]/route.ts | 84 + app/api/stage-submissions/bulk-upload/route.ts | 202 +++ app/api/stage-submissions/sync/route.ts | 35 + .../client-data-table/data-table-filter-list.tsx | 23 +- components/client-data-table/table-filters.ts | 344 ++++ components/form-data-stat/form-data-stat-table.tsx | 15 + components/form-data/form-data-table-columns.tsx | 3 + components/form-data/spreadJS-dialog copy 2.tsx | 1002 ---------- components/form-data/spreadJS-dialog copy 3.tsx | 1916 -------------------- components/form-data/spreadJS-dialog copy 4.tsx | 1491 --------------- components/form-data/spreadJS-dialog copy 5.tsx | 1493 +++++++++++++++ components/form-data/spreadJS-dialog copy.tsx | 539 ------ components/form-data/spreadJS-dialog.tsx | 32 +- .../vendor-data/tag-table/tag-table-column.tsx | 2 + config/rfqHistoryColumnsConfig.ts | 127 +- db/schema/generalContract.ts | 2 + db/schema/vendorDocu.ts | 483 ++++- hooks/use-document-polling.ts | 76 + i18n/locales/en/document.json | 4 +- i18n/locales/ko/document.json | 4 +- .../detail/general-contract-detail.tsx | 1 - .../detail/general-contract-items-table.tsx | 139 +- lib/general-contracts/service.ts | 88 +- lib/rfq-last/attachment/rfq-attachments-table.tsx | 128 +- lib/rfq-last/quotation-compare-view.tsx | 4 +- lib/rfq-last/service.ts | 1 - lib/rfq-last/table/create-general-rfq-dialog.tsx | 4 +- lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 18 +- lib/rfq-last/vendor/rfq-vendor-table.tsx | 341 +++- lib/vendor-document-list/import-service.ts | 8 +- .../plant/document-stage-dialogs.tsx | 28 +- .../plant/document-stage-toolbar.tsx | 114 +- .../plant/document-stages-columns.tsx | 86 +- .../plant/document-stages-expanded-content.tsx | 4 +- .../plant/document-stages-service.ts | 123 +- .../plant/document-stages-table.tsx | 20 +- .../plant/excel-import-export.ts | 6 +- .../plant/shi-buyer-system-api.ts | 874 +++++++++ lib/vendor-document-list/plant/upload/columns.tsx | 379 ++++ .../plant/upload/components/history-dialog.tsx | 144 ++ .../upload/components/multi-upload-dialog.tsx | 492 +++++ .../plant/upload/components/project-filter.tsx | 109 ++ .../upload/components/single-upload-dialog.tsx | 265 +++ .../upload/components/view-submission-dialog.tsx | 520 ++++++ lib/vendor-document-list/plant/upload/service.ts | 228 +++ lib/vendor-document-list/plant/upload/table.tsx | 223 +++ .../plant/upload/toolbar-actions.tsx | 242 +++ .../plant/upload/util/filie-parser.ts | 132 ++ .../plant/upload/validation.ts | 35 + .../rfq-history-table-columns.tsx | 129 +- .../rfq-history-table/rfq-history-table.tsx | 172 +- lib/vendors/service.ts | 313 +++- 57 files changed, 7920 insertions(+), 5508 deletions(-) create mode 100644 app/[lng]/partners/(partners)/document-upload/page.tsx create mode 100644 app/api/stage-submissions/[id]/route.ts create mode 100644 app/api/stage-submissions/bulk-upload/route.ts create mode 100644 app/api/stage-submissions/sync/route.ts create mode 100644 components/client-data-table/table-filters.ts delete mode 100644 components/form-data/spreadJS-dialog copy 2.tsx delete mode 100644 components/form-data/spreadJS-dialog copy 3.tsx delete mode 100644 components/form-data/spreadJS-dialog copy 4.tsx create mode 100644 components/form-data/spreadJS-dialog copy 5.tsx delete mode 100644 components/form-data/spreadJS-dialog copy.tsx create mode 100644 hooks/use-document-polling.ts create mode 100644 lib/vendor-document-list/plant/shi-buyer-system-api.ts create mode 100644 lib/vendor-document-list/plant/upload/columns.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/history-dialog.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/project-filter.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx create mode 100644 lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx create mode 100644 lib/vendor-document-list/plant/upload/service.ts create mode 100644 lib/vendor-document-list/plant/upload/table.tsx create mode 100644 lib/vendor-document-list/plant/upload/toolbar-actions.tsx create mode 100644 lib/vendor-document-list/plant/upload/util/filie-parser.ts create mode 100644 lib/vendor-document-list/plant/upload/validation.ts diff --git a/.env.development b/.env.development index a4b8bbd7..f4017238 100644 --- a/.env.development +++ b/.env.development @@ -175,4 +175,6 @@ NAS_PATH="public" # 기존 개발에서는 선언되지 않은 변수라 주석 READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp" # 해당 계정은 별도로 만들어야 함 # === 디버그 로깅 (lib/debug-utils.ts) === -NEXT_PUBLIC_DEBUG=true \ No newline at end of file +NEXT_PUBLIC_DEBUG=true + +SWP_BASE_URL=http://60.100.99.217/DDP/Services/VNDRService.svc \ No newline at end of file diff --git a/.env.production b/.env.production index d33cef88..5214c212 100644 --- a/.env.production +++ b/.env.production @@ -177,4 +177,6 @@ NAS_PATH="public" READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp" # 해당 계정은 별도로 만들어야 함 # === 디버그 로깅 (lib/debug-utils.ts) === -NEXT_PUBLIC_DEBUG=false \ No newline at end of file +NEXT_PUBLIC_DEBUG=false + +SWP_BASE_URL=http://60.100.99.217/DDP/Services/VNDRService.svc diff --git a/README.md b/README.md index 84c0ae42..40d5fea7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ 1. 프로젝트 압축 ```bash -zip -r public/archive-$(date +%Y%m%d-%H%M%S).zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" "./_docker/*" +zip -r public/archive-$(date +%Y%m%d-%H%M%S).zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" "./_docker/*" "./db/migrations/*" zip -r public/archive-$(date +%Y%m%d-%H%M%S).zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" "./node_modules/*" "./_docker/*" "./db/migrations/*" ``` diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx index c7f8f8b6..6ae24f58 100644 --- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx @@ -5,8 +5,7 @@ import { getValidFilters } from "@/lib/data-table" import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations" import { VendorRfqHistoryTable } from "@/lib/vendors/rfq-history-table/rfq-history-table" -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 +interface RfqHistoryPageProps { params: { lng: string id: string @@ -14,7 +13,7 @@ interface IndexPageProps { searchParams: Promise } -export default async function RfqHistoryPage(props: IndexPageProps) { +export default async function RfqHistoryPage(props: RfqHistoryPageProps) { const resolvedParams = await props.params const lng = resolvedParams.lng const id = resolvedParams.id @@ -39,16 +38,18 @@ export default async function RfqHistoryPage(props: IndexPageProps) { return (
-

- RFQ History -

+

RFQ 견적 이력

- 협력업체의 RFQ 참여 이력을 확인할 수 있습니다. + 협력업체의 RFQ 견적 참여 이력을 확인할 수 있습니다.

- +
) diff --git a/app/[lng]/partners/(partners)/document-upload/page.tsx b/app/[lng]/partners/(partners)/document-upload/page.tsx new file mode 100644 index 00000000..9df82fd4 --- /dev/null +++ b/app/[lng]/partners/(partners)/document-upload/page.tsx @@ -0,0 +1,154 @@ +// app/(vendor)/stage-submissions/page.tsx +import * as React from "react" +import { searchParamsCache } from "@/lib/vendor-document-list/plant/upload/validation" +import { StageSubmissionsTable } from "@/lib/vendor-document-list/plant/upload/table" +import { redirect } from "next/navigation" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { getStageSubmissions, getProjects, getSubmissionStats } from "@/lib/vendor-document-list/plant/upload/service" +import db from "@/db/db" +import { eq } from "drizzle-orm" +import { vendors } from "@/db/schema" + +export default async function StageSubmissionsPage({ + searchParams, +}: { + searchParams: Promise> +}) { + // Session 체크 + const session = await getServerSession(authOptions) + + if (!session?.user?.companyId) { + redirect("/partners") + } + + + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, session.user.companyId), + columns: { + vendorName: true, + vendorCode: true, + } + }) + + const params = searchParamsCache.parse(await searchParams) + + const submissionsPromise = getStageSubmissions(params) + const projectsPromise = getProjects() + const statsPromise = getSubmissionStats() + + const [submissions, projects, stats] = await Promise.all([ + submissionsPromise, + projectsPromise, + statsPromise + ]) + + return ( +
+ {/* Header */} +
+
+

My Stage Submissions

+

+ Manage document submissions for your approved stages +

+
+
+ {/* */} + {/* Company: */} + {vendor.vendorName || "Your Company"} + +

+ Buyer Approved Documents +

+ {/*
*/} +
+
+ + {/* Stats Cards */} +
+ + + Pending Submissions + + +
+ {stats.pending} +
+
+
+ + + + Overdue + + +
+ {stats.overdue} +
+
+
+ + + + Awaiting Sync + + +
+ {stats.awaitingSync} +
+
+
+ + + + Completed + + +
+ {stats.completed} +
+
+
+
+ + {/* Main Table */} + + + Your Submission List + + View and manage document submissions for your company's stages + + + + +
+ Loading your submissions... +
+
+ } + > + ({ id: p.id, code: p.code || "" })) } + ])} + selectedProjectId={params.projectId} + + /> + + + + + ) +} \ No newline at end of file diff --git a/app/api/stage-submissions/[id]/route.ts b/app/api/stage-submissions/[id]/route.ts new file mode 100644 index 00000000..4885edaf --- /dev/null +++ b/app/api/stage-submissions/[id]/route.ts @@ -0,0 +1,84 @@ +// app/api/stage-submissions/[id]/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { stageSubmissions, stageSubmissionAttachments } from "@/db/schema" +import { eq, and } from "drizzle-orm" + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const vendorId = session.user.companyId + const submissionId = parseInt(params.id) + + if (isNaN(submissionId)) { + return NextResponse.json({ error: "Invalid submission ID" }, { status: 400 }) + } + + // submission 정보 조회 (벤더 검증 포함) + const submission = await db + .select() + .from(stageSubmissions) + .where( + and( + eq(stageSubmissions.id, submissionId), + eq(stageSubmissions.vendorId, vendorId) + ) + ) + .limit(1) + + if (submission.length === 0) { + return NextResponse.json( + { error: "Submission not found" }, + { status: 404 } + ) + } + + // 첨부 파일 조회 + const files = await db + .select({ + id: stageSubmissionAttachments.id, + originalFileName: stageSubmissionAttachments.originalFileName, + fileSize: stageSubmissionAttachments.fileSize, + uploadedAt: stageSubmissionAttachments.uploadedAt, + syncStatus: stageSubmissionAttachments.syncStatus, + storageUrl: stageSubmissionAttachments.storageUrl, + fileType: stageSubmissionAttachments.fileType, + status: stageSubmissionAttachments.status, + }) + .from(stageSubmissionAttachments) + .where(eq(stageSubmissionAttachments.submissionId, submissionId)) + .orderBy(stageSubmissionAttachments.uploadedAt) + + // 응답 데이터 구성 + const result = { + id: submission[0].id, + revisionNumber: submission[0].revisionNumber, + submissionStatus: submission[0].submissionStatus, + reviewStatus: submission[0].reviewStatus, + reviewComments: submission[0].reviewComments, + submittedBy: submission[0].submittedBy, + submittedAt: submission[0].submittedAt, + submissionTitle: submission[0].submissionTitle, + submissionDescription: submission[0].submissionDescription, + files: files.filter(f => f.status === "ACTIVE"), // 활성 파일만 + } + + return NextResponse.json(result) + + } catch (error) { + console.error("Error fetching submission detail:", error) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/stage-submissions/bulk-upload/route.ts b/app/api/stage-submissions/bulk-upload/route.ts new file mode 100644 index 00000000..4ecb5c5c --- /dev/null +++ b/app/api/stage-submissions/bulk-upload/route.ts @@ -0,0 +1,202 @@ +// app/api/stage-submissions/bulk-upload/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { stageSubmissions, stageSubmissionAttachments, vendors } from "@/db/schema" +import { eq, and } from "drizzle-orm" +import { + extractRevisionNumber, + normalizeRevisionCode +} from "@/lib/vendor-document-list/plant/upload/utils/file-parser" +import { saveFileStream } from "@/lib/file-storage" + +export async function POST(request: NextRequest) { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const vendorId = session.user.companyId + const userId = session.user.id + + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, session.user.companyId), + columns: { + vendorName: true, + vendorCode: true, + } + }) + + try { + const formData = await request.formData() + const files = formData.getAll('files') as File[] + + // 메타데이터 파싱 + const metadata: any[] = [] + let index = 0 + while (formData.has(`metadata[${index}]`)) { + const meta = formData.get(`metadata[${index}]`) + if (meta) { + metadata.push(JSON.parse(meta as string)) + } + index++ + } + + if (files.length !== metadata.length) { + return NextResponse.json( + { error: "Files and metadata count mismatch" }, + { status: 400 } + ) + } + + // 총 용량 체크 (10GB) + const totalSize = files.reduce((acc, file) => acc + file.size, 0) + const maxSize = 10 * 1024 * 1024 * 1024 // 10GB + + if (totalSize > maxSize) { + return NextResponse.json( + { error: `Total file size exceeds 10GB limit` }, + { status: 400 } + ) + } + + const uploadResults = [] + + // 각 파일 처리 + for (let i = 0; i < files.length; i++) { + const file = files[i] + const meta = metadata[i] + + try { + await db.transaction(async (tx) => { + // 리비전 정보 추출 + const revisionNumber = extractRevisionNumber(meta.revision) + const revisionCode = normalizeRevisionCode(meta.revision) // ⭐ 추가 + + // 1. 해당 스테이지의 최신 submission 찾기 + let submission = await tx + .select() + .from(stageSubmissions) + .where( + and( + eq(stageSubmissions.stageId, meta.stageId), + eq(stageSubmissions.documentId, meta.documentId) + ) + ) + .orderBy(stageSubmissions.revisionNumber) + .limit(1) + + if (!submission[0] || submission[0].revisionNumber < revisionNumber) { + // 새 submission 생성 + const [newSubmission] = await tx + .insert(stageSubmissions) + .values({ + stageId: meta.stageId, + documentId: meta.documentId, + revisionNumber, + revisionCode, // ⭐ 원본 리비전 코드 저장 + revisionType: revisionNumber === 0 ? "INITIAL" : "RESUBMISSION", + submissionStatus: "SUBMITTED", + submittedBy: session.user.name || session.user.email || "Unknown", + submittedByEmail: session.user.email, + vendorId, + vendorCode: vendor?.vendorCode || null, + totalFiles: 1, + totalFileSize: file.size, + submissionTitle: `Revision ${revisionCode} Submission`, // ⭐ 코드 사용 + syncStatus: "pending", + lastModifiedBy: "EVCP", + }) + .returning() + + submission = [newSubmission] + } else if (submission[0].revisionNumber === revisionNumber) { + // 같은 리비전 업데이트 + await tx + .update(stageSubmissions) + .set({ + revisionCode, // ⭐ 코드 업데이트 + totalFiles: submission[0].totalFiles + 1, + totalFileSize: submission[0].totalFileSize + file.size, + updatedAt: new Date(), + }) + .where(eq(stageSubmissions.id, submission[0].id)) + } + + // 2. 파일 저장 (대용량 파일은 스트리밍) + const directory = `submissions/${meta.documentId}/${meta.stageId}/${revisionCode}` // ⭐ 디렉토리에 리비전 코드 포함 + + let saveResult + if (file.size > 100 * 1024 * 1024) { // 100MB 이상은 스트리밍 + saveResult = await saveFileStream({ + file, + directory, + originalName: meta.originalName, + userId: userId.toString() + }) + } else { + const { saveFile } = await import("@/lib/file-storage") + saveResult = await saveFile({ + file, + directory, + originalName: meta.originalName, + userId: userId.toString() + }) + } + + if (!saveResult.success) { + throw new Error(saveResult.error || "File save failed") + } + + // 3. attachment 레코드 생성 + await tx.insert(stageSubmissionAttachments).values({ + submissionId: submission[0].id, + fileName: saveResult.fileName!, + originalFileName: meta.originalName, + fileType: file.type, + fileExtension: meta.originalName.split('.').pop(), + fileSize: file.size, + storageType: "LOCAL", + storagePath: saveResult.filePath!, + storageUrl: saveResult.publicPath!, + mimeType: file.type, + uploadedBy: session.user.name || session.user.email || "Unknown", + status: "ACTIVE", + syncStatus: "pending", + lastModifiedBy: "EVCP", + }) + }) + + uploadResults.push({ + fileName: meta.originalName, + success: true + }) + + } catch (error) { + console.error(`Failed to upload ${meta.originalName}:`, error) + uploadResults.push({ + fileName: meta.originalName, + success: false, + error: error instanceof Error ? error.message : "Upload failed" + }) + } + } + + const successCount = uploadResults.filter(r => r.success).length + + return NextResponse.json({ + success: true, + uploaded: successCount, + failed: uploadResults.length - successCount, + results: uploadResults + }) + + } catch (error) { + console.error("Bulk upload error:", error) + return NextResponse.json( + { error: "Bulk upload failed" }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/stage-submissions/sync/route.ts b/app/api/stage-submissions/sync/route.ts new file mode 100644 index 00000000..ed9d30ce --- /dev/null +++ b/app/api/stage-submissions/sync/route.ts @@ -0,0 +1,35 @@ +// app/api/stage-submissions/sync/route.ts +import { ShiBuyerSystemAPI } from "@/lib/vendor-document-list/plant/shi-buyer-system-api" +import { NextRequest, NextResponse } from "next/server" + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const { submissionIds } = body + + if (!submissionIds || !Array.isArray(submissionIds) || submissionIds.length === 0) { + return NextResponse.json( + { error: "제출 ID 목록이 필요합니다." }, + { status: 400 } + ) + } + + const api = new ShiBuyerSystemAPI() + const results = await api.syncSubmissionsToSHI(submissionIds) + + return NextResponse.json({ + success: true, + message: `${results.successCount}/${results.totalCount}개 제출 건이 동기화되었습니다.`, + results + }) + } catch (error) { + console.error("Sync API Error:", error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "동기화 실패" + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/components/client-data-table/data-table-filter-list.tsx b/components/client-data-table/data-table-filter-list.tsx index f06d837e..6a9da8ba 100644 --- a/components/client-data-table/data-table-filter-list.tsx +++ b/components/client-data-table/data-table-filter-list.tsx @@ -84,25 +84,16 @@ export function ClientDataTableAdvancedFilter({ // 3) Sync to table React.useEffect(() => { const newColumnFilters = filters.map((f) => { - // If it's numeric, transform f.value from string → number - if (f.type === "number") { - return { - id: String(f.id), - value: { - operator: f.operator, - inputValue: parseFloat(String(f.value)), - } - } - } - else { - // For text, date, boolean, etc., it's fine to keep value as a string or whatever - return { - id: String(f.id), - value: f.value, + // 모든 타입에 대해 operator와 value를 함께 전달 + return { + id: String(f.id), + value: { + operator: f.operator, + value: f.type === "number" ? parseFloat(String(f.value)) : f.value, } } }) - + table.setColumnFilters(newColumnFilters) }, [filters, joinOperator, table]) diff --git a/components/client-data-table/table-filters.ts b/components/client-data-table/table-filters.ts new file mode 100644 index 00000000..44391999 --- /dev/null +++ b/components/client-data-table/table-filters.ts @@ -0,0 +1,344 @@ +import { Row } from "@tanstack/react-table" + +export type FilterOperator = + | "iLike" | "notILike" | "eq" | "ne" | "isEmpty" | "isNotEmpty" + | "lt" | "lte" | "gt" | "gte" | "isBetween" | "isRelativeToToday" + +export type ColumnType = "text" | "number" | "date" | "boolean" | "select" | "multi-select" + +interface FilterValue { + operator: FilterOperator + value: any +} + +/** + * 글로벌 필터 함수 생성 + * @param type - 컬럼 타입 + * @returns 필터 함수 + */ +export const createFilterFn = (type: ColumnType) => { + return (row: Row, columnId: string, filterValue: FilterValue) => { + const cellValue = row.getValue(columnId) + const { operator, value } = filterValue + + // 공통 처리: isEmpty/isNotEmpty + if (operator === "isEmpty") { + if (type === "multi-select") { + return !cellValue || (Array.isArray(cellValue) && cellValue.length === 0) + } + return cellValue == null || cellValue === "" || cellValue === undefined + } + + if (operator === "isNotEmpty") { + if (type === "multi-select") { + return cellValue != null && Array.isArray(cellValue) && cellValue.length > 0 + } + return cellValue != null && cellValue !== "" && cellValue !== undefined + } + + // value가 없고 isEmpty/isNotEmpty가 아닌 경우 + if ((value === "" || value == null) && operator !== "isEmpty" && operator !== "isNotEmpty") { + return true + } + + // 타입별 처리 + switch (type) { + case "text": { + const cellStr = String(cellValue || "").toLowerCase() + const filterStr = String(value || "").toLowerCase() + + switch (operator) { + case "iLike": + return cellStr.includes(filterStr) + case "notILike": + return !cellStr.includes(filterStr) + case "eq": + return cellStr === filterStr + case "ne": + return cellStr !== filterStr + default: + return true + } + } + + case "number": { + const cellNum = cellValue != null ? Number(cellValue) : null + const filterNum = value != null ? Number(value) : null + + if (cellNum == null || filterNum == null) { + return false + } + + switch (operator) { + case "eq": + return cellNum === filterNum + case "ne": + return cellNum !== filterNum + case "lt": + return cellNum < filterNum + case "lte": + return cellNum <= filterNum + case "gt": + return cellNum > filterNum + case "gte": + return cellNum >= filterNum + default: + return true + } + } + + case "date": { + const cellDate = cellValue ? new Date(cellValue as string | Date) : null + + if (!cellDate || isNaN(cellDate.getTime())) { + return false + } + + switch (operator) { + case "eq": { + if (!value) return false + const filterDate = new Date(value) + return cellDate.toDateString() === filterDate.toDateString() + } + + case "ne": { + if (!value) return true + const filterDate = new Date(value) + return cellDate.toDateString() !== filterDate.toDateString() + } + + case "lt": { + if (!value) return false + const filterDate = new Date(value) + return cellDate < filterDate + } + + case "lte": { + if (!value) return false + const filterDate = new Date(value) + filterDate.setHours(23, 59, 59, 999) // 그 날의 끝까지 포함 + return cellDate <= filterDate + } + + case "gt": { + if (!value) return false + const filterDate = new Date(value) + return cellDate > filterDate + } + + case "gte": { + if (!value) return false + const filterDate = new Date(value) + filterDate.setHours(0, 0, 0, 0) // 그 날의 시작부터 포함 + return cellDate >= filterDate + } + + case "isBetween": { + if (!Array.isArray(value) || value.length !== 2) return false + const [startDate, endDate] = value + if (!startDate || !endDate) return false + const start = new Date(startDate) + const end = new Date(endDate) + start.setHours(0, 0, 0, 0) + end.setHours(23, 59, 59, 999) + return cellDate >= start && cellDate <= end + } + + case "isRelativeToToday": { + const today = new Date() + today.setHours(0, 0, 0, 0) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + + // value는 상대적 날짜 지정자 (예: "today", "yesterday", "thisWeek", "lastWeek", "thisMonth", "lastMonth") + switch (value) { + case "today": + return cellDate >= today && cellDate < tomorrow + + case "yesterday": { + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + return cellDate >= yesterday && cellDate < today + } + + case "tomorrow": { + const dayAfterTomorrow = new Date(tomorrow) + dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1) + return cellDate >= tomorrow && cellDate < dayAfterTomorrow + } + + case "thisWeek": { + const startOfWeek = new Date(today) + startOfWeek.setDate(today.getDate() - today.getDay()) // 일요일부터 시작 + const endOfWeek = new Date(startOfWeek) + endOfWeek.setDate(startOfWeek.getDate() + 6) + endOfWeek.setHours(23, 59, 59, 999) + return cellDate >= startOfWeek && cellDate <= endOfWeek + } + + case "lastWeek": { + const startOfLastWeek = new Date(today) + startOfLastWeek.setDate(today.getDate() - today.getDay() - 7) + const endOfLastWeek = new Date(startOfLastWeek) + endOfLastWeek.setDate(startOfLastWeek.getDate() + 6) + endOfLastWeek.setHours(23, 59, 59, 999) + return cellDate >= startOfLastWeek && cellDate <= endOfLastWeek + } + + case "thisMonth": { + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1) + const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0) + endOfMonth.setHours(23, 59, 59, 999) + return cellDate >= startOfMonth && cellDate <= endOfMonth + } + + case "lastMonth": { + const startOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1) + const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0) + endOfLastMonth.setHours(23, 59, 59, 999) + return cellDate >= startOfLastMonth && cellDate <= endOfLastMonth + } + + case "thisYear": { + const startOfYear = new Date(today.getFullYear(), 0, 1) + const endOfYear = new Date(today.getFullYear(), 11, 31) + endOfYear.setHours(23, 59, 59, 999) + return cellDate >= startOfYear && cellDate <= endOfYear + } + + case "lastYear": { + const startOfLastYear = new Date(today.getFullYear() - 1, 0, 1) + const endOfLastYear = new Date(today.getFullYear() - 1, 11, 31) + endOfLastYear.setHours(23, 59, 59, 999) + return cellDate >= startOfLastYear && cellDate <= endOfLastYear + } + + default: + // 숫자가 오면 일 단위 상대 날짜로 처리 (예: "7" = 7일 이내, "-7" = 7일 전) + const days = parseInt(value) + if (!isNaN(days)) { + if (days > 0) { + // 미래 n일 이내 + const futureDate = new Date(today) + futureDate.setDate(futureDate.getDate() + days) + return cellDate >= today && cellDate <= futureDate + } else if (days < 0) { + // 과거 n일 이내 + const pastDate = new Date(today) + pastDate.setDate(pastDate.getDate() + days) + return cellDate >= pastDate && cellDate <= today + } + } + return true + } + } + + default: + return true + } + } + + case "boolean": { + const cellBool = cellValue === true || cellValue === "true" || cellValue === 1 + const filterBool = value === true || value === "true" + + switch (operator) { + case "eq": + return cellBool === filterBool + case "ne": + return cellBool !== filterBool + default: + return true + } + } + + case "select": { + const cellStr = String(cellValue || "") + const filterStr = String(value || "") + + switch (operator) { + case "eq": + return cellStr === filterStr + case "ne": + return cellStr !== filterStr + default: + return true + } + } + + case "multi-select": { + const cellArray = Array.isArray(cellValue) ? cellValue : [] + const filterArray = Array.isArray(value) ? value : [] + + switch (operator) { + case "eq": + // 선택된 모든 값들이 포함되어 있는지 확인 + return filterArray.every(v => cellArray.includes(v)) + case "ne": + // 선택된 값들 중 하나라도 포함되어 있지 않은지 확인 + return !filterArray.some(v => cellArray.includes(v)) + default: + return true + } + } + + default: + return true + } + } +} + +/** + * AND/OR 조건으로 여러 필터 결합 + * @param filters - 필터 배열 + * @param joinOperator - 결합 연산자 ("and" | "or") + */ +export const combineFilters = ( + row: Row, + filters: Array<{ + columnId: string + filterValue: FilterValue + type: ColumnType + }>, + joinOperator: "and" | "or" = "and" +): boolean => { + if (filters.length === 0) return true + + if (joinOperator === "and") { + return filters.every(filter => { + const filterFn = createFilterFn(filter.type) + return filterFn(row, filter.columnId, filter.filterValue) + }) + } else { + return filters.some(filter => { + const filterFn = createFilterFn(filter.type) + return filterFn(row, filter.columnId, filter.filterValue) + }) + } +} + +/** + * 테이블 전체에 대한 커스텀 필터 함수 + * ClientDataTableAdvancedFilter와 함께 사용 + */ +export const globalFilterFn = ( + row: Row, + columnId: string, + filterValue: any +): boolean => { + // filterValue가 객체 형태로 전달되는 경우를 처리 + if (filterValue && typeof filterValue === 'object' && 'filters' in filterValue) { + const { filters, joinOperator } = filterValue + return combineFilters(row, filters, joinOperator) + } + + // 단일 필터의 경우 + if (filterValue && typeof filterValue === 'object' && 'operator' in filterValue) { + // 컬럼 타입을 추론하거나 전달받아야 함 + // 기본적으로 text로 처리 + const filterFn = createFilterFn("text") + return filterFn(row, columnId, filterValue) + } + + return true +} \ No newline at end of file diff --git a/components/form-data-stat/form-data-stat-table.tsx b/components/form-data-stat/form-data-stat-table.tsx index a56a4e88..1f313a2f 100644 --- a/components/form-data-stat/form-data-stat-table.tsx +++ b/components/form-data-stat/form-data-stat-table.tsx @@ -16,6 +16,7 @@ import { Progress } from "@/components/ui/progress"; import { getVendorFormStatus, getProjectsWithContracts } from "@/lib/forms/stat"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; +import { createFilterFn } from "@/components/client-data-table/table-filters"; // 타입 정의 interface VendorFormStatus { @@ -216,6 +217,8 @@ export function VendorFormStatusTable({ { accessorKey: "vendorName", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => (
{row.original.vendorName}
), @@ -226,6 +229,8 @@ export function VendorFormStatusTable({ { accessorKey: "formCount", header: ({ column }) => , + filterFn: createFilterFn("number"), + cell: ({ row }) => (
{row.original.formCount} @@ -237,6 +242,8 @@ export function VendorFormStatusTable({ { accessorKey: "tagCount", header: ({ column }) => , + filterFn: createFilterFn("number"), + cell: ({ row }) => (
{row.original.tagCount} @@ -248,6 +255,8 @@ export function VendorFormStatusTable({ { accessorKey: "totalFields", header: ({ column }) => , + filterFn: createFilterFn("number"), + cell: ({ row }) => (
{row.original.totalFields.toLocaleString()} @@ -259,6 +268,8 @@ export function VendorFormStatusTable({ { accessorKey: "completedFields", header: ({ column }) => , + filterFn: createFilterFn("number"), + cell: ({ row }) => (
{row.original.completedFields.toLocaleString()} @@ -270,6 +281,8 @@ export function VendorFormStatusTable({ { accessorKey: "completionRate", header: ({ column }) => , + filterFn: createFilterFn("number"), + cell: ({ row }) => { const rate = row.original.completionRate; return ( @@ -293,6 +306,8 @@ export function VendorFormStatusTable({ { id: "progress", header: "진행 상태", + filterFn: createFilterFn("number"), + cell: ({ row }) => { const { completedFields, totalFields } = row.original; return ( diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index 598b66c6..2c6b6a30 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -19,6 +19,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { toast } from 'sonner'; +import { createFilterFn } from "@/components/client-data-table/table-filters"; /** row 액션 관련 타입 */ export interface DataTableRowAction { @@ -251,6 +252,8 @@ function createColumnDef(col: DataTableColumnJSON, isInGroup: boolean = false): /> ), + filterFn: col.type === 'NUMBER' ? createFilterFn("number") : col.type === 'LIST' ? createFilterFn("multi-select"):createFilterFn("text"), + meta: { excelHeader: col.label, minWidth: 80, diff --git a/components/form-data/spreadJS-dialog copy 2.tsx b/components/form-data/spreadJS-dialog copy 2.tsx deleted file mode 100644 index 520362ff..00000000 --- a/components/form-data/spreadJS-dialog copy 2.tsx +++ /dev/null @@ -1,1002 +0,0 @@ -"use client"; - -import * as React from "react"; -import dynamic from "next/dynamic"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { GenericData } from "./export-excel-form"; -import * as GC from "@mescius/spread-sheets"; -import { toast } from "sonner"; -import { updateFormDataInDB } from "@/lib/forms/services"; -import { Loader, Save, AlertTriangle } from "lucide-react"; -import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; -import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; - -// SpreadSheets를 동적으로 import (SSR 비활성화) -const SpreadSheets = dynamic( - () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), - { - ssr: false, - loading: () => ( -
- - Loading SpreadSheets... -
- ) - } -); - -// 라이센스 키 설정을 클라이언트에서만 실행 -if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { - GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; -} - -interface TemplateItem { - TMPL_ID: string; - NAME: string; - TMPL_TYPE: string; - SPR_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array; - CONTENT?: string; - DATA_SHEETS: Array<{ - SHEET_NAME: string; - REG_TYPE_ID: string; - MAP_CELL_ATT: Array<{ - ATT_ID: string; - IN: string; - }>; - }>; - }; - GRD_LST_SETUP: { - REG_TYPE_ID: string; - SPR_ITM_IDS: Array; - ATTS: Array<{}>; - }; - SPR_ITM_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array; - CONTENT?: string; - DATA_SHEETS: Array<{ - SHEET_NAME: string; - REG_TYPE_ID: string; - MAP_CELL_ATT: Array<{ - ATT_ID: string; - IN: string; - }>; - }>; - }; -} - -interface ValidationError { - cellAddress: string; - attId: string; - value: any; - expectedType: ColumnType; - message: string; -} - -interface CellMapping { - attId: string; - cellAddress: string; - isEditable: boolean; - dataRowIndex?: number; -} - -interface TemplateViewDialogProps { - isOpen: boolean; - onClose: () => void; - templateData: TemplateItem[] | any; - selectedRow?: GenericData; // SPREAD_ITEM용 - tableData?: GenericData[]; // SPREAD_LIST용 - formCode: string; - columnsJSON: DataTableColumnJSON[] - contractItemId: number; - editableFieldsMap?: Map; - onUpdateSuccess?: (updatedValues: Record | GenericData[]) => void; -} - -export function TemplateViewDialog({ - isOpen, - onClose, - templateData, - selectedRow, - tableData = [], - formCode, - contractItemId, - columnsJSON, - editableFieldsMap = new Map(), - onUpdateSuccess -}: TemplateViewDialogProps) { - const [hostStyle, setHostStyle] = React.useState({ - width: '100%', - height: '100%' - }); - - const [isPending, setIsPending] = React.useState(false); - const [hasChanges, setHasChanges] = React.useState(false); - const [currentSpread, setCurrentSpread] = React.useState(null); - const [cellMappings, setCellMappings] = React.useState([]); - const [isClient, setIsClient] = React.useState(false); - const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | null>(null); - const [validationErrors, setValidationErrors] = React.useState([]); - const [selectedTemplateId, setSelectedTemplateId] = React.useState(""); - const [availableTemplates, setAvailableTemplates] = React.useState([]); - - // 클라이언트 사이드에서만 렌더링되도록 보장 - React.useEffect(() => { - setIsClient(true); - }, []); - - // 사용 가능한 템플릿들을 필터링하고 설정 - React.useEffect(() => { - if (!templateData) return; - - let templates: TemplateItem[]; - if (Array.isArray(templateData)) { - templates = templateData as TemplateItem[]; - } else { - templates = [templateData as TemplateItem]; - } - - // CONTENT가 있는 템플릿들 필터링 - const validTemplates = templates.filter(template => { - const hasSpreadListContent = template.SPR_LST_SETUP?.CONTENT; - const hasSpreadItemContent = template.SPR_ITM_LST_SETUP?.CONTENT; - const isValidType = template.TMPL_TYPE === "SPREAD_LIST" || template.TMPL_TYPE === "SPREAD_ITEM"; - - return isValidType && (hasSpreadListContent || hasSpreadItemContent); - }); - - setAvailableTemplates(validTemplates); - - // 첫 번째 유효한 템플릿을 기본으로 선택 - if (validTemplates.length > 0 && !selectedTemplateId) { - setSelectedTemplateId(validTemplates[0].TMPL_ID); - setTemplateType(validTemplates[0].TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'); - } - }, [templateData, selectedTemplateId]); - - // 선택된 템플릿 변경 처리 - const handleTemplateChange = (templateId: string) => { - const template = availableTemplates.find(t => t.TMPL_ID === templateId); - if (template) { - setSelectedTemplateId(templateId); - setTemplateType(template.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'); - setHasChanges(false); - setValidationErrors([]); - - // SpreadSheets 재초기화 - if (currentSpread) { - const template = availableTemplates.find(t => t.TMPL_ID === templateId); - if (template) { - initSpread(currentSpread, template); - } - } - } - }; - - // 현재 선택된 템플릿 가져오기 - const selectedTemplate = React.useMemo(() => { - return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); - }, [availableTemplates, selectedTemplateId]); - - // 편집 가능한 필드 목록 계산 - const editableFields = React.useMemo(() => { - // SPREAD_ITEM인 경우: selectedRow의 TAG_NO로 확인 - if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { - if (!editableFieldsMap.has(selectedRow.TAG_NO)) { - return []; - } - return editableFieldsMap.get(selectedRow.TAG_NO) || []; - } - - // SPREAD_LIST인 경우: 첫 번째 행의 TAG_NO를 기준으로 처리 - if (templateType === 'SPREAD_LIST' && tableData.length > 0) { - const firstRowTagNo = tableData[0]?.TAG_NO; - if (firstRowTagNo && editableFieldsMap.has(firstRowTagNo)) { - return editableFieldsMap.get(firstRowTagNo) || []; - } - } - - return []; - }, [templateType, selectedRow?.TAG_NO, tableData, editableFieldsMap]); - - // 필드가 편집 가능한지 판별하는 함수 - const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { - // columnsJSON에서 해당 attId의 shi 값 확인 - const columnConfig = columnsJSON.find(col => col.key === attId); - if (columnConfig?.shi === true) { - return false; // columnsJSON에서 shi가 true이면 편집 불가 - } - - // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (columnsJSON의 shi가 false인 경우) - if (attId === "TAG_NO" || attId === "TAG_DESC") { - return true; - } - - // SPREAD_ITEM인 경우: editableFields 체크 - if (templateType === 'SPREAD_ITEM') { - return editableFields.includes(attId); - } - - // SPREAD_LIST인 경우: 개별 행의 편집 가능성도 고려 - if (templateType === 'SPREAD_LIST') { - // 기본적으로 editableFields에 포함되어야 함 - if (!editableFields.includes(attId)) { - return false; - } - - // rowData가 제공된 경우 해당 행의 shi 상태도 확인 - if (rowData && rowData.shi === true) { - return false; - } - - return true; - } - - // 기본적으로는 editableFields 체크 - // return editableFields.includes(attId); - return true; - }, [templateType, columnsJSON, editableFields]); - - // 편집 가능한 필드 개수 계산 - const editableFieldsCount = React.useMemo(() => { - return cellMappings.filter(m => m.isEditable).length; - }, [cellMappings]); - - // 셀 주소를 행과 열로 변환하는 함수 - const parseCellAddress = (address: string): { row: number, col: number } | null => { - if (!address || address.trim() === "") return null; - - const match = address.match(/^([A-Z]+)(\d+)$/); - if (!match) return null; - - const [, colStr, rowStr] = match; - - let col = 0; - for (let i = 0; i < colStr.length; i++) { - col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); - } - col -= 1; - - const row = parseInt(rowStr) - 1; - - return { row, col }; - }; - - // 데이터 타입 검증 함수 - const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { - if (value === undefined || value === null || value === "") { - return null; // 빈 값은 별도 required 검증에서 처리 - } - - switch (columnType) { - case "NUMBER": - if (isNaN(Number(value))) { - return "Value must be a valid number"; - } - break; - case "LIST": - if (options && !options.includes(String(value))) { - return `Value must be one of: ${options.join(", ")}`; - } - break; - case "STRING": - // STRING 타입은 대부분의 값을 허용 - break; - default: - // 커스텀 타입의 경우 추가 검증 로직이 필요할 수 있음 - break; - } - - return null; - }; - - // 전체 데이터 검증 함수 - const validateAllData = React.useCallback(() => { - if (!currentSpread || !selectedTemplate) return []; - - const activeSheet = currentSpread.getActiveSheet(); - const errors: ValidationError[] = []; - - cellMappings.forEach(mapping => { - const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - if (!columnConfig) return; - - const cellPos = parseCellAddress(mapping.cellAddress); - if (!cellPos) return; - - if (templateType === 'SPREAD_ITEM') { - // 단일 행 검증 - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - - if (errorMessage) { - errors.push({ - cellAddress: mapping.cellAddress, - attId: mapping.attId, - value: cellValue, - expectedType: columnConfig.type, - message: errorMessage - }); - } - } else if (templateType === 'SPREAD_LIST') { - // 복수 행 검증 - 각 매핑은 이미 개별 행을 가리킴 - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - - if (errorMessage) { - errors.push({ - cellAddress: mapping.cellAddress, - attId: mapping.attId, - value: cellValue, - expectedType: columnConfig.type, - message: errorMessage - }); - } - } - }); - - setValidationErrors(errors); - return errors; - }, [currentSpread, selectedTemplate, cellMappings, columnsJSON, templateType]); - - // ═══════════════════════════════════════════════════════════════════════════════ - // 🛠️ 헬퍼 함수들 - // ═══════════════════════════════════════════════════════════════════════════════ - - // 🎨 셀 스타일 생성 - const createCellStyle = React.useCallback((isEditable: boolean) => { - const style = new GC.Spread.Sheets.Style(); - if (isEditable) { - style.backColor = "#f0fdf4"; // 연한 초록 (편집 가능) - } else { - style.backColor = "#f9fafb"; // 연한 회색 (읽기 전용) - style.foreColor = "#6b7280"; - } - return style; - }, []); - - -// 🎯 간소화된 드롭다운 설정 - setupSimpleValidation 완전 제거 - -const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { - try { - console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); - - // ✅ options 정규화 - const safeOptions = options - .filter(opt => opt !== null && opt !== undefined && opt !== '') - .map(opt => String(opt).trim()) - .filter(opt => opt.length > 0) - .filter((opt, index, arr) => arr.indexOf(opt) === index) - .slice(0, 20); - - if (safeOptions.length === 0) { - console.warn(`⚠️ No valid options found, skipping`); - return; - } - - console.log(`📋 Safe options:`, safeOptions); - - // ✅ DataValidation용 문자열 준비 - const optionsString = safeOptions.join(','); - - // 🔑 핵심 수정: 각 셀마다 개별 ComboBox 인스턴스 생성! - for (let i = 0; i < rowCount; i++) { - try { - const targetRow = cellPos.row + i; - - // ✅ 각 셀마다 새로운 ComboBox 인스턴스 생성 - const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBoxCellType.items(safeOptions); // 배열로 전달 - comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - - // ✅ 각 셀마다 새로운 DataValidation 인스턴스 생성 - const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); - - // ComboBox + DataValidation 둘 다 적용 - activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); - activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); - - // 셀 잠금 해제 - const cell = activeSheet.getCell(targetRow, cellPos.col); - cell.locked(false); - - console.log(`✅ Individual dropdown applied to [${targetRow}, ${cellPos.col}]`); - - } catch (cellError) { - console.warn(`⚠️ Failed to apply to row ${cellPos.row + i}:`, cellError); - } - } - - console.log(`✅ Safe dropdown setup completed for ${rowCount} cells`); - - } catch (error) { - console.error('❌ Dropdown setup failed:', error); - } -}, []); - // 🚀 행 용량 확보 - const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { - const currentRowCount = activeSheet.getRowCount(); - if (requiredRowCount > currentRowCount) { - activeSheet.setRowCount(requiredRowCount + 10); // 여유분 추가 - console.log(`📈 Expanded sheet to ${requiredRowCount + 10} rows`); - } - }, []); - - // 🛡️ 시트 보호 및 이벤트 설정 - const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { - console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); - - // 시트 보호 설정 - activeSheet.options.isProtected = true; - activeSheet.options.protectionOptions = { - allowSelectLockedCells: true, - allowSelectUnlockedCells: true, - allowSort: false, - allowFilter: false, - allowEditObjects: false, - allowResizeRows: false, - allowResizeColumns: false - }; - - // 🎯 변경 감지 이벤트 - const changeEvents = [ - GC.Spread.Sheets.Events.CellChanged, - GC.Spread.Sheets.Events.ValueChanged, - GC.Spread.Sheets.Events.ClipboardPasted - ]; - - changeEvents.forEach(eventType => { - activeSheet.bind(eventType, () => { - console.log(`📝 ${eventType} detected`); - setHasChanges(true); - }); - }); - - // 🚫 편집 시작 권한 확인 (수정됨) - activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { - console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); - - // ✅ 정확한 매핑 찾기 (행/열 정확히 일치) - const exactMapping = mappings.find(m => { - const cellPos = parseCellAddress(m.cellAddress); - return cellPos && cellPos.row === info.row && cellPos.col === info.col; - }); - - if (!exactMapping) { - console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); - return; // 매핑이 없으면 허용 (템플릿 영역 밖) - } - - console.log(`📋 Found mapping: ${exactMapping.attId} at ${exactMapping.cellAddress}`); - - // 기본 편집 권한 확인 - if (!exactMapping.isEditable) { - console.log(`🚫 Field ${exactMapping.attId} is not editable`); - toast.warning(`${exactMapping.attId} field is read-only`); - info.cancel = true; - return; - } - - // SPREAD_LIST 개별 행 SHI 확인 - if (templateType === 'SPREAD_LIST' && exactMapping.dataRowIndex !== undefined) { - const dataRowIndex = exactMapping.dataRowIndex; - - console.log(`🔍 Checking SHI for data row ${dataRowIndex}`); - - if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { - const rowData = tableData[dataRowIndex]; - if (rowData?.shi === true) { - console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); - toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); - info.cancel = true; - return; - } - } else { - console.warn(`⚠️ Invalid dataRowIndex: ${dataRowIndex} (tableData.length: ${tableData.length})`); - } - } - - console.log(`✅ Edit allowed for ${exactMapping.attId}`); - }); - - // ✅ 편집 완료 검증 (수정됨) - activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { - console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}`); - - // ✅ 정확한 매핑 찾기 - const exactMapping = mappings.find(m => { - const cellPos = parseCellAddress(m.cellAddress); - return cellPos && cellPos.row === info.row && cellPos.col === info.col; - }); - - if (!exactMapping) { - console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - skipping validation`); - return; - } - - const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); - if (columnConfig) { - const cellValue = activeSheet.getValue(info.row, info.col); - console.log(`🔍 Validating ${exactMapping.attId}: "${cellValue}"`); - - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - const cell = activeSheet.getCell(info.row, info.col); - - if (errorMessage) { - console.log(`❌ Validation failed: ${errorMessage}`); - - // 🚨 에러 스타일 적용 (편집 가능 상태 유지) - const errorStyle = new GC.Spread.Sheets.Style(); - errorStyle.backColor = "#fef2f2"; - errorStyle.foreColor = "#dc2626"; - errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - - activeSheet.setStyle(info.row, info.col, errorStyle); - cell.locked(!exactMapping.isEditable); - - toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}. Please correct the value.`, { duration: 5000 }); - } else { - console.log(`✅ Validation passed`); - - // ✅ 정상 스타일 복원 - const normalStyle = createCellStyle(exactMapping.isEditable); - activeSheet.setStyle(info.row, info.col, normalStyle); - cell.locked(!exactMapping.isEditable); - } - } - }); - - console.log(`🛡️ Protection and events configured for ${mappings.length} mappings`); - }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); - - // ═══════════════════════════════════════════════════════════════════════════════ - // 🏗️ 메인 SpreadSheets 초기화 함수 - // ═══════════════════════════════════════════════════════════════════════════════ - - const initSpread = React.useCallback((spread: any, template?: TemplateItem) => { - const workingTemplate = template || selectedTemplate; - if (!spread || !workingTemplate) return; - - try { - // 🔄 초기 설정 - setCurrentSpread(spread); - setHasChanges(false); - setValidationErrors([]); - - // 📋 템플릿 콘텐츠 및 데이터 시트 추출 - let contentJson = null; - let dataSheets = null; - - // SPR_LST_SETUP.CONTENT 우선 사용 - if (workingTemplate.SPR_LST_SETUP?.CONTENT) { - contentJson = workingTemplate.SPR_LST_SETUP.CONTENT; - dataSheets = workingTemplate.SPR_LST_SETUP.DATA_SHEETS; - console.log('✅ Using SPR_LST_SETUP.CONTENT for template:', workingTemplate.NAME); - } - // SPR_ITM_LST_SETUP.CONTENT 대안 사용 - else if (workingTemplate.SPR_ITM_LST_SETUP?.CONTENT) { - contentJson = workingTemplate.SPR_ITM_LST_SETUP.CONTENT; - dataSheets = workingTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS; - console.log('✅ Using SPR_ITM_LST_SETUP.CONTENT for template:', workingTemplate.NAME); - } - - if (!contentJson) { - console.warn('❌ No CONTENT found in template:', workingTemplate.NAME); - return; - } - - // 🏗️ SpreadSheets 초기화 - const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; - - // 성능을 위한 렌더링 일시 중단 - spread.suspendPaint(); - - try { - // 템플릿 구조 로드 - spread.fromJSON(jsonData); - const activeSheet = spread.getActiveSheet(); - - // 시트 보호 해제 (편집을 위해) - activeSheet.options.isProtected = false; - - // 📊 셀 매핑 및 데이터 처리 - if (dataSheets && dataSheets.length > 0) { - const mappings: CellMapping[] = []; - - // 🔄 각 데이터 시트의 매핑 정보 처리 - dataSheets.forEach(dataSheet => { - if (dataSheet.MAP_CELL_ATT) { - dataSheet.MAP_CELL_ATT.forEach(mapping => { - const { ATT_ID, IN } = mapping; - - if (IN && IN.trim() !== "") { - const cellPos = parseCellAddress(IN); - if (cellPos) { - const columnConfig = columnsJSON.find(col => col.key === ATT_ID); - - // 🎯 템플릿 타입별 데이터 처리 - if (templateType === 'SPREAD_ITEM' && selectedRow) { - // 📝 단일 행 처리 (SPREAD_ITEM) - const isEditable = isFieldEditable(ATT_ID); - - // 매핑 정보 저장 - mappings.push({ - attId: ATT_ID, - cellAddress: IN, - isEditable: isEditable, - dataRowIndex: 0 - }); - - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - const value = selectedRow[ATT_ID]; - - // 값 설정 - cell.value(value ?? null); - - // 🎨 스타일 및 편집 권한 설정 - cell.locked(!isEditable); - const style = createCellStyle(isEditable); - activeSheet.setStyle(cellPos.row, cellPos.col, style); - - // 📋 LIST 타입 드롭다운 설정 - if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); - } - - } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { - // 📊 복수 행 처리 (SPREAD_LIST) - 성능 최적화됨 - console.log(`🔄 Processing SPREAD_LIST for ${ATT_ID} with ${tableData.length} rows`); - - // 🚀 행 확장 (필요시) - ensureRowCapacity(activeSheet, cellPos.row + tableData.length); - - // 📋 각 행마다 개별 매핑 생성 - tableData.forEach((rowData, index) => { - const targetRow = cellPos.row + index; - const targetCellAddress = `${String.fromCharCode(65 + cellPos.col)}${targetRow + 1}`; - const cellEditable = isFieldEditable(ATT_ID, rowData); - - // 개별 매핑 추가 - mappings.push({ - attId: ATT_ID, - cellAddress: targetCellAddress, // 각 행마다 다른 주소 - isEditable: cellEditable, - dataRowIndex: index // 원본 데이터 인덱스 - }); - - console.log(`📝 Mapping ${ATT_ID} Row ${index}: ${targetCellAddress} (${cellEditable ? 'Editable' : 'ReadOnly'})`); - }); - - // 📋 LIST 타입 드롭다운 설정 (조건부) - if (columnConfig?.type === "LIST" && columnConfig.options) { - // 편집 가능한 행이 하나라도 있으면 드롭다운 설정 - const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); - if (hasEditableRows) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); - } - } - - // 🎨 개별 셀 데이터 및 스타일 설정 - tableData.forEach((rowData, index) => { - const targetRow = cellPos.row + index; - const cell = activeSheet.getCell(targetRow, cellPos.col); - const value = rowData[ATT_ID]; - - // 값 설정 - cell.value(value ?? null); - console.log(`📝 Row ${targetRow}: ${ATT_ID} = "${value}"`); - - // 편집 권한 및 스타일 설정 - const cellEditable = isFieldEditable(ATT_ID, rowData); - cell.locked(!cellEditable); - const style = createCellStyle(cellEditable); - activeSheet.setStyle(targetRow, cellPos.col, style); - }); - } - - console.log(`📌 Mapped ${ATT_ID} → ${IN} (${templateType})`); - } - } - }); - } - }); - - // 💾 매핑 정보 저장 및 이벤트 설정 - setCellMappings(mappings); - setupSheetProtectionAndEvents(activeSheet, mappings); - } - - } finally { - // 렌더링 재개 - spread.resumePaint(); - } - - } catch (error) { - console.error('❌ Error initializing spread:', error); - toast.error('Failed to load template'); - if (spread?.resumePaint) { - spread.resumePaint(); - } - } - }, [selectedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON, createCellStyle, setupOptimizedListValidation, ensureRowCapacity, setupSheetProtectionAndEvents]); - - // 변경사항 저장 함수 - const handleSaveChanges = React.useCallback(async () => { - if (!currentSpread || !hasChanges) { - toast.info("No changes to save"); - return; - } - - // 저장 전 데이터 검증 - const errors = validateAllData(); - if (errors.length > 0) { - toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); - return; - } - - try { - setIsPending(true); - - const activeSheet = currentSpread.getActiveSheet(); - - if (templateType === 'SPREAD_ITEM' && selectedRow) { - // 단일 행 저장 - const dataToSave = { ...selectedRow }; - - cellMappings.forEach(mapping => { - if (mapping.isEditable) { - const cellPos = parseCellAddress(mapping.cellAddress); - if (cellPos) { - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - dataToSave[mapping.attId] = cellValue; - } - } - }); - - dataToSave.TAG_NO = selectedRow.TAG_NO; - - const { success, message } = await updateFormDataInDB( - formCode, - contractItemId, - dataToSave - ); - - if (!success) { - toast.error(message); - return; - } - - toast.success("Changes saved successfully!"); - onUpdateSuccess?.(dataToSave); - - } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { - // 복수 행 저장 - const updatedRows: GenericData[] = []; - let saveCount = 0; - - for (let i = 0; i < tableData.length; i++) { - const originalRow = tableData[i]; - const dataToSave = { ...originalRow }; - let hasRowChanges = false; - - // 각 매핑에 대해 해당 행의 값 확인 - cellMappings.forEach(mapping => { - if (mapping.dataRowIndex === i && mapping.isEditable) { - const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - const isColumnEditable = columnConfig?.shi !== true; - const isRowEditable = originalRow.shi !== true; - - if (isColumnEditable && isRowEditable) { - const cellPos = parseCellAddress(mapping.cellAddress); - if (cellPos) { - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - - // 값이 변경되었는지 확인 - if (cellValue !== originalRow[mapping.attId]) { - dataToSave[mapping.attId] = cellValue; - hasRowChanges = true; - } - } - } - } - }); - - // 변경사항이 있는 행만 저장 - if (hasRowChanges) { - dataToSave.TAG_NO = originalRow.TAG_NO; // TAG_NO는 절대 변경되지 않도록 - - const { success } = await updateFormDataInDB( - formCode, - contractItemId, - dataToSave - ); - - if (success) { - updatedRows.push(dataToSave); - saveCount++; - } - } else { - updatedRows.push(originalRow); // 변경사항이 없으면 원본 유지 - } - } - - if (saveCount > 0) { - toast.success(`${saveCount} rows saved successfully!`); - onUpdateSuccess?.(updatedRows); - } else { - toast.info("No changes to save"); - } - } - - setHasChanges(false); - setValidationErrors([]); - - } catch (error) { - console.error("Error saving changes:", error); - toast.error("An unexpected error occurred while saving"); - } finally { - setIsPending(false); - } - }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]); - - if (!isOpen) return null; - - // 데이터 유효성 검사 - const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; - const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; - - return ( - - - - SEDP Template - {formCode} - -
- {/* 템플릿 선택 */} - {availableTemplates.length > 1 && ( -
- Template: - -
- )} - - {/* 템플릿 정보 */} - {selectedTemplate && ( -
- - Template Type: {selectedTemplate.TMPL_TYPE === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : 'Item View (SPREAD_ITEM)'} - - {templateType === 'SPREAD_ITEM' && selectedRow && ( - • Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'} - )} - {templateType === 'SPREAD_LIST' && ( - • {dataCount} rows - )} - {hasChanges && ( - - • Unsaved changes - - )} - {validationErrors.length > 0 && ( - - - {validationErrors.length} validation errors - - )} -
- )} - - {/* 범례 */} -
- - - Editable fields - - - - Read-only fields - - - - Validation errors - - {cellMappings.length > 0 && ( - - {editableFieldsCount} of {cellMappings.length} fields editable - - )} -
-
-
-
- - {/* SpreadSheets 컴포넌트 영역 */} -
- {selectedTemplate && isClient && isDataValid ? ( - - ) : ( -
- {!isClient ? ( - <> - - Loading... - - ) : !selectedTemplate ? ( - "No template available" - ) : !isDataValid ? ( - `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` - ) : ( - "Template not ready" - )} -
- )} -
- - -
- - - {hasChanges && ( - - )} - - {validationErrors.length > 0 && ( - - )} -
-
-
-
- ); -} \ No newline at end of file diff --git a/components/form-data/spreadJS-dialog copy 3.tsx b/components/form-data/spreadJS-dialog copy 3.tsx deleted file mode 100644 index 1ea8232b..00000000 --- a/components/form-data/spreadJS-dialog copy 3.tsx +++ /dev/null @@ -1,1916 +0,0 @@ -"use client"; - -import * as React from "react"; -import dynamic from "next/dynamic"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { GenericData } from "./export-excel-form"; -import * as GC from "@mescius/spread-sheets"; -import { toast } from "sonner"; -import { updateFormDataInDB } from "@/lib/forms/services"; -import { Loader, Save, AlertTriangle } from "lucide-react"; -import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; -import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; - - -// SpreadSheets를 동적으로 import (SSR 비활성화) -const SpreadSheets = dynamic( - () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), - { - ssr: false, - loading: () => ( -
- - Loading SpreadSheets... -
- ) - } -); - -// 라이센스 키 설정을 클라이언트에서만 실행 -if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { - GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; -} - -interface TemplateItem { - TMPL_ID: string; - NAME: string; - TMPL_TYPE: string; - SPR_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array; - CONTENT?: string; - DATA_SHEETS: Array<{ - SHEET_NAME: string; - REG_TYPE_ID: string; - MAP_CELL_ATT: Array<{ - ATT_ID: string; - IN: string; - }>; - }>; - }; - GRD_LST_SETUP: { - REG_TYPE_ID: string; - SPR_ITM_IDS: Array; - ATTS: Array<{}>; - }; - SPR_ITM_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array; - CONTENT?: string; - DATA_SHEETS: Array<{ - SHEET_NAME: string; - REG_TYPE_ID: string; - MAP_CELL_ATT: Array<{ - ATT_ID: string; - IN: string; - }>; - }>; - }; -} - -interface ValidationError { - cellAddress: string; - attId: string; - value: any; - expectedType: ColumnType; - message: string; -} - -interface CellMapping { - attId: string; - cellAddress: string; - isEditable: boolean; - dataRowIndex?: number; -} - -interface TemplateViewDialogProps { - isOpen: boolean; - onClose: () => void; - templateData: TemplateItem[] | any; - selectedRow?: GenericData; // SPREAD_ITEM용 - tableData?: GenericData[]; // SPREAD_LIST용 - formCode: string; - columnsJSON: DataTableColumnJSON[] - contractItemId: number; - editableFieldsMap?: Map; - onUpdateSuccess?: (updatedValues: Record | GenericData[]) => void; -} - -export function TemplateViewDialog({ - isOpen, - onClose, - templateData, - selectedRow, - tableData = [], - formCode, - contractItemId, - columnsJSON, - editableFieldsMap = new Map(), - onUpdateSuccess -}: TemplateViewDialogProps) { - const [hostStyle, setHostStyle] = React.useState({ - width: '100%', - height: '100%' - }); - - const [isPending, setIsPending] = React.useState(false); - const [hasChanges, setHasChanges] = React.useState(false); - const [currentSpread, setCurrentSpread] = React.useState(null); - const [cellMappings, setCellMappings] = React.useState([]); - const [isClient, setIsClient] = React.useState(false); - const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); - const [validationErrors, setValidationErrors] = React.useState([]); - const [selectedTemplateId, setSelectedTemplateId] = React.useState(""); - const [availableTemplates, setAvailableTemplates] = React.useState([]); - - const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { - // 1. SPREAD_LIST: TMPL_TYPE이 SPREAD_LIST이고 SPR_LST_SETUP.CONTENT가 있음 - if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { - return 'SPREAD_LIST'; - } - - // 2. SPREAD_ITEM: TMPL_TYPE이 SPREAD_ITEM이고 SPR_ITM_LST_SETUP.CONTENT가 있음 - if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { - return 'SPREAD_ITEM'; - } - - // 3. GRD_LIST: GRD_LST_SETUP이 있고 columnsJSON이 있음 (동적 테이블) - if (template.GRD_LST_SETUP && columnsJSON.length > 0) { - return 'GRD_LIST'; - } - - return null; // 유효하지 않은 템플릿 - }, [columnsJSON]); - - const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { - return determineTemplateType(template) !== null; - }, [determineTemplateType]); - - - // 클라이언트 사이드에서만 렌더링되도록 보장 - React.useEffect(() => { - setIsClient(true); - }, []); - - // 사용 가능한 템플릿들을 필터링하고 설정 - React.useEffect(() => { - if (!templateData) return; - - let templates: TemplateItem[]; - if (Array.isArray(templateData)) { - templates = templateData as TemplateItem[]; - } else { - templates = [templateData as TemplateItem]; - } - - // 유효한 템플릿들만 필터링 - const validTemplates = templates.filter(isValidTemplate); - - setAvailableTemplates(validTemplates); - - // 첫 번째 유효한 템플릿을 기본으로 선택 - if (validTemplates.length > 0 && !selectedTemplateId) { - const firstTemplate = validTemplates[0]; - const templateTypeToSet = determineTemplateType(firstTemplate); - - setSelectedTemplateId(firstTemplate.TMPL_ID); - setTemplateType(templateTypeToSet); - } - }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); - - - // 선택된 템플릿 변경 처리 - const handleTemplateChange = (templateId: string) => { - const template = availableTemplates.find(t => t.TMPL_ID === templateId); - if (template) { - const templateTypeToSet = determineTemplateType(template); - - setSelectedTemplateId(templateId); - setTemplateType(templateTypeToSet); - setHasChanges(false); - setValidationErrors([]); - - // SpreadSheets 재초기화 - if (currentSpread && template) { - initSpread(currentSpread, template); - } - } - }; - // 현재 선택된 템플릿 가져오기 - const selectedTemplate = React.useMemo(() => { - return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); - }, [availableTemplates, selectedTemplateId]); - - - // 편집 가능한 필드 목록 계산 - const editableFields = React.useMemo(() => { - // SPREAD_ITEM인 경우: selectedRow의 TAG_NO로 확인 - if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { - if (!editableFieldsMap.has(selectedRow.TAG_NO)) { - return []; - } - return editableFieldsMap.get(selectedRow.TAG_NO) || []; - } - - // SPREAD_LIST 또는 GRD_LIST인 경우: 첫 번째 행의 TAG_NO를 기준으로 처리 - if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { - const firstRowTagNo = tableData[0]?.TAG_NO; - if (firstRowTagNo && editableFieldsMap.has(firstRowTagNo)) { - return editableFieldsMap.get(firstRowTagNo) || []; - } - } - - return []; - }, [templateType, selectedRow?.TAG_NO, tableData, editableFieldsMap]); - - // 필드가 편집 가능한지 판별하는 함수 - const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { - // columnsJSON에서 해당 attId의 shi 값 확인 - const columnConfig = columnsJSON.find(col => col.key === attId); - if (columnConfig?.shi === true) { - return false; // columnsJSON에서 shi가 true이면 편집 불가 - } - - // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (columnsJSON의 shi가 false인 경우) - if (attId === "TAG_NO" || attId === "TAG_DESC") { - return false; - } - - if (attId === "status") { - return false; - } - - // SPREAD_ITEM인 경우: editableFields 체크 - // if (templateType === 'SPREAD_ITEM') { - // return editableFields.includes(attId); - // } - - // SPREAD_LIST 또는 GRD_LIST인 경우: 개별 행의 편집 가능성도 고려 - if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - // 기본적으로 editableFields에 포함되어야 함 - // if (!editableFields.includes(attId)) { - // return false; - // } - - // rowData가 제공된 경우 해당 행의 shi 상태도 확인 - if (rowData && rowData.shi === true) { - return false; - } - - return true; - } - - return true; - }, [templateType, columnsJSON, editableFields]); - - // 편집 가능한 필드 개수 계산 - const editableFieldsCount = React.useMemo(() => { - return cellMappings.filter(m => m.isEditable).length; - }, [cellMappings]); - - // 셀 주소를 행과 열로 변환하는 함수 - const parseCellAddress = (address: string): { row: number, col: number } | null => { - if (!address || address.trim() === "") return null; - - const match = address.match(/^([A-Z]+)(\d+)$/); - if (!match) return null; - - const [, colStr, rowStr] = match; - - let col = 0; - for (let i = 0; i < colStr.length; i++) { - col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); - } - col -= 1; - - const row = parseInt(rowStr) - 1; - - return { row, col }; - }; - - // 행과 열을 셀 주소로 변환하는 함수 (GRD_LIST용) - const getCellAddress = (row: number, col: number): string => { - let colStr = ''; - let colNum = col; - while (colNum >= 0) { - colStr = String.fromCharCode((colNum % 26) + 65) + colStr; - colNum = Math.floor(colNum / 26) - 1; - } - return colStr + (row + 1); - }; - - // 데이터 타입 검증 함수 - const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { - if (value === undefined || value === null || value === "") { - return null; // 빈 값은 별도 required 검증에서 처리 - } - - switch (columnType) { - case "NUMBER": - if (isNaN(Number(value))) { - return "Value must be a valid number"; - } - break; - case "LIST": - if (options && !options.includes(String(value))) { - return `Value must be one of: ${options.join(", ")}`; - } - break; - case "STRING": - // STRING 타입은 대부분의 값을 허용 - break; - default: - // 커스텀 타입의 경우 추가 검증 로직이 필요할 수 있음 - break; - } - - return null; - }; - - // 전체 데이터 검증 함수 - const validateAllData = React.useCallback(() => { - if (!currentSpread || !selectedTemplate) return []; - - const activeSheet = currentSpread.getActiveSheet(); - const errors: ValidationError[] = []; - - cellMappings.forEach(mapping => { - const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - if (!columnConfig) return; - - const cellPos = parseCellAddress(mapping.cellAddress); - if (!cellPos) return; - - if (templateType === 'SPREAD_ITEM') { - // 단일 행 검증 - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - - if (errorMessage) { - errors.push({ - cellAddress: mapping.cellAddress, - attId: mapping.attId, - value: cellValue, - expectedType: columnConfig.type, - message: errorMessage - }); - } - } else if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - // 복수 행 검증 - 각 매핑은 이미 개별 행을 가리킴 - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - - if (errorMessage) { - errors.push({ - cellAddress: mapping.cellAddress, - attId: mapping.attId, - value: cellValue, - expectedType: columnConfig.type, - message: errorMessage - }); - } - } - }); - - setValidationErrors(errors); - return errors; - }, [currentSpread, selectedTemplate, cellMappings, columnsJSON, templateType]); - - // ═══════════════════════════════════════════════════════════════════════════════ - // 🛠️ 헬퍼 함수들 - // ═══════════════════════════════════════════════════════════════════════════════ - - // 🎨 셀 스타일 생성 - const createCellStyle = React.useCallback((isEditable: boolean) => { - const style = new GC.Spread.Sheets.Style(); - if (isEditable) { - style.backColor = "#f0fdf4"; // 연한 초록 (편집 가능) - } else { - style.backColor = "#f9fafb"; // 연한 회색 (읽기 전용) - style.foreColor = "#6b7280"; - } - return style; - }, []); - - // 🎯 드롭다운 설정 - const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { - try { - console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); - - // ✅ options 정규화 - const safeOptions = options - .filter(opt => opt !== null && opt !== undefined && opt !== '') - .map(opt => String(opt).trim()) - .filter(opt => opt.length > 0) - .filter((opt, index, arr) => arr.indexOf(opt) === index) - .slice(0, 20); - - if (safeOptions.length === 0) { - console.warn(`⚠️ No valid options found, skipping`); - return; - } - - console.log(`📋 Safe options:`, safeOptions); - - // ✅ DataValidation용 문자열 준비 - const optionsString = safeOptions.join(','); - - // 🔑 핵심 수정: 각 셀마다 개별 ComboBox 인스턴스 생성! - for (let i = 0; i < rowCount; i++) { - try { - const targetRow = cellPos.row + i; - - // ✅ 각 셀마다 새로운 ComboBox 인스턴스 생성 - const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBoxCellType.items(safeOptions); // 배열로 전달 - comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - - // ✅ 각 셀마다 새로운 DataValidation 인스턴스 생성 - const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); - - // ComboBox + DataValidation 둘 다 적용 - activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); - activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); - - // 셀 잠금 해제 - const cell = activeSheet.getCell(targetRow, cellPos.col); - cell.locked(false); - - console.log(`✅ Individual dropdown applied to [${targetRow}, ${cellPos.col}]`); - - } catch (cellError) { - console.warn(`⚠️ Failed to apply to row ${cellPos.row + i}:`, cellError); - } - } - - console.log(`✅ Safe dropdown setup completed for ${rowCount} cells`); - - } catch (error) { - console.error('❌ Dropdown setup failed:', error); - } - }, []); - - // 🛡️ 안전한 시트 검증 함수 추가 -const validateActiveSheet = React.useCallback((activeSheet: any, functionName: string = 'unknown') => { - console.log(`🔍 Validating activeSheet for ${functionName}:`); - - if (!activeSheet) { - console.error(`❌ activeSheet is null/undefined in ${functionName}`); - return false; - } - - console.log(`✅ activeSheet exists (type: ${typeof activeSheet})`); - console.log(`✅ constructor: ${activeSheet.constructor?.name}`); - - // 핵심 메서드들 존재 여부 확인 - const requiredMethods = ['getRowCount', 'getColumnCount', 'setRowCount', 'setColumnCount', 'getCell', 'getValue', 'setStyle']; - const missingMethods = requiredMethods.filter(method => typeof activeSheet[method] !== 'function'); - - if (missingMethods.length > 0) { - console.error(`❌ Missing methods in ${functionName}:`, missingMethods); - console.log(`📋 Available methods:`, Object.getOwnPropertyNames(activeSheet).filter(prop => typeof activeSheet[prop] === 'function').slice(0, 20)); - return false; - } - - console.log(`✅ All required methods available for ${functionName}`); - return true; -}, []); -// 🛡️ 안전한 ActiveSheet 가져오기 함수 -const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { - console.log(`🔍 Getting safe activeSheet for ${functionName}`); - - if (!spread) { - console.error(`❌ Spread is null/undefined in ${functionName}`); - return null; - } - - try { - // 현재 활성 시트 가져오기 - let activeSheet = spread.getActiveSheet(); - - if (!activeSheet) { - console.warn(`⚠️ ActiveSheet is null, attempting to get first sheet in ${functionName}`); - - // 첫 번째 시트 시도 - const sheetCount = spread.getSheetCount(); - console.log(`📊 Total sheets: ${sheetCount}`); - - if (sheetCount > 0) { - activeSheet = spread.getSheet(0); - if (activeSheet) { - spread.setActiveSheetIndex(0); - console.log(`✅ Successfully got first sheet in ${functionName}`); - } - } - } - - if (!activeSheet) { - console.error(`❌ Failed to get any valid sheet in ${functionName}`); - return null; - } - - // 시트 유효성 검증 - const validation = validateActiveSheet(activeSheet, functionName); - if (!validation) { - console.error(`❌ Sheet validation failed in ${functionName}`); - return null; - } - - console.log(`✅ Got valid activeSheet for ${functionName}: ${activeSheet.name?.() || 'unnamed'}`); - return activeSheet; - - } catch (error) { - console.error(`❌ Error getting activeSheet in ${functionName}:`, error); - return null; - } -}, [validateActiveSheet]); - -// 🛡️ 수정된 ensureRowCapacity 함수 -const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { - try { - // 🔍 상세한 null/undefined 체크 - if (!activeSheet) { - console.error('❌ activeSheet is null/undefined in ensureRowCapacity'); - return false; - } - - console.log('🔍 ActiveSheet validation in ensureRowCapacity:'); - console.log(' - Type:', typeof activeSheet); - console.log(' - Constructor:', activeSheet.constructor?.name); - console.log(' - Is null:', activeSheet === null); - console.log(' - Is undefined:', activeSheet === undefined); - - // 🔍 메서드 존재 여부 확인 - if (typeof activeSheet.getRowCount !== 'function') { - console.error('❌ getRowCount method does not exist on activeSheet'); - console.log('📋 Available properties:', Object.getOwnPropertyNames(activeSheet).slice(0, 20)); - return false; - } - - // 🔍 시트 상태 확인 - const currentRowCount = activeSheet.getRowCount(); - console.log(`📊 Current row count: ${currentRowCount} (type: ${typeof currentRowCount})`); - - if (typeof currentRowCount !== 'number' || isNaN(currentRowCount)) { - console.error('❌ getRowCount returned invalid value:', currentRowCount); - return false; - } - - if (requiredRowCount > currentRowCount) { - // 🔍 setRowCount 메서드 확인 - if (typeof activeSheet.setRowCount !== 'function') { - console.error('❌ setRowCount method does not exist on activeSheet'); - return false; - } - - const newRowCount = requiredRowCount + 10; - activeSheet.setRowCount(newRowCount); - console.log(`📈 Expanded sheet: ${currentRowCount} → ${newRowCount} rows`); - - // 🔍 설정 후 검증 - const verifyRowCount = activeSheet.getRowCount(); - console.log(`✅ Verified new row count: ${verifyRowCount}`); - - return true; - } else { - console.log(`✅ Sheet already has sufficient rows: ${currentRowCount} >= ${requiredRowCount}`); - return true; - } - - } catch (error) { - console.error('❌ Error in ensureRowCapacity:', error); - console.error('❌ Error stack:', error.stack); - return false; - } -}, []); - -// 🛡️ 안전한 컬럼 용량 확보 함수 -const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { - try { - // 🔍 상세한 null/undefined 체크 - if (!activeSheet) { - console.error('❌ activeSheet is null/undefined in ensureColumnCapacity'); - return false; - } - - console.log('🔍 ActiveSheet validation in ensureColumnCapacity:'); - console.log(' - Type:', typeof activeSheet); - console.log(' - Constructor:', activeSheet.constructor?.name); - console.log(' - Is null:', activeSheet === null); - console.log(' - Is undefined:', activeSheet === undefined); - - // 🔍 메서드 존재 여부 확인 - if (typeof activeSheet.getColumnCount !== 'function') { - console.error('❌ getColumnCount method does not exist on activeSheet'); - console.log('📋 Available properties:', Object.getOwnPropertyNames(activeSheet).slice(0, 20)); - return false; - } - - const currentColumnCount = activeSheet.getColumnCount(); - console.log(`📊 Current column count: ${currentColumnCount} (type: ${typeof currentColumnCount})`); - - if (typeof currentColumnCount !== 'number' || isNaN(currentColumnCount)) { - console.error('❌ getColumnCount returned invalid value:', currentColumnCount); - return false; - } - - if (requiredColumnCount > currentColumnCount) { - if (typeof activeSheet.setColumnCount !== 'function') { - console.error('❌ setColumnCount method does not exist on activeSheet'); - return false; - } - - const newColumnCount = requiredColumnCount + 10; - activeSheet.setColumnCount(newColumnCount); - console.log(`📈 Expanded columns: ${currentColumnCount} → ${newColumnCount}`); - - // 🔍 설정 후 검증 - const verifyColumnCount = activeSheet.getColumnCount(); - console.log(`✅ Verified new column count: ${verifyColumnCount}`); - - return true; - } else { - console.log(`✅ Sheet already has sufficient columns: ${currentColumnCount} >= ${requiredColumnCount}`); - return true; - } - - } catch (error) { - console.error('❌ Error in ensureColumnCapacity:', error); - console.error('❌ Error stack:', error.stack); - return false; - } -}, []); - - -// 🎯 텍스트 너비 계산 함수들 (createGrdListTable 함수 위에 추가) -const measureTextWidth = React.useCallback((text: string, fontSize: number = 12, fontFamily: string = 'Arial'): number => { - // Canvas를 사용한 정확한 텍스트 너비 측정 - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (!context) return text.length * 8; // fallback - - context.font = `${fontSize}px ${fontFamily}`; - const metrics = context.measureText(text || ''); - return Math.ceil(metrics.width); -}, []); - -const calculateColumnWidth = React.useCallback(( - headerText: string, - dataValues: any[] = [], - minWidth: number = 80, - maxWidth: number = 300, - padding: number = 20 -): number => { - // 헤더 텍스트 너비 계산 - const headerWidth = measureTextWidth(headerText, 12, 'Arial'); - - // 데이터 값들의 최대 너비 계산 - let maxDataWidth = 0; - if (dataValues.length > 0) { - maxDataWidth = Math.max( - ...dataValues - .slice(0, 10) // 성능을 위해 처음 10개만 샘플링 - .map(value => measureTextWidth(String(value || ''), 11, 'Arial')) - ); - } - - // 헤더와 데이터 중 더 큰 너비 + 패딩 적용 - const calculatedWidth = Math.max(headerWidth, maxDataWidth) + padding; - - // 최소/최대 너비 제한 적용 - return Math.min(Math.max(calculatedWidth, minWidth), maxWidth); -}, [measureTextWidth]); - -const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { - console.log('🎨 Setting optimal column widths...'); - - columns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - - // 해당 컬럼의 데이터 값들 추출 - const dataValues = tableData.map(row => row[column.key]).filter(val => val != null); - - // 최적 너비 계산 - const optimalWidth = calculateColumnWidth( - column.label || column.key, - dataValues, - column.type === 'NUMBER' ? 100 : 80, // 숫자는 좀 더 넓게 - column.type === 'STRING' ? 250 : 200, // 문자열은 더 넓게 - column.type === 'LIST' ? 30 : 20 // 드롭다운은 여유 패딩 - ); - - // 컬럼 너비 설정 - activeSheet.setColumnWidth(targetCol, optimalWidth); - - console.log(`📏 Column ${targetCol} (${column.key}): width set to ${optimalWidth}px`); - }); -}, [calculateColumnWidth]); - - // 🔍 컬럼 그룹 분석 함수 - const analyzeColumnGroups = React.useCallback((columns: DataTableColumnJSON[]) => { - const groups: Array<{ - head: string; - isGroup: boolean; - columns: DataTableColumnJSON[]; - }> = []; - - let i = 0; - while (i < columns.length) { - const currentCol = columns[i]; - - // head가 없거나 빈 문자열인 경우 단일 컬럼으로 처리 - if (!currentCol.head || !currentCol.head.trim()) { - groups.push({ - head: '', - isGroup: false, - columns: [currentCol] - }); - i++; - continue; - } - - // 같은 head를 가진 연속된 컬럼들을 찾기 - const groupHead = currentCol.head.trim(); - const groupColumns: DataTableColumnJSON[] = [currentCol]; - let j = i + 1; - - while (j < columns.length && columns[j].head && columns[j].head.trim() === groupHead) { - groupColumns.push(columns[j]); - j++; - } - - // 그룹 추가 - groups.push({ - head: groupHead, - isGroup: groupColumns.length > 1, - columns: groupColumns - }); - - i = j; // 다음 그룹으로 이동 - } - - return { groups }; - }, []); - - -// 🆕 수정된 createGrdListTable 함수 -// 🆕 개선된 GRD_LIST용 동적 테이블 생성 함수 -const createGrdListTable = React.useCallback((activeSheet: any, template: TemplateItem) => { - console.log('🏗️ Creating GRD_LIST table'); - - // columnsJSON의 visible 컬럼들을 seq 순서로 정렬하여 사용 - const visibleColumns = columnsJSON - .filter(col => col.hidden !== true) - .sort((a, b) => { - const seqA = a.seq !== undefined ? a.seq : 999999; - const seqB = b.seq !== undefined ? b.seq : 999999; - return seqA - seqB; - }); - - console.log('📊 Using columns:', visibleColumns.map(c => `${c.key}(seq:${c.seq})`)); - console.log(`📊 Total visible columns: ${visibleColumns.length}`); - - if (visibleColumns.length === 0) { - console.warn('❌ No visible columns found in columnsJSON'); - return []; - } - - // ⭐ 컬럼 용량 확보 - const startCol = 1; - const requiredColumnCount = startCol + visibleColumns.length; - ensureColumnCapacity(activeSheet, requiredColumnCount); - - // 테이블 생성 시작 - const mappings: CellMapping[] = []; - - // 🔍 그룹 헤더 분석 - const groupInfo = analyzeColumnGroups(visibleColumns); - const hasGroups = groupInfo.groups.length > 0; - - // 헤더 행 계산: 그룹이 있으면 2행, 없으면 1행 - const groupHeaderRow = 0; - const columnHeaderRow = hasGroups ? 1 : 0; - const dataStartRow = hasGroups ? 2 : 1; - - // 🎨 헤더 스타일 생성 - const groupHeaderStyle = new GC.Spread.Sheets.Style(); - groupHeaderStyle.backColor = "#1e40af"; - groupHeaderStyle.foreColor = "#ffffff"; - groupHeaderStyle.font = "bold 13px Arial"; - groupHeaderStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; - groupHeaderStyle.vAlign = GC.Spread.Sheets.VerticalAlign.center; - - const columnHeaderStyle = new GC.Spread.Sheets.Style(); - columnHeaderStyle.backColor = "#3b82f6"; - columnHeaderStyle.foreColor = "#ffffff"; - columnHeaderStyle.font = "bold 12px Arial"; - columnHeaderStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; - columnHeaderStyle.vAlign = GC.Spread.Sheets.VerticalAlign.center; - - let currentCol = startCol; - - // 🏗️ 그룹 헤더 및 컬럼 헤더 생성 - if (hasGroups) { - // 그룹 헤더가 있는 경우 - groupInfo.groups.forEach(group => { - if (group.isGroup) { - // 그룹 헤더 생성 및 병합 - const groupStartCol = currentCol; - const groupEndCol = currentCol + group.columns.length - 1; - - // 그룹 헤더 셀 설정 - const groupHeaderCell = activeSheet.getCell(groupHeaderRow, groupStartCol); - groupHeaderCell.value(group.head); - - // 그룹 헤더 병합 - if (group.columns.length > 1) { - activeSheet.addSpan(groupHeaderRow, groupStartCol, 1, group.columns.length); - } - - // 그룹 헤더 스타일 적용 - for (let col = groupStartCol; col <= groupEndCol; col++) { - activeSheet.setStyle(groupHeaderRow, col, groupHeaderStyle); - activeSheet.getCell(groupHeaderRow, col).locked(true); - } - - console.log(`📝 Group Header [${groupHeaderRow}, ${groupStartCol}-${groupEndCol}]: ${group.head}`); - - // 그룹 내 개별 컬럼 헤더 생성 - group.columns.forEach((column, index) => { - const colIndex = groupStartCol + index; - const columnHeaderCell = activeSheet.getCell(columnHeaderRow, colIndex); - columnHeaderCell.value(column.label); - activeSheet.setStyle(columnHeaderRow, colIndex, columnHeaderStyle); - columnHeaderCell.locked(true); - - console.log(`📝 Column Header [${columnHeaderRow}, ${colIndex}]: ${column.label}`); - }); - - currentCol += group.columns.length; - } else { - // 그룹이 아닌 단일 컬럼 - const column = group.columns[0]; - - // 그룹 헤더 행에는 빈 셀 - const groupHeaderCell = activeSheet.getCell(groupHeaderRow, currentCol); - groupHeaderCell.value(""); - activeSheet.setStyle(groupHeaderRow, currentCol, groupHeaderStyle); - groupHeaderCell.locked(true); - - // 컬럼 헤더 생성 - const columnHeaderCell = activeSheet.getCell(columnHeaderRow, currentCol); - columnHeaderCell.value(column.label); - activeSheet.setStyle(columnHeaderRow, currentCol, columnHeaderStyle); - columnHeaderCell.locked(true); - - console.log(`📝 Single Column [${columnHeaderRow}, ${currentCol}]: ${column.label}`); - currentCol++; - } - }); - } else { - // 그룹이 없는 경우 - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - const columnConfig = columnsJSON.find(col => col.key === column.key); - - // 📋 각 행마다 개별 셀 설정 - tableData.forEach((rowData, rowIndex) => { - const targetRow = dataStartRow + rowIndex; - const cell = activeSheet.getCell(targetRow, targetCol); - const value = rowData[column.key]; - const cellEditable = isFieldEditable(column.key, rowData); - - // 🔧 새로 추가: 셀 타입 및 편집기 설정 - if (columnConfig) { - setupCellTypeAndEditor(activeSheet, { row: targetRow, col: targetCol }, columnConfig, cellEditable, 1); - } - - // 값 설정 - cell.value(value ?? null); - - // 스타일 설정 - const style = createCellStyle(cellEditable); - activeSheet.setStyle(targetRow, targetCol, style); - - // 개별 매핑 추가 - mappings.push({ - attId: column.key, - cellAddress: getCellAddress(targetRow, targetCol), - isEditable: cellEditable, - dataRowIndex: rowIndex - }); - }); - }); - } - - // 🔄 데이터 행 및 매핑 생성 (SPREAD_LIST 방식과 동일한 로직) - const dataRowCount = tableData.length; - ensureRowCapacity(activeSheet, dataStartRow + dataRowCount); - - // 📋 각 컬럼별로 매핑 생성 (SPREAD_LIST와 동일한 방식) - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - - console.log(`🔄 Processing column ${column.key} with ${dataRowCount} rows`); - - // 📋 각 행마다 개별 매핑 생성 (SPREAD_LIST와 동일) - tableData.forEach((rowData, rowIndex) => { - const targetRow = dataStartRow + rowIndex; - const cellAddress = getCellAddress(targetRow, targetCol); - - // 🛡️ readonly 체크 (SPREAD_LIST와 동일한 로직) - const cellEditable = isFieldEditable(column.key, rowData); - - // 개별 매핑 추가 - mappings.push({ - attId: column.key, - cellAddress: cellAddress, - isEditable: cellEditable, - dataRowIndex: rowIndex - }); - - console.log(`📝 Mapping ${column.key} Row ${rowIndex}: ${cellAddress} (${cellEditable ? 'Editable' : 'ReadOnly'})`); - }); - - // 📋 LIST 타입 드롭다운 설정 (편집 가능한 행이 있는 경우만) - if (column.type === "LIST" && column.options) { - const hasEditableRows = tableData.some((rowData) => isFieldEditable(column.key, rowData)); - if (hasEditableRows) { - const cellPos = { row: dataStartRow, col: targetCol }; - setupOptimizedListValidation(activeSheet, cellPos, column.options, dataRowCount); - console.log(`📋 Dropdown set for ${column.key}: ${hasEditableRows ? 'Has editable rows' : 'All readonly'}`); - } - } - }); - - // 🎨 개별 셀 데이터 및 스타일 설정 (SPREAD_LIST와 동일한 방식) - tableData.forEach((rowData, rowIndex) => { - const targetRow = dataStartRow + rowIndex; - - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - const cell = activeSheet.getCell(targetRow, targetCol); - const value = rowData[column.key]; - - // 값 설정 - cell.value(value ?? null); - - // 🛡️ 편집 권한 및 스타일 재확인 (SPREAD_LIST와 동일) - const cellEditable = isFieldEditable(column.key, rowData); - cell.locked(!cellEditable); - const style = createCellStyle(cellEditable); - activeSheet.setStyle(targetRow, targetCol, style); - - // 🔍 디버깅: readonly 상태 로깅 - if (!cellEditable) { - const columnConfig = columnsJSON.find(col => col.key === column.key); - const reasons = []; - - if (columnConfig?.shi === true) { - reasons.push('column.shi=true'); - } - if (rowData.shi === true) { - reasons.push('row.shi=true'); - } - if (!editableFields.includes(column.key) && column.key !== "TAG_NO" && column.key !== "TAG_DESC") { - reasons.push('not in editableFields'); - } - - console.log(`🔒 ReadOnly [${targetRow}, ${targetCol}] ${column.key}: ${reasons.join(', ')}`); - } - }); - }); - - // 🎨 컬럼 너비 자동 설정 - setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); - - console.log(`🏗️ GRD_LIST table created with ${mappings.length} mappings, hasGroups: ${hasGroups}`); - console.log(`📊 Readonly analysis:`); - console.log(` Total cells: ${mappings.length}`); - console.log(` Editable cells: ${mappings.filter(m => m.isEditable).length}`); - console.log(` Readonly cells: ${mappings.filter(m => !m.isEditable).length}`); - - return mappings; -}, [tableData, columnsJSON, isFieldEditable, createCellStyle, ensureRowCapacity, ensureColumnCapacity, setupOptimizedListValidation, setOptimalColumnWidths, editableFields, getCellAddress, analyzeColumnGroups]); - -// 🛡️ 추가: readonly 상태 확인 헬퍼 함수 -const analyzeReadonlyStatus = React.useCallback((column: DataTableColumnJSON, rowData: GenericData) => { - const reasons: string[] = []; - - // 1. 컬럼 자체가 readonly인지 확인 - if (column.shi === true) { - reasons.push('Column marked as readonly (shi=true)'); - } - - // 2. 행 자체가 readonly인지 확인 - if (rowData.shi === true) { - reasons.push('Row marked as readonly (shi=true)'); - } - - // 3. editableFields에 포함되지 않은 경우 - if (!editableFields.includes(column.key) && column.key !== "TAG_NO" && column.key !== "TAG_DESC") { - reasons.push('Not in editable fields list'); - } - - // 4. 특수 필드 체크 - if (column.key === "TAG_NO" || column.key === "TAG_DESC") { - // TAG_NO와 TAG_DESC는 기본 편집 가능하지만 다른 조건들은 적용됨 - if (column.shi === true || rowData.shi === true) { - // 다른 readonly 조건이 있으면 적용 - } else { - return { isEditable: true, reasons: ['Default editable field'] }; - } - } - - const isEditable = reasons.length === 0; - - return { - isEditable, - reasons: isEditable ? ['Editable'] : reasons - }; -}, [editableFields]); - - - -// 🛡️ 수정된 시트 보호 및 이벤트 설정 함수 -const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { - console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); - - // 🔧 1단계: 먼저 시트 보호를 완전히 해제하고 강력한 잠금 해제 실행 - console.log('🔓 Step 1: Forcing unlock all editable cells...'); - activeSheet.options.isProtected = false; - - // 🔧 2단계: 모든 편집 가능한 셀에 대해 강제 잠금 해제 및 CellType 설정 - mappings.forEach((mapping, index) => { - if (!mapping.isEditable) return; - - const cellPos = parseCellAddress(mapping.cellAddress); - if (!cellPos) return; - - try { - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - - // 강제 잠금 해제 - cell.locked(false); - - // CellType 명시적 설정 - if (columnConfig?.type === "LIST" && columnConfig.options) { - // LIST 타입: ComboBox 설정 - const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBox.items(columnConfig.options); - comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); - console.log(`📋 ComboBox set for ${mapping.attId} at ${mapping.cellAddress}`); - } else { - // 다른 모든 타입: 기본 텍스트 편집기 설정 - const textCellType = new GC.Spread.Sheets.CellTypes.Text(); - activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); - console.log(`📝 Text editor set for ${mapping.attId} at ${mapping.cellAddress}`); - - // NUMBER 타입인 경우에만 validation 추가 (편집은 가능하게) - if (columnConfig?.type === "NUMBER") { - const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( - GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, - -999999999, 999999999, true - ); - numberValidator.showInputMessage(false); - numberValidator.showErrorMessage(false); - activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); - } - } - - // 편집 가능 스타일 명확히 표시 - const editableStyle = createCellStyle(true); - activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); - - console.log(`🔓 Forced unlock: ${mapping.attId} at ${mapping.cellAddress}`); - - } catch (error) { - console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); - } - }); - - // 🔧 3단계: 시트 보호 재설정 (편집 허용하는 설정으로) - activeSheet.options.isProtected = true; - activeSheet.options.protectionOptions = { - allowSelectLockedCells: true, - allowSelectUnlockedCells: true, - allowSort: false, - allowFilter: false, - allowEditObjects: true, // ✅ 편집 객체 허용 - allowResizeRows: false, - allowResizeColumns: false, - allowFormatCells: false, - allowInsertRows: false, - allowInsertColumns: false, - allowDeleteRows: false, - allowDeleteColumns: false - }; - - // 🔧 4단계: 편집 테스트 실행 - console.log('🧪 Testing cell editability...'); - const editableMapping = mappings.find(m => m.isEditable); - if (editableMapping) { - const cellPos = parseCellAddress(editableMapping.cellAddress); - if (cellPos) { - try { - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - const testValue = 'TEST_' + Math.random().toString(36).substr(2, 5); - const originalValue = cell.value(); - - console.log(`🧪 Testing ${editableMapping.attId} at ${editableMapping.cellAddress}`); - console.log(`🧪 Locked status: ${cell.locked()}`); - - // 직접 값 설정 테스트 - cell.value(testValue); - const newValue = cell.value(); - - if (newValue === testValue) { - console.log('✅ Cell edit test PASSED'); - cell.value(originalValue); // 원래 값 복원 - } else { - console.log(`❌ Cell edit test FAILED: ${newValue} !== ${testValue}`); - } - } catch (testError) { - console.error('❌ Edit test error:', testError); - } - } - } - - // 🎯 변경 감지 이벤트 - const changeEvents = [ - GC.Spread.Sheets.Events.CellChanged, - GC.Spread.Sheets.Events.ValueChanged, - GC.Spread.Sheets.Events.ClipboardPasted - ]; - - changeEvents.forEach(eventType => { - activeSheet.bind(eventType, () => { - console.log(`📝 ${eventType} detected`); - setHasChanges(true); - }); - }); - - // 🚫 편집 시작 권한 확인 (수정됨) - activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { - console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); - - // ✅ 정확한 매핑 찾기 (행/열 정확히 일치) - const exactMapping = mappings.find(m => { - const cellPos = parseCellAddress(m.cellAddress); - return cellPos && cellPos.row === info.row && cellPos.col === info.col; - }); - - if (!exactMapping) { - console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); - return; // 매핑이 없으면 허용 (템플릿 영역 밖) - } - - console.log(`📋 Found mapping: ${exactMapping.attId} at ${exactMapping.cellAddress}, isEditable: ${exactMapping.isEditable}`); - - // 🔍 추가 디버깅: 셀의 실제 상태 확인 - const cell = activeSheet.getCell(info.row, info.col); - const isLocked = cell.locked(); - const cellValue = cell.value(); - - console.log(`🔍 Cell state check:`, { - attId: exactMapping.attId, - isEditable: exactMapping.isEditable, - isLocked: isLocked, - currentValue: cellValue - }); - - // 🔧 추가: EditStarting 시점에서도 강제 잠금 해제 재시도 - if (exactMapping.isEditable && isLocked) { - console.log(`🔓 Re-unlocking cell during EditStarting...`); - cell.locked(false); - - // CellType도 재설정 - const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); - if (columnConfig?.type !== "LIST") { - const textCellType = new GC.Spread.Sheets.CellTypes.Text(); - activeSheet.setCellType(info.row, info.col, textCellType); - } - } - - // 기본 편집 권한 확인 - if (!exactMapping.isEditable) { - console.log(`🚫 Field ${exactMapping.attId} is not editable`); - toast.warning(`${exactMapping.attId} field is read-only`); - info.cancel = true; - return; - } - - // SPREAD_LIST 또는 GRD_LIST 개별 행 SHI 확인 - if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { - const dataRowIndex = exactMapping.dataRowIndex; - - console.log(`🔍 Checking SHI for data row ${dataRowIndex}`); - - if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { - const rowData = tableData[dataRowIndex]; - if (rowData?.shi === true) { - console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); - toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); - info.cancel = true; - return; - } - } else { - console.warn(`⚠️ Invalid dataRowIndex: ${dataRowIndex} (tableData.length: ${tableData.length})`); - } - } - - console.log(`✅ Edit allowed for ${exactMapping.attId}`); - }); - - // ✅ 편집 완료 검증 (기존 로직 유지) - activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { - console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); - - // ✅ 정확한 매핑 찾기 - const exactMapping = mappings.find(m => { - const cellPos = parseCellAddress(m.cellAddress); - return cellPos && cellPos.row === info.row && cellPos.col === info.col; - }); - - if (!exactMapping) { - console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - skipping validation`); - return; - } - - const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); - if (columnConfig) { - const cellValue = activeSheet.getValue(info.row, info.col); - console.log(`🔍 Validating ${exactMapping.attId}: "${cellValue}"`); - - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - const cell = activeSheet.getCell(info.row, info.col); - - if (errorMessage) { - console.log(`❌ Validation failed: ${errorMessage}`); - - // 🚨 에러 스타일 적용 (편집 가능 상태 유지) - const errorStyle = new GC.Spread.Sheets.Style(); - errorStyle.backColor = "#fef2f2"; - errorStyle.foreColor = "#dc2626"; - errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - - activeSheet.setStyle(info.row, info.col, errorStyle); - cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 - - toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}. Please correct the value.`, { duration: 5000 }); - } else { - console.log(`✅ Validation passed`); - - // ✅ 정상 스타일 복원 - const normalStyle = createCellStyle(exactMapping.isEditable); - activeSheet.setStyle(info.row, info.col, normalStyle); - cell.locked(!exactMapping.isEditable); - } - } - - // 🔄 변경 상태 업데이트 - setHasChanges(true); - }); - - // 🔧 5단계: 설정 완료 후 1초 뒤에 추가 잠금 해제 실행 (안전장치) - setTimeout(() => { - console.log('🔄 Running safety unlock after 1 second...'); - mappings.forEach(mapping => { - if (!mapping.isEditable) return; - - const cellPos = parseCellAddress(mapping.cellAddress); - if (!cellPos) return; - - try { - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - if (cell.locked()) { - console.log(`🔓 Safety unlock: ${mapping.attId}`); - cell.locked(false); - } - } catch (error) { - console.error(`❌ Safety unlock error for ${mapping.cellAddress}:`, error); - } - }); - }, 1000); - - console.log(`🛡️ Protection and events configured for ${mappings.length} mappings`); - console.log(`🔓 Editable cells: ${mappings.filter(m => m.isEditable).length}`); - console.log(`🔒 Readonly cells: ${mappings.filter(m => !m.isEditable).length}`); -}, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); - -// 🔧 셀 타입 및 편집기 설정 함수 (initSpread 함수 내부에 추가) -const setupCellTypeAndEditor = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, columnConfig: DataTableColumnJSON, isEditable: boolean, rowCount: number = 1) => { - console.log(`🔧 Setting up cell type for ${columnConfig.key} (${columnConfig.type}) at [${cellPos.row}, ${cellPos.col}]`); - - try { - // 편집 가능한 셀에만 적절한 셀 타입 설정 - if (isEditable) { - for (let i = 0; i < rowCount; i++) { - const targetRow = cellPos.row + i; - const cell = activeSheet.getCell(targetRow, cellPos.col); - - // 셀 잠금 해제 - cell.locked(false); - - switch (columnConfig.type) { - case "LIST": - // 드롭다운은 기존 setupOptimizedListValidation 함수에서 처리 - break; - - case "NUMBER": - // 숫자 입력용 셀 타입 설정 - const numberCellType = new GC.Spread.Sheets.CellTypes.Text(); - activeSheet.setCellType(targetRow, cellPos.col, numberCellType); - - // 숫자 validation 설정 (선택사항) - const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( - GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, - -999999999, 999999999, true - ); - numberValidator.showInputMessage(true); - numberValidator.inputTitle("Number Input"); - numberValidator.inputMessage("Please enter a valid number"); - activeSheet.setDataValidator(targetRow, cellPos.col, numberValidator); - break; - - case "STRING": - default: - // 기본 텍스트 입력용 셀 타입 설정 - const textCellType = new GC.Spread.Sheets.CellTypes.Text(); - activeSheet.setCellType(targetRow, cellPos.col, textCellType); - break; - } - - console.log(`✅ Cell type set for [${targetRow}, ${cellPos.col}]: ${columnConfig.type}`); - } - } else { - // 읽기 전용 셀 설정 - for (let i = 0; i < rowCount; i++) { - const targetRow = cellPos.row + i; - const cell = activeSheet.getCell(targetRow, cellPos.col); - cell.locked(true); - } - } - - } catch (error) { - console.error(`❌ Error setting cell type for ${columnConfig.key}:`, error); - } -}, []); - - // ═══════════════════════════════════════════════════════════════════════════════ - // 🏗️ 메인 SpreadSheets 초기화 함수 - // ═══════════════════════════════════════════════════════════════════════════════ - -// 🛡️ 수정된 initSpread 함수 - activeSheet 참조 문제 해결 -const initSpread = React.useCallback((spread: any, template?: TemplateItem) => { - const workingTemplate = template || selectedTemplate; - if (!spread || !workingTemplate) { - console.error('❌ Invalid spread or template in initSpread'); - return; - } - - try { - // 🔄 초기 설정 - setCurrentSpread(spread); - setHasChanges(false); - setValidationErrors([]); - - // 성능을 위한 렌더링 일시 중단 - spread.suspendPaint(); - - try { - // ⚠️ 초기 activeSheet 가져오기 - let activeSheet = getSafeActiveSheet(spread, 'initSpread-initial'); - if (!activeSheet) { - throw new Error('Failed to get initial activeSheet'); - } - - // 시트 보호 해제 (편집을 위해) - activeSheet.options.isProtected = false; - - let mappings: CellMapping[] = []; - - // 🆕 GRD_LIST 처리 - if (templateType === 'GRD_LIST' && workingTemplate.GRD_LST_SETUP) { - console.log('🏗️ Processing GRD_LIST template'); - - // 기본 워크북 설정 - spread.clearSheets(); - spread.addSheet(0); - const sheet = spread.getSheet(0); - sheet.name('Data'); - spread.setActiveSheet('Data'); - - // 동적 테이블 생성 - mappings = createGrdListTable(sheet, workingTemplate); - - } else { - // 🔍 SPREAD_LIST 및 SPREAD_ITEM 처리 - let contentJson = null; - let dataSheets = null; - - // SPR_LST_SETUP.CONTENT 우선 사용 - if (workingTemplate.SPR_LST_SETUP?.CONTENT) { - contentJson = workingTemplate.SPR_LST_SETUP.CONTENT; - dataSheets = workingTemplate.SPR_LST_SETUP.DATA_SHEETS; - console.log('✅ Using SPR_LST_SETUP for template:', workingTemplate.NAME); - } - // SPR_ITM_LST_SETUP.CONTENT 대안 사용 - else if (workingTemplate.SPR_ITM_LST_SETUP?.CONTENT) { - contentJson = workingTemplate.SPR_ITM_LST_SETUP.CONTENT; - dataSheets = workingTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS; - console.log('✅ Using SPR_ITM_LST_SETUP for template:', workingTemplate.NAME); - } - - if (!contentJson) { - throw new Error(`No template content found for ${workingTemplate.NAME}`); - } - - if (!dataSheets || dataSheets.length === 0) { - throw new Error(`No data sheets configuration found for ${workingTemplate.NAME}`); - } - - console.log('🔍 Template info:', { - templateName: workingTemplate.NAME, - templateType: templateType, - dataSheetsCount: dataSheets.length, - hasSelectedRow: !!selectedRow, - tableDataLength: tableData.length - }); - - // 🏗️ SpreadSheets 템플릿 로드 - const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; - - console.log('📥 Loading template JSON...'); - spread.fromJSON(jsonData); - console.log('✅ Template JSON loaded'); - - // ⚠️ 중요: 템플릿 로드 후 activeSheet 다시 가져오기 - activeSheet = getSafeActiveSheet(spread, 'initSpread-after-fromJSON'); - if (!activeSheet) { - throw new Error('ActiveSheet became null after loading template'); - } - - console.log('🔍 Active sheet after template load:', { - name: activeSheet.name?.() || 'unnamed', - rowCount: activeSheet.getRowCount(), - colCount: activeSheet.getColumnCount() - }); - - // 시트 보호 다시 해제 (템플릿 로드 후 다시 설정될 수 있음) - activeSheet.options.isProtected = false; - - // 📊 데이터 매핑 및 로딩 처리 - console.log(`🔄 Processing ${dataSheets.length} data sheets`); - - dataSheets.forEach((dataSheet, sheetIndex) => { - console.log(`📋 Processing data sheet ${sheetIndex}:`, { - sheetName: dataSheet.SHEET_NAME, - mappingCount: dataSheet.MAP_CELL_ATT?.length || 0 - }); - - if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { - dataSheet.MAP_CELL_ATT.forEach((mapping, mappingIndex) => { - const { ATT_ID, IN } = mapping; - - if (!ATT_ID || !IN || IN.trim() === "") { - console.warn(`⚠️ Invalid mapping: ATT_ID=${ATT_ID}, IN=${IN}`); - return; - } - - const cellPos = parseCellAddress(IN); - if (!cellPos) { - console.warn(`⚠️ Invalid cell address: ${IN}`); - return; - } - - const columnConfig = columnsJSON.find(col => col.key === ATT_ID); - - // 🎯 템플릿 타입별 데이터 처리 - if (templateType === 'SPREAD_ITEM' && selectedRow) { - console.log(`📝 Processing SPREAD_ITEM for ${ATT_ID}`); - - const isEditable = isFieldEditable(ATT_ID); - const value = selectedRow[ATT_ID]; - - // 매핑 정보 저장 - mappings.push({ - attId: ATT_ID, - cellAddress: IN, - isEditable: isEditable, - dataRowIndex: 0 - }); - - // ⚠️ 안전한 셀 참조 및 값 설정 - try { - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - console.log(`🔄 Setting SPREAD_ITEM cell [${cellPos.row}, ${cellPos.col}] ${ATT_ID}: "${value}"`); - - // 🔧 새로 추가: 셀 타입 및 편집기 설정 - setupCellTypeAndEditor(activeSheet, cellPos, columnConfig, isEditable, 1); - - // 값 설정 - cell.value(value ?? null); - - // 스타일 설정 - const style = createCellStyle(isEditable); - activeSheet.setStyle(cellPos.row, cellPos.col, style); - - // LIST 타입 드롭다운 설정 (기존 코드 유지) - if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); - } - - console.log(`✅ SPREAD_ITEM cell set successfully`); - } catch (cellError) { - console.error(`❌ Error setting SPREAD_ITEM cell:`, cellError); - } - } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { - console.log(`📊 Processing SPREAD_LIST for ${ATT_ID} with ${tableData.length} rows`); - - // 🚀 행 확장 - 안전한 방법으로 - const requiredRows = cellPos.row + tableData.length; - console.log(`🚀 Ensuring ${requiredRows} rows for SPREAD_LIST`); - - // ⚠️ activeSheet 유효성 재검증 - const currentActiveSheet = getSafeActiveSheet(spread, 'ensureRowCapacity'); - if (!currentActiveSheet) { - console.error(`❌ ActiveSheet is null before ensureRowCapacity`); - return; - } - - if (!ensureRowCapacity(currentActiveSheet, requiredRows)) { - console.error(`❌ Failed to ensure row capacity for ${requiredRows} rows`); - return; - } - - // activeSheet 참조 업데이트 - activeSheet = currentActiveSheet; - - // 매핑 생성 - tableData.forEach((rowData, index) => { - const targetRow = cellPos.row + index; - const targetCellAddress = getCellAddress(targetRow, cellPos.col); - const cellEditable = isFieldEditable(ATT_ID, rowData); - - mappings.push({ - attId: ATT_ID, - cellAddress: targetCellAddress, - isEditable: cellEditable, - dataRowIndex: index - }); - }); - - // LIST 타입 드롭다운 설정 - if (columnConfig?.type === "LIST" && columnConfig.options) { - const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); - if (hasEditableRows) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); - } - } -// 개별 셀 데이터 및 스타일 설정 -tableData.forEach((rowData, index) => { - const targetRow = cellPos.row + index; - - try { - const cell = activeSheet.getCell(targetRow, cellPos.col); - const value = rowData[ATT_ID]; - const cellEditable = isFieldEditable(ATT_ID, rowData); - - console.log(`🔄 Setting SPREAD_LIST Row ${index} ${ATT_ID}: "${value}"`); - - // 🔧 새로 추가: 각 셀에 대한 타입 및 편집기 설정 - setupCellTypeAndEditor(activeSheet, { row: targetRow, col: cellPos.col }, columnConfig, cellEditable, 1); - - // 값 설정 - cell.value(value ?? null); - - // 스타일 설정 - const style = createCellStyle(cellEditable); - activeSheet.setStyle(targetRow, cellPos.col, style); - - } catch (cellError) { - console.error(`❌ Error setting SPREAD_LIST cell Row ${index}:`, cellError); - } -}); - - - console.log(`✅ SPREAD_LIST processing completed for ${ATT_ID}`); - } - }); - } - }); - } - - // 💾 매핑 정보 저장 및 이벤트 설정 - setCellMappings(mappings); - - // ⚠️ 최종 activeSheet 재확인 후 이벤트 설정 - const finalActiveSheet = getSafeActiveSheet(spread, 'setupSheetProtectionAndEvents'); - if (finalActiveSheet) { - setupSheetProtectionAndEvents(finalActiveSheet, mappings); - } else { - console.error('❌ Failed to get activeSheet for events setup'); - } - - console.log(`✅ Template initialization completed with ${mappings.length} mappings`); - - } finally { - // 렌더링 재개 - spread.resumePaint(); - } - - } catch (error) { - console.error('❌ Error initializing spread:', error); - // toast.error(`Failed to load template: ${error.message}`); - if (spread?.resumePaint) { - spread.resumePaint(); - } - } -}, [selectedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON, createCellStyle, setupOptimizedListValidation, ensureRowCapacity, setupSheetProtectionAndEvents, createGrdListTable, getCellAddress, getSafeActiveSheet, validateActiveSheet]); - // 변경사항 저장 함수 - const handleSaveChanges = React.useCallback(async () => { - if (!currentSpread || !hasChanges) { - toast.info("No changes to save"); - return; - } - - // 저장 전 데이터 검증 - const errors = validateAllData(); - if (errors.length > 0) { - toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); - return; - } - - try { - setIsPending(true); - - const activeSheet = currentSpread.getActiveSheet(); - - if (templateType === 'SPREAD_ITEM' && selectedRow) { - // 단일 행 저장 - const dataToSave = { ...selectedRow }; - - cellMappings.forEach(mapping => { - if (mapping.isEditable) { - const cellPos = parseCellAddress(mapping.cellAddress); - if (cellPos) { - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - dataToSave[mapping.attId] = cellValue; - } - } - }); - - dataToSave.TAG_NO = selectedRow.TAG_NO; - - const { success, message } = await updateFormDataInDB( - formCode, - contractItemId, - dataToSave - ); - - if (!success) { - toast.error(message); - return; - } - - toast.success("Changes saved successfully!"); - onUpdateSuccess?.(dataToSave); - - } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { - // 복수 행 저장 (SPREAD_LIST와 GRD_LIST 동일 처리) - const updatedRows: GenericData[] = []; - let saveCount = 0; - - for (let i = 0; i < tableData.length; i++) { - const originalRow = tableData[i]; - const dataToSave = { ...originalRow }; - let hasRowChanges = false; - - // 각 매핑에 대해 해당 행의 값 확인 - cellMappings.forEach(mapping => { - if (mapping.dataRowIndex === i && mapping.isEditable) { - const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - const isColumnEditable = columnConfig?.shi !== true; - const isRowEditable = originalRow.shi !== true; - - if (isColumnEditable && isRowEditable) { - const cellPos = parseCellAddress(mapping.cellAddress); - if (cellPos) { - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - - // 값이 변경되었는지 확인 - if (cellValue !== originalRow[mapping.attId]) { - dataToSave[mapping.attId] = cellValue; - hasRowChanges = true; - } - } - } - } - }); - - // 변경사항이 있는 행만 저장 - if (hasRowChanges) { - dataToSave.TAG_NO = originalRow.TAG_NO; // TAG_NO는 절대 변경되지 않도록 - - const { success } = await updateFormDataInDB( - formCode, - contractItemId, - dataToSave - ); - - if (success) { - updatedRows.push(dataToSave); - saveCount++; - } - } else { - updatedRows.push(originalRow); // 변경사항이 없으면 원본 유지 - } - } - - if (saveCount > 0) { - toast.success(`${saveCount} rows saved successfully!`); - onUpdateSuccess?.(updatedRows); - } else { - toast.info("No changes to save"); - } - } - - setHasChanges(false); - setValidationErrors([]); - - } catch (error) { - console.error("Error saving changes:", error); - toast.error("An unexpected error occurred while saving"); - } finally { - setIsPending(false); - } - }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]); - - if (!isOpen) return null; - - // 데이터 유효성 검사 - const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; - const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; - - - return ( - - - - SEDP Template - {formCode} - -
- {/* 템플릿 선택 */} - {availableTemplates.length > 1 && ( -
- Template: - -
- )} - - {/* 템플릿 정보 */} - {selectedTemplate && ( -
- - Template Type: { - templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : - templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : - 'Grid List View (GRD_LIST)' - } - - {templateType === 'SPREAD_ITEM' && selectedRow && ( - • Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'} - )} - {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( - • {dataCount} rows - )} - {hasChanges && ( - - • Unsaved changes - - )} - {validationErrors.length > 0 && ( - - - {validationErrors.length} validation errors - - )} -
- )} - - {/* 범례 */} -
- - - Editable fields - - - - Read-only fields - - - - Validation errors - - {cellMappings.length > 0 && ( - - {editableFieldsCount} of {cellMappings.length} fields editable - - )} -
-
-
-
- - {/* SpreadSheets 컴포넌트 영역 */} -
- {selectedTemplate && isClient && isDataValid ? ( - - ) : ( -
- {!isClient ? ( - <> - - Loading... - - ) : !selectedTemplate ? ( - "No template available" - ) : !isDataValid ? ( - `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` - ) : ( - "Template not ready" - )} -
- )} -
- - -
- - - {hasChanges && ( - - )} - - {validationErrors.length > 0 && ( - - )} -
-
-
-
- ); -} \ No newline at end of file diff --git a/components/form-data/spreadJS-dialog copy 4.tsx b/components/form-data/spreadJS-dialog copy 4.tsx deleted file mode 100644 index 14f4d3ea..00000000 --- a/components/form-data/spreadJS-dialog copy 4.tsx +++ /dev/null @@ -1,1491 +0,0 @@ -"use client"; - -import * as React from "react"; -import dynamic from "next/dynamic"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { GenericData } from "./export-excel-form"; -import * as GC from "@mescius/spread-sheets"; -import { toast } from "sonner"; -import { updateFormDataInDB } from "@/lib/forms/services"; -import { Loader, Save, AlertTriangle } from "lucide-react"; -import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; -import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; - -const SpreadSheets = dynamic( - () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), - { - ssr: false, - loading: () => ( -
- - Loading SpreadSheets... -
- ) - } -); - -if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { - GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; -} - -interface TemplateItem { - TMPL_ID: string; - NAME: string; - TMPL_TYPE: string; - SPR_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array; - CONTENT?: string; - DATA_SHEETS: Array<{ - SHEET_NAME: string; - REG_TYPE_ID: string; - MAP_CELL_ATT: Array<{ - ATT_ID: string; - IN: string; - }>; - }>; - }; - GRD_LST_SETUP: { - REG_TYPE_ID: string; - SPR_ITM_IDS: Array; - ATTS: Array<{}>; - }; - SPR_ITM_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array; - CONTENT?: string; - DATA_SHEETS: Array<{ - SHEET_NAME: string; - REG_TYPE_ID: string; - MAP_CELL_ATT: Array<{ - ATT_ID: string; - IN: string; - }>; - }>; - }; -} - -interface ValidationError { - cellAddress: string; - attId: string; - value: any; - expectedType: ColumnType; - message: string; -} - -interface CellMapping { - attId: string; - cellAddress: string; - isEditable: boolean; - dataRowIndex?: number; -} - -interface TemplateViewDialogProps { - isOpen: boolean; - onClose: () => void; - templateData: TemplateItem[] | any; - selectedRow?: GenericData; - tableData?: GenericData[]; - formCode: string; - columnsJSON: DataTableColumnJSON[] - contractItemId: number; - editableFieldsMap?: Map; - onUpdateSuccess?: (updatedValues: Record | GenericData[]) => void; -} - -// 🚀 로딩 프로그레스 컴포넌트 -interface LoadingProgressProps { - phase: string; - progress: number; - total: number; - isVisible: boolean; -} - -const LoadingProgress: React.FC = ({ phase, progress, total, isVisible }) => { - const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; - - if (!isVisible) return null; - - return ( -
-
-
- - Loading Template -
- -
-
{phase}
-
-
-
-
- {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) -
-
-
-
- ); -}; - -export function TemplateViewDialog({ - isOpen, - onClose, - templateData, - selectedRow, - tableData = [], - formCode, - contractItemId, - columnsJSON, - editableFieldsMap = new Map(), - onUpdateSuccess -}: TemplateViewDialogProps) { - const [hostStyle, setHostStyle] = React.useState({ - width: '100%', - height: '100%' - }); - - const [isPending, setIsPending] = React.useState(false); - const [hasChanges, setHasChanges] = React.useState(false); - const [currentSpread, setCurrentSpread] = React.useState(null); - const [cellMappings, setCellMappings] = React.useState([]); - const [isClient, setIsClient] = React.useState(false); - const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); - const [validationErrors, setValidationErrors] = React.useState([]); - const [selectedTemplateId, setSelectedTemplateId] = React.useState(""); - const [availableTemplates, setAvailableTemplates] = React.useState([]); - - // 🆕 로딩 상태 추가 - const [loadingProgress, setLoadingProgress] = React.useState<{ - phase: string; - progress: number; - total: number; - } | null>(null); - const [isInitializing, setIsInitializing] = React.useState(false); - - // 🔄 진행상황 업데이트 함수 - const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { - setLoadingProgress({ phase, progress, total }); - }, []); - - const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { - if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { - return 'SPREAD_LIST'; - } - if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { - return 'SPREAD_ITEM'; - } - if (template.GRD_LST_SETUP && columnsJSON.length > 0) { - return 'GRD_LIST'; - } - return null; - }, [columnsJSON]); - - const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { - return determineTemplateType(template) !== null; - }, [determineTemplateType]); - - React.useEffect(() => { - setIsClient(true); - }, []); - - React.useEffect(() => { - if (!templateData) return; - - let templates: TemplateItem[]; - if (Array.isArray(templateData)) { - templates = templateData as TemplateItem[]; - } else { - templates = [templateData as TemplateItem]; - } - - const validTemplates = templates.filter(isValidTemplate); - setAvailableTemplates(validTemplates); - - if (validTemplates.length > 0 && !selectedTemplateId) { - const firstTemplate = validTemplates[0]; - const templateTypeToSet = determineTemplateType(firstTemplate); - setSelectedTemplateId(firstTemplate.TMPL_ID); - setTemplateType(templateTypeToSet); - } - }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); - - const handleTemplateChange = (templateId: string) => { - const template = availableTemplates.find(t => t.TMPL_ID === templateId); - if (template) { - const templateTypeToSet = determineTemplateType(template); - setSelectedTemplateId(templateId); - setTemplateType(templateTypeToSet); - setHasChanges(false); - setValidationErrors([]); - - if (currentSpread && template) { - initSpread(currentSpread, template); - } - } - }; - - const selectedTemplate = React.useMemo(() => { - return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); - }, [availableTemplates, selectedTemplateId]); - - const editableFields = React.useMemo(() => { - // SPREAD_ITEM의 경우에만 전역 editableFields 사용 - if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { - if (!editableFieldsMap.has(selectedRow.TAG_NO)) { - return []; - } - return editableFieldsMap.get(selectedRow.TAG_NO) || []; - } - - // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 - return []; - }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); - - -const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { - const columnConfig = columnsJSON.find(col => col.key === attId); - if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { - return false; - } - - if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { - return false; - } - - if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 - if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { - return false; - } - - const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; - if (!rowEditableFields.includes(attId)) { - return false; - } - - if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { - return false; - } - return true; - } - - // SPREAD_ITEM의 경우 기존 로직 유지 - if (templateType === 'SPREAD_ITEM') { - return editableFields.includes(attId); - } - - return true; -}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 - -const editableFieldsCount = React.useMemo(() => { - if (templateType === 'SPREAD_ITEM') { - // SPREAD_ITEM의 경우 기존 로직 유지 - return cellMappings.filter(m => m.isEditable).length; - } - - if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - // 각 행별로 편집 가능한 필드 수를 계산 - let totalEditableCount = 0; - - tableData.forEach((rowData, rowIndex) => { - cellMappings.forEach(mapping => { - if (mapping.dataRowIndex === rowIndex) { - if (isFieldEditable(mapping.attId, rowData)) { - totalEditableCount++; - } - } - }); - }); - - return totalEditableCount; - } - - return cellMappings.filter(m => m.isEditable).length; -}, [cellMappings, templateType, tableData, isFieldEditable]); - - // 🚀 배치 처리 함수들 - const setBatchValues = React.useCallback(( - activeSheet: any, - valuesToSet: Array<{row: number, col: number, value: any}> - ) => { - console.log(`🚀 Setting ${valuesToSet.length} values in batch`); - - const columnGroups = new Map>(); - - valuesToSet.forEach(({row, col, value}) => { - if (!columnGroups.has(col)) { - columnGroups.set(col, []); - } - columnGroups.get(col)!.push({row, value}); - }); - - columnGroups.forEach((values, col) => { - values.sort((a, b) => a.row - b.row); - - let start = 0; - while (start < values.length) { - let end = start; - while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { - end++; - } - - const rangeValues = values.slice(start, end + 1).map(v => v.value); - const startRow = values[start].row; - - try { - if (rangeValues.length === 1) { - activeSheet.setValue(startRow, col, rangeValues[0]); - } else { - const dataArray = rangeValues.map(v => [v]); - activeSheet.setArray(startRow, col, dataArray); - } - } catch (error) { - for (let i = start; i <= end; i++) { - try { - activeSheet.setValue(values[i].row, col, values[i].value); - } catch (cellError) { - console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); - } - } - } - - start = end + 1; - } - }); - }, []); - - const createCellStyle = React.useCallback((isEditable: boolean) => { - const style = new GC.Spread.Sheets.Style(); - if (isEditable) { - style.backColor = "#bbf7d0"; - } else { - style.backColor = "#e5e7eb"; - style.foreColor = "#4b5563"; - } - return style; - }, []); - - const setBatchStyles = React.useCallback(( - activeSheet: any, - stylesToSet: Array<{row: number, col: number, isEditable: boolean}> - ) => { - console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); - - const editableStyle = createCellStyle(true); - const readonlyStyle = createCellStyle(false); - - // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) - stylesToSet.forEach(({row, col, isEditable}) => { - try { - const cell = activeSheet.getCell(row, col); - const style = isEditable ? editableStyle : readonlyStyle; - - activeSheet.setStyle(row, col, style); - cell.locked(!isEditable); // 편집 가능하면 잠금 해제 - - // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 - if (isEditable) { - const textCellType = new GC.Spread.Sheets.CellTypes.Text(); - activeSheet.setCellType(row, col, textCellType); - } - } catch (error) { - console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); - } - }); - }, [createCellStyle]); - - const parseCellAddress = (address: string): { row: number, col: number } | null => { - if (!address || address.trim() === "") return null; - - const match = address.match(/^([A-Z]+)(\d+)$/); - if (!match) return null; - - const [, colStr, rowStr] = match; - - let col = 0; - for (let i = 0; i < colStr.length; i++) { - col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); - } - col -= 1; - - const row = parseInt(rowStr) - 1; - return { row, col }; - }; - - const getCellAddress = (row: number, col: number): string => { - let colStr = ''; - let colNum = col; - while (colNum >= 0) { - colStr = String.fromCharCode((colNum % 26) + 65) + colStr; - colNum = Math.floor(colNum / 26) - 1; - } - return colStr + (row + 1); - }; - - const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { - if (value === undefined || value === null || value === "") { - return null; - } - - switch (columnType) { - case "NUMBER": - if (isNaN(Number(value))) { - return "Value must be a valid number"; - } - break; - case "LIST": - if (options && !options.includes(String(value))) { - return `Value must be one of: ${options.join(", ")}`; - } - break; - case "STRING": - break; - default: - break; - } - - return null; - }; - - const validateAllData = React.useCallback(() => { - if (!currentSpread || !selectedTemplate) return []; - - const activeSheet = currentSpread.getActiveSheet(); - const errors: ValidationError[] = []; - - cellMappings.forEach(mapping => { - const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - if (!columnConfig) return; - - const cellPos = parseCellAddress(mapping.cellAddress); - if (!cellPos) return; - - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - - if (errorMessage) { - errors.push({ - cellAddress: mapping.cellAddress, - attId: mapping.attId, - value: cellValue, - expectedType: columnConfig.type, - message: errorMessage - }); - } - }); - - setValidationErrors(errors); - return errors; - }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); - - - - const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { - try { - console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); - - const safeOptions = options - .filter(opt => opt !== null && opt !== undefined && opt !== '') - .map(opt => String(opt).trim()) - .filter(opt => opt.length > 0) - .filter((opt, index, arr) => arr.indexOf(opt) === index) - .slice(0, 20); - - if (safeOptions.length === 0) { - console.warn(`⚠️ No valid options found, skipping`); - return; - } - - const optionsString = safeOptions.join(','); - - for (let i = 0; i < rowCount; i++) { - try { - const targetRow = cellPos.row + i; - - // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성 - const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBoxCellType.items(safeOptions); - comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - - // 🔧 DataValidation 설정 - const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); - cellValidator.showInputMessage(false); - cellValidator.showErrorMessage(false); - - // ComboBox와 Validator 적용 - activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); - activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); - - // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 - const cell = activeSheet.getCell(targetRow, cellPos.col); - cell.locked(false); - - console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); - - } catch (cellError) { - console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); - } - } - - console.log(`✅ Dropdown setup completed for ${rowCount} cells`); - - } catch (error) { - console.error('❌ Dropdown setup failed:', error); - } - }, []); - - const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { - if (!spread) return null; - - try { - let activeSheet = spread.getActiveSheet(); - if (!activeSheet) { - const sheetCount = spread.getSheetCount(); - if (sheetCount > 0) { - activeSheet = spread.getSheet(0); - if (activeSheet) { - spread.setActiveSheetIndex(0); - } - } - } - return activeSheet; - } catch (error) { - console.error(`❌ Error getting activeSheet in ${functionName}:`, error); - return null; - } - }, []); - - const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { - try { - if (!activeSheet) return false; - - const currentRowCount = activeSheet.getRowCount(); - if (requiredRowCount > currentRowCount) { - const newRowCount = requiredRowCount + 10; - activeSheet.setRowCount(newRowCount); - } - return true; - } catch (error) { - console.error('❌ Error in ensureRowCapacity:', error); - return false; - } - }, []); - - const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { - try { - if (!activeSheet) return false; - - const currentColumnCount = activeSheet.getColumnCount(); - if (requiredColumnCount > currentColumnCount) { - const newColumnCount = requiredColumnCount + 10; - activeSheet.setColumnCount(newColumnCount); - } - return true; - } catch (error) { - console.error('❌ Error in ensureColumnCapacity:', error); - return false; - } - }, []); - - const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { - columns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; - activeSheet.setColumnWidth(targetCol, optimalWidth); - }); - }, []); - - // 🚀 최적화된 GRD_LIST 생성 - // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) -const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { - console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); - - const visibleColumns = columnsJSON - .filter(col => col.hidden !== true) - .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); - - if (visibleColumns.length === 0) return []; - - const startCol = 1; - const dataStartRow = 1; - const mappings: CellMapping[] = []; - - ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); - ensureRowCapacity(activeSheet, dataStartRow + tableData.length); - - // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) - const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); - let freezeColumnCount = 0; - - if (tagDescColumnIndex !== -1) { - // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) - freezeColumnCount = startCol + tagDescColumnIndex + 1; - console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); - } else { - // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) - const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); - if (tagNoColumnIndex !== -1) { - freezeColumnCount = startCol + tagNoColumnIndex + 1; - console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); - } - } - - // 헤더 생성 - const headerStyle = new GC.Spread.Sheets.Style(); - headerStyle.backColor = "#3b82f6"; - headerStyle.foreColor = "#ffffff"; - headerStyle.font = "bold 12px Arial"; - headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; - - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - const cell = activeSheet.getCell(0, targetCol); - cell.value(column.label); - cell.locked(true); - activeSheet.setStyle(0, targetCol, headerStyle); - }); - - // 🚀 데이터 배치 처리 준비 - const allValues: Array<{row: number, col: number, value: any}> = []; - const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; - - // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) - const dropdownConfigs: Array<{ - startRow: number; - col: number; - rowCount: number; - options: string[]; - editableRows: number[]; // 편집 가능한 행만 추적 - }> = []; - - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - - // 드롭다운 설정을 위한 편집 가능한 행 찾기 - if (column.type === "LIST" && column.options) { - const editableRows: number[] = []; - tableData.forEach((rowData, rowIndex) => { - if (isFieldEditable(column.key, rowData)) { // rowData 전달 - editableRows.push(dataStartRow + rowIndex); - } - }); - - if (editableRows.length > 0) { - dropdownConfigs.push({ - startRow: dataStartRow, - col: targetCol, - rowCount: tableData.length, - options: column.options, - editableRows: editableRows - }); - } - } - - tableData.forEach((rowData, rowIndex) => { - const targetRow = dataStartRow + rowIndex; - const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 - const value = rowData[column.key]; - - mappings.push({ - attId: column.key, - cellAddress: getCellAddress(targetRow, targetCol), - isEditable: cellEditable, - dataRowIndex: rowIndex - }); - - allValues.push({ - row: targetRow, - col: targetCol, - value: value ?? null - }); - - allStyles.push({ - row: targetRow, - col: targetCol, - isEditable: cellEditable - }); - }); - }); - - // 🚀 배치로 값과 스타일 설정 - setBatchValues(activeSheet, allValues); - setBatchStyles(activeSheet, allStyles); - - // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) - dropdownConfigs.forEach(({ col, options, editableRows }) => { - try { - console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); - - const safeOptions = options - .filter(opt => opt !== null && opt !== undefined && opt !== '') - .map(opt => String(opt).trim()) - .filter(opt => opt.length > 0) - .slice(0, 20); - - if (safeOptions.length === 0) return; - - // 편집 가능한 행에만 드롭다운 적용 - editableRows.forEach(targetRow => { - try { - const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBoxCellType.items(safeOptions); - comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - - const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); - cellValidator.showInputMessage(false); - cellValidator.showErrorMessage(false); - - activeSheet.setCellType(targetRow, col, comboBoxCellType); - activeSheet.setDataValidator(targetRow, col, cellValidator); - - // 🚀 편집 권한 명시적 설정 - const cell = activeSheet.getCell(targetRow, col); - cell.locked(false); - - console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); - } catch (cellError) { - console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); - } - }); - } catch (error) { - console.error(`❌ Dropdown config failed for column ${col}:`, error); - } - }); - - // 🧊 틀고정 설정 - if (freezeColumnCount > 0) { - try { - activeSheet.frozenColumnCount(freezeColumnCount); - activeSheet.frozenRowCount(1); // 헤더 행도 고정 - - console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); - - // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) - for (let col = 0; col < freezeColumnCount; col++) { - for (let row = 0; row <= tableData.length; row++) { - try { - const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); - - if (row === 0) { - // 헤더는 기존 스타일 유지 - continue; - } else { - // 데이터 셀에 고정 구분선 추가 - if (col === freezeColumnCount - 1) { - currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); - activeSheet.setStyle(row, col, currentStyle); - } - } - } catch (styleError) { - console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); - } - } - } - } catch (freezeError) { - console.error('❌ Failed to apply freeze:', freezeError); - } - } - - setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); - - console.log(`✅ Optimized GRD_LIST created with freeze:`); - console.log(` - Total mappings: ${mappings.length}`); - console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); - console.log(` - Dropdown configs: ${dropdownConfigs.length}`); - console.log(` - Frozen columns: ${freezeColumnCount}`); - - return mappings; -}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); - - const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { - console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); - - // 🔧 시트 보호 완전 해제 후 편집 권한 설정 - activeSheet.options.isProtected = false; - - // 🔧 편집 가능한 셀들을 위한 강화된 설정 - mappings.forEach((mapping) => { - const cellPos = parseCellAddress(mapping.cellAddress); - if (!cellPos) return; - - try { - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - - if (mapping.isEditable) { - // 🚀 편집 가능한 셀 설정 강화 - cell.locked(false); - - if (columnConfig?.type === "LIST" && columnConfig.options) { - // LIST 타입: 새 ComboBox 인스턴스 생성 - const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBox.items(columnConfig.options); - comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); - - // DataValidation도 추가 - const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); - activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); - } else if (columnConfig?.type === "NUMBER") { - // NUMBER 타입: 숫자 입력 허용 - const textCellType = new GC.Spread.Sheets.CellTypes.Text(); - activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); - - // 숫자 validation 추가 (에러 메시지 없이) - const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( - GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, - -999999999, 999999999, true - ); - numberValidator.showInputMessage(false); - numberValidator.showErrorMessage(false); - activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); - } else { - // 기본 TEXT 타입: 자유 텍스트 입력 - const textCellType = new GC.Spread.Sheets.CellTypes.Text(); - activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); - } - - // 편집 가능 스타일 재적용 - const editableStyle = createCellStyle(true); - activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); - - console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); - } else { - // 읽기 전용 셀 - cell.locked(true); - const readonlyStyle = createCellStyle(false); - activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); - } - } catch (error) { - console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); - } - }); - - // 🛡️ 시트 보호 재설정 (편집 허용 모드로) - activeSheet.options.isProtected = false; - activeSheet.options.protectionOptions = { - allowSelectLockedCells: true, - allowSelectUnlockedCells: true, - allowSort: false, - allowFilter: false, - allowEditObjects: true, // ✅ 편집 객체 허용 - allowResizeRows: false, - allowResizeColumns: false, - allowFormatCells: false, - allowInsertRows: false, - allowInsertColumns: false, - allowDeleteRows: false, - allowDeleteColumns: false - }; - - // 🎯 변경 감지 이벤트 - const changeEvents = [ - GC.Spread.Sheets.Events.CellChanged, - GC.Spread.Sheets.Events.ValueChanged, - GC.Spread.Sheets.Events.ClipboardPasted - ]; - - changeEvents.forEach(eventType => { - activeSheet.bind(eventType, () => { - console.log(`📝 ${eventType} detected`); - setHasChanges(true); - }); - }); - - // 🚫 편집 시작 권한 확인 - activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { - console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); - - const exactMapping = mappings.find(m => { - const cellPos = parseCellAddress(m.cellAddress); - return cellPos && cellPos.row === info.row && cellPos.col === info.col; - }); - - if (!exactMapping) { - console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); - return; // 매핑이 없으면 허용 - } - - console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`); - - if (!exactMapping.isEditable) { - console.log(`🚫 Field ${exactMapping.attId} is not editable`); - toast.warning(`${exactMapping.attId} field is read-only`); - info.cancel = true; - return; - } - - // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인 - if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { - const dataRowIndex = exactMapping.dataRowIndex; - if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { - const rowData = tableData[dataRowIndex]; - if (rowData?.shi === "OUT" || rowData?.shi === null ) { - console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); - toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); - info.cancel = true; - return; - } - } - } - - console.log(`✅ Edit allowed for ${exactMapping.attId}`); - }); - - // ✅ 편집 완료 검증 - activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { - console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); - - const exactMapping = mappings.find(m => { - const cellPos = parseCellAddress(m.cellAddress); - return cellPos && cellPos.row === info.row && cellPos.col === info.col; - }); - - if (!exactMapping) return; - - const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); - if (columnConfig) { - const cellValue = activeSheet.getValue(info.row, info.col); - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - const cell = activeSheet.getCell(info.row, info.col); - - if (errorMessage) { - // 🚨 에러 스타일 적용 - const errorStyle = new GC.Spread.Sheets.Style(); - errorStyle.backColor = "#fef2f2"; - errorStyle.foreColor = "#dc2626"; - errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - - activeSheet.setStyle(info.row, info.col, errorStyle); - cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 - toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); - } else { - // ✅ 정상 스타일 복원 - const normalStyle = createCellStyle(exactMapping.isEditable); - activeSheet.setStyle(info.row, info.col, normalStyle); - cell.locked(!exactMapping.isEditable); - } - } - - setHasChanges(true); - }); - - console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); - }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); - - // 🚀 최적화된 initSpread - const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { - const workingTemplate = template || selectedTemplate; - if (!spread || !workingTemplate) { - console.error('❌ Invalid spread or template'); - return; - } - - try { - console.log('🚀 Starting optimized spread initialization...'); - setIsInitializing(true); - updateProgress('Initializing...', 0, 100); - - setCurrentSpread(spread); - setHasChanges(false); - setValidationErrors([]); - - // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 - spread.suspendPaint(); - spread.suspendEvent(); - spread.suspendCalcService(); - - updateProgress('Setting up workspace...', 10, 100); - - try { - let activeSheet = getSafeActiveSheet(spread, 'initSpread'); - if (!activeSheet) { - throw new Error('Failed to get initial activeSheet'); - } - - activeSheet.options.isProtected = false; - let mappings: CellMapping[] = []; - - if (templateType === 'GRD_LIST') { - updateProgress('Creating dynamic table...', 20, 100); - - spread.clearSheets(); - spread.addSheet(0); - const sheet = spread.getSheet(0); - sheet.name('Data'); - spread.setActiveSheet('Data'); - - updateProgress('Processing table data...', 50, 100); - mappings = createGrdListTableOptimized(sheet, workingTemplate); - - } else { - updateProgress('Loading template structure...', 20, 100); - - let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; - let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; - - if (!contentJson || !dataSheets) { - throw new Error(`No template content found for ${workingTemplate.NAME}`); - } - - const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; - - updateProgress('Loading template layout...', 40, 100); - spread.fromJSON(jsonData); - - activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); - if (!activeSheet) { - throw new Error('ActiveSheet became null after loading template'); - } - - activeSheet.options.isProtected = false; - - if (templateType === 'SPREAD_LIST' && tableData.length > 0) { - updateProgress('Processing data rows...', 60, 100); - - dataSheets.forEach(dataSheet => { - if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { - dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { - const { ATT_ID, IN } = mapping; - if (!ATT_ID || !IN || IN.trim() === "") return; - - const cellPos = parseCellAddress(IN); - if (!cellPos) return; - - const requiredRows = cellPos.row + tableData.length; - if (!ensureRowCapacity(activeSheet, requiredRows)) return; - - // 🚀 배치 데이터 준비 - const valuesToSet: Array<{row: number, col: number, value: any}> = []; - const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; - - tableData.forEach((rowData, index) => { - const targetRow = cellPos.row + index; - const cellEditable = isFieldEditable(ATT_ID, rowData); - const value = rowData[ATT_ID]; - - mappings.push({ - attId: ATT_ID, - cellAddress: getCellAddress(targetRow, cellPos.col), - isEditable: cellEditable, - dataRowIndex: index - }); - - valuesToSet.push({ - row: targetRow, - col: cellPos.col, - value: value ?? null - }); - - stylesToSet.push({ - row: targetRow, - col: cellPos.col, - isEditable: cellEditable - }); - }); - - // 🚀 배치 처리 - setBatchValues(activeSheet, valuesToSet); - setBatchStyles(activeSheet, stylesToSet); - - // 드롭다운 설정 - const columnConfig = columnsJSON.find(col => col.key === ATT_ID); - if (columnConfig?.type === "LIST" && columnConfig.options) { - const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); - if (hasEditableRows) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); - } - } - }); - } - }); - - } else if (templateType === 'SPREAD_ITEM' && selectedRow) { - updateProgress('Setting up form fields...', 60, 100); - - dataSheets.forEach(dataSheet => { - dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { - const { ATT_ID, IN } = mapping; - const cellPos = parseCellAddress(IN); - if (cellPos) { - const isEditable = isFieldEditable(ATT_ID); - const value = selectedRow[ATT_ID]; - - mappings.push({ - attId: ATT_ID, - cellAddress: IN, - isEditable: isEditable, - dataRowIndex: 0 - }); - - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - cell.value(value ?? null); - - const style = createCellStyle(isEditable); - activeSheet.setStyle(cellPos.row, cellPos.col, style); - - const columnConfig = columnsJSON.find(col => col.key === ATT_ID); - if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); - } - } - }); - }); - } - } - - updateProgress('Configuring interactions...', 90, 100); - setCellMappings(mappings); - - const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); - if (finalActiveSheet) { - setupSheetProtectionAndEvents(finalActiveSheet, mappings); - } - - updateProgress('Finalizing...', 100, 100); - console.log(`✅ Optimized initialization completed with ${mappings.length} mappings`); - - } finally { - // 🚀 올바른 순서로 재개 - spread.resumeCalcService(); - spread.resumeEvent(); - spread.resumePaint(); - } - - } catch (error) { - console.error('❌ Error in optimized spread initialization:', error); - if (spread?.resumeCalcService) spread.resumeCalcService(); - if (spread?.resumeEvent) spread.resumeEvent(); - if (spread?.resumePaint) spread.resumePaint(); - toast.error(`Template loading failed: ${error.message}`); - } finally { - setIsInitializing(false); - setLoadingProgress(null); - } - }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]); - - const handleSaveChanges = React.useCallback(async () => { - if (!currentSpread || !hasChanges) { - toast.info("No changes to save"); - return; - } - - const errors = validateAllData(); - if (errors.length > 0) { - toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); - return; - } - - try { - setIsPending(true); - const activeSheet = currentSpread.getActiveSheet(); - - if (templateType === 'SPREAD_ITEM' && selectedRow) { - const dataToSave = { ...selectedRow }; - - cellMappings.forEach(mapping => { - if (mapping.isEditable) { - const cellPos = parseCellAddress(mapping.cellAddress); - if (cellPos) { - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - dataToSave[mapping.attId] = cellValue; - } - } - }); - - dataToSave.TAG_NO = selectedRow.TAG_NO; - - const { success, message } = await updateFormDataInDB( - formCode, - contractItemId, - dataToSave - ); - - if (!success) { - toast.error(message); - return; - } - - toast.success("Changes saved successfully!"); - onUpdateSuccess?.(dataToSave); - - } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { - console.log('🔍 Starting batch save process...'); - - const updatedRows: GenericData[] = []; - let saveCount = 0; - let checkedCount = 0; - - for (let i = 0; i < tableData.length; i++) { - const originalRow = tableData[i]; - const dataToSave = { ...originalRow }; - let hasRowChanges = false; - - console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`); - - cellMappings.forEach(mapping => { - if (mapping.dataRowIndex === i && mapping.isEditable) { - checkedCount++; - - // 🔧 isFieldEditable과 동일한 로직 사용 - const rowData = tableData[i]; - const fieldEditable = isFieldEditable(mapping.attId, rowData); - - console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`); - - if (fieldEditable) { - const cellPos = parseCellAddress(mapping.cellAddress); - if (cellPos) { - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - const originalValue = originalRow[mapping.attId]; - - // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리) - const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim(); - const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim(); - - console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`); - - if (normalizedCellValue !== normalizedOriginalValue) { - dataToSave[mapping.attId] = cellValue; - hasRowChanges = true; - console.log(` ✅ Change detected for ${mapping.attId}`); - } - } - } - } - }); - - if (hasRowChanges) { - console.log(`💾 Saving row ${i} with changes`); - dataToSave.TAG_NO = originalRow.TAG_NO; - - try { - const { success, message } = await updateFormDataInDB( - formCode, - contractItemId, - dataToSave - ); - - if (success) { - updatedRows.push(dataToSave); - saveCount++; - console.log(`✅ Row ${i} saved successfully`); - } else { - console.error(`❌ Failed to save row ${i}: ${message}`); - toast.error(`Failed to save row ${i + 1}: ${message}`); - updatedRows.push(originalRow); // 원본 데이터 유지 - } - } catch (error) { - console.error(`❌ Error saving row ${i}:`, error); - toast.error(`Error saving row ${i + 1}`); - updatedRows.push(originalRow); // 원본 데이터 유지 - } - } else { - updatedRows.push(originalRow); - console.log(`ℹ️ No changes in row ${i}`); - } - } - - console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`); - - if (saveCount > 0) { - toast.success(`${saveCount} rows saved successfully!`); - onUpdateSuccess?.(updatedRows); - } else { - console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`); - toast.warning("No actual changes were found to save. Please check if the values were properly edited."); - } - } - - setHasChanges(false); - setValidationErrors([]); - - } catch (error) { - console.error("Error saving changes:", error); - toast.error("An unexpected error occurred while saving"); - } finally { - setIsPending(false); - } - }, [ - currentSpread, - hasChanges, - templateType, - selectedRow, - tableData, - formCode, - contractItemId, - onUpdateSuccess, - cellMappings, - columnsJSON, - validateAllData, - isFieldEditable // 🔧 의존성 추가 - ]); - - if (!isOpen) return null; - - const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; - const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; - - return ( - - - - SEDP Template - {formCode} - -
- {availableTemplates.length > 1 && ( -
- Template: - -
- )} - - {selectedTemplate && ( -
- - Template Type: { - templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : - templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : - 'Grid List View (GRD_LIST)' - } - - {templateType === 'SPREAD_ITEM' && selectedRow && ( - • Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'} - )} - {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( - • {dataCount} rows - )} - {hasChanges && ( - - • Unsaved changes - - )} - {validationErrors.length > 0 && ( - - - {validationErrors.length} validation errors - - )} -
- )} - -
- - - Editable fields - - - - Read-only fields - - - - Validation errors - - {cellMappings.length > 0 && ( - - {editableFieldsCount} of {cellMappings.length} fields editable - - )} -
-
-
-
- -
- {/* 🆕 로딩 프로그레스 오버레이 */} - - - {selectedTemplate && isClient && isDataValid ? ( - - ) : ( -
- {!isClient ? ( - <> - - Loading... - - ) : !selectedTemplate ? ( - "No template available" - ) : !isDataValid ? ( - `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` - ) : ( - "Template not ready" - )} -
- )} -
- - -
- - - {hasChanges && ( - - )} - - {validationErrors.length > 0 && ( - - )} -
-
-
-
- ); -} \ No newline at end of file diff --git a/components/form-data/spreadJS-dialog copy 5.tsx b/components/form-data/spreadJS-dialog copy 5.tsx new file mode 100644 index 00000000..fbeceaf3 --- /dev/null +++ b/components/form-data/spreadJS-dialog copy 5.tsx @@ -0,0 +1,1493 @@ +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GenericData } from "./export-excel-form"; +import * as GC from "@mescius/spread-sheets"; +import { toast } from "sonner"; +import { updateFormDataInDB } from "@/lib/forms/services"; +import { Loader, Save, AlertTriangle } from "lucide-react"; +import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; +import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; +import { setupSpreadJSLicense } from "@/lib/spread-js/license-utils"; + +const SpreadSheets = dynamic( + () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), + { + ssr: false, + loading: () => ( +
+ + Loading SpreadSheets... +
+ ) + } +); + +// 도메인별 라이선스 설정 +if (typeof window !== 'undefined') { + setupSpreadJSLicense(GC); +} + +interface TemplateItem { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP: { + REG_TYPE_ID: string; + SPR_ITM_IDS: Array; + ATTS: Array<{}>; + }; + SPR_ITM_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; +} + +interface ValidationError { + cellAddress: string; + attId: string; + value: any; + expectedType: ColumnType; + message: string; +} + +interface CellMapping { + attId: string; + cellAddress: string; + isEditable: boolean; + dataRowIndex?: number; +} + +interface TemplateViewDialogProps { + isOpen: boolean; + onClose: () => void; + templateData: TemplateItem[] | any; + selectedRow?: GenericData; + tableData?: GenericData[]; + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map; + onUpdateSuccess?: (updatedValues: Record | GenericData[]) => void; +} + +// 🚀 로딩 프로그레스 컴포넌트 +interface LoadingProgressProps { + phase: string; + progress: number; + total: number; + isVisible: boolean; +} + +const LoadingProgress: React.FC = ({ phase, progress, total, isVisible }) => { + const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; + + if (!isVisible) return null; + + return ( +
+
+
+ + Loading Template +
+ +
+
{phase}
+
+
+
+
+ {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) +
+
+
+
+ ); +}; + +export function TemplateViewDialog({ + isOpen, + onClose, + templateData, + selectedRow, + tableData = [], + formCode, + contractItemId, + columnsJSON, + editableFieldsMap = new Map(), + onUpdateSuccess +}: TemplateViewDialogProps) { + const [hostStyle, setHostStyle] = React.useState({ + width: '100%', + height: '100%' + }); + + const [isPending, setIsPending] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const [currentSpread, setCurrentSpread] = React.useState(null); + const [cellMappings, setCellMappings] = React.useState([]); + const [isClient, setIsClient] = React.useState(false); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); + const [validationErrors, setValidationErrors] = React.useState([]); + const [selectedTemplateId, setSelectedTemplateId] = React.useState(""); + const [availableTemplates, setAvailableTemplates] = React.useState([]); + + // 🆕 로딩 상태 추가 + const [loadingProgress, setLoadingProgress] = React.useState<{ + phase: string; + progress: number; + total: number; + } | null>(null); + const [isInitializing, setIsInitializing] = React.useState(false); + + // 🔄 진행상황 업데이트 함수 + const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { + setLoadingProgress({ phase, progress, total }); + }, []); + + const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { + if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_LIST'; + } + if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_ITEM'; + } + if (template.GRD_LST_SETUP && columnsJSON.length > 0) { + return 'GRD_LIST'; + } + return null; + }, [columnsJSON]); + + const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { + return determineTemplateType(template) !== null; + }, [determineTemplateType]); + + React.useEffect(() => { + setIsClient(true); + }, []); + + React.useEffect(() => { + if (!templateData) return; + + let templates: TemplateItem[]; + if (Array.isArray(templateData)) { + templates = templateData as TemplateItem[]; + } else { + templates = [templateData as TemplateItem]; + } + + const validTemplates = templates.filter(isValidTemplate); + setAvailableTemplates(validTemplates); + + if (validTemplates.length > 0 && !selectedTemplateId) { + const firstTemplate = validTemplates[0]; + const templateTypeToSet = determineTemplateType(firstTemplate); + setSelectedTemplateId(firstTemplate.TMPL_ID); + setTemplateType(templateTypeToSet); + } + }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); + + const handleTemplateChange = (templateId: string) => { + const template = availableTemplates.find(t => t.TMPL_ID === templateId); + if (template) { + const templateTypeToSet = determineTemplateType(template); + setSelectedTemplateId(templateId); + setTemplateType(templateTypeToSet); + setHasChanges(false); + setValidationErrors([]); + + if (currentSpread && template) { + initSpread(currentSpread, template); + } + } + }; + + const selectedTemplate = React.useMemo(() => { + return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); + }, [availableTemplates, selectedTemplateId]); + + const editableFields = React.useMemo(() => { + // SPREAD_ITEM의 경우에만 전역 editableFields 사용 + if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { + if (!editableFieldsMap.has(selectedRow.TAG_NO)) { + return []; + } + return editableFieldsMap.get(selectedRow.TAG_NO) || []; + } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); + + +const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } + + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return false; + } + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { + return false; + } + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; + } + return true; + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + + return true; +}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + +const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 + return cellMappings.filter(m => m.isEditable).length; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; +}, [cellMappings, templateType, tableData, isFieldEditable]); + + // 🚀 배치 처리 함수들 + const setBatchValues = React.useCallback(( + activeSheet: any, + valuesToSet: Array<{row: number, col: number, value: any}> + ) => { + console.log(`🚀 Setting ${valuesToSet.length} values in batch`); + + const columnGroups = new Map>(); + + valuesToSet.forEach(({row, col, value}) => { + if (!columnGroups.has(col)) { + columnGroups.set(col, []); + } + columnGroups.get(col)!.push({row, value}); + }); + + columnGroups.forEach((values, col) => { + values.sort((a, b) => a.row - b.row); + + let start = 0; + while (start < values.length) { + let end = start; + while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { + end++; + } + + const rangeValues = values.slice(start, end + 1).map(v => v.value); + const startRow = values[start].row; + + try { + if (rangeValues.length === 1) { + activeSheet.setValue(startRow, col, rangeValues[0]); + } else { + const dataArray = rangeValues.map(v => [v]); + activeSheet.setArray(startRow, col, dataArray); + } + } catch (error) { + for (let i = start; i <= end; i++) { + try { + activeSheet.setValue(values[i].row, col, values[i].value); + } catch (cellError) { + console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); + } + } + } + + start = end + 1; + } + }); + }, []); + + const createCellStyle = React.useCallback((isEditable: boolean) => { + const style = new GC.Spread.Sheets.Style(); + if (isEditable) { + style.backColor = "#bbf7d0"; + } else { + style.backColor = "#e5e7eb"; + style.foreColor = "#4b5563"; + } + return style; + }, []); + + const setBatchStyles = React.useCallback(( + activeSheet: any, + stylesToSet: Array<{row: number, col: number, isEditable: boolean}> + ) => { + console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); + + const editableStyle = createCellStyle(true); + const readonlyStyle = createCellStyle(false); + + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) + stylesToSet.forEach(({row, col, isEditable}) => { + try { + const cell = activeSheet.getCell(row, col); + const style = isEditable ? editableStyle : readonlyStyle; + + activeSheet.setStyle(row, col, style); + cell.locked(!isEditable); // 편집 가능하면 잠금 해제 + + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 + if (isEditable) { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(row, col, textCellType); + } + } catch (error) { + console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); + } + }); + }, [createCellStyle]); + + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; + + const row = parseInt(rowStr) - 1; + return { row, col }; + }; + + const getCellAddress = (row: number, col: number): string => { + let colStr = ''; + let colNum = col; + while (colNum >= 0) { + colStr = String.fromCharCode((colNum % 26) + 65) + colStr; + colNum = Math.floor(colNum / 26) - 1; + } + return colStr + (row + 1); + }; + + const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { + if (value === undefined || value === null || value === "") { + return null; + } + + switch (columnType) { + case "NUMBER": + if (isNaN(Number(value))) { + return "Value must be a valid number"; + } + break; + case "LIST": + if (options && !options.includes(String(value))) { + return `Value must be one of: ${options.join(", ")}`; + } + break; + case "STRING": + break; + default: + break; + } + + return null; + }; + + const validateAllData = React.useCallback(() => { + if (!currentSpread || !selectedTemplate) return []; + + const activeSheet = currentSpread.getActiveSheet(); + const errors: ValidationError[] = []; + + cellMappings.forEach(mapping => { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + if (!columnConfig) return; + + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + + if (errorMessage) { + errors.push({ + cellAddress: mapping.cellAddress, + attId: mapping.attId, + value: cellValue, + expectedType: columnConfig.type, + message: errorMessage + }); + } + }); + + setValidationErrors(errors); + return errors; + }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); + + + + const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { + try { + console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + return; + } + + const optionsString = safeOptions.join(','); + + for (let i = 0; i < rowCount; i++) { + try { + const targetRow = cellPos.row + i; + + // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // 🔧 DataValidation 설정 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + // ComboBox와 Validator 적용 + activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); + + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(false); + + console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + if (!spread) return null; + + try { + let activeSheet = spread.getActiveSheet(); + if (!activeSheet) { + const sheetCount = spread.getSheetCount(); + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + } + } + } + return activeSheet; + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } + }, []); + + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + try { + if (!activeSheet) return false; + + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + return false; + } + }, []); + + const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + if (!activeSheet) return false; + + const currentColumnCount = activeSheet.getColumnCount(); + if (requiredColumnCount > currentColumnCount) { + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + return false; + } + }, []); + + const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; + activeSheet.setColumnWidth(targetCol, optimalWidth); + }); + }, []); + + // 🚀 최적화된 GRD_LIST 생성 + // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) +const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); + + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; + + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) + const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); + let freezeColumnCount = 0; + + if (tagDescColumnIndex !== -1) { + // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) + freezeColumnCount = startCol + tagDescColumnIndex + 1; + console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); + } else { + // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) + const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); + if (tagNoColumnIndex !== -1) { + freezeColumnCount = startCol + tagNoColumnIndex + 1; + console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); + } + } + + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); + } + }); + + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable + }); + }); + }); + + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); + + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); + + if (safeOptions.length === 0) return; + + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + // 🧊 틀고정 설정 + if (freezeColumnCount > 0) { + try { + activeSheet.frozenColumnCount(freezeColumnCount); + activeSheet.frozenRowCount(1); // 헤더 행도 고정 + + console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); + + // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) + for (let col = 0; col < freezeColumnCount; col++) { + for (let row = 0; row <= tableData.length; row++) { + try { + const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + if (row === 0) { + // 헤더는 기존 스타일 유지 + continue; + } else { + // 데이터 셀에 고정 구분선 추가 + if (col === freezeColumnCount - 1) { + currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); + activeSheet.setStyle(row, col, currentStyle); + } + } + } catch (styleError) { + console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); + } + } + } + } catch (freezeError) { + console.error('❌ Failed to apply freeze:', freezeError); + } + } + + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created with freeze:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + console.log(` - Frozen columns: ${freezeColumnCount}`); + + return mappings; +}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + + const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); + + // 🔧 시트 보호 완전 해제 후 편집 권한 설정 + activeSheet.options.isProtected = false; + + // 🔧 편집 가능한 셀들을 위한 강화된 설정 + mappings.forEach((mapping) => { + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + + if (mapping.isEditable) { + // 🚀 편집 가능한 셀 설정 강화 + cell.locked(false); + + if (columnConfig?.type === "LIST" && columnConfig.options) { + // LIST 타입: 새 ComboBox 인스턴스 생성 + const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBox.items(columnConfig.options); + comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); + + // DataValidation도 추가 + const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); + activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); + } else if (columnConfig?.type === "NUMBER") { + // NUMBER 타입: 숫자 입력 허용 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + + // 숫자 validation 추가 (에러 메시지 없이) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(false); + numberValidator.showErrorMessage(false); + activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); + } else { + // 기본 TEXT 타입: 자유 텍스트 입력 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + } + + // 편집 가능 스타일 재적용 + const editableStyle = createCellStyle(true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); + } else { + // 읽기 전용 셀 + cell.locked(true); + const readonlyStyle = createCellStyle(false); + activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); + } + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); + + // 🛡️ 시트 보호 재설정 (편집 허용 모드로) + activeSheet.options.isProtected = false; + activeSheet.options.protectionOptions = { + allowSelectLockedCells: true, + allowSelectUnlockedCells: true, + allowSort: false, + allowFilter: false, + allowEditObjects: true, // ✅ 편집 객체 허용 + allowResizeRows: false, + allowResizeColumns: false, + allowFormatCells: false, + allowInsertRows: false, + allowInsertColumns: false, + allowDeleteRows: false, + allowDeleteColumns: false + }; + + // 🎯 변경 감지 이벤트 + const changeEvents = [ + GC.Spread.Sheets.Events.CellChanged, + GC.Spread.Sheets.Events.ValueChanged, + GC.Spread.Sheets.Events.ClipboardPasted + ]; + + changeEvents.forEach(eventType => { + activeSheet.bind(eventType, () => { + console.log(`📝 ${eventType} detected`); + setHasChanges(true); + }); + }); + + // 🚫 편집 시작 권한 확인 + activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { + console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) { + console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); + return; // 매핑이 없으면 허용 + } + + console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`); + + if (!exactMapping.isEditable) { + console.log(`🚫 Field ${exactMapping.attId} is not editable`); + toast.warning(`${exactMapping.attId} field is read-only`); + info.cancel = true; + return; + } + + // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { + const dataRowIndex = exactMapping.dataRowIndex; + if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { + const rowData = tableData[dataRowIndex]; + if (rowData?.shi === "OUT" || rowData?.shi === null ) { + console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); + toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); + info.cancel = true; + return; + } + } + } + + console.log(`✅ Edit allowed for ${exactMapping.attId}`); + }); + + // ✅ 편집 완료 검증 + activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { + console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); + + const exactMapping = mappings.find(m => { + const cellPos = parseCellAddress(m.cellAddress); + return cellPos && cellPos.row === info.row && cellPos.col === info.col; + }); + + if (!exactMapping) return; + + const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); + if (columnConfig) { + const cellValue = activeSheet.getValue(info.row, info.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + const cell = activeSheet.getCell(info.row, info.col); + + if (errorMessage) { + // 🚨 에러 스타일 적용 + const errorStyle = new GC.Spread.Sheets.Style(); + errorStyle.backColor = "#fef2f2"; + errorStyle.foreColor = "#dc2626"; + errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + + activeSheet.setStyle(info.row, info.col, errorStyle); + cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 + toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); + } else { + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + + setHasChanges(true); + }); + + console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); + }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + + // 🚀 최적화된 initSpread + const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template'); + return; + } + + try { + console.log('🚀 Starting optimized spread initialization...'); + setIsInitializing(true); + updateProgress('Initializing...', 0, 100); + + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 + spread.suspendPaint(); + spread.suspendEvent(); + spread.suspendCalcService(); + + updateProgress('Setting up workspace...', 10, 100); + + try { + let activeSheet = getSafeActiveSheet(spread, 'initSpread'); + if (!activeSheet) { + throw new Error('Failed to get initial activeSheet'); + } + + activeSheet.options.isProtected = false; + let mappings: CellMapping[] = []; + + if (templateType === 'GRD_LIST') { + updateProgress('Creating dynamic table...', 20, 100); + + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + updateProgress('Processing table data...', 50, 100); + mappings = createGrdListTableOptimized(sheet, workingTemplate); + + } else { + updateProgress('Loading template structure...', 20, 100); + + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; + let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; + + if (!contentJson || !dataSheets) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); + } + + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + updateProgress('Loading template layout...', 40, 100); + spread.fromJSON(jsonData); + + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + activeSheet.options.isProtected = false; + + if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + updateProgress('Processing data rows...', 60, 100); + + dataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + if (!ATT_ID || !IN || IN.trim() === "") return; + + const cellPos = parseCellAddress(IN); + if (!cellPos) return; + + const requiredRows = cellPos.row + tableData.length; + if (!ensureRowCapacity(activeSheet, requiredRows)) return; + + // 🚀 배치 데이터 준비 + const valuesToSet: Array<{row: number, col: number, value: any}> = []; + const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; + + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cellEditable = isFieldEditable(ATT_ID, rowData); + const value = rowData[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: getCellAddress(targetRow, cellPos.col), + isEditable: cellEditable, + dataRowIndex: index + }); + + valuesToSet.push({ + row: targetRow, + col: cellPos.col, + value: value ?? null + }); + + stylesToSet.push({ + row: targetRow, + col: cellPos.col, + isEditable: cellEditable + }); + }); + + // 🚀 배치 처리 + setBatchValues(activeSheet, valuesToSet); + setBatchStyles(activeSheet, stylesToSet); + + // 드롭다운 설정 + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); + } + } + }); + } + }); + + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { + updateProgress('Setting up form fields...', 60, 100); + + dataSheets.forEach(dataSheet => { + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + const cellPos = parseCellAddress(IN); + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + cell.value(value ?? null); + + const style = createCellStyle(isEditable); + activeSheet.setStyle(cellPos.row, cellPos.col, style); + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + } + } + }); + }); + } + } + + updateProgress('Configuring interactions...', 90, 100); + setCellMappings(mappings); + + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } + + updateProgress('Finalizing...', 100, 100); + console.log(`✅ Optimized initialization completed with ${mappings.length} mappings`); + + } finally { + // 🚀 올바른 순서로 재개 + spread.resumeCalcService(); + spread.resumeEvent(); + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error in optimized spread initialization:', error); + if (spread?.resumeCalcService) spread.resumeCalcService(); + if (spread?.resumeEvent) spread.resumeEvent(); + if (spread?.resumePaint) spread.resumePaint(); + toast.error(`Template loading failed: ${error.message}`); + } finally { + setIsInitializing(false); + setLoadingProgress(null); + } + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]); + + const handleSaveChanges = React.useCallback(async () => { + if (!currentSpread || !hasChanges) { + toast.info("No changes to save"); + return; + } + + const errors = validateAllData(); + if (errors.length > 0) { + toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); + return; + } + + try { + setIsPending(true); + const activeSheet = currentSpread.getActiveSheet(); + + if (templateType === 'SPREAD_ITEM' && selectedRow) { + const dataToSave = { ...selectedRow }; + + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); + + dataToSave.TAG_NO = selectedRow.TAG_NO; + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success("Changes saved successfully!"); + onUpdateSuccess?.(dataToSave); + + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + console.log('🔍 Starting batch save process...'); + + const updatedRows: GenericData[] = []; + let saveCount = 0; + let checkedCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`); + + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + checkedCount++; + + // 🔧 isFieldEditable과 동일한 로직 사용 + const rowData = tableData[i]; + const fieldEditable = isFieldEditable(mapping.attId, rowData); + + console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`); + + if (fieldEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const originalValue = originalRow[mapping.attId]; + + // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리) + const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim(); + const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim(); + + console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`); + + if (normalizedCellValue !== normalizedOriginalValue) { + dataToSave[mapping.attId] = cellValue; + hasRowChanges = true; + console.log(` ✅ Change detected for ${mapping.attId}`); + } + } + } + } + }); + + if (hasRowChanges) { + console.log(`💾 Saving row ${i} with changes`); + dataToSave.TAG_NO = originalRow.TAG_NO; + + try { + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + console.log(`✅ Row ${i} saved successfully`); + } else { + console.error(`❌ Failed to save row ${i}: ${message}`); + toast.error(`Failed to save row ${i + 1}: ${message}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } catch (error) { + console.error(`❌ Error saving row ${i}:`, error); + toast.error(`Error saving row ${i + 1}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } else { + updatedRows.push(originalRow); + console.log(`ℹ️ No changes in row ${i}`); + } + } + + console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`); + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`); + toast.warning("No actual changes were found to save. Please check if the values were properly edited."); + } + } + + setHasChanges(false); + setValidationErrors([]); + + } catch (error) { + console.error("Error saving changes:", error); + toast.error("An unexpected error occurred while saving"); + } finally { + setIsPending(false); + } + }, [ + currentSpread, + hasChanges, + templateType, + selectedRow, + tableData, + formCode, + contractItemId, + onUpdateSuccess, + cellMappings, + columnsJSON, + validateAllData, + isFieldEditable // 🔧 의존성 추가 + ]); + + if (!isOpen) return null; + + const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; + const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; + + return ( + + + + SEDP Template - {formCode} + +
+ {availableTemplates.length > 1 && ( +
+ Template: + +
+ )} + + {selectedTemplate && ( +
+ + Template Type: { + templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : + 'Grid List View (GRD_LIST)' + } + + {templateType === 'SPREAD_ITEM' && selectedRow && ( + • Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'} + )} + {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( + • {dataCount} rows + )} + {hasChanges && ( + + • Unsaved changes + + )} + {validationErrors.length > 0 && ( + + + {validationErrors.length} validation errors + + )} +
+ )} + +
+ + + Editable fields + + + + Read-only fields + + + + Validation errors + + {cellMappings.length > 0 && ( + + {editableFieldsCount} of {cellMappings.length} fields editable + + )} +
+
+
+
+ +
+ {/* 🆕 로딩 프로그레스 오버레이 */} + + + {selectedTemplate && isClient && isDataValid ? ( + + ) : ( +
+ {!isClient ? ( + <> + + Loading... + + ) : !selectedTemplate ? ( + "No template available" + ) : !isDataValid ? ( + `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` + ) : ( + "Template not ready" + )} +
+ )} +
+ + +
+ + + {hasChanges && ( + + )} + + {validationErrors.length > 0 && ( + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/form-data/spreadJS-dialog copy.tsx b/components/form-data/spreadJS-dialog copy.tsx deleted file mode 100644 index 5a51c2b5..00000000 --- a/components/form-data/spreadJS-dialog copy.tsx +++ /dev/null @@ -1,539 +0,0 @@ -"use client"; - -import * as React from "react"; -import dynamic from "next/dynamic"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { GenericData } from "./export-excel-form"; -import * as GC from "@mescius/spread-sheets"; -import { toast } from "sonner"; -import { updateFormDataInDB } from "@/lib/forms/services"; -import { Loader, Save } from "lucide-react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; - -// SpreadSheets를 동적으로 import (SSR 비활성화) -const SpreadSheets = dynamic( - () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), - { - ssr: false, - loading: () => ( -
- - Loading SpreadSheets... -
- ) - } -); - -// 라이센스 키 설정을 클라이언트에서만 실행 -if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { - GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; -} - -interface TemplateItem { - TMPL_ID: string; - NAME: string; - TMPL_TYPE: string; - SPR_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array; - CONTENT?: string; - DATA_SHEETS: Array<{ - SHEET_NAME: string; - REG_TYPE_ID: string; - MAP_CELL_ATT: Array<{ - ATT_ID: string; - IN: string; - }>; - }>; - }; - GRD_LST_SETUP: { - REG_TYPE_ID: string; - SPR_ITM_IDS: Array; - ATTS: Array<{}>; - }; - SPR_ITM_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array; - CONTENT?: string; - DATA_SHEETS: Array<{ - SHEET_NAME: string; - REG_TYPE_ID: string; - MAP_CELL_ATT: Array<{ - ATT_ID: string; - IN: string; - }>; - }>; - }; -} - -interface TemplateViewDialogProps { - isOpen: boolean; - onClose: () => void; - templateData: TemplateItem[] | any; - selectedRow: GenericData; - formCode: string; - contractItemId: number; - editableFieldsMap?: Map; // 편집 가능 필드 정보 - onUpdateSuccess?: (updatedValues: Record) => void; -} - -export function TemplateViewDialog({ - isOpen, - onClose, - templateData, - selectedRow, - formCode, - contractItemId, - editableFieldsMap = new Map(), - onUpdateSuccess -}: TemplateViewDialogProps) { - const [hostStyle, setHostStyle] = React.useState({ - width: '100%', - height: '100%' - }); - - const [isPending, setIsPending] = React.useState(false); - const [hasChanges, setHasChanges] = React.useState(false); - const [currentSpread, setCurrentSpread] = React.useState(null); - const [selectedTemplateId, setSelectedTemplateId] = React.useState(""); - const [cellMappings, setCellMappings] = React.useState>([]); - const [isClient, setIsClient] = React.useState(false); - - // 클라이언트 사이드에서만 렌더링되도록 보장 - React.useEffect(() => { - setIsClient(true); - }, []); - - // 템플릿 데이터를 배열로 정규화하고 CONTENT가 있는 것만 필터링 - const normalizedTemplates = React.useMemo((): TemplateItem[] => { - if (!templateData) return []; - - let templates: TemplateItem[]; - if (Array.isArray(templateData)) { - templates = templateData as TemplateItem[]; - } else { - templates = [templateData as TemplateItem]; - } - - return templates.filter(template => { - const sprContent = template.SPR_LST_SETUP?.CONTENT; - const sprItmContent = template.SPR_ITM_LST_SETUP?.CONTENT; - return sprContent || sprItmContent; - }); - }, [templateData]); - - // 선택된 템플릿 가져오기 - const selectedTemplate = React.useMemo(() => { - if (!selectedTemplateId) return normalizedTemplates[0]; - return normalizedTemplates.find(t => t.TMPL_ID === selectedTemplateId) || normalizedTemplates[0]; - }, [normalizedTemplates, selectedTemplateId]); - - // 현재 TAG의 편집 가능한 필드 목록 가져오기 - const editableFields = React.useMemo(() => { - if (!selectedRow?.TAG_NO || !editableFieldsMap.has(selectedRow.TAG_NO)) { - return []; - } - return editableFieldsMap.get(selectedRow.TAG_NO) || []; - }, [selectedRow?.TAG_NO, editableFieldsMap]); - - // 필드가 편집 가능한지 판별하는 함수 - const isFieldEditable = React.useCallback((attId: string) => { - // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 - if (attId === "TAG_NO" || attId === "TAG_DESC") { - return true; - } - - // editableFieldsMap이 있으면 해당 리스트에 있는지 확인 - if (selectedRow?.TAG_NO && editableFieldsMap.has(selectedRow.TAG_NO)) { - return editableFields.includes(attId); - } - - return false; - }, [selectedRow?.TAG_NO, editableFieldsMap, editableFields]); - - // 셀 주소를 행과 열로 변환하는 함수 (예: "M1" -> {row: 0, col: 12}) - const parseCellAddress = (address: string): {row: number, col: number} | null => { - if (!address || address.trim() === "") return null; - - const match = address.match(/^([A-Z]+)(\d+)$/); - if (!match) return null; - - const [, colStr, rowStr] = match; - - // 열 문자를 숫자로 변환 (A=0, B=1, ..., Z=25, AA=26, ...) - let col = 0; - for (let i = 0; i < colStr.length; i++) { - col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); - } - col -= 1; // 0-based index로 변환 - - const row = parseInt(rowStr) - 1; // 0-based index로 변환 - - return { row, col }; - }; - - // 템플릿 변경 시 기본 선택 - React.useEffect(() => { - if (normalizedTemplates.length > 0 && !selectedTemplateId) { - setSelectedTemplateId(normalizedTemplates[0].TMPL_ID); - } - }, [normalizedTemplates, selectedTemplateId]); - - const initSpread = React.useCallback((spread: any) => { - if (!spread || !selectedTemplate || !selectedRow) return; - - try { - setCurrentSpread(spread); - setHasChanges(false); - - // CONTENT 찾기 - let contentJson = null; - let dataSheets = null; - - if (selectedTemplate.SPR_LST_SETUP?.CONTENT) { - contentJson = selectedTemplate.SPR_LST_SETUP.CONTENT; - dataSheets = selectedTemplate.SPR_LST_SETUP.DATA_SHEETS; - console.log('Using SPR_LST_SETUP.CONTENT for template:', selectedTemplate.NAME); - } else if (selectedTemplate.SPR_ITM_LST_SETUP?.CONTENT) { - contentJson = selectedTemplate.SPR_ITM_LST_SETUP.CONTENT; - dataSheets = selectedTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS; - console.log('Using SPR_ITM_LST_SETUP.CONTENT for template:', selectedTemplate.NAME); - } - - if (!contentJson) { - console.warn('No CONTENT found in template:', selectedTemplate.NAME); - return; - } - - console.log('Loading template content for:', selectedTemplate.NAME); - - const jsonData = typeof contentJson === 'string' - ? JSON.parse(contentJson) - : contentJson; - - // 렌더링 일시 중단 (성능 향상) - spread.suspendPaint(); - - try { - // fromJSON으로 템플릿 구조 로드 - spread.fromJSON(jsonData); - - // 활성 시트 가져오기 - const activeSheet = spread.getActiveSheet(); - - // 시트 보호 먼저 해제 - activeSheet.options.isProtected = false; - - // MAP_CELL_ATT 정보를 사용해서 셀에 데이터 매핑과 스타일을 한번에 처리 - if (dataSheets && dataSheets.length > 0) { - const mappings: Array<{attId: string, cellAddress: string, isEditable: boolean}> = []; - - dataSheets.forEach(dataSheet => { - if (dataSheet.MAP_CELL_ATT) { - dataSheet.MAP_CELL_ATT.forEach(mapping => { - const { ATT_ID, IN } = mapping; - - // 셀 주소가 비어있지 않은 경우만 처리 - if (IN && IN.trim() !== "") { - const cellPos = parseCellAddress(IN); - if (cellPos) { - const isEditable = isFieldEditable(ATT_ID); - mappings.push({ - attId: ATT_ID, - cellAddress: IN, - isEditable: isEditable - }); - - // 셀 객체 가져오기 - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - - // selectedRow에서 해당 값 가져와서 셀에 설정 - const value = selectedRow[ATT_ID]; - if (value !== undefined && value !== null) { - cell.value(value); - } - - // 편집 권한 설정 - cell.locked(!isEditable); - - // 즉시 스타일 적용 (기존 스타일 보존하면서) - const existingStyle = activeSheet.getStyle(cellPos.row, cellPos.col); - if (existingStyle) { - // 기존 스타일 복사 - const newStyle = Object.assign(new GC.Spread.Sheets.Style(), existingStyle); - - // 편집 권한에 따라 배경색만 변경 - if (isEditable) { - newStyle.backColor = "#f0fdf4"; // 연한 녹색 - } else { - newStyle.backColor = "#f9fafb"; // 연한 회색 - newStyle.foreColor = "#6b7280"; // 회색 글자 - } - - // 스타일 적용 - activeSheet.setStyle(cellPos.row, cellPos.col, newStyle); - } else { - // 기존 스타일이 없는 경우 새로운 스타일 생성 - const newStyle = new GC.Spread.Sheets.Style(); - if (isEditable) { - newStyle.backColor = "#f0fdf4"; - } else { - newStyle.backColor = "#f9fafb"; - newStyle.foreColor = "#6b7280"; - } - activeSheet.setStyle(cellPos.row, cellPos.col, newStyle); - } - - console.log(`Mapped ${ATT_ID} (${value}) to cell ${IN} - ${isEditable ? 'Editable' : 'Read-only'}`); - } - } - }); - } - }); - - setCellMappings(mappings); - - // 시트 보호 설정 - activeSheet.options.isProtected = true; - activeSheet.options.protectionOptions = { - allowSelectLockedCells: true, - allowSelectUnlockedCells: true, - allowSort: false, - allowFilter: false, - allowEditObjects: false, - allowResizeRows: false, - allowResizeColumns: false - }; - - // 이벤트 리스너 추가 - activeSheet.bind(GC.Spread.Sheets.Events.CellChanged, (event: any, info: any) => { - console.log('Cell changed:', info); - setHasChanges(true); - }); - - activeSheet.bind(GC.Spread.Sheets.Events.ValueChanged, (event: any, info: any) => { - console.log('Value changed:', info); - setHasChanges(true); - }); - - // 편집 시작 시 읽기 전용 셀 확인 - activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { - const mapping = mappings.find(m => { - const cellPos = parseCellAddress(m.cellAddress); - return cellPos && cellPos.row === info.row && cellPos.col === info.col; - }); - - if (mapping && !mapping.isEditable) { - toast.warning(`${mapping.attId} field is read-only`); - info.cancel = true; - } - }); - } - } finally { - // 렌더링 재개 (모든 변경사항이 한번에 화면에 표시됨) - spread.resumePaint(); - } - - } catch (error) { - console.error('Error initializing spread:', error); - toast.error('Failed to load template'); - // 에러 발생 시에도 렌더링 재개 - if (spread && spread.resumePaint) { - spread.resumePaint(); - } - } - }, [selectedTemplate, selectedRow, isFieldEditable]); - - // 템플릿 변경 핸들러 - const handleTemplateChange = (templateId: string) => { - setSelectedTemplateId(templateId); - setHasChanges(false); - - if (currentSpread) { - setTimeout(() => { - initSpread(currentSpread); - }, 100); - } - }; - - // 변경사항 저장 함수 - const handleSaveChanges = React.useCallback(async () => { - if (!currentSpread || !hasChanges || !selectedRow) { - toast.info("No changes to save"); - return; - } - - try { - setIsPending(true); - - const activeSheet = currentSpread.getActiveSheet(); - const dataToSave = { ...selectedRow }; - - // cellMappings를 사용해서 편집 가능한 셀의 값만 추출 - cellMappings.forEach(mapping => { - if (mapping.isEditable) { - const cellPos = parseCellAddress(mapping.cellAddress); - if (cellPos) { - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - dataToSave[mapping.attId] = cellValue; - } - } - }); - - // TAG_NO는 절대 변경되지 않도록 원본 값으로 강제 설정 - dataToSave.TAG_NO = selectedRow.TAG_NO; - - console.log('Data to save (TAG_NO preserved):', dataToSave); - - const { success, message } = await updateFormDataInDB( - formCode, - contractItemId, - dataToSave - ); - - if (!success) { - toast.error(message); - return; - } - - toast.success("Changes saved successfully!"); - - const updatedData = { - ...selectedRow, - ...dataToSave, - }; - - onUpdateSuccess?.(updatedData); - setHasChanges(false); - - } catch (error) { - console.error("Error saving changes:", error); - toast.error("An unexpected error occurred while saving"); - } finally { - setIsPending(false); - } - }, [currentSpread, hasChanges, formCode, contractItemId, selectedRow, onUpdateSuccess, cellMappings]); - - if (!isOpen) return null; - - return ( - - - - SEDP Template - {formCode} - - {selectedRow && `Selected TAG_NO: ${selectedRow.TAG_NO || 'N/A'}`} - {hasChanges && ( - - • Unsaved changes - - )} -
-
- - - Editable fields - - - - Read-only fields - - {cellMappings.length > 0 && ( - - {cellMappings.filter(m => m.isEditable).length} of {cellMappings.length} fields editable - - )} -
-
-
- - {/* 템플릿 선택 UI */} - {normalizedTemplates.length > 1 && ( -
-
- - - - ({normalizedTemplates.length} templates available) - -
-
- )} - - {/* SpreadSheets 컴포넌트 영역 */} -
- {selectedTemplate && isClient ? ( - - ) : ( -
- {!isClient ? ( - <> - - Loading... - - ) : ( - "No template available" - )} -
- )} -
- - - - - {hasChanges && ( - - )} - - -
-
- ); -} \ No newline at end of file diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index fbeceaf3..19c9a616 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -362,15 +362,20 @@ const editableFieldsCount = React.useMemo(() => { }); }, []); - const createCellStyle = React.useCallback((isEditable: boolean) => { - const style = new GC.Spread.Sheets.Style(); + const createCellStyle = React.useCallback((activeSheet: any, row: number, col: number, isEditable: boolean) => { + // 기존 스타일 가져오기 (없으면 새로 생성) + const existingStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + // backColor만 수정 if (isEditable) { - style.backColor = "#bbf7d0"; + existingStyle.backColor = "#bbf7d0"; } else { - style.backColor = "#e5e7eb"; - style.foreColor = "#4b5563"; + existingStyle.backColor = "#e5e7eb"; + // 읽기 전용일 때만 텍스트 색상 변경 (선택사항) + existingStyle.foreColor = "#4b5563"; } - return style; + + return existingStyle; }, []); const setBatchStyles = React.useCallback(( @@ -379,14 +384,11 @@ const editableFieldsCount = React.useMemo(() => { ) => { console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); - const editableStyle = createCellStyle(true); - const readonlyStyle = createCellStyle(false); - // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) stylesToSet.forEach(({row, col, isEditable}) => { try { const cell = activeSheet.getCell(row, col); - const style = isEditable ? editableStyle : readonlyStyle; + const style = createCellStyle(activeSheet, row, col, isEditable); activeSheet.setStyle(row, col, style); cell.locked(!isEditable); // 편집 가능하면 잠금 해제 @@ -854,14 +856,14 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat } // 편집 가능 스타일 재적용 - const editableStyle = createCellStyle(true); + const editableStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, true); activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); } else { // 읽기 전용 셀 cell.locked(true); - const readonlyStyle = createCellStyle(false); + const readonlyStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, false); activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); } } catch (error) { @@ -972,7 +974,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); } else { // ✅ 정상 스타일 복원 - const normalStyle = createCellStyle(exactMapping.isEditable); + const normalStyle = createCellStyle(activeSheet, info.row, info.col, exactMapping.isEditable); activeSheet.setStyle(info.row, info.col, normalStyle); cell.locked(!exactMapping.isEditable); } @@ -1132,7 +1134,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat const cell = activeSheet.getCell(cellPos.row, cellPos.col); cell.value(value ?? null); - const style = createCellStyle(isEditable); + const style = createCellStyle(activeSheet, cellPos.row, cellPos.col, isEditable); activeSheet.setStyle(cellPos.row, cellPos.col, style); const columnConfig = columnsJSON.find(col => col.key === ATT_ID); @@ -1173,7 +1175,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat setIsInitializing(false); setLoadingProgress(null); } - }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]); + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings, createCellStyle, isFieldEditable, columnsJSON, setupOptimizedListValidation, parseCellAddress, ensureRowCapacity, getCellAddress]); const handleSaveChanges = React.useCallback(async () => { if (!currentSpread || !hasChanges) { diff --git a/components/vendor-data/tag-table/tag-table-column.tsx b/components/vendor-data/tag-table/tag-table-column.tsx index a22611cf..6f0d977f 100644 --- a/components/vendor-data/tag-table/tag-table-column.tsx +++ b/components/vendor-data/tag-table/tag-table-column.tsx @@ -24,6 +24,7 @@ import { } from "@/components/ui/dropdown-menu" import { Ellipsis } from "lucide-react" import { Tag } from "@/types/vendorData" +import { createFilterFn } from "@/components/client-data-table/table-filters" export interface DataTableRowAction { @@ -70,6 +71,7 @@ export function getColumns({ header: ({ column }) => ( ), + filterFn: createFilterFn("text"), cell: ({ row }) =>
{row.getValue("tagNo")}
, meta: { excelHeader: "Tag No" diff --git a/config/rfqHistoryColumnsConfig.ts b/config/rfqHistoryColumnsConfig.ts index 67ec7fbd..f0c72491 100644 --- a/config/rfqHistoryColumnsConfig.ts +++ b/config/rfqHistoryColumnsConfig.ts @@ -10,64 +10,127 @@ export interface RfqHistoryColumnConfig { } export const rfqHistoryColumnsConfig: RfqHistoryColumnConfig[] = [ + // 개별 컬럼들 (그룹 없음) { - id: "rfqCode", - label: "RFQ Code", - excelHeader: "RFQ Code", - size: 120, + id: "rfqType", + label: "견적종류", + excelHeader: "견적종류", + type: "text", + size: 100, + }, + { + id: "status", + label: "견적상태", + excelHeader: "견적상태", + type: "text", + size: 100, }, { - id: "projectCode", - label: "Project Code", - excelHeader: "Project Code", + id: "rfqCode", + label: "견적번호", + excelHeader: "견적번호", + type: "text", size: 120, }, { - id: "projectName", - label: "Project Name", - excelHeader: "Project Name", + id: "projectInfo", + label: "프로젝트", + excelHeader: "프로젝트", + type: "text", size: 200, }, { - id: "description", - label: "Description", - excelHeader: "Description", - size: 300, + id: "packageInfo", + label: "PKG No. (PKG명)", + excelHeader: "PKG No. (PKG명)", + type: "text", + size: 150, }, { - id: "status", - label: "Status", - excelHeader: "Status", - size: 100, + id: "materialInfo", + label: "자재그룹 (자재그룹명)", + excelHeader: "자재그룹 (자재그룹명)", + type: "text", + size: 200, }, + + // 견적정보 그룹만 유지 { - id: "vendorStatus", - label: "Vendor Status", - excelHeader: "Vendor Status", - size: 120, + id: "currency", + label: "통화", + group: "견적정보", + excelHeader: "통화", + type: "text", + size: 80, }, { id: "totalAmount", - label: "Total Amount", - excelHeader: "Total Amount", + label: "총 견적금액", + group: "견적정보", + excelHeader: "총 견적금액", + type: "number", size: 120, }, { id: "leadTime", - label: "Lead Time", - excelHeader: "Lead Time", + label: "업체 L/T", + group: "견적정보", + excelHeader: "업체 L/T", + type: "text", + size: 100, + }, + { + id: "paymentTerms", + label: "지급조건", + group: "견적정보", + excelHeader: "지급조건", + type: "text", + size: 100, + }, + { + id: "incoterms", + label: "Incoterms", + group: "견적정보", + excelHeader: "Incoterms", + type: "text", + size: 100, + }, + { + id: "shippingLocation", + label: "선적지", + group: "견적정보", + excelHeader: "선적지", + type: "text", size: 100, }, + + // 개별 컬럼들 (그룹 없음) + { + id: "contractInfo", + label: "PO/계약정보", + excelHeader: "PO/계약정보", + type: "text", + size: 150, + }, + { + id: "rfqSendDate", + label: "견적요청일", + excelHeader: "견적요청일", + type: "date", + size: 120, + }, { - id: "dueDate", - label: "Due Date", - excelHeader: "Due Date", + id: "submittedAt", + label: "견적회신일", + excelHeader: "견적회신일", + type: "date", size: 120, }, { - id: "createdAt", - label: "Created At", - excelHeader: "Created At", + id: "picName", + label: "견적담당자", + excelHeader: "견적담당자", + type: "text", size: 120, }, ] \ No newline at end of file diff --git a/db/schema/generalContract.ts b/db/schema/generalContract.ts index 4d1de750..29479b20 100644 --- a/db/schema/generalContract.ts +++ b/db/schema/generalContract.ts @@ -146,6 +146,8 @@ export const generalContractItems = pgTable('general_contract_items', { // ═══════════════════════════════════════════════════════════════ quantity: decimal('quantity', { precision: 15, scale: 3 }), // 수량 quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위 + totalWeight: decimal('total_weight', { precision: 15, scale: 3 }), // 총 중량 + weightUnit: varchar('weight_unit', { length: 50 }), // 중량단위 contractDeliveryDate: date('contract_delivery_date'), // 계약납기일 contractUnitPrice: decimal('contract_unit_price', { precision: 15, scale: 2 }), // 계약단가 contractAmount: decimal('contract_amount', { precision: 15, scale: 2 }), // 계약금액 diff --git a/db/schema/vendorDocu.ts b/db/schema/vendorDocu.ts index c37bc2ec..3d9ad46c 100644 --- a/db/schema/vendorDocu.ts +++ b/db/schema/vendorDocu.ts @@ -1,7 +1,7 @@ // enhanced-schema.ts import { pgTable, integer, varchar, timestamp, date, pgView, uniqueIndex, jsonb, boolean, serial,json, - index, text + index, text, bigint } from "drizzle-orm/pg-core" import { eq, sql } from "drizzle-orm"; import { projects } from "./projects"; @@ -1362,6 +1362,19 @@ export const stageDocuments = pgTable( .references(() => projects.id, { onDelete: "cascade" }), vendorId: integer("vendor_id").notNull(), // vendors 테이블의 vendor_id 직접 저장 contractId: integer("contract_id").notNull(), + + + // 구매자 시스템 동기화 필드 추가 + buyerSystemStatus: varchar("buyer_system_status", { length: 50 }), // 승인(DC), 검토중, 반려 등 + buyerSystemComment: text("buyer_system_comment"), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }), + syncStatus: varchar("sync_status", { length: 20 }).default("pending"), // pending, synced, error, syncing + syncError: text("sync_error"), + + // 실시간 동기화를 위한 버전 관리 + syncVersion: integer("sync_version").default(0), // 변경 추적용 + lastModifiedBy: varchar("last_modified_by", { length: 100 }), // EVCP or BUYER_SYSTEM + // 메타 정보 createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), @@ -1384,11 +1397,16 @@ export const stageDocuments = pgTable( // 검색용 인덱스 vendorIdIndex: index("stage_doc_vendor_id_idx").on(table.vendorId), statusIndex: index("stage_doc_status_idx").on(table.status), + + // 동기화 관련 인덱스 추가 + syncStatusIndex: index("stage_doc_sync_status_idx").on(table.syncStatus), + buyerStatusIndex: index("stage_doc_buyer_status_idx").on(table.buyerSystemStatus), } } ) // 🎯 간소화된 스테이지 전용 뷰 +// db/schema/stage-documents.ts export const stageDocumentsView = pgView("stage_documents_view", { // 기본 문서 정보 documentId: integer("document_id").notNull(), @@ -1398,7 +1416,7 @@ export const stageDocumentsView = pgView("stage_documents_view", { status: varchar("status", { length: 50 }).notNull(), issuedDate: date("issued_date"), - // 프로젝트 및 벤더 정보 (직접 참조로 간소화) + // 프로젝트 및 벤더 정보 projectId: integer("project_id").notNull(), contractId: integer("contract_id").notNull(), projectCode: varchar("project_code", { length: 50 }), @@ -1406,7 +1424,16 @@ export const stageDocumentsView = pgView("stage_documents_view", { vendorName: varchar("vendor_name", { length: 255 }), vendorCode: varchar("vendor_code", { length: 50 }), - // 현재 스테이지 정보 (가장 우선순위가 높은 미완료 스테이지) + // 동기화 상태 필드 추가 + buyerSystemStatus: varchar("buyer_system_status", { length: 50 }), + buyerSystemComment: text("buyer_system_comment"), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }), + syncStatus: varchar("sync_status", { length: 20 }), + syncError: text("sync_error"), + syncVersion: integer("sync_version"), + lastModifiedBy: varchar("last_modified_by", { length: 100 }), + + // 현재 스테이지 정보 currentStageId: integer("current_stage_id"), currentStageName: varchar("current_stage_name", { length: 100 }), currentStageStatus: varchar("current_stage_status", { length: 50 }), @@ -1426,7 +1453,7 @@ export const stageDocumentsView = pgView("stage_documents_view", { completedStages: integer("completed_stages"), progressPercentage: integer("progress_percentage"), - // 전체 스테이지 목록 (리비전 및 첨부파일 제외) + // 전체 스테이지 목록 allStages: jsonb("all_stages").$type stageIssueStages.id, { onDelete: "cascade" }), + documentId: integer("document_id") + .notNull() + .references(() => stageDocuments.id, { onDelete: "cascade" }), + + // 리비전 관리 + revisionNumber: integer("revision_number").notNull().default(1), + revisionCode: varchar("revision_code", { length: 50 }).notNull(), // 새 필드: "Rev0", "A", "1" 등 + revisionType: varchar("revision_type", { length: 50 }) + .default("INITIAL"), // INITIAL, RESUBMISSION, CORRECTION + + // 제출 정보 + submissionStatus: varchar("submission_status", { length: 50 }) + .notNull() + .default("DRAFT"), // DRAFT, SUBMITTED, UNDER_REVIEW, APPROVED, REJECTED, WITHDRAWN + + // 제출자 정보 + submittedBy: varchar("submitted_by", { length: 100 }).notNull(), + submittedByEmail: varchar("submitted_by_email", { length: 255 }), + submittedAt: timestamp("submitted_at", { withTimezone: true }).notNull().defaultNow(), + + // 검토자 정보 + reviewedBy: varchar("reviewed_by", { length: 100 }), + reviewedByEmail: varchar("reviewed_by_email", { length: 255 }), + reviewedAt: timestamp("reviewed_at", { withTimezone: true }), + + // 제출 내용 + submissionTitle: varchar("submission_title", { length: 500 }), + submissionDescription: text("submission_description"), + submissionNotes: text("submission_notes"), + + // 반려/승인 정보 + reviewStatus: varchar("review_status", { length: 50 }), // PENDING, APPROVED, REJECTED, CONDITIONAL + reviewComments: text("review_comments"), + rejectionReason: text("rejection_reason"), + + // 벤더 정보 (denormalized for quick access) + vendorId: integer("vendor_id").notNull(), + vendorCode: varchar("vendor_code", { length: 50 }), + + // 메타데이터 + totalFiles: integer("total_files").default(0), + totalFileSize: bigint("total_file_size", { mode: "number" }).default(0), // bytes + + // ✨ 구매자 시스템 동기화 정보 (제출 레벨) + buyerSystemStatus: varchar("buyer_system_status", { length: 50 }), // PENDING, SYNCING, SYNCED, FAILED, PARTIAL + buyerSystemComment: text("buyer_system_comment"), + buyerSystemSubmissionId: varchar("buyer_system_submission_id", { length: 255 }), // 구매자 시스템의 제출 ID + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }), + syncStatus: varchar("sync_status", { length: 20 }).default("pending"), // pending, syncing, synced, failed, partial + syncError: text("sync_error"), + syncVersion: integer("sync_version").default(0), + lastModifiedBy: varchar("last_modified_by", { length: 100 }), // EVCP, BUYER_SYSTEM + syncRetryCount: integer("sync_retry_count").default(0), + nextRetryAt: timestamp("next_retry_at", { withTimezone: true }), + + // 동기화 통계 + totalFilesToSync: integer("total_files_to_sync").default(0), + syncedFilesCount: integer("synced_files_count").default(0), + failedFilesCount: integer("failed_files_count").default(0), + + // 타임스탬프 + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => { + return { + // 한 스테이지에서 리비전 번호는 유니크 + uniqueStageRevision: uniqueIndex("unique_stage_revision").on( + table.stageId, + table.revisionNumber + ), + + // 검색용 인덱스 + documentIdIndex: index("submission_document_id_idx").on(table.documentId), + vendorIdIndex: index("submission_vendor_id_idx").on(table.vendorId), + statusIndex: index("submission_status_idx").on(table.submissionStatus), + submittedAtIndex: index("submission_submitted_at_idx").on(table.submittedAt), + + // ✨ 동기화 관련 인덱스 + syncStatusIndex: index("submission_sync_status_idx").on(table.syncStatus), + buyerSystemStatusIndex: index("submission_buyer_status_idx").on(table.buyerSystemStatus), + nextRetryIndex: index("submission_next_retry_idx").on(table.nextRetryAt), + } + } +); + +// 📎 제출 첨부파일 테이블 - 구매자 시스템 동기화 필드 추가 +export const stageSubmissionAttachments = pgTable( + "stage_submission_attachments", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // 참조 관계 + submissionId: integer("submission_id") + .notNull() + .references(() => stageSubmissions.id, { onDelete: "cascade" }), + + // 파일 정보 + fileName: varchar("file_name", { length: 255 }).notNull(), + originalFileName: varchar("original_file_name", { length: 255 }).notNull(), + fileType: varchar("file_type", { length: 100 }), // application/pdf, image/jpeg, etc. + fileExtension: varchar("file_extension", { length: 20 }), + fileSize: bigint("file_size", { mode: "number" }).notNull(), // bytes + + // 저장 정보 + storageType: varchar("storage_type", { length: 50 }) + .default("S3"), // S3, LOCAL, AZURE, GCS + storagePath: varchar("storage_path", { length: 1000 }).notNull(), + storageUrl: varchar("storage_url", { length: 2000 }), + bucketName: varchar("bucket_name", { length: 255 }), + + // 파일 메타데이터 + mimeType: varchar("mime_type", { length: 100 }), + checksum: varchar("checksum", { length: 255 }), // MD5 or SHA256 + + // 문서 분류 (옵션) + documentType: varchar("document_type", { length: 100 }), // DRAWING, SPEC, REPORT, CERTIFICATE, etc. + documentCategory: varchar("document_category", { length: 100 }), + + // 버전 관리 + fileVersion: varchar("file_version", { length: 50 }), + isLatest: boolean("is_latest").default(true), + + // 업로드 정보 + uploadedBy: varchar("uploaded_by", { length: 100 }).notNull(), + uploadedAt: timestamp("uploaded_at", { withTimezone: true }).defaultNow().notNull(), + + // 상태 + status: varchar("status", { length: 50 }) + .default("ACTIVE"), // ACTIVE, DELETED, ARCHIVED + + // ✨ 구매자 시스템 동기화 정보 (파일 레벨) + buyerSystemFileId: varchar("buyer_system_file_id", { length: 255 }), // 구매자 시스템의 파일 ID + buyerSystemStatus: varchar("buyer_system_status", { length: 50 }), // PENDING, UPLOADING, UPLOADED, FAILED + buyerSystemComment: text("buyer_system_comment"), + buyerSystemUrl: varchar("buyer_system_url", { length: 2000 }), // 구매자 시스템에서의 파일 URL + + // 동기화 상태 + syncStatus: varchar("sync_status", { length: 20 }).default("pending"), // pending, syncing, synced, failed + syncStartedAt: timestamp("sync_started_at", { withTimezone: true }), + syncCompletedAt: timestamp("sync_completed_at", { withTimezone: true }), + syncError: text("sync_error"), + syncRetryCount: integer("sync_retry_count").default(0), + lastSyncAttempt: timestamp("last_sync_attempt", { withTimezone: true }), + + // 전송 정보 + transferMethod: varchar("transfer_method", { length: 50 }), // API, FTP, SFTP, EMAIL + transferredBytes: bigint("transferred_bytes", { mode: "number" }), // 실제 전송된 바이트 + transferProgress: integer("transfer_progress"), // 0-100 퍼센트 + + // 동기화 메타데이터 + syncVersion: integer("sync_version").default(0), + lastModifiedBy: varchar("last_modified_by", { length: 100 }), // EVCP, BUYER_SYSTEM + + // 보안/접근 제어 + isPublic: boolean("is_public").default(false), + expiresAt: timestamp("expires_at", { withTimezone: true }), + + // 추가 메타데이터 + metadata: jsonb("metadata").$type<{ + pageCount?: number; + dimensions?: { width: number; height: number }; + duration?: number; // for videos + buyerSystemMetadata?: any; // 구매자 시스템에서 받은 추가 정보 + [key: string]: any; + }>(), + + // 타임스탬프 + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => { + return { + // 검색용 인덱스 + submissionIdIndex: index("attachment_submission_id_idx").on(table.submissionId), + fileTypeIndex: index("attachment_file_type_idx").on(table.fileType), + statusIndex: index("attachment_status_idx").on(table.status), + uploadedAtIndex: index("attachment_uploaded_at_idx").on(table.uploadedAt), + + // ✨ 동기화 관련 인덱스 + syncStatusIndex: index("attachment_sync_status_idx").on(table.syncStatus), + buyerSystemFileIdIndex: index("attachment_buyer_file_id_idx").on(table.buyerSystemFileId), + buyerSystemStatusIndex: index("attachment_buyer_status_idx").on(table.buyerSystemStatus), + } + } +); + + + +// 🔍 제출 현황 뷰 수정 - 동기화 상태 포함 +export const stageSubmissionView = pgView("stage_submission_view", { + // 문서 정보 + documentId: integer("document_id").notNull(), + docNumber: varchar("doc_number", { length: 100 }).notNull(), + vendorDocNumber: varchar("vendor_doc_number", { length: 100 }), + documentTitle: varchar("document_title", { length: 255 }).notNull(), + + // 프로젝트/벤더 정보 + projectId: integer("project_id").notNull(), + projectCode: varchar("project_code", { length: 50 }), + vendorId: integer("vendor_id").notNull(), + vendorName: varchar("vendor_name", { length: 255 }), + vendorCode: varchar("vendor_code", { length: 50 }), + + // 스테이지 정보 + stageId: integer("stage_id"), + stageName: varchar("stage_name", { length: 100 }), + stageOrder: integer("stage_order"), + stageStatus: varchar("stage_status", { length: 50 }), + stagePlanDate: date("stage_plan_date"), + + // 최신 제출 정보 + latestSubmissionId: integer("latest_submission_id"), + latestRevisionNumber: integer("latest_revision_number"), + latestRevisionCode: varchar("latest_revision_code", { length: 50 }), // ⭐ 추가 + latestSubmissionStatus: varchar("latest_submission_status", { length: 50 }), + latestSubmittedAt: timestamp("latest_submitted_at", { withTimezone: true }), + latestSubmittedBy: varchar("latest_submitted_by", { length: 100 }), + latestReviewStatus: varchar("latest_review_status", { length: 50 }), + + // ✨ 동기화 상태 + latestSyncStatus: varchar("latest_sync_status", { length: 20 }), + latestBuyerSystemStatus: varchar("latest_buyer_system_status", { length: 50 }), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }), + totalFilesToSync: integer("total_files_to_sync"), + syncedFilesCount: integer("synced_files_count"), + syncProgress: integer("sync_progress"), // 0-100 퍼센트 + + // 제출 통계 + totalSubmissions: integer("total_submissions"), + approvedSubmissions: integer("approved_submissions"), + rejectedSubmissions: integer("rejected_submissions"), + pendingSubmissions: integer("pending_submissions"), + + // 파일 정보 + totalFiles: integer("total_files"), + totalFileSize: bigint("total_file_size", { mode: "number" }), + + // 제출 필요 여부 + requiresSubmission: boolean("requires_submission"), + requiresSync: boolean("requires_sync"), + isOverdue: boolean("is_overdue"), + daysUntilDue: integer("days_until_due"), + + // 제출 이력 (revisionCode 추가) + submissionHistory: jsonb("submission_history").$type>(), + +}).as(sql` + WITH eligible_documents AS ( + -- buyerSystemStatus가 '승인(DC)'인 문서만 선택 + SELECT + sd.id as document_id, + sd.doc_number, + sd.vendor_doc_number, + sd.title as document_title, + sd.project_id, + sd.vendor_id, + p.code as project_code, + v.vendor_name, + v.vendor_code + FROM stage_documents sd + LEFT JOIN projects p ON sd.project_id = p.id + LEFT JOIN vendors v ON sd.vendor_id = v.id + WHERE sd.buyer_system_status = '승인(DC)' + AND sd.status = 'ACTIVE' + ), + + submission_stats AS ( + SELECT + ss.stage_id, + COUNT(*) as total_submissions, + COUNT(CASE WHEN ss.review_status = 'APPROVED' THEN 1 END) as approved_submissions, + COUNT(CASE WHEN ss.review_status = 'REJECTED' THEN 1 END) as rejected_submissions, + COUNT(CASE WHEN ss.review_status = 'PENDING' OR ss.review_status IS NULL THEN 1 END) as pending_submissions, + SUM(ss.total_files) as total_files, + SUM(ss.total_file_size) as total_file_size, + MAX(ss.revision_number) as latest_revision_number + FROM stage_submissions ss + GROUP BY ss.stage_id + ), + + latest_submission AS ( + SELECT DISTINCT ON (stage_id) + stage_id, + id as submission_id, + revision_number, + revision_code, -- ⭐ 추가 + submission_status, + submitted_at, + submitted_by, + review_status, + sync_status, + buyer_system_status, + last_synced_at, + total_files_to_sync, + synced_files_count, + CASE + WHEN total_files_to_sync > 0 + THEN ROUND((synced_files_count * 100.0) / total_files_to_sync) + ELSE 0 + END as sync_progress + FROM stage_submissions + ORDER BY stage_id, revision_number DESC + ), + + submission_history_agg AS ( + SELECT + ss.stage_id, + json_agg( + json_build_object( + 'submissionId', ss.id, + 'revisionNumber', ss.revision_number, + 'revisionCode', ss.revision_code, -- ⭐ 추가 + 'status', ss.submission_status, + 'submittedAt', ss.submitted_at, + 'submittedBy', ss.submitted_by, + 'reviewStatus', ss.review_status, + 'syncStatus', ss.sync_status, + 'fileCount', ss.total_files + ) ORDER BY ss.revision_number DESC + ) as submission_history + FROM stage_submissions ss + GROUP BY ss.stage_id + ) + + SELECT + ed.document_id, + ed.doc_number, + ed.vendor_doc_number, + ed.document_title, + ed.project_id, + ed.project_code, + ed.vendor_id, + ed.vendor_name, + ed.vendor_code, + + ist.id as stage_id, + ist.stage_name, + ist.stage_order, + ist.stage_status, + ist.plan_date as stage_plan_date, + + ls.submission_id as latest_submission_id, + ls.revision_number as latest_revision_number, + ls.revision_code as latest_revision_code, -- ⭐ 추가 + ls.submission_status as latest_submission_status, + ls.submitted_at as latest_submitted_at, + ls.submitted_by as latest_submitted_by, + ls.review_status as latest_review_status, + + -- 동기화 상태 + ls.sync_status as latest_sync_status, + ls.buyer_system_status as latest_buyer_system_status, + ls.last_synced_at, + ls.total_files_to_sync, + ls.synced_files_count, + ls.sync_progress, + + COALESCE(ss.total_submissions, 0) as total_submissions, + COALESCE(ss.approved_submissions, 0) as approved_submissions, + COALESCE(ss.rejected_submissions, 0) as rejected_submissions, + COALESCE(ss.pending_submissions, 0) as pending_submissions, + COALESCE(ss.total_files, 0) as total_files, + COALESCE(ss.total_file_size, 0) as total_file_size, + + -- 제출이 필요한지 판단 + CASE + WHEN ist.stage_status IN ('PLANNED', 'IN_PROGRESS', 'REJECTED') + AND (ls.review_status IS NULL OR ls.review_status != 'APPROVED') + THEN true + ELSE false + END as requires_submission, + + -- 동기화가 필요한지 판단 + CASE + WHEN ls.sync_status IN ('pending', 'failed') + OR ls.synced_files_count < ls.total_files_to_sync + THEN true + ELSE false + END as requires_sync, + + -- 기한 관련 + CASE + WHEN ist.plan_date < CURRENT_DATE + AND ist.stage_status NOT IN ('COMPLETED', 'APPROVED') + THEN true + ELSE false + END as is_overdue, + + CASE + WHEN ist.plan_date IS NOT NULL + THEN ist.plan_date - CURRENT_DATE + ELSE NULL + END as days_until_due, + + COALESCE(sha.submission_history, '[]'::json) as submission_history + + FROM eligible_documents ed + INNER JOIN stage_issue_stages ist ON ed.document_id = ist.document_id + LEFT JOIN submission_stats ss ON ist.id = ss.stage_id + LEFT JOIN latest_submission ls ON ist.id = ls.stage_id + LEFT JOIN submission_history_agg sha ON ist.id = sha.stage_id + + ORDER BY ed.document_id, ist.stage_order +`); + +export type StageSubmissionView = typeof stageSubmissionView.$inferSelect \ No newline at end of file diff --git a/hooks/use-document-polling.ts b/hooks/use-document-polling.ts new file mode 100644 index 00000000..21162491 --- /dev/null +++ b/hooks/use-document-polling.ts @@ -0,0 +1,76 @@ +// app/hooks/use-document-polling.ts +"use client" + +import { pullDocumentStatusFromSHI } from "@/lib/vendor-document-list/plant/document-stages-service" +import { useEffect, useState } from "react" +import { toast } from "sonner" + +interface UseDocumentPollingProps { + contractId: number + autoStart?: boolean + onUpdate?: () => void +} + +export function useDocumentPolling({ + contractId, + autoStart = true, + onUpdate +}: UseDocumentPollingProps) { + const [isPolling, setIsPolling] = useState(false) + const [lastPolledAt, setLastPolledAt] = useState(null) + const [pollingStatus, setPollingStatus] = useState<'idle' | 'polling' | 'success' | 'error'>('idle') + + // 폴링 함수 + const pollDocuments = async (showToast = true) => { + setIsPolling(true) + setPollingStatus('polling') + + try { + const result = await pullDocumentStatusFromSHI(contractId) + + if (result.success) { + setPollingStatus('success') + setLastPolledAt(new Date()) + + if (showToast) { + if (result.updatedCount > 0) { + toast.success(result.message) + } else { + toast.info("모든 문서가 최신 상태입니다.") + } + } + + // 업데이트 콜백 실행 + if (result.updatedCount > 0 && onUpdate) { + onUpdate() + } + } else { + setPollingStatus('error') + if (showToast) { + toast.error(result.message) + } + } + } catch (error) { + setPollingStatus('error') + if (showToast) { + toast.error("동기화 중 오류가 발생했습니다.") + } + } finally { + setIsPolling(false) + } + } + + // 페이지 로드 시 자동 폴링 + useEffect(() => { + if (autoStart && contractId) { + pollDocuments(false) // 초기 로드 시에는 토스트 메시지 표시 안 함 + } + }, [contractId]) + + return { + isPolling, + lastPolledAt, + pollingStatus, + pollDocuments + } +} \ No newline at end of file diff --git a/i18n/locales/en/document.json b/i18n/locales/en/document.json index cc770eb5..4313cfcd 100644 --- a/i18n/locales/en/document.json +++ b/i18n/locales/en/document.json @@ -15,8 +15,8 @@ "overdueDocuments": "Overdue Documents", "dueSoonDocuments": "Due Soon", "averageProgress": "Average Progress", - "totalCount": "Showing {shown} of {total} total", - "totalDocumentCount": "Total {total} documents", + "totalCount": "Showing {{shown}} of {total} total", + "totalDocumentCount": "Total {{total}} documents", "checkImmediately": "Requires immediate attention", "dueInDays": "Due within 3 days", "overallProgress": "Overall project progress" diff --git a/i18n/locales/ko/document.json b/i18n/locales/ko/document.json index 5caa3e7e..f416986e 100644 --- a/i18n/locales/ko/document.json +++ b/i18n/locales/ko/document.json @@ -5,8 +5,8 @@ "overdueDocuments": "지연 문서", "dueSoonDocuments": "마감 임박", "averageProgress": "평균 진행률", - "totalCount": "총 {total}개 중 {shown}개 표시", - "totalDocumentCount": "총 {total}개 문서", + "totalCount": "총 {{total}}개 중 {{shown}}개 표시", + "totalDocumentCount": "총 {{total}}개 문서", "checkImmediately": "즉시 확인 필요", "dueInDays": "3일 이내 마감", "overallProgress": "전체 프로젝트 진행도" diff --git a/lib/general-contracts/detail/general-contract-detail.tsx b/lib/general-contracts/detail/general-contract-detail.tsx index 9d9f35bd..8e7a7aff 100644 --- a/lib/general-contracts/detail/general-contract-detail.tsx +++ b/lib/general-contracts/detail/general-contract-detail.tsx @@ -149,7 +149,6 @@ export default function ContractDetailPage() { items={[]} onItemsChange={() => {}} onTotalAmountChange={() => {}} - currency="USD" availableBudget={0} readOnly={contract?.contractScope === '단가' || contract?.contractScope === '물량(실적)'} /> diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index 5176c6ce..1b9a1a06 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, @@ -26,12 +27,13 @@ import { Save, LoaderIcon } from 'lucide-react' interface ContractItem { id?: number - project: string itemCode: string itemInfo: string specification: string quantity: number quantityUnit: string + totalWeight: number + weightUnit: string contractDeliveryDate: string contractUnitPrice: number contractAmount: number @@ -45,22 +47,27 @@ interface ContractItemsTableProps { items: ContractItem[] onItemsChange: (items: ContractItem[]) => void onTotalAmountChange: (total: number) => void - currency?: string availableBudget?: number readOnly?: boolean } +// 통화 목록 +const CURRENCIES = ["USD", "EUR", "KRW", "JPY", "CNY"]; + +// 수량 단위 목록 +const QUANTITY_UNITS = ["KG", "TON", "EA", "M", "M2", "M3", "L", "ML", "G", "SET", "PCS"]; + +// 중량 단위 목록 +const WEIGHT_UNITS = ["KG", "TON", "G", "LB", "OZ"]; + export function ContractItemsTable({ contractId, items, onItemsChange, onTotalAmountChange, - currency = 'USD', availableBudget = 0, readOnly = false }: ContractItemsTableProps) { - // 통화 코드가 null이거나 undefined일 때 기본값 설정 - const safeCurrency = currency || 'USD' const [localItems, setLocalItems] = React.useState(items) const [isSaving, setIsSaving] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false) @@ -74,16 +81,17 @@ export function ContractItemsTable({ const fetchedItems = await getContractItems(contractId) const formattedItems = fetchedItems.map(item => ({ id: item.id, - project: item.project || '', itemCode: item.itemCode || '', itemInfo: item.itemInfo || '', specification: item.specification || '', quantity: Number(item.quantity) || 0, - quantityUnit: item.quantityUnit || 'KG', + quantityUnit: item.quantityUnit || 'EA', + totalWeight: Number(item.totalWeight) || 0, + weightUnit: item.weightUnit || 'KG', contractDeliveryDate: item.contractDeliveryDate || '', contractUnitPrice: Number(item.contractUnitPrice) || 0, contractAmount: Number(item.contractAmount) || 0, - contractCurrency: item.contractCurrency || safeCurrency, + contractCurrency: item.contractCurrency || 'KRW', isSelected: false })) as ContractItem[] setLocalItems(formattedItems as ContractItem[]) @@ -99,7 +107,7 @@ export function ContractItemsTable({ } loadItems() - }, [contractId, currency, onItemsChange]) + }, [contractId, onItemsChange]) // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선) React.useEffect(() => { @@ -116,10 +124,8 @@ export function ContractItemsTable({ const errors: string[] = [] for (let index = 0; index < localItems.length; index++) { const item = localItems[index] - if (!item.project) errors.push(`${index + 1}번째 품목의 프로젝트`) if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) - if (!item.specification) errors.push(`${index + 1}번째 품목의 사양`) if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`) @@ -170,16 +176,17 @@ export function ContractItemsTable({ // 행 추가 const addRow = () => { const newItem: ContractItem = { - project: '', itemCode: '', itemInfo: '', specification: '', quantity: 0, - quantityUnit: 'KG', + quantityUnit: 'EA', // 기본 수량 단위 + totalWeight: 0, + weightUnit: 'KG', // 기본 중량 단위 contractDeliveryDate: '', contractUnitPrice: 0, contractAmount: 0, - contractCurrency: safeCurrency, + contractCurrency: 'KRW', // 기본 통화 isSelected: false } const updatedItems = [...localItems, newItem] @@ -213,10 +220,10 @@ export function ContractItemsTable({ // 통화 포맷팅 - const formatCurrency = (amount: number) => { + const formatCurrency = (amount: number, currency: string = 'KRW') => { return new Intl.NumberFormat('ko-KR', { style: 'currency', - currency: safeCurrency, + currency: currency, }).format(amount) } @@ -270,7 +277,7 @@ export function ContractItemsTable({
- 총 금액: {totalAmount.toLocaleString()} {currency} + 총 금액: {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')} 총 수량: {totalQuantity.toLocaleString()}
{!readOnly && ( @@ -316,19 +323,19 @@ export function ContractItemsTable({
- {formatCurrency(totalAmount)} + {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
- {formatCurrency(availableBudget)} + {formatCurrency(availableBudget, localItems[0]?.contractCurrency || 'KRW')}
= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(amountDifference)} + {formatCurrency(amountDifference, localItems[0]?.contractCurrency || 'KRW')}
@@ -357,12 +364,13 @@ export function ContractItemsTable({ /> )} - 프로젝트 품목코드 (PKG No.) Item 정보 (자재그룹 / 자재코드) 규격 수량 수량단위 + 총 중량 + 중량단위 계약납기일 계약단가 계약금액 @@ -383,19 +391,6 @@ export function ContractItemsTable({ /> )} - - {readOnly ? ( - {item.project || '-'} - ) : ( - updateItem(index, 'project', e.target.value)} - placeholder="프로젝트" - className="h-8 text-sm" - disabled={!isEnabled} - /> - )} - {readOnly ? ( {item.itemCode || '-'} @@ -453,15 +448,60 @@ export function ContractItemsTable({ {readOnly ? ( {item.quantityUnit || '-'} ) : ( - updateItem(index, 'quantityUnit', e.target.value)} - placeholder="단위" - className="h-8 text-sm w-16" + onValueChange={(value) => updateItem(index, 'quantityUnit', value)} + disabled={!isEnabled} + > + + + + + {QUANTITY_UNITS.map((unit) => ( + + {unit} + + ))} + + + )} + + + {readOnly ? ( + {item.totalWeight.toLocaleString()} + ) : ( + updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)} + className="h-8 text-sm text-right" + placeholder="0" disabled={!isEnabled} /> )} + + {readOnly ? ( + {item.weightUnit || '-'} + ) : ( + + )} + {readOnly ? ( {item.contractDeliveryDate || '-'} @@ -498,13 +538,22 @@ export function ContractItemsTable({ {readOnly ? ( {item.contractCurrency || '-'} ) : ( - updateItem(index, 'contractCurrency', e.target.value)} - placeholder="통화" - className="h-8 text-sm w-16" + onValueChange={(value) => updateItem(index, 'contractCurrency', value)} disabled={!isEnabled} - /> + > + + + + + {CURRENCIES.map((currency) => ( + + {currency} + + ))} + + )} @@ -528,14 +577,14 @@ export function ContractItemsTable({
총 단가 - {totalUnitPrice.toLocaleString()} {currency} + {formatCurrency(totalUnitPrice, localItems[0]?.contractCurrency || 'KRW')}
합계 금액 - {formatCurrency(totalAmount)} + {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts index 8c74c616..52301dae 100644 --- a/lib/general-contracts/service.ts +++ b/lib/general-contracts/service.ts @@ -372,7 +372,8 @@ export async function createContract(data: Record) { try { // 계약번호 자동 생성 // TODO: 구매 발주담당자 코드 필요 - 파라미터 추가 - const userId = data.registeredById as string + const rawUserId = data.registeredById + const userId = (rawUserId && !isNaN(Number(rawUserId))) ? String(rawUserId) : undefined const contractNumber = await generateContractNumber( userId, data.type as string @@ -676,6 +677,8 @@ export async function updateContractItems(contractId: number, items: Record= 3) { purchaseManagerCode = user[0].userCode.substring(0, 3).toUpperCase(); @@ -1774,8 +1777,20 @@ export async function generateContractNumber( let sequenceNumber = 1 if (existingContracts.length > 0) { const lastContractNumber = existingContracts[0].contractNumber - const lastSequence = parseInt(lastContractNumber.slice(-3)) - sequenceNumber = lastSequence + 1 + const lastSequenceStr = lastContractNumber.slice(-3) + + // contractNumber에서 숫자만 추출하여 sequence 찾기 + const numericParts = lastContractNumber.match(/\d+/g) + if (numericParts && numericParts.length > 0) { + // 마지막 숫자 부분을 시퀀스로 사용 (일반적으로 마지막 3자리) + const potentialSequence = numericParts[numericParts.length - 1] + const lastSequence = parseInt(potentialSequence) + + if (!isNaN(lastSequence)) { + sequenceNumber = lastSequence + 1 + } + } + // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지 } // 일련번호를 3자리로 포맷팅 @@ -1797,8 +1812,19 @@ export async function generateContractNumber( let sequenceNumber = 1 if (existingContracts.length > 0) { const lastContractNumber = existingContracts[0].contractNumber - const lastSequence = parseInt(lastContractNumber.slice(-3)) - sequenceNumber = lastSequence + 1 + + // contractNumber에서 숫자만 추출하여 sequence 찾기 + const numericParts = lastContractNumber.match(/\d+/g) + if (numericParts && numericParts.length > 0) { + // 마지막 숫자 부분을 시퀀스로 사용 + const potentialSequence = numericParts[numericParts.length - 1] + const lastSequence = parseInt(potentialSequence) + + if (!isNaN(lastSequence)) { + sequenceNumber = lastSequence + 1 + } + } + // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지 } // 최종 계약번호 생성: C + 발주담당자코드(3자리) + 계약종류(2자리) + 연도(2자리) + 일련번호(3자리) diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx index 155fd412..09c9fe35 100644 --- a/lib/rfq-last/attachment/rfq-attachments-table.tsx +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -50,6 +50,7 @@ import { AddAttachmentDialog } from "./add-attachment-dialog"; import { UpdateRevisionDialog } from "./update-revision-dialog"; import { toast } from "sonner"; import { RevisionHistoryDialog } from "./revision-historty-dialog"; +import { createFilterFn } from "@/components/client-data-table/table-filters"; // 타입 정의 interface RfqAttachment { @@ -238,6 +239,7 @@ export function RfqAttachmentsTable({ { accessorKey: "serialNo", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => ( {row.original.serialNo || "-"} ), @@ -248,6 +250,7 @@ export function RfqAttachmentsTable({ { accessorKey: "originalFileName", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => { const file = row.original; return ( @@ -266,6 +269,7 @@ export function RfqAttachmentsTable({ { accessorKey: "description", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => (
{row.original.description || "-"} @@ -276,6 +280,7 @@ export function RfqAttachmentsTable({ { accessorKey: "currentRevision", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => { const revision = row.original.currentRevision; return revision ? ( @@ -291,6 +296,7 @@ export function RfqAttachmentsTable({ { accessorKey: "fileSize", header: ({ column }) => , + filterFn: createFilterFn("number"), // number 타입으로 변경 cell: ({ row }) => ( {formatFileSize(row.original.fileSize)} @@ -298,15 +304,51 @@ export function RfqAttachmentsTable({ ), size: 80, }, + { + accessorKey: "fileType", + header: ({ column }) => , + filterFn: createFilterFn("select"), // 추가 + cell: ({ row }) => { + const fileType = row.original.fileType; + if (!fileType) return -; + + const type = fileType.toLowerCase(); + let displayType = "기타"; + let color = "text-gray-500"; + + if (type.includes('pdf')) { + displayType = "PDF"; + color = "text-red-500"; + } else if (type.includes('excel') || ['xls', 'xlsx'].includes(type)) { + displayType = "Excel"; + color = "text-green-500"; + } else if (type.includes('word') || ['doc', 'docx'].includes(type)) { + displayType = "Word"; + color = "text-blue-500"; + } else if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) { + displayType = "이미지"; + color = "text-purple-500"; + } + + return ( + + {displayType} + + ); + }, + size: 100, + }, { accessorKey: "createdByName", header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => row.original.createdByName || "-", size: 100, }, { accessorKey: "createdAt", header: ({ column }) => , + filterFn: createFilterFn("date"), // date 타입으로 변경 cell: ({ row }) => { const date = row.original.createdAt; return date ? ( @@ -334,53 +376,45 @@ export function RfqAttachmentsTable({ { accessorKey: "updatedAt", header: ({ column }) => , + filterFn: createFilterFn("date"), // date 타입으로 변경 cell: ({ row }) => { const date = row.original.updatedAt; return date ? format(new Date(date), "MM-dd HH:mm") : "-"; }, size: 100, }, + { + accessorKey: "revisionComment", + header: ({ column }) => , + filterFn: createFilterFn("text"), // 추가 + cell: ({ row }) => { + const comment = row.original.revisionComment; + return comment ? ( + + + + + {comment} + + + +

{comment}

+
+
+
+ ) : ( + - + ); + }, + size: 150, + }, { id: "actions", header: "작업", cell: ({ row }) => { return ( - - - - - handleAction({ row, type: "download" })}> - - 다운로드 - - handleAction({ row, type: "preview" })}> - - 미리보기 - - - handleAction({ row, type: "history" })}> - - 리비전 이력 - - handleAction({ row, type: "update" })}> - - 새 버전 업로드 - - - handleAction({ row, type: "delete" })} - className="text-red-600" - > - - 삭제 - - + {/* ... 기존 드롭다운 메뉴 내용 ... */} ); }, @@ -394,18 +428,18 @@ export function RfqAttachmentsTable({ { id: "originalFileName", label: "파일명", type: "text" }, { id: "description", label: "설명", type: "text" }, { id: "currentRevision", label: "리비전", type: "text" }, - { - id: "fileType", - label: "파일 타입", - type: "select", - options: [ - { label: "PDF", value: "pdf" }, - { label: "Excel", value: "xlsx" }, - { label: "Word", value: "docx" }, - { label: "이미지", value: "image" }, - { label: "기타", value: "other" }, - ] - }, + // { + // id: "fileType", + // label: "파일 타입", + // type: "select", + // options: [ + // { label: "PDF", value: "pdf" }, + // { label: "Excel", value: "xlsx" }, + // { label: "Word", value: "docx" }, + // { label: "이미지", value: "image" }, + // { label: "기타", value: "other" }, + // ] + // }, { id: "createdByName", label: "업로드자", type: "text" }, { id: "createdAt", label: "업로드일", type: "date" }, { id: "updatedAt", label: "수정일", type: "date" }, diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 28c8b3b1..91d46295 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -507,7 +507,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { 일반계약 - + */}
)}
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 85db1ea7..43943c71 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3096,7 +3096,6 @@ async function processVendors({ // PDF 저장 디렉토리 준비 const contractsDir = path.join( - process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated" diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index 7abf06a3..2c69f4b7 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -347,7 +347,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp {/* 스크롤 가능한 컨텐츠 영역 */}
- + {/* 기본 정보 섹션 */}
@@ -766,8 +766,10 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp {rfqCategory === "general" && ( - + )} -
+ {/* 담당자 지정 다이얼로그 */} { try { setIsUpdatingShortList(true); - + const vendorIds = selectedRows .map(vendor => vendor.vendorId) .filter(id => id != null); @@ -320,7 +323,7 @@ export function RfqVendorTable({ // 견적 비교 핸들러 const handleQuotationCompare = React.useCallback(() => { - const vendorsWithQuotation = selectedRows.filter(row => + const vendorsWithQuotation = selectedRows.filter(row => row.response?.submission?.submittedAt ); @@ -334,7 +337,7 @@ export function RfqVendorTable({ .map(v => v.vendorId) .filter(id => id != null) .join(','); - + router.push(`/evcp/rfq-last/${rfqId}/compare?vendors=${vendorIds}`); }, [selectedRows, rfqId, router]); @@ -349,8 +352,8 @@ export function RfqVendorTable({ setIsLoadingSendData(true); // 선택된 벤더 ID들 추출 - const selectedVendorIds = rfqCode?.startsWith("I")? selectedRows - .filter(v=>v.shortList) + const selectedVendorIds = rfqCode?.startsWith("I") ? selectedRows + .filter(v => v.shortList) .map(row => row.vendorId) .filter(id => id != null) : selectedRows @@ -468,7 +471,7 @@ export function RfqVendorTable({ } else { toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); } - + // 페이지 새로고침 router.refresh(); } catch (error) { @@ -593,6 +596,8 @@ export function RfqVendorTable({ { accessorKey: "rfqCode", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { return ( {row.original.rfqCode || "-"} @@ -603,6 +608,8 @@ export function RfqVendorTable({ { accessorKey: "vendorName", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const vendor = row.original; return ( @@ -620,12 +627,16 @@ export function RfqVendorTable({ { accessorKey: "vendorCategory", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => row.original.vendorCategory || "-", size: 100, }, { accessorKey: "vendorCountry", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const country = row.original.vendorCountry; const isLocal = country === "KR" || country === "한국"; @@ -640,6 +651,8 @@ export function RfqVendorTable({ { accessorKey: "vendorGrade", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const grade = row.original.vendorGrade; if (!grade) return -; @@ -661,9 +674,11 @@ export function RfqVendorTable({ header: ({ column }) => ( ), + filterFn: createFilterFn("text"), + cell: ({ row }) => { const status = row.original.tbeStatus; - + if (!status || status === "준비중") { return ( @@ -672,7 +687,7 @@ export function RfqVendorTable({ ); } - + const statusConfig = { "진행중": { variant: "default", icon: , color: "text-blue-600" }, "검토중": { variant: "secondary", icon: , color: "text-orange-600" }, @@ -680,7 +695,7 @@ export function RfqVendorTable({ "완료": { variant: "success", icon: , color: "text-green-600" }, "취소": { variant: "destructive", icon: , color: "text-red-600" }, }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; - + return ( {statusConfig.icon} @@ -690,42 +705,44 @@ export function RfqVendorTable({ }, size: 100, }, - + { accessorKey: "tbeEvaluationResult", header: ({ column }) => ( ), + filterFn: createFilterFn("text"), + cell: ({ row }) => { const result = row.original.tbeEvaluationResult; const status = row.original.tbeStatus; - + // TBE가 완료되지 않았으면 표시하지 않음 if (status !== "완료" || !result) { return -; } - + const resultConfig = { - "Acceptable": { - variant: "success", - icon: , + "Acceptable": { + variant: "success", + icon: , text: "적합", color: "bg-green-50 text-green-700 border-green-200" }, - "Acceptable with Comment": { - variant: "warning", - icon: , + "Acceptable with Comment": { + variant: "warning", + icon: , text: "조건부 적합", color: "bg-yellow-50 text-yellow-700 border-yellow-200" }, - "Not Acceptable": { - variant: "destructive", - icon: , + "Not Acceptable": { + variant: "destructive", + icon: , text: "부적합", color: "bg-red-50 text-red-700 border-red-200" }, }[result]; - + return ( @@ -755,6 +772,8 @@ export function RfqVendorTable({ { accessorKey: "contractRequirements", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const vendor = row.original; const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; @@ -833,6 +852,8 @@ export function RfqVendorTable({ { accessorKey: "sendVersion", header: ({ column }) => , + filterFn: createFilterFn("number"), + cell: ({ row }) => { const version = row.original.sendVersion; @@ -844,6 +865,8 @@ export function RfqVendorTable({ { accessorKey: "emailStatus", header: "이메일 상태", + filterFn: createFilterFn("text"), + cell: ({ row }) => { const response = row.original; const emailSentAt = response?.emailSentAt; @@ -936,6 +959,8 @@ export function RfqVendorTable({ { accessorKey: "currency", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const currency = row.original.currency; return currency ? ( @@ -949,6 +974,8 @@ export function RfqVendorTable({ { accessorKey: "paymentTermsCode", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const code = row.original.paymentTermsCode; const desc = row.original.paymentTermsDescription; @@ -972,12 +999,16 @@ export function RfqVendorTable({ { accessorKey: "taxCode", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => row.original.taxCode || "-", size: 60, }, { accessorKey: "deliveryDate", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const deliveryDate = row.original.deliveryDate; const contractDuration = row.original.contractDuration; @@ -1003,6 +1034,8 @@ export function RfqVendorTable({ { accessorKey: "incotermsCode", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const code = row.original.incotermsCode; const detail = row.original.incotermsDetail; @@ -1030,6 +1063,8 @@ export function RfqVendorTable({ { accessorKey: "placeOfShipping", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const place = row.original.placeOfShipping; return place ? ( @@ -1046,6 +1081,7 @@ export function RfqVendorTable({ { accessorKey: "placeOfDestination", header: ({ column }) => , + filterFn: createFilterFn("text"), cell: ({ row }) => { const place = row.original.placeOfDestination; return place ? ( @@ -1062,6 +1098,8 @@ export function RfqVendorTable({ { id: "additionalConditions", header: "추가조건", + filterFn: createFilterFn("text"), + cell: ({ row }) => { const conditions = formatAdditionalConditions(row.original); if (conditions === "-") { @@ -1084,6 +1122,8 @@ export function RfqVendorTable({ { accessorKey: "response.submission.submittedAt", header: ({ column }) => , + filterFn: createFilterFn("text"), + cell: ({ row }) => { const participationRepliedAt = row.original.response?.attend?.participationRepliedAt; @@ -1131,6 +1171,7 @@ export function RfqVendorTable({ }, ...(rfqCode?.startsWith("I") ? [{ accessorKey: "shortList", + filterFn: createFilterFn("boolean"), // boolean으로 변경 header: ({ column }) => , cell: ({ row }) => ( row.original.shortList ? ( @@ -1143,6 +1184,7 @@ export function RfqVendorTable({ }] : []), { accessorKey: "updatedByUserName", + filterFn: createFilterFn("text"), // 추가 header: ({ column }) => , cell: ({ row }) => { const name = row.original.updatedByUserName; @@ -1238,24 +1280,160 @@ export function RfqVendorTable({ } ], [handleAction, rfqCode, isLoadingSendData]); + // advancedFilterFields 정의 - columns와 매칭되도록 정리 const advancedFilterFields: DataTableAdvancedFilterField[] = [ - { id: "vendorName", label: "벤더명", type: "text" }, - { id: "vendorCode", label: "벤더코드", type: "text" }, - { id: "vendorCountry", label: "국가", type: "text" }, { - id: "response.status", - label: "응답 상태", + id: "rfqCode", + label: "ITB/RFQ/견적 No.", + type: "text" + }, + { + id: "vendorName", + label: "협력업체명", + type: "text" + }, + { + id: "vendorCode", + label: "협력업체코드", + type: "text" + }, + { + id: "vendorCategory", + label: "업체분류", + type: "select", + options: [ + { label: "제조업체", value: "제조업체" }, + { label: "무역업체", value: "무역업체" }, + { label: "대리점", value: "대리점" }, + // 실제 카테고리에 맞게 추가 + ] + }, + { + id: "vendorCountry", + label: "내외자(위치)", + type: "select", + options: [ + { label: "한국(KR)", value: "KR" }, + { label: "한국", value: "한국" }, + { label: "중국(CN)", value: "CN" }, + { label: "일본(JP)", value: "JP" }, + { label: "미국(US)", value: "US" }, + { label: "독일(DE)", value: "DE" }, + // 필요한 국가 추가 + ] + }, + { + id: "vendorGrade", + label: "AVL 등급", type: "select", options: [ - { label: "초대됨", value: "초대됨" }, - { label: "작성중", value: "작성중" }, - { label: "제출완료", value: "제출완료" }, - { label: "수정요청", value: "수정요청" }, - { label: "최종확정", value: "최종확정" }, + { label: "A", value: "A" }, + { label: "B", value: "B" }, + { label: "C", value: "C" }, + { label: "D", value: "D" }, + ] + }, + { + id: "tbeStatus", + label: "TBE 상태", + type: "select", + options: [ + { label: "대기", value: "준비중" }, + { label: "진행중", value: "진행중" }, + { label: "검토중", value: "검토중" }, + { label: "보류", value: "보류" }, + { label: "완료", value: "완료" }, { label: "취소", value: "취소" }, ] }, { + id: "tbeEvaluationResult", + label: "TBE 평가결과", + type: "select", + options: [ + { label: "적합", value: "Acceptable" }, + { label: "조건부 적합", value: "Acceptable with Comment" }, + { label: "부적합", value: "Not Acceptable" }, + ] + }, + { + id: "sendVersion", + label: "발송 회차", + type: "number" + }, + { + id: "emailStatus", + label: "이메일 상태", + type: "select", + options: [ + { label: "미발송", value: "미발송" }, + { label: "발송됨", value: "sent" }, + { label: "발송 실패", value: "failed" }, + ] + }, + { + id: "currency", + label: "요청 통화", + type: "select", + options: [ + { label: "KRW", value: "KRW" }, + { label: "USD", value: "USD" }, + { label: "EUR", value: "EUR" }, + { label: "JPY", value: "JPY" }, + { label: "CNY", value: "CNY" }, + ] + }, + { + id: "paymentTermsCode", + label: "지급조건", + type: "text" + }, + { + id: "taxCode", + label: "Tax", + type: "text", + }, + { + id: "deliveryDate", + label: "계약납기일", + type: "date" + }, + { + id: "contractDuration", + label: "계약기간", + type: "text" + }, + { + id: "incotermsCode", + label: "Incoterms", + type: "text", + }, + { + id: "placeOfShipping", + label: "선적지", + type: "text" + }, + { + id: "placeOfDestination", + label: "도착지", + type: "text" + }, + { + id: "firstYn", + label: "초도품", + type: "boolean" + }, + { + id: "materialPriceRelatedYn", + label: "연동제", + type: "boolean" + }, + { + id: "sparepartYn", + label: "스페어파트", + type: "boolean" + }, + ...(rfqCode?.startsWith("I") ? [{ id: "shortList", label: "Short List", type: "select", @@ -1263,7 +1441,12 @@ export function RfqVendorTable({ { label: "선정", value: "true" }, { label: "대기", value: "false" }, ] - }, + }] : []), + { + id: "updatedByUserName", + label: "최신수정자", + type: "text" + } ]; // 선택된 벤더 정보 (BatchUpdate용) @@ -1280,15 +1463,27 @@ export function RfqVendorTable({ // 참여 의사가 있는 선택된 벤더 수 계산 const participatingCount = selectedRows.length; - const shortListCount = selectedRows.filter(v=>v.shortList).length; + const shortListCount = selectedRows.filter(v => v.shortList).length; // 견적서가 있는 선택된 벤더 수 계산 - const quotationCount = selectedRows.filter(row => + const quotationCount = selectedRows.filter(row => row.response?.submission?.submittedAt ).length; return (
+ {(rfqCode?.startsWith("I") || rfqCode?.startsWith("R")) && + + + } + + - + {selectedRows.length > 0 && ( <> {/* Short List 확정 버튼 */} - {rfqCode?.startsWith("I")&& - - } + > + {isUpdatingShortList ? ( + <> + + 처리중... + + ) : ( + <> + + Short List 확정 + {participatingCount > 0 && ` (${participatingCount})`} + + )} + + } {/* 견적 비교 버튼 */} @@ -1370,7 +1565,7 @@ export function RfqVendorTable({ )} - + - @@ -532,7 +532,7 @@ export function AddDocumentDialog({ interface EditDocumentDialogProps { open: boolean onOpenChange: (open: boolean) => void - document: DocumentStagesOnlyView | null + document: StageDocumentsView | null contractId: number projectType: "ship" | "plant" } @@ -753,7 +753,7 @@ export function EditDocumentDialog({ interface EditStageDialogProps { open: boolean onOpenChange: (open: boolean) => void - document: DocumentStagesOnlyView | null + document: StageDocumentsView | null stageId: number | null } @@ -1290,7 +1290,7 @@ export function ExcelImportDialog({ interface DeleteDocumentsDialogProps extends React.ComponentPropsWithoutRef { - documents: Row["original"][] + documents: Row["original"][] showTrigger?: boolean onSuccess?: () => void } diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx index 87b221b7..601a9152 100644 --- a/lib/vendor-document-list/plant/document-stage-toolbar.tsx +++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx @@ -1,11 +1,10 @@ "use client" import * as React from "react" -import { type DocumentStagesOnlyView } from "@/db/schema" +import { type StageDocumentsView } from "@/db/schema" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Plus, FileSpreadsheet } from "lucide-react" +import { Download, RefreshCw, Send, CheckCircle, AlertCircle, Plus, FileSpreadsheet, Loader2 } from "lucide-react" import { toast } from "sonner" - import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" @@ -15,12 +14,17 @@ import { AddDocumentDialog, ExcelImportDialog } from "./document-stage-dialogs" +import { sendDocumentsToSHI } from "./document-stages-service" +import { useDocumentPolling } from "@/hooks/use-document-polling" +import { cn } from "@/lib/utils" +import { MultiUploadDialog } from "./upload/components/multi-upload-dialog" +// import { useRouter } from "next/navigation" // 서버 액션 import (필요한 경우) // import { importDocumentsExcel } from "./document-stages-service" interface DocumentsTableToolbarActionsProps { - table: Table + table: Table contractId: number projectType: "ship" | "plant" } @@ -33,6 +37,43 @@ export function DocumentsTableToolbarActions({ // 다이얼로그 상태 관리 const [showAddDialog, setShowAddDialog] = React.useState(false) const [showExcelImportDialog, setShowExcelImportDialog] = React.useState(false) + const [isSending, setIsSending] = React.useState(false) + const router = useRouter() + + // 자동 폴링 훅 사용 + const { + isPolling, + lastPolledAt, + pollingStatus, + pollDocuments + } = useDocumentPolling({ + contractId, + autoStart: true, + onUpdate: () => { + // 테이블 새로고침 + router.refresh() + } + }) + + async function handleSendToSHI() { + setIsSending(true) + try { + const result = await sendDocumentsToSHI(contractId) + + if (result.success) { + toast.success(result.message) + router.refresh() + // 테이블 새로고침 + } else { + toast.error(result.message) + } + } catch (error) { + toast.error("전송 중 오류가 발생했습니다.") + } finally { + setIsSending(false) + } + } + const handleExcelImport = () => { setShowExcelImportDialog(true) @@ -50,17 +91,28 @@ export function DocumentsTableToolbarActions({ }) } + + + return (
+ + + {/* 1) 선택된 문서가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} + {(() => { + const selectedRows = table.getFilteredSelectedRowModel().rows; + const deletableDocuments = selectedRows + .map((row) => row.original)s + .filter((doc) => !doc.buyerSystemStatus); // buyerSystemStatus가 null인 것만 필터링 + + return deletableDocuments.length > 0 ? ( + table.toggleAllRowsSelected(false)} + /> + ) : null; + })()} {/* 2) 새 문서 추가 다이얼로그 */} @@ -76,9 +128,45 @@ export function DocumentsTableToolbarActions({ projectType={projectType} /> + {/* SHI 전송 버튼 */} + + + + + | null>> + setRowAction: React.Dispatch | null>> projectType: string domain?: "evcp" | "partners" // 선택적 파라미터로 유지 } @@ -139,11 +144,11 @@ export function getDocumentStagesColumns({ setRowAction, projectType, domain = "partners", // 기본값 설정 -}: GetColumnsProps): ColumnDef[] { +}: GetColumnsProps): ColumnDef[] { const isPlantProject = projectType === "plant" const isEvcpDomain = domain === "evcp" - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ // 체크박스 선택 { id: "select", @@ -315,6 +320,75 @@ export function getDocumentStagesColumns({ // 나머지 공통 컬럼들 columns.push( // 현재 스테이지 (상태, 담당자 한 줄) + + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const doc = row.original + + return ( +
+ + {getStatusText(doc.status || '')} + +
+ ) + }, + size: 180, + enableResizing: true, + meta: { + excelHeader: "Document Status" + }, + }, + + { + accessorKey: "buyerSystemStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const doc = row.original + const getBuyerStatusBadge = () => { + if (!doc.buyerSystemStatus) { + return Not Recieved + } + + switch (doc.buyerSystemStatus) { + case '승인(DC)': + return Approved + case '검토중': + return 검토중 + case '반려': + return 반려 + default: + return {doc.buyerSystemStatus} + } + } + + return ( +
+ {getBuyerStatusBadge()} + {doc.buyerSystemComment && ( + + + + + +

{doc.buyerSystemComment}

+
+
+ )} +
+ ) + }, + size: 120, + }, { accessorKey: "currentStageName", header: ({ column }) => ( @@ -486,7 +560,7 @@ export function getDocumentStagesColumns({ label: "Delete Document", icon: Trash2, action: () => setRowAction({ row, type: "delete" }), - show: true, + show: !doc.buyerSystemStatus, // null일 때만 true className: "text-red-600 dark:text-red-400", shortcut: "⌘⌫" } diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx index ca5e9c5b..72a804a8 100644 --- a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx +++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx @@ -2,7 +2,7 @@ "use client" import React from "react" -import { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -40,7 +40,7 @@ import { toast } from "sonner" import { updateStage } from "./document-stages-service" interface DocumentStagesExpandedContentProps { - document: DocumentStagesOnlyView + document: StageDocumentsView onEditStage: (stageId: number) => void projectType: "ship" | "plant" } diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 57f17bae..30a235c3 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -4,7 +4,7 @@ import { revalidatePath, revalidateTag } from "next/cache" import { redirect } from "next/navigation" import db from "@/db/db" -import { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema" +import {stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema" import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm" import { createDocumentSchema, @@ -32,6 +32,7 @@ import { GetEnhancedDocumentsSchema, GetDocumentsSchema } from "../enhanced-docu import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository" import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { ShiBuyerSystemAPI } from "./shi-buyer-system-api" interface UpdateDocumentData { documentId: number @@ -810,7 +811,7 @@ export async function getDocumentClassOptions(documentClassId: number) { eq(documentClassOptions.isActive, true) ) ) - // .orderBy(asc(documentClassOptions.sortOrder)) + .orderBy(asc(documentClassOptions.sdq)) return { success: true, data: options } } catch (error) { @@ -920,6 +921,8 @@ export async function createDocument(data: CreateDocumentData) { }, }) + console.log(contract,"contract") + if (!contract) { return { success: false, error: "유효하지 않은 계약(ID)입니다." } } @@ -1053,7 +1056,7 @@ export async function getDocumentStagesOnly( finalWhere = and( advancedWhere, globalWhere, - eq(documentStagesOnlyView.contractId, contractId) + eq(stageDocumentsView.contractId, contractId) ) } @@ -1066,7 +1069,7 @@ export async function getDocumentStagesOnly( ? desc(stageDocumentsView[item.id]) : asc(stageDocumentsView[item.id]) ) - : [desc(documentStagesOnlyView.createdAt)] + : [desc(stageDocumentsView.createdAt)] // 트랜잭션 실행 @@ -1183,3 +1186,115 @@ export async function getDocumentsByStageStats(contractId: number) { return [] } } + + +export async function sendDocumentsToSHI(contractId: number) { + try { + const api = new ShiBuyerSystemAPI() + const result = await api.sendToSHI(contractId) + + // 캐시 무효화 + revalidatePath(`/partners/document-list-only/${contractId}`) + + return result + } catch (error) { + console.error("SHI 전송 실패:", error) + return { + success: false, + message: error instanceof Error ? error.message : "전송 중 오류가 발생했습니다." + } + } +} + +export async function pullDocumentStatusFromSHI( + contractId: number, +) { + try { + const api = new ShiBuyerSystemAPI() + const result = await api.pullDocumentStatus(contractId) + + // 캐시 무효화 + revalidatePath(`/partners/document-list-only/${contractId}`) + + return result + } catch (error) { + console.error("문서 상태 풀링 실패:", error) + return { + success: false, + message: error instanceof Error ? error.message : "상태 가져오기 중 오류가 발생했습니다." + } + } +} + + +interface FileValidation { + projectId: number + docNumber: string + stageName: string + revision: string +} + +interface ValidationResult { + projectId: number + docNumber: string + stageName: string + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: number + } +} + +export async function validateFiles(files: FileValidation[]): Promise { + const session = await getServerSession(authOptions) + if (!session?.user?.companyId) { + throw new Error("Unauthorized") + } + + const vendorId = session.user.companyId + const results: ValidationResult[] = [] + + for (const file of files) { + // stageSubmissionView에서 매칭되는 레코드 찾기 + const match = await db + .select({ + documentId: stageSubmissionView.documentId, + stageId: stageSubmissionView.stageId, + documentTitle: stageSubmissionView.documentTitle, + latestRevisionNumber: stageSubmissionView.latestRevisionNumber, + }) + .from(stageSubmissionView) + .where( + and( + eq(stageSubmissionView.vendorId, vendorId), + eq(stageSubmissionView.projectId, file.projectId), + eq(stageSubmissionView.docNumber, file.docNumber), + eq(stageSubmissionView.stageName, file.stageName) + ) + ) + .limit(1) + + if (match.length > 0) { + results.push({ + projectId: file.projectId, + docNumber: file.docNumber, + stageName: file.stageName, + matched: { + documentId: match[0].documentId, + stageId: match[0].stageId!, + documentTitle: match[0].documentTitle, + currentRevision: match[0].latestRevisionNumber || 0, + } + }) + } else { + results.push({ + projectId: file.projectId, + docNumber: file.docNumber, + stageName: file.stageName, + }) + } + } + + return results +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 3d2ddafd..50d54a92 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -9,7 +9,7 @@ import type { import { useDataTable } from "@/hooks/use-data-table" import { getDocumentStagesOnly } from "./document-stages-service" -import type { DocumentStagesOnlyView } from "@/db/schema" +import type { StageDocumentsView } from "@/db/schema" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -51,17 +51,17 @@ export function DocumentStagesTable({ const { data: session } = useSession() - + // URL에서 언어 파라미터 가져오기 const params = useParams() const lng = (params?.lng as string) || 'ko' const { t } = useTranslation(lng, 'document') - // 세션에서 도메인을 가져오기 - const currentDomain = session?.user?.domain as "evcp" | "partners" + // 세션에서 도메인을 가져오기 + const currentDomain = session?.user?.domain as "evcp" | "partners" // 상태 관리 - const [rowAction, setRowAction] = React.useState | null>(null) + const [rowAction, setRowAction] = React.useState | null>(null) const [expandedRows, setExpandedRows] = React.useState>(new Set()) const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') @@ -72,7 +72,7 @@ export function DocumentStagesTable({ const [excelImportOpen, setExcelImportOpen] = React.useState(false) // 선택된 항목들 - const [selectedDocument, setSelectedDocument] = React.useState(null) + const [selectedDocument, setSelectedDocument] = React.useState(null) const [selectedStageId, setSelectedStageId] = React.useState(null) // 컬럼 정의 @@ -116,7 +116,7 @@ export function DocumentStagesTable({ const stats = React.useMemo(() => { console.log('DocumentStagesTable - data:', data) console.log('DocumentStagesTable - data length:', data?.length) - + const totalDocs = data?.length || 0 const overdue = data?.filter(doc => doc.isOverdue)?.length || 0 const dueSoon = data?.filter(doc => @@ -138,7 +138,7 @@ export function DocumentStagesTable({ highPriority, avgProgress } - + console.log('DocumentStagesTable - stats:', result) return result }, [data]) @@ -201,10 +201,10 @@ export function DocumentStagesTable({ } // 필터 필드 정의 - const filterFields: DataTableFilterField[] = [ + const filterFields: DataTableFilterField[] = [ ] - const advancedFilterFields: DataTableAdvancedFilterField[] = [ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "docNumber", label: "문서번호", diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts index 3ddb7195..c1409205 100644 --- a/lib/vendor-document-list/plant/excel-import-export.ts +++ b/lib/vendor-document-list/plant/excel-import-export.ts @@ -10,7 +10,7 @@ import { type ExcelImportResult, type CreateDocumentInput } from './document-stage-validations' -import { DocumentStagesOnlyView } from '@/db/schema' +import { StageDocumentsView } from '@/db/schema' // ============================================================================= // 1. 엑셀 템플릿 생성 및 다운로드 @@ -510,7 +510,7 @@ function formatExcelDate(value: any): string | undefined { // 문서 데이터를 엑셀로 익스포트 export function exportDocumentsToExcel( - documents: DocumentStagesOnlyView[], + documents: StageDocumentsView[], projectType: "ship" | "plant" ) { const headers = [ @@ -609,7 +609,7 @@ export function exportDocumentsToExcel( } // 스테이지 상세 데이터를 엑셀로 익스포트 -export function exportStageDetailsToExcel(documents: DocumentStagesOnlyView[]) { +export function exportStageDetailsToExcel(documents: StageDocumentsView[]) { const headers = [ "문서번호", "문서명", diff --git a/lib/vendor-document-list/plant/shi-buyer-system-api.ts b/lib/vendor-document-list/plant/shi-buyer-system-api.ts new file mode 100644 index 00000000..1f15efa6 --- /dev/null +++ b/lib/vendor-document-list/plant/shi-buyer-system-api.ts @@ -0,0 +1,874 @@ +// app/lib/shi-buyer-system-api.ts +import db from "@/db/db" +import { stageDocuments, stageIssueStages, contracts, vendors, projects, stageSubmissions, stageSubmissionAttachments } from "@/db/schema" +import { eq, and, sql, ne } from "drizzle-orm" +import fs from 'fs/promises' +import path from 'path' + +interface ShiDocumentInfo { + PROJ_NO: string + SHI_DOC_NO: string + CATEGORY: string + RESPONSIBLE_CD: string + RESPONSIBLE: string + VNDR_CD: string + VNDR_NM: string + DSN_SKL: string + MIFP_CD: string + MIFP_NM: string + CG_EMPNO1: string + CG_EMPNM1: string + OWN_DOC_NO: string + DSC: string + DOC_CLASS: string + COMMENT: string + STATUS: string + CRTER: string + CRTE_DTM: string + CHGR: string + CHG_DTM: string +} + +interface ShiScheduleInfo { + PROJ_NO: string + SHI_DOC_NO: string + DDPKIND: string + SCHEDULE_TYPE: string + BASELINE1: string | null + REVISED1: string | null + FORECAST1: string | null + ACTUAL1: string | null + BASELINE2: string | null + REVISED2: string | null + FORECAST2: string | null + ACTUAL2: string | null + CRTER: string + CRTE_DTM: string + CHGR: string + CHG_DTM: string +} + +// SHI API 응답 타입 +interface ShiDocumentResponse { + PROJ_NO: string + SHI_DOC_NO: string + STATUS: string + COMMENT: string | null + CATEGORY?: string + RESPONSIBLE_CD?: string + RESPONSIBLE?: string + VNDR_CD?: string + VNDR_NM?: string + DSN_SKL?: string + MIFP_CD?: string + MIFP_NM?: string + CG_EMPNO1?: string + CG_EMPNM1?: string + OWN_DOC_NO?: string + DSC?: string + DOC_CLASS?: string + CRTER?: string + CRTE_DTM?: string + CHGR?: string + CHG_DTM?: string +} + +interface ShiApiResponse { + GetDwgInfoResult: ShiDocumentResponse[] +} + +// InBox 파일 정보 인터페이스 추가 +interface InBoxFileInfo { + PROJ_NO: string + SHI_DOC_NO: string + STAGE_NAME: string + REVISION_NO: string + VNDR_CD: string + VNDR_NM: string + FILE_NAME: string + FILE_SIZE: number + CONTENT_TYPE: string + UPLOAD_DATE: string + UPLOADED_BY: string + STATUS: string + COMMENT: string +} + +// SaveInBoxList API 응답 인터페이스 +interface SaveInBoxListResponse { + SaveInBoxListResult: { + success: boolean + message: string + processedCount?: number + files?: Array<{ + fileName: string + networkPath: string + status: string + }> + } +} + +export class ShiBuyerSystemAPI { + private baseUrl = process.env.SWP_BASE_URL || 'http://60.100.99.217/DDP/Services/VNDRService.svc' + private ddcUrl = process.env.DDC_BASE_URL || 'http://60.100.99.217/DDC/Services/WebService.svc' + private localStoragePath = process.env.NAS_PATH || './uploads' + + async sendToSHI(contractId: number) { + try { + // 1. 전송할 문서 조회 + const documents = await this.getDocumentsToSend(contractId) + + if (documents.length === 0) { + return { success: false, message: "전송할 문서가 없습니다." } + } + + // 2. 도서 정보 전송 + await this.sendDocumentInfo(documents) + + // 3. 스케줄 정보 전송 + await this.sendScheduleInfo(documents) + + // 4. 동기화 상태 업데이트 + await this.updateSyncStatus(documents.map(d => d.documentId)) + + return { + success: true, + message: `${documents.length}개 문서가 성공적으로 전송되었습니다.`, + count: documents.length + } + } catch (error) { + console.error("SHI 전송 오류:", error) + + // 에러 시 동기화 상태 업데이트 + await this.updateSyncError( + contractId, + error instanceof Error ? error.message : "알 수 없는 오류" + ) + + throw error + } + } + + private async getDocumentsToSend(contractId: number) { + const result = await db + .select({ + documentId: stageDocuments.id, + docNumber: stageDocuments.docNumber, + vendorDocNumber: stageDocuments.vendorDocNumber, + title: stageDocuments.title, + status: stageDocuments.status, + projectCode: sql`(SELECT code FROM projects WHERE id = ${stageDocuments.projectId})`, + vendorCode: sql`(SELECT vendor_code FROM vendors WHERE id = ${stageDocuments.vendorId})`, + vendorName: sql`(SELECT vendor_name FROM vendors WHERE id = ${stageDocuments.vendorId})`, + stages: sql` + COALESCE( + (SELECT json_agg(row_to_json(s.*)) + FROM stage_issue_stages s + WHERE s.document_id = ${stageDocuments.id} + ORDER BY s.stage_order), + '[]'::json + ) + ` + }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, 'ACTIVE'), + ne(stageDocuments.buyerSystemStatus, "승인(DC)") + ) + ) + + return result + } + + private async sendDocumentInfo(documents: any[]) { + const shiDocuments: ShiDocumentInfo[] = documents.map(doc => ({ + PROJ_NO: doc.projectCode, + SHI_DOC_NO: doc.docNumber, + CATEGORY: "SHIP", + RESPONSIBLE_CD: "EVCP", + RESPONSIBLE: "eVCP System", + VNDR_CD: doc.vendorCode || "", + VNDR_NM: doc.vendorName || "", + DSN_SKL: "B3", + MIFP_CD: "", + MIFP_NM: "", + CG_EMPNO1: "", + CG_EMPNM1: "", + OWN_DOC_NO: doc.vendorDocNumber || doc.docNumber, + DSC: doc.title, + DOC_CLASS: "B3", + COMMENT: "", + STATUS: "ACTIVE", + CRTER: "EVCP_SYSTEM", + CRTE_DTM: new Date().toISOString(), + CHGR: "EVCP_SYSTEM", + CHG_DTM: new Date().toISOString() + })) + + const response = await fetch(`${this.baseUrl}/SetDwgInfo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(shiDocuments) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`도서 정보 전송 실패: ${response.statusText} - ${errorText}`) + } + + return response.json() + } + + private async sendScheduleInfo(documents: any[]) { + const schedules: ShiScheduleInfo[] = [] + + for (const doc of documents) { + for (const stage of doc.stages) { + if (stage.plan_date) { + schedules.push({ + PROJ_NO: doc.projectCode, + SHI_DOC_NO: doc.docNumber, + DDPKIND: "V", + SCHEDULE_TYPE: stage.stage_name, + BASELINE1: stage.plan_date ? new Date(stage.plan_date).toISOString() : null, + REVISED1: null, + FORECAST1: null, + ACTUAL1: stage.actual_date ? new Date(stage.actual_date).toISOString() : null, + BASELINE2: null, + REVISED2: null, + FORECAST2: null, + ACTUAL2: null, + CRTER: "EVCP_SYSTEM", + CRTE_DTM: new Date().toISOString(), + CHGR: "EVCP_SYSTEM", + CHG_DTM: new Date().toISOString() + }) + } + } + } + + if (schedules.length === 0) { + console.log("전송할 스케줄 정보가 없습니다.") + return + } + + const response = await fetch(`${this.baseUrl}/SetScheduleInfo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(schedules) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`스케줄 정보 전송 실패: ${response.statusText} - ${errorText}`) + } + + return response.json() + } + + private async updateSyncStatus(documentIds: number[]) { + if (documentIds.length === 0) return + + await db + .update(stageDocuments) + .set({ + syncStatus: 'synced', + lastSyncedAt: new Date(), + syncError: null, + syncVersion: sql`sync_version + 1`, + lastModifiedBy: 'EVCP' + }) + .where(sql`id = ANY(${documentIds})`) + } + + private async updateSyncError(contractId: number, errorMessage: string) { + await db + .update(stageDocuments) + .set({ + syncStatus: 'error', + syncError: errorMessage, + lastModifiedBy: 'EVCP' + }) + .where( + and( + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, 'ACTIVE') + ) + ) + } + + async pullDocumentStatus(contractId: number) { + try { + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!contract) { + throw new Error(`계약을 찾을 수 없습니다: ${contractId}`) + } + + const project = await db.query.projects.findFirst({ + where: eq(projects.id, contract.projectId), + }); + + if (!project) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${contract.projectId}`) + } + + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, contract.vendorId), + }); + + if (!vendor) { + throw new Error(`벤더를 찾을 수 없습니다: ${contract.vendorId}`) + } + + const shiDocuments = await this.fetchDocumentsFromSHI(project.code, { + VNDR_CD: vendor.vendorCode + }) + + if (!shiDocuments || shiDocuments.length === 0) { + return { + success: true, + message: "동기화할 문서가 없습니다.", + updatedCount: 0, + documents: [] + } + } + + const updateResults = await this.updateLocalDocuments(project.code, shiDocuments) + + return { + success: true, + message: `${updateResults.updatedCount}개 문서의 상태가 업데이트되었습니다.`, + updatedCount: updateResults.updatedCount, + newCount: updateResults.newCount, + documents: updateResults.documents + } + } catch (error) { + console.error("문서 상태 풀링 오류:", error) + throw error + } + } + + private async fetchDocumentsFromSHI( + projectCode: string, + filters?: { + SHI_DOC_NO?: string + CATEGORY?: string + VNDR_CD?: string + RESPONSIBLE_CD?: string + STATUS?: string + DOC_CLASS?: string + CRTE_DTM_FROM?: string + CRTE_DTM_TO?: string + CHG_DTM_FROM?: string + CHG_DTM_TO?: string + } + ): Promise { + const params = new URLSearchParams({ PROJ_NO: projectCode }) + + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value) params.append(key, value) + }) + } + + const url = `${this.baseUrl}/GetDwgInfo?${params.toString()}` + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`문서 조회 실패: ${response.statusText}`) + } + + const data: ShiApiResponse = await response.json() + + return data.GetDwgInfoResult || [] + } + + private async updateLocalDocuments( + projectCode: string, + shiDocuments: ShiDocumentResponse[] + ) { + let updatedCount = 0 + let newCount = 0 + const updatedDocuments: any[] = [] + + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode) + }) + + if (!project) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`) + } + + for (const shiDoc of shiDocuments) { + const localDoc = await db.query.stageDocuments.findFirst({ + where: and( + eq(stageDocuments.projectId, project.id), + eq(stageDocuments.docNumber, shiDoc.SHI_DOC_NO) + ) + }) + + if (localDoc) { + if ( + localDoc.buyerSystemStatus !== shiDoc.STATUS || + localDoc.buyerSystemComment !== shiDoc.COMMENT + ) { + await db + .update(stageDocuments) + .set({ + buyerSystemStatus: shiDoc.STATUS, + buyerSystemComment: shiDoc.COMMENT, + lastSyncedAt: new Date(), + syncStatus: 'synced', + syncError: null, + lastModifiedBy: 'BUYER_SYSTEM', + syncVersion: sql`sync_version + 1` + }) + .where(eq(stageDocuments.id, localDoc.id)) + + updatedCount++ + updatedDocuments.push({ + docNumber: shiDoc.SHI_DOC_NO, + title: shiDoc.DSC || localDoc.title, + status: shiDoc.STATUS, + comment: shiDoc.COMMENT, + action: 'updated' + }) + } + } else { + console.log(`SHI에만 존재하는 문서: ${shiDoc.SHI_DOC_NO}`) + newCount++ + updatedDocuments.push({ + docNumber: shiDoc.SHI_DOC_NO, + title: shiDoc.DSC || 'N/A', + status: shiDoc.STATUS, + comment: shiDoc.COMMENT, + action: 'new_in_shi' + }) + } + } + + return { + updatedCount, + newCount, + documents: updatedDocuments + } + } + + async getSyncStatus(contractId: number) { + const documents = await db + .select({ + docNumber: stageDocuments.docNumber, + title: stageDocuments.title, + syncStatus: stageDocuments.syncStatus, + lastSyncedAt: stageDocuments.lastSyncedAt, + syncError: stageDocuments.syncError, + buyerSystemStatus: stageDocuments.buyerSystemStatus, + buyerSystemComment: stageDocuments.buyerSystemComment + }) + .from(stageDocuments) + .where(eq(stageDocuments.contractId, contractId)) + + return documents + } + + /** + * 스테이지 제출 건들의 파일을 SHI 구매자 시스템으로 동기화 + * @param submissionIds 제출 ID 배열 + */ + async syncSubmissionsToSHI(submissionIds: number[]) { + const results = { + totalCount: submissionIds.length, + successCount: 0, + failedCount: 0, + details: [] as any[] + } + + for (const submissionId of submissionIds) { + try { + const result = await this.syncSingleSubmission(submissionId) + if (result.success) { + results.successCount++ + } else { + results.failedCount++ + } + results.details.push(result) + } catch (error) { + results.failedCount++ + results.details.push({ + submissionId, + success: false, + error: error instanceof Error ? error.message : "Unknown error" + }) + } + } + + return results + } + + /** + * 단일 제출 건 동기화 + */ + private async syncSingleSubmission(submissionId: number) { + try { + // 1. 제출 정보 조회 (프로젝트, 문서, 스테이지, 파일 정보 포함) + const submissionInfo = await this.getSubmissionFullInfo(submissionId) + + if (!submissionInfo) { + throw new Error(`제출 정보를 찾을 수 없습니다: ${submissionId}`) + } + + // 2. 동기화 시작 상태 업데이트 + await this.updateSubmissionSyncStatus(submissionId, 'syncing') + + // 3. 첨부파일들과 실제 파일 내용을 준비 + const filesWithContent = await this.prepareFilesWithContent(submissionInfo) + + if (filesWithContent.length === 0) { + await this.updateSubmissionSyncStatus(submissionId, 'synced', '전송할 파일이 없습니다') + return { + submissionId, + success: true, + message: "전송할 파일이 없습니다" + } + } + + // 4. SaveInBoxList API 호출하여 네트워크 경로 받기 + const response = await this.sendToInBox(filesWithContent) + + // 5. 응답받은 네트워크 경로에 파일 저장 + if (response.SaveInBoxListResult.success && response.SaveInBoxListResult.files) { + await this.saveFilesToNetworkPaths(filesWithContent, response.SaveInBoxListResult.files) + + // 6. 동기화 결과 업데이트 + await this.updateSubmissionSyncStatus(submissionId, 'synced', null, { + syncedFilesCount: filesWithContent.length, + buyerSystemStatus: 'SYNCED' + }) + + // 개별 파일 상태 업데이트 + await this.updateAttachmentsSyncStatus( + submissionInfo.attachments.map(a => a.id), + 'synced' + ) + + return { + submissionId, + success: true, + message: response.SaveInBoxListResult.message, + syncedFiles: filesWithContent.length + } + } else { + throw new Error(response.SaveInBoxListResult.message) + } + } catch (error) { + await this.updateSubmissionSyncStatus( + submissionId, + 'failed', + error instanceof Error ? error.message : '알 수 없는 오류' + ) + + throw error + } + } + + /** + * 제출 정보 조회 (관련 정보 포함) + */ + private async getSubmissionFullInfo(submissionId: number) { + const result = await db + .select({ + submission: stageSubmissions, + stage: stageIssueStages, + document: stageDocuments, + project: projects, + vendor: vendors + }) + .from(stageSubmissions) + .innerJoin(stageIssueStages, eq(stageSubmissions.stageId, stageIssueStages.id)) + .innerJoin(stageDocuments, eq(stageSubmissions.documentId, stageDocuments.id)) + .innerJoin(projects, eq(stageDocuments.projectId, projects.id)) + .leftJoin(vendors, eq(stageDocuments.vendorId, vendors.id)) + .where(eq(stageSubmissions.id, submissionId)) + .limit(1) + + if (result.length === 0) return null + + // 첨부파일 조회 - 파일 경로 포함 + const attachments = await db + .select() + .from(stageSubmissionAttachments) + .where( + and( + eq(stageSubmissionAttachments.submissionId, submissionId), + eq(stageSubmissionAttachments.status, 'ACTIVE') + ) + ) + + return { + ...result[0], + attachments + } + } + + /** + * 파일 내용과 함께 InBox 파일 정보 준비 + */ + private async prepareFilesWithContent(submissionInfo: any): Promise> { + const filesWithContent: Array = [] + + for (const attachment of submissionInfo.attachments) { + try { + // 파일 경로 결정 (storagePath 또는 storageUrl 사용) + const filePath = attachment.storagePath || attachment.storageUrl + + if (!filePath) { + console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`) + continue + } + + // 전체 경로 생성 + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(this.localStoragePath, filePath) + + // 파일 읽기 + const fileBuffer = await fs.readFile(fullPath) + + // 파일 정보 생성 + const fileInfo: InBoxFileInfo & { fileBuffer: Buffer, attachment: any } = { + PROJ_NO: submissionInfo.project.code, + SHI_DOC_NO: submissionInfo.document.docNumber, + STAGE_NAME: submissionInfo.stage.stageName, + REVISION_NO: String(submissionInfo.submission.revisionNumber), + VNDR_CD: submissionInfo.vendor?.vendorCode || '', + VNDR_NM: submissionInfo.vendor?.vendorName || '', + FILE_NAME: attachment.fileName, + FILE_SIZE: fileBuffer.length, // 실제 파일 크기 사용 + CONTENT_TYPE: attachment.mimeType || 'application/octet-stream', + UPLOAD_DATE: new Date().toISOString(), + UPLOADED_BY: submissionInfo.submission.submittedBy, + STATUS: 'PENDING', + COMMENT: `Revision ${submissionInfo.submission.revisionNumber} - ${submissionInfo.stage.stageName}`, + fileBuffer: fileBuffer, + attachment: attachment + } + + filesWithContent.push(fileInfo) + } catch (error) { + console.error(`파일 읽기 실패: ${attachment.fileName}`, error) + // 파일 읽기 실패 시 계속 진행 + continue + } + } + + return filesWithContent + } + + /** + * SaveInBoxList API 호출 (파일 메타데이터만 전송) + */ + private async sendToInBox(files: Array): Promise { + // fileBuffer를 제외한 메타데이터만 전송 + const fileMetadata = files.map(({ fileBuffer, attachment, ...metadata }) => metadata) + + const request = { files: fileMetadata } + + const response = await fetch(`${this.ddcUrl}/SaveInBoxList`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(request) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`InBox 전송 실패: ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + + // 응답 구조 확인 및 처리 + if (!data.SaveInBoxListResult) { + return { + SaveInBoxListResult: { + success: true, + message: "전송 완료", + processedCount: files.length, + files: files.map(f => ({ + fileName: f.FILE_NAME, + networkPath: `\\\\network\\share\\${f.PROJ_NO}\\${f.SHI_DOC_NO}\\${f.FILE_NAME}`, + status: 'READY' + })) + } + } + } + + return data + } + + /** + * 네트워크 경로에 파일 저장 + */ + private async saveFilesToNetworkPaths( + filesWithContent: Array, + networkPathInfo: Array<{ fileName: string, networkPath: string, status: string }> + ) { + for (const fileInfo of filesWithContent) { + const pathInfo = networkPathInfo.find(p => p.fileName === fileInfo.FILE_NAME) + + if (!pathInfo || !pathInfo.networkPath) { + console.error(`네트워크 경로를 찾을 수 없습니다: ${fileInfo.FILE_NAME}`) + continue + } + + try { + // 네트워크 경로에 파일 저장 + // Windows 네트워크 경로인 경우 처리 + let targetPath = pathInfo.networkPath + + // Windows 네트워크 경로를 Node.js가 이해할 수 있는 형식으로 변환 + if (process.platform === 'win32' && targetPath.startsWith('\\\\')) { + // 그대로 사용 + } else if (process.platform !== 'win32' && targetPath.startsWith('\\\\')) { + // Linux/Mac에서는 SMB 마운트 경로로 변환 필요 + // 예: \\\\server\\share -> /mnt/server/share + targetPath = targetPath.replace(/\\\\/g, '/mnt/').replace(/\\/g, '/') + } + + // 디렉토리 생성 (없는 경우) + const targetDir = path.dirname(targetPath) + await fs.mkdir(targetDir, { recursive: true }) + + // 파일 저장 + await fs.writeFile(targetPath, fileInfo.fileBuffer) + + console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`) + + // DB에 네트워크 경로 업데이트 + await db + .update(stageSubmissionAttachments) + .set({ + buyerSystemUrl: pathInfo.networkPath, + buyerSystemStatus: 'UPLOADED', + lastModifiedBy: 'EVCP' + }) + .where(eq(stageSubmissionAttachments.id, fileInfo.attachment.id)) + + } catch (error) { + console.error(`파일 저장 실패: ${fileInfo.FILE_NAME}`, error) + // 개별 파일 실패는 전체 프로세스를 중단하지 않음 + } + } + } + + /** + * 제출 동기화 상태 업데이트 + */ + private async updateSubmissionSyncStatus( + submissionId: number, + status: string, + error?: string | null, + additionalData?: any + ) { + const updateData: any = { + syncStatus: status, + lastSyncedAt: new Date(), + syncError: error, + lastModifiedBy: 'EVCP', + ...additionalData + } + + if (status === 'failed') { + updateData.syncRetryCount = sql`sync_retry_count + 1` + updateData.nextRetryAt = new Date(Date.now() + 30 * 60 * 1000) // 30분 후 재시도 + } + + await db + .update(stageSubmissions) + .set(updateData) + .where(eq(stageSubmissions.id, submissionId)) + } + + /** + * 첨부파일 동기화 상태 업데이트 + */ + private async updateAttachmentsSyncStatus( + attachmentIds: number[], + status: string + ) { + if (attachmentIds.length === 0) return + + await db + .update(stageSubmissionAttachments) + .set({ + syncStatus: status, + syncCompletedAt: status === 'synced' ? new Date() : null, + buyerSystemStatus: status === 'synced' ? 'UPLOADED' : 'PENDING', + lastModifiedBy: 'EVCP' + }) + .where(sql`id = ANY(${attachmentIds})`) + } + + /** + * 동기화 재시도 (실패한 건들) + */ + async retrySyncFailedSubmissions(contractId?: number) { + const conditions = [ + eq(stageSubmissions.syncStatus, 'failed'), + sql`next_retry_at <= NOW()` + ] + + if (contractId) { + const documentIds = await db + .select({ id: stageDocuments.id }) + .from(stageDocuments) + .where(eq(stageDocuments.contractId, contractId)) + + if (documentIds.length > 0) { + conditions.push( + sql`document_id = ANY(${documentIds.map(d => d.id)})` + ) + } + } + + const failedSubmissions = await db + .select({ id: stageSubmissions.id }) + .from(stageSubmissions) + .where(and(...conditions)) + .limit(10) // 한 번에 최대 10개씩 재시도 + + if (failedSubmissions.length === 0) { + return { + success: true, + message: "재시도할 제출 건이 없습니다.", + retryCount: 0 + } + } + + const submissionIds = failedSubmissions.map(s => s.id) + const results = await this.syncSubmissionsToSHI(submissionIds) + + return { + success: true, + message: `${results.successCount}/${results.totalCount}개 제출 건 재시도 완료`, + ...results + } + } +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx new file mode 100644 index 00000000..c0f17afc --- /dev/null +++ b/lib/vendor-document-list/plant/upload/columns.tsx @@ -0,0 +1,379 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { StageSubmissionView } from "@/db/schema" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Ellipsis, + Upload, + Eye, + RefreshCw, + CheckCircle2, + XCircle, + AlertCircle, + Clock +} from "lucide-react" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "docNumber", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorDocNumber = row.original.vendorDocNumber + return ( +
+
{row.getValue("docNumber")}
+ {vendorDocNumber && ( +
{vendorDocNumber}
+ )} +
+ ) + }, + size: 150, + }, + { + accessorKey: "documentTitle", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.getValue("documentTitle")} +
+ ), + size: 250, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.getValue("projectCode")} + ), + size: 100, + }, + { + accessorKey: "stageName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const stageName = row.getValue("stageName") as string + const stageStatus = row.original.stageStatus + const stageOrder = row.original.stageOrder + + return ( +
+
+ + {stageOrder ? `#${stageOrder}` : ""} + + {stageName} +
+ {stageStatus && ( + + {stageStatus} + + )} +
+ ) + }, + size: 200, + }, + { + accessorKey: "stagePlanDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const planDate = row.getValue("stagePlanDate") as Date | null + const isOverdue = row.original.isOverdue + const daysUntilDue = row.original.daysUntilDue + + if (!planDate) return - + + return ( +
+
+ {formatDate(planDate)} +
+ {daysUntilDue !== null && ( +
+ {isOverdue ? ( + + + {Math.abs(daysUntilDue)} days overdue + + ) : daysUntilDue === 0 ? ( + + + Due today + + ) : ( + + {daysUntilDue} days remaining + + )} +
+ )} +
+ ) + }, + size: 150, + }, + { + accessorKey: "latestSubmissionStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const status = row.getValue("latestSubmissionStatus") as string | null + const reviewStatus = row.original.latestReviewStatus + const revisionNumber = row.original.latestRevisionNumber + const revisionCode = row.original.latestRevisionCode + + if (!status) { + return ( + + + Not submitted + + ) + } + + return ( +
+ + {reviewStatus || status} + + {revisionCode !== null &&( +
+ {revisionCode} +
+ )} +
+ ) + }, + size: 150, + }, + { + id: "syncStatus", + accessorKey: "latestSyncStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const syncStatus = row.getValue("latestSyncStatus") as string | null + const syncProgress = row.original.syncProgress + const requiresSync = row.original.requiresSync + + if (!syncStatus || syncStatus === "pending") { + if (requiresSync) { + return ( + + + Pending + + ) + } + return - + } + + return ( +
+ + {syncStatus === "syncing" && } + {syncStatus === "synced" && } + {syncStatus === "failed" && } + {syncStatus} + + {syncProgress !== null && syncProgress !== undefined && syncStatus === "syncing" && ( + + )} +
+ ) + }, + size: 120, + }, + { + accessorKey: "totalFiles", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const totalFiles = row.getValue("totalFiles") as number + const syncedFiles = row.original.syncedFilesCount + + if (!totalFiles) return 0 + + return ( +
+ {syncedFiles !== null && syncedFiles !== undefined ? ( + {syncedFiles}/{totalFiles} + ) : ( + {totalFiles} + )} +
+ ) + }, + size: 80, + }, + // { + // accessorKey: "vendorName", + // header: ({ column }) => ( + // + // ), + // cell: ({ row }) => { + // const vendorName = row.getValue("vendorName") as string + // const vendorCode = row.original.vendorCode + + // return ( + //
+ //
{vendorName}
+ // {vendorCode && ( + //
{vendorCode}
+ // )} + //
+ // ) + // }, + // size: 150, + // }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const requiresSubmission = row.original.requiresSubmission + const requiresSync = row.original.requiresSync + const latestSubmissionId = row.original.latestSubmissionId + + return ( + + + + + + {requiresSubmission && ( + setRowAction({ row, type: "upload" })} + className="gap-2" + > + + Upload Documents + + )} + + {latestSubmissionId && ( + <> + setRowAction({ row, type: "view" })} + className="gap-2" + > + + View Submission + + + {requiresSync && ( + setRowAction({ row, type: "sync" })} + className="gap-2" + > + + Retry Sync + + )} + + )} + + + + setRowAction({ row, type: "history" })} + className="gap-2" + > + + View History + + + + ) + }, + size: 40, + } + ] +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/history-dialog.tsx b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx new file mode 100644 index 00000000..9c4f160b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx @@ -0,0 +1,144 @@ +// lib/vendor-document-list/plant/upload/components/history-dialog.tsx +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + CheckCircle2, + XCircle, + Clock, + FileText, + User, + Calendar, + AlertCircle +} from "lucide-react" +import { StageSubmissionView } from "@/db/schema" +import { formatDateTime } from "@/lib/utils" + +interface HistoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView +} + +export function HistoryDialog({ + open, + onOpenChange, + submission +}: HistoryDialogProps) { + const history = submission.submissionHistory || [] + + const getStatusIcon = (status: string, reviewStatus?: string) => { + if (reviewStatus === "APPROVED") { + return + } + if (reviewStatus === "REJECTED") { + return + } + if (status === "SUBMITTED") { + return + } + return + } + + const getStatusBadge = (status: string, reviewStatus?: string) => { + const variant = reviewStatus === "APPROVED" ? "success" : + reviewStatus === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return ( + + {reviewStatus || status} + + ) + } + + return ( + + + + Submission History + + View all submission history for this stage + + + + {/* Document Info */} +
+
+
+ + {submission.docNumber} + + - {submission.documentTitle} + +
+ {submission.stageName} +
+
+ + {/* History Timeline */} + + {history.length === 0 ? ( +
+ No submission history available +
+ ) : ( +
+ {history.map((item, index) => ( +
+ {/* Timeline line */} + {index < history.length - 1 && ( +
+ )} + + {/* Timeline item */} +
+
+ {getStatusIcon(item.status, item.reviewStatus)} +
+ +
+
+ Revision {item.revisionNumber} + {getStatusBadge(item.status, item.reviewStatus)} + {item.syncStatus && ( + + Sync: {item.syncStatus} + + )} +
+ +
+
+ + {item.submittedBy} +
+
+ + {formatDateTime(new Date(item.submittedAt))} +
+
+ + {item.fileCount} file(s) +
+
+
+
+
+ ))} +
+ )} + + +
+ ) +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx new file mode 100644 index 00000000..81a1d486 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx @@ -0,0 +1,492 @@ +// lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useCallback } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + CheckCircle2, + AlertCircle, + Loader2, + CloudUpload, + FileWarning +} from "lucide-react" +import { toast } from "sonner" +import { validateFiles } from "../../document-stages-service" +import { parseFileName, ParsedFileName } from "../util/filie-parser" + +interface FileWithMetadata { + file: File + parsed: ParsedFileName + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: string // number에서 string으로 변경 + } + status: 'pending' | 'validating' | 'uploading' | 'success' | 'error' + error?: string + progress?: number +} + +interface MultiUploadDialogProps { + projectId: number + // projectCode: string + onUploadComplete?: () => void +} + + +export function MultiUploadDialog({ + projectId, + // projectCode, + onUploadComplete +}: MultiUploadDialogProps) { + const [open, setOpen] = useState(false) + const [files, setFiles] = useState([]) + const [isValidating, setIsValidating] = useState(false) + const [isUploading, setIsUploading] = useState(false) + + // 디버깅용 로그 + console.log("Current files:", files) + + // 파일 추가 핸들러 - onChange 이벤트용 + const handleFilesChange = useCallback((e: React.ChangeEvent) => { + const fileList = e.target.files + console.log("Files selected via input:", fileList) + + if (fileList && fileList.length > 0) { + handleFilesAdded(Array.from(fileList)) + } + }, []) + + // 파일 추가 핸들러 - 공통 + const handleFilesAdded = useCallback(async (newFiles: File[]) => { + console.log("handleFilesAdded called with:", newFiles) + + if (!newFiles || newFiles.length === 0) { + console.log("No files provided") + return + } + + const processedFiles: FileWithMetadata[] = newFiles.map(file => { + const parsed = parseFileName(file.name) + console.log(`Parsed ${file.name}:`, parsed) + + return { + file, + parsed, + status: 'pending' as const + } + }) + + setFiles(prev => { + const updated = [...prev, ...processedFiles] + console.log("Updated files state:", updated) + return updated + }) + + // 유효한 파일들만 검증 + const validFiles = processedFiles.filter(f => f.parsed.isValid) + console.log("Valid files for validation:", validFiles) + + if (validFiles.length > 0) { + await validateFilesWithServer(validFiles) + } + }, []) + + // 서버 검증 + const validateFilesWithServer = async (filesToValidate: FileWithMetadata[]) => { + console.log("Starting validation for:", filesToValidate) + setIsValidating(true) + + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'validating' as const } + : file + )) + + try { + const validationData = filesToValidate.map(f => ({ + projectId, // projectCode 대신 projectId 사용 + docNumber: f.parsed.docNumber, + stageName: f.parsed.stageName, + revision: f.parsed.revision + }))s + + console.log("Sending validation data:", validationData) + const results = await validateFiles(validationData) + console.log("Validation results:", results) + + // 매칭 결과 업데이트 - projectCode 체크 제거 + setFiles(prev => prev.map(file => { + const result = results.find(r => + r.docNumber === file.parsed.docNumber && + r.stageName === file.parsed.stageName + ) + + if (result && result.matched) { + console.log(`File ${file.file.name} matched:`, result.matched) + return { + ...file, + matched: result.matched, + status: 'pending' as const + } + } + return { ...file, status: 'pending' as const } + })) + } catch (error) { + console.error("Validation error:", error) + toast.error("Failed to validate files") + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'error' as const, error: 'Validation failed' } + : file + )) + } finally { + setIsValidating(false) + } + } + // Drag and Drop 핸들러 + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + const droppedFiles = Array.from(e.dataTransfer.files) + console.log("Files dropped:", droppedFiles) + + if (droppedFiles.length > 0) { + handleFilesAdded(droppedFiles) + } + }, [handleFilesAdded]) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + // 파일 제거 + const removeFile = (index: number) => { + console.log("Removing file at index:", index) + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 실행 + const handleUpload = async () => { + const uploadableFiles = files.filter(f => f.parsed.isValid && f.matched) + console.log("Files to upload:", uploadableFiles) + + if (uploadableFiles.length === 0) { + toast.error("No valid files to upload") + return + } + + setIsUploading(true) + + // 업로드 중 상태로 변경 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'uploading' as const } + : file + )) + + try { + const formData = new FormData() + + uploadableFiles.forEach((fileData, index) => { + formData.append(`files`, fileData.file) + formData.append(`metadata[${index}]`, JSON.stringify({ + documentId: fileData.matched!.documentId, + stageId: fileData.matched!.stageId, + revision: fileData.parsed.revision, + originalName: fileData.file.name + })) + }) + + console.log("Sending upload request") + const response = await fetch('/api/stage-submissions/bulk-upload', { + method: 'POST', + body: formData + }) + + if (!response.ok) { + const error = await response.text() + console.error("Upload failed:", error) + throw new Error('Upload failed') + } + + const result = await response.json() + console.log("Upload result:", result) + + // 성공 상태 업데이트 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'success' as const } + : file + )) + + toast.success(`Successfully uploaded ${result.uploaded} files`) + + setTimeout(() => { + setOpen(false) + setFiles([]) + onUploadComplete?.() + }, 2000) + + } catch (error) { + console.error("Upload error:", error) + toast.error("Upload failed") + + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'error' as const, error: 'Upload failed' } + : file + )) + } finally { + setIsUploading(false) + } + } + + // 통계 계산 + const stats = { + total: files.length, + valid: files.filter(f => f.parsed.isValid).length, + matched: files.filter(f => f.matched).length, + ready: files.filter(f => f.parsed.isValid && f.matched).length, + totalSize: files.reduce((acc, f) => acc + f.file.size, 0) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + // 파일별 상태 아이콘 + const getStatusIcon = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return + } + + switch (fileData.status) { + case 'validating': + return + case 'uploading': + return + case 'success': + return + case 'error': + return + default: + if (fileData.matched) { + return + } else { + return + } + } + } + + // 파일별 상태 설명 + const getStatusDescription = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return fileData.parsed.error || "Invalid format" + } + + switch (fileData.status) { + case 'validating': + return "Checking..." + case 'uploading': + return "Uploading..." + case 'success': + return "Uploaded" + case 'error': + return fileData.error || "Failed" + default: + if (fileData.matched) { + // projectCode 제거 + return `${fileData.parsed.docNumber}_${fileData.parsed.stageName}` + } else { + return "Document not found in system" + } + } + } + + return ( + + + + + + + Bulk Document Upload + + Upload multiple files at once. Files should be named as: DocNumber_StageName_Revision.ext + + + + {/* Custom Dropzone with input */} +
document.getElementById('file-upload')?.click()} + > + + +

Drop files here or click to browse

+

+ Maximum 10GB total • Format: DocNumber_StageName_Revision.ext +

+
+ + {/* Stats */} + {files.length > 0 && ( +
+ Total: {stats.total} + + Valid Format: {stats.valid} + + 0 ? "success" : "secondary"}> + Matched: {stats.matched} + + 0 ? "default" : "outline"}> + Ready: {stats.ready} + + + Size: {formatFileSize(stats.totalSize)} + +
+ )} + + {/* File List */} + {files.length > 0 && ( +
+ + +
Files ({files.length})
+
+ + {files.map((fileData, index) => ( + + + {getStatusIcon(fileData)} + + + + {fileData.file.name} + + {getStatusDescription(fileData)} + + + + + {fileData.file.size} + + + + + + + ))} +
+
+ )} + {/* Error Alert */} + {files.filter(f => !f.parsed.isValid).length > 0 && ( + + + + {files.filter(f => !f.parsed.isValid).length} file(s) have invalid naming format. + Expected: ProjectCode_DocNumber_StageName_Rev0.ext + + + )} + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/project-filter.tsx b/lib/vendor-document-list/plant/upload/components/project-filter.tsx new file mode 100644 index 00000000..33c2819b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/project-filter.tsx @@ -0,0 +1,109 @@ +// lib/vendor-document-list/plant/upload/components/project-filter.tsx +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Building2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" + +interface Project { + id: number + code: string +} + +interface ProjectFilterProps { + projects: Project[] + value?: number | null + onValueChange: (value: number | null) => void +} + +export function ProjectFilter({ projects, value, onValueChange }: ProjectFilterProps) { + const [open, setOpen] = React.useState(false) + + const selectedProject = projects.find(p => p.id === value) + + return ( + + + + + + + + + No project found. + + { + onValueChange(null) + setOpen(false) + }} + > + + All Projects + + {projects.map((project) => ( + { + onValueChange(project.id) + setOpen(false) + }} + > + + {project.code} + + ))} + + + + + + ) +} \ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx new file mode 100644 index 00000000..a33a7160 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx @@ -0,0 +1,265 @@ +// lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx +"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 { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + FileList, + FileListAction, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + FileIcon, + Loader2, + AlertCircle +} from "lucide-react" +import { toast } from "sonner" +import { StageSubmissionView } from "@/db/schema" + +interface SingleUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView + onUploadComplete?: () => void +} + +export function SingleUploadDialog({ + open, + onOpenChange, + submission, + onUploadComplete +}: SingleUploadDialogProps) { + const [files, setFiles] = useState([]) + const [description, setDescription] = useState("") + const [isUploading, setIsUploading] = useState(false) + const fileInputRef = React.useRef(null) + + // 파일 선택 + const handleFileChange = (e: React.ChangeEvent) => { + const fileList = e.target.files + if (fileList) { + setFiles(Array.from(fileList)) + } + } + + // 파일 제거 + const removeFile = (index: number) => { + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 처리 + const handleUpload = async () => { + if (files.length === 0) { + toast.error("Please select files to upload") + return + } + + setIsUploading(true) + + try { + const formData = new FormData() + + files.forEach((file) => { + formData.append("files", file) + }) + + formData.append("documentId", submission.documentId.toString()) + formData.append("stageId", submission.stageId!.toString()) + formData.append("description", description) + + // 현재 리비전 + 1 + const nextRevision = (submission.latestRevisionNumber || 0) + 1 + formData.append("revision", nextRevision.toString()) + + const response = await fetch("/api/stage-submissions/upload", { + method: "POST", + body: formData, + }) + + if (!response.ok) { + throw new Error("Upload failed") + } + + const result = await response.json() + toast.success(`Successfully uploaded ${files.length} file(s)`) + + // 초기화 및 닫기 + setFiles([]) + setDescription("") + onOpenChange(false) + onUploadComplete?.() + + } catch (error) { + console.error("Upload error:", error) + toast.error("Failed to upload files") + } finally { + setIsUploading(false) + } + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const totalSize = files.reduce((acc, file) => acc + file.size, 0) + + return ( + + + + Upload Documents + + Upload documents for this stage submission + + + + {/* Document Info */} +
+
+ Document: + {submission.docNumber} + {submission.vendorDocNumber && ( + + ({submission.vendorDocNumber}) + + )} +
+
+ Stage: + {submission.stageName} +
+
+ Current Revision: + Rev. {submission.latestRevisionNumber || 0} + + Next: Rev. {(submission.latestRevisionNumber || 0) + 1} + +
+
+ + {/* File Upload Area */} +
fileInputRef.current?.click()} + > + + +

Click to browse files

+

+ You can select multiple files +

+
+ + {/* File List */} + {files.length > 0 && ( + <> + + {files.map((file, index) => ( + + + + + + {file.name} + + + {file.size} + + + + + + ))} + + +
+ {files.length} file(s) selected + Total: {formatFileSize(totalSize)} +
+ + )} + + {/* Description */} +
+ +