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 /app | |
| parent | b67861fbb424c7ad47ad1538f75e2945bd8890c5 (diff) | |
(대표님) rfq 히스토리, swp 등
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx | 17 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/document-upload/page.tsx | 154 | ||||
| -rw-r--r-- | app/api/stage-submissions/[id]/route.ts | 84 | ||||
| -rw-r--r-- | app/api/stage-submissions/bulk-upload/route.ts | 202 | ||||
| -rw-r--r-- | app/api/stage-submissions/sync/route.ts | 35 |
5 files changed, 484 insertions, 8 deletions
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 |
