summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-19 07:51:27 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-19 07:51:27 +0000
commit9ecdfb23fe3df6a5df86782385002c562dfc1198 (patch)
tree4188cb7e6bf2c862d9c86a59d79946bd41217227 /app
parentb67861fbb424c7ad47ad1538f75e2945bd8890c5 (diff)
(대표님) rfq 히스토리, swp 등
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx17
-rw-r--r--app/[lng]/partners/(partners)/document-upload/page.tsx154
-rw-r--r--app/api/stage-submissions/[id]/route.ts84
-rw-r--r--app/api/stage-submissions/bulk-upload/route.ts202
-rw-r--r--app/api/stage-submissions/sync/route.ts35
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