diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-19 07:51:27 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-19 07:51:27 +0000 |
| commit | 9ecdfb23fe3df6a5df86782385002c562dfc1198 (patch) | |
| tree | 4188cb7e6bf2c862d9c86a59d79946bd41217227 | |
| parent | b67861fbb424c7ad47ad1538f75e2945bd8890c5 (diff) | |
(대표님) rfq 히스토리, swp 등
56 files changed, 6431 insertions, 4019 deletions
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 @@ -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<SearchParams>
}
-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 (
<div className="space-y-6">
<div>
- <h3 className="text-lg font-medium">
- RFQ History
- </h3>
+ <h3 className="text-lg font-medium">RFQ 견적 이력</h3>
<p className="text-sm text-muted-foreground">
- 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
+ 협력업체의 RFQ 견적 참여 이력을 확인할 수 있습니다.
</p>
</div>
<Separator />
<div>
- <VendorRfqHistoryTable promises={promises} />
+ <VendorRfqHistoryTable
+ promises={promises}
+ lng={lng}
+ vendorId={idAsNumber}
+ />
</div>
</div>
)
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<Record<string, string | string[] | undefined>> +}) { + // 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 ( + <div className="container mx-auto py-6 space-y-6"> + {/* Header */} + <div className="flex items-center justify-between"> + <div> + <h1 className="text-3xl font-bold tracking-tight">My Stage Submissions</h1> + <p className="text-muted-foreground mt-1"> + Manage document submissions for your approved stages + </p> + </div> + <div className=""> + {/* <Badge variant="outline" className="gap-1"> */} + {/* <span className="font-semibold">Company:</span> */} + <span className="font-semibold"> {vendor.vendorName || "Your Company"}</span> + + <p className="text-muted-foreground text-sm"> + Buyer Approved Documents + </p> + {/* </Badge> */} + </div> + </div> + + {/* Stats Cards */} + <div className="grid gap-4 md:grid-cols-4"> + <Card> + <CardHeader className="pb-2"> + <CardDescription>Pending Submissions</CardDescription> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {stats.pending} + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="pb-2"> + <CardDescription>Overdue</CardDescription> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-destructive"> + {stats.overdue} + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="pb-2"> + <CardDescription>Awaiting Sync</CardDescription> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-warning"> + {stats.awaitingSync} + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="pb-2"> + <CardDescription>Completed</CardDescription> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-success"> + {stats.completed} + </div> + </CardContent> + </Card> + </div> + + {/* Main Table */} + <Card> + <CardHeader> + <CardTitle>Your Submission List</CardTitle> + <CardDescription> + View and manage document submissions for your company's stages + </CardDescription> + </CardHeader> + <CardContent> + <React.Suspense + fallback={ + <div className="flex h-[400px] items-center justify-center"> + <div className="animate-pulse text-muted-foreground"> + Loading your submissions... + </div> + </div> + } + > + <StageSubmissionsTable + promises={Promise.all([ + { data: submissions.data, pageCount: submissions.pageCount }, + { projects: projects.map(p => ({ id: p.id, code: p.code || "" })) } + ])} + selectedProjectId={params.projectId} + + /> + </React.Suspense> + </CardContent> + </Card> + </div> + ) +}
\ 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<TData>({ // 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 <TData>(row: Row<TData>, 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 = <TData>( + row: Row<TData>, + 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 = <TData>( + row: Row<TData>, + 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="벤더명" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => ( <div className="font-medium">{row.original.vendorName}</div> ), @@ -226,6 +229,8 @@ export function VendorFormStatusTable({ { accessorKey: "formCount", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Form 개수" />, + filterFn: createFilterFn("number"), + cell: ({ row }) => ( <div className="text-center"> <Badge variant="outline">{row.original.formCount}</Badge> @@ -237,6 +242,8 @@ export function VendorFormStatusTable({ { accessorKey: "tagCount", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Tag 개수" />, + filterFn: createFilterFn("number"), + cell: ({ row }) => ( <div className="text-center"> <Badge variant="outline">{row.original.tagCount}</Badge> @@ -248,6 +255,8 @@ export function VendorFormStatusTable({ { accessorKey: "totalFields", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="전체 필드" />, + filterFn: createFilterFn("number"), + cell: ({ row }) => ( <div className="text-center font-mono"> {row.original.totalFields.toLocaleString()} @@ -259,6 +268,8 @@ export function VendorFormStatusTable({ { accessorKey: "completedFields", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="완료 필드" />, + filterFn: createFilterFn("number"), + cell: ({ row }) => ( <div className="text-center font-mono"> {row.original.completedFields.toLocaleString()} @@ -270,6 +281,8 @@ export function VendorFormStatusTable({ { accessorKey: "completionRate", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="완료율" />, + 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<TData> { @@ -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: () => ( - <div className="flex items-center justify-center h-full"> - <Loader className="mr-2 h-4 w-4 animate-spin" /> - Loading SpreadSheets... - </div> - ) - } -); - -// 라이센스 키 설정을 클라이언트에서만 실행 -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<string>; - 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<string>; - ATTS: Array<{}>; - }; - SPR_ITM_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array<string>; - 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<string, string[]>; - onUpdateSuccess?: (updatedValues: Record<string, any> | 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<any>(null); - const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]); - const [isClient, setIsClient] = React.useState(false); - const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | null>(null); - const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); - const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); - const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); - - // 클라이언트 사이드에서만 렌더링되도록 보장 - 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 ( - <Dialog open={isOpen} onOpenChange={onClose}> - <DialogContent - className="w-[80%] max-w-none h-[80vh] flex flex-col" - style={{ maxWidth: "80vw" }} - > - <DialogHeader className="flex-shrink-0"> - <DialogTitle>SEDP Template - {formCode}</DialogTitle> - <DialogDescription> - <div className="space-y-3"> - {/* 템플릿 선택 */} - {availableTemplates.length > 1 && ( - <div className="flex items-center gap-4"> - <span className="text-sm font-medium">Template:</span> - <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> - <SelectTrigger className="w-64"> - <SelectValue placeholder="Select a template" /> - </SelectTrigger> - <SelectContent> - {availableTemplates.map(template => ( - <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> - {template.NAME} ({template.TMPL_TYPE}) - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - )} - - {/* 템플릿 정보 */} - {selectedTemplate && ( - <div className="flex items-center gap-4 text-sm"> - <span className="font-medium text-blue-600"> - Template Type: {selectedTemplate.TMPL_TYPE === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : 'Item View (SPREAD_ITEM)'} - </span> - {templateType === 'SPREAD_ITEM' && selectedRow && ( - <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span> - )} - {templateType === 'SPREAD_LIST' && ( - <span>• {dataCount} rows</span> - )} - {hasChanges && ( - <span className="text-orange-600 font-medium"> - • Unsaved changes - </span> - )} - {validationErrors.length > 0 && ( - <span className="text-red-600 font-medium flex items-center"> - <AlertTriangle className="w-4 h-4 mr-1" /> - {validationErrors.length} validation errors - </span> - )} - </div> - )} - - {/* 범례 */} - <div className="flex items-center gap-4 text-xs"> - <span className="text-muted-foreground"> - <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span> - Editable fields - </span> - <span className="text-muted-foreground"> - <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> - Read-only fields - </span> - <span className="text-muted-foreground"> - <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span> - Validation errors - </span> - {cellMappings.length > 0 && ( - <span className="text-blue-600"> - {editableFieldsCount} of {cellMappings.length} fields editable - </span> - )} - </div> - </div> - </DialogDescription> - </DialogHeader> - - {/* SpreadSheets 컴포넌트 영역 */} - <div className="flex-1 overflow-hidden"> - {selectedTemplate && isClient && isDataValid ? ( - <SpreadSheets - key={`${selectedTemplate.TMPL_TYPE}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} - workbookInitialized={initSpread} - hostStyle={hostStyle} - /> - ) : ( - <div className="flex items-center justify-center h-full text-muted-foreground"> - {!isClient ? ( - <> - <Loader className="mr-2 h-4 w-4 animate-spin" /> - Loading... - </> - ) : !selectedTemplate ? ( - "No template available" - ) : !isDataValid ? ( - `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` - ) : ( - "Template not ready" - )} - </div> - )} - </div> - - <DialogFooter className="flex-shrink-0"> - <div className="flex items-center gap-2"> - <Button variant="outline" onClick={onClose}> - Close - </Button> - - {hasChanges && ( - <Button - variant="default" - onClick={handleSaveChanges} - disabled={isPending || validationErrors.length > 0} - > - {isPending ? ( - <> - <Loader className="mr-2 h-4 w-4 animate-spin" /> - Saving... - </> - ) : ( - <> - <Save className="mr-2 h-4 w-4" /> - Save Changes - </> - )} - </Button> - )} - - {validationErrors.length > 0 && ( - <Button - variant="outline" - onClick={validateAllData} - className="text-red-600 border-red-300 hover:bg-red-50" - > - <AlertTriangle className="mr-2 h-4 w-4" /> - Check Errors ({validationErrors.length}) - </Button> - )} - </div> - </DialogFooter> - </DialogContent> - </Dialog> - ); -}
\ 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: () => ( - <div className="flex items-center justify-center h-full"> - <Loader className="mr-2 h-4 w-4 animate-spin" /> - Loading SpreadSheets... - </div> - ) - } -); - -// 라이센스 키 설정을 클라이언트에서만 실행 -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<string>; - 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<string>; - ATTS: Array<{}>; - }; - SPR_ITM_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array<string>; - 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<string, string[]>; - onUpdateSuccess?: (updatedValues: Record<string, any> | 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<any>(null); - const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]); - const [isClient, setIsClient] = React.useState(false); - const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); - const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); - const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); - const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); - - 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 ( - <Dialog open={isOpen} onOpenChange={onClose}> - <DialogContent - className="w-[80%] max-w-none h-[80vh] flex flex-col" - style={{ maxWidth: "80vw" }} - > - <DialogHeader className="flex-shrink-0"> - <DialogTitle>SEDP Template - {formCode}</DialogTitle> - <DialogDescription> - <div className="space-y-3"> - {/* 템플릿 선택 */} - {availableTemplates.length > 1 && ( - <div className="flex items-center gap-4"> - <span className="text-sm font-medium">Template:</span> - <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> - <SelectTrigger className="w-64"> - <SelectValue placeholder="Select a template" /> - </SelectTrigger> - <SelectContent> - {availableTemplates.map(template => ( - <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> - {template.NAME} ({ - template.TMPL_TYPE - }) - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - )} - - {/* 템플릿 정보 */} - {selectedTemplate && ( - <div className="flex items-center gap-4 text-sm"> - <span className="font-medium text-blue-600"> - Template Type: { - templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : - templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : - 'Grid List View (GRD_LIST)' - } - </span> - {templateType === 'SPREAD_ITEM' && selectedRow && ( - <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span> - )} - {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( - <span>• {dataCount} rows</span> - )} - {hasChanges && ( - <span className="text-orange-600 font-medium"> - • Unsaved changes - </span> - )} - {validationErrors.length > 0 && ( - <span className="text-red-600 font-medium flex items-center"> - <AlertTriangle className="w-4 h-4 mr-1" /> - {validationErrors.length} validation errors - </span> - )} - </div> - )} - - {/* 범례 */} - <div className="flex items-center gap-4 text-xs"> - <span className="text-muted-foreground"> - <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span> - Editable fields - </span> - <span className="text-muted-foreground"> - <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> - Read-only fields - </span> - <span className="text-muted-foreground"> - <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span> - Validation errors - </span> - {cellMappings.length > 0 && ( - <span className="text-blue-600"> - {editableFieldsCount} of {cellMappings.length} fields editable - </span> - )} - </div> - </div> - </DialogDescription> - </DialogHeader> - - {/* SpreadSheets 컴포넌트 영역 */} - <div className="flex-1 overflow-hidden"> - {selectedTemplate && isClient && isDataValid ? ( - <SpreadSheets - key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} - workbookInitialized={initSpread} - hostStyle={hostStyle} - /> - ) : ( - <div className="flex items-center justify-center h-full text-muted-foreground"> - {!isClient ? ( - <> - <Loader className="mr-2 h-4 w-4 animate-spin" /> - Loading... - </> - ) : !selectedTemplate ? ( - "No template available" - ) : !isDataValid ? ( - `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` - ) : ( - "Template not ready" - )} - </div> - )} - </div> - - <DialogFooter className="flex-shrink-0"> - <div className="flex items-center gap-2"> - <Button variant="outline" onClick={onClose}> - Close - </Button> - - {hasChanges && ( - <Button - variant="default" - onClick={handleSaveChanges} - disabled={isPending || validationErrors.length > 0} - > - {isPending ? ( - <> - <Loader className="mr-2 h-4 w-4 animate-spin" /> - Saving... - </> - ) : ( - <> - <Save className="mr-2 h-4 w-4" /> - Save Changes - </> - )} - </Button> - )} - - {validationErrors.length > 0 && ( - <Button - variant="outline" - onClick={validateAllData} - className="text-red-600 border-red-300 hover:bg-red-50" - > - <AlertTriangle className="mr-2 h-4 w-4" /> - Check Errors ({validationErrors.length}) - </Button> - )} - </div> - </DialogFooter> - </DialogContent> - </Dialog> - ); -}
\ No newline at end of file diff --git a/components/form-data/spreadJS-dialog copy 4.tsx b/components/form-data/spreadJS-dialog copy 5.tsx index 14f4d3ea..fbeceaf3 100644 --- a/components/form-data/spreadJS-dialog copy 4.tsx +++ b/components/form-data/spreadJS-dialog copy 5.tsx @@ -12,6 +12,7 @@ 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), @@ -26,8 +27,9 @@ const SpreadSheets = dynamic( } ); -if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { - GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; +// 도메인별 라이선스 설정 +if (typeof window !== 'undefined') { + setupSpreadJSLicense(GC); } interface TemplateItem { 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: () => ( - <div className="flex items-center justify-center h-full"> - <Loader className="mr-2 h-4 w-4 animate-spin" /> - Loading SpreadSheets... - </div> - ) - } -); - -// 라이센스 키 설정을 클라이언트에서만 실행 -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<string>; - 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<string>; - ATTS: Array<{}>; - }; - SPR_ITM_LST_SETUP: { - ACT_SHEET: string; - HIDN_SHEETS: Array<string>; - 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<string, string[]>; // 편집 가능 필드 정보 - onUpdateSuccess?: (updatedValues: Record<string, any>) => 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<any>(null); - const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); - const [cellMappings, setCellMappings] = React.useState<Array<{attId: string, cellAddress: string, isEditable: boolean}>>([]); - 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 ( - <Dialog open={isOpen} onOpenChange={onClose}> - <DialogContent - className="w-[80%] max-w-none h-[80vh] flex flex-col" - style={{maxWidth:"80vw"}} - > - <DialogHeader className="flex-shrink-0"> - <DialogTitle>SEDP Template - {formCode}</DialogTitle> - <DialogDescription> - {selectedRow && `Selected TAG_NO: ${selectedRow.TAG_NO || 'N/A'}`} - {hasChanges && ( - <span className="ml-2 text-orange-600 font-medium"> - • Unsaved changes - </span> - )} - <br /> - <div className="flex items-center gap-4 mt-2"> - <span className="text-xs text-muted-foreground"> - <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span> - Editable fields - </span> - <span className="text-xs text-muted-foreground"> - <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> - Read-only fields - </span> - {cellMappings.length > 0 && ( - <span className="text-xs text-blue-600"> - {cellMappings.filter(m => m.isEditable).length} of {cellMappings.length} fields editable - </span> - )} - </div> - </DialogDescription> - </DialogHeader> - - {/* 템플릿 선택 UI */} - {normalizedTemplates.length > 1 && ( - <div className="flex-shrink-0 px-4 py-2 border-b"> - <div className="flex items-center gap-2"> - <label className="text-sm font-medium">Template:</label> - <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> - <SelectTrigger className="w-64"> - <SelectValue placeholder="Select a template" /> - </SelectTrigger> - <SelectContent> - {normalizedTemplates.map((template) => ( - <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> - <div className="flex flex-col"> - <span>{template.NAME || `Template ${template.TMPL_ID.slice(0, 8)}`}</span> - <span className="text-xs text-muted-foreground">{template.TMPL_TYPE}</span> - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - <span className="text-xs text-muted-foreground"> - ({normalizedTemplates.length} templates available) - </span> - </div> - </div> - )} - - {/* SpreadSheets 컴포넌트 영역 */} - <div className="flex-1 overflow-hidden"> - {selectedTemplate && isClient ? ( - <SpreadSheets - key={selectedTemplateId} - workbookInitialized={initSpread} - hostStyle={hostStyle} - /> - ) : ( - <div className="flex items-center justify-center h-full text-muted-foreground"> - {!isClient ? ( - <> - <Loader className="mr-2 h-4 w-4 animate-spin" /> - Loading... - </> - ) : ( - "No template available" - )} - </div> - )} - </div> - - <DialogFooter className="flex-shrink-0"> - <Button variant="outline" onClick={onClose}> - Close - </Button> - - {hasChanges && ( - <Button - variant="default" - onClick={handleSaveChanges} - disabled={isPending} - > - {isPending ? ( - <> - <Loader className="mr-2 h-4 w-4 animate-spin" /> - Saving... - </> - ) : ( - <> - <Save className="mr-2 h-4 w-4" /> - Save Changes - </> - )} - </Button> - )} - - </DialogFooter> - </DialogContent> - </Dialog> - ); -}
\ 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<TData> { @@ -70,6 +71,7 @@ export function getColumns({ header: ({ column }) => ( <ClientDataTableColumnHeaderSimple column={column} title="Tag No." /> ), + filterFn: createFilterFn("text"), cell: ({ row }) => <div className="w-20">{row.getValue("tagNo")}</div>, 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<Array<{ id: number; stageName: string; @@ -1491,7 +1518,6 @@ export const stageDocumentsView = pgView("stage_documents_view", { WHERE stage_status NOT IN ('COMPLETED', 'APPROVED') ORDER BY document_id, stage_order ASC, priority DESC ), - -- 문서별 스테이지 집계 (리비전 제외) stage_aggregation AS ( SELECT ist.document_id, @@ -1522,7 +1548,7 @@ export const stageDocumentsView = pgView("stage_documents_view", { sd.status, sd.issued_date, - -- 프로젝트 및 벤더 정보 (직접 참조로 간소화) + -- 프로젝트 및 벤더 정보 sd.project_id, sd.contract_id, p.code as project_code, @@ -1530,6 +1556,15 @@ export const stageDocumentsView = pgView("stage_documents_view", { v.vendor_name, v.vendor_code, + -- 동기화 상태 필드 추가 + sd.buyer_system_status, + sd.buyer_system_comment, + sd.last_synced_at, + sd.sync_status, + sd.sync_error, + sd.sync_version, + sd.last_modified_by, + -- 현재 스테이지 정보 csi.current_stage_id, csi.current_stage_name, @@ -1550,7 +1585,7 @@ export const stageDocumentsView = pgView("stage_documents_view", { ds.completed_stages, ds.progress_percentage, - -- 전체 스테이지 (리비전 제외) + -- 전체 스테이지 COALESCE(sa.all_stages, '[]'::json) as all_stages, -- 메타 정보 @@ -1558,11 +1593,8 @@ export const stageDocumentsView = pgView("stage_documents_view", { sd.updated_at FROM stage_documents sd - -- 간소화된 JOIN (vendors는 vendor_id로 직접 조인) LEFT JOIN projects p ON sd.project_id = p.id LEFT JOIN vendors v ON sd.vendor_id = v.id - - -- 스테이지 관련 정보 JOIN LEFT JOIN document_stats ds ON sd.id = ds.document_id LEFT JOIN current_stage_info csi ON sd.id = csi.document_id LEFT JOIN stage_aggregation sa ON sd.id = sa.document_id @@ -1570,7 +1602,6 @@ export const stageDocumentsView = pgView("stage_documents_view", { ORDER BY sd.created_at DESC `); - // 🎯 issue_stages 테이블도 stage_documents를 참조하도록 수정 export const stageIssueStages = pgTable( "stage_issue_stages", // 또는 기존 issue_stages 테이블을 수정 @@ -1624,3 +1655,433 @@ export type StageDocumentInsert = typeof stageDocuments.$inferInsert export type StageDocumentsView = typeof stageDocumentsView.$inferSelect export type StageIssueStage = typeof stageIssueStages.$inferSelect export type StageIssueStageInsert = typeof stageIssueStages.$inferInsert + + + +// 📄 스테이지 제출 테이블 - 구매자 시스템 동기화 필드 추가 +export const stageSubmissions = pgTable( + "stage_submissions", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // 참조 관계 + stageId: integer("stage_id") + .notNull() + .references(() => 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<Array<{ + submissionId: number; + revisionNumber: number; + revisionCode: string | null; // ⭐ 추가 + status: string; + submittedAt: string; + submittedBy: string; + reviewStatus: string | null; + syncStatus: string | null; + fileCount: number; + }>>(), + +}).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<Date | null>(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<ContractItem[]>(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({ <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> - <span className="text-sm text-gray-600">총 금액: {totalAmount.toLocaleString()} {currency}</span> + <span className="text-sm text-gray-600">총 금액: {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}</span> <span className="text-sm text-gray-600">총 수량: {totalQuantity.toLocaleString()}</span> </div> {!readOnly && ( @@ -316,19 +323,19 @@ export function ContractItemsTable({ <div className="space-y-1"> <Label className="text-sm font-medium">총 계약금액</Label> <div className="text-lg font-bold text-primary"> - {formatCurrency(totalAmount)} + {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')} </div> </div> <div className="space-y-1"> <Label className="text-sm font-medium">가용예산</Label> <div className="text-lg font-bold"> - {formatCurrency(availableBudget)} + {formatCurrency(availableBudget, localItems[0]?.contractCurrency || 'KRW')} </div> </div> <div className="space-y-1"> <Label className="text-sm font-medium">가용예산 比 (금액차)</Label> <div className={`text-lg font-bold ${amountDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(amountDifference)} + {formatCurrency(amountDifference, localItems[0]?.contractCurrency || 'KRW')} </div> </div> <div className="space-y-1"> @@ -357,12 +364,13 @@ export function ContractItemsTable({ /> )} </TableHead> - <TableHead className="px-3 py-3 font-semibold">프로젝트</TableHead> <TableHead className="px-3 py-3 font-semibold">품목코드 (PKG No.)</TableHead> <TableHead className="px-3 py-3 font-semibold">Item 정보 (자재그룹 / 자재코드)</TableHead> <TableHead className="px-3 py-3 font-semibold">규격</TableHead> <TableHead className="px-3 py-3 font-semibold text-right">수량</TableHead> <TableHead className="px-3 py-3 font-semibold">수량단위</TableHead> + <TableHead className="px-3 py-3 font-semibold text-right">총 중량</TableHead> + <TableHead className="px-3 py-3 font-semibold">중량단위</TableHead> <TableHead className="px-3 py-3 font-semibold">계약납기일</TableHead> <TableHead className="px-3 py-3 font-semibold text-right">계약단가</TableHead> <TableHead className="px-3 py-3 font-semibold text-right">계약금액</TableHead> @@ -385,19 +393,6 @@ export function ContractItemsTable({ </TableCell> <TableCell className="px-3 py-3"> {readOnly ? ( - <span className="text-sm">{item.project || '-'}</span> - ) : ( - <Input - value={item.project} - onChange={(e) => updateItem(index, 'project', e.target.value)} - placeholder="프로젝트" - className="h-8 text-sm" - disabled={!isEnabled} - /> - )} - </TableCell> - <TableCell className="px-3 py-3"> - {readOnly ? ( <span className="text-sm">{item.itemCode || '-'}</span> ) : ( <Input @@ -453,17 +448,62 @@ export function ContractItemsTable({ {readOnly ? ( <span className="text-sm">{item.quantityUnit || '-'}</span> ) : ( - <Input + <Select value={item.quantityUnit} - onChange={(e) => updateItem(index, 'quantityUnit', e.target.value)} - placeholder="단위" - className="h-8 text-sm w-16" + onValueChange={(value) => updateItem(index, 'quantityUnit', value)} + disabled={!isEnabled} + > + <SelectTrigger className="h-8 text-sm w-20"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {QUANTITY_UNITS.map((unit) => ( + <SelectItem key={unit} value={unit}> + {unit} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </TableCell> + <TableCell className="px-3 py-3"> + {readOnly ? ( + <span className="text-sm text-right">{item.totalWeight.toLocaleString()}</span> + ) : ( + <Input + type="number" + value={item.totalWeight} + onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)} + className="h-8 text-sm text-right" + placeholder="0" disabled={!isEnabled} /> )} </TableCell> <TableCell className="px-3 py-3"> {readOnly ? ( + <span className="text-sm">{item.weightUnit || '-'}</span> + ) : ( + <Select + value={item.weightUnit} + onValueChange={(value) => updateItem(index, 'weightUnit', value)} + disabled={!isEnabled} + > + <SelectTrigger className="h-8 text-sm w-20"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {WEIGHT_UNITS.map((unit) => ( + <SelectItem key={unit} value={unit}> + {unit} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </TableCell> + <TableCell className="px-3 py-3"> + {readOnly ? ( <span className="text-sm">{item.contractDeliveryDate || '-'}</span> ) : ( <Input @@ -498,13 +538,22 @@ export function ContractItemsTable({ {readOnly ? ( <span className="text-sm">{item.contractCurrency || '-'}</span> ) : ( - <Input + <Select value={item.contractCurrency} - onChange={(e) => updateItem(index, 'contractCurrency', e.target.value)} - placeholder="통화" - className="h-8 text-sm w-16" + onValueChange={(value) => updateItem(index, 'contractCurrency', value)} disabled={!isEnabled} - /> + > + <SelectTrigger className="h-8 text-sm w-20"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {CURRENCIES.map((currency) => ( + <SelectItem key={currency} value={currency}> + {currency} + </SelectItem> + ))} + </SelectContent> + </Select> )} </TableCell> </TableRow> @@ -528,14 +577,14 @@ export function ContractItemsTable({ <div className="flex items-center justify-between"> <span className="text-sm font-medium text-muted-foreground">총 단가</span> <span className="text-lg font-semibold"> - {totalUnitPrice.toLocaleString()} {currency} + {formatCurrency(totalUnitPrice, localItems[0]?.contractCurrency || 'KRW')} </span> </div> <div className="border-t pt-4"> <div className="flex items-center justify-between"> <span className="text-xl font-bold text-primary">합계 금액</span> <span className="text-2xl font-bold text-primary"> - {formatCurrency(totalAmount)} + {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')} </span> </div> </div> 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<string, unknown>) { 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<stri specification: item.specification as string,
quantity: item.quantity as number,
quantityUnit: item.quantityUnit as string,
+ totalWeight: item.totalWeight as number,
+ weightUnit: item.weightUnit as string,
contractDeliveryDate: item.contractDeliveryDate as string,
contractUnitPrice: item.contractUnitPrice as number,
contractAmount: item.contractAmount as number,
@@ -1554,8 +1557,8 @@ async function mapContractSummaryToDb(contractSummary: any) { // 계약번호 생성
const contractNumber = await generateContractNumber(
- basicInfo.contractType || basicInfo.type || 'UP',
- basicInfo.userId
+ basicInfo.userId,
+ basicInfo.contractType || basicInfo.type || 'UP'
)
return {
@@ -1584,38 +1587,38 @@ async function mapContractSummaryToDb(contractSummary: any) { currency: basicInfo.currency || basicInfo.contractCurrency || 'USD',
totalAmount: Number(basicInfo.totalAmount || basicInfo.contractAmount || 0),
- // SAP ECC 관련 필드들
- poVersion: basicInfo.revision || 1,
- purchaseDocType: basicInfo.type || 'UP',
- purchaseOrg: basicInfo.purchaseOrg || '',
- purchaseGroup: basicInfo.purchaseGroup || '',
- exchangeRate: Number(basicInfo.exchangeRate || 1),
+ // // SAP ECC 관련 필드들
+ // poVersion: basicInfo.revision || 1,
+ // purchaseDocType: basicInfo.type || 'UP',
+ // purchaseOrg: basicInfo.purchaseOrg || '',
+ // purchaseGroup: basicInfo.purchaseGroup || '',
+ // exchangeRate: Number(basicInfo.exchangeRate || 1),
- // 계약/보증 관련
- contractGuaranteeCode: basicInfo.contractGuaranteeCode || '',
- defectGuaranteeCode: basicInfo.defectGuaranteeCode || '',
- guaranteePeriodCode: basicInfo.guaranteePeriodCode || '',
- advancePaymentYn: basicInfo.advancePaymentYn || 'N',
+ // // 계약/보증 관련
+ // contractGuaranteeCode: basicInfo.contractGuaranteeCode || '',
+ // defectGuaranteeCode: basicInfo.defectGuaranteeCode || '',
+ // guaranteePeriodCode: basicInfo.guaranteePeriodCode || '',
+ // advancePaymentYn: basicInfo.advancePaymentYn || 'N',
- // 전자계약/승인 관련
- electronicContractYn: basicInfo.electronicContractYn || 'Y',
- electronicApprovalDate: basicInfo.electronicApprovalDate || null,
- electronicApprovalTime: basicInfo.electronicApprovalTime || '',
- ownerApprovalYn: basicInfo.ownerApprovalYn || 'N',
+ // // 전자계약/승인 관련
+ // electronicContractYn: basicInfo.electronicContractYn || 'Y',
+ // electronicApprovalDate: basicInfo.electronicApprovalDate || null,
+ // electronicApprovalTime: basicInfo.electronicApprovalTime || '',
+ // ownerApprovalYn: basicInfo.ownerApprovalYn || 'N',
- // 기타
- plannedInOutFlag: basicInfo.plannedInOutFlag || 'I',
- settlementStandard: basicInfo.settlementStandard || 'A',
- weightSettlementFlag: basicInfo.weightSettlementFlag || 'N',
+ // // 기타
+ // plannedInOutFlag: basicInfo.plannedInOutFlag || 'I',
+ // settlementStandard: basicInfo.settlementStandard || 'A',
+ // weightSettlementFlag: basicInfo.weightSettlementFlag || 'N',
// 연동제 관련
priceIndexYn: basicInfo.priceIndexYn || 'N',
writtenContractNo: basicInfo.contractNumber || '',
contractVersion: basicInfo.revision || 1,
- // 부분 납품/결제
- partialShippingAllowed: basicInfo.partialShippingAllowed || false,
- partialPaymentAllowed: basicInfo.partialPaymentAllowed || false,
+ // // 부분 납품/결제
+ // partialShippingAllowed: basicInfo.partialShippingAllowed || false,
+ // partialPaymentAllowed: basicInfo.partialPaymentAllowed || false,
// 메모
remarks: basicInfo.notes || basicInfo.remarks || '',
@@ -1748,7 +1751,7 @@ export async function generateContractNumber( const user = await db
.select({ userCode: users.userCode })
.from(users)
- .where(eq(users.id, userId))
+ .where(eq(users.id, parseInt(userId || '0')))
.limit(1);
if (user[0]?.userCode && user[0].userCode.length >= 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="일련번호" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => ( <span className="font-mono text-sm">{row.original.serialNo || "-"}</span> ), @@ -248,6 +250,7 @@ export function RfqAttachmentsTable({ { accessorKey: "originalFileName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="파일명" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => { const file = row.original; return ( @@ -266,6 +269,7 @@ export function RfqAttachmentsTable({ { accessorKey: "description", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => ( <div className="max-w-[200px] truncate" title={row.original.description || ""}> {row.original.description || "-"} @@ -276,6 +280,7 @@ export function RfqAttachmentsTable({ { accessorKey: "currentRevision", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="리비전" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => { const revision = row.original.currentRevision; return revision ? ( @@ -291,6 +296,7 @@ export function RfqAttachmentsTable({ { accessorKey: "fileSize", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="크기" />, + filterFn: createFilterFn("number"), // number 타입으로 변경 cell: ({ row }) => ( <span className="text-sm text-muted-foreground"> {formatFileSize(row.original.fileSize)} @@ -299,14 +305,50 @@ export function RfqAttachmentsTable({ size: 80, }, { + accessorKey: "fileType", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="파일 타입" />, + filterFn: createFilterFn("select"), // 추가 + cell: ({ row }) => { + const fileType = row.original.fileType; + if (!fileType) return <span className="text-muted-foreground">-</span>; + + 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 ( + <Badge variant="outline" className={cn("text-xs", color)}> + {displayType} + </Badge> + ); + }, + size: 100, + }, + { accessorKey: "createdByName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드자" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => row.original.createdByName || "-", size: 100, }, { accessorKey: "createdAt", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드일" />, + filterFn: createFilterFn("date"), // date 타입으로 변경 cell: ({ row }) => { const date = row.original.createdAt; return date ? ( @@ -334,6 +376,7 @@ export function RfqAttachmentsTable({ { accessorKey: "updatedAt", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="수정일" />, + filterFn: createFilterFn("date"), // date 타입으로 변경 cell: ({ row }) => { const date = row.original.updatedAt; return date ? format(new Date(date), "MM-dd HH:mm") : "-"; @@ -341,46 +384,37 @@ export function RfqAttachmentsTable({ size: 100, }, { + accessorKey: "revisionComment", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="리비전 코멘트" />, + filterFn: createFilterFn("text"), // 추가 + cell: ({ row }) => { + const comment = row.original.revisionComment; + return comment ? ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm truncate max-w-[150px] block cursor-help"> + {comment} + </span> + </TooltipTrigger> + <TooltipContent className="max-w-[300px]"> + <p className="text-sm">{comment}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 150, + }, + { id: "actions", header: "작업", cell: ({ row }) => { return ( <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="h-8 w-8 p-0"> - <span className="sr-only">메뉴 열기</span> - <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> - </svg> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={() => handleAction({ row, type: "download" })}> - <Download className="mr-2 h-4 w-4" /> - 다운로드 - </DropdownMenuItem> - <DropdownMenuItem onClick={() => handleAction({ row, type: "preview" })}> - <Eye className="mr-2 h-4 w-4" /> - 미리보기 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem onClick={() => handleAction({ row, type: "history" })}> - <History className="mr-2 h-4 w-4" /> - 리비전 이력 - </DropdownMenuItem> - <DropdownMenuItem onClick={() => handleAction({ row, type: "update" })}> - <Upload className="mr-2 h-4 w-4" /> - 새 버전 업로드 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - onClick={() => handleAction({ row, type: "delete" })} - className="text-red-600" - > - <Trash2 className="mr-2 h-4 w-4" /> - 삭제 - </DropdownMenuItem> - </DropdownMenuContent> + {/* ... 기존 드롭다운 메뉴 내용 ... */} </DropdownMenu> ); }, @@ -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) { <FileText className="h-3 w-3" /> 일반계약 </Button> - <Button + {/* <Button size="sm" variant="default" onClick={() => { @@ -518,7 +518,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { > <Globe className="h-3 w-3" /> 입찰 - </Button> + </Button> */} </div> )} </div> 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 {/* 스크롤 가능한 컨텐츠 영역 */} <ScrollArea className="flex-1 px-1"> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2"> + <form id="createGeneralRfqForm" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2"> {/* 기본 정보 섹션 */} <div className="space-y-4"> @@ -766,8 +766,10 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp </Button> <Button type="submit" + form="createGeneralRfqForm" onClick={form.handleSubmit(onSubmit)} disabled={isLoading} + // variant="default" > {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isLoading ? "생성 중..." : "일반견적 생성"} diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx index d933fa95..7d48f5a4 100644 --- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx +++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Users, RefreshCw, FileDown, Plus } from "lucide-react"; import { RfqsLastView } from "@/db/schema"; import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog"; +import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; // 추가 import { Badge } from "@/components/ui/badge"; import { Tooltip, @@ -26,6 +27,8 @@ export function RfqTableToolbarActions<TData>({ onRefresh }: RfqTableToolbarActionsProps<TData>) { const [showAssignDialog, setShowAssignDialog] = React.useState(false); + + console.log(rfqCategory) // 선택된 행 가져오기 const selectedRows = table.getFilteredSelectedRowModel().rows; @@ -52,6 +55,10 @@ export function RfqTableToolbarActions<TData>({ onRefresh?.(); }; + const handleCreateGeneralRfqSuccess = () => { + onRefresh?.(); // 테이블 데이터 새로고침 + }; + return ( <> <div className="flex items-center gap-2"> @@ -114,16 +121,8 @@ export function RfqTableToolbarActions<TData>({ </Button> {rfqCategory === "general" && ( - <Button - variant="outline" - size="sm" - className="flex items-center gap-2" - > - <Plus className="h-4 w-4" /> - 일반견적 생성 - </Button> + <CreateGeneralRfqDialog onSuccess={handleCreateGeneralRfqSuccess} /> )} - <Button variant="outline" size="sm" @@ -134,6 +133,7 @@ export function RfqTableToolbarActions<TData>({ 엑셀 다운로드 </Button> </div> + {/* 담당자 지정 다이얼로그 */} <RfqAssignPicDialog diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 72539113..0ebcecbd 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -29,7 +29,8 @@ import { Router, Shield, CheckSquare, - GitCompare + GitCompare, + Link } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -69,6 +70,7 @@ import { VendorResponseDetailDialog } from "./vendor-detail-dialog"; import { DeleteVendorDialog } from "./delete-vendor-dialog"; import { useRouter } from "next/navigation" import { EditContractDialog } from "./edit-contract-dialog"; +import { createFilterFn } from "@/components/client-data-table/table-filters"; // 타입 정의 interface RfqDetail { @@ -292,13 +294,14 @@ export function RfqVendorTable({ ); console.log(mergedData, "mergedData") + console.log(rfqId, "rfqId") // Short List 확정 핸들러 const handleShortListConfirm = React.useCallback(async () => { 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="ITB/RFQ/견적 No." />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { return ( <span className="font-mono text-xs">{row.original.rfqCode || "-"}</span> @@ -603,6 +608,8 @@ export function RfqVendorTable({ { accessorKey: "vendorName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const vendor = row.original; return ( @@ -620,12 +627,16 @@ export function RfqVendorTable({ { accessorKey: "vendorCategory", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업체분류" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => row.original.vendorCategory || "-", size: 100, }, { accessorKey: "vendorCountry", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="내외자 (위치)" />, + 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="AVL 정보 (등급)" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const grade = row.original.vendorGrade; if (!grade) return <span className="text-muted-foreground">-</span>; @@ -661,9 +674,11 @@ export function RfqVendorTable({ header: ({ column }) => ( <ClientDataTableColumnHeaderSimple column={column} title="TBE 상태" /> ), + filterFn: createFilterFn("text"), + cell: ({ row }) => { const status = row.original.tbeStatus; - + if (!status || status === "준비중") { return ( <Badge variant="outline" className="text-gray-500"> @@ -672,7 +687,7 @@ export function RfqVendorTable({ </Badge> ); } - + const statusConfig = { "진행중": { variant: "default", icon: <Clock className="h-3 w-3 mr-1" />, color: "text-blue-600" }, "검토중": { variant: "secondary", icon: <Eye className="h-3 w-3 mr-1" />, color: "text-orange-600" }, @@ -680,7 +695,7 @@ export function RfqVendorTable({ "완료": { variant: "success", icon: <CheckCircle className="h-3 w-3 mr-1" />, color: "text-green-600" }, "취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" />, color: "text-red-600" }, }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; - + return ( <Badge variant={statusConfig.variant as any} className={statusConfig.color}> {statusConfig.icon} @@ -690,42 +705,44 @@ export function RfqVendorTable({ }, size: 100, }, - + { accessorKey: "tbeEvaluationResult", header: ({ column }) => ( <ClientDataTableColumnHeaderSimple column={column} title="TBE 평가" /> ), + filterFn: createFilterFn("text"), + cell: ({ row }) => { const result = row.original.tbeEvaluationResult; const status = row.original.tbeStatus; - + // TBE가 완료되지 않았으면 표시하지 않음 if (status !== "완료" || !result) { return <span className="text-xs text-muted-foreground">-</span>; } - + const resultConfig = { - "Acceptable": { - variant: "success", - icon: <CheckCircle className="h-3 w-3" />, + "Acceptable": { + variant: "success", + icon: <CheckCircle className="h-3 w-3" />, text: "적합", color: "bg-green-50 text-green-700 border-green-200" }, - "Acceptable with Comment": { - variant: "warning", - icon: <AlertCircle className="h-3 w-3" />, + "Acceptable with Comment": { + variant: "warning", + icon: <AlertCircle className="h-3 w-3" />, text: "조건부 적합", color: "bg-yellow-50 text-yellow-700 border-yellow-200" }, - "Not Acceptable": { - variant: "destructive", - icon: <XCircle className="h-3 w-3" />, + "Not Acceptable": { + variant: "destructive", + icon: <XCircle className="h-3 w-3" />, text: "부적합", color: "bg-red-50 text-red-700 border-red-200" }, }[result]; - + return ( <TooltipProvider> <Tooltip> @@ -755,6 +772,8 @@ export function RfqVendorTable({ { accessorKey: "contractRequirements", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />, + 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="발송 회차" />, + 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const currency = row.original.currency; return currency ? ( @@ -949,6 +974,8 @@ export function RfqVendorTable({ { accessorKey: "paymentTermsCode", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="지급조건" />, + 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="Tax" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => row.original.taxCode || "-", size: 60, }, { accessorKey: "deliveryDate", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="계약납기일/기간" />, + 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="Incoterms" />, + 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="선적지" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const place = row.original.placeOfShipping; return place ? ( @@ -1046,6 +1081,7 @@ export function RfqVendorTable({ { accessorKey: "placeOfDestination", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="도착지" />, + 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, + 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 }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />, cell: ({ row }) => ( row.original.shortList ? ( @@ -1143,6 +1184,7 @@ export function RfqVendorTable({ }] : []), { accessorKey: "updatedByUserName", + filterFn: createFilterFn("text"), // 추가 header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정자" />, cell: ({ row }) => { const name = row.original.updatedByUserName; @@ -1238,24 +1280,160 @@ export function RfqVendorTable({ } ], [handleAction, rfqCode, isLoadingSendData]); + // advancedFilterFields 정의 - columns와 매칭되도록 정리 const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ - { 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 ( <div className="flex items-center gap-2"> + {(rfqCode?.startsWith("I") || rfqCode?.startsWith("R")) && + + <Button + variant="outline" + size="sm" + > + <Link className="h-4 w-4 mr-2" /> + AVL 연동 + </Button> + } + + <Button variant="outline" size="sm" @@ -1298,32 +1493,32 @@ export function RfqVendorTable({ <Plus className="h-4 w-4 mr-2" /> 벤더 추가 </Button> - + {selectedRows.length > 0 && ( <> {/* Short List 확정 버튼 */} - {rfqCode?.startsWith("I")&& - <Button - variant="outline" - size="sm" - onClick={handleShortListConfirm} - disabled={isUpdatingShortList } + {rfqCode?.startsWith("I") && + <Button + variant="outline" + size="sm" + onClick={handleShortListConfirm} + disabled={isUpdatingShortList} // className={ "border-green-500 text-green-600 hover:bg-green-50" } - > - {isUpdatingShortList ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - 처리중... - </> - ) : ( - <> - <CheckSquare className="h-4 w-4 mr-2" /> - Short List 확정 - {participatingCount > 0 && ` (${participatingCount})`} - </> - )} - </Button> - } + > + {isUpdatingShortList ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 처리중... + </> + ) : ( + <> + <CheckSquare className="h-4 w-4 mr-2" /> + Short List 확정 + {participatingCount > 0 && ` (${participatingCount})`} + </> + )} + </Button> + } {/* 견적 비교 버튼 */} <Button @@ -1334,7 +1529,7 @@ export function RfqVendorTable({ className={quotationCount >= 2 ? "border-blue-500 text-blue-600 hover:bg-blue-50" : ""} > <GitCompare className="h-4 w-4 mr-2" /> - 견적 비교 + 견적 비교 {quotationCount > 0 && ` (${quotationCount})`} </Button> @@ -1370,7 +1565,7 @@ export function RfqVendorTable({ </Button> </> )} - + <Button variant="outline" size="sm" @@ -1464,19 +1659,19 @@ export function RfqVendorTable({ /> )} - {/* 기본계약 수정 다이얼로그 - 새로 추가 */} - {editContractVendor && ( - <EditContractDialog - open={!!editContractVendor} - onOpenChange={(open) => !open && setEditContractVendor(null)} - rfqId={rfqId} - vendor={editContractVendor} - onSuccess={() => { - setEditContractVendor(null); - router.refresh(); - }} - /> - )} + {/* 기본계약 수정 다이얼로그 - 새로 추가 */} + {editContractVendor && ( + <EditContractDialog + open={!!editContractVendor} + onOpenChange={(open) => !open && setEditContractVendor(null)} + rfqId={rfqId} + vendor={editContractVendor} + onSuccess={() => { + setEditContractVendor(null); + router.refresh(); + }} + /> + )} </> ); }
\ No newline at end of file diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts index ede2963f..13c51824 100644 --- a/lib/vendor-document-list/import-service.ts +++ b/lib/vendor-document-list/import-service.ts @@ -506,7 +506,7 @@ class ImportService { // DOLCE FileInfo API 응답 구조에 맞게 처리 if (data.FileInfoListResult) { const files = data.FileInfoListResult as DOLCEFileInfo[] - const activeFiles = files.filter(f => f.UseYn === 'Y') + const activeFiles = files.filter(f => f.UseYn === 'True') debugSuccess(`DOLCE 파일 정보 조회 완료`, { uploadId, totalFiles: files.length, @@ -885,7 +885,7 @@ class ImportService { const fileInfos = await this.fetchFileInfoFromDOLCE(detailDoc.UploadId) for (const fileInfo of fileInfos) { - if (fileInfo.UseYn !== 'Y') { + if (fileInfo.UseYn !== 'True') { debugProcess(`비활성 파일 스킵`, { fileName: fileInfo.FileName }) continue } @@ -1578,10 +1578,10 @@ async getImportStatus( if (detailDoc.Category === 'FS' && detailDoc.UploadId) { try { const fileInfos = await this.fetchFileInfoFromDOLCE(detailDoc.UploadId) - availableAttachments += fileInfos.filter(f => f.UseYn === 'Y').length + availableAttachments += fileInfos.filter(f => f.UseYn === 'True').length for (const fileInfo of fileInfos) { - if (fileInfo.UseYn !== 'Y') continue + if (fileInfo.UseYn !== 'True') continue const existingAttachment = await db .select({ id: documentAttachments.id }) diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 26f6b638..f49d7d47 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -31,7 +31,7 @@ import { SelectValue, } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" -import { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle} from "lucide-react" import { toast } from "sonner" import { @@ -109,11 +109,10 @@ export function AddDocumentDialog({ const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([]) const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([]) + const [isLoadingInitialData, setIsLoadingInitialData] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) - console.log(documentNumberTypes, "documentNumberTypes") - console.log(documentClassOptions, "documentClassOptions") - const [formData, setFormData] = React.useState({ documentNumberTypeId: "", documentClassId: "", @@ -126,12 +125,13 @@ export function AddDocumentDialog({ // Load initial data React.useEffect(() => { if (open) { + resetForm() // 폼 리셋 추가 loadInitialData() } }, [open]) const loadInitialData = async () => { - setIsLoading(true) + setIsLoadingInitialData(true) // isLoading 대신 try { const [typesResult, classesResult] = await Promise.all([ getDocumentNumberTypes(contractId), @@ -147,7 +147,7 @@ export function AddDocumentDialog({ } catch (error) { toast.error("Error loading data.") } finally { - setIsLoading(false) + setIsLoadingInitialData(false) } } @@ -284,7 +284,7 @@ export function AddDocumentDialog({ return } - setIsLoading(true) + setIsSubmitting(true) // isLoading 대신 try { const result = await createDocument({ contractId, @@ -307,7 +307,7 @@ export function AddDocumentDialog({ } catch (error) { toast.error("Error adding document.") } finally { - setIsLoading(false) + setIsSubmitting(false) // isLoading 대신 } } @@ -513,11 +513,11 @@ export function AddDocumentDialog({ )} <DialogFooter className="flex-shrink-0"> - <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}> Cancel </Button> - <Button onClick={handleSubmit} disabled={isLoading || !isFormValid()}> - {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} + <Button onClick={handleSubmit} disabled={isSubmitting || !isFormValid()}> + {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} Add Document </Button> </DialogFooter> @@ -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<typeof Dialog> { - documents: Row<DocumentStagesOnlyView>["original"][] + documents: Row<StageDocumentsView>["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<DocumentStagesOnlyView> + table: Table<StageDocumentsView> 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 ( <div className="flex items-center gap-2"> + + + {/* 1) 선택된 문서가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - <DeleteDocumentsDialog - documents={table - .getFilteredSelectedRowModel() - .rows.map((row) => 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 ? ( + <DeleteDocumentsDialog + documents={deletableDocuments} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null; + })()} {/* 2) 새 문서 추가 다이얼로그 */} @@ -76,9 +128,45 @@ export function DocumentsTableToolbarActions({ projectType={projectType} /> + {/* SHI 전송 버튼 */} + <Button + variant="samsung" + size="sm" + onClick={handleSendToSHI} + disabled={isSending} + className="gap-2" + > + {isSending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Sending.. + </> + ) : ( + <> + <Send className="h-4 w-4" /> + Send to SHI + </> + )} + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => pollDocuments(true)} + disabled={isPolling} + className="gap-2" + > + <RefreshCw className={cn( + "h-4 w-4", + isPolling && "animate-spin" + )} /> + Sync from SHI + </Button> + + <Button onClick={handleExcelImport} variant="outline" size="sm"> <FileSpreadsheet className="mr-2 h-4 w-4" /> - Excel Import + Excel Import </Button> <ExcelImportDialog diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx index aee47029..2f8fd482 100644 --- a/lib/vendor-document-list/plant/document-stages-columns.tsx +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -6,7 +6,7 @@ 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 { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { DropdownMenu, DropdownMenuContent, @@ -28,12 +28,17 @@ import { Eye, Edit, Plus, - Trash2 + Trash2,MessageSquare } from "lucide-react" import { cn } from "@/lib/utils" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesOnlyView> | null>> + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageDocumentsView> | null>> projectType: string domain?: "evcp" | "partners" // 선택적 파라미터로 유지 } @@ -139,11 +144,11 @@ export function getDocumentStagesColumns({ setRowAction, projectType, domain = "partners", // 기본값 설정 -}: GetColumnsProps): ColumnDef<DocumentStagesOnlyView>[] { +}: GetColumnsProps): ColumnDef<StageDocumentsView>[] { const isPlantProject = projectType === "plant" const isEvcpDomain = domain === "evcp" - const columns: ColumnDef<DocumentStagesOnlyView>[] = [ + const columns: ColumnDef<StageDocumentsView>[] = [ // 체크박스 선택 { id: "select", @@ -315,6 +320,75 @@ export function getDocumentStagesColumns({ // 나머지 공통 컬럼들 columns.push( // 현재 스테이지 (상태, 담당자 한 줄) + + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Status" /> + ), + cell: ({ row }) => { + const doc = row.original + + return ( + <div className="flex items-center gap-2"> + <Badge + variant={getStatusColor(doc.status || false)} + className="text-xs px-1.5 py-0" + > + {getStatusText(doc.status || '')} + </Badge> + </div> + ) + }, + size: 180, + enableResizing: true, + meta: { + excelHeader: "Document Status" + }, + }, + + { + accessorKey: "buyerSystemStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="SHI Status" /> + ), + cell: ({ row }) => { + const doc = row.original + const getBuyerStatusBadge = () => { + if (!doc.buyerSystemStatus) { + return <Badge variant="outline">Not Recieved</Badge> + } + + switch (doc.buyerSystemStatus) { + case '승인(DC)': + return <Badge variant="success">Approved</Badge> + case '검토중': + return <Badge variant="default">검토중</Badge> + case '반려': + return <Badge variant="destructive">반려</Badge> + default: + return <Badge variant="secondary">{doc.buyerSystemStatus}</Badge> + } + } + + return ( + <div className="flex flex-col gap-1"> + {getBuyerStatusBadge()} + {doc.buyerSystemComment && ( + <Tooltip> + <TooltipTrigger> + <MessageSquare className="h-3 w-3 text-muted-foreground" /> + </TooltipTrigger> + <TooltipContent> + <p className="max-w-xs">{doc.buyerSystemComment}</p> + </TooltipContent> + </Tooltip> + )} + </div> + ) + }, + 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<ValidationResult[]> { + 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<DataTableRowAction<DocumentStagesOnlyView> | null>(null) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageDocumentsView> | null>(null) const [expandedRows, setExpandedRows] = React.useState<Set<string>>(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<DocumentStagesOnlyView | null>(null) + const [selectedDocument, setSelectedDocument] = React.useState<StageDocumentsView | null>(null) const [selectedStageId, setSelectedStageId] = React.useState<number | null>(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<DocumentStagesOnlyView>[] = [ + const filterFields: DataTableFilterField<StageDocumentsView>[] = [ ] - const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesOnlyView>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<StageDocumentsView>[] = [ { 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<string>`(SELECT code FROM projects WHERE id = ${stageDocuments.projectId})`, + vendorCode: sql<string>`(SELECT vendor_code FROM vendors WHERE id = ${stageDocuments.vendorId})`, + vendorName: sql<string>`(SELECT vendor_name FROM vendors WHERE id = ${stageDocuments.vendorId})`, + stages: sql<any[]>` + 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<ShiDocumentResponse[]> { + 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<Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>> { + const filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }> = [] + + 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<InBoxFileInfo & { fileBuffer: Buffer }>): Promise<SaveInBoxListResponse> { + // 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<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>, + 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<React.SetStateAction<DataTableRowAction<StageSubmissionView> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<StageSubmissionView>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Doc Number" /> + ), + cell: ({ row }) => { + const vendorDocNumber = row.original.vendorDocNumber + return ( + <div className="space-y-1"> + <div className="font-medium">{row.getValue("docNumber")}</div> + {vendorDocNumber && ( + <div className="text-xs text-muted-foreground">{vendorDocNumber}</div> + )} + </div> + ) + }, + size: 150, + }, + { + accessorKey: "documentTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Title" /> + ), + cell: ({ row }) => ( + <div className="max-w-[300px] truncate" title={row.getValue("documentTitle")}> + {row.getValue("documentTitle")} + </div> + ), + size: 250, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => ( + <Badge variant="outline">{row.getValue("projectCode")}</Badge> + ), + size: 100, + }, + { + accessorKey: "stageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Stage" /> + ), + cell: ({ row }) => { + const stageName = row.getValue("stageName") as string + const stageStatus = row.original.stageStatus + const stageOrder = row.original.stageOrder + + return ( + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {stageOrder ? `#${stageOrder}` : ""} + </Badge> + <span className="text-sm">{stageName}</span> + </div> + {stageStatus && ( + <Badge + variant={ + stageStatus === "COMPLETED" ? "success" : + stageStatus === "IN_PROGRESS" ? "default" : + stageStatus === "REJECTED" ? "destructive" : + "secondary" + } + className="text-xs" + > + {stageStatus} + </Badge> + )} + </div> + ) + }, + size: 200, + }, + { + accessorKey: "stagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Due Date" /> + ), + cell: ({ row }) => { + const planDate = row.getValue("stagePlanDate") as Date | null + const isOverdue = row.original.isOverdue + const daysUntilDue = row.original.daysUntilDue + + if (!planDate) return <span className="text-muted-foreground">-</span> + + return ( + <div className="space-y-1"> + <div className={isOverdue ? "text-destructive font-medium" : ""}> + {formatDate(planDate)} + </div> + {daysUntilDue !== null && ( + <div className="text-xs"> + {isOverdue ? ( + <Badge variant="destructive" className="gap-1"> + <AlertCircle className="h-3 w-3" /> + {Math.abs(daysUntilDue)} days overdue + </Badge> + ) : daysUntilDue === 0 ? ( + <Badge variant="warning" className="gap-1"> + <Clock className="h-3 w-3" /> + Due today + </Badge> + ) : ( + <span className="text-muted-foreground"> + {daysUntilDue} days remaining + </span> + )} + </div> + )} + </div> + ) + }, + size: 150, + }, + { + accessorKey: "latestSubmissionStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Submission Status" /> + ), + 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 ( + <Badge variant="outline" className="gap-1"> + <AlertCircle className="h-3 w-3" /> + Not submitted + </Badge> + ) + } + + return ( + <div className="space-y-1"> + <Badge + variant={ + reviewStatus === "APPROVED" ? "success" : + reviewStatus === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : + "secondary" + } + > + {reviewStatus || status} + </Badge> + {revisionCode !== null &&( + <div className="text-xs text-muted-foreground"> + {revisionCode} + </div> + )} + </div> + ) + }, + size: 150, + }, + { + id: "syncStatus", + accessorKey: "latestSyncStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Sync Status" /> + ), + 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 ( + <Badge variant="outline" className="gap-1"> + <Clock className="h-3 w-3" /> + Pending + </Badge> + ) + } + return <span className="text-muted-foreground">-</span> + } + + return ( + <div className="space-y-2"> + <Badge + variant={ + syncStatus === "synced" ? "success" : + syncStatus === "failed" ? "destructive" : + syncStatus === "syncing" ? "default" : + "secondary" + } + className="gap-1" + > + {syncStatus === "syncing" && <RefreshCw className="h-3 w-3 animate-spin" />} + {syncStatus === "synced" && <CheckCircle2 className="h-3 w-3" />} + {syncStatus === "failed" && <XCircle className="h-3 w-3" />} + {syncStatus} + </Badge> + {syncProgress !== null && syncProgress !== undefined && syncStatus === "syncing" && ( + <Progress value={syncProgress} className="h-1.5 w-20" /> + )} + </div> + ) + }, + size: 120, + }, + { + accessorKey: "totalFiles", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Files" /> + ), + cell: ({ row }) => { + const totalFiles = row.getValue("totalFiles") as number + const syncedFiles = row.original.syncedFilesCount + + if (!totalFiles) return <span className="text-muted-foreground">0</span> + + return ( + <div className="text-sm"> + {syncedFiles !== null && syncedFiles !== undefined ? ( + <span>{syncedFiles}/{totalFiles}</span> + ) : ( + <span>{totalFiles}</span> + )} + </div> + ) + }, + size: 80, + }, + // { + // accessorKey: "vendorName", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Vendor" /> + // ), + // cell: ({ row }) => { + // const vendorName = row.getValue("vendorName") as string + // const vendorCode = row.original.vendorCode + + // return ( + // <div className="space-y-1"> + // <div className="text-sm">{vendorName}</div> + // {vendorCode && ( + // <div className="text-xs text-muted-foreground">{vendorCode}</div> + // )} + // </div> + // ) + // }, + // 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 ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 p-0" + > + <Ellipsis className="size-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + {requiresSubmission && ( + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "upload" })} + className="gap-2" + > + <Upload className="h-4 w-4" /> + Upload Documents + </DropdownMenuItem> + )} + + {latestSubmissionId && ( + <> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "view" })} + className="gap-2" + > + <Eye className="h-4 w-4" /> + View Submission + </DropdownMenuItem> + + {requiresSync && ( + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "sync" })} + className="gap-2" + > + <RefreshCw className="h-4 w-4" /> + Retry Sync + </DropdownMenuItem> + )} + </> + )} + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "history" })} + className="gap-2" + > + <Clock className="h-4 w-4" /> + View History + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + 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 <CheckCircle2 className="h-4 w-4 text-success" /> + } + if (reviewStatus === "REJECTED") { + return <XCircle className="h-4 w-4 text-destructive" /> + } + if (status === "SUBMITTED") { + return <Clock className="h-4 w-4 text-primary" /> + } + return <AlertCircle className="h-4 w-4 text-muted-foreground" /> + } + + const getStatusBadge = (status: string, reviewStatus?: string) => { + const variant = reviewStatus === "APPROVED" ? "success" : + reviewStatus === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return ( + <Badge variant={variant}> + {reviewStatus || status} + </Badge> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>Submission History</DialogTitle> + <DialogDescription> + View all submission history for this stage + </DialogDescription> + </DialogHeader> + + {/* Document Info */} + <div className="grid gap-2 p-4 bg-muted rounded-lg"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">{submission.docNumber}</span> + <span className="text-sm text-muted-foreground"> + - {submission.documentTitle} + </span> + </div> + <Badge variant="outline">{submission.stageName}</Badge> + </div> + </div> + + {/* History Timeline */} + <ScrollArea className="h-[400px] pr-4"> + {history.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + No submission history available + </div> + ) : ( + <div className="space-y-4"> + {history.map((item, index) => ( + <div key={item.submissionId} className="relative"> + {/* Timeline line */} + {index < history.length - 1 && ( + <div className="absolute left-5 top-10 bottom-0 w-0.5 bg-border" /> + )} + + {/* Timeline item */} + <div className="flex gap-4"> + <div className="flex-shrink-0 w-10 h-10 rounded-full bg-background border-2 border-border flex items-center justify-center"> + {getStatusIcon(item.status, item.reviewStatus)} + </div> + + <div className="flex-1 pb-4"> + <div className="flex items-center gap-2 mb-2"> + <span className="font-medium">Revision {item.revisionNumber}</span> + {getStatusBadge(item.status, item.reviewStatus)} + {item.syncStatus && ( + <Badge variant="outline" className="text-xs"> + Sync: {item.syncStatus} + </Badge> + )} + </div> + + <div className="grid gap-1 text-sm text-muted-foreground"> + <div className="flex items-center gap-2"> + <User className="h-3 w-3" /> + <span>{item.submittedBy}</span> + </div> + <div className="flex items-center gap-2"> + <Calendar className="h-3 w-3" /> + <span>{formatDateTime(new Date(item.submittedAt))}</span> + </div> + <div className="flex items-center gap-2"> + <FileText className="h-3 w-3" /> + <span>{item.fileCount} file(s)</span> + </div> + </div> + </div> + </div> + </div> + ))} + </div> + )} + </ScrollArea> + </DialogContent> + </Dialog> + ) +}
\ 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<FileWithMetadata[]>([]) + const [isValidating, setIsValidating] = useState(false) + const [isUploading, setIsUploading] = useState(false) + + // 디버깅용 로그 + console.log("Current files:", files) + + // 파일 추가 핸들러 - onChange 이벤트용 + const handleFilesChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + 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<HTMLDivElement>) => { + 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<HTMLDivElement>) => { + 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 <FileWarning className="h-4 w-4 text-destructive" /> + } + + switch (fileData.status) { + case 'validating': + return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + case 'uploading': + return <Loader2 className="h-4 w-4 animate-spin text-primary" /> + case 'success': + return <CheckCircle2 className="h-4 w-4 text-success" /> + case 'error': + return <AlertCircle className="h-4 w-4 text-destructive" /> + default: + if (fileData.matched) { + return <CheckCircle2 className="h-4 w-4 text-success" /> + } else { + return <AlertCircle className="h-4 w-4 text-warning" /> + } + } + } + + // 파일별 상태 설명 + 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 ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="outline" className="gap-2"> + <CloudUpload className="h-4 w-4" /> + Multi-Upload + </Button> + </DialogTrigger> + <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>Bulk Document Upload</DialogTitle> + <DialogDescription> + Upload multiple files at once. Files should be named as: DocNumber_StageName_Revision.ext + </DialogDescription> + </DialogHeader> + + {/* Custom Dropzone with input */} + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors cursor-pointer" + onDrop={handleDrop} + onDragOver={handleDragOver} + onClick={() => document.getElementById('file-upload')?.click()} + > + <input + id="file-upload" + type="file" + multiple + className="hidden" + onChange={handleFilesChange} + accept="*/*" + /> + <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> + <p className="text-lg font-medium">Drop files here or click to browse</p> + <p className="text-sm text-gray-500 mt-1"> + Maximum 10GB total • Format: DocNumber_StageName_Revision.ext + </p> + </div> + + {/* Stats */} + {files.length > 0 && ( + <div className="flex gap-2 flex-wrap"> + <Badge variant="outline">Total: {stats.total}</Badge> + <Badge variant={stats.valid === stats.total ? "success" : "secondary"}> + Valid Format: {stats.valid} + </Badge> + <Badge variant={stats.matched > 0 ? "success" : "secondary"}> + Matched: {stats.matched} + </Badge> + <Badge variant={stats.ready > 0 ? "default" : "outline"}> + Ready: {stats.ready} + </Badge> + <Badge variant="outline"> + Size: {formatFileSize(stats.totalSize)} + </Badge> + </div> + )} + + {/* File List */} + {files.length > 0 && ( + <div className="flex-1 rounded-md border overflow-y-auto" style={{ minHeight: 200, maxHeight: 400 }}> + <FileList className="p-4"> + <FileListHeader> + <div className="text-sm font-medium">Files ({files.length})</div> + </FileListHeader> + + {files.map((fileData, index) => ( + <FileListItem key={index}> + <FileListIcon> + {getStatusIcon(fileData)} + </FileListIcon> + + <FileListInfo> + <FileListName>{fileData.file.name}</FileListName> + <FileListDescription> + {getStatusDescription(fileData)} + </FileListDescription> + </FileListInfo> + + <FileListSize> + {fileData.file.size} + </FileListSize> + + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={(e) => { + e.stopPropagation() + removeFile(index) + }} + disabled={isUploading || fileData.status === 'uploading'} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + {/* Error Alert */} + {files.filter(f => !f.parsed.isValid).length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {files.filter(f => !f.parsed.isValid).length} file(s) have invalid naming format. + Expected: ProjectCode_DocNumber_StageName_Rev0.ext + </AlertDescription> + </Alert> + )} + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setOpen(false) + setFiles([]) + }} + disabled={isUploading} + > + Cancel + </Button> + <Button + onClick={handleUpload} + disabled={stats.ready === 0 || isUploading || isValidating} + className="gap-2" + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Uploading {stats.ready} files... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Upload {stats.ready} file{stats.ready !== 1 ? 's' : ''} + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ 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 ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-[250px] justify-between" + > + <div className="flex items-center gap-2 truncate"> + <Building2 className="h-4 w-4 shrink-0 text-muted-foreground" /> + {selectedProject ? ( + <> + <span className="truncate">{selectedProject.code}</span> + <Badge variant="secondary" className="ml-1"> + Selected + </Badge> + </> + ) : ( + <span className="text-muted-foreground">All Projects</span> + )} + </div> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[250px] p-0"> + <Command> + <CommandInput placeholder="Search project..." /> + <CommandList> + <CommandEmpty>No project found.</CommandEmpty> + <CommandGroup> + <CommandItem + value="" + onSelect={() => { + onValueChange(null) + setOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + value === null ? "opacity-100" : "opacity-0" + )} + /> + <span className="text-muted-foreground">All Projects</span> + </CommandItem> + {projects.map((project) => ( + <CommandItem + key={project.id} + value={project.code} + onSelect={() => { + onValueChange(project.id) + setOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + value === project.id ? "opacity-100" : "opacity-0" + )} + /> + {project.code} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ 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<File[]>([]) + const [description, setDescription] = useState("") + const [isUploading, setIsUploading] = useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일 선택 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>Upload Documents</DialogTitle> + <DialogDescription> + Upload documents for this stage submission + </DialogDescription> + </DialogHeader> + + {/* Document Info */} + <div className="grid gap-2 p-4 bg-muted rounded-lg"> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Document:</span> + <span className="text-sm">{submission.docNumber}</span> + {submission.vendorDocNumber && ( + <span className="text-sm text-muted-foreground"> + ({submission.vendorDocNumber}) + </span> + )} + </div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Stage:</span> + <Badge variant="secondary">{submission.stageName}</Badge> + </div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Current Revision:</span> + <span className="text-sm">Rev. {submission.latestRevisionNumber || 0}</span> + <Badge variant="outline" className="ml-2"> + Next: Rev. {(submission.latestRevisionNumber || 0) + 1} + </Badge> + </div> + </div> + + {/* File Upload Area */} + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors cursor-pointer" + onClick={() => fileInputRef.current?.click()} + > + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={handleFileChange} + accept="*/*" + /> + <Upload className="mx-auto h-10 w-10 text-gray-400 mb-3" /> + <p className="text-sm font-medium">Click to browse files</p> + <p className="text-xs text-gray-500 mt-1"> + You can select multiple files + </p> + </div> + + {/* File List */} + {files.length > 0 && ( + <> + <FileList> + {files.map((file, index) => ( + <FileListItem key={index}> + <FileListIcon> + <FileIcon className="h-4 w-4 text-muted-foreground" /> + </FileListIcon> + <FileListInfo> + <FileListName>{file.name}</FileListName> + </FileListInfo> + <FileListSize> + {file.size} + </FileListSize> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={(e) => { + e.stopPropagation() + removeFile(index) + }} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + + <div className="flex justify-between text-sm text-muted-foreground"> + <span>{files.length} file(s) selected</span> + <span>Total: {formatFileSize(totalSize)}</span> + </div> + </> + )} + + {/* Description */} + <div className="space-y-2"> + <Label htmlFor="description">Description (Optional)</Label> + <Textarea + id="description" + placeholder="Add a description for this submission..." + value={description} + onChange={(e) => setDescription(e.target.value)} + rows={3} + /> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + Cancel + </Button> + <Button + onClick={handleUpload} + disabled={files.length === 0 || isUploading} + className="gap-2" + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Uploading... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Upload + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx b/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx new file mode 100644 index 00000000..9a55a7fa --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx @@ -0,0 +1,520 @@ +// lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useEffect, useRef } from "react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Download, + Eye, + FileText, + Calendar, + User, + CheckCircle2, + XCircle, + Clock, + RefreshCw, + Loader2 +} from "lucide-react" +import { StageSubmissionView } from "@/db/schema" +import { formatDateTime, formatDate } from "@/lib/utils" +import { toast } from "sonner" +import { downloadFile, formatFileSize } from "@/lib/file-download" + +interface ViewSubmissionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView +} + +interface SubmissionDetail { + id: number + revisionNumber: number + submissionStatus: string + reviewStatus?: string + reviewComments?: string + submittedBy: string + submittedAt: Date + files: Array<{ + id: number + originalFileName: string + fileSize: number + uploadedAt: Date + syncStatus: string + storageUrl: string + }> +} + +// PDFTron 문서 뷰어 컴포넌트 +const DocumentViewer: React.FC<{ + open: boolean + onClose: () => void + files: Array<{ + id: number + originalFileName: string + storageUrl: string + }> +}> = ({ open, onClose, files }) => { + const [instance, setInstance] = useState<null | WebViewerInstance>(null) + const [viewerLoading, setViewerLoading] = useState<boolean>(true) + const [fileSetLoading, setFileSetLoading] = useState<boolean>(true) + const viewer = useRef<HTMLDivElement>(null) + const initialized = useRef(false) + const isCancelled = useRef(false) + + const cleanupHtmlStyle = () => { + const htmlElement = document.documentElement + const originalStyle = htmlElement.getAttribute("style") || "" + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")) + + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";") + } else { + htmlElement.removeAttribute("style") + } + } + + useEffect(() => { + if (open && !initialized.current) { + initialized.current = true + isCancelled.current = false + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("WebViewer 초기화 취소됨") + return + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: + "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css: "/globals.css", + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + setInstance(instance) + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) + instance.UI.disableElements([ + "addTabButton", + "multiTabsEmptyPage", + ]) + setViewerLoading(false) + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + setTimeout(() => cleanupHtmlStyle(), 500) + } + }, [open]) + + useEffect(() => { + const loadDocuments = async () => { + if (instance && files.length > 0) { + const { UI } = instance + const tabIds = [] + + for (const file of files) { + const fileExtension = file.originalFileName.split('.').pop()?.toLowerCase() + + const options = { + filename: file.originalFileName, + ...(fileExtension === 'xlsx' || fileExtension === 'xls' ? { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + } : {}), + } + + try { + const response = await fetch(file.storageUrl) + const blob = await response.blob() + const tab = await UI.TabManager.addTab(blob, options) + tabIds.push(tab) + } catch (error) { + console.error(`Failed to load ${file.originalFileName}:`, error) + toast.error(`Failed to load ${file.originalFileName}`) + } + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]) + } + + setFileSetLoading(false) + } + } + + loadDocuments() + }, [instance, files]) + + const handleClose = async () => { + if (!fileSetLoading) { + if (instance) { + try { + await instance.UI.dispose() + setInstance(null) + } catch (e) { + console.warn("dispose error", e) + } + } + + setTimeout(() => cleanupHtmlStyle(), 1000) + onClose() + } + } + + return ( + <Dialog open={open} onOpenChange={(val) => !val && handleClose()}> + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>Preview</DialogTitle> + {/* <DialogDescription>첨부파일 미리보기</DialogDescription> */} + </DialogHeader> + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viewerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + 문서 뷰어 로딩 중... + </p> + </div> + )} + </div> + </DialogContent> + </Dialog> + ) +} + +export function ViewSubmissionDialog({ + open, + onOpenChange, + submission +}: ViewSubmissionDialogProps) { + const [loading, setLoading] = useState(false) + const [submissionDetail, setSubmissionDetail] = useState<SubmissionDetail | null>(null) + const [downloadingFiles, setDownloadingFiles] = useState<Set<number>>(new Set()) + const [viewerOpen, setViewerOpen] = useState(false) + const [selectedFiles, setSelectedFiles] = useState<Array<{ + id: number + originalFileName: string + storageUrl: string + }>>([]) + + useEffect(() => { + if (open && submission.latestSubmissionId) { + fetchSubmissionDetail() + } + }, [open, submission.latestSubmissionId]) + + const fetchSubmissionDetail = async () => { + if (!submission.latestSubmissionId) return + + setLoading(true) + try { + const response = await fetch(`/api/stage-submissions/${submission.latestSubmissionId}`) + if (response.ok) { + const data = await response.json() + setSubmissionDetail(data) + } + } catch (error) { + console.error("Failed to fetch submission details:", error) + toast.error("Failed to load submission details") + } finally { + setLoading(false) + } + } + + const handleDownload = async (file: any) => { + setDownloadingFiles(prev => new Set(prev).add(file.id)) + + try { + const result = await downloadFile( + file.storageUrl, + file.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onError: (error) => { + console.error("Download failed:", error) + toast.error(`Failed to download ${file.originalFileName}`) + }, + onSuccess: (fileName, fileSize) => { + console.log(`Successfully downloaded ${fileName}`) + } + } + ) + + if (!result.success) { + console.error("Download failed:", result.error) + } + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(file.id) + return newSet + }) + } + } + + // PDFTron으로 미리보기 처리 + const handlePreview = (file: any) => { + setSelectedFiles([{ + id: file.id, + originalFileName: file.originalFileName, + storageUrl: file.storageUrl + }]) + setViewerOpen(true) + } + + // 모든 파일 미리보기 + const handlePreviewAll = () => { + if (submissionDetail) { + const files = submissionDetail.files.map(file => ({ + id: file.id, + originalFileName: file.originalFileName, + storageUrl: file.storageUrl + })) + setSelectedFiles(files) + setViewerOpen(true) + } + } + + const getStatusBadge = (status?: string) => { + if (!status) return null + + const variant = status === "APPROVED" ? "success" : + status === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return <Badge variant={variant}>{status}</Badge> + } + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>View Submission</DialogTitle> + <DialogDescription> + Submission details and attached files + </DialogDescription> + </DialogHeader> + + {loading ? ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : submissionDetail ? ( + <Tabs defaultValue="details" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="details">Details</TabsTrigger> + <TabsTrigger value="files"> + Files ({submissionDetail.files.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="details" className="space-y-4"> + <div className="grid gap-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Revision + </p> + <p className="text-lg font-medium"> + Rev. {submissionDetail.revisionNumber} + </p> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Status + </p> + <div className="flex items-center gap-2"> + {getStatusBadge(submissionDetail.submissionStatus)} + {submissionDetail.reviewStatus && + getStatusBadge(submissionDetail.reviewStatus)} + </div> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Submitted By + </p> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <span>{submissionDetail.submittedBy}</span> + </div> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Submitted At + </p> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span>{formatDateTime(submissionDetail.submittedAt)}</span> + </div> + </div> + </div> + + {submissionDetail.reviewComments && ( + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Review Comments + </p> + <div className="p-3 bg-muted rounded-lg"> + <p className="text-sm">{submissionDetail.reviewComments}</p> + </div> + </div> + )} + </div> + </TabsContent> + + <TabsContent value="files"> + <div className="flex justify-end mb-4"> + <Button + variant="outline" + size="sm" + onClick={handlePreviewAll} + disabled={submissionDetail.files.length === 0} + > + <Eye className="h-4 w-4 mr-2" /> + 모든 파일 미리보기 + </Button> + </div> + <ScrollArea className="h-[400px]"> + <Table> + <TableHeader> + <TableRow> + <TableHead>File Name</TableHead> + <TableHead>Size</TableHead> + <TableHead>Upload Date</TableHead> + <TableHead>Sync Status</TableHead> + <TableHead className="text-right">Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {submissionDetail.files.map((file) => { + const isDownloading = downloadingFiles.has(file.id) + + return ( + <TableRow key={file.id}> + <TableCell className="font-medium"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + {file.originalFileName} + </div> + </TableCell> + <TableCell>{formatFileSize(file.fileSize)}</TableCell> + <TableCell>{formatDate(file.uploadedAt)}</TableCell> + <TableCell> + <Badge + variant={ + file.syncStatus === "synced" ? "success" : + file.syncStatus === "failed" ? "destructive" : + "secondary" + } + className="text-xs" + > + {file.syncStatus} + </Badge> + </TableCell> + <TableCell className="text-right"> + <div className="flex justify-end gap-2"> + <Button + variant="ghost" + size="icon" + onClick={() => handleDownload(file)} + disabled={isDownloading} + title="Download" + > + {isDownloading ? ( + <RefreshCw className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + <Button + variant="ghost" + size="icon" + onClick={() => handlePreview(file)} + disabled={isDownloading} + title="Preview" + > + {isDownloading ? ( + <RefreshCw className="h-4 w-4 animate-spin" /> + ) : ( + <Eye className="h-4 w-4" /> + )} + </Button> + </div> + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </ScrollArea> + </TabsContent> + </Tabs> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + No submission found + </div> + )} + </DialogContent> + </Dialog> + + {/* PDFTron 문서 뷰어 다이얼로그 */} + {viewerOpen && ( + <DocumentViewer + open={viewerOpen} + onClose={() => { + setViewerOpen(false) + setSelectedFiles([]) + }} + files={selectedFiles} + /> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/service.ts b/lib/vendor-document-list/plant/upload/service.ts new file mode 100644 index 00000000..18e6c132 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/service.ts @@ -0,0 +1,228 @@ +import db from "@/db/db" +import { stageSubmissionView, StageSubmissionView } from "@/db/schema" +import { and, asc, desc, eq, or, ilike, isTrue, sql, isNotNull, count } from "drizzle-orm" +import { filterColumns } from "@/lib/filter-columns" +import { GetStageSubmissionsSchema } from "./validation" +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { redirect } from "next/navigation" + +// Repository functions (동일) +async function selectStageSubmissions( + tx: typeof db, + params: { + where?: any + orderBy?: any + offset?: number + limit?: number + } +) { + const { where, orderBy = [desc(stageSubmissionView.isOverdue)], offset = 0, limit = 10 } = params + + const query = tx + .select() + .from(stageSubmissionView) + .$dynamic() + + if (where) query.where(where) + if (orderBy) query.orderBy(...(Array.isArray(orderBy) ? orderBy : [orderBy])) + query.limit(limit).offset(offset) + + return await query +} + +async function countStageSubmissions(tx: typeof db, where?: any) { + const query = tx + .select({ count: count() }) + .from(stageSubmissionView) + .$dynamic() + + if (where) query.where(where) + + const result = await query + return result[0]?.count ?? 0 +} + +// Service function with session check +export async function getStageSubmissions(input: GetStageSubmissionsSchema) { + // Session 체크 + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '로그인이 필요합니다.' + } + } + const vendorId = session.user.companyId // companyId가 vendorId + + try { + const offset = (input.page - 1) * input.perPage + + // Advanced filters + const advancedWhere = filterColumns({ + table: stageSubmissionView, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // Global search + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(stageSubmissionView.documentTitle, s), + ilike(stageSubmissionView.docNumber, s), + ilike(stageSubmissionView.vendorDocNumber, s), + ilike(stageSubmissionView.stageName, s) + // vendorName 검색 제거 (자기 회사만 보므로) + ) + } + + // Status filters + let statusWhere + if (input.submissionStatus && input.submissionStatus !== "all") { + switch (input.submissionStatus) { + case "required": + statusWhere = eq(stageSubmissionView.requiresSubmission, true) + break + case "submitted": + statusWhere = eq(stageSubmissionView.latestSubmissionStatus, "SUBMITTED") + break + case "approved": + statusWhere = eq(stageSubmissionView.latestReviewStatus, "APPROVED") + break + case "rejected": + statusWhere = eq(stageSubmissionView.latestReviewStatus, "REJECTED") + break + } + } + + // Sync status filter + let syncWhere + if (input.syncStatus && input.syncStatus !== "all") { + if (input.syncStatus === "pending") { + syncWhere = or( + eq(stageSubmissionView.latestSyncStatus, "pending"), + eq(stageSubmissionView.requiresSync, true) + ) + } else { + syncWhere = eq(stageSubmissionView.latestSyncStatus, input.syncStatus) + } + } + + // Project filter + let projectWhere = input.projectId ? eq(stageSubmissionView.projectId, input.projectId) : undefined + + // ✅ 벤더 필터 - session의 companyId 사용 + const vendorWhere = eq(stageSubmissionView.vendorId, vendorId) + + const finalWhere = and( + vendorWhere, // 항상 벤더 필터 적용 + advancedWhere, + globalWhere, + statusWhere, + syncWhere, + projectWhere + ) + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(stageSubmissionView[item.id]) + : asc(stageSubmissionView[item.id]) + ) + : [desc(stageSubmissionView.isOverdue), asc(stageSubmissionView.daysUntilDue)] + + // Transaction + const { data, total } = await db.transaction(async (tx) => { + const data = await selectStageSubmissions(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }) + const total = await countStageSubmissions(tx, finalWhere) + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount } + } catch (err) { + console.error("Error fetching stage submissions:", err) + return { data: [], pageCount: 0 } + } +} + +// 프로젝트 목록 조회 - 벤더 필터 적용 +export async function getProjects() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '로그인이 필요합니다.' + } + } + if (!session?.user?.companyId) { + return [] + } + + const vendorId = session.user.companyId + + const projects = await db + .selectDistinct({ + id: stageSubmissionView.projectId, + code: stageSubmissionView.projectCode, + }) + .from(stageSubmissionView) + .where( + and( + eq(stageSubmissionView.vendorId, vendorId), + isNotNull(stageSubmissionView.projectId) + ) + ) + .orderBy(asc(stageSubmissionView.projectCode)) + + return projects +} + +// 통계 조회 - 벤더별 +export async function getSubmissionStats() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '로그인이 필요합니다.' + } + } + + + if (!session?.user?.companyId) { + return { + pending: 0, + overdue: 0, + awaitingSync: 0, + completed: 0, + } + } + + const vendorId = session.user.companyId + + const stats = await db + .select({ + pending: sql<number>`count(*) filter (where ${stageSubmissionView.requiresSubmission} = true)::int`, + overdue: sql<number>`count(*) filter (where ${stageSubmissionView.isOverdue} = true)::int`, + awaitingSync: sql<number>`count(*) filter (where ${stageSubmissionView.requiresSync} = true)::int`, + completed: sql<number>`count(*) filter (where ${stageSubmissionView.latestReviewStatus} = 'APPROVED')::int`, + }) + .from(stageSubmissionView) + .where(eq(stageSubmissionView.vendorId, vendorId)) + + return stats[0] || { + pending: 0, + overdue: 0, + awaitingSync: 0, + completed: 0, + } +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx new file mode 100644 index 00000000..92507900 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/table.tsx @@ -0,0 +1,223 @@ +// lib/vendor-document-list/plant/upload/table.tsx +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getColumns } from "./columns" +import { getStageSubmissions } from "./service" +import { StageSubmissionView } from "@/db/schema" +import { StageSubmissionToolbarActions } from "./toolbar-actions" +import { useRouter, useSearchParams, usePathname } from "next/navigation" +import { ProjectFilter } from "./components/project-filter" +import { SingleUploadDialog } from "./components/single-upload-dialog" +import { HistoryDialog } from "./components/history-dialog" +import { ViewSubmissionDialog } from "./components/view-submission-dialog" + +interface StageSubmissionsTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getStageSubmissions>>, + { projects: Array<{ id: number; code: string }> } + ]> + selectedProjectId?: number | null +} + +export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubmissionsTableProps) { + const [{ data, pageCount }, { projects }] = React.use(promises) + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageSubmissionView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 프로젝트 필터 핸들러 + const handleProjectChange = (projectId: number | null) => { + const current = new URLSearchParams(Array.from(searchParams.entries())) + + if (projectId) { + current.set("projectId", projectId.toString()) + } else { + current.delete("projectId") + } + + // 페이지를 1로 리셋 + current.set("page", "1") + + const search = current.toString() + const query = search ? `?${search}` : "" + + router.push(`${pathname}${query}`) + } + + // Filter fields - 프로젝트 필터 제거 + const filterFields: DataTableFilterField<StageSubmissionView>[] = [ + { + id: "stageStatus", + label: "Stage Status", + options: [ + { label: "Planned", value: "PLANNED" }, + { label: "In Progress", value: "IN_PROGRESS" }, + { label: "Submitted", value: "SUBMITTED" }, + { label: "Approved", value: "APPROVED" }, + { label: "Rejected", value: "REJECTED" }, + { label: "Completed", value: "COMPLETED" }, + ] + }, + { + id: "latestSubmissionStatus", + label: "Submission Status", + options: [ + { label: "Submitted", value: "SUBMITTED" }, + { label: "Under Review", value: "UNDER_REVIEW" }, + { label: "Draft", value: "DRAFT" }, + { label: "Withdrawn", value: "WITHDRAWN" }, + ] + }, + { + id: "requiresSubmission", + label: "Requires Submission", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + }, + { + id: "requiresSync", + label: "Requires Sync", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + }, + { + id: "isOverdue", + label: "Overdue", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + } + ] + + const advancedFilterFields: DataTableAdvancedFilterField<StageSubmissionView>[] = [ + { + id: "docNumber", + label: "Doc Number", + type: "text", + }, + { + id: "documentTitle", + label: "Document Title", + type: "text", + }, + { + id: "stageName", + label: "Stage Name", + type: "text", + }, + { + id: "stagePlanDate", + label: "Due Date", + type: "date", + }, + { + id: "daysUntilDue", + label: "Days Until Due", + type: "number", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [ + { id: "isOverdue", desc: true }, + { id: "daysUntilDue", desc: false } + ], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.documentId}-${originalRow.stageId}`, + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <> + <DataTable table={table}> + {/* 프로젝트 필터를 툴바 위에 배치 */} + <div className="flex items-center justify-between pb-3"> + <ProjectFilter + projects={projects} + value={selectedProjectId} + onValueChange={handleProjectChange} + /> + <div className="text-sm text-muted-foreground"> + {data.length} record(s) found + </div> + </div> + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <StageSubmissionToolbarActions + table={table} + rowAction={rowAction} + setRowAction={setRowAction} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* Upload Dialog */} + {rowAction?.type === "upload" && ( + <SingleUploadDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + submission={rowAction.row.original} + onUploadComplete={() => { + setRowAction(null) + // 테이블 새로고침 + window.location.reload() + }} + /> + )} + + {/* View Submission Dialog */} + {rowAction?.type === "view" && ( + <ViewSubmissionDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + submission={rowAction.row.original} + /> + )} + + {/* History Dialog */} + {rowAction?.type === "history" && ( + <HistoryDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + submission={rowAction.row.original} + /> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/toolbar-actions.tsx b/lib/vendor-document-list/plant/upload/toolbar-actions.tsx new file mode 100644 index 00000000..072fd72d --- /dev/null +++ b/lib/vendor-document-list/plant/upload/toolbar-actions.tsx @@ -0,0 +1,242 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCw, Upload, Send, AlertCircle } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { StageSubmissionView } from "@/db/schema" +import { DataTableRowAction } from "@/types/table" +import { MultiUploadDialog } from "./components/multi-upload-dialog" +import { useRouter, useSearchParams } from "next/navigation" + +interface StageSubmissionToolbarActionsProps { + table: Table<StageSubmissionView> + rowAction: DataTableRowAction<StageSubmissionView> | null + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageSubmissionView> | null>> +} + +export function StageSubmissionToolbarActions({ + table, + rowAction, + setRowAction +}: StageSubmissionToolbarActionsProps) { + const selectedRows = table.getFilteredSelectedRowModel().rows + const router = useRouter() + const searchParams = useSearchParams() + + const projectId = searchParams.get('projectId') + + + const [isSyncing, setIsSyncing] = React.useState(false) + const [showSyncDialog, setShowSyncDialog] = React.useState(false) + const [syncTargets, setSyncTargets] = React.useState<typeof selectedRows>([]) + + const handleUploadComplete = () => { + // Refresh table + router.refresh() + } + + const handleSyncClick = () => { + const rowsRequiringSync = selectedRows.filter( + row => row.original.requiresSync && row.original.latestSubmissionId + ) + setSyncTargets(rowsRequiringSync) + setShowSyncDialog(true) + } + + const handleSyncConfirm = async () => { + setShowSyncDialog(false) + setIsSyncing(true) + + try { + // Extract submission IDs + const submissionIds = syncTargets + .map(row => row.original.latestSubmissionId) + .filter((id): id is number => id !== null) + + if (submissionIds.length === 0) { + toast.error("No submissions to sync.") + return + } + + // API call + const response = await fetch('/api/stage-submissions/sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ submissionIds }), + }) + + const result = await response.json() + + if (result.success) { + toast.success(result.message) + + // Display detailed information for successful items + if (result.results?.details) { + const successCount = result.results.details.filter((d: any) => d.success).length + const failedCount = result.results.details.filter((d: any) => !d.success).length + + if (failedCount > 0) { + toast.warning(`${successCount} succeeded, ${failedCount} failed`) + } + } + + // Refresh table + router.refresh() + table.toggleAllPageRowsSelected(false) // Deselect all + } else { + toast.error(result.error || "Sync failed") + } + } catch (error) { + console.error("Sync error:", error) + toast.error("An error occurred during synchronization.") + } finally { + setIsSyncing(false) + } + } + + return ( + <> + <div className="flex items-center gap-2"> + {projectId && ( + <MultiUploadDialog + projectId={parseInt(projectId)} + // projectCode={projectCode} + onUploadComplete={handleUploadComplete} + /> + )} + {selectedRows.length > 0 && ( + <> + {/* Bulk Upload for selected rows that require submission */} + {selectedRows.some(row => row.original.requiresSubmission) && ( + <Button + variant="outline" + size="sm" + onClick={() => { + // Filter selected rows that require submission + const rowsRequiringSubmission = selectedRows.filter( + row => row.original.requiresSubmission + ) + // Open bulk upload dialog + console.log("Bulk upload for:", rowsRequiringSubmission) + }} + className="gap-2" + > + <Upload className="size-4" /> + <span>Upload ({selectedRows.filter(r => r.original.requiresSubmission).length})</span> + </Button> + )} + + {/* Bulk Sync for selected rows that need syncing */} + {selectedRows.some(row => row.original.requiresSync && row.original.latestSubmissionId) && ( + <Button + variant="outline" + size="sm" + onClick={handleSyncClick} + disabled={isSyncing} + className="gap-2" + > + {isSyncing ? ( + <> + <RefreshCw className="size-4 animate-spin" /> + <span>Syncing...</span> + </> + ) : ( + <> + <RefreshCw className="size-4" /> + <span>Sync ({selectedRows.filter(r => r.original.requiresSync && r.original.latestSubmissionId).length})</span> + </> + )} + </Button> + )} + </> + )} + + {/* Export Button */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: `stage-submissions-${new Date().toISOString().split('T')[0]}`, + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + + {/* Sync Confirmation Dialog */} + <AlertDialog open={showSyncDialog} onOpenChange={setShowSyncDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <RefreshCw className="size-5" /> + Sync to Buyer System + </AlertDialogTitle> + <AlertDialogDescription className="space-y-3"> + <div> + Are you sure you want to sync {syncTargets.length} selected submission(s) to the buyer system? + </div> + <div className="space-y-2 rounded-lg bg-muted p-3"> + <div className="text-sm font-medium">Items to sync:</div> + <ul className="text-sm space-y-1"> + {syncTargets.slice(0, 3).map((row, idx) => ( + <li key={idx} className="flex items-center gap-2"> + <span className="text-muted-foreground">•</span> + <span>{row.original.docNumber}</span> + <span className="text-muted-foreground">-</span> + <span>{row.original.stageName}</span> + <span className="text-muted-foreground"> + (Rev.{row.original.latestRevisionNumber}) + </span> + </li> + ))} + {syncTargets.length > 3 && ( + <li className="text-muted-foreground"> + ... and {syncTargets.length - 3} more + </li> + )} + </ul> + </div> + <div className="flex items-start gap-2 text-sm text-amber-600"> + <AlertCircle className="size-4 mt-0.5 shrink-0" /> + <div> + Synchronized files will be sent to the SHI Buyer System and + cannot be recalled after transmission. + </div> + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={handleSyncConfirm} + // className="bg-samsung hover:bg-samsung/90" + > + Start Sync + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/util/filie-parser.ts b/lib/vendor-document-list/plant/upload/util/filie-parser.ts new file mode 100644 index 00000000..42dac9b4 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/util/filie-parser.ts @@ -0,0 +1,132 @@ +// lib/vendor-document-list/plant/upload/utils/file-parser.ts + +export interface ParsedFileName { + docNumber: string + stageName: string + revision: string + extension: string + originalName: string + isValid: boolean + error?: string +} + +export function parseFileName(fileName: string): ParsedFileName { + try { + // 확장자 분리 + const lastDotIndex = fileName.lastIndexOf('.') + if (lastDotIndex === -1) { + return { + docNumber: '', + stageName: '', + revision: '', + extension: '', + originalName: fileName, + isValid: false, + error: 'No file extension found' + } + } + + const extension = fileName.substring(lastDotIndex + 1) + const nameWithoutExt = fileName.substring(0, lastDotIndex) + + // 언더스코어로 분리 (최소 3개 부분 필요) + const parts = nameWithoutExt.split('_') + + if (parts.length < 3) { + return { + docNumber: '', + stageName: '', + revision: '', + extension, + originalName: fileName, + isValid: false, + error: `Invalid format. Expected: DocNumber_StageName_Revision.${extension}` + } + } + + // 파싱 결과 + const docNumber = parts[0] + const stageName = parts.slice(1, -1).join('_') // 중간 부분이 여러 개일 수 있음 + const revision = parts[parts.length - 1] // 마지막 부분이 리비전 + + // 기본 검증 + if (!docNumber || !stageName || !revision) { + return { + docNumber: '', + stageName: '', + revision: '', + extension, + originalName: fileName, + isValid: false, + error: 'Missing required parts' + } + } + + return { + docNumber, + stageName, + revision, + extension, + originalName: fileName, + isValid: true + } + } catch (error) { + return { + docNumber: '', + stageName: '', + revision: '', + extension: '', + originalName: fileName, + isValid: false, + error: 'Failed to parse filename' + } + } +} + +// 리비전 번호 추출 (숫자 우선, 없으면 문자를 숫자로 변환) +export function extractRevisionNumber(revision: string): number { + const cleanRevision = revision.toLowerCase().replace(/[^a-z0-9]/g, '') + + // Rev0, Rev1 형식 + const revMatch = cleanRevision.match(/rev(\d+)/) + if (revMatch) return parseInt(revMatch[1]) + + // R0, R1 형식 + const rMatch = cleanRevision.match(/r(\d+)/) + if (rMatch) return parseInt(rMatch[1]) + + // v1, v2 형식 + const vMatch = cleanRevision.match(/v(\d+)/) + if (vMatch) return parseInt(vMatch[1]) + + // 단순 숫자 + const numMatch = cleanRevision.match(/^(\d+)$/) + if (numMatch) return parseInt(numMatch[1]) + + // RevA, RevB 또는 A, B 형식 -> 숫자로 변환 (A=1, B=2, etc.) + const alphaMatch = cleanRevision.match(/^(?:rev)?([a-z])$/i) + if (alphaMatch) { + return alphaMatch[1].toUpperCase().charCodeAt(0) - 64 // A=1, B=2, C=3... + } + + // 기본값 + return 0 +} + +// 리비전 코드 정규화 (DB 저장용) +export function normalizeRevisionCode(revision: string): string { + // Rev0 -> 0, RevA -> A, v1 -> 1 등으로 정규화 + const cleanRevision = revision.toLowerCase() + + // Rev 제거 + if (cleanRevision.startsWith('rev')) { + return revision.substring(3) + } + + // R, v 제거 + if (cleanRevision.startsWith('r') || cleanRevision.startsWith('v')) { + return revision.substring(1) + } + + return revision +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/validation.ts b/lib/vendor-document-list/plant/upload/validation.ts new file mode 100644 index 00000000..80a7d390 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/validation.ts @@ -0,0 +1,35 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + } from "nuqs/server" + import * as z from "zod" + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + import { StageSubmissionView } from "@/db/schema" + + export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(20), + sort: getSortingStateParser<StageSubmissionView>().withDefault([ + { id: "isOverdue", desc: true }, + { id: "daysUntilDue", desc: false }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + + // 프로젝트 필터만 유지 + projectId: parseAsInteger, + syncStatus: parseAsStringEnum(["all", "pending", "syncing", "synced", "failed", "partial"]).withDefault("all"), + submissionStatus: parseAsStringEnum(["all", "required", "submitted", "approved", "rejected"]).withDefault("all"), + }) + + export type GetStageSubmissionsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
\ No newline at end of file diff --git a/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx index 66ddee47..8054b128 100644 --- a/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx +++ b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx @@ -3,7 +3,7 @@ import * as React from "react"
import { type DataTableRowAction } from "@/types/table"
import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis } from "lucide-react"
+import { Ellipsis, MoreHorizontal } from "lucide-react"
import { toast } from "sonner"
import { getErrorMessage } from "@/lib/handle-error"
@@ -15,13 +15,6 @@ import { DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
@@ -30,7 +23,6 @@ import { VendorItem, vendors } from "@/db/schema/vendors" import { modifyVendor } from "../service"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { getRFQStatusIcon } from "@/lib/tasks/utils"
import { rfqHistoryColumnsConfig } from "@/config/rfqHistoryColumnsConfig"
export interface RfqHistoryRow {
@@ -61,13 +53,13 @@ export interface RfqHistoryRow { interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqHistoryRow> | null>>;
- openItemsModal: (rfqId: number) => void;
+ onViewDetails: (rfqId: number) => void;
}
/**
* tanstack table 컬럼 정의 (중첩 헤더 버전)
*/
-export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): ColumnDef<RfqHistoryRow>[] {
+export function getColumns({ setRowAction, onViewDetails }: GetColumnsProps): ColumnDef<RfqHistoryRow>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
@@ -112,14 +104,14 @@ export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): C variant="ghost"
className="flex size-8 p-0 data-[state=open]:bg-muted"
>
- <Ellipsis className="size-4" aria-hidden="true" />
+ <span className="text-lg">⋯</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
+ onSelect={() => onViewDetails(row.original.id)}
>
- View Details
+ 견적상세
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -129,30 +121,60 @@ export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): C }
// ----------------------------------------------------------------
- // 3) 일반 컬럼들
+ // 3) 개별 컬럼들 (그룹 없음)
// ----------------------------------------------------------------
- const basicColumns: ColumnDef<RfqHistoryRow>[] = rfqHistoryColumnsConfig.map((cfg) => {
+ const basicColumns1: ColumnDef<RfqHistoryRow>[] = []
+ const quotationGroupColumns: ColumnDef<RfqHistoryRow>[] = []
+ const basicColumns2: ColumnDef<RfqHistoryRow>[] = []
+
+ const sortableIds = new Set([
+ "rfqType",
+ "status",
+ "rfqCode",
+ "projectInfo",
+ "packageInfo",
+ "materialInfo",
+ "currency",
+ "totalAmount",
+ "leadTime",
+ "paymentTerms",
+ "incoterms",
+ "shippingLocation",
+ "contractInfo",
+ "rfqSendDate",
+ "submittedAt",
+ "picName",
+ ])
+
+ rfqHistoryColumnsConfig.forEach((cfg) => {
+ const isSortable = sortableIds.has(cfg.id)
const column: ColumnDef<RfqHistoryRow> = {
accessorKey: cfg.id,
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title={cfg.label} />
),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
size: cfg.size,
+ enableSorting: isSortable,
}
- if (cfg.id === "description") {
+ if (cfg.id === "materialInfo") {
column.cell = ({ row }) => {
- const description = row.original.description
- if (!description) return null
+ const materialInfo = row.original.materialInfo
+ if (!materialInfo) return null
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="break-words whitespace-normal line-clamp-2">
- {description}
+ {materialInfo}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[400px] whitespace-pre-wrap break-words">
- {description}
+ {materialInfo}
</TooltipContent>
</Tooltip>
)
@@ -163,10 +185,8 @@ export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): C column.cell = ({ row }) => {
const statusVal = row.original.status
if (!statusVal) return null
- const Icon = getRFQStatusIcon(statusVal)
return (
- <div className="flex items-center">
- <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" />
+ <div className="whitespace-nowrap">
<span className="capitalize">{statusVal}</span>
</div>
)
@@ -176,48 +196,57 @@ export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): C if (cfg.id === "totalAmount") {
column.cell = ({ row }) => {
const amount = row.original.totalAmount
- const currency = row.original.currency
- if (!amount || !currency) return null
+ if (!amount) return null
return (
<div className="whitespace-nowrap">
- {`${currency} ${amount.toLocaleString()}`}
+ {amount.toLocaleString()}
</div>
)
}
}
- if (cfg.id === "dueDate" || cfg.id === "createdAt") {
- column.cell = ({ row }) => (
- <div className="whitespace-nowrap">
- {formatDate(row.getValue(cfg.id), "KR")}
- </div>
- )
+ if (cfg.id === "rfqSendDate" || cfg.id === "submittedAt") {
+ column.cell = ({ row }) => {
+ const v = row.getValue(cfg.id) as Date | null
+ if (!v) return <div className="whitespace-nowrap">-</div>
+ return (
+ <div className="whitespace-nowrap">{formatDate(v, "KR")}</div>
+ )
+ }
+ }
+
+ // 컬럼을 적절한 배열에 분류
+ if (cfg.group === "견적정보") {
+ quotationGroupColumns.push(column)
+ } else if (["contractInfo", "rfqSendDate", "submittedAt", "picName"].includes(cfg.id)) {
+ basicColumns2.push(column)
+ } else {
+ basicColumns1.push(column)
}
return column
})
- const itemsColumn: ColumnDef<RfqHistoryRow> = {
- id: "items",
- header: "Items",
- cell: ({ row }) => {
- const rfq = row.original;
- const count = rfq.itemCount || 0;
- return (
- <Button variant="ghost" onClick={() => openItemsModal(rfq.id)}>
- {count === 0 ? "No Items" : `${count} Items`}
- </Button>
- )
- },
- }
-
// ----------------------------------------------------------------
- // 4) 최종 컬럼 배열
+ // 4) 최종 컬럼 배열 (bid-history-table-columns.tsx 방식)
// ----------------------------------------------------------------
+ const createGroupColumn = (groupName: string, columns: ColumnDef<RfqHistoryRow>[]): ColumnDef<RfqHistoryRow> => {
+ return {
+ id: `group-${groupName.replace(/\s+/g, '-')}`,
+ header: groupName,
+ columns: columns,
+ meta: {
+ isGroupColumn: true,
+ groupBorders: true,
+ } as any
+ }
+ }
+
return [
selectColumn,
- ...basicColumns,
- itemsColumn,
+ ...basicColumns1,
+ ...(quotationGroupColumns.length > 0 ? [createGroupColumn("견적정보", quotationGroupColumns)] : []),
+ ...basicColumns2,
actionsColumn,
]
}
\ No newline at end of file diff --git a/lib/vendors/rfq-history-table/rfq-history-table.tsx b/lib/vendors/rfq-history-table/rfq-history-table.tsx index 71830303..11a4bf9d 100644 --- a/lib/vendors/rfq-history-table/rfq-history-table.tsx +++ b/lib/vendors/rfq-history-table/rfq-history-table.tsx @@ -14,26 +14,38 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { getColumns } from "./rfq-history-table-columns" import { getRfqHistory } from "../service" import { RfqHistoryTableToolbarActions } from "./rfq-history-table-toolbar-actions" -import { RfqItemsTableDialog } from "./rfq-items-table-dialog" import { getRFQStatusIcon } from "@/lib/tasks/utils" import { TooltipProvider } from "@/components/ui/tooltip" +import { useRouter } from "next/navigation" export interface RfqHistoryRow { id: number; + rfqType: string | null; + status: string; rfqCode: string | null; + projectInfo: string | null; + packageInfo: string | null; + materialInfo: string | null; + // 견적정보 세부 필드들 + currency: string | null; + totalAmount: number | null; + leadTime: string | null; + paymentTerms: string | null; + incoterms: string | null; + shippingLocation: string | null; + contractInfo: string | null; + rfqSendDate: Date | null; + submittedAt: Date | null; + picName: string | null; + // 기존 필드들 (호환성을 위해 유지) projectCode: string | null; projectName: string | null; description: string | null; dueDate: Date; - status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; vendorStatus: string; - totalAmount: number | null; - currency: string | null; - leadTime: string | null; itemCount: number; tbeResult: string | null; cbeResult: string | null; - createdAt: Date; items: { rfqId: number; id: number; @@ -42,6 +54,7 @@ export interface RfqHistoryRow { quantity: number | null; uom: string | null; }[]; + actions?: any; // actions 컬럼용 } interface RfqHistoryTableProps { @@ -50,68 +63,117 @@ interface RfqHistoryTableProps { Awaited<ReturnType<typeof getRfqHistory>>, ] > + lng: string + vendorId: number } -export function VendorRfqHistoryTable({ promises }: RfqHistoryTableProps) { - const [{ data, pageCount }] = React.use(promises) +export function VendorRfqHistoryTable({ promises, lng, vendorId }: RfqHistoryTableProps) { + const [{ data = [], pageCount = 0 }] = React.use(promises) + const router = useRouter() - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqHistoryRow> | null>(null) + const [, setRowAction] = React.useState<DataTableRowAction<RfqHistoryRow> | null>(null) - const [itemsModalOpen, setItemsModalOpen] = React.useState(false); - const [selectedRfq, setSelectedRfq] = React.useState<RfqHistoryRow | null>(null); - - const openItemsModal = React.useCallback((rfqId: number) => { - const rfq = data.find(r => r.id === rfqId); - if (rfq) { - setSelectedRfq(rfq); - setItemsModalOpen(true); - } - }, [data]); + const onViewDetails = React.useCallback((rfqId: number) => { + router.push(`/${lng}/evcp/rfq-last/${rfqId}`); + }, [router, lng]); const columns = React.useMemo(() => getColumns({ setRowAction, - openItemsModal, - }), [setRowAction, openItemsModal]); + onViewDetails, + }), [setRowAction, onViewDetails]); const filterFields: DataTableFilterField<RfqHistoryRow>[] = [ { - id: "rfqCode", - label: "RFQ Code", - placeholder: "Filter RFQ Code...", + id: "rfqType", + label: "견적종류", + options: [ + { label: "ITB", value: "ITB" }, + { label: "RFQ", value: "RFQ" }, + { label: "일반", value: "일반" }, + { label: "정기견적", value: "정기견적" } + ], }, { id: "status", - label: "Status", - options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({ - label: toSentenceCase(status), - value: status, - icon: getRFQStatusIcon(status), - })), + label: "견적상태", + options: [ + { label: "ITB 발송", value: "ITB 발송" }, + { label: "Short List 확정", value: "Short List 확정" }, + { label: "최종업체선정", value: "최종업체선정" }, + { label: "견적접수", value: "견적접수" }, + { label: "견적평가중", value: "견적평가중" }, + { label: "견적완료", value: "견적완료" } + ], }, + { id: "rfqCode", label: "견적번호", placeholder: "견적번호로 검색..." }, + { id: "projectInfo", label: "프로젝트", placeholder: "프로젝트로 검색..." }, + { id: "packageInfo", label: "PKG No.", placeholder: "PKG로 검색..." }, + { id: "materialInfo", label: "자재그룹", placeholder: "자재그룹으로 검색..." }, { - id: "vendorStatus", - label: "Vendor Status", - placeholder: "Filter Vendor Status...", - } + id: "currency", + label: "통화", + options: [ + { label: "USD", value: "USD" }, + { label: "KRW", value: "KRW" }, + { label: "EUR", value: "EUR" }, + { label: "JPY", value: "JPY" } + ], + }, + { id: "paymentTerms", label: "지급조건", placeholder: "지급조건으로 검색..." }, + { id: "incoterms", label: "Incoterms", placeholder: "Incoterms로 검색..." }, + { id: "shippingLocation", label: "선적지", placeholder: "선적지로 검색..." }, + { id: "picName", label: "견적담당자", placeholder: "담당자로 검색..." }, ] const advancedFilterFields: DataTableAdvancedFilterField<RfqHistoryRow>[] = [ - { id: "rfqCode", label: "RFQ Code", type: "text" }, - { id: "projectCode", label: "Project Code", type: "text" }, - { id: "projectName", label: "Project Name", type: "text" }, - { - id: "status", - label: "RFQ Status", + { + id: "rfqType", + label: "견적종류", + type: "multi-select", + options: [ + { label: "ITB", value: "ITB" }, + { label: "RFQ", value: "RFQ" }, + { label: "일반", value: "일반" }, + { label: "정기견적", value: "정기견적" } + ], + }, + { + id: "status", + label: "견적상태", + type: "multi-select", + options: [ + { label: "ITB 발송", value: "ITB 발송" }, + { label: "Short List 확정", value: "Short List 확정" }, + { label: "최종업체선정", value: "최종업체선정" }, + { label: "견적접수", value: "견적접수" }, + { label: "견적평가중", value: "견적평가중" }, + { label: "견적완료", value: "견적완료" } + ], + }, + { id: "rfqCode", label: "견적번호", type: "text" }, + { id: "projectInfo", label: "프로젝트", type: "text" }, + { id: "packageInfo", label: "PKG No.", type: "text" }, + { id: "materialInfo", label: "자재그룹", type: "text" }, + { + id: "currency", + label: "통화", type: "multi-select", - options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({ - label: toSentenceCase(status), - value: status, - icon: getRFQStatusIcon(status), - })), + options: [ + { label: "USD", value: "USD" }, + { label: "KRW", value: "KRW" }, + { label: "EUR", value: "EUR" }, + { label: "JPY", value: "JPY" } + ], }, - { id: "vendorStatus", label: "Vendor Status", type: "text" }, - { id: "dueDate", label: "Due Date", type: "date" }, - { id: "createdAt", label: "Created At", type: "date" }, + { id: "totalAmount", label: "총 견적금액", type: "number" }, + { id: "leadTime", label: "업체 L/T(W)", type: "text" }, + { id: "paymentTerms", label: "지급조건", type: "text" }, + { id: "incoterms", label: "Incoterms", type: "text" }, + { id: "shippingLocation", label: "선적지", type: "text" }, + { id: "contractInfo", label: "PO/계약정보", type: "text" }, + { id: "rfqSendDate", label: "견적요청일", type: "date" }, + { id: "submittedAt", label: "견적회신일", type: "date" }, + { id: "picName", label: "견적담당자", type: "text" }, ] const { table } = useDataTable({ @@ -122,13 +184,13 @@ export function VendorRfqHistoryTable({ promises }: RfqHistoryTableProps) { enablePinning: true, enableAdvancedFilter: true, initialState: { - sorting: [{ id: "createdAt", desc: true }], + sorting: [{ id: "rfqSendDate", desc: true }], columnPinning: { right: ["actions"] }, }, - getRowId: (originalRow) => String(originalRow.id), - shallow: true, + getRowId: (originalRow, index) => originalRow?.id ? String(originalRow.id) : String(index), + shallow: false, clearOnDefault: true, - }) + }); return ( <> @@ -141,15 +203,9 @@ export function VendorRfqHistoryTable({ promises }: RfqHistoryTableProps) { filterFields={advancedFilterFields} shallow={false} > - <RfqHistoryTableToolbarActions table={table} /> </DataTableAdvancedToolbar> </DataTable> - <RfqItemsTableDialog - open={itemsModalOpen} - onOpenChange={setItemsModalOpen} - items={selectedRfq?.items ?? []} - /> </TooltipProvider> </> ) diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 9362a88c..596a52a0 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -9,7 +9,8 @@ import crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { saveDRMFile } from "@/lib/file-stroage"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; - +import { rfqLastVendorResponses, vendorQuotationView } from "@/db/schema/rfqVendor"; +import { rfqsLast, rfqLastDetails } from "@/db/schema/rfqLast"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; @@ -1213,117 +1214,235 @@ const removeVendormaterialsSchema = z.object({ }) - export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number) { - return unstable_cache( - async () => { - try { - logger.info({ vendorId, input }, "Starting getRfqHistory"); + try { + logger.info({ vendorId, input }, "Starting getRfqHistory"); - const offset = (input.page - 1) * input.perPage; + const offset = (input.page - 1) * input.perPage; - // 기본 where 조건 (vendorId) - const vendorWhere = eq(vendorRfqView.vendorId, vendorId); - logger.debug({ vendorWhere }, "Vendor where condition"); + // 기본 where 조건 (vendorId) + const vendorWhere = eq(rfqLastVendorResponses.vendorId, vendorId); + logger.debug({ vendorWhere }, "Vendor where condition"); - // 고급 필터링 - const advancedWhere = filterColumns({ - table: vendorRfqView, - filters: input.filters, - joinOperator: input.joinOperator, - }); - logger.debug({ advancedWhere }, "Advanced where condition"); + // 고급 필터링 + const advancedWhere = filterColumns({ + table: rfqsLast, + filters: input.filters, + joinOperator: input.joinOperator, + customColumnMapping: { + projectCode: { table: projects, column: "code" }, + projectName: { table: projects, column: "name" }, + projectInfo: { table: projects, column: "code" }, + packageInfo: { table: rfqsLast, column: "packageNo" }, + currency: { table: rfqLastVendorResponses, column: "vendorCurrency" }, + totalAmount: { table: rfqLastVendorResponses, column: "totalAmount" }, + paymentTerms: { table: rfqLastVendorResponses, column: "vendorPaymentTermsCode" }, + incoterms: { table: rfqLastVendorResponses, column: "vendorIncotermsCode" }, + shippingLocation: { table: rfqLastVendorResponses, column: "vendorPlaceOfShipping" }, + leadTime: { table: rfqLastVendorResponses, column: "vendorDeliveryDate" }, + contractInfo: { table: rfqLastDetails, column: "contractNo" }, + rfqSendDate: { table: rfqsLast, column: "rfqSendDate" }, + submittedAt: { table: rfqLastVendorResponses, column: "submittedAt" }, + }, + }); + logger.debug({ advancedWhere }, "Advanced where condition"); - // 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(vendorRfqView.rfqCode, s), - ilike(vendorRfqView.projectCode, s), - ilike(vendorRfqView.projectName, s) - ); - logger.debug({ globalWhere, search: input.search }, "Global search condition"); - } + // 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(rfqsLast.rfqCode, s), + ilike(projects.code, s), + ilike(projects.name, s), + ilike(rfqsLast.rfqType, s), + ilike(rfqsLast.status, s) + ); + logger.debug({ globalWhere, search: input.search }, "Global search condition"); + } - const finalWhere = and( - advancedWhere, - globalWhere, - vendorWhere - ); - logger.debug({ finalWhere }, "Final where condition"); + const finalWhere = and( + advancedWhere, + globalWhere, + vendorWhere + ); + logger.debug({ finalWhere }, "Final where condition"); - // 정렬 조건 - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(rfqs[item.id]) : asc(rfqs[item.id]) - ) - : [desc(rfqs.createdAt)]; - logger.debug({ orderBy }, "Order by condition"); + // 정렬 조건 - 동적 매핑 + const sortFieldMap: Record<string, any> = { + rfqType: rfqsLast.rfqType, + status: rfqsLast.status, + rfqCode: rfqsLast.rfqCode, + projectInfo: projects.code, + projectCode: projects.code, + projectName: projects.name, + packageNo: rfqsLast.packageNo, + packageName: rfqsLast.packageName, + packageInfo: rfqsLast.packageNo, + materialInfo: projects.code, + majorItemMaterialCategory: sql<string | null>`COALESCE( + (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), + (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) + )`, + majorItemMaterialDescription: sql<string | null>`COALESCE( + (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), + (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) + )`, + currency: rfqLastVendorResponses.vendorCurrency, + totalAmount: rfqLastVendorResponses.totalAmount, + leadTime: rfqLastVendorResponses.vendorDeliveryDate, + paymentTerms: rfqLastVendorResponses.vendorPaymentTermsCode, + incoterms: rfqLastVendorResponses.vendorIncotermsCode, + shippingLocation: rfqLastVendorResponses.vendorPlaceOfShipping, + contractInfo: rfqLastDetails.contractNo, + rfqSendDate: rfqsLast.rfqSendDate, + submittedAt: rfqLastVendorResponses.submittedAt, + picName: rfqsLast.picName, + }; - // 트랜잭션으로 데이터 조회 - const { data, total } = await db.transaction(async (tx) => { - logger.debug("Starting transaction for RFQ history query"); + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => { + const field = sortFieldMap[item.id]; + if (!field) { + logger.warn({ sortField: item.id }, "Unknown sort field, using default"); + return desc(rfqsLast.rfqSendDate); + } + return item.desc ? desc(field) : asc(field); + }) + : [desc(rfqsLast.rfqSendDate)]; + logger.debug({ orderBy }, "Order by condition"); - const data = await selectRfqHistory(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - logger.debug({ dataLength: data.length }, "RFQ history data fetched"); + // 트랜잭션으로 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + logger.debug("Starting transaction for RFQ history query"); - // RFQ 아이템 정보 조회 - const rfqIds = data.map(rfq => rfq.id); - const items = await tx - .select({ - rfqId: rfqItems.rfqId, - id: rfqItems.id, - itemCode: rfqItems.itemCode, - description: rfqItems.description, - quantity: rfqItems.quantity, - uom: rfqItems.uom, - }) - .from(rfqItems) - .where(inArray(rfqItems.rfqId, rfqIds)); + // RFQ History 데이터 조회 - rfqsLast 기준으로 조인 (bid-history와 동일한 패턴) + const rfqHistoryData = await tx + .select({ + id: rfqsLast.id, + rfqType: rfqsLast.rfqType, + status: rfqsLast.status, + rfqCode: rfqsLast.rfqCode, + projectCode: projects.code, + projectName: projects.name, + packageNo: rfqsLast.packageNo, + packageName: rfqsLast.packageName, + majorItemMaterialCategory: sql<string | null>`COALESCE( + (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), + (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) + )`, + majorItemMaterialDescription: sql<string | null>`COALESCE( + (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), + (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) + )`, + vendorCurrency: rfqLastVendorResponses.vendorCurrency, + totalAmount: rfqLastVendorResponses.totalAmount, + vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode, + vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode, + vendorPlaceOfShipping: rfqLastVendorResponses.vendorPlaceOfShipping, + vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate, + vendorContractDuration: rfqLastVendorResponses.vendorContractDuration, + contractNo: rfqLastDetails.contractNo, + contractStatus: rfqLastDetails.contractStatus, + contractCreatedAt: rfqLastDetails.contractCreatedAt, + paymentTermsCode: rfqLastDetails.paymentTermsCode, + incotermsCode: rfqLastDetails.incotermsCode, + placeOfShipping: rfqLastDetails.placeOfShipping, + rfqSendDate: rfqsLast.rfqSendDate, + submittedAt: rfqLastVendorResponses.submittedAt, + picName: rfqsLast.picName, + responseStatus: rfqLastVendorResponses.status, + responseVersion: rfqLastVendorResponses.responseVersion, + }) + .from(rfqsLast) + .leftJoin(rfqLastVendorResponses, and( + eq(rfqLastVendorResponses.rfqsLastId, rfqsLast.id), + eq(rfqLastVendorResponses.vendorId, vendorId) + )) + .leftJoin(rfqLastDetails, eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetails.id)) + .leftJoin(projects, eq(rfqsLast.projectId, projects.id)) + .where(and( + advancedWhere, + globalWhere + )) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); - // RFQ 데이터에 아이템 정보 추가 - const dataWithItems = data.map(rfq => ({ - ...rfq, - items: items.filter(item => item.rfqId === rfq.id), - })); + logger.debug({ dataLength: rfqHistoryData.length }, "RFQ history data fetched"); + + // 데이터 변환 + const data = rfqHistoryData.map(row => ({ + id: row.id, + rfqType: row.rfqType, + status: row.status, + rfqCode: ((): string | null => { + if (!row.rfqCode) return null; + const rev = row.responseVersion ? ` (Rev.${row.responseVersion})` : ''; + return `${row.rfqCode}${rev}`; + })(), + projectInfo: row.projectCode && row.projectName ? `${row.projectCode} (${row.projectName})` : row.projectCode || row.projectName, + packageInfo: row.packageNo && row.packageName ? `${row.packageNo} (${row.packageName})` : row.packageNo || row.packageName, + materialInfo: row.majorItemMaterialCategory && row.majorItemMaterialDescription ? `${row.majorItemMaterialCategory} (${row.majorItemMaterialDescription})` : row.majorItemMaterialCategory || row.majorItemMaterialDescription, + // 견적정보 세부 필드들 + currency: row.vendorCurrency, + totalAmount: row.totalAmount, + leadTime: row.vendorDeliveryDate ?? row.vendorContractDuration ?? null, + paymentTerms: row.vendorPaymentTermsCode ?? row.paymentTermsCode ?? null, + incoterms: row.vendorIncotermsCode ?? row.incotermsCode ?? null, + shippingLocation: row.vendorPlaceOfShipping ?? row.placeOfShipping ?? null, + contractInfo: ((): string | null => { + const parts: string[] = []; + if (row.contractNo) parts.push(String(row.contractNo)); + if (row.contractStatus) parts.push(String(row.contractStatus)); + if (row.contractCreatedAt) parts.push(new Date(row.contractCreatedAt).toISOString().split('T')[0]); + return parts.length ? parts.join(' / ') : null; + })(), + rfqSendDate: row.rfqSendDate, + submittedAt: row.submittedAt, + picName: row.picName, + vendorStatus: row.responseStatus ?? '미응답' + })); + + // Total count 조회 - rfqsLast 기준으로 조인 (bid-history와 동일한 패턴) + const total = await tx + .select({ count: sql<number>`count(*)` }) + .from(rfqsLast) + .leftJoin(rfqLastVendorResponses, and( + eq(rfqLastVendorResponses.rfqsLastId, rfqsLast.id), + eq(rfqLastVendorResponses.vendorId, vendorId) + )) + .leftJoin(rfqLastDetails, eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetails.id)) + .leftJoin(projects, eq(rfqsLast.projectId, projects.id)) + .where(and( + advancedWhere, + globalWhere + )); - const total = await countRfqHistory(tx, finalWhere); - logger.debug({ total }, "RFQ history total count"); + const totalCount = total[0]?.count ?? 0; + logger.debug({ totalCount }, "RFQ history total count"); - return { data: dataWithItems, total }; - }); + return { data, total: totalCount }; + }); - const pageCount = Math.ceil(total / input.perPage); - logger.info({ - vendorId, - dataLength: data.length, - total, - pageCount - }, "RFQ history query completed"); + const pageCount = Math.ceil(total / input.perPage); + logger.info({ + vendorId, + dataLength: data.length, + total, + pageCount + }, "RFQ history query completed"); - return { data, pageCount }; - } catch (err) { - logger.error({ - err, - vendorId, - stack: err instanceof Error ? err.stack : undefined - }, 'Error fetching RFQ history'); - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify({ input, vendorId })], - { - revalidate: 3600, - tags: ["rfq-history"], - } - )(); + return { data, pageCount }; + } catch (err) { + logger.error({ + err, + vendorId, + stack: err instanceof Error ? err.stack : undefined + }, 'Error fetching RFQ history'); + return { data: [], pageCount: 0 }; + } } export async function checkJoinPortal(taxID: string) { |
