From 50adedf48ee4674ebe00f1ee72d93485183cdc51 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 5 Sep 2025 11:44:32 +0000 Subject: (대표님, 최겸, 임수민) EDP 입력 진행률, 견적목록관리, EDP excel import 오류수정, GTC-Contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/gtc-vendor/clause-table.tsx | 5 +- .../gtc-clauses-table-toolbar-actions.tsx | 9 +- .../gtc-vendor/preview-document-dialog.tsx | 331 ++++- lib/basic-contract/service.ts | 88 +- lib/forms/stat.ts | 28 +- lib/forms/vendor-completion-examples.ts | 205 ---- lib/forms/vendor-completion-stats.ts | 1007 ---------------- lib/forms/vendor-tag-actions.ts | 0 .../table/gtc-clauses-table-toolbar-actions.tsx | 3 +- lib/gtc-contract/service.ts | 11 +- lib/procurement-select/service.ts | 85 ++ .../attachment/revision-historty-dialog.tsx | 305 +++++ lib/rfq-last/attachment/rfq-attachments-table.tsx | 370 +++--- lib/rfq-last/attachment/vendor-response-table.tsx | 519 ++++++++ lib/rfq-last/service.ts | 1267 +++++++++++++++++++- lib/rfq-last/validations.ts | 90 +- lib/rfq-last/vendor/add-vendor-dialog.tsx | 307 +++++ .../vendor/batch-update-conditions-dialog.tsx | 1121 +++++++++++++++++ lib/rfq-last/vendor/rfq-vendor-table.tsx | 746 ++++++++++++ lib/rfq-last/vendor/vendor-detail-dialog.tsx | 0 .../vendor/vendor-response-status-card.tsx | 51 + 21 files changed, 4974 insertions(+), 1574 deletions(-) delete mode 100644 lib/forms/vendor-completion-examples.ts delete mode 100644 lib/forms/vendor-completion-stats.ts delete mode 100644 lib/forms/vendor-tag-actions.ts create mode 100644 lib/procurement-select/service.ts create mode 100644 lib/rfq-last/attachment/revision-historty-dialog.tsx create mode 100644 lib/rfq-last/attachment/vendor-response-table.tsx create mode 100644 lib/rfq-last/vendor/add-vendor-dialog.tsx create mode 100644 lib/rfq-last/vendor/batch-update-conditions-dialog.tsx create mode 100644 lib/rfq-last/vendor/rfq-vendor-table.tsx create mode 100644 lib/rfq-last/vendor/vendor-detail-dialog.tsx create mode 100644 lib/rfq-last/vendor/vendor-response-status-card.tsx (limited to 'lib') diff --git a/lib/basic-contract/gtc-vendor/clause-table.tsx b/lib/basic-contract/gtc-vendor/clause-table.tsx index a9230cd4..88b1f45c 100644 --- a/lib/basic-contract/gtc-vendor/clause-table.tsx +++ b/lib/basic-contract/gtc-vendor/clause-table.tsx @@ -30,6 +30,8 @@ interface GtcClausesTableProps { Awaited>, Awaited>, Awaited>, + Vendor | null, // vendor 데이터 추가 + ] > , @@ -46,7 +48,7 @@ export function GtcClausesVendorTable({ vendorId, vendorName }: GtcClausesTableProps) { - const [{ data, pageCount }, users, vendorData] = React.use(promises) + const [{ data, pageCount }, users, vendorData, vendor] = React.use(promises) const [rowAction, setRowAction] = @@ -195,6 +197,7 @@ export function GtcClausesVendorTable({ table={table} documentId={documentId} document={document} + vendor={vendor} /> diff --git a/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx b/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx index 3a0fbdb6..f0cebe5f 100644 --- a/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx +++ b/lib/basic-contract/gtc-vendor/gtc-clauses-table-toolbar-actions.tsx @@ -37,12 +37,14 @@ import { exportFullDataToExcel, type ExcelColumnDef } from "@/lib/export" import { getAllGtcClausesForExport, importGtcClausesFromExcel } from "@/lib/gtc-contract/service" import { ImportExcelDialog } from "./import-excel-dialog" import { toast } from "@/hooks/use-toast" +import { Vendor } from "@/db/schema" interface GtcClausesTableToolbarActionsProps { table: Table documentId: number document: any currentUserId?: number // 현재 사용자 ID 추가 + vendor:Vendor } // GTC 조항을 위한 Excel 컬럼 정의 (실용적으로 간소화) @@ -101,7 +103,7 @@ export function GtcClausesTableToolbarActions({ table, documentId, document, - currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함) + vendor }: GtcClausesTableToolbarActionsProps) { const [showCreateDialog, setShowCreateDialog] = React.useState(false) const [showReorderDialog, setShowReorderDialog] = React.useState(false) @@ -188,7 +190,7 @@ export function GtcClausesTableToolbarActions({ // Excel 데이터 가져오기 처리 const handleImportExcelData = async (data: Partial[]) => { try { - const result = await importGtcClausesFromExcel(documentId, data, currentUserId) + const result = await importGtcClausesFromExcel(documentId, data) if (result.success) { toast({ @@ -340,7 +342,8 @@ export function GtcClausesTableToolbarActions({ open={showPreviewDialog} onOpenChange={setShowPreviewDialog} clauses={previewClauses} - document={document} + contractDocument={document} + vendor={vendor} onExport={() => { console.log("Export from preview dialog") }} diff --git a/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx b/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx index 78ddc7f7..c017b8be 100644 --- a/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx +++ b/lib/basic-contract/gtc-vendor/preview-document-dialog.tsx @@ -5,38 +5,62 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" import { Eye, Download, + Save, + Upload, Loader2, FileText, RefreshCw, Settings, - AlertCircle + AlertCircle, + CheckCircle } from "lucide-react" import { toast } from "sonner" import { type GtcClauseTreeView } from "@/db/schema/gtc" import { ClausePreviewViewer } from "./clause-preview-viewer" +import { saveGtcDocumentAction } from "../service" + +interface Vendor { + vendorName: string + address: string + representativeName: string + taxId: string + phone: string +} interface PreviewDocumentDialogProps extends React.ComponentPropsWithRef { clauses: GtcClauseTreeView[] - document: any + contractDocument: any + vendor: Vendor onExport?: () => void } export function PreviewDocumentDialog({ clauses, - document, + contractDocument, + vendor, onExport, ...props }: PreviewDocumentDialogProps) { + const [isGenerating, setIsGenerating] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [isConverting, setIsConverting] = React.useState(false) const [documentGenerated, setDocumentGenerated] = React.useState(false) const [viewerInstance, setViewerInstance] = React.useState(null) const [hasError, setHasError] = React.useState(false) + + // 파일 업로드 관련 상태 + const [selectedFile, setSelectedFile] = React.useState(null) + const [convertedPdf, setConvertedPdf] = React.useState(null) + const fileInputRef = React.useRef(null) // 조항 통계 계산 const stats = React.useMemo(() => { @@ -58,8 +82,6 @@ export function PreviewDocumentDialog({ setDocumentGenerated(false) try { - // 실제로는 ClausePreviewViewer에서 문서 생성을 처리하므로 - // 여기서는 상태만 관리 console.log("🚀 문서 미리보기 생성 시작") // ClausePreviewViewer가 완전히 로드될 때까지 기다림 @@ -78,12 +100,182 @@ export function PreviewDocumentDialog({ } } + // 파일 선택 핸들러 + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + // Word 파일만 허용 + const allowedTypes = [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx + 'application/msword' // .doc + ] + + if (!allowedTypes.includes(file.type)) { + toast.error("Word 파일(.doc, .docx)만 업로드할 수 있습니다.") + return + } + + if (file.size > 50 * 1024 * 1024) { // 50MB 제한 + toast.error("파일 크기는 50MB 이하여야 합니다.") + return + } + + setSelectedFile(file) + setConvertedPdf(null) // 이전 변환 결과 초기화 + toast.success(`파일이 선택되었습니다: ${file.name}`) + } + + // PDF 변환 함수 + const handleConvertToPdf = async () => { + if (!selectedFile) { + toast.error("먼저 Word 파일을 선택해주세요.") + return + } + + // 브라우저 환경 체크 + if (typeof window === 'undefined' || typeof document === 'undefined') { + toast.error("브라우저 환경에서만 PDF 변환이 가능합니다.") + return + } + + setIsConverting(true) + + try { + console.log("🔄 PDF 변환 시작:", selectedFile.name) + + // PDFTron WebViewer 동적 import + const { default: WebViewer } = await import("@pdftron/webviewer") + + // 임시 WebViewer 인스턴스 생성 (화면에 표시하지 않음) + const tempDiv = document.createElement('div') + tempDiv.style.display = 'none' + tempDiv.style.position = 'absolute' + tempDiv.style.top = '-9999px' + tempDiv.style.left = '-9999px' + tempDiv.style.width = '1px' + tempDiv.style.height = '1px' + document.body.appendChild(tempDiv) + + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + }, + tempDiv + ) + + try { + // WebViewer 초기화 대기 + await new Promise(resolve => setTimeout(resolve, 1000)) + + const { Core } = instance + const { createDocument } = Core + + const templateData = { + company_name: vendor.vendorName || '협력업체명', + company_address: vendor.address || '주소', + representative_name: vendor.representativeName || '대표자명', + signature_date: new Date().toLocaleDateString('ko-KR'), + tax_id: vendor.taxId || '사업자번호', + phone_number: vendor.phone || '전화번호', + } + + const templateDoc = await createDocument(selectedFile, { + filename: selectedFile.name|| 'template.docx', + extension: 'docx', + }) + + await templateDoc.applyTemplateValues(templateData) + + + // 문서 로드 완료 대기 + await new Promise(resolve => setTimeout(resolve, 3000)) + + // PDF로 변환 - 더 안전한 방식 + const fileData = await templateDoc.getFileData() + const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }) + + console.log("✅ PDF 변환 완료:", pdfBuffer.byteLength, "bytes") + setConvertedPdf(new Uint8Array(pdfBuffer)) + toast.success("PDF 변환이 완료되었습니다.") + + } finally { + // 임시 WebViewer 정리 + try { + instance.UI.dispose() + } catch (disposeError) { + console.warn("WebViewer dispose 오류:", disposeError) + } + + try { + if (tempDiv && tempDiv.parentNode) { + document.body.removeChild(tempDiv) + } + } catch (removeError) { + console.warn("임시 div 제거 오류:", removeError) + } + } + + } catch (error) { + console.error("❌ PDF 변환 실패:", error) + toast.error(`PDF 변환 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsConverting(false) + } + } + + // 문서 저장 함수 + const handleSaveDocument = async () => { + if (!convertedPdf || !selectedFile) { + toast.error("먼저 파일을 업로드하고 PDF로 변환해주세요.") + return + } + + setIsSaving(true) + + try { + console.log("💾 문서 저장 시작") + + const result = await saveGtcDocumentAction({ + documentId: contractDocument.id, + pdfBuffer: convertedPdf, + originalFileName: selectedFile.name, + vendor + }) + + if (result.success) { + toast.success(`문서가 성공적으로 저장되었습니다.`) + console.log("✅ 문서 저장 완료:", { + fileName: result.fileName, + filePath: result.filePath, + fileSize: result.fileSize + }) + + // 저장 완료 후 상태 초기화 + setSelectedFile(null) + setConvertedPdf(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } else { + throw new Error(result.error || "문서 저장에 실패했습니다.") + } + } catch (error) { + console.error("❌ 문서 저장 실패:", error) + toast.error(`문서 저장 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } finally { + setIsSaving(false) + } + } + const handleExportDocument = () => { if (viewerInstance) { try { - // PDFTron의 다운로드 기능 실행 viewerInstance.UI.downloadPdf({ - filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` + filename: `${contractDocument?.title || 'GTC계약서'}_미리보기.pdf` }) toast.success("PDF 다운로드가 시작됩니다.") } catch (error) { @@ -119,7 +311,7 @@ export function PreviewDocumentDialog({ if (props.open && !documentGenerated && !isGenerating && !hasError) { const timer = setTimeout(() => { handleGeneratePreview() - }, 300) // 다이얼로그 애니메이션 후 시작 + }, 300) return () => clearTimeout(timer) } @@ -130,8 +322,15 @@ export function PreviewDocumentDialog({ if (!props.open) { setDocumentGenerated(false) setIsGenerating(false) + setIsSaving(false) + setIsConverting(false) setHasError(false) setViewerInstance(null) + setSelectedFile(null) + setConvertedPdf(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } } }, [props.open]) @@ -141,20 +340,23 @@ export function PreviewDocumentDialog({ - 문서 미리보기 + 문서 미리보기 및 저장 - 현재 조항들을 기반으로 생성된 문서를 미리보기합니다. + 조항 기반 미리보기를 확인하고, Word 파일을 업로드하여 최종 문서를 저장하세요. {/* 문서 정보 및 통계 */} -
-
+
+
- {document?.title || 'GTC 계약서'} + {contractDocument?.title || 'GTC 계약서'} {stats.total}개 조항 + {vendor && ( + {vendor.vendorName} + )} {hasError && ( @@ -164,33 +366,22 @@ export function PreviewDocumentDialog({
{documentGenerated && !hasError && ( - <> - - {/* */} - + )} {hasError && (
-
+ {/* 파일 업로드 섹션 */} +
+
+
+ +
+ + {selectedFile && ( + + )} +
+ {selectedFile && ( +

+ 선택됨: {selectedFile.name} ({(selectedFile.size / (1024 * 1024)).toFixed(2)}MB) +

+ )} +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* 통계 정보 */} +
{stats.total}
총 조항
@@ -241,7 +496,7 @@ export function PreviewDocumentDialog({

문서 생성 중 오류가 발생했습니다. 네트워크 연결이나 파일 권한을 확인해주세요.

- @@ -249,7 +504,7 @@ export function PreviewDocumentDialog({ ) : documentGenerated ? (

문서 미리보기 준비 중

- diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 8189381b..057526cf 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -65,7 +65,7 @@ import { sendEmail } from "../mail/sendEmail"; import { headers } from 'next/headers'; import { filterColumns } from "@/lib/filter-columns"; import { differenceInDays, addYears, isBefore } from "date-fns"; -import { deleteFile, saveFile } from "@/lib/file-stroage"; +import { deleteFile, saveBuffer, saveFile } from "@/lib/file-stroage"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" @@ -3407,3 +3407,89 @@ export async function updateVendorDocumentStatus( return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." } } } + + +interface SaveDocumentParams { + documentId: number + pdfBuffer: Uint8Array + originalFileName: string + vendor: { + vendorName: string + } + userId: number +} + +export async function saveGtcDocumentAction({ + documentId, + pdfBuffer, + originalFileName, + vendor +}: SaveDocumentParams) { + try { + console.log("📄 GTC 문서 저장 시작:", { + documentId, + vendorName: vendor.vendorName, + originalFileName, + bufferSize: pdfBuffer.length + }) + + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { success: false, error: "인증되지 않은 사용자입니다." } + } + const userId = Number(session.user.id); + + // 1. PDF 파일명 생성 + const baseName = originalFileName.replace(/\.[^/.]+$/, "") // 확장자 제거 + const fileName = `GTC_${vendor.vendorName}_${baseName}_${new Date().toISOString().split('T')[0]}.pdf` + + // 2. 파일 저장 (공용 파일 저장 함수 사용) + const saveResult = await saveBuffer({ + buffer: Buffer.from(pdfBuffer), + fileName, + directory: 'basic-contracts', + originalName: fileName, + userId: userId.toString() + }) + + if (!saveResult.success) { + throw new Error(saveResult.error || '파일 저장 실패') + } + + // 3. 데이터베이스 업데이트 + await db.update(basicContract) + .set({ + fileName: saveResult.fileName!, + filePath: saveResult.publicPath!, + status: 'PENDING', + // 기존 서명 관련 timestamp들 리셋 + vendorSignedAt: null, + buyerSignedAt: null, + legalReviewRequestedAt: null, + legalReviewCompletedAt: null, + updatedAt: new Date() + }) + .where(eq(basicContract.id, documentId)) + + console.log("✅ GTC 문서 저장 완료:", { + fileName: saveResult.fileName, + filePath: saveResult.publicPath, + fileSize: saveResult.fileSize + }) + + return { + success: true, + fileName: saveResult.fileName, + filePath: saveResult.publicPath, + fileSize: saveResult.fileSize + } + + } catch (error) { + console.error("❌ GTC 문서 저장 실패:", error) + return { + success: false, + error: error instanceof Error ? error.message : '문서 저장 중 오류가 발생했습니다' + } + } +} \ No newline at end of file diff --git a/lib/forms/stat.ts b/lib/forms/stat.ts index fbcc6f46..45bf2710 100644 --- a/lib/forms/stat.ts +++ b/lib/forms/stat.ts @@ -20,11 +20,12 @@ export async function getVendorFormStatus(): Promise { try { // 1. 모든 벤더 조회 const vendorList = await db - .select({ - id: vendors.id, - vendorName: vendors.vendorName - }) - .from(vendors) + .selectDistinct({ + vendorId: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) const vendorStatusList: VendorFormStatus[] = [] @@ -37,12 +38,12 @@ export async function getVendorFormStatus(): Promise { // 2. 벤더별 계약 조회 const vendorContracts = await db - .selectDistinct({ - vendorId: vendors.id, - vendorName: vendors.vendorName, - }) - .from(vendors) - .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where(eq(contracts.vendorId, vendor.vendorId)) for (const contract of vendorContracts) { // 3. 계약별 contractItems 조회 @@ -119,8 +120,7 @@ export async function getVendorFormStatus(): Promise { // 최종 입력 필요 필드 = shi 기반 필드 + TAG 기반 편집 가능 필드 const allRequiredFields = inputRequiredFields.filter(field => tagEditableFields.includes(field) - ) - + ) // 각 필드별 입력 상태 체크 for (const fieldKey of allRequiredFields) { vendorTotalFields++ @@ -143,7 +143,7 @@ export async function getVendorFormStatus(): Promise { : 0 vendorStatusList.push({ - vendorId: vendor.id, + vendorId: vendor.vendorId, vendorName: vendor.vendorName || '이름 없음', formCount: vendorFormCount, tagCount: uniqueTags.size, diff --git a/lib/forms/vendor-completion-examples.ts b/lib/forms/vendor-completion-examples.ts deleted file mode 100644 index b2cac730..00000000 --- a/lib/forms/vendor-completion-examples.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * 벤더 입력 완성도 서버 액션 사용 예제 - * - * 이 파일은 실제 사용 방법을 보여주는 예제입니다. - */ - -import { - calculateVendorFormCompletion, - getProjectVendorCompletionSummary, - calculateVendorContractCompletion, - getVendorAllContractsCompletionSummary, - getAllVendorsContractsCompletionSummary, - getAllProjectsVendorCompletionSummary -} from './vendor-completion-stats'; - -/** - * 예제 1: 특정 벤더의 특정 Form 완성도 확인 - */ -export async function exampleVendorFormCompletion() { - const contractItemId = 123; - const formCode = "SPR_LST"; - - const stats = await calculateVendorFormCompletion(contractItemId, formCode); - - if (stats) { - console.log(`벤더: ${stats.vendorName}`); - console.log(`폼: ${stats.formName} (${stats.formCode})`); - console.log(`완성도: ${stats.completionPercentage}%`); - console.log(`입력 현황: ${stats.totalFilledFields}/${stats.totalRequiredFields} 필드 완료`); - console.log(`미입력 필드: ${stats.totalEmptyFields}개`); - console.log(`총 태그 수: ${stats.tagCount}개`); - - // 태그별 세부 현황 - stats.detailsByTag.forEach(tag => { - console.log(` 태그 ${tag.tagNo}: ${tag.completionPercentage}% (${tag.filledFields}/${tag.requiredFields})`); - }); - } - - return stats; -} - -/** - * 예제 2: 프로젝트의 모든 벤더들의 특정 Form 완성도 요약 - */ -export async function exampleProjectFormCompletion() { - const projectId = 456; - const formCode = "SPR_LST"; - - const summary = await getProjectVendorCompletionSummary(projectId, formCode); - - if (summary) { - console.log(`프로젝트: ${summary.projectName} (${summary.projectCode})`); - console.log(`평균 완성도: ${summary.averageCompletionPercentage}%`); - console.log(`참여 벤더 수: ${summary.totalVendors}개`); - - // 벤더별 완성도 - summary.vendors.forEach(vendor => { - console.log(` ${vendor.vendorName}: ${vendor.completionPercentage}%`); - }); - } - - return summary; -} - -/** - * 예제 3: 특정 벤더의 특정 계약에 대한 모든 Form 완성도 - */ -export async function exampleVendorContractCompletion() { - const vendorId = 789; - const contractItemId = 123; - - const stats = await calculateVendorContractCompletion(vendorId, contractItemId); - - if (stats) { - console.log(`벤더: ${stats.contracts?.[0]?.forms?.[0]?.vendorName || 'Unknown'}`); - console.log(`프로젝트: ${stats.projectName} (${stats.projectCode})`); - console.log(`아이템: ${stats.itemName} (${stats.itemCode})`); - console.log(`전체 완성도: ${stats.averageCompletionPercentage}%`); - console.log(`총 폼 수: ${stats.totalForms}개`); - console.log(`총 필드 수: ${stats.totalRequiredFields}개`); - console.log(`입력된 필드: ${stats.totalFilledFields}개`); - - // 폼별 완성도 - stats.forms.forEach(form => { - console.log(` ${form.formName}: ${form.completionPercentage}%`); - }); - } - - return stats; -} - -/** - * 예제 4: 특정 벤더의 모든 계약에 대한 입력 완성도 요약 - */ -export async function exampleVendorAllContractsCompletion() { - const vendorId = 789; - - const summary = await getVendorAllContractsCompletionSummary(vendorId); - - if (summary) { - console.log(`벤더: ${summary.vendorName}`); - console.log(`전체 완성도: ${summary.overallCompletionPercentage}%`); - console.log(`총 계약 수: ${summary.totalContracts}개`); - console.log(`총 폼 수: ${summary.totalForms}개`); - console.log(`총 필드 수: ${summary.totalRequiredFields}개`); - console.log(`입력된 필드: ${summary.totalFilledFields}개`); - - // 프로젝트별 요약 - console.log('\n프로젝트별 완성도:'); - summary.projectBreakdown.forEach(project => { - console.log(` ${project.projectName}: ${project.completionPercentage}% (계약 ${project.contractsCount}개, 폼 ${project.formsCount}개)`); - }); - - // 계약별 세부 현황 - console.log('\n계약별 세부 현황:'); - summary.contracts.forEach(contract => { - console.log(` ${contract.itemName}: ${contract.averageCompletionPercentage}% (폼 ${contract.totalForms}개)`); - }); - } - - return summary; -} - -/** - * 예제 5: 모든 벤더들의 계약 완성도 요약 (관리자용) - */ -export async function exampleAllVendorsCompletion() { - const summary = await getAllVendorsContractsCompletionSummary(); - - console.log(`전체 벤더 수: ${summary.totalVendors}개`); - console.log(`전체 평균 완성도: ${summary.overallAverageCompletion}%`); - - // 상위 성과 벤더들 - console.log('\n상위 성과 벤더들:'); - summary.topPerformingVendors.forEach((vendor, index) => { - console.log(` ${index + 1}. ${vendor.vendorName}: ${vendor.completionPercentage}%`); - }); - - // 하위 성과 벤더들 - console.log('\n개선이 필요한 벤더들:'); - summary.lowPerformingVendors.forEach((vendor, index) => { - console.log(` ${index + 1}. ${vendor.vendorName}: ${vendor.completionPercentage}%`); - }); - - return summary; -} - -/** - * 예제 6: 모든 프로젝트의 벤더 완성도 요약 (관리자용) - */ -export async function exampleAllProjectsCompletion() { - const summary = await getAllProjectsVendorCompletionSummary(); - - console.log(`전체 프로젝트 수: ${summary.totalProjects}개`); - console.log(`전체 평균 완성도: ${summary.overallAverageCompletion}%`); - - // 프로젝트별 완성도 - console.log('\n프로젝트별 완성도:'); - summary.projects.forEach(project => { - console.log(` ${project.projectName}: ${project.averageCompletionPercentage}% (벤더 ${project.totalVendors}개)`); - }); - - return summary; -} - -/** - * 예제 7: 대시보드용 완성도 통계 생성 - */ -export async function generateCompletionDashboard() { - console.log('=== 벤더 입력 완성도 대시보드 ===\n'); - - // 전체 벤더 요약 - const allVendors = await getAllVendorsContractsCompletionSummary(); - console.log(`📊 전체 통계`); - console.log(`- 총 벤더 수: ${allVendors.totalVendors}개`); - console.log(`- 평균 완성도: ${allVendors.overallAverageCompletion}%`); - - // 전체 프로젝트 요약 - const allProjects = await getAllProjectsVendorCompletionSummary(); - console.log(`- 총 프로젝트 수: ${allProjects.totalProjects}개`); - console.log(`- 프로젝트 평균 완성도: ${allProjects.overallAverageCompletion}%\n`); - - // 우수 벤더 - console.log('🏆 우수 벤더 (완성도 90% 이상):'); - const excellentVendors = allVendors.vendors.filter(v => v.overallCompletionPercentage >= 90); - excellentVendors.forEach(vendor => { - console.log(` ✅ ${vendor.vendorName}: ${vendor.overallCompletionPercentage}%`); - }); - - // 주의 필요 벤더 - console.log('\n⚠️ 주의 필요 벤더 (완성도 50% 미만):'); - const warningVendors = allVendors.vendors.filter(v => v.overallCompletionPercentage < 50); - warningVendors.forEach(vendor => { - console.log(` 🔴 ${vendor.vendorName}: ${vendor.overallCompletionPercentage}%`); - }); - - return { - totalVendors: allVendors.totalVendors, - averageCompletion: allVendors.overallAverageCompletion, - excellentVendors: excellentVendors.length, - warningVendors: warningVendors.length, - topVendors: allVendors.topPerformingVendors, - lowVendors: allVendors.lowPerformingVendors - }; -} diff --git a/lib/forms/vendor-completion-stats.ts b/lib/forms/vendor-completion-stats.ts deleted file mode 100644 index 97efec30..00000000 --- a/lib/forms/vendor-completion-stats.ts +++ /dev/null @@ -1,1007 +0,0 @@ -"use server"; - -import db from "@/db/db"; -import { - formMetas, - formEntries, - tags, - tagClasses, - tagClassAttributes -} from "@/db/schema/vendorData"; -import { contractItems } from "@/db/schema/contract"; -import { contracts } from "@/db/schema/contract"; -import { projects } from "@/db/schema/projects"; -import { vendors } from "@/db/schema/vendors"; -import { eq, and, desc } from "drizzle-orm"; -import type { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; - -export interface VendorFormCompletionStats { - vendorId: number; - vendorName: string; - contractItemId: number; - formCode: string; - formName: string; - totalRequiredFields: number; - totalFilledFields: number; - totalEmptyFields: number; - completionPercentage: number; - tagCount: number; - detailsByTag: Array<{ - tagNo: string; - requiredFields: number; - filledFields: number; - emptyFields: number; - completionPercentage: number; - }>; -} - -export interface ProjectVendorCompletionSummary { - projectId: number; - projectCode: string; - projectName: string; - vendors: VendorFormCompletionStats[]; - totalVendors: number; - averageCompletionPercentage: number; -} - -export interface VendorContractCompletionStats { - contractId: number; - contractItemId: number; - projectId: number; - projectCode: string; - projectName: string; - itemCode: string; - itemName: string; - forms: VendorFormCompletionStats[]; - totalForms: number; - totalRequiredFields: number; - totalFilledFields: number; - totalEmptyFields: number; - averageCompletionPercentage: number; -} - -export interface VendorAllContractsCompletionSummary { - vendorId: number; - vendorName: string; - contracts: VendorContractCompletionStats[]; - totalContracts: number; - totalForms: number; - totalTags: number; - totalRequiredFields: number; - totalFilledFields: number; - totalEmptyFields: number; - overallCompletionPercentage: number; - projectBreakdown: Array<{ - projectId: number; - projectCode: string; - projectName: string; - contractsCount: number; - formsCount: number; - completionPercentage: number; - }>; -} - -/** - * 필드가 벤더에 의해 편집 가능한지 확인 - * SHI 값이 "BOTH" 또는 "IN"인 경우만 벤더가 편집 가능 (대소문자 무관) - */ -function isFieldEditableByVendor(column: DataTableColumnJSON): boolean { - const shi = column.shi?.toString().toUpperCase(); - const isEditable = shi === "BOTH" || shi === "IN"; - console.log(`isFieldEditableByVendor - Key: ${column.key}, shi: ${column.shi}, upperShi: ${shi}, isEditable: ${isEditable}`); - return isEditable; -} - -/** - * 특정 태그에 대해 편집 가능한 필드 목록을 가져옴 - */ -async function getEditableFieldsForTag( - tagNo: string, - contractItemId: number, - projectId: number -): Promise { - try { - // 1. 해당 태그 정보 조회 - const tagResult = await db - .select({ - tagClass: tags.class - }) - .from(tags) - .where( - and( - eq(tags.tagNo, tagNo), - eq(tags.contractItemId, contractItemId) - ) - ) - .limit(1); - - if (tagResult.length === 0) { - console.log(`getEditableFieldsForTag - No tag found for tagNo: ${tagNo}, contractItemId: ${contractItemId}`); - return []; - } - - console.log(`getEditableFieldsForTag - Found tag for tagNo: ${tagNo}, class: ${tagResult[0].tagClass}`); - - // 2. tagClasses에서 해당 class와 projectId로 tagClass 찾기 - const tagClassResult = await db - .select({ id: tagClasses.id }) - .from(tagClasses) - .where( - and( - eq(tagClasses.label, tagResult[0].tagClass), - eq(tagClasses.projectId, projectId) - ) - ) - .limit(1); - - if (tagClassResult.length === 0) { - console.log(`getEditableFieldsForTag - No tag class found for class: ${tagResult[0].tagClass}, projectId: ${projectId}`); - return []; - } - - console.log(`getEditableFieldsForTag - Found tag class: ${tagClassResult[0].id} for class: ${tagResult[0].tagClass}`); - - // 3. tagClassAttributes에서 편집 가능한 필드 목록 조회 - const editableAttributes = await db - .select({ attId: tagClassAttributes.attId }) - .from(tagClassAttributes) - .where(eq(tagClassAttributes.tagClassId, tagClassResult[0].id)) - .orderBy(tagClassAttributes.seq); - - console.log(`getEditableFieldsForTag - Found ${editableAttributes.length} editable attributes for tag class ${tagClassResult[0].id}:`, editableAttributes.map(attr => attr.attId)); - - return editableAttributes.map(attr => attr.attId); - } catch (error) { - console.error(`Error getting editable fields for tag ${tagNo}:`, error); - return []; - } -} - -/** - * 값이 "빈" 값인지 확인 - */ -function isEmptyValue(value: unknown): boolean { - if (value === null || value === undefined) return true; - if (typeof value === 'string') return value.trim() === ''; - if (typeof value === 'number') return isNaN(value); - return false; -} - -/** - * 특정 contract item의 form에 대한 벤더 입력 완성도 계산 - */ -export async function calculateVendorFormCompletion( - contractItemId: number, - formCode: string -): Promise { - try { - // 1. Contract Item 정보 및 Vendor 정보 조회 - const contractInfo = await db - .select({ - projectId: projects.id, - projectCode: projects.code, - projectName: projects.name, - vendorId: vendors.id, - vendorName: vendors.vendorName, - contractId: contracts.id - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) - .where(eq(contractItems.id, contractItemId)) - .limit(1); - - if (contractInfo.length === 0) { - console.warn(`No contract item found with ID: ${contractItemId}`); - return null; - } - - const { projectId, vendorId, vendorName } = contractInfo[0]; - - // 2. Form 메타데이터 조회 (컬럼 정의) - const metaRows = await db - .select() - .from(formMetas) - .where(eq(formMetas.formCode, formCode)) - .orderBy(desc(formMetas.updatedAt)) - .limit(1); - - const meta = metaRows[0]; - if (!meta) { - console.warn(`No form meta found for formCode: ${formCode} and projectId: ${projectId}`); - return null; - } - - console.log(`calculateVendorFormCompletion - Found form meta for formCode: ${formCode}, projectId: ${projectId}, columns type: ${typeof meta.columns}, isArray: ${Array.isArray(meta.columns)}`); - - // 3. Form 실제 데이터 조회 - const entryRows = await db - .select() - .from(formEntries) - .where( - and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) - ) - ) - .orderBy(desc(formEntries.updatedAt)) - .limit(1); - - const entry = entryRows[0]; - if (!entry || !Array.isArray(entry.data)) { - console.warn(`No form data found for formCode: ${formCode} and contractItemId: ${contractItemId}`); - return null; - } - - // 4. 컬럼 정의에서 벤더가 편집 가능한 필드 필터링 - const columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO', 'status']; - const editableColumns = columns.filter(col => - !excludeKeys.includes(col.key) && isFieldEditableByVendor(col) - ); - - console.log(`calculateVendorFormCompletion - Total columns: ${columns.length}, Editable columns: ${editableColumns.length}`); - console.log(`calculateVendorFormCompletion - Editable column keys:`, editableColumns.map(col => col.key)); - console.log(`calculateVendorFormCompletion - All column keys:`, columns.map(col => col.key)); - console.log(`calculateVendorFormCompletion - All column shi values:`, columns.map(col => col.shi)); - - // 5. 각 태그별로 완성도 계산 - const detailsByTag: VendorFormCompletionStats['detailsByTag'] = []; - let totalRequiredFields = 0; - let totalFilledFields = 0; - - const formData = entry.data as Array>; - - for (const rowData of formData) { - const tagNo = rowData.TAG_NO as string; - if (!tagNo) continue; - - // Debug 페이지와 동일하게 직접 editableColumns 사용 (getEditableFieldsForTag 대신) - const actualEditableFields = editableColumns; - - const requiredFieldsCount = actualEditableFields.length; - let filledFieldsCount = 0; - - // 각 편집 가능한 필드의 값 확인 - for (const column of actualEditableFields) { - const value = rowData[column.key]; - if (!isEmptyValue(value)) { - filledFieldsCount++; - } - } - - const emptyFieldsCount = requiredFieldsCount - filledFieldsCount; - const completionPercentage = requiredFieldsCount > 0 - ? Math.round((filledFieldsCount / requiredFieldsCount) * 100) - : 100; - - detailsByTag.push({ - tagNo: tagNo as string, - requiredFields: requiredFieldsCount, - filledFields: filledFieldsCount, - emptyFields: emptyFieldsCount, - completionPercentage - }); - - totalRequiredFields += requiredFieldsCount; - totalFilledFields += filledFieldsCount; - } - - const totalEmptyFields = totalRequiredFields - totalFilledFields; - const overallCompletionPercentage = totalRequiredFields > 0 - ? Math.round((totalFilledFields / totalRequiredFields) * 100) - : 100; - - return { - vendorId, - vendorName, - contractItemId, - formCode, - formName: meta.formName, - totalRequiredFields, - totalFilledFields, - totalEmptyFields, - completionPercentage: overallCompletionPercentage, - tagCount: formData.length, - detailsByTag - }; - - } catch (error) { - console.error(`Error calculating vendor form completion:`, error); - return null; - } -} - -/** - * 프로젝트의 모든 벤더들에 대한 특정 form의 입력 완성도 요약 - */ -export async function getProjectVendorCompletionSummary( - projectId: number, - formCode: string -): Promise { - try { - // 1. 프로젝트 정보 조회 - const projectInfo = await db - .select({ - id: projects.id, - code: projects.code, - name: projects.name - }) - .from(projects) - .where(eq(projects.id, projectId)) - .limit(1); - - if (projectInfo.length === 0) { - console.warn(`No project found with ID: ${projectId}`); - return null; - } - - const project = projectInfo[0]; - - // 2. 해당 프로젝트의 모든 contract items 조회 (formCode와 연관된) - const contractItemsInfo = await db - .select({ - contractItemId: contractItems.id, - vendorId: vendors.id, - vendorName: vendors.vendorName - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) - .innerJoin(formEntries, and( - eq(formEntries.contractItemId, contractItems.id), - eq(formEntries.formCode, formCode) - )) - .where(eq(contracts.projectId, projectId)); - - // 3. 각 contract item별로 완성도 계산 - const vendorStats: VendorFormCompletionStats[] = []; - - for (const item of contractItemsInfo) { - const stats = await calculateVendorFormCompletion( - item.contractItemId, - formCode - ); - - if (stats) { - vendorStats.push(stats); - } - } - - // 4. 전체 평균 완성도 계산 - const averageCompletionPercentage = vendorStats.length > 0 - ? Math.round( - vendorStats.reduce((sum, stat) => sum + stat.completionPercentage, 0) / vendorStats.length - ) - : 0; - - return { - projectId: project.id, - projectCode: project.code, - projectName: project.name, - vendors: vendorStats, - totalVendors: vendorStats.length, - averageCompletionPercentage - }; - - } catch (error) { - console.error(`Error getting project vendor completion summary:`, error); - return null; - } -} - -/** - * 특정 벤더의 특정 계약(contract item)에 대한 모든 form 완성도 계산 - */ -export async function calculateVendorContractCompletion( - vendorId: number, - contractItemId: number -): Promise { - try { - // 1. Contract Item 정보 조회 - const contractItemInfo = await db - .select({ - contractId: contracts.id, - contractItemId: contractItems.id, - projectId: projects.id, - projectCode: projects.code, - projectName: projects.name, - itemId: contractItems.itemId, - description: contractItems.description, - vendorId: vendors.id, - vendorName: vendors.vendorName - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) - .where( - and( - eq(contractItems.id, contractItemId), - eq(vendors.id, vendorId) - ) - ) - .limit(1); - - if (contractItemInfo.length === 0) { - console.warn(`No contract item found for vendorId: ${vendorId}, contractItemId: ${contractItemId}`); - return null; - } - - const contractInfo = contractItemInfo[0]; - - // 2. 해당 contract item과 연관된 모든 form codes 조회 - const formCodes = await db - .selectDistinct({ - formCode: formEntries.formCode - }) - .from(formEntries) - .where(eq(formEntries.contractItemId, contractItemId)); - - // 3. 각 form에 대한 완성도 계산 - const formStats: VendorFormCompletionStats[] = []; - let totalRequiredFields = 0; - let totalFilledFields = 0; - - for (const { formCode } of formCodes) { - const formCompletion = await calculateVendorFormCompletion(contractItemId, formCode); - if (formCompletion) { - formStats.push(formCompletion); - totalRequiredFields += formCompletion.totalRequiredFields; - totalFilledFields += formCompletion.totalFilledFields; - } - } - - const totalEmptyFields = totalRequiredFields - totalFilledFields; - const averageCompletionPercentage = formStats.length > 0 - ? Math.round( - formStats.reduce((sum, stat) => sum + stat.completionPercentage, 0) / formStats.length - ) - : 0; - - return { - contractId: contractInfo.contractId, - contractItemId: contractInfo.contractItemId, - projectId: contractInfo.projectId, - projectCode: contractInfo.projectCode, - projectName: contractInfo.projectName, - itemCode: contractInfo.itemId?.toString() || '', - itemName: contractInfo.description || '', - forms: formStats, - totalForms: formStats.length, - totalRequiredFields, - totalFilledFields, - totalEmptyFields, - averageCompletionPercentage - }; - - } catch (error) { - console.error(`Error calculating vendor contract completion:`, error); - return null; - } -} - -/** - * 특정 벤더가 보유한 모든 계약에 대한 입력 완성도 요약 - */ -export async function getVendorAllContractsCompletionSummary( - vendorId: number -): Promise { - try { - // 1. 벤더 정보 조회 - const vendorInfo = await db - .select({ - id: vendors.id, - vendorName: vendors.vendorName - }) - .from(vendors) - .where(eq(vendors.id, vendorId)) - .limit(1); - - if (vendorInfo.length === 0) { - console.warn(`No vendor found with ID: ${vendorId}`); - return null; - } - - const vendor = vendorInfo[0]; - - // 2. 해당 벤더의 모든 contract items 조회 - const contractItemsInfo = await db - .select({ - contractId: contracts.id, - contractItemId: contractItems.id, - projectId: projects.id, - projectCode: projects.code, - projectName: projects.name, - itemId: contractItems.itemId, - description: contractItems.description - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .where(eq(contracts.vendorId, vendorId)); - - // 3. 각 contract item별로 완성도 계산 - const contractStats: VendorContractCompletionStats[] = []; - - for (const item of contractItemsInfo) { - console.log(`getVendorAllContractsCompletionSummary - Processing contract item: ${item.contractItemId} for vendor: ${vendorId}`); - const contractCompletion = await calculateVendorContractCompletion( - vendorId, - item.contractItemId - ); - - if (contractCompletion) { - console.log(`getVendorAllContractsCompletionSummary - Contract completion for item ${item.contractItemId}:`, { - totalRequiredFields: contractCompletion.totalRequiredFields, - totalFilledFields: contractCompletion.totalFilledFields, - totalForms: contractCompletion.totalForms - }); - contractStats.push(contractCompletion); - } else { - console.log(`getVendorAllContractsCompletionSummary - No contract completion for item: ${item.contractItemId}`); - } - } - - // 4. 전체 통계 계산 - const totalRequiredFields = contractStats.reduce((sum, stat) => sum + stat.totalRequiredFields, 0); - const totalFilledFields = contractStats.reduce((sum, stat) => sum + stat.totalFilledFields, 0); - const totalEmptyFields = totalRequiredFields - totalFilledFields; - const totalForms = contractStats.reduce((sum, stat) => sum + stat.totalForms, 0); - const totalTags = contractStats.reduce((sum, stat) => - sum + stat.forms.reduce((formSum, form) => formSum + form.tagCount, 0), 0 - ); - - const overallCompletionPercentage = totalRequiredFields > 0 - ? Math.round((totalFilledFields / totalRequiredFields) * 100) - : 100; - - // 5. 프로젝트별 요약 계산 - const projectMap = new Map(); - - contractStats.forEach(contract => { - const key = contract.projectId; - if (!projectMap.has(key)) { - projectMap.set(key, { - projectId: contract.projectId, - projectCode: contract.projectCode, - projectName: contract.projectName, - contracts: [] - }); - } - projectMap.get(key)!.contracts.push(contract); - }); - - const projectBreakdown = Array.from(projectMap.values()).map(project => { - const projectTotalRequired = project.contracts.reduce((sum, c) => sum + c.totalRequiredFields, 0); - const projectTotalFilled = project.contracts.reduce((sum, c) => sum + c.totalFilledFields, 0); - const projectCompletionPercentage = projectTotalRequired > 0 - ? Math.round((projectTotalFilled / projectTotalRequired) * 100) - : 100; - - return { - projectId: project.projectId, - projectCode: project.projectCode, - projectName: project.projectName, - contractsCount: project.contracts.length, - formsCount: project.contracts.reduce((sum, c) => sum + c.totalForms, 0), - completionPercentage: projectCompletionPercentage - }; - }); - - return { - vendorId: vendor.id, - vendorName: vendor.vendorName, - contracts: contractStats, - totalContracts: contractStats.length, - totalForms, - totalTags, - totalRequiredFields, - totalFilledFields, - totalEmptyFields, - overallCompletionPercentage, - projectBreakdown - }; - - } catch (error) { - console.error(`Error getting vendor all contracts completion summary:`, error); - return null; - } -} - -/** - * 모든 프로젝트의 모든 form에 대한 벤더 완성도 요약 (관리자용) - */ -export async function getAllProjectsVendorCompletionSummary(): Promise<{ - projects: ProjectVendorCompletionSummary[]; - totalProjects: number; - overallAverageCompletion: number; -}> { - try { - // 1. 모든 프로젝트 조회 - const allProjects = await db - .select({ - id: projects.id, - code: projects.code, - name: projects.name - }) - .from(projects); - - // 2. 각 프로젝트별로 form들의 완성도 조회 - const projectSummaries: ProjectVendorCompletionSummary[] = []; - - for (const project of allProjects) { - // 해당 프로젝트의 모든 form codes 조회 - const formCodes = await db - .selectDistinct({ - formCode: formMetas.formCode - }) - .from(formMetas) - .where(eq(formMetas.projectId, project.id)); - - // 각 form에 대한 완성도 조회 후 통합 - const allVendorStats: VendorFormCompletionStats[] = []; - - for (const { formCode } of formCodes) { - const summary = await getProjectVendorCompletionSummary(project.id, formCode); - if (summary) { - allVendorStats.push(...summary.vendors); - } - } - - if (allVendorStats.length > 0) { - const averageCompletion = Math.round( - allVendorStats.reduce((sum, stat) => sum + stat.completionPercentage, 0) / allVendorStats.length - ); - - projectSummaries.push({ - projectId: project.id, - projectCode: project.code, - projectName: project.name, - vendors: allVendorStats, - totalVendors: allVendorStats.length, - averageCompletionPercentage: averageCompletion - }); - } - } - - // 3. 전체 평균 계산 - const overallAverageCompletion = projectSummaries.length > 0 - ? Math.round( - projectSummaries.reduce((sum, proj) => sum + proj.averageCompletionPercentage, 0) / projectSummaries.length - ) - : 0; - - return { - projects: projectSummaries, - totalProjects: projectSummaries.length, - overallAverageCompletion - }; - - } catch (error) { - console.error(`Error getting all projects vendor completion summary:`, error); - return { - projects: [], - totalProjects: 0, - overallAverageCompletion: 0 - }; - } -} - -/** - * 특정 벤더의 필드 계산 상세 정보를 디버깅용으로 반환 - */ -export async function debugVendorFieldCalculation(vendorId: number): Promise<{ - vendorId: number; - vendorName: string; - debugInfo: { - contracts: Array<{ - contractId: number; - contractItemId: number; - projectName: string; - forms: Array<{ - formCode: string; - formName: string; - tags: Array<{ - tagNo: string; - editableFields: string[]; - requiredFieldsCount: number; - filledFieldsCount: number; - fieldDetails: Array<{ - fieldKey: string; - fieldValue: unknown; - isEmpty: boolean; - }>; - }>; - totalRequiredFields: number; - totalFilledFields: number; - }>; - totalRequiredFields: number; - totalFilledFields: number; - }>; - grandTotal: { - totalRequiredFields: number; - totalFilledFields: number; - totalEmptyFields: number; - completionPercentage: number; - }; - }; -} | null> { - try { - // 1. 벤더 정보 조회 - const vendorInfo = await db - .select({ - id: vendors.id, - vendorName: vendors.vendorName - }) - .from(vendors) - .where(eq(vendors.id, vendorId)) - .limit(1); - - if (vendorInfo.length === 0) { - console.warn(`No vendor found with ID: ${vendorId}`); - return null; - } - - const vendor = vendorInfo[0]; - - // 2. 해당 벤더의 모든 contract items 조회 - const contractItemsInfo = await db - .select({ - contractId: contracts.id, - contractItemId: contractItems.id, - projectId: projects.id, - projectCode: projects.code, - projectName: projects.name, - itemId: contractItems.itemId, - description: contractItems.description - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .where(eq(contracts.vendorId, vendorId)); - - const debugContracts = []; - - for (const item of contractItemsInfo) { - // 3. 해당 contract item과 연관된 모든 form codes 조회 - const formCodes = await db - .selectDistinct({ - formCode: formEntries.formCode - }) - .from(formEntries) - .where(eq(formEntries.contractItemId, item.contractItemId)); - - const debugForms = []; - let contractTotalRequired = 0; - let contractTotalFilled = 0; - - for (const { formCode } of formCodes) { - // 4. Form 메타데이터 조회 - const metaRows = await db - .select() - .from(formMetas) - .where(eq(formMetas.formCode, formCode)) - .orderBy(desc(formMetas.updatedAt)) - .limit(1); - - const meta = metaRows[0]; - if (!meta) { - console.log(`No form meta found for formCode: ${formCode}, projectId: ${item.projectId}`); - continue; - } - - console.log(`Found form meta for formCode: ${formCode}, projectId: ${item.projectId}, columns type: ${typeof meta.columns}, isArray: ${Array.isArray(meta.columns)}`); - - // 5. Form 실제 데이터 조회 - const entryRows = await db - .select() - .from(formEntries) - .where( - and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, item.contractItemId) - ) - ) - .orderBy(desc(formEntries.updatedAt)) - .limit(1); - - const entry = entryRows[0]; - if (!entry || !Array.isArray(entry.data)) continue; - - // 6. 컬럼 정의에서 벤더가 편집 가능한 필드 필터링 - const columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO', 'status']; - const editableColumns = columns.filter(col => - !excludeKeys.includes(col.key) && isFieldEditableByVendor(col) - ); - - const debugTags = []; - let formTotalRequired = 0; - let formTotalFilled = 0; - - const formData = entry.data as Array>; - - for (const rowData of formData) { - const tagNo = rowData.TAG_NO as string; - if (!tagNo) continue; - - // 직접 editableColumns 사용 (getEditableFieldsForTag 대신) - const actualEditableFields = editableColumns; - - const requiredFieldsCount = actualEditableFields.length; - let filledFieldsCount = 0; - - const fieldDetails = []; - // 각 편집 가능한 필드의 값 확인 - for (const column of actualEditableFields) { - const value = rowData[column.key]; - const isEmpty = isEmptyValue(value); - if (!isEmpty) { - filledFieldsCount++; - } - fieldDetails.push({ - fieldKey: column.key, - fieldValue: value, - isEmpty - }); - } - - debugTags.push({ - tagNo, - editableFields: actualEditableFields.map(col => col.key), - requiredFieldsCount, - filledFieldsCount, - fieldDetails - }); - - formTotalRequired += requiredFieldsCount; - formTotalFilled += filledFieldsCount; - } - - debugForms.push({ - formCode, - formName: meta.formName, - tags: debugTags, - totalRequiredFields: formTotalRequired, - totalFilledFields: formTotalFilled - }); - - contractTotalRequired += formTotalRequired; - contractTotalFilled += formTotalFilled; - } - - debugContracts.push({ - contractId: item.contractId, - contractItemId: item.contractItemId, - projectName: item.projectName, - forms: debugForms, - totalRequiredFields: contractTotalRequired, - totalFilledFields: contractTotalFilled - }); - } - - // 전체 합계 계산 - const grandTotalRequired = debugContracts.reduce((sum, contract) => sum + contract.totalRequiredFields, 0); - const grandTotalFilled = debugContracts.reduce((sum, contract) => sum + contract.totalFilledFields, 0); - const grandTotalEmpty = grandTotalRequired - grandTotalFilled; - const grandCompletionPercentage = grandTotalRequired > 0 - ? Math.round((grandTotalFilled / grandTotalRequired) * 100) - : 100; - - return { - vendorId: vendor.id, - vendorName: vendor.vendorName, - debugInfo: { - contracts: debugContracts, - grandTotal: { - totalRequiredFields: grandTotalRequired, - totalFilledFields: grandTotalFilled, - totalEmptyFields: grandTotalEmpty, - completionPercentage: grandCompletionPercentage - } - } - }; - - } catch (error) { - console.error(`Error debugging vendor field calculation:`, error); - return null; - } -} - -/** - * 모든 벤더들의 전체 계약 완성도 요약 (관리자용) - */ -export async function getAllVendorsContractsCompletionSummary(): Promise<{ - vendors: VendorAllContractsCompletionSummary[]; - totalVendors: number; - overallAverageCompletion: number; - topPerformingVendors: Array<{ - vendorId: number; - vendorName: string; - completionPercentage: number; - }>; - lowPerformingVendors: Array<{ - vendorId: number; - vendorName: string; - completionPercentage: number; - }>; -}> { - try { - // 1. 계약이 있는 모든 벤더 조회 - const vendorsWithContracts = await db - .selectDistinct({ - vendorId: vendors.id, - vendorName: vendors.vendorName - }) - .from(vendors) - .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) - .innerJoin(contractItems, eq(contractItems.contractId, contracts.id)); - - // 2. 각 벤더별로 완성도 계산 - const vendorSummaries: VendorAllContractsCompletionSummary[] = []; - - for (const vendor of vendorsWithContracts) { - console.log(`getAllVendorsContractsCompletionSummary - Processing vendor: ${vendor.vendorId} (${vendor.vendorName})`); - const summary = await getVendorAllContractsCompletionSummary(vendor.vendorId); - if (summary) { - console.log(`getAllVendorsContractsCompletionSummary - Vendor ${vendor.vendorId} summary:`, { - totalRequiredFields: summary.totalRequiredFields, - totalFilledFields: summary.totalFilledFields, - totalTags: summary.totalTags, - totalForms: summary.totalForms - }); - vendorSummaries.push(summary); - } else { - console.log(`getAllVendorsContractsCompletionSummary - No summary for vendor: ${vendor.vendorId}`); - } - } - - // 3. 전체 평균 계산 - const overallAverageCompletion = vendorSummaries.length > 0 - ? Math.round( - vendorSummaries.reduce((sum, vendor) => sum + vendor.overallCompletionPercentage, 0) / vendorSummaries.length - ) - : 0; - - // 4. 상위/하위 성과 벤더 추출 (상위 5개, 하위 5개) - const sortedVendors = [...vendorSummaries].sort((a, b) => b.overallCompletionPercentage - a.overallCompletionPercentage); - - const topPerformingVendors = sortedVendors.slice(0, 5).map(vendor => ({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - completionPercentage: vendor.overallCompletionPercentage - })); - - const lowPerformingVendors = sortedVendors.slice(-5).reverse().map(vendor => ({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - completionPercentage: vendor.overallCompletionPercentage - })); - - return { - vendors: vendorSummaries, - totalVendors: vendorSummaries.length, - overallAverageCompletion, - topPerformingVendors, - lowPerformingVendors - }; - - } catch (error) { - console.error(`Error getting all vendors contracts completion summary:`, error); - return { - vendors: [], - totalVendors: 0, - overallAverageCompletion: 0, - topPerformingVendors: [], - lowPerformingVendors: [] - }; - } -} - diff --git a/lib/forms/vendor-tag-actions.ts b/lib/forms/vendor-tag-actions.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx index ea516f49..70cec7fa 100644 --- a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx +++ b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx @@ -101,7 +101,6 @@ export function GtcClausesTableToolbarActions({ table, documentId, document, - currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함) }: GtcClausesTableToolbarActionsProps) { const [showCreateDialog, setShowCreateDialog] = React.useState(false) const [showReorderDialog, setShowReorderDialog] = React.useState(false) @@ -161,7 +160,7 @@ export function GtcClausesTableToolbarActions({ // Excel 데이터 가져오기 처리 const handleImportExcelData = async (data: Partial[]) => { try { - const result = await importGtcClausesFromExcel(documentId, data, currentUserId) + const result = await importGtcClausesFromExcel(documentId, data) if (result.success) { toast({ diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts index f9725f80..0d21f7aa 100644 --- a/lib/gtc-contract/service.ts +++ b/lib/gtc-contract/service.ts @@ -9,7 +9,8 @@ import { users } from "@/db/schema/users" import { vendors } from "@/db/schema/vendors" import { filterColumns } from "@/lib/filter-columns" import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema, CloneGtcDocumentSchema } from "./validations" - +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" /** * 프로젝트 존재 여부 확인 */ @@ -568,7 +569,6 @@ interface ImportResult { export async function importGtcClausesFromExcel( documentId: number, data: Partial[], - userId: number = 1 // TODO: 실제 사용자 ID로 교체 ): Promise { const result: ImportResult = { success: false, @@ -577,6 +577,13 @@ export async function importGtcClausesFromExcel( duplicates: [] } + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + try { // 데이터 검증 및 변환 const validData: ImportGtcClauseData[] = [] diff --git a/lib/procurement-select/service.ts b/lib/procurement-select/service.ts new file mode 100644 index 00000000..14fe54d7 --- /dev/null +++ b/lib/procurement-select/service.ts @@ -0,0 +1,85 @@ +'use server' + +import db from "@/db/db" +import { + incoterms, placeOfShipping, paymentTerms +} from "@/db/schema" +import { eq } from "drizzle-orm" + +export async function getIncotermsForSelection() { + try { + const data = await db + .select({ + code: incoterms.code, + description: incoterms.description, + }) + .from(incoterms) + .where(eq(incoterms.isActive, true)) + .orderBy(incoterms.code) + + return data + + } catch (error) { + console.error("Error fetching incoterms:", error) + throw new Error("Failed to fetch incoterms") + } +} + + +export async function getPaymentTermsForSelection() { + try { + const data = await db + .select({ + code: paymentTerms.code, + description: paymentTerms.description, + }) + .from(paymentTerms) + .where(eq(paymentTerms.isActive, true)) + .orderBy(paymentTerms.code) + + return data + + } catch (error) { + console.error("Error fetching paymentTerms:", error) + throw new Error("Failed to fetch paymentTerms") + } +} + + +export async function getPlaceOfShippingForSelection() { + try { + const data = await db + .select({ + code: placeOfShipping.code, + description: placeOfShipping.description, + }) + .from(placeOfShipping) + .where(eq(placeOfShipping.isActive, true)) + .orderBy(placeOfShipping.code) + + return data + + } catch (error) { + console.error("Error fetching placeOfShipping:", error) + throw new Error("Failed to fetch placeOfShipping") + } +} + +export async function getPlaceOfDestinationForSelection() { + try { + const data = await db + .select({ + code: placeOfShipping.code, + description: placeOfShipping.description, + }) + .from(placeOfShipping) + .where(eq(placeOfShipping.isActive, true)) + .orderBy(placeOfShipping.code) + + return data + + } catch (error) { + console.error("Error fetching placeOfShipping:", error) + throw new Error("Failed to fetch placeOfShipping") + } +} \ No newline at end of file diff --git a/lib/rfq-last/attachment/revision-historty-dialog.tsx b/lib/rfq-last/attachment/revision-historty-dialog.tsx new file mode 100644 index 00000000..6e4772cb --- /dev/null +++ b/lib/rfq-last/attachment/revision-historty-dialog.tsx @@ -0,0 +1,305 @@ +// @/lib/rfq-last/attachment/revision-history-dialog.tsx + +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Download, + Eye, + FileText, + Clock, + User, + MessageSquare, + AlertCircle, + CheckCircle, +} from "lucide-react"; +import { format, formatDistanceToNow } from "date-fns"; +import { ko } from "date-fns/locale"; +import { toast } from "sonner"; +import { downloadFile } from "@/lib/file-download"; +import { + getRevisionHistory, + type AttachmentWithHistory, + type RevisionHistory, +} from "../service"; +import { formatFileSize } from "@/lib/utils"; + +interface RevisionHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + attachmentId: number; + attachmentName?: string; +} + +export function RevisionHistoryDialog({ + open, + onOpenChange, + attachmentId, + attachmentName, +}: RevisionHistoryDialogProps) { + const [loading, setLoading] = React.useState(false); + const [historyData, setHistoryData] = React.useState(null); + const [error, setError] = React.useState(null); + + // 다이얼로그가 열릴 때 데이터 로드 + React.useEffect(() => { + if (open && attachmentId) { + loadRevisionHistory(); + } + }, [open, attachmentId]); + + const loadRevisionHistory = async () => { + setLoading(true); + setError(null); + try { + const result = await getRevisionHistory(attachmentId); + if (result.success && result.data) { + setHistoryData(result.data); + } else { + setError(result.error || "리비전 히스토리를 불러올 수 없습니다."); + } + } catch (err) { + console.error("Load revision history error:", err); + setError("리비전 히스토리 조회 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + // 리비전 다운로드 + const handleDownloadRevision = async (revision: RevisionHistory) => { + try { + await downloadFile(revision.filePath, revision.originalFileName, { + action: 'download', + showToast: true, + }); + } catch (err) { + console.error("Download revision error:", err); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } + }; + + // 리비전 미리보기 + const handlePreviewRevision = async (revision: RevisionHistory) => { + try { + await downloadFile(revision.filePath, revision.originalFileName, { + action: 'preview', + showToast: true, + }); + } catch (err) { + console.error("Preview revision error:", err); + toast.error("파일 미리보기 중 오류가 발생했습니다."); + } + }; + + // 리비전 번호에 따른 색상 결정 + const getRevisionBadgeVariant = (isLatest: boolean) => { + return isLatest ? "default" : "secondary"; + }; + + return ( + + + + + + 리비전 히스토리 + + + {historyData?.originalFileName || attachmentName || "파일"}의 모든 버전 히스토리를 확인할 수 있습니다. + + + +
+ {loading ? ( +
+ + + +
+ ) : error ? ( + + + {error} + + ) : historyData ? ( + <> + {/* 파일 정보 헤더 */} +
+
+
+ 일련번호:{" "} + {historyData.serialNo || "-"} +
+
+ 현재 리비전:{" "} + + Rev. {historyData.currentRevision || "A"} + +
+ {historyData.description && ( +
+ 설명:{" "} + {historyData.description} +
+ )} +
+
+ + {/* 리비전 테이블 */} + + + + + 리비전 + 파일명 + 크기 + 업로드자 + 업로드일시 + 코멘트 + 작업 + + + + {historyData.revisions.length > 0 ? ( + historyData.revisions.map((revision) => ( + + + + Rev. {revision.revisionNo} + {revision.isLatest && ( + + )} + + + +
+ + {revision.originalFileName} + + {revision.fileName !== revision.originalFileName && ( + + ({revision.fileName}) + + )} +
+
+ + + {formatFileSize(revision.fileSize)} + + + +
+ + + {revision.createdByName || "Unknown"} + +
+
+ +
+ + + {format(new Date(revision.createdAt), "yyyy-MM-dd HH:mm")} + +
+ + {formatDistanceToNow(new Date(revision.createdAt), { + addSuffix: true, + locale: ko, + })} + +
+ + {revision.revisionComment ? ( +
+ + + {revision.revisionComment} + +
+ ) : ( + - + )} +
+ +
+ + +
+
+
+ )) + ) : ( + + + 리비전 히스토리가 없습니다. + + + )} +
+
+
+ + {/* 요약 정보 */} +
+ 총 {historyData.revisions.length}개의 리비전 + + 최초 업로드:{" "} + {historyData.revisions.length > 0 + ? format( + new Date( + historyData.revisions[historyData.revisions.length - 1].createdAt + ), + "yyyy년 MM월 dd일" + ) + : "-"} + +
+ + ) : null} +
+
+
+ ); +} \ No newline at end of file diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx index a66e12a2..155fd412 100644 --- a/lib/rfq-last/attachment/rfq-attachments-table.tsx +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -1,7 +1,6 @@ "use client"; import * as React from "react"; -import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent } from "@/components/ui/card"; @@ -24,10 +23,8 @@ import { format, formatDistanceToNow } from "date-fns"; import { ko } from "date-fns/locale"; import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; -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 { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -43,16 +40,16 @@ import { } from "@/components/ui/tooltip"; import type { DataTableAdvancedFilterField, - DataTableFilterField, DataTableRowAction, } from "@/types/table"; import { cn } from "@/lib/utils"; -import { getRfqLastAttachments } from "@/lib/rfq-last/service"; +import { getRfqAllAttachments } from "@/lib/rfq-last/service"; import { downloadFile } from "@/lib/file-download"; import { DeleteAttachmentsDialog } from "./delete-attachments-dialog"; import { AddAttachmentDialog } from "./add-attachment-dialog"; import { UpdateRevisionDialog } from "./update-revision-dialog"; -import { useQueryState ,parseAsStringEnum} from "nuqs"; +import { toast } from "sonner"; +import { RevisionHistoryDialog } from "./revision-historty-dialog"; // 타입 정의 interface RfqAttachment { @@ -77,9 +74,7 @@ interface RfqAttachment { interface RfqAttachmentsTableProps { rfqId: number; - initialDesignData: Awaited>; - initialPurchaseData: Awaited>; - className?: string; + initialData: RfqAttachment[]; } // 파일 타입별 아이콘 반환 @@ -112,31 +107,41 @@ const formatFileSize = (bytes: number | null) => { export function RfqAttachmentsTable({ rfqId, - initialDesignData, - initialPurchaseData, - className + initialData, }: RfqAttachmentsTableProps) { - const router = useRouter(); - const [activeTab, setActiveTab] = useQueryState( - 'tab', - parseAsStringEnum(['설계', '구매']) - .withDefault('설계') - .withOptions({ shallow: false }) - ); - - const [designData] = React.useState(initialDesignData); - const [purchaseData] = React.useState(initialPurchaseData); + const [activeTab, setActiveTab] = React.useState<'설계' | '구매'>('설계'); + const [data, setData] = React.useState(initialData); const [selectedAttachment, setSelectedAttachment] = React.useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [updateRevisionDialogOpen, setUpdateRevisionDialogOpen] = React.useState(false); + const [revisionHistoryDialogOpen, setRevisionHistoryDialogOpen] = React.useState(false); + const [addDialogOpen, setAddDialogOpen] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState([]); + + // 탭에 따른 데이터 필터링 + const filteredData = React.useMemo(() => { + return data.filter(item => item.attachmentType === activeTab); + }, [data, activeTab]); - // 새로고침 (router.refresh 사용) - const handleRefresh = React.useCallback(() => { + // 데이터 새로고침 + const handleRefresh = React.useCallback(async () => { setIsRefreshing(true); - router.refresh(); - setTimeout(() => setIsRefreshing(false), 1000); - }, [router]); + try { + const result = await getRfqAllAttachments(rfqId); + if (result.success && result.data) { + setData(result.data); + toast.success("데이터를 새로고침했습니다."); + } else { + toast.error("데이터를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Refresh error:", error); + toast.error("새로고침 중 오류가 발생했습니다."); + } finally { + setIsRefreshing(false); + } + }, [rfqId]); // 액션 처리 const handleAction = React.useCallback(async (action: DataTableRowAction) => { @@ -162,8 +167,8 @@ export function RfqAttachmentsTable({ break; case "history": - // 리비전 이력 보기 - 별도 구현 필요 - console.log("History:", attachment); + setSelectedAttachment(attachment); + setRevisionHistoryDialogOpen(true); break; case "update": @@ -178,10 +183,35 @@ export function RfqAttachmentsTable({ } }, []); + // 선택된 항목 일괄 삭제 + const handleBulkDelete = React.useCallback(() => { + if (selectedRows.length === 0) { + toast.warning("삭제할 항목을 선택해주세요."); + return; + } + setDeleteDialogOpen(true); + }, [selectedRows]); + + // 선택된 항목 일괄 다운로드 + const handleBulkDownload = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("다운로드할 항목을 선택해주세요."); + return; + } + + for (const attachment of selectedRows) { + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: false + }); + } + } + toast.success(`${selectedRows.length}개 파일을 다운로드했습니다.`); + }, [selectedRows]); + // 컬럼 정의 - const getAttachmentColumns = React.useCallback(( - onAction: (action: DataTableRowAction) => void - ): ColumnDef[] => [ + const columns: ColumnDef[] = React.useMemo(() => [ { id: "select", header: ({ table }) => ( @@ -203,18 +233,21 @@ export function RfqAttachmentsTable({ size: 40, enableSorting: false, enableHiding: false, + enablePinning: true, }, { accessorKey: "serialNo", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => ( {row.original.serialNo || "-"} ), size: 100, + meta: { excelHeader: "일련번호" }, + enablePinning: true, }, { accessorKey: "originalFileName", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const file = row.original; return ( @@ -224,11 +257,6 @@ export function RfqAttachmentsTable({ {file.originalFileName || file.fileName || "-"} - {file.fileName && file.fileName !== file.originalFileName && ( - - ({file.fileName}) - - )}
); @@ -237,7 +265,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "description", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => (
{row.original.description || "-"} @@ -247,7 +275,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "currentRevision", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const revision = row.original.currentRevision; return revision ? ( @@ -262,7 +290,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "fileSize", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => ( {formatFileSize(row.original.fileSize)} @@ -270,30 +298,15 @@ export function RfqAttachmentsTable({ ), size: 80, }, - { - accessorKey: "fileType", - header: ({ column }) => , - cell: ({ row }) => { - const type = row.original.fileType; - return type ? ( - - {type.toUpperCase()} - - ) : ( - - - ); - }, - size: 80, - }, { accessorKey: "createdByName", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => row.original.createdByName || "-", size: 100, }, { accessorKey: "createdAt", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const date = row.original.createdAt; return date ? ( @@ -320,7 +333,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "updatedAt", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const date = row.original.updatedAt; return date ? format(new Date(date), "MM-dd HH:mm") : "-"; @@ -342,26 +355,26 @@ export function RfqAttachmentsTable({ - onAction({ row, type: "download" })}> + handleAction({ row, type: "download" })}> 다운로드 - onAction({ row, type: "preview" })}> + handleAction({ row, type: "preview" })}> 미리보기 - onAction({ row, type: "history" })}> + handleAction({ row, type: "history" })}> 리비전 이력 - onAction({ row, type: "update" })}> + handleAction({ row, type: "update" })}> 새 버전 업로드 onAction({ row, type: "delete" })} + onClick={() => handleAction({ row, type: "delete" })} className="text-red-600" > @@ -372,17 +385,9 @@ export function RfqAttachmentsTable({ ); }, size: 60, + enablePinning: true, }, - ], []); - - const columns = React.useMemo(() => getAttachmentColumns(handleAction), [getAttachmentColumns, handleAction]); - - const filterFields: DataTableFilterField[] = [ - { id: "serialNo", label: "일련번호" }, - { id: "originalFileName", label: "파일명" }, - { id: "description", label: "설명" }, - { id: "createdByName", label: "업로드자" }, - ]; + ], [handleAction]); const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "serialNo", label: "일련번호", type: "text" }, @@ -406,121 +411,136 @@ export function RfqAttachmentsTable({ { id: "updatedAt", label: "수정일", type: "date" }, ]; - const { table: designTable } = useDataTable({ - data: designData.data, - columns, - pageCount: designData.pageCount, - rowCount: designData.data.length, - filterFields, - enableAdvancedFilter: true, - // 설계 탭용 파라미터 prefix - paramPrefix: 'design_', - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - - const { table: purchaseTable } = useDataTable({ - data: purchaseData.data, - columns, - pageCount: purchaseData.pageCount, - rowCount: purchaseData.data.length, - filterFields, - enableAdvancedFilter: true, - // 구매 탭용 파라미터 prefix - paramPrefix: 'purchase_', - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - + // 탭별 데이터 카운트 + const designCount = React.useMemo(() => + data.filter(item => item.attachmentType === "설계").length, [data] + ); + const purchaseCount = React.useMemo(() => + data.filter(item => item.attachmentType === "구매").length, [data] + ); - React.useEffect(() => { - router.refresh(); - }, [activeTab]); + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( +
+ {selectedRows.length > 0 && ( + <> + + + + )} + + + {/* 구매 탭에서만 파일 업로드 버튼 표시 */} + {activeTab === "구매" && ( + + )} +
+ ), [selectedRows, activeTab, isRefreshing, addDialogOpen, handleBulkDownload, handleBulkDelete, handleRefresh, rfqId]); return ( -
- +
+ setActiveTab(value as '설계' | '구매')} + >
설계 첨부파일 - {designData.data.length} + {designCount} 구매 첨부파일 - {purchaseData.data.length} + {purchaseCount} - -
- - - {/* 구매 탭에서만 파일 업로드 버튼 표시 */} - {activeTab === "구매" && ( - - )} -
- - - - - - - + + + {additionalActions} + + - - - - - - - + + + {additionalActions} + +
{/* 삭제 다이얼로그 */} - {selectedAttachment && ( + {(selectedAttachment || selectedRows.length > 0) && ( { + setDeleteDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachments={selectedAttachment ? [selectedAttachment] : selectedRows} onSuccess={handleRefresh} /> )} @@ -529,11 +549,31 @@ export function RfqAttachmentsTable({ {selectedAttachment && ( { + setUpdateRevisionDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} attachment={selectedAttachment} onSuccess={handleRefresh} /> )} + + {/* 리비전 히스토리 다이얼로그 */} + {selectedAttachment && ( + { + setRevisionHistoryDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachmentId={selectedAttachment.id} + attachmentName={selectedAttachment.originalFileName || selectedAttachment.fileName || undefined} + /> + )}
); } \ No newline at end of file diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx new file mode 100644 index 00000000..6e1a02c8 --- /dev/null +++ b/lib/rfq-last/attachment/vendor-response-table.tsx @@ -0,0 +1,519 @@ +// @/lib/rfq-last/vendor/vendor-response-table.tsx + +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Download, + FileText, + RefreshCw, + Eye, + Trash2, + File, + FileImage, + FileSpreadsheet, + FileCode, + Building2, + Calendar, + AlertCircle +} from "lucide-react"; +import { format, formatDistanceToNow, isValid, isBefore, isAfter } from "date-fns"; +import { ko } from "date-fns/locale"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table"; +import { cn } from "@/lib/utils"; +import { getRfqVendorAttachments } from "@/lib/rfq-last/service"; +import { downloadFile } from "@/lib/file-download"; +import { toast } from "sonner"; + +// 타입 정의 +interface VendorAttachment { + id: number; + vendorResponseId: number; + attachmentType: string; + documentNo: string | null; + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number | null; + fileType: string | null; + description: string | null; + validFrom: Date | null; + validTo: Date | null; + uploadedBy: number; + uploadedAt: Date; + uploadedByName: string | null; + vendorId: number | null; + vendorName: string | null; + vendorCode: string | null; + responseStatus: "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소" | null; + responseVersion: number | null; +} + +interface VendorResponseTableProps { + rfqId: number; + initialData: VendorAttachment[]; +} + +// 파일 타입별 아이콘 반환 +const getFileIcon = (fileType: string | null) => { + if (!fileType) return ; + + const type = fileType.toLowerCase(); + if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) { + return ; + } + if (type.includes('excel') || type.includes('spreadsheet') || ['xls', 'xlsx'].includes(type)) { + return ; + } + if (type.includes('pdf')) { + return ; + } + if (type.includes('code') || ['js', 'ts', 'tsx', 'jsx', 'html', 'css'].includes(type)) { + return ; + } + return ; +}; + +// 파일 크기 포맷팅 +const formatFileSize = (bytes: number | null) => { + if (!bytes) return "-"; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; +}; + +// 응답 상태별 색상 +const getStatusVariant = (status: string | null) => { + switch (status) { + case "작성중": return "outline"; + case "제출완료": return "default"; + case "수정요청": return "secondary"; + case "최종확정": return "success"; + case "취소": return "destructive"; + default: return "outline"; + } +}; + +// 유효기간 체크 +const checkValidity = (validTo: Date | null) => { + if (!validTo) return null; + const today = new Date(); + const expiry = new Date(validTo); + + if (isBefore(expiry, today)) { + return "expired"; + } else if (isBefore(expiry, new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000))) { + return "expiring-soon"; // 30일 이내 만료 + } + return "valid"; +}; + +export function VendorResponseTable({ + rfqId, + initialData, +}: VendorResponseTableProps) { + const [data, setData] = React.useState(initialData); + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState([]); + + // 데이터 새로고침 + const handleRefresh = React.useCallback(async () => { + setIsRefreshing(true); + try { + const result = await getRfqVendorAttachments(rfqId); + if (result.success && result.data) { + setData(result.data); + toast.success("데이터를 새로고침했습니다."); + } else { + toast.error("데이터를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Refresh error:", error); + toast.error("새로고침 중 오류가 발생했습니다."); + } finally { + setIsRefreshing(false); + } + }, [rfqId]); + + // 액션 처리 + const handleAction = React.useCallback(async (action: DataTableRowAction) => { + const attachment = action.row.original; + + switch (action.type) { + case "download": + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: true + }); + } + break; + + case "preview": + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'preview', + showToast: true + }); + } + break; + } + }, []); + + // 선택된 항목 일괄 다운로드 + const handleBulkDownload = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("다운로드할 항목을 선택해주세요."); + return; + } + + for (const attachment of selectedRows) { + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: false + }); + } + } + toast.success(`${selectedRows.length}개 파일을 다운로드했습니다.`); + }, [selectedRows]); + + // 컬럼 정의 + const columns: ColumnDef[] = React.useMemo(() => [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + enablePinning: true, + }, + { + accessorKey: "vendorName", + header: ({ column }) => , + cell: ({ row }) => { + const vendor = row.original; + return ( +
+ +
+ {vendor.vendorName || "-"} + {vendor.vendorCode} +
+
+ ); + }, + size: 150, + enablePinning: true, + }, + { + accessorKey: "attachmentType", + header: ({ column }) => , + cell: ({ row }) => { + const type = row.original.attachmentType; + return ( + + {type} + + ); + }, + size: 100, + }, + { + accessorKey: "documentNo", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.documentNo || "-"} + ), + size: 120, + }, + { + accessorKey: "originalFileName", + header: ({ column }) => , + cell: ({ row }) => { + const file = row.original; + return ( +
+ {getFileIcon(file.fileType)} +
+ + {file.originalFileName || file.fileName || "-"} + +
+
+ ); + }, + size: 300, + }, + { + accessorKey: "description", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.description || "-"} +
+ ), + size: 200, + }, + { + accessorKey: "validTo", + header: ({ column }) => , + cell: ({ row }) => { + const { validFrom, validTo } = row.original; + const validity = checkValidity(validTo); + + if (!validTo) return -; + + return ( + + + +
+ {validity === "expired" && ( + + )} + {validity === "expiring-soon" && ( + + )} + + {format(new Date(validTo), "yyyy-MM-dd")} + +
+
+ +

유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}

+ {validity === "expired" &&

만료됨

} + {validity === "expiring-soon" &&

곧 만료 예정

} +
+
+
+ ); + }, + size: 120, + }, + { + accessorKey: "responseStatus", + header: ({ column }) => , + cell: ({ row }) => { + const status = row.original.responseStatus; + return status ? ( + + {status} + + ) : ( + - + ); + }, + size: 100, + }, + { + accessorKey: "fileSize", + header: ({ column }) => , + cell: ({ row }) => ( + + {formatFileSize(row.original.fileSize)} + + ), + size: 80, + }, + { + accessorKey: "uploadedAt", + header: ({ column }) => , + cell: ({ row }) => { + const date = row.original.uploadedAt; + return date ? ( + + + + + {format(new Date(date), "MM-dd HH:mm")} + + + +

{format(new Date(date), "yyyy년 MM월 dd일 HH시 mm분")}

+

+ ({formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko })}) +

+
+
+
+ ) : ( + "-" + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + return ( + + + + + + handleAction({ row, type: "download" })}> + + 다운로드 + + handleAction({ row, type: "preview" })}> + + 미리보기 + + + + ); + }, + size: 60, + enablePinning: true, + }, + ], [handleAction]); + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "vendorCode", label: "벤더코드", type: "text" }, + { + id: "attachmentType", + label: "문서 유형", + type: "select", + options: [ + { label: "견적서", value: "견적서" }, + { label: "기술제안서", value: "기술제안서" }, + { label: "인증서", value: "인증서" }, + { label: "카탈로그", value: "카탈로그" }, + { label: "도면", value: "도면" }, + { label: "테스트성적서", value: "테스트성적서" }, + { label: "기타", value: "기타" }, + ] + }, + { id: "documentNo", label: "문서번호", type: "text" }, + { id: "originalFileName", label: "파일명", type: "text" }, + { id: "description", label: "설명", type: "text" }, + { + id: "responseStatus", + label: "응답 상태", + type: "select", + options: [ + { label: "작성중", value: "작성중" }, + { label: "제출완료", value: "제출완료" }, + { label: "수정요청", value: "수정요청" }, + { label: "최종확정", value: "최종확정" }, + { label: "취소", value: "취소" }, + ] + }, + { id: "validFrom", label: "유효시작일", type: "date" }, + { id: "validTo", label: "유효종료일", type: "date" }, + { id: "uploadedAt", label: "업로드일", type: "date" }, + ]; + + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( +
+ {selectedRows.length > 0 && ( + + )} + +
+ ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh]); + + // 벤더별 그룹 카운트 + const vendorCounts = React.useMemo(() => { + const counts = new Map(); + data.forEach(item => { + const vendor = item.vendorName || "Unknown"; + counts.set(vendor, (counts.get(vendor) || 0) + 1); + }); + return counts; + }, [data]); + + return ( +
+ {/* 벤더별 요약 정보 */} +
+ {Array.from(vendorCounts.entries()).map(([vendor, count]) => ( + + {vendor}: {count} + + ))} +
+ + + {additionalActions} + +
+ ); +} \ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index ffeed1b1..67cb901f 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -1,12 +1,14 @@ // lib/rfq/service.ts 'use server' -import { unstable_cache, unstable_noStore } from "next/cache"; +import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView } from "@/db/schema"; -import {sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; +import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView} from "@/db/schema"; +import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" export async function getRfqs(input: GetRfqsSchema) { unstable_noStore(); @@ -172,68 +174,57 @@ export const findRfqLastById = async (id: number): Promise }; -export async function getRfqLastAttachments( - input: GetRfqLastAttachmentsSchema, - rfqId: number, - attachmentType: "설계" | "구매" -) { +// 모든 첨부파일을 가져오는 새로운 서버 액션 +export async function getRfqAllAttachments(rfqId: number) { try { - const offset = (input.page - 1) * input.perPage - - // Advanced Filter 처리 (메인 테이블 기준) - const advancedWhere = filterColumns({ - table: rfqLastAttachments, - filters: input.filters, - joinOperator: input.joinOperator, - }) - - // 전역 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - ilike(rfqLastAttachments.serialNo, s), - ilike(rfqLastAttachments.description, s), - ilike(rfqLastAttachments.currentRevision, s), - ilike(rfqLastAttachmentRevisions.fileName, s), - ilike(rfqLastAttachmentRevisions.originalFileName, s) + // 데이터 조회 + const data = await db + .select({ + // 첨부파일 메인 정보 + id: rfqLastAttachments.id, + attachmentType: rfqLastAttachments.attachmentType, + serialNo: rfqLastAttachments.serialNo, + rfqId: rfqLastAttachments.rfqId, + currentRevision: rfqLastAttachments.currentRevision, + latestRevisionId: rfqLastAttachments.latestRevisionId, + description: rfqLastAttachments.description, + createdBy: rfqLastAttachments.createdBy, + createdAt: rfqLastAttachments.createdAt, + updatedAt: rfqLastAttachments.updatedAt, + + // 최신 리비전 파일 정보 + fileName: rfqLastAttachmentRevisions.fileName, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + revisionComment: rfqLastAttachmentRevisions.revisionComment, + + // 생성자 정보 + createdByName: users.name, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) ) - } + .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id)) + .where(eq(rfqLastAttachments.rfqId, rfqId)) + .orderBy(desc(rfqLastAttachments.createdAt)) - // 파일 타입 필터 - let fileTypeWhere - if (input.fileType && input.fileType.length > 0) { - fileTypeWhere = inArray(rfqLastAttachmentRevisions.fileType, input.fileType) + return { + data, + success: true } - - // 최종 WHERE 절 - const finalWhere = and( - eq(rfqLastAttachments.rfqId, rfqId), - eq(rfqLastAttachments.attachmentType, attachmentType), - advancedWhere, - globalWhere, - fileTypeWhere - ) - - // 정렬 - const orderBy = input.sort.length > 0 - ? input.sort.map((item) => - item.desc - ? desc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) - : asc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) - ) - : [desc(rfqLastAttachments.createdAt)] - - // 데이터 조회 (기존 코드와 동일) - const { data, total } = await db.transaction(async (tx) => { - // ... 기존 조회 로직 - }) - - const pageCount = Math.ceil(total / input.perPage) - return { data, pageCount } } catch (err) { - console.error("getRfqAttachments error:", err) - return { data: [], pageCount: 0 } + console.error("getRfqAllAttachments error:", err) + return { + data: [], + success: false + } } } // 사용자 목록 조회 (필터용) @@ -689,3 +680,1159 @@ export async function getRfqBasicInfoAction(rfqId: number) { } } +export interface RevisionHistory { + id: number; + attachmentId: number; + revisionNo: string; + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number; + fileType: string; + isLatest: boolean; + revisionComment: string | null; + createdBy: number; + createdAt: Date; + createdByName: string | null; +} + +export interface AttachmentWithHistory { + id: number; + serialNo: string | null; + description: string | null; + currentRevision: string | null; + originalFileName: string | null; + revisions: RevisionHistory[]; +} + +// 리비전 히스토리 조회 +export async function getRevisionHistory(attachmentId: number): Promise<{ + success: boolean; + data?: AttachmentWithHistory; + error?: string; +}> { + try { + // 첨부파일 기본 정보 조회 + const [attachment] = await db + .select({ + id: rfqLastAttachments.id, + serialNo: rfqLastAttachments.serialNo, + description: rfqLastAttachments.description, + currentRevision: rfqLastAttachments.currentRevision, + latestRevisionId: rfqLastAttachments.latestRevisionId, + }) + .from(rfqLastAttachments) + .where(eq(rfqLastAttachments.id, attachmentId)); + + if (!attachment) { + return { + success: false, + error: "첨부파일을 찾을 수 없습니다.", + }; + } + + // 최신 리비전 정보 조회 (파일명 가져오기 위해) + let originalFileName: string | null = null; + if (attachment.latestRevisionId) { + const [latestRevision] = await db + .select({ + originalFileName: rfqLastAttachmentRevisions.originalFileName, + }) + .from(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.id, attachment.latestRevisionId)); + + originalFileName = latestRevision?.originalFileName || null; + } + + // 모든 리비전 히스토리 조회 + const revisions = await db + .select({ + id: rfqLastAttachmentRevisions.id, + attachmentId: rfqLastAttachmentRevisions.attachmentId, + revisionNo: rfqLastAttachmentRevisions.revisionNo, + fileName: rfqLastAttachmentRevisions.fileName, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + isLatest: rfqLastAttachmentRevisions.isLatest, + revisionComment: rfqLastAttachmentRevisions.revisionComment, + createdBy: rfqLastAttachmentRevisions.createdBy, + createdAt: rfqLastAttachmentRevisions.createdAt, + createdByName: users.name, + }) + .from(rfqLastAttachmentRevisions) + .leftJoin(users, eq(rfqLastAttachmentRevisions.createdBy, users.id)) + .where(eq(rfqLastAttachmentRevisions.attachmentId, attachmentId)) + .orderBy(desc(rfqLastAttachmentRevisions.createdAt)); + + return { + success: true, + data: { + ...attachment, + originalFileName, + revisions, + }, + }; + } catch (error) { + console.error("Get revision history error:", error); + return { + success: false, + error: "리비전 히스토리 조회 중 오류가 발생했습니다.", + }; + } +} + +// 특정 리비전 다운로드 URL 생성 +export async function getRevisionDownloadUrl(revisionId: number): Promise<{ + success: boolean; + data?: { + url: string; + fileName: string; + }; + error?: string; +}> { + try { + const [revision] = await db + .select({ + filePath: rfqLastAttachmentRevisions.filePath, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + }) + .from(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.id, revisionId)); + + if (!revision) { + return { + success: false, + error: "리비전을 찾을 수 없습니다.", + }; + } + + return { + success: true, + data: { + url: revision.filePath, + fileName: revision.originalFileName, + }, + }; + } catch (error) { + console.error("Get revision download URL error:", error); + return { + success: false, + error: "다운로드 URL 생성 중 오류가 발생했습니다.", + }; + } +} + +export async function getRfqVendorAttachments(rfqId: number) { + try { + // 데이터 조회 + const data = await db + .select({ + // 첨부파일 메인 정보 + id: rfqLastVendorAttachments.id, + vendorResponseId: rfqLastVendorAttachments.vendorResponseId, + attachmentType: rfqLastVendorAttachments.attachmentType, + documentNo: rfqLastVendorAttachments.documentNo, + + // 파일 정보 + fileName: rfqLastVendorAttachments.fileName, + originalFileName: rfqLastVendorAttachments.originalFileName, + filePath: rfqLastVendorAttachments.filePath, + fileSize: rfqLastVendorAttachments.fileSize, + fileType: rfqLastVendorAttachments.fileType, + + // 파일 설명 + description: rfqLastVendorAttachments.description, + + // 유효기간 + validFrom: rfqLastVendorAttachments.validFrom, + validTo: rfqLastVendorAttachments.validTo, + + // 업로드 정보 + uploadedBy: rfqLastVendorAttachments.uploadedBy, + uploadedAt: rfqLastVendorAttachments.uploadedAt, + + // 업로더 정보 + uploadedByName: users.name, + + // 벤더 정보 + vendorId: rfqLastVendorResponses.vendorId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + + // 응답 상태 + responseStatus: rfqLastVendorResponses.status, + responseVersion: rfqLastVendorResponses.responseVersion, + }) + .from(rfqLastVendorAttachments) + .leftJoin( + rfqLastVendorResponses, + eq(rfqLastVendorAttachments.vendorResponseId, rfqLastVendorResponses.id) + ) + .leftJoin(users, eq(rfqLastVendorAttachments.uploadedBy, users.id)) + .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id)) + .where(eq(rfqLastVendorResponses.rfqsLastId, rfqId)) + .orderBy(desc(rfqLastVendorAttachments.uploadedAt)) + + return { + vendorData, + vendorSuccess: true + } + } catch (err) { + console.error("getRfqVendorAttachments error:", err) + return { + vendorData: [], + vendorSuccess: false + } + } +} + + + +// 벤더 추가 액션 +export async function addVendorToRfq({ + rfqId, + vendorId, + conditions, +}: { + rfqId: number; + vendorId: number; + conditions: { + currency: string; + paymentTermsCode: string; + incotermsCode: string; + incotermsDetail?: string; + deliveryDate: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + }; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + // 중복 체크 + const existing = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ) + .limit(1); + + if (existing.length > 0) { + return { success: false, error: "이미 추가된 벤더입니다." }; + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. rfqLastDetails에 벤더 추가 + const [detail] = await tx + .insert(rfqLastDetails) + .values({ + rfqsLastId: rfqId, + vendorsId: vendorId, + ...conditions, + updatedBy: userId, + }) + .returning(); + + // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 + const [response] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: detail.id, + vendorId: vendorId, + status: "초대됨", + responseVersion: 1, + isLatest: true, + currency: conditions.currency, + // 구매자 제시 조건 복사 (초기값) + vendorCurrency: conditions.currency, + vendorPaymentTermsCode: conditions.paymentTermsCode, + vendorIncotermsCode: conditions.incotermsCode, + vendorIncotermsDetail: conditions.incotermsDetail, + vendorDeliveryDate: conditions.deliveryDate, + vendorContractDuration: conditions.contractDuration, + vendorTaxCode: conditions.taxCode, + vendorPlaceOfShipping: conditions.placeOfShipping, + vendorPlaceOfDestination: conditions.placeOfDestination, + vendorMaterialPriceRelatedYn: conditions.materialPriceRelatedYn, + vendorSparepartYn: conditions.sparepartYn, + vendorFirstYn: conditions.firstYn, + vendorFirstDescription: conditions.firstDescription, + vendorSparepartDescription: conditions.sparepartDescription, + createdBy: user.id, + updatedBy: user.id, + }) + .returning(); + + // 3. 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "생성", + newStatus: "초대됨", + changeDetails: { action: "벤더 초대", conditions }, + performedBy: userId, + }); + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Add vendor error:", error); + return { success: false, error: "벤더 추가 중 오류가 발생했습니다." }; + } +} + +export async function addVendorsToRfq({ + rfqId, + vendorIds, + conditions, +}: { + rfqId: number; + vendorIds: number[]; + conditions?: { + currency: string; + paymentTermsCode: string; + incotermsCode: string; + incotermsDetail?: string; + deliveryDate: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + } | null; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + // 빈 배열 체크 + if (!vendorIds || vendorIds.length === 0) { + return { success: false, error: "벤더를 선택해주세요." }; + } + + // 중복 체크 - 이미 추가된 벤더들 확인 + const existingVendors = await db + .select({ + vendorId: rfqLastDetails.vendorsId, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + inArray(rfqLastDetails.vendorsId, vendorIds) + ) + ); + + const existingVendorIds = existingVendors.map(v => v.vendorId); + const newVendorIds = vendorIds.filter(id => !existingVendorIds.includes(id)); + + if (newVendorIds.length === 0) { + return { + success: false, + error: "모든 벤더가 이미 추가되어 있습니다." + }; + } + + // 일부만 중복인 경우 경고 메시지 준비 + const skippedCount = vendorIds.length - newVendorIds.length; + + // 트랜잭션으로 처리 + const results = await db.transaction(async (tx) => { + const addedVendors = []; + + for (const vendorId of newVendorIds) { + // conditions가 없는 경우 기본값 설정 + const vendorConditions = conditions || { + currency: "USD", + paymentTermsCode: "NET30", + incotermsCode: "FOB", + deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후 + taxCode: "VV", + }; + + // 1. rfqLastDetails에 벤더 추가 + const [detail] = await tx + .insert(rfqLastDetails) + .values({ + rfqsLastId: rfqId, + vendorsId: vendorId, + ...vendorConditions, + updatedBy: userId, + }) + .returning(); + + // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 + const [response] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: detail.id, + vendorId: vendorId, + status: "초대됨", + responseVersion: 1, + isLatest: true, + currency: vendorConditions.currency, + // 구매자 제시 조건 복사 (초기값) + vendorCurrency: vendorConditions.currency, + vendorPaymentTermsCode: vendorConditions.paymentTermsCode, + vendorIncotermsCode: vendorConditions.incotermsCode, + vendorIncotermsDetail: vendorConditions.incotermsDetail, + vendorDeliveryDate: vendorConditions.deliveryDate, + vendorContractDuration: vendorConditions.contractDuration, + vendorTaxCode: vendorConditions.taxCode, + vendorPlaceOfShipping: vendorConditions.placeOfShipping, + vendorPlaceOfDestination: vendorConditions.placeOfDestination, + vendorMaterialPriceRelatedYn: vendorConditions.materialPriceRelatedYn, + vendorSparepartYn: vendorConditions.sparepartYn, + vendorFirstYn: vendorConditions.firstYn, + vendorFirstDescription: vendorConditions.firstDescription, + vendorSparepartDescription: vendorConditions.sparepartDescription, + createdBy: userId, + updatedBy: userId, + }) + .returning(); + + // 3. 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "생성", + newStatus: "초대됨", + changeDetails: { + action: "벤더 초대", + conditions: vendorConditions, + batchAdd: true, + totalVendors: newVendorIds.length + }, + performedBy: userId, + }); + + addedVendors.push({ + vendorId, + detailId: detail.id, + responseId: response.id, + }); + } + + return addedVendors; + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + // 성공 메시지 구성 + let message = `${results.length}개 벤더가 추가되었습니다.`; + if (skippedCount > 0) { + message += ` (${skippedCount}개는 이미 추가된 벤더로 제외)`; + } + + return { + success: true, + data: { + added: results.length, + skipped: skippedCount, + message, + } + }; + } catch (error) { + console.error("Add vendors error:", error); + return { + success: false, + error: "벤더 추가 중 오류가 발생했습니다." + }; + } +} + +// 벤더 조건 일괄 업데이트 함수 (추가) +export async function updateVendorConditionsBatch({ + rfqId, + vendorIds, + conditions, +}: { + rfqId: number; + vendorIds: number[]; + conditions: { + currency?: string; + paymentTermsCode?: string; + incotermsCode?: string; + incotermsDetail?: string; + deliveryDate?: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + }; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + if (!vendorIds || vendorIds.length === 0) { + return { success: false, error: "벤더를 선택해주세요." }; + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. rfqLastDetails 업데이트 + await tx + .update(rfqLastDetails) + .set({ + ...conditions, + updatedBy: userId, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + inArray(rfqLastDetails.vendorsId, vendorIds) + ) + ); + + // 2. rfqLastVendorResponses의 구매자 제시 조건도 업데이트 + const vendorConditions = Object.keys(conditions).reduce((acc, key) => { + if (conditions[key] !== undefined) { + acc[`vendor${key.charAt(0).toUpperCase() + key.slice(1)}`] = conditions[key]; + } + return acc; + }, {}); + + await tx + .update(rfqLastVendorResponses) + .set({ + ...vendorConditions, + updatedBy: userId, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + inArray(rfqLastVendorResponses.vendorId, vendorIds), + eq(rfqLastVendorResponses.isLatest, true) + ) + ); + + // 3. 이력 기록 (각 벤더별로) + const responses = await tx + .select({ id: rfqLastVendorResponses.id }) + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + inArray(rfqLastVendorResponses.vendorId, vendorIds), + eq(rfqLastVendorResponses.isLatest, true) + ) + ); + + for (const response of responses) { + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "조건변경", + changeDetails: { + action: "조건 일괄 업데이트", + conditions, + batchUpdate: true, + totalVendors: vendorIds.length + }, + performedBy: userId, + }); + } + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { + success: true, + data: { + message: `${vendorIds.length}개 벤더의 조건이 업데이트되었습니다.` + } + }; + } catch (error) { + console.error("Update vendor conditions error:", error); + return { + success: false, + error: "조건 업데이트 중 오류가 발생했습니다." + }; + } +} + +// RFQ 발송 액션 +export async function sendRfqToVendors({ + rfqId, + vendorIds, +}: { + rfqId: number; + vendorIds: number[]; +}) { + try { + + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + const userId = Number(session.user.id) + + // 벤더별 응답 상태 업데이트 + for (const vendorId of vendorIds) { + const [response] = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1); + + if (response) { + // 상태 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + status: "작성중", + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(rfqLastVendorResponses.id, response.id)); + + // 이력 기록 + await db.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "발송", + previousStatus: response.status, + newStatus: "작성중", + changeDetails: { action: "RFQ 발송" }, + performedBy: userId, + }); + } + } + + // TODO: 실제 이메일 발송 로직 + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true, count: vendorIds.length }; + } catch (error) { + console.error("Send RFQ error:", error); + return { success: false, error: "RFQ 발송 중 오류가 발생했습니다." }; + } +} + +// 벤더 삭제 액션 +export async function removeVendorFromRfq({ + rfqId, + vendorId, +}: { + rfqId: number; + vendorId: number; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + // 응답 체크 + const [response] = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1); + + if (response && response.status !== "초대됨") { + return { + success: false, + error: "이미 진행 중인 벤더는 삭제할 수 없습니다." + }; + } + + // 삭제 + await db + .delete(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Remove vendor error:", error); + return { success: false, error: "벤더 삭제 중 오류가 발생했습니다." }; + } +} + +// 벤더 응답 상태 업데이트 +export async function updateVendorResponseStatus({ + responseId, + status, + reason, +}: { + responseId: number; + status: "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + reason?: string; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + + const [current] = await db + .select() + .from(rfqLastVendorResponses) + .where(eq(rfqLastVendorResponses.id, responseId)) + .limit(1); + + if (!current) { + return { success: false, error: "응답을 찾을 수 없습니다." }; + } + + // 상태 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + status, + submittedAt: status === "제출완료" ? new Date() : current.submittedAt, + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(rfqLastVendorResponses.id, responseId)); + + // 이력 기록 + await db.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: responseId, + action: getActionFromStatus(status), + previousStatus: current.status, + newStatus: status, + changeReason: reason, + performedBy: Number(session.user.id), + }); + + revalidatePath(`/evcp/rfq-last/${current.rfqsLastId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Update status error:", error); + return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }; + } +} + +// 상태에 따른 액션 텍스트 +function getActionFromStatus(status: string): string { + switch (status) { + case "제출완료": return "제출"; + case "수정요청": return "반려"; + case "최종확정": return "승인"; + case "취소": return "취소"; + default: return "수정"; + } +} + +export async function getRfqVendorResponses(rfqId: number) { + try { + // 1. RFQ 기본 정보 조회 + const rfqData = await db + .select({ + id: rfqsLast.id, + rfqCode: rfqsLast.rfqCode, + title: rfqsLast.title, + status: rfqsLast.status, + startDate: rfqsLast.startDate, + endDate: rfqsLast.endDate, + }) + .from(rfqsLast) + .where(eq(rfqsLast.id, rfqId)) + .limit(1); + + if (!rfqData || rfqData.length === 0) { + return { + success: false, + error: "RFQ를 찾을 수 없습니다.", + data: null + }; + } + + // 2. RFQ 세부 정보 조회 (복수 버전이 있을 수 있음) + const details = await db + .select() + .from(rfqLastDetails) + .where(eq(rfqLastDetails.rfqsLastId, rfqId)) + .orderBy(desc(rfqLastDetails.version)); + + // 3. 벤더 응답 정보 조회 (벤더 정보, 제출자 정보 포함) + const vendorResponsesData = await db + .select({ + // 응답 기본 정보 + id: rfqLastVendorResponses.id, + rfqsLastId: rfqLastVendorResponses.rfqsLastId, + rfqLastDetailsId: rfqLastVendorResponses.rfqLastDetailsId, + responseVersion: rfqLastVendorResponses.responseVersion, + isLatest: rfqLastVendorResponses.isLatest, + status: rfqLastVendorResponses.status, + + // 벤더 정보 + vendorId: rfqLastVendorResponses.vendorId, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + vendorEmail: vendors.email, + + // 제출 정보 + submittedAt: rfqLastVendorResponses.submittedAt, + submittedBy: rfqLastVendorResponses.submittedBy, + submittedByName: users.name, + + // 금액 정보 + totalAmount: rfqLastVendorResponses.totalAmount, + currency: rfqLastVendorResponses.currency, + + // 벤더 제안 조건 + vendorCurrency: rfqLastVendorResponses.vendorCurrency, + vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode, + vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode, + vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate, + vendorContractDuration: rfqLastVendorResponses.vendorContractDuration, + + // 초도품/Spare part 응답 + vendorFirstYn: rfqLastVendorResponses.vendorFirstYn, + vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance, + vendorSparepartYn: rfqLastVendorResponses.vendorSparepartYn, + vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance, + + // 비고 + generalRemark: rfqLastVendorResponses.generalRemark, + technicalProposal: rfqLastVendorResponses.technicalProposal, + + // 타임스탬프 + createdAt: rfqLastVendorResponses.createdAt, + updatedAt: rfqLastVendorResponses.updatedAt, + }) + .from(rfqLastVendorResponses) + .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id)) + .leftJoin(users, eq(rfqLastVendorResponses.submittedBy, users.id)) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.isLatest, true) // 최신 버전만 조회 + ) + ) + .orderBy(desc(rfqLastVendorResponses.createdAt)); + + // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산 + const vendorResponsesWithCounts = await Promise.all( + vendorResponsesData.map(async (response) => { + // 견적 아이템 수 조회 + const itemCount = await db + .select({ count: sql`COUNT(*)::int` }) + .from(rfqLastVendorQuotationItems) + .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)); + + // 첨부파일 수 조회 + const attachmentCount = await db + .select({ count: sql`COUNT(*)::int` }) + .from(rfqLastVendorAttachments) + .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)); + + return { + ...response, + quotedItemCount: itemCount[0]?.count || 0, + attachmentCount: attachmentCount[0]?.count || 0, + }; + }) + ); + + // 5. 응답 데이터 정리 + const formattedResponses = vendorResponsesWithCounts.map(response => ({ + id: response.id, + rfqsLastId: response.rfqsLastId, + rfqLastDetailsId: response.rfqLastDetailsId, + responseVersion: response.responseVersion, + isLatest: response.isLatest, + status: response.status || "초대됨", // 기본값 설정 + + // 벤더 정보 + vendor: { + id: response.vendorId, + code: response.vendorCode, + name: response.vendorName, + email: response.vendorEmail, + }, + + // 제출 정보 + submission: { + submittedAt: response.submittedAt, + submittedBy: response.submittedBy, + submittedByName: response.submittedByName, + }, + + // 금액 정보 + pricing: { + totalAmount: response.totalAmount, + currency: response.currency || "USD", + vendorCurrency: response.vendorCurrency, + }, + + // 벤더 제안 조건 + vendorTerms: { + paymentTermsCode: response.vendorPaymentTermsCode, + incotermsCode: response.vendorIncotermsCode, + deliveryDate: response.vendorDeliveryDate, + contractDuration: response.vendorContractDuration, + }, + + // 초도품/Spare part + additionalRequirements: { + firstArticle: { + required: response.vendorFirstYn, + acceptance: response.vendorFirstAcceptance, + }, + sparePart: { + required: response.vendorSparepartYn, + acceptance: response.vendorSparepartAcceptance, + }, + }, + + // 카운트 정보 + counts: { + quotedItems: response.quotedItemCount, + attachments: response.attachmentCount, + }, + + // 비고 + remarks: { + general: response.generalRemark, + technical: response.technicalProposal, + }, + + // 타임스탬프 + timestamps: { + createdAt: response.createdAt, + updatedAt: response.updatedAt, + }, + })); + + return { + success: true, + data: formattedResponses, + rfq: rfqData[0], + details: details, + }; + + } catch (error) { + console.error("Failed to get vendor responses:", error); + return { + success: false, + error: error instanceof Error ? error.message : "벤더 응답 정보를 가져오는데 실패했습니다.", + data: null, + }; + } +} + +export async function getRfqWithDetails(rfqId: number) { + try { + // 1. RFQ 기본 정보 조회 (rfqsLastView 활용) + const [rfqData] = await db + .select() + .from(rfqsLastView) + .where(eq(rfqsLastView.id, rfqId)); + + if (!rfqData) { + return { success: false, error: "RFQ를 찾을 수 없습니다." }; + } + + // 2. 벤더별 상세 조건 조회 (rfqLastDetailsView 활용) + const details = await db + .select() + .from(rfqLastDetailsView) + .where(eq(rfqLastDetailsView.rfqId, rfqId)) + .orderBy(desc(rfqLastDetailsView.detailId)); + + return { + success: true, + data: { + // RFQ 기본 정보 (rfqsLastView에서 제공) + id: rfqData.id, + rfqCode: rfqData.rfqCode, + rfqType: rfqData.rfqType, + rfqTitle: rfqData.rfqTitle, + series: rfqData.series, + rfqSealedYn: rfqData.rfqSealedYn, + + // ITB 관련 + projectCompany: rfqData.projectCompany, + projectFlag: rfqData.projectFlag, + projectSite: rfqData.projectSite, + smCode: rfqData.smCode, + + // PR 정보 + prNumber: rfqData.prNumber, + prIssueDate: rfqData.prIssueDate, + + // 프로젝트 정보 + projectId: rfqData.projectId, + projectCode: rfqData.projectCode, + projectName: rfqData.projectName, + + // 아이템 정보 + itemCode: rfqData.itemCode, + itemName: rfqData.itemName, + + // 패키지 정보 + packageNo: rfqData.packageNo, + packageName: rfqData.packageName, + + // 날짜 및 상태 + dueDate: rfqData.dueDate, + rfqSendDate: rfqData.rfqSendDate, + status: rfqData.status, + + // PIC 정보 + picId: rfqData.picId, + picCode: rfqData.picCode, + picName: rfqData.picName, + picUserName: rfqData.picUserName, + engPicName: rfqData.engPicName, + + // 집계 정보 (View에서 이미 계산됨) + vendorCount: rfqData.vendorCount, + shortListedVendorCount: rfqData.shortListedVendorCount, + quotationReceivedCount: rfqData.quotationReceivedCount, + prItemsCount: rfqData.prItemsCount, + majorItemsCount: rfqData.majorItemsCount, + + // 견적 제출 정보 + earliestQuotationSubmittedAt: rfqData.earliestQuotationSubmittedAt, + + // Major Item 정보 + majorItemMaterialCode: rfqData.majorItemMaterialCode, + majorItemMaterialDescription: rfqData.majorItemMaterialDescription, + majorItemMaterialCategory: rfqData.majorItemMaterialCategory, + majorItemPrNo: rfqData.majorItemPrNo, + + // 감사 정보 + createdBy: rfqData.createdBy, + createdByUserName: rfqData.createdByUserName, + createdAt: rfqData.createdAt, + sentBy: rfqData.sentBy, + sentByUserName: rfqData.sentByUserName, + updatedBy: rfqData.updatedBy, + updatedByUserName: rfqData.updatedByUserName, + updatedAt: rfqData.updatedAt, + + // 비고 + remark: rfqData.remark, + + // 벤더별 상세 조건 (rfqLastDetailsView에서 제공) + details: details.map(d => ({ + detailId: d.detailId, + + // 벤더 정보 + vendorId: d.vendorId, + vendorName: d.vendorName, + vendorCode: d.vendorCode, + vendorCountry: d.vendorCountry, + + // 조건 정보 + currency: d.currency, + paymentTermsCode: d.paymentTermsCode, + paymentTermsDescription: d.paymentTermsDescription, + incotermsCode: d.incotermsCode, + incotermsDescription: d.incotermsDescription, + incotermsDetail: d.incotermsDetail, + deliveryDate: d.deliveryDate, + contractDuration: d.contractDuration, + taxCode: d.taxCode, + placeOfShipping: d.placeOfShipping, + placeOfDestination: d.placeOfDestination, + + // Boolean 필드들 + shortList: d.shortList, + returnYn: d.returnYn, + returnedAt: d.returnedAt, + prjectGtcYn: d.prjectGtcYn, + generalGtcYn: d.generalGtcYn, + ndaYn: d.ndaYn, + agreementYn: d.agreementYn, + materialPriceRelatedYn: d.materialPriceRelatedYn, + sparepartYn: d.sparepartYn, + firstYn: d.firstYn, + + // 설명 필드 + firstDescription: d.firstDescription, + sparepartDescription: d.sparepartDescription, + remark: d.remark, + cancelReason: d.cancelReason, + + // 견적 관련 정보 (View에서 이미 계산됨) + hasQuotation: d.hasQuotation, + quotationStatus: d.quotationStatus, + quotationTotalPrice: d.quotationTotalPrice, + quotationVersion: d.quotationVersion, + quotationVersionCount: d.quotationVersionCount, + lastQuotationDate: d.lastQuotationDate, + quotationSubmittedAt: d.quotationSubmittedAt, + + // 감사 정보 + updatedBy: d.updatedBy, + updatedByUserName: d.updatedByUserName, + updatedAt: d.updatedAt, + })), + } + }; + } catch (error) { + console.error("Get RFQ with details error:", error); + return { success: false, error: "데이터 조회 중 오류가 발생했습니다." }; + } +} \ No newline at end of file diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts index 34110141..5615db7a 100644 --- a/lib/rfq-last/validations.ts +++ b/lib/rfq-last/validations.ts @@ -71,87 +71,25 @@ export const searchParamsRfqTabCache = createSearchParamsCache({ tab: parseAsStringEnum(['design', 'purchase']).withDefault('design'), }) -// 설계 탭 전용 파라미터 -export const searchParamsRfqDesignCache = createSearchParamsCache({ - design_page: parseAsInteger.withDefault(1), - design_perPage: parseAsInteger.withDefault(10), - design_sort: getSortingStateParser().withDefault([ - { id: "createdAt", desc: true }, - ]), - design_search: parseAsString.withDefault(""), - design_fileType: parseAsArrayOf(z.string()).withDefault([]), - design_filters: getFiltersStateParser().withDefault([]), - design_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -}) -// 구매 탭 전용 파라미터 -export const searchParamsRfqPurchaseCache = createSearchParamsCache({ - purchase_page: parseAsInteger.withDefault(1), - purchase_perPage: parseAsInteger.withDefault(10), - purchase_sort: getSortingStateParser().withDefault([ - { id: "createdAt", desc: true }, - ]), - purchase_search: parseAsString.withDefault(""), - purchase_fileType: parseAsArrayOf(z.string()).withDefault([]), - purchase_filters: getFiltersStateParser().withDefault([]), - purchase_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -}) // 통합 파라미터 캐시 (모든 파라미터를 한 번에 파싱) -export const searchParamsRfqAttachmentsCache = createSearchParamsCache({ - // 공통 - tab: parseAsStringEnum(['design', 'purchase']).withDefault('design'), +export const searchParamsRfqAttachmentsCache =createSearchParamsCache({ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 설계 탭 파라미터 - design_page: parseAsInteger.withDefault(1), - design_perPage: parseAsInteger.withDefault(10), - design_sort: getSortingStateParser().withDefault([ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ { id: "createdAt", desc: true }, ]), - design_search: parseAsString.withDefault(""), - design_fileType: parseAsArrayOf(z.string()).withDefault([]), - design_filters: getFiltersStateParser().withDefault([]), - design_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 구매 탭 파라미터 - purchase_page: parseAsInteger.withDefault(1), - purchase_perPage: parseAsInteger.withDefault(10), - purchase_sort: getSortingStateParser().withDefault([ - { id: "createdAt", desc: true }, - ]), - purchase_search: parseAsString.withDefault(""), - purchase_fileType: parseAsArrayOf(z.string()).withDefault([]), - purchase_filters: getFiltersStateParser().withDefault([]), - purchase_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -}) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), -// 타입 정의 -export type GetRfqLastAttachmentsSchema = { - page: number - perPage: number - sort: Array<{ id: string; desc: boolean }> - search: string - fileType: string[] - filters: any[] - joinOperator: "and" | "or" - attachmentType?: string[] -} -// 헬퍼 함수: prefix가 붙은 파라미터를 일반 파라미터로 변환 -export function extractTabParams( - allParams: Awaited>, - tabPrefix: 'design' | 'purchase' -): GetRfqLastAttachmentsSchema { - const prefix = `${tabPrefix}_` - - return { - page: allParams[`${prefix}page` as keyof typeof allParams] as number, - perPage: allParams[`${prefix}perPage` as keyof typeof allParams] as number, - sort: allParams[`${prefix}sort` as keyof typeof allParams] as any, - search: allParams[`${prefix}search` as keyof typeof allParams] as string, - fileType: allParams[`${prefix}fileType` as keyof typeof allParams] as string[], - filters: allParams[`${prefix}filters` as keyof typeof allParams] as any[], - joinOperator: allParams[`${prefix}joinOperator` as keyof typeof allParams] as "and" | "or", - } -} \ No newline at end of file +}); + + +// 타입 정의 +export type GetRfqLastAttachmentsSchema =Awaited< +ReturnType +>; diff --git a/lib/rfq-last/vendor/add-vendor-dialog.tsx b/lib/rfq-last/vendor/add-vendor-dialog.tsx new file mode 100644 index 00000000..d8745298 --- /dev/null +++ b/lib/rfq-last/vendor/add-vendor-dialog.tsx @@ -0,0 +1,307 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown, Loader2, X, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { addVendorsToRfq } from "../service"; +import { getVendorsForSelection } from "@/lib/b-rfq/service"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Info } from "lucide-react"; + +interface AddVendorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + onSuccess: () => void; +} + +export function AddVendorDialog({ + open, + onOpenChange, + rfqId, + onSuccess, +}: AddVendorDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [vendorOpen, setVendorOpen] = React.useState(false); + const [vendorList, setVendorList] = React.useState([]); + const [selectedVendors, setSelectedVendors] = React.useState([]); + + // 벤더 로드 + const loadVendors = React.useCallback(async () => { + try { + const result = await getVendorsForSelection(); + if (result) { + setVendorList(result); + } + } catch (error) { + console.error("Failed to load vendors:", error); + toast.error("벤더 목록을 불러오는데 실패했습니다."); + } + }, []); + + React.useEffect(() => { + if (open) { + loadVendors(); + } + }, [open, loadVendors]); + + // 초기화 + React.useEffect(() => { + if (!open) { + setSelectedVendors([]); + } + }, [open]); + + // 벤더 추가 + const handleAddVendor = (vendor: any) => { + if (!selectedVendors.find(v => v.id === vendor.id)) { + setSelectedVendors([...selectedVendors, vendor]); + } + setVendorOpen(false); + }; + + // 벤더 제거 + const handleRemoveVendor = (vendorId: number) => { + setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)); + }; + + // 제출 처리 - 벤더만 추가 + const handleSubmit = async () => { + if (selectedVendors.length === 0) { + toast.error("최소 1개 이상의 벤더를 선택해주세요."); + return; + } + + setIsLoading(true); + + try { + const vendorIds = selectedVendors.map(v => v.id); + const result = await addVendorsToRfq({ + rfqId, + vendorIds, + // 기본값으로 벤더만 추가 (상세 조건은 나중에 일괄 입력) + conditions: null, + }); + + if (result.success) { + toast.success( +
+

{selectedVendors.length}개 벤더가 추가되었습니다.

+

+ 벤더 목록에서 '정보 일괄 입력' 버튼으로 조건을 설정하세요. +

+
+ ); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "벤더 추가에 실패했습니다."); + } + } catch (error) { + console.error("Submit error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + // 이미 선택된 벤더인지 확인 + const isVendorSelected = (vendorId: number) => { + return selectedVendors.some(v => v.id === vendorId); + }; + + return ( + + + {/* 헤더 */} + + 벤더 추가 + + 견적 요청을 보낼 벤더를 선택하세요. 조건 설정은 추가 후 일괄로 진행할 수 있습니다. + + + + {/* 컨텐츠 영역 */} +
+
+ {/* 안내 메시지 */} + + + + 여기서는 벤더만 선택합니다. 납기일, 결제조건 등의 상세 정보는 벤더 추가 후 + '정보 일괄 입력' 기능으로 한 번에 설정할 수 있습니다. + + + + {/* 벤더 선택 카드 */} + + +
+ 벤더 선택 + + {selectedVendors.length}개 선택됨 + +
+ + RFQ를 발송할 벤더를 선택하세요. 여러 개 선택 가능합니다. + +
+ +
+ {/* 벤더 추가 버튼 */} + + + + + + + + { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + 검색 결과가 없습니다. + + {vendorList + .filter(vendor => !isVendorSelected(vendor.id)) + .map((vendor) => ( + handleAddVendor(vendor)} + > +
+ + {vendor.vendorCode} + + {vendor.vendorName} + {vendor.country && ( + + {vendor.country} + + )} +
+
+ ))} +
+
+
+
+
+ + {/* 선택된 벤더 목록 */} + {selectedVendors.length > 0 && ( +
+ + +
+ {selectedVendors.map((vendor, index) => ( +
+
+ + {index + 1}. + + + {vendor.vendorCode} + + + {vendor.vendorName} + +
+ +
+ ))} +
+
+
+ )} + + {/* 벤더가 없는 경우 메시지 */} + {selectedVendors.length === 0 && ( +
+

아직 선택된 벤더가 없습니다.

+

위 버튼을 클릭하여 벤더를 추가하세요.

+
+ )} +
+
+
+
+
+ + {/* 푸터 */} + + + + +
+
+ ); +} \ No newline at end of file diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx new file mode 100644 index 00000000..1b8fa528 --- /dev/null +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -0,0 +1,1121 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +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 { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Calendar } from "@/components/ui/calendar"; +import { CalendarIcon, Loader2, Info, Package, Check, ChevronsUpDown } from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { updateVendorConditionsBatch } from "../service"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + getIncotermsForSelection, + getPaymentTermsForSelection, + getPlaceOfShippingForSelection, + getPlaceOfDestinationForSelection +} from "@/lib/procurement-select/service"; + +interface BatchUpdateConditionsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + rfqCode: string; + selectedVendors: Array<{ + id: number; + vendorName: string; + vendorCode: string; + }>; + onSuccess: () => void; +} + +// 타입 정의 +interface SelectOption { + id: number; + code: string; + description: string; +} + +// 폼 스키마 +const formSchema = z.object({ + currency: z.string().optional(), + paymentTermsCode: z.string().optional(), + incotermsCode: z.string().optional(), + incotermsDetail: z.string().optional(), + contractDuration: z.string().optional(), + taxCode: z.string().optional(), + placeOfShipping: z.string().optional(), + placeOfDestination: z.string().optional(), + deliveryDate: z.date().optional(), + materialPriceRelatedYn: z.boolean().default(false), + sparepartYn: z.boolean().default(false), + firstYn: z.boolean().default(false), + firstDescription: z.string().optional(), + sparepartDescription: z.string().optional(), +}); + +type FormValues = z.infer; + +const currencies = ["USD", "EUR", "KRW", "JPY", "CNY"]; + +export function BatchUpdateConditionsDialog({ + open, + onOpenChange, + rfqId, + rfqCode, + selectedVendors, + onSuccess, +}: BatchUpdateConditionsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + + // Select 옵션들 상태 + const [incoterms, setIncoterms] = React.useState([]); + const [paymentTerms, setPaymentTerms] = React.useState([]); + const [shippingPlaces, setShippingPlaces] = React.useState([]); + const [destinationPlaces, setDestinationPlaces] = React.useState([]); + + // 로딩 상태 + const [incotermsLoading, setIncotermsLoading] = React.useState(false); + const [paymentTermsLoading, setPaymentTermsLoading] = React.useState(false); + const [shippingLoading, setShippingLoading] = React.useState(false); + const [destinationLoading, setDestinationLoading] = React.useState(false); + + // Popover 열림 상태 + const [incotermsOpen, setIncotermsOpen] = React.useState(false); + const [paymentTermsOpen, setPaymentTermsOpen] = React.useState(false); + const [shippingOpen, setShippingOpen] = React.useState(false); + const [destinationOpen, setDestinationOpen] = React.useState(false); + const [calendarOpen, setCalendarOpen] = React.useState(false); + + // 체크박스로 각 필드 업데이트 여부 관리 + const [fieldsToUpdate, setFieldsToUpdate] = React.useState({ + currency: false, + paymentTermsCode: false, + incoterms: false, + deliveryDate: false, + contractDuration: false, + taxCode: false, + shipping: false, + materialPrice: false, + sparepart: false, + first: false, + }); + + // 폼 초기화 + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + currency: "", + paymentTermsCode: "", + incotermsCode: "", + incotermsDetail: "", + contractDuration: "", + taxCode: "", + placeOfShipping: "", + placeOfDestination: "", + materialPriceRelatedYn: false, + sparepartYn: false, + firstYn: false, + firstDescription: "", + sparepartDescription: "", + }, + }); + + // 데이터 로드 함수들 + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true); + try { + const data = await getIncotermsForSelection(); + setIncoterms(data); + } catch (error) { + console.error("Failed to load incoterms:", error); + toast.error("Incoterms 목록을 불러오는데 실패했습니다."); + } finally { + setIncotermsLoading(false); + } + }, []); + + const loadPaymentTerms = React.useCallback(async () => { + setPaymentTermsLoading(true); + try { + const data = await getPaymentTermsForSelection(); + setPaymentTerms(data); + } catch (error) { + console.error("Failed to load payment terms:", error); + toast.error("결제조건 목록을 불러오는데 실패했습니다."); + } finally { + setPaymentTermsLoading(false); + } + }, []); + + const loadShippingPlaces = React.useCallback(async () => { + setShippingLoading(true); + try { + const data = await getPlaceOfShippingForSelection(); + setShippingPlaces(data); + } catch (error) { + console.error("Failed to load shipping places:", error); + toast.error("선적지 목록을 불러오는데 실패했습니다."); + } finally { + setShippingLoading(false); + } + }, []); + + const loadDestinationPlaces = React.useCallback(async () => { + setDestinationLoading(true); + try { + const data = await getPlaceOfDestinationForSelection(); + setDestinationPlaces(data); + } catch (error) { + console.error("Failed to load destination places:", error); + toast.error("도착지 목록을 불러오는데 실패했습니다."); + } finally { + setDestinationLoading(false); + } + }, []); + + // 초기 데이터 로드 + React.useEffect(() => { + if (open) { + loadIncoterms(); + loadPaymentTerms(); + loadShippingPlaces(); + loadDestinationPlaces(); + } + }, [open, loadIncoterms, loadPaymentTerms, loadShippingPlaces, loadDestinationPlaces]); + + // 다이얼로그 닫힐 때 초기화 + React.useEffect(() => { + if (!open) { + form.reset(); + setFieldsToUpdate({ + currency: false, + paymentTermsCode: false, + incoterms: false, + deliveryDate: false, + contractDuration: false, + taxCode: false, + shipping: false, + materialPrice: false, + sparepart: false, + first: false, + }); + } + }, [open, form]); + + // 제출 처리 + const onSubmit = async (data: FormValues) => { + const hasFieldsToUpdate = Object.values(fieldsToUpdate).some(v => v); + if (!hasFieldsToUpdate) { + toast.error("최소 1개 이상의 변경할 항목을 선택해주세요."); + return; + } + + // 선택된 필드만 포함하여 conditions 객체 생성 + const conditions: any = {}; + + if (fieldsToUpdate.currency && data.currency) { + conditions.currency = data.currency; + } + if (fieldsToUpdate.paymentTermsCode && data.paymentTermsCode) { + conditions.paymentTermsCode = data.paymentTermsCode; + } + if (fieldsToUpdate.incoterms) { + if (data.incotermsCode) conditions.incotermsCode = data.incotermsCode; + if (data.incotermsDetail) conditions.incotermsDetail = data.incotermsDetail; + } + if (fieldsToUpdate.deliveryDate && data.deliveryDate) { + conditions.deliveryDate = data.deliveryDate; + } + if (fieldsToUpdate.contractDuration) { + conditions.contractDuration = data.contractDuration; + } + if (fieldsToUpdate.taxCode) { + conditions.taxCode = data.taxCode; + } + if (fieldsToUpdate.shipping) { + conditions.placeOfShipping = data.placeOfShipping; + conditions.placeOfDestination = data.placeOfDestination; + } + if (fieldsToUpdate.materialPrice) { + conditions.materialPriceRelatedYn = data.materialPriceRelatedYn; + } + if (fieldsToUpdate.sparepart) { + conditions.sparepartYn = data.sparepartYn; + if (data.sparepartYn) { + conditions.sparepartDescription = data.sparepartDescription; + } + } + if (fieldsToUpdate.first) { + conditions.firstYn = data.firstYn; + if (data.firstYn) { + conditions.firstDescription = data.firstDescription; + } + } + + setIsLoading(true); + + try { + const vendorIds = selectedVendors.map(v => v.id); + const result = await updateVendorConditionsBatch({ + rfqId, + vendorIds, + conditions, + }); + + if (result.success) { + toast.success(result.data?.message || "조건이 성공적으로 업데이트되었습니다."); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "조건 업데이트에 실패했습니다."); + } + } catch (error) { + console.error("Submit error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const getUpdateCount = () => { + return Object.values(fieldsToUpdate).filter(v => v).length; + }; + + // 선택된 옵션 찾기 헬퍼 함수들 + const selectedIncoterm = incoterms.find(i => i.code === form.watch("incotermsCode")); + const selectedPaymentTerm = paymentTerms.find(p => p.code === form.watch("paymentTermsCode")); + const selectedShipping = shippingPlaces.find(s => s.code === form.watch("placeOfShipping")); + const selectedDestination = destinationPlaces.find(d => d.code === form.watch("placeOfDestination")); + + return ( + + + {/* 헤더 */} + + 조건 일괄 설정 + + 선택한 {selectedVendors.length}개 벤더에 동일한 조건을 적용합니다. + 변경하려는 항목만 체크하고 값을 입력하세요. + + + +
+ + {/* 스크롤 가능한 컨텐츠 영역 */} + +
+ {/* 선택된 벤더 정보 */} + + +
+ + + 대상 벤더 + + {selectedVendors.length}개 +
+
+ +
+ {selectedVendors.map((vendor) => ( + + {vendor.vendorCode} - {vendor.vendorName} + + ))} +
+
+
+ + {/* 안내 메시지 */} + + + + 체크박스를 선택한 항목만 업데이트됩니다. + 선택하지 않은 항목은 기존 값이 유지됩니다. + + + + {/* 기본 조건 설정 */} + + + 기본 조건 + + + {/* 통화 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked }) + } + /> + ( + + + 통화 + +
+ + + + + + + + + + 검색 결과가 없습니다. + + {currencies.map((currency) => ( + field.onChange(currency)} + > + {currency} + + + ))} + + + + + + + +
+
+ )} + /> +
+ + {/* 결제 조건 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked }) + } + /> + ( + + + 결제 조건 + +
+ + + + + + + + + + + 검색 결과가 없습니다. + + {paymentTerms.map((term) => ( + { + field.onChange(term.code); + setPaymentTermsOpen(false); + }} + > +
+ {term.code} + - + {term.description} + +
+
+ ))} +
+
+
+
+
+ +
+
+ )} + /> +
+ + {/* 인코텀즈 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked }) + } + /> +
+ +
+ ( + + + + + + + + + + + + 검색 결과가 없습니다. + + {incoterms.map((incoterm) => ( + { + field.onChange(incoterm.code); + setIncotermsOpen(false); + }} + > +
+ {incoterm.code} + - + {incoterm.description} + +
+
+ ))} +
+
+
+
+
+ +
+ )} + /> + {/* ( + + + + + + + )} + /> */} +
+
+
+ + {/* 납기일 */} + {!rfqCode.startsWith("F") && ( +
+ + setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked }) + } + /> + ( + + + 납기일 + +
+ + + + + + + + { + field.onChange(date); + setCalendarOpen(false); + }} + initialFocus + /> + + + +
+
+ )} + /> +
+ )} + + {/* 계약 기간 */} + {rfqCode.startsWith("F") && ( +
+ + setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked }) + } + /> + ( + + + 계약 기간 + +
+ + + + +
+
+ )} + /> +
+ )} + + {/* 세금 코드 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked }) + } + /> + ( + + + 세금 코드 + +
+ + + + +
+
+ )} + /> +
+ + {/* 선적지/도착지 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked }) + } + /> +
+ ( + + + 선적지 + +
+ + + + + + + + + + + 검색 결과가 없습니다. + + {shippingPlaces.map((place) => ( + { + field.onChange(place.code); + setShippingOpen(false); + }} + > +
+ {place.code} + - + {place.description} + +
+
+ ))} +
+
+
+
+
+ +
+
+ )} + /> + + ( + + + 도착지 + +
+ + + + + + + + + + + 검색 결과가 없습니다. + + {destinationPlaces.map((place) => ( + { + field.onChange(place.code); + setDestinationOpen(false); + }} + > +
+ {place.code} + - + {place.description} + +
+
+ ))} +
+
+
+
+
+ +
+
+ )} + /> +
+
+
+
+ + {/* 추가 옵션 */} + + + 추가 옵션 + + + {/* 연동제 적용 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked }) + } + /> + ( + +
+ + 연동제 적용 + +
+ 원자재 가격 연동 여부 +
+
+ + + +
+ )} + /> +
+ + {/* Spare Part */} +
+
+ + setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked }) + } + /> + ( + +
+ + Spare Part + +
+ 예비 부품 요구사항 +
+
+ + + +
+ )} + /> +
+ {form.watch("sparepartYn") && fieldsToUpdate.sparepart && ( + ( + + +