From b67e36df49f067cbd5ba899f9fbcc755f38d4b4f Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 4 Sep 2025 08:31:31 +0000 Subject: (대표님, 최겸, 임수민) 작업사항 커밋 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/admin/edp-progress-debug/page.tsx | 210 +++++++++ app/[lng]/admin/edp-progress/page.tsx | 488 ++++----------------- app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx | 2 +- app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx | 11 +- app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx | 112 ++++- app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx | 67 ++- .../(partners)/bid/[id]/pre-quote/page.tsx | 97 ++++ app/api/rfq-attachments/[id]/route.ts | 78 ++++ app/api/rfq-attachments/revision/route.ts | 122 ++++++ app/api/rfq-attachments/upload/route.ts | 201 +++++++++ 10 files changed, 965 insertions(+), 423 deletions(-) create mode 100644 app/[lng]/admin/edp-progress-debug/page.tsx create mode 100644 app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx create mode 100644 app/api/rfq-attachments/[id]/route.ts create mode 100644 app/api/rfq-attachments/revision/route.ts create mode 100644 app/api/rfq-attachments/upload/route.ts (limited to 'app') diff --git a/app/[lng]/admin/edp-progress-debug/page.tsx b/app/[lng]/admin/edp-progress-debug/page.tsx new file mode 100644 index 00000000..ebaa07a2 --- /dev/null +++ b/app/[lng]/admin/edp-progress-debug/page.tsx @@ -0,0 +1,210 @@ +"use client"; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { debugVendorFieldCalculation } from '@/lib/forms/vendor-completion-stats'; +import { Loader, Search, FileText, Tag, CheckCircle, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +export default function DebugVendorFieldsPage() { + const [loading, setLoading] = React.useState(false); + const [vendorId, setVendorId] = React.useState('1'); + const [debugData, setDebugData] = React.useState(null); + + const handleDebug = async () => { + setLoading(true); + setDebugData(null); + + try { + const result = await debugVendorFieldCalculation(Number(vendorId)); + setDebugData(result); + + if (result) { + toast.success(`${result.vendorName}의 필드 계산 디버그 완료`); + } else { + toast.warning('벤더 데이터가 없습니다'); + } + } catch (error) { + console.error('Error debugging vendor fields:', error); + toast.error(`디버그 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } finally { + setLoading(false); + } + }; + + const renderFieldDetails = (fieldDetails: any[]) => ( +
+ {fieldDetails.map((field, index) => ( +
+ {field.fieldKey} + = + {String(field.fieldValue ?? 'null')} + {field.isEmpty ? ( + + ) : ( + + )} +
+ ))} +
+ ); + + return ( +
+
+ +

벤더 필드 계산 디버그

+
+ + {/* Input */} + + + 벤더 ID 입력 + + +
+
+ + setVendorId(e.target.value)} + placeholder="1" + type="number" + /> +
+
+ +
+
+
+
+ + {/* Results */} + {debugData && ( +
+ {/* Summary */} + + + + + {debugData.vendorName} - 전체 요약 + + + +
+
+
+ {debugData.debugInfo.grandTotal.totalRequiredFields} +
+

전체 필드

+
+
+
+ {debugData.debugInfo.grandTotal.totalFilledFields} +
+

입력 필드

+
+
+
+ {debugData.debugInfo.grandTotal.totalEmptyFields} +
+

빈 필드

+
+
+
+ {debugData.debugInfo.grandTotal.completionPercentage}% +
+

완성도

+
+
+
+
+ + {/* Detailed Breakdown */} + + + 상세 분석 + + + +
+ {debugData.debugInfo.contracts.map((contract: any, contractIndex: number) => ( +
+
+ + + 계약 {contract.contractId} - {contract.projectName} + + + 전체: {contract.totalRequiredFields} | 입력: {contract.totalFilledFields} + +
+ +
+ {contract.forms.map((form: any, formIndex: number) => ( +
+
+ + {form.formName} ({form.formCode}) + + 전체: {form.totalRequiredFields} | 입력: {form.totalFilledFields} + +
+ +
+ {form.tags.map((tag: any, tagIndex: number) => ( +
+
+ + {tag.tagNo} + + 전체: {tag.requiredFieldsCount} | 입력: {tag.filledFieldsCount} + +
+ +
+
+ 편집 가능한 필드: {tag.editableFields.join(', ')} +
+ {renderFieldDetails(tag.fieldDetails)} +
+
+ ))} +
+
+ ))} +
+
+ ))} +
+
+
+
+ + {/* Raw Data */} + + + 원시 데이터 (JSON) + + + +
+                  {JSON.stringify(debugData, null, 2)}
+                
+
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/[lng]/admin/edp-progress/page.tsx b/app/[lng]/admin/edp-progress/page.tsx index 4efb739c..c42a1db7 100644 --- a/app/[lng]/admin/edp-progress/page.tsx +++ b/app/[lng]/admin/edp-progress/page.tsx @@ -2,430 +2,134 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { - calculateVendorFormCompletion, - getProjectVendorCompletionSummary, - calculateVendorContractCompletion, - getVendorAllContractsCompletionSummary, - getAllVendorsContractsCompletionSummary, - getAllProjectsVendorCompletionSummary, - type VendorFormCompletionStats, - type ProjectVendorCompletionSummary, - type VendorAllContractsCompletionSummary -} from '@/lib/forms/vendor-completion-stats'; -import { Loader, TestTube, BarChart, FileText, TrendingUp } from 'lucide-react'; +import { getAllVendorsContractsCompletionSummary } from '@/lib/forms/vendor-completion-stats'; +import { Loader, Users, RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; -interface TestResult { - type: string; - data: VendorFormCompletionStats | ProjectVendorCompletionSummary | VendorAllContractsCompletionSummary | unknown; +interface VendorProgress { + vendorId: number; + vendorName: string; + totalForms: number; + tagCount: number; + totalRequiredFields: number; + totalFilledFields: number; + completionPercentage: number; } export default function EDPProgressTestPage() { - const [loading, setLoading] = React.useState(null); - const [results, setResults] = React.useState(null); - - // Form inputs - const [contractItemId, setContractItemId] = React.useState('123'); - const [formCode, setFormCode] = React.useState('SPR_LST'); - const [projectId, setProjectId] = React.useState('1'); - const [vendorId, setVendorId] = React.useState('1'); + const [loading, setLoading] = React.useState(false); + const [vendorProgress, setVendorProgress] = React.useState([]); - const handleTest = async (testType: string, testFunction: () => Promise) => { - setLoading(testType); - setResults(null); + const loadVendorProgress = async () => { + setLoading(true); try { - const result = await testFunction(); - setResults({ type: testType, data: result }); + const result = await getAllVendorsContractsCompletionSummary(); - if (result) { - toast.success(`${testType} 테스트 완료`); + if (result && result.vendors) { + const progressData: VendorProgress[] = result.vendors.map(vendor => ({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + totalForms: vendor.totalForms, + tagCount: vendor.totalTags, + totalRequiredFields: vendor.totalRequiredFields, + totalFilledFields: vendor.totalFilledFields, + completionPercentage: vendor.overallCompletionPercentage + })); + + setVendorProgress(progressData); + toast.success(`${progressData.length}개 벤더의 진척도를 불러왔습니다`); } else { - toast.warning(`${testType} 결과가 없습니다`); + toast.warning('벤더 데이터가 없습니다'); } } catch (error) { - console.error(`Error in ${testType}:`, error); - toast.error(`${testType} 테스트 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + console.error('Error loading vendor progress:', error); + toast.error(`벤더 진척도 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); } finally { - setLoading(null); + setLoading(false); } }; - const renderVendorFormStats = (stats: VendorFormCompletionStats) => ( -
-
- - -
{stats.completionPercentage}%
-

완성도

-
-
- - -
{stats.totalFilledFields}
-

입력된 필드

-
-
- - -
{stats.totalRequiredFields}
-

총 필드

-
-
- - -
{stats.tagCount}
-

태그 수

-
-
-
- - - - 태그별 세부 현황 - - - -
- {stats.detailsByTag.map((tag, index) => ( -
- {tag.tagNo} -
- = 80 ? "default" : tag.completionPercentage >= 50 ? "secondary" : "destructive"}> - {tag.completionPercentage}% - - - {tag.filledFields}/{tag.requiredFields} - -
-
- ))} -
-
-
-
-
- ); - - const renderProjectSummary = (summary: ProjectVendorCompletionSummary) => ( -
-
- - -
{summary.averageCompletionPercentage}%
-

평균 완성도

-
-
- - -
{summary.totalVendors}
-

참여 벤더

-
-
- - -
{summary.projectCode}
-

프로젝트 코드

-
-
-
- - - - 벤더별 완성도 - - - -
- {summary.vendors.map((vendor, index) => ( -
- {vendor.vendorName} - = 80 ? "default" : vendor.completionPercentage >= 50 ? "secondary" : "destructive"}> - {vendor.completionPercentage}% - -
- ))} -
-
-
-
-
- ); - - const renderVendorAllContracts = (summary: VendorAllContractsCompletionSummary) => ( -
-
- - -
{summary.overallCompletionPercentage}%
-

전체 완성도

-
-
- - -
{summary.totalContracts}
-

총 계약

-
-
- - -
{summary.totalForms}
-

총 폼

-
-
- - -
{summary.totalFilledFields}/{summary.totalRequiredFields}
-

입력 필드

-
-
-
- -
- - - 프로젝트별 분석 - - - -
- {summary.projectBreakdown.map((project, index) => ( -
-
-
{project.projectName}
-
- 계약 {project.contractsCount}개, 폼 {project.formsCount}개 -
-
- = 80 ? "default" : "secondary"}> - {project.completionPercentage}% - -
- ))} -
-
-
-
- - - - 계약별 세부 현황 - - - -
- {summary.contracts.map((contract, index) => ( -
-
-
{contract.itemName}
-
- {contract.projectName} - 폼 {contract.totalForms}개 -
-
- = 80 ? "default" : "secondary"}> - {contract.averageCompletionPercentage}% - -
- ))} -
-
-
-
-
-
- ); + React.useEffect(() => { + loadVendorProgress(); + }, []); return (
-
- -

EDP Progress 서버 액션 테스트

+
+
+ +

벤더 진척도 현황

+
+
- {/* Input Parameters */} + {/* Vendor Progress List */} - - - 테스트 파라미터 - - - 아래 값들을 수정하여 다양한 시나리오를 테스트할 수 있습니다. - + 벤더별 작업 진척도 -
-
- - setContractItemId(e.target.value)} - placeholder="123" - /> + {loading ? ( +
+ + 벤더 진척도를 불러오는 중...
-
- - setFormCode(e.target.value)} - placeholder="SPR_LST" - /> + ) : vendorProgress.length === 0 ? ( +
+ 벤더 데이터가 없습니다.
+ ) : (
- - setProjectId(e.target.value)} - placeholder="1" - /> -
-
- - setVendorId(e.target.value)} - placeholder="1" - /> + {/* Header */} +
+
벤더명
+
폼 개수
+
태그 개수
+
전체 필드
+
입력 필드
+
완성도
+
+ + {/* Vendor Rows */} + +
+ {vendorProgress.map((vendor) => ( +
+
{vendor.vendorName}
+
{vendor.totalForms}
+
{vendor.tagCount}
+
{vendor.totalRequiredFields}
+
{vendor.totalFilledFields}
+
+ = 80 ? "default" : + vendor.completionPercentage >= 50 ? "secondary" : + "destructive" + } + > + {vendor.completionPercentage}% + +
+
+ ))} +
+
-
+ )} - - {/* Test Buttons */} - - - - - 테스트 액션들 - - - -
- - - - - - - - - - - -
-
-
- - - - {/* Results */} - {results && ( - - - - - 테스트 결과: {results.type} - - - - {!results.data ? ( -
- 데이터가 없습니다. 파라미터를 확인해주세요. -
- ) : results.type === 'vendor-form' ? ( - renderVendorFormStats(results.data as VendorFormCompletionStats) - ) : results.type === 'project-summary' ? ( - renderProjectSummary(results.data as ProjectVendorCompletionSummary) - ) : results.type === 'vendor-all-contracts' ? ( - renderVendorAllContracts(results.data as VendorAllContractsCompletionSummary) - ) : ( -
-
-
-                    {JSON.stringify(results.data, null, 2)}
-                  
-
-
- )} -
-
- )}
); -} +} \ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx index b675aed1..eb5e62d0 100644 --- a/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx @@ -61,7 +61,7 @@ export default async function SettingsLayout({ {/* 4) 입찰 정보가 있으면 번호 + 제목 + "상세 정보" 표기 */}

{bidding - ? `${bidding.biddingNumber ?? ""} - ${bidding.title}` + ? `입찰 No. ${bidding.biddingNumber ?? ""} - ${bidding.title}` : "Loading Bidding..."}

diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx index e2c22b22..64d6d740 100644 --- a/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx +++ b/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx @@ -1,7 +1,8 @@ import { Suspense } from 'react' import { notFound } from 'next/navigation' import { getBiddingDetailData } from '@/lib/bidding/detail/service' -import { BiddingDetailContent } from '@/lib/bidding/detail/table/bidding-detail-content' +import { getBiddingCompanies } from '@/lib/bidding/pre-quote/service' +import { BiddingPreQuoteContent } from '@/lib/bidding/pre-quote/table/bidding-pre-quote-content' // 메타데이터 생성 export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { @@ -38,13 +39,17 @@ export default async function Page({ params }: PageProps) { notFound() } + // 사전견적용 입찰 업체들 조회 + const biddingCompaniesResult = await getBiddingCompanies(parsedId) + const biddingCompanies = biddingCompaniesResult.success ? biddingCompaniesResult.data : [] + return ( 로딩 중...
}> - diff --git a/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx index 1b058801..999bfe8b 100644 --- a/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx @@ -4,9 +4,12 @@ import { Separator } from "@/components/ui/separator" import { SidebarNav } from "@/components/layout/sidebar-nav" import { formatDate } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { ArrowLeft, Clock, AlertTriangle, CheckCircle, XCircle, AlertCircle } from "lucide-react" import { RfqsLastView } from "@/db/schema" import { findRfqLastById } from "@/lib/rfq-last/service" +import { differenceInDays } from "date-fns" +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" export const metadata: Metadata = { title: "견적 목록 상세", @@ -23,30 +26,92 @@ export default async function RfqLayout({ // 1) URL 파라미터에서 id 추출, Number로 변환 const resolvedParams = await params const lng = resolvedParams.lng - const id = resolvedParams.id + const rfqId = parseInt(resolvedParams.id, 10); + + if (!rfqId || isNaN(rfqId) || rfqId <= 0) { + return ( +
+ + + 오류 + + 유효하지 않은 RFQ입니다. + + +
+ ); + } + - const idAsNumber = Number(id) // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqsLastView | null = await findRfqLastById(idAsNumber) + const rfq: RfqsLastView | null = await findRfqLastById(rfqId) // 3) 사이드바 메뉴 const sidebarNavItems = [ { title: "견적 문서관리", - href: `/${lng}/evcp/rfq-last/${id}`, + href: `/${lng}/evcp/rfq-last/${rfqId}`, }, { title: "RFQ 발송", - href: `/${lng}/evcp/rfq-last/${id}/vendor`, + href: `/${lng}/evcp/rfq-last/${rfqId}/vendor`, }, ] + // Due Date 상태 계산 함수 + const getDueDateStatus = (dueDate: Date | string | null) => { + if (!dueDate) return null; + + const now = new Date(); + const due = new Date(dueDate); + const daysLeft = differenceInDays(due, now); + + if (daysLeft < 0) { + return { + icon: , + text: `${Math.abs(daysLeft)}일 지남`, + className: "text-red-600", + bgClassName: "bg-red-50" + }; + } else if (daysLeft === 0) { + return { + icon: , + text: "오늘 마감", + className: "text-orange-600", + bgClassName: "bg-orange-50" + }; + } else if (daysLeft <= 3) { + return { + icon: , + text: `${daysLeft}일 남음`, + className: "text-amber-600", + bgClassName: "bg-amber-50" + }; + } else if (daysLeft <= 7) { + return { + icon: , + text: `${daysLeft}일 남음`, + className: "text-blue-600", + bgClassName: "bg-blue-50" + }; + } else { + return { + icon: , + text: `${daysLeft}일 남음`, + className: "text-green-600", + bgClassName: "bg-green-50" + }; + } + }; + + const dueDateStatus = rfq?.dueDate ? getDueDateStatus(rfq.dueDate) : null; + return ( <>
-
+
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} + {/* 제목 로직 수정: rfqTitle 있으면 사용, 없으면 rfqCode만 표시 */}

{rfq - ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}` + ? rfq.rfqTitle + ? `견적 상세 관리 ${rfq.rfqCode ?? ""} | ${rfq.rfqTitle}` + : `견적 상세 관리 ${rfq.rfqCode ?? ""}` : "Loading RFQ..."}

- -

- RFQ 관리하는 화면입니다. -

-

Due Date:{rfq && rfq?.dueDate && {formatDate(rfq?.dueDate, "KR")}}

+ + {/*

+ RFQ 관리하는 화면입니다. +

*/} + + {/* Due Date 표시 개선 */} + {rfq?.dueDate && dueDateStatus && ( +
+ Due Date: + {formatDate(rfq.dueDate, "KR")} +
+ {dueDateStatus.icon} + {dueDateStatus.text} +
+
+ )}
-
+
diff --git a/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx b/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx index 6819e122..1ccb7559 100644 --- a/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx @@ -3,6 +3,9 @@ import { type SearchParams } from "@/types/table" import { getValidFilters } from "@/lib/data-table" import { searchParamsRfqAttachmentsCache } from "@/lib/b-rfq/validations" import { getRfqLastAttachments } from "@/lib/rfq-last/service" +import { RfqAttachmentsTable } from "@/lib/rfq-last/attachment/rfq-attachments-table" +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" +import { AlertCircle } from "lucide-react" interface IndexPageProps { // Next.js 13 App Router에서 기본으로 주어지는 객체들 @@ -16,21 +19,61 @@ interface IndexPageProps { export default async function RfqPage(props: IndexPageProps) { const resolvedParams = await props.params const lng = resolvedParams.lng - const id = resolvedParams.id + const rfqId = parseInt(resolvedParams.id, 10); - const idAsNumber = Number(id) + if (!rfqId || isNaN(rfqId) || rfqId <= 0) { + return ( +
+ + + 오류 + + 유효하지 않은 RFQ입니다. + + +
+ ); + } // 2) SearchParams 파싱 (Zod) // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsRfqAttachmentsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) + const searchParams = await props.searchParams; + const activeTab = searchParams.tab || '설계'; + + // 활성 탭에 따라 다른 파라미터 파싱 + const designSearch = activeTab === '설계' + ? searchParamsRfqAttachmentsCache.parse({ + ...searchParams, + // design_ prefix가 붙은 파라미터들 추출 + page: searchParams.design_page, + perPage: searchParams.design_perPage, + sort: searchParams.design_sort, + filters: searchParams.design_filters, + }) + : { page: 1, perPage: 10, sort: [], filters: [] }; + + const purchaseSearch = activeTab === '구매' + ? searchParamsRfqAttachmentsCache.parse({ + ...searchParams, + // purchase_ prefix가 붙은 파라미터들 추출 + page: searchParams.purchase_page, + perPage: searchParams.purchase_perPage, + sort: searchParams.purchase_sort, + filters: searchParams.purchase_filters, + }) + : { page: 1, perPage: 10, sort: [], filters: [] }; + + // 활성 탭의 데이터만 실제로 가져오기 + const [designData, purchaseData] = await Promise.all([ + activeTab === '설계' + ? getRfqLastAttachments({ ...designSearch }, rfqId, "설계") + : { data: [], pageCount: 0 }, + activeTab === '구매' + ? getRfqLastAttachments({ ...purchaseSearch }, rfqId, "구매") + : { data: [], pageCount: 0 } + ]); - const promises = getRfqLastAttachments({ - ...search, - filters: validFilters, - }, idAsNumber) // 4) 렌더링 return ( @@ -45,7 +88,11 @@ export default async function RfqPage(props: IndexPageProps) {
- {/* */} +
) diff --git a/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx b/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx new file mode 100644 index 00000000..6364f7f8 --- /dev/null +++ b/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx @@ -0,0 +1,97 @@ +import { PartnersBiddingPreQuote } from '@/lib/bidding/vendor/partners-bidding-pre-quote' +import { Suspense } from 'react' +import { Skeleton } from '@/components/ui/skeleton' + +import { getServerSession } from 'next-auth' +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +interface PartnersPreQuotePageProps { + params: Promise<{ + id: string + }> +} + +export default async function PartnersPreQuotePage(props: PartnersPreQuotePageProps) { + const resolvedParams = await props.params + const biddingId = parseInt(resolvedParams.id) + + if (isNaN(biddingId)) { + return ( +
+
+

유효하지 않은 입찰 ID입니다.

+
+
+ ) + } + + // 세션에서 companyId 가져오기 + const session = await getServerSession(authOptions) + const companyId = session?.user?.companyId + + if (!companyId) { + return ( +
+
+

회사 정보가 없습니다. 다시 로그인 해주세요.

+
+
+ ) + } + + return ( +
+ }> + + +
+ ) +} + +function PreQuoteSkeleton() { + return ( +
+ {/* 헤더 스켈레톤 */} +
+
+ + +
+
+ + {/* 입찰 공고 스켈레톤 */} +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ + {/* 현재 설정된 조건 스켈레톤 */} +
+ +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+ + {/* 사전견적 폼 스켈레톤 */} +
+ +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} + +
+
+
+ ) +} diff --git a/app/api/rfq-attachments/[id]/route.ts b/app/api/rfq-attachments/[id]/route.ts new file mode 100644 index 00000000..df99c1ad --- /dev/null +++ b/app/api/rfq-attachments/[id]/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import db from "@/db/db"; +import { eq } from "drizzle-orm"; +import { rfqLastAttachments, rfqLastAttachmentRevisions } from "@/db/schema"; +import { deleteFile } from "@/lib/file-storage"; + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, message: "인증이 필요합니다" }, + { status: 401 } + ); + } + + const attachmentId = parseInt(params.id); + + // 트랜잭션 시작 + const result = await db.transaction(async (tx) => { + // 1. 첨부파일 정보 조회 + const [attachment] = await tx + .select() + .from(rfqLastAttachments) + .where(eq(rfqLastAttachments.id, attachmentId)); + + if (!attachment) { + throw new Error("첨부파일을 찾을 수 없습니다"); + } + + // 2. 모든 리비전 조회 + const revisions = await tx + .select() + .from(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.attachmentId, attachmentId)); + + // 3. 모든 리비전 파일 삭제 (공용 삭제 함수 사용) + for (const revision of revisions) { + if (revision.filePath) { + await deleteFile(revision.filePath); + } + } + + // 4. 리비전 레코드 삭제 + await tx + .delete(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.attachmentId, attachmentId)); + + // 5. 첨부파일 레코드 삭제 + await tx + .delete(rfqLastAttachments) + .where(eq(rfqLastAttachments.id, attachmentId)); + + return attachment; + }); + + return NextResponse.json({ + success: true, + message: "파일이 삭제되었습니다", + data: result, + }); + + } catch (error) { + console.error("Delete attachment error:", error); + return NextResponse.json( + { + success: false, + message: error instanceof Error ? error.message : "파일 삭제 실패" + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/rfq-attachments/revision/route.ts b/app/api/rfq-attachments/revision/route.ts new file mode 100644 index 00000000..2592ae78 --- /dev/null +++ b/app/api/rfq-attachments/revision/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import db from "@/db/db"; +import { eq } from "drizzle-orm"; +import { rfqLastAttachments, rfqLastAttachmentRevisions } from "@/db/schema"; +import { saveFile, deleteFile } from "@/lib/file-storage"; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, message: "인증이 필요합니다" }, + { status: 401 } + ); + } + + const formData = await request.formData(); + const attachmentId = parseInt(formData.get("attachmentId") as string); + const revisionComment = formData.get("revisionComment") as string; + const file = formData.get("file") as File; + + if (!file || file.size === 0) { + return NextResponse.json( + { success: false, message: "파일이 없습니다" }, + { status: 400 } + ); + } + + // 파일 크기 검증 (100MB) + if (file.size > 100 * 1024 * 1024) { + return NextResponse.json( + { success: false, message: "파일 크기는 100MB를 초과할 수 없습니다" }, + { status: 400 } + ); + } + + // 트랜잭션 시작 + const result = await db.transaction(async (tx) => { + // 1. 기존 첨부파일 정보 조회 + const [existingAttachment] = await tx + .select() + .from(rfqLastAttachments) + .where(eq(rfqLastAttachments.id, attachmentId)); + + if (!existingAttachment) { + throw new Error("첨부파일을 찾을 수 없습니다"); + } + + // 2. 현재 리비전 번호 계산 + const currentRevision = existingAttachment.currentRevision || "A"; + const nextRevision = String.fromCharCode(currentRevision.charCodeAt(0) + 1); + + // 3. 공용 파일 저장 함수 사용 + const saveResult = await saveFile({ + file, + directory: `uploads/rfq-attachments/rfq-${existingAttachment.rfqId}`, + originalName: file.name, + userId: session.user.id, + }); + + if (!saveResult.success) { + throw new Error(saveResult.error || "파일 저장 실패"); + } + + // 4. 기존 latest 플래그 해제 + if (existingAttachment.latestRevisionId) { + await tx + .update(rfqLastAttachmentRevisions) + .set({ isLatest: false }) + .where(eq(rfqLastAttachmentRevisions.id, existingAttachment.latestRevisionId)); + } + + // 5. 새 리비전 생성 + const [revision] = await tx + .insert(rfqLastAttachmentRevisions) + .values({ + attachmentId, + revisionNo: nextRevision, + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + filePath: saveResult.publicPath!, + fileSize: saveResult.fileSize!, + fileType: file.type || "unknown", + isLatest: true, + revisionComment, + uploadedBy: parseInt(session.user.id), + uploadedAt: new Date(), + }) + .returning(); + + // 6. 첨부파일 정보 업데이트 + await tx + .update(rfqLastAttachments) + .set({ + currentRevision: nextRevision, + latestRevisionId: revision.id, + updatedAt: new Date(), + }) + .where(eq(rfqLastAttachments.id, attachmentId)); + + return { attachment: existingAttachment, revision }; + }); + + return NextResponse.json({ + success: true, + message: `새 버전이 업로드되었습니다 (Rev. ${result.revision.revisionNo})`, + data: result.revision, + }); + + } catch (error) { + console.error("Update revision error:", error); + return NextResponse.json( + { + success: false, + message: error instanceof Error ? error.message : "리비전 업데이트 실패" + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/rfq-attachments/upload/route.ts b/app/api/rfq-attachments/upload/route.ts new file mode 100644 index 00000000..3343c905 --- /dev/null +++ b/app/api/rfq-attachments/upload/route.ts @@ -0,0 +1,201 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import db from "@/db/db"; +import { eq, and } from "drizzle-orm"; +import { rfqLastAttachments, rfqLastAttachmentRevisions } from "@/db/schema"; +import { saveFile } from "@/lib/file-stroage"; + +// 시리얼 번호 생성 함수 +async function generateSerialNo(rfqId: number, attachmentType: string, index: number = 0): Promise { + const prefix = attachmentType === "설계" ? "DES" : "PUR"; + + const existingAttachments = await db + .select({ id: rfqLastAttachments.id }) + .from(rfqLastAttachments) + .where( + and( + eq(rfqLastAttachments.rfqId, rfqId), + eq(rfqLastAttachments.attachmentType, attachmentType as "설계" | "구매") + ) + ); + + const nextNumber = existingAttachments.length + 1 + index; + const paddedNumber = String(nextNumber).padStart(4, "0"); + + return `${prefix}-${rfqId}-${paddedNumber}`; +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, message: "인증이 필요합니다" }, + { status: 401 } + ); + } + + const formData = await request.formData(); + const rfqId = parseInt(formData.get("rfqId") as string); + const attachmentType = formData.get("attachmentType") as "구매" | "설계"; + const description = formData.get("description") as string; + const files = formData.getAll("files") as File[]; + + // 파일 유효성 검증 + if (!files || files.length === 0) { + return NextResponse.json( + { success: false, message: "파일이 없습니다" }, + { status: 400 } + ); + } + + // 최대 파일 개수 검증 + const MAX_FILES = 10; + if (files.length > MAX_FILES) { + return NextResponse.json( + { success: false, message: `최대 ${MAX_FILES}개까지 업로드 가능합니다` }, + { status: 400 } + ); + } + + // 각 파일 크기 검증 (100MB) + const MAX_FILE_SIZE = 100 * 1024 * 1024; + const oversizedFiles = files.filter(file => file.size > MAX_FILE_SIZE); + if (oversizedFiles.length > 0) { + return NextResponse.json( + { + success: false, + message: `다음 파일들이 100MB를 초과합니다: ${oversizedFiles.map(f => f.name).join(", ")}` + }, + { status: 400 } + ); + } + + // 업로드 결과 저장 + const uploadedAttachments = []; + const failedUploads = []; + + // 각 파일에 대해 트랜잭션 처리 + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + try { + const result = await db.transaction(async (tx) => { + // 1. 시리얼 번호 생성 (인덱스 전달) + const serialNo = await generateSerialNo(rfqId, attachmentType, i); + + // 2. 파일 저장 + const saveResult = await saveFile({ + file, + directory: `uploads/rfq-attachments/rfq-${rfqId}`, + originalName: file.name, + userId: session.user.id, + }); + + if (!saveResult.success) { + throw new Error(saveResult.error || `파일 저장 실패: ${file.name}`); + } + + // 3. 첨부파일 레코드 생성 + const [attachment] = await tx + .insert(rfqLastAttachments) + .values({ + rfqId, + attachmentType, + serialNo, + description: description || null, + currentRevision: "A", + createdBy: parseInt(session.user.id), + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + // 4. 리비전 레코드 생성 + const [revision] = await tx + .insert(rfqLastAttachmentRevisions) + .values({ + attachmentId: attachment.id, + revisionNo: "A", + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + filePath: saveResult.publicPath!, + fileSize: saveResult.fileSize!, + fileType: file.type || "application/octet-stream", + isLatest: true, + revisionComment: "초기 업로드", + createdBy: parseInt(session.user.id), + createdAt: new Date(), + }) + .returning(); + + // 5. 첨부파일의 latestRevisionId 업데이트 + await tx + .update(rfqLastAttachments) + .set({ + latestRevisionId: revision.id + }) + .where(eq(rfqLastAttachments.id, attachment.id)); + + return { + attachment, + revision, + fileName: file.name, + serialNo + }; + }); + + uploadedAttachments.push(result); + } catch (error) { + console.error(`Upload error for file ${file.name}:`, error); + failedUploads.push({ + fileName: file.name, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + // 결과 반환 + if (uploadedAttachments.length === 0) { + return NextResponse.json( + { + success: false, + message: "모든 파일 업로드가 실패했습니다", + failedUploads + }, + { status: 500 } + ); + } + + // 부분 성공 또는 완전 성공 + const isPartialSuccess = failedUploads.length > 0; + const message = isPartialSuccess + ? `${uploadedAttachments.length}개 파일 업로드 성공, ${failedUploads.length}개 실패` + : `${uploadedAttachments.length}개 파일이 성공적으로 업로드되었습니다`; + + return NextResponse.json({ + success: true, + message, + uploadedCount: uploadedAttachments.length, + data: { + uploaded: uploadedAttachments.map(item => ({ + id: item.attachment.id, + serialNo: item.serialNo, + fileName: item.fileName + })), + failed: failedUploads + } + }); + + } catch (error) { + console.error("Upload error:", error); + return NextResponse.json( + { + success: false, + message: error instanceof Error ? error.message : "파일 업로드 실패" + }, + { status: 500 } + ); + } +} \ No newline at end of file -- cgit v1.2.3