diff options
| -rw-r--r-- | app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx | 48 | ||||
| -rw-r--r-- | app/api/revisions/max-serial-no/route.ts | 175 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 10 | ||||
| -rw-r--r-- | components/form-data/import-excel-form.tsx | 312 | ||||
| -rw-r--r-- | components/form-data/spreadJS-dialog copy.tsx | 2 | ||||
| -rw-r--r-- | components/form-data/spreadJS-dialog.tsx | 71 | ||||
| -rw-r--r-- | components/pq-input/pq-input-tabs.tsx | 278 | ||||
| -rw-r--r-- | components/pq-input/pq-review-wrapper.tsx | 63 | ||||
| -rw-r--r-- | lib/forms/stat.ts | 50 | ||||
| -rw-r--r-- | lib/pq/service.ts | 19 | ||||
| -rw-r--r-- | lib/rfq-last/service.ts | 1 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 10 | ||||
| -rw-r--r-- | lib/tbe-last/service.ts | 21 | ||||
| -rw-r--r-- | lib/tbe-last/vendor/tbe-table.tsx | 8 | ||||
| -rw-r--r-- | lib/vendor-document-list/ship/import-from-dolce-button.tsx | 188 |
15 files changed, 839 insertions, 417 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx index c8b0e9b8..c63cf4df 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx @@ -36,14 +36,37 @@ export default async function PQReviewPage(props: PQReviewPageProps) { const params = await props.params
const vendorId = parseInt(params.vendorId, 10)
const submissionId = parseInt(params.submissionId, 10)
-
+
+ // 파라미터 유효성 검증
+ if (isNaN(vendorId) || isNaN(submissionId)) {
+ return (
+ <div className="container mx-auto p-6">
+ <div className="text-center">
+ <h1 className="text-2xl font-bold text-red-600">잘못된 접근</h1>
+ <p className="text-gray-600 mt-2">유효하지 않은 파라미터입니다.</p>
+ </div>
+ </div>
+ )
+ }
+
try {
// PQ Submission 정보 조회
const pqSubmission = await getPQById(submissionId, vendorId)
// PQ 데이터 조회 (질문과 답변)
const pqData = await getPQDataByVendorId(vendorId, pqSubmission.projectId || undefined)
-
+
+ // 협력업체 정보 (pqSubmission에 이미 포함되어 있음)
+ const vendorInfo = {
+ vendorName: pqSubmission.vendorName,
+ vendorCode: pqSubmission.vendorCode,
+ vendorStatus: pqSubmission.vendorStatus,
+ vendorCountry: pqSubmission.vendorCountry,
+ vendorEmail: pqSubmission.vendorEmail,
+ vendorPhone: pqSubmission.vendorPhone,
+ vendorFax: pqSubmission.vendorFax,
+ }
+
// 프로젝트 정보 (프로젝트 PQ인 경우)
const projectInfo = pqSubmission.projectId ? {
id: pqSubmission.projectId,
@@ -60,9 +83,6 @@ export default async function PQReviewPage(props: PQReviewPageProps) { const statusLabel = getStatusLabel(pqSubmission.status)
const statusVariant = getStatusVariant(pqSubmission.status)
- // 수정 가능 여부 (SUBMITTED 상태일 때만 가능)
- const canReview = pqSubmission.status === "SUBMITTED"
-
return (
<Shell className="gap-6 max-w-5xl">
<div className="flex items-center justify-between">
@@ -136,7 +156,7 @@ export default async function PQReviewPage(props: PQReviewPageProps) { pqData={pqData}
vendorId={vendorId}
pqSubmission={pqSubmission}
- canReview={canReview}
+ vendorInfo={vendorInfo}
/>
</TabsContent>
@@ -156,7 +176,21 @@ export default async function PQReviewPage(props: PQReviewPageProps) { <p className="text-sm font-medium text-muted-foreground">상태</p>
<p>{pqSubmission.vendorStatus}</p>
</div>
- {/* 필요시 추가 정보 표시 */}
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">국가</p>
+ <p>{pqSubmission.vendorCountry}</p>
+ </div>
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">이메일</p>
+ <p>{pqSubmission.vendorEmail}</p>
+ </div>
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">전화번호</p>
+ <p>{pqSubmission.vendorPhone}</p>
+ </div>
+
+
+ {/* 필요시 추가 정보 표시 */}
</div>
</div>
</TabsContent>
diff --git a/app/api/revisions/max-serial-no/route.ts b/app/api/revisions/max-serial-no/route.ts index c0bfe5c3..0681b66d 100644 --- a/app/api/revisions/max-serial-no/route.ts +++ b/app/api/revisions/max-serial-no/route.ts @@ -1,10 +1,16 @@ import { NextRequest, NextResponse } from 'next/server' import db from '@/db/db' -import { revisions, issueStages } from '@/db/schema/vendorDocu' +import { revisions, issueStages, documents, projects } from '@/db/schema' import { eq, sql } from 'drizzle-orm' import { alias } from 'drizzle-orm/pg-core' import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils' +// DOLCE Detail Document 타입 정의 +interface DOLCEDetailDocument { + RegisterSerialNoMax?: string | number + // 다른 필드들도 필요시 추가 +} + export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) @@ -23,7 +29,7 @@ export async function GET(request: NextRequest) { const parsedDocumentId = parseInt(documentId) debugLog('3. Parsed documentId:', parsedDocumentId) - // 해당 document의 모든 issueStages와 연결된 revisions에서 최대 serialNo 조회 + // 1. 내부 DB에서 최대 serialNo 조회 const r = alias(revisions, 'r') const is = alias(issueStages, 'is') @@ -45,30 +51,114 @@ export async function GET(request: NextRequest) { ) .where(eq(is.documentId, parsedDocumentId)) - debugLog('5. Query result:', maxSerialResult) - debugLog('6. Query result length:', maxSerialResult.length) - debugLog('7. First result item:', maxSerialResult[0]) + debugLog('5. Internal DB query result:', maxSerialResult) + + const internalMaxSerial = maxSerialResult[0]?.maxSerial || 0 + const internalMaxRegisterSerial = maxSerialResult[0]?.maxRegisterSerial || 0 + const internalMaxSerialNo = Math.max(internalMaxSerial, internalMaxRegisterSerial) + + debugLog('6. Internal maxSerialNo:', internalMaxSerialNo) + + // 2. Document 정보 조회 (DOLCE API 호출을 위해) + const documentInfo = await db + .select({ + docNumber: documents.docNumber, + drawingKind: documents.drawingKind, + discipline: documents.discipline, + projectId: documents.projectId, + projectCode: projects.code + }) + .from(documents) + .leftJoin(projects, eq(documents.projectId, projects.id)) + .where(eq(documents.id, parsedDocumentId)) + .limit(1) + + debugLog('7. Document info:', documentInfo[0]) + + if (!documentInfo[0]) { + debugError('Document not found for id:', parsedDocumentId) + return NextResponse.json( + { error: 'Document not found' }, + { status: 404 } + ) + } + + const { docNumber, drawingKind, discipline, projectCode } = documentInfo[0] + + // 필수 필드 검증 + if (!projectCode || !docNumber || !discipline || !drawingKind) { + debugLog('8. Missing required fields for DOLCE API') + debugLog(' - projectCode:', projectCode) + debugLog(' - docNumber:', docNumber) + debugLog(' - discipline:', discipline) + debugLog(' - drawingKind:', drawingKind) + + // DOLCE API 호출 불가능한 경우, 내부 DB 값만 사용 + return NextResponse.json({ + maxSerialNo: internalMaxSerialNo, + nextSerialNo: internalMaxSerialNo + 1, + documentId: documentId, + source: 'internal_only', + debug: { + parsedDocumentId, + internalMaxSerialNo, + missingFields: { + projectCode: !projectCode, + docNumber: !docNumber, + discipline: !discipline, + drawingKind: !drawingKind + } + } + }) + } + + // 3. DOLCE API 호출 + let dolceMaxSerialNo = 0 + try { + debugLog('9. Calling DOLCE API...') + const dolceDocuments = await fetchDetailFromDOLCE( + projectCode, + docNumber, + discipline, + drawingKind + ) - const maxSerialValue = maxSerialResult[0]?.maxSerial || 0 - const maxRegisterSerialValue = maxSerialResult[0]?.maxRegisterSerial || 0 + debugLog('10. DOLCE API response:', dolceDocuments) - debugLog('8. maxSerial value:', maxSerialValue) - debugLog('9. maxRegisterSerial value:', maxRegisterSerialValue) + if (dolceDocuments && dolceDocuments.length > 0) { + // 첫 번째 문서의 RegisterSerialNoMax 값 사용 + const firstDoc = dolceDocuments[0] + if (firstDoc.RegisterSerialNoMax) { + dolceMaxSerialNo = parseInt(String(firstDoc.RegisterSerialNoMax)) || 0 + debugLog('11. DOLCE maxSerialNo:', dolceMaxSerialNo) + } + } + } catch (error) { + debugError('DOLCE API call failed:', error) + // DOLCE API 실패 시에도 내부 DB 값은 사용 + } - const maxSerialNo = Math.max(maxSerialValue, maxRegisterSerialValue) + // 4. 내부 DB와 DOLCE 값 중 최대값 선택 + const finalMaxSerialNo = Math.max(internalMaxSerialNo, dolceMaxSerialNo) - debugSuccess('10. Final maxSerialNo:', maxSerialNo) - debugSuccess('11. Next serialNo:', maxSerialNo + 1) + debugSuccess('12. Final maxSerialNo:', finalMaxSerialNo) + debugSuccess('13. Next serialNo:', finalMaxSerialNo + 1) return NextResponse.json({ - maxSerialNo, - nextSerialNo: maxSerialNo + 1, + maxSerialNo: finalMaxSerialNo, + nextSerialNo: finalMaxSerialNo + 1, documentId: documentId, + source: dolceMaxSerialNo > internalMaxSerialNo ? 'dolce' : 'internal', debug: { parsedDocumentId, - queryResult: maxSerialResult, - maxSerialValue, - maxRegisterSerialValue + internalMaxSerialNo, + dolceMaxSerialNo, + documentInfo: { + projectCode, + docNumber, + discipline, + drawingKind + } } }) } catch (error) { @@ -79,4 +169,55 @@ export async function GET(request: NextRequest) { { status: 500 } ) } +} + +// DOLCE Detail API 호출 함수 +async function fetchDetailFromDOLCE( + projectCode: string, + drawingNo: string, + discipline: string, + drawingKind: string +): Promise<DOLCEDetailDocument[]> { + const endpoint = process.env.DOLCE_DOC_DETAIL_API_URL || 'http://60.100.99.217:1111/Services/VDCSWebService.svc/DetailDwgReceiptMgmt' + + const requestBody = { + project: projectCode, + drawingNo: drawingNo, + discipline: discipline, + drawingKind: drawingKind + } + + console.log(`Fetching detail from DOLCE: ${projectCode} - ${drawingNo}`) + console.log('Request body:', requestBody) + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`DOLCE Detail API failed: HTTP ${response.status} - ${errorText}`) + } + + const data = await response.json() + + // DOLCE Detail API 응답 구조에 맞게 처리 + if (data.DetailDwgReceiptMgmtResult) { + const documents = data.DetailDwgReceiptMgmtResult as DOLCEDetailDocument[] + console.log(`Found ${documents.length} detail records for ${drawingNo}`) + return documents + } else { + console.warn(`Unexpected DOLCE Detail response structure:`, data) + return [] + } + + } catch (error) { + console.error(`DOLCE Detail API call failed for ${drawingNo}:`, error) + throw error + } }
\ No newline at end of file diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 3d8b1438..09745bb0 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -99,7 +99,7 @@ export default function DynamicTable({ const router = useRouter(); const lng = (params?.lng as string) || "ko"; const { t } = useTranslation(lng, "engineering"); - const pathname = usePathname(); + const [rowAction, setRowAction] = React.useState<DataTableRowAction<GenericData> | null>(null); @@ -115,14 +115,14 @@ export default function DynamicTable({ const [formStats, setFormStats] = React.useState<FormStatusByVendor | null>(null); const [isLoadingStats, setIsLoadingStats] = React.useState(true); - const isEVCPPath = pathname.includes('evcp'); + React.useEffect(() => { const fetchFormStats = async () => { try { setIsLoadingStats(true); // getFormStatusByVendor 서버 액션 직접 호출 - const data = await getFormStatusByVendor(projectId, formCode); + const data = await getFormStatusByVendor(projectId, contractItemId, formCode); if (data && data.length > 0) { setFormStats(data[0]); @@ -674,7 +674,7 @@ export default function DynamicTable({ return ( <> - {!isEVCPPath && ( + <div className="mb-6"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6"> {/* Tag Count */} @@ -810,7 +810,7 @@ export default function DynamicTable({ </Card> </div> </div> - )} + <ClientDataTable data={tableData} diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx index df6ab6c1..0e757891 100644 --- a/components/form-data/import-excel-form.tsx +++ b/components/form-data/import-excel-form.tsx @@ -56,22 +56,22 @@ interface GenericData { * Check if a field is editable for a specific TAG_NO */ function isFieldEditable( - column: DataTableColumnJSON, - tagNo: string, + column: DataTableColumnJSON, + tagNo: string, editableFieldsMap: Map<string, string[]> ): boolean { // SHI-only fields (shi === "OUT" or shi === null) are never editable if (column.shi === "OUT" || column.shi === null) return false; - + // System fields are never editable if (column.key === "TAG_NO" || column.key === "TAG_DESC" || column.key === "status") return false; - + // If no editableFieldsMap provided, assume all non-SHI fields are editable if (!editableFieldsMap || editableFieldsMap.size === 0) return true; - + // If TAG_NO not in map, no fields are editable if (!editableFieldsMap.has(tagNo)) return false; - + // Check if this field is in the editable fields list for this TAG_NO const editableFields = editableFieldsMap.get(tagNo) || []; return editableFields.includes(column.key); @@ -86,7 +86,7 @@ function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[ if (existingErrorSheet) { workbook.removeWorksheet("Import_Errors"); } - + const errorSheet = workbook.addWorksheet("Import_Errors"); // Add header error section if exists @@ -114,7 +114,7 @@ function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[ // Data validation errors section const startRow = errorSheet.rowCount + 1; - + // Summary row errorSheet.addRow([`DATA VALIDATION ERRORS: ${errors.length} errors found`]); const summaryRow = errorSheet.getRow(startRow); @@ -143,7 +143,7 @@ function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[ const headerRow = errorSheet.getRow(errorSheet.rowCount); headerRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; headerRow.alignment = { horizontal: "center" }; - + headerRow.eachCell((cell) => { cell.fill = { type: "pattern", @@ -167,7 +167,7 @@ function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[ // Color code by error type errorRow.eachCell((cell) => { let bgColor = "FFFFFFFF"; // Default white - + switch (error.errorType) { case "MISSING_TAG_NO": bgColor = "FFFFCCCC"; // Light red @@ -285,8 +285,8 @@ export async function importExcelData({ saveAs(new Blob([outBuffer]), `import-error-report_${Date.now()}.xlsx`); toast.error(`Header validation failed. ${headerErrors.length} errors found. Check downloaded error report.`); - return { - success: false, + return { + success: false, error: "Header validation errors", errorCount: headerErrors.length, hasErrors: true @@ -312,30 +312,28 @@ export async function importExcelData({ for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { const row = worksheet.getRow(rowNum); const rowValues = row.values as ExcelJS.CellValue[]; - if (!rowValues || rowValues.length <= 1) continue; // Skip empty rows + // 실제 값이 있는지 확인 (빈 문자열이 아닌 실제 내용) + const hasAnyValue = rowValues && rowValues.slice(1).some(val => + val !== undefined && + val !== null && + String(val).trim() !== "" + ); + + if (!hasAnyValue) { + console.log(`Row ${rowNum} is empty, skipping...`); + continue; // 완전히 빈 행은 건너뛰기 + } const rowObj: Record<string, any> = {}; const skippedFields: string[] = []; // 현재 행에서 건너뛴 필드들 let hasErrors = false; - + // Get the TAG_NO first to identify existing data const tagNoColIndex = keyToIndexMap.get("TAG_NO"); const tagNo = tagNoColIndex ? String(rowValues[tagNoColIndex] ?? "").trim() : ""; const existingRowData = existingDataMap.get(tagNo); - // Validate TAG_NO first - if (!tagNo) { - validationErrors.push({ - tagNo: `Row-${rowNum}`, - rowIndex: rowNum, - columnKey: "TAG_NO", - columnLabel: "TAG NO", - errorType: "MISSING_TAG_NO", - errorMessage: "TAG_NO is empty or missing", - currentValue: tagNo, - }); - hasErrors = true; - } else if (!existingTagNumbers.has(tagNo)) { + if (!existingTagNumbers.has(tagNo)) { validationErrors.push({ tagNo: tagNo, rowIndex: rowNum, @@ -355,7 +353,7 @@ export async function importExcelData({ // Check if this field is editable for this TAG_NO const fieldEditable = isFieldEditable(col, tagNo, editableFieldsMap); - + if (!fieldEditable) { // If field is not editable, preserve existing value if (existingRowData && existingRowData[col.key] !== undefined) { @@ -373,7 +371,7 @@ export async function importExcelData({ break; } } - + // Determine skip reason let skipReason = ""; if (col.shi === "OUT" || col.shi === null) { @@ -383,10 +381,10 @@ export async function importExcelData({ } else { skipReason = "Not editable for this TAG"; } - + // Log skipped field skippedFields.push(`${col.label} (${skipReason})`); - + // Check if Excel contains a value for a read-only field and warn const cellValue = rowValues[colIndex] ?? ""; const stringVal = String(cellValue).trim(); @@ -403,7 +401,7 @@ export async function importExcelData({ }); hasErrors = true; } - + return; // Skip processing Excel value for this column } @@ -492,13 +490,13 @@ export async function importExcelData({ const outBuffer = await workbook.xlsx.writeBuffer(); saveAs(new Blob([outBuffer]), `import-error-report_${Date.now()}.xlsx`); - + toast.error( `Data validation failed. ${validationErrors.length} errors found across ${new Set(validationErrors.map(e => e.tagNo)).size} TAG(s). Check downloaded error report.` ); - - return { - success: false, + + return { + success: false, error: "Data validation errors", errorCount: validationErrors.length, hasErrors: true, @@ -510,14 +508,14 @@ export async function importExcelData({ // Create locally merged data for UI update const mergedData = [...tableData]; const dataMap = new Map<string, GenericData>(); - + // Map existing data by TAG_NO mergedData.forEach(item => { if (item.TAG_NO) { dataMap.set(item.TAG_NO, item); } }); - + // Update with imported data importedData.forEach(item => { if (item.TAG_NO) { @@ -530,137 +528,137 @@ export async function importExcelData({ }); // If formCode and contractItemId are provided, save directly to DB - // importExcelData 함수에서 DB 저장 부분 -if (formCode && contractItemId) { - try { - // 배치 업데이트 함수 호출 - const result = await updateFormDataBatchInDB( - formCode, - contractItemId, - importedData // 모든 imported rows를 한번에 전달 - ); - - if (result.success) { - // 로컬 상태 업데이트 - if (onDataUpdate) { - onDataUpdate(() => mergedData); - } - - // 성공 메시지 구성 - const { updatedCount, notFoundTags } = result.data || {}; - - let message = `Successfully updated ${updatedCount || importedData.length} rows`; - - // 건너뛴 필드가 있는 경우 - if (skippedFieldsLog.length > 0) { - const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); - message += ` (${totalSkippedFields} read-only fields preserved)`; - } - - // 찾을 수 없는 TAG가 있는 경우 - if (notFoundTags && notFoundTags.length > 0) { - console.warn("Tags not found in database:", notFoundTags); - message += `. Warning: ${notFoundTags.length} tags not found in database`; - } - - toast.success(message); - - return { - success: true, - importedCount: updatedCount || importedData.length, - message: message, - errorCount: 0, - hasErrors: false, - skippedFields: skippedFieldsLog, - notFoundTags: notFoundTags - }; - - } else { - // 배치 업데이트 실패 - console.error("Batch update failed:", result.message); - - // 부분 성공인 경우 - if (result.data?.updatedCount > 0) { - // 부분적으로라도 업데이트된 경우 로컬 상태 업데이트 - if (onDataUpdate) { - onDataUpdate(() => mergedData); - } - - toast.warning( - `Partially updated: ${result.data.updatedCount} of ${importedData.length} rows updated. ` + - `${result.data.failedCount || 0} failed.` + // importExcelData 함수에서 DB 저장 부분 + if (formCode && contractItemId) { + try { + // 배치 업데이트 함수 호출 + const result = await updateFormDataBatchInDB( + formCode, + contractItemId, + importedData // 모든 imported rows를 한번에 전달 ); - - return { - success: true, // 부분 성공도 success로 처리 - importedCount: result.data.updatedCount, - message: result.message, - errorCount: result.data.failedCount || 0, - hasErrors: true, - skippedFields: skippedFieldsLog - }; - - } else { - // 완전 실패 - toast.error(result.message || "Failed to update data to database"); - + + if (result.success) { + // 로컬 상태 업데이트 + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + // 성공 메시지 구성 + const { updatedCount, notFoundTags } = result.data || {}; + + let message = `Successfully updated ${updatedCount || importedData.length} rows`; + + // 건너뛴 필드가 있는 경우 + if (skippedFieldsLog.length > 0) { + const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); + message += ` (${totalSkippedFields} read-only fields preserved)`; + } + + // 찾을 수 없는 TAG가 있는 경우 + if (notFoundTags && notFoundTags.length > 0) { + console.warn("Tags not found in database:", notFoundTags); + message += `. Warning: ${notFoundTags.length} tags not found in database`; + } + + toast.success(message); + + return { + success: true, + importedCount: updatedCount || importedData.length, + message: message, + errorCount: 0, + hasErrors: false, + skippedFields: skippedFieldsLog, + notFoundTags: notFoundTags + }; + + } else { + // 배치 업데이트 실패 + console.error("Batch update failed:", result.message); + + // 부분 성공인 경우 + if (result.data?.updatedCount > 0) { + // 부분적으로라도 업데이트된 경우 로컬 상태 업데이트 + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + toast.warning( + `Partially updated: ${result.data.updatedCount} of ${importedData.length} rows updated. ` + + `${result.data.failedCount || 0} failed.` + ); + + return { + success: true, // 부분 성공도 success로 처리 + importedCount: result.data.updatedCount, + message: result.message, + errorCount: result.data.failedCount || 0, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + + } else { + // 완전 실패 + toast.error(result.message || "Failed to update data to database"); + + return { + success: false, + error: result.message, + errorCount: importedData.length, + hasErrors: true, + skippedFields: skippedFieldsLog + }; + } + } + + } catch (saveError) { + // 예외 발생 처리 + console.error("Failed to save imported data:", saveError); + + const errorMessage = saveError instanceof Error + ? saveError.message + : "Unknown error occurred"; + + toast.error(`Database update failed: ${errorMessage}`); + return { success: false, - error: result.message, + error: saveError, + message: errorMessage, errorCount: importedData.length, hasErrors: true, skippedFields: skippedFieldsLog }; } + + } else { + // formCode나 contractItemId가 없는 경우 - 로컬 업데이트만 + if (onDataUpdate) { + onDataUpdate(() => mergedData); + } + + const successMessage = skippedFieldsLog.length > 0 + ? `Imported ${importedData.length} rows successfully (read-only fields preserved)` + : `Imported ${importedData.length} rows successfully`; + + toast.success(`${successMessage} (local only - no database connection)`); + + return { + success: true, + importedCount: importedData.length, + message: "Data imported locally only", + errorCount: 0, + hasErrors: false, + skippedFields: skippedFieldsLog + }; } - - } catch (saveError) { - // 예외 발생 처리 - console.error("Failed to save imported data:", saveError); - - const errorMessage = saveError instanceof Error - ? saveError.message - : "Unknown error occurred"; - - toast.error(`Database update failed: ${errorMessage}`); - - return { - success: false, - error: saveError, - message: errorMessage, - errorCount: importedData.length, - hasErrors: true, - skippedFields: skippedFieldsLog - }; - } - -} else { - // formCode나 contractItemId가 없는 경우 - 로컬 업데이트만 - if (onDataUpdate) { - onDataUpdate(() => mergedData); - } - - const successMessage = skippedFieldsLog.length > 0 - ? `Imported ${importedData.length} rows successfully (read-only fields preserved)` - : `Imported ${importedData.length} rows successfully`; - - toast.success(`${successMessage} (local only - no database connection)`); - - return { - success: true, - importedCount: importedData.length, - message: "Data imported locally only", - errorCount: 0, - hasErrors: false, - skippedFields: skippedFieldsLog - }; -} - + } catch (err) { console.error("Excel import error:", err); toast.error("Excel import failed."); - return { - success: false, + return { + success: false, error: err, errorCount: 1, hasErrors: true diff --git a/components/form-data/spreadJS-dialog copy.tsx b/components/form-data/spreadJS-dialog copy.tsx index d001463e..8f7c3bc6 100644 --- a/components/form-data/spreadJS-dialog copy.tsx +++ b/components/form-data/spreadJS-dialog copy.tsx @@ -1389,7 +1389,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat <div className="flex items-center gap-4 text-sm"> <span className="font-medium text-blue-600"> Template Type: { - templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + template/home/ec2-user/evcp/components/form-dataType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : 'Grid List View (GRD_LIST)' } diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index af1a3dca..375c097c 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -176,13 +176,13 @@ export function TemplateViewDialog({ }, []); const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { - if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + if (template?.TMPL_TYPE === "SPREAD_LIST" && (template?.SPR_LST_SETUP?.CONTENT || template?.SPR_ITM_LST_SETUP?.CONTENT)) { return 'SPREAD_LIST'; } - if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + if (template?.TMPL_TYPE === "SPREAD_ITEM" && (template?.SPR_LST_SETUP?.CONTENT || template?.SPR_ITM_LST_SETUP?.CONTENT)) { return 'SPREAD_ITEM'; } - if (template.GRD_LST_SETUP && columnsJSON.length > 0) { + if (template?.GRD_LST_SETUP && columnsJSON.length > 0) { return 'GRD_LIST'; } return null; @@ -221,10 +221,10 @@ export function TemplateViewDialog({ DATA_SHEETS: [] } }; - + setAvailableTemplates([defaultGrdTemplate]); - setSelectedTemplateId('DEFAULT_GRD_LIST'); - setTemplateType('GRD_LIST'); + // setSelectedTemplateId('DEFAULT_GRD_LIST'); + // setTemplateType('GRD_LIST'); console.log('📋 Created default GRD_LIST template'); } return; @@ -238,7 +238,7 @@ export function TemplateViewDialog({ } const validTemplates = templates.filter(isValidTemplate); - + // 유효한 템플릿이 없지만 columnsJSON이 있으면 기본 GRD_LIST 추가 if (validTemplates.length === 0 && columnsJSON && columnsJSON.length > 0) { const defaultGrdTemplate: TemplateItem = { @@ -261,11 +261,11 @@ export function TemplateViewDialog({ DATA_SHEETS: [] } }; - + validTemplates.push(defaultGrdTemplate); console.log('📋 Added default GRD_LIST template to empty template list'); } - + setAvailableTemplates(validTemplates); if (validTemplates.length > 0 && !selectedTemplateId) { @@ -1251,13 +1251,13 @@ export function TemplateViewDialog({ } }); - // 🔧 마지막에 activeSheetName으로 다시 전환 - if (activeSheetName && spread.getSheetFromName(activeSheetName)) { - spread.setActiveSheet(activeSheetName); - activeSheet = spread.getActiveSheet(); - } + // 🔧 마지막에 activeSheetName으로 다시 전환 + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + activeSheet = spread.getActiveSheet(); + } + - }); } } @@ -1292,6 +1292,32 @@ export function TemplateViewDialog({ } }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings, createCellStyle, isFieldEditable, columnsJSON, setupOptimizedListValidation, parseCellAddress, ensureRowCapacity, getCellAddress]); + React.useEffect(() => { + if (!selectedTemplateId) { + const only = availableTemplates[0]; + const type = determineTemplateType(only); + + // 선택되어 있지 않다면 자동 선택 + if (selectedTemplateId !== only.TMPL_ID) { + setSelectedTemplateId(only.TMPL_ID); + setTemplateType(type); + } + + // 이미 스프레드가 마운트되어 있다면 즉시 초기화(선택 변경만으로도 리렌더되지만 안전하게 보강) + if (currentSpread) { + initSpread(currentSpread, only); + } + } + }, [ + availableTemplates, + selectedTemplateId, + currentSpread, + determineTemplateType, + initSpread, + setTemplateType, + setSelectedTemplateId + ]); + const handleSaveChanges = React.useCallback(async () => { if (!currentSpread || !hasChanges) { toast.info("No changes to save"); @@ -1454,6 +1480,8 @@ export function TemplateViewDialog({ const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; + + return ( <Dialog open={isOpen} onOpenChange={onClose}> <DialogContent @@ -1463,7 +1491,8 @@ export function TemplateViewDialog({ <DialogTitle>SEDP Template - {formCode}</DialogTitle> <DialogDescription> <div className="space-y-3"> - {availableTemplates.length > 1 && ( + {availableTemplates.length > 0 ? ( + // 템플릿이 2개 이상일 때: Select 박스 표시 <div className="flex items-center gap-4"> <span className="text-sm font-medium">Template:</span> <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> @@ -1479,7 +1508,15 @@ export function TemplateViewDialog({ </SelectContent> </Select> </div> - )} + ) : availableTemplates.length === 1 ? ( + // 템플릿이 정확히 1개일 때: 템플릿 이름을 텍스트로 표시 + <div className="flex items-center gap-4"> + <span className="text-sm font-medium">Template:</span> + <span className="text-sm text-blue-600 font-medium"> + {availableTemplates[0].NAME} ({availableTemplates[0].TMPL_TYPE}) + </span> + </div> + ) : null} {selectedTemplate && ( <div className="flex items-center gap-4 text-sm"> diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx index a37a52db..3f7e1718 100644 --- a/components/pq-input/pq-input-tabs.tsx +++ b/components/pq-input/pq-input-tabs.tsx @@ -15,7 +15,7 @@ import { import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" -import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown, Download } from "lucide-react" +import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown, Download, Loader2 } from "lucide-react" import prettyBytes from "pretty-bytes" import { useToast } from "@/hooks/use-toast" import { @@ -68,6 +68,7 @@ import { // Additional UI import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" // Server actions import { @@ -156,6 +157,14 @@ export function PQInputTabs({ const [allSaved, setAllSaved] = React.useState(false) const [showConfirmDialog, setShowConfirmDialog] = React.useState(false) + // 필터 상태 관리 + const [filterOptions, setFilterOptions] = React.useState({ + showAll: true, + showSaved: true, + showNotSaved: true, + }) + + const { toast } = useToast() const shouldDisableInput = isReadOnly; @@ -166,10 +175,10 @@ export function PQInputTabs({ const parseCode = (code: string) => { return code.split('-').map(part => parseInt(part, 10)) } - + const aCode = parseCode(a.code) const bCode = parseCode(b.code) - + for (let i = 0; i < Math.max(aCode.length, bCode.length); i++) { const aPart = aCode[i] || 0 const bPart = bCode[i] || 0 @@ -181,6 +190,14 @@ export function PQInputTabs({ }) } + // 필터링 함수 + const shouldShowItem = (isSaved: boolean) => { + if (filterOptions.showAll) return true; + if (isSaved && filterOptions.showSaved) return true; + if (!isSaved && filterOptions.showNotSaved) return true; + return false; + } + // ---------------------------------------------------------------------- // A) Create initial form values // Mark items as "saved" if they have existing answer or attachments @@ -219,6 +236,7 @@ export function PQInputTabs({ return { answers } } + // ---------------------------------------------------------------------- // B) Set up react-hook-form // ---------------------------------------------------------------------- @@ -339,7 +357,7 @@ export function PQInputTabs({ if (answerData.answer) { switch (inputFormat) { case "EMAIL": - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ if (!emailRegex.test(answerData.answer)) { toast({ title: "이메일 형식 오류", @@ -350,22 +368,24 @@ export function PQInputTabs({ } break case "PHONE": - const phoneRegex = /^[\d-]+$/ + case "FAX": + // 전화번호/팩스번호는 숫자만 허용 + const phoneRegex = /^\d+$/ if (!phoneRegex.test(answerData.answer)) { toast({ - title: "전화번호 형식 오류", - description: "올바른 전화번호 형식을 입력해주세요. (예: 02-1234-5678)", + title: `${inputFormat === "PHONE" ? "전화번호" : "팩스번호"} 형식 오류`, + description: `숫자만 입력해주세요.`, variant: "destructive", }) return } break case "NUMBER": - const numberRegex = /^-?\d*\.?\d*$/ + const numberRegex = /^-?\d+(\.\d+)?$/ if (!numberRegex.test(answerData.answer)) { toast({ title: "숫자 형식 오류", - description: "숫자만 입력해주세요. (소수점, 음수 허용)", + description: "올바른 숫자 형식을 입력해주세요. (예: 123, -123, 123.45)", variant: "destructive", }) return @@ -389,7 +409,7 @@ export function PQInputTabs({ for (const localFile of answerData.newUploads) { try { const uploadResult = await uploadVendorFileAction(localFile.fileObj) - const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`) + const currentUploaded = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)] currentUploaded.push({ fileName: uploadResult.fileName, url: uploadResult.url, @@ -435,10 +455,7 @@ export function PQInputTabs({ if (saveResult.ok) { // Mark as saved form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false }) - toast({ - title: "Saved", - description: "Item saved successfully", - }) + // Individual save toast removed - only show toast in handleSaveAll } } catch (error) { console.error("Save error:", error) @@ -470,6 +487,7 @@ export function PQInputTabs({ try { setIsSaving(true) const answers = form.getValues().answers + let savedCount = 0 // Only save items that are dirty or have new uploads for (let i = 0; i < answers.length; i++) { @@ -478,17 +496,26 @@ export function PQInputTabs({ if (!itemDirty && !hasNewUploads) continue await handleSaveItem(i) + savedCount++ } - toast({ - title: "All Saved", - description: "All items saved successfully", - }) + // 저장된 항목이 있을 때만 토스트 메시지 표시 + if (savedCount > 0) { + toast({ + title: "임시 저장 완료", + description: `항목이 저장되었습니다.`, + }) + } else { + toast({ + title: "저장할 항목 없음", + description: "변경된 항목이 없습니다.", + }) + } } catch (error) { console.error("Save all error:", error) toast({ - title: "Save Error", - description: "Failed to save all items", + title: "저장 실패", + description: "일괄 저장 중 오류가 발생했습니다.", variant: "destructive", }) } finally { @@ -614,53 +641,125 @@ export function PQInputTabs({ {renderProjectInfo()} <Tabs defaultValue={data[0]?.groupName || ""} className="w-full"> - {/* Top Controls */} - <div className="flex justify-between items-center mb-4"> - <TabsList className="grid grid-cols-4"> - {data.map((group) => ( - <TabsTrigger - key={group.groupName} - value={group.groupName} - className="truncate" + {/* Top Controls - Sticky Header */} + <div className="sticky top-0 z-10 bg-background border-b border-border mb-4 pb-4"> + {/* Filter Controls */} + <div className="mb-3 flex items-center gap-4"> + <span className="text-sm font-medium">필터:</span> + <div className="flex items-center gap-4"> + <div className="flex items-center space-x-2"> + <Checkbox + id="showAll" + checked={filterOptions.showAll} + onCheckedChange={(checked) => { + const newOptions = { ...filterOptions, showAll: !!checked }; + if (!checked && !filterOptions.showSaved && !filterOptions.showNotSaved) { + // 최소 하나는 체크되어 있어야 함 + newOptions.showSaved = true; + } + setFilterOptions(newOptions); + }} + /> + <label htmlFor="showAll" className="text-sm">전체 항목</label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="showSaved" + checked={filterOptions.showSaved} + onCheckedChange={(checked) => { + const newOptions = { ...filterOptions, showSaved: !!checked }; + if (!checked && !filterOptions.showAll && !filterOptions.showNotSaved) { + // 최소 하나는 체크되어 있어야 함 + newOptions.showAll = true; + } + setFilterOptions(newOptions); + }} + /> + <label htmlFor="showSaved" className="text-sm text-green-600">Save 항목</label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="showNotSaved" + checked={filterOptions.showNotSaved} + onCheckedChange={(checked) => { + const newOptions = { ...filterOptions, showNotSaved: !!checked }; + if (!checked && !filterOptions.showAll && !filterOptions.showSaved) { + // 최소 하나는 체크되어 있어야 함 + newOptions.showAll = true; + } + setFilterOptions(newOptions); + }} + /> + <label htmlFor="showNotSaved" className="text-sm text-amber-600">Not Save 항목</label> + </div> + </div> + </div> + + <div className="flex justify-between items-center"> + <TabsList className="grid grid-cols-4"> + {data.map((group) => ( + <TabsTrigger + key={group.groupName} + value={group.groupName} + className="truncate" + > + <div className="flex items-center gap-2"> + {/* Mobile: truncated version */} + <span className="block sm:hidden"> + {group.groupName.length > 5 + ? group.groupName.slice(0, 5) + "..." + : group.groupName} + </span> + {/* Desktop: full text */} + <span className="hidden sm:block">{group.groupName}</span> + <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium"> + {group.items.length} + </span> + </div> + </TabsTrigger> + ))} + </TabsList> + + <div className="flex gap-2"> + {/* Save All button */} + <Button + type="button" + variant="outline" + disabled={isSaving || !isAnyItemDirty || shouldDisableInput} + onClick={handleSaveAll} > - <div className="flex items-center gap-2"> - {/* Mobile: truncated version */} - <span className="block sm:hidden"> - {group.groupName.length > 5 - ? group.groupName.slice(0, 5) + "..." - : group.groupName} - </span> - {/* Desktop: full text */} - <span className="hidden sm:block">{group.groupName}</span> - <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium"> - {group.items.length} - </span> - </div> - </TabsTrigger> - ))} - </TabsList> - - <div className="flex gap-2"> - {/* Save All button */} - <Button - type="button" - variant="outline" - disabled={isSaving || !isAnyItemDirty || shouldDisableInput} - onClick={handleSaveAll} - > - {isSaving ? "Saving..." : "임시 저장"} - <Save className="ml-2 h-4 w-4" /> - </Button> - - {/* Submit PQ button */} - <Button - type="button" - disabled={!allSaved || isSubmitting || shouldDisableInput} - onClick={handleSubmitPQ} - > - {isSubmitting ? "Submitting..." : "최종 제출"} - <CheckCircle2 className="ml-2 h-4 w-4" /> - </Button> + {isSaving ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + 임시 저장 + </> + )} + </Button> + + {/* Submit PQ button */} + <Button + type="button" + disabled={!allSaved || isSubmitting || shouldDisableInput} + onClick={handleSubmitPQ} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 제출 중... + </> + ) : ( + <> + <CheckCircle2 className="mr-2 h-4 w-4" /> + 최종 제출 + </> + )} + </Button> + </div> </div> </div> @@ -681,7 +780,12 @@ export function PQInputTabs({ const isItemDirty = !!dirtyFieldsItem const hasNewUploads = newUploads.length > 0 const canSave = isItemDirty || hasNewUploads - + + // 면제된 항목은 입력 비활성화 + const isDisabled = shouldDisableInput + + // 필터링 적용 + if (!shouldShowItem(isSaved)) return null return ( <Collapsible key={criteriaId} defaultOpen={isReadOnly || !isSaved} className="w-full"> @@ -698,7 +802,6 @@ export function PQInputTabs({ </CollapsibleTrigger> <CardTitle className="text-md"> {code} - {checkPoint} - </CardTitle> </div> {description && ( @@ -731,14 +834,16 @@ export function PQInputTabs({ </span> )} + {/* 개별 저장 버튼 주석처리 <Button size="sm" variant="outline" - disabled={isSaving || !canSave} + disabled={isSaving || !canSave || isDisabled} onClick={() => handleSaveItem(answerIndex)} > Save </Button> + */} </div> </div> </CardHeader> @@ -798,7 +903,7 @@ export function PQInputTabs({ <Input {...field} type="email" - disabled={shouldDisableInput} + disabled={isDisabled} placeholder="example@company.com" onChange={(e) => { field.onChange(e) @@ -811,14 +916,18 @@ export function PQInputTabs({ /> ); case "PHONE": + case "FAX": return ( <Input {...field} type="tel" - disabled={shouldDisableInput} + disabled={isDisabled} placeholder="02-1234-5678" onChange={(e) => { - field.onChange(e) + // 전화번호 형식만 허용 (숫자, -, +, 공백) + const value = e.target.value; + const filteredValue = value.replace(/[^\d\-\+\s]/g, ''); + field.onChange(filteredValue); form.setValue( `answers.${answerIndex}.saved`, false, @@ -832,7 +941,7 @@ export function PQInputTabs({ <Input {...field} type="text" - disabled={shouldDisableInput} + disabled={isDisabled} placeholder="숫자를 입력하세요" onChange={(e) => { // 숫자만 허용 @@ -853,7 +962,7 @@ export function PQInputTabs({ <div className="space-y-2"> <Textarea {...field} - disabled={shouldDisableInput} + disabled={isDisabled} className="min-h-24" placeholder="텍스트 답변을 입력하세요" onChange={(e) => { @@ -874,7 +983,7 @@ export function PQInputTabs({ return ( <Textarea {...field} - disabled={shouldDisableInput} + disabled={isDisabled} className="min-h-24" placeholder="답변을 입력해주세요." onChange={(e) => { @@ -916,7 +1025,7 @@ export function PQInputTabs({ handleDropAccepted(criteriaId, files) } onDropRejected={handleDropRejected} - disabled={shouldDisableInput} + disabled={isDisabled} > {() => ( <FormItem> @@ -1050,8 +1159,8 @@ export function PQInputTabs({ </div> )} - {/* SHI 코멘트 필드 (읽기 전용) */} - {item.shiComment && ( + {/* SHI 코멘트 필드 (읽기 전용) - 승인 상태에서는 거부사유 숨김 */} + {item.shiComment && currentPQ?.status !== "APPROVED" && ( <FormField control={form.control} name={`answers.${answerIndex}.shiComment`} @@ -1082,7 +1191,7 @@ export function PQInputTabs({ <FormControl> <Textarea {...field} - disabled={shouldDisableInput} + disabled={isDisabled} className="min-h-20 bg-muted/50" placeholder="벤더 Reply를 입력하세요." onChange={(e) => { @@ -1180,10 +1289,17 @@ export function PQInputTabs({ onClick={() => setShowConfirmDialog(false)} disabled={isSubmitting} > - Cancel + 취소 </Button> <Button onClick={handleConfirmSubmission} disabled={isSubmitting}> - {isSubmitting ? "Submitting..." : "Confirm Submit"} + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 제출 중... + </> + ) : ( + "제출 확인" + )} </Button> </DialogFooter> </DialogContent> diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx index ca5f314f..44916dce 100644 --- a/components/pq-input/pq-review-wrapper.tsx +++ b/components/pq-input/pq-review-wrapper.tsx @@ -21,7 +21,7 @@ import { DialogTitle } from "@/components/ui/dialog" import { useToast } from "@/hooks/use-toast" -import { CheckCircle, AlertCircle, Paperclip } from "lucide-react" +import { CheckCircle, AlertCircle, Paperclip, Square } from "lucide-react" import { PQGroupData } from "@/lib/pq/service" import { approvePQAction, rejectPQAction, updateSHICommentAction } from "@/lib/pq/service" // import * as ExcelJS from 'exceljs'; @@ -48,14 +48,14 @@ interface PQReviewWrapperProps { pqData: PQGroupData[] vendorId: number pqSubmission: PQSubmission - canReview: boolean + vendorInfo?: any // 협력업체 정보 (선택사항) } export function PQReviewWrapper({ pqData, vendorId, pqSubmission, - canReview + vendorInfo }: PQReviewWrapperProps) { const router = useRouter() const { toast } = useToast() @@ -66,6 +66,7 @@ export function PQReviewWrapper({ const [rejectReason, setRejectReason] = React.useState("") const [shiComments, setShiComments] = React.useState<Record<number, string>>({}) const [isUpdatingComment, setIsUpdatingComment] = React.useState<number | null>(null) + // 코드 순서로 정렬하는 함수 (1-1-1, 1-1-2, 1-2-1 순서) const sortByCode = (items: any[]) => { @@ -88,6 +89,7 @@ export function PQReviewWrapper({ }) } + // 기존 SHI 코멘트를 로컬 상태에 초기화 React.useEffect(() => { const initialComments: Record<number, string> = {} @@ -369,25 +371,27 @@ export function PQReviewWrapper({ <Card key={item.criteriaId}> <CardHeader> <div className="flex justify-between items-start"> - <div> - <CardTitle className="text-base"> - {item.code} - {item.checkPoint} - - - </CardTitle> - {item.description && ( - <CardDescription className="mt-1 whitespace-pre-wrap"> - {item.description} - </CardDescription> - )} - {item.remarks && ( - <div className="mt-2 p-2 rounded-md"> - <p className="text-sm font-medium text-muted-foreground mb-1">Remark:</p> - <p className="text-sm whitespace-pre-wrap"> - {item.remarks} - </p> + <div className="flex-1"> + <div className="flex items-start gap-3"> + <div className="flex-1"> + <CardTitle className="text-base"> + {item.code} - {item.checkPoint} + </CardTitle> + {item.description && ( + <CardDescription className="mt-1 whitespace-pre-wrap"> + {item.description} + </CardDescription> + )} + {item.remarks && ( + <div className="mt-2 p-2 rounded-md"> + <p className="text-sm font-medium text-muted-foreground mb-1">Remark:</p> + <p className="text-sm whitespace-pre-wrap"> + {item.remarks} + </p> + </div> + )} </div> - )} + </div> </div> {/* 항목 상태 표시 */} {!!item.answer || item.attachments.length > 0 ? ( @@ -606,26 +610,27 @@ export function PQReviewWrapper({ ))} {/* 검토 버튼 */} - {canReview && ( <div className="fixed bottom-4 right-4 bg-background p-4 rounded-lg shadow-md border"> <div className="flex gap-2"> - {/* <Button - variant="outline" + + + {/* <Button + variant="outline" onClick={handleExportToExcel} disabled={isExporting} > <Download className="h-4 w-4 mr-2" /> {isExporting ? "내보내기 중..." : "Excel 내보내기"} </Button> */} - <Button - variant="outline" + <Button + variant="outline" onClick={() => setShowRejectDialog(true)} disabled={isRejecting} > {isRejecting ? "거부 중..." : "거부"} </Button> - <Button - variant="default" + <Button + variant="default" onClick={() => setShowApproveDialog(true)} disabled={isApproving} > @@ -633,7 +638,7 @@ export function PQReviewWrapper({ </Button> </div> </div> - )} + {/* 승인 확인 다이얼로그 */} <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}> diff --git a/lib/forms/stat.ts b/lib/forms/stat.ts index 054f2462..f13bab61 100644 --- a/lib/forms/stat.ts +++ b/lib/forms/stat.ts @@ -218,39 +218,13 @@ export async function getVendorFormStatus(projectId?: number): Promise<VendorFor -export async function getFormStatusByVendor(projectId: number, formCode: string): Promise<FormStatusByVendor[]> { +export async function getFormStatusByVendor(projectId: number, contractItemId: number, formCode: string): Promise<FormStatusByVendor[]> { try { const session = await getServerSession(authOptions) if (!session?.user?.id) { throw new Error("인증이 필요합니다.") } - const vendorStatusList: FormStatusByVendor[] = [] - const vendorId = Number(session.user.companyId) - - const vendorContracts = await db - .select({ - id: contracts.id, - projectId: contracts.projectId - }) - .from(contracts) - .where( - and( - eq(contracts.vendorId, vendorId), - eq(contracts.projectId, projectId) - ) - ) - - const contractIds = vendorContracts.map(v => v.id) - - const contractItemsList = await db - .select({ - id: contractItems.id - }) - .from(contractItems) - .where(inArray(contractItems.contractId, contractIds)) - - const contractItemIds = contractItemsList.map(v => v.id) let vendorFormCount = 0 let vendorTagCount = 0 @@ -277,7 +251,7 @@ export async function getFormStatusByVendor(projectId: number, formCode: string) .from(forms) .where( and( - inArray(forms.contractItemId, contractItemIds), + eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode) ) ) @@ -294,28 +268,16 @@ export async function getFormStatusByVendor(projectId: number, formCode: string) .from(formEntries) .where( and( - inArray(formEntries.contractItemId, contractItemIds), + eq(formEntries.contractItemId, contractItemId), eq(formEntries.formCode, formCode) ) ) // 6. TAG별 편집 가능 필드 조회 - const editableFieldsByTag = new Map<string, string[]>() - - for (const contractItemId of contractItemIds) { - const tagFields = await getEditableFieldsByTag(contractItemId, projectId) - - tagFields.forEach((fields, tagNo) => { - if (!editableFieldsByTag.has(tagNo)) { - editableFieldsByTag.set(tagNo, fields) - } else { - const existingFields = editableFieldsByTag.get(tagNo) || [] - const mergedFields = [...new Set([...existingFields, ...fields])] - editableFieldsByTag.set(tagNo, mergedFields) - } - }) - } + const editableFieldsByTag = await getEditableFieldsByTag(contractItemId, projectId) + const vendorStatusList: VendorFormStatus[] = [] + for (const entry of entriesList) { const metaResult = await db .select({ diff --git a/lib/pq/service.ts b/lib/pq/service.ts index 67be5398..f58a1d4d 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -94,6 +94,10 @@ export async function getPQDataByVendorId( projectId?: number
): Promise<PQGroupData[]> {
try {
+ // 파라미터 유효성 검증
+ if (isNaN(vendorId)) {
+ throw new Error("Invalid vendorId parameter");
+ }
// 기본 쿼리 구성
const selectObj = {
criteriaId: pqCriterias.id,
@@ -1531,6 +1535,7 @@ export async function getAllPQsByVendorId(vendorId: number) { // 특정 PQ의 상세 정보 조회 (개별 PQ 페이지용)
export async function getPQById(pqSubmissionId: number, vendorId: number) {
try {
+
const pq = await db
.select({
id: vendorPQSubmissions.id,
@@ -1543,12 +1548,15 @@ export async function getPQById(pqSubmissionId: number, vendorId: number) { approvedAt: vendorPQSubmissions.approvedAt,
rejectedAt: vendorPQSubmissions.rejectedAt,
rejectReason: vendorPQSubmissions.rejectReason,
-
+
// 벤더 정보 (추가)
vendorName: vendors.vendorName,
vendorCode: vendors.vendorCode,
vendorStatus: vendors.status,
-
+ vendorCountry: vendors.country,
+ vendorEmail: vendors.email,
+ vendorPhone: vendors.phone,
+
// 프로젝트 정보 (조인)
projectName: projects.name,
projectCode: projects.code,
@@ -1564,11 +1572,11 @@ export async function getPQById(pqSubmissionId: number, vendorId: number) { )
.limit(1)
.then(rows => rows[0]);
-
+
if (!pq) {
throw new Error("PQ not found or access denied");
}
-
+
return pq;
} catch (error) {
console.error("Error fetching PQ by ID:", error);
@@ -4046,11 +4054,12 @@ export async function updatePqValidToAction(input: UpdatePqValidToInput) { }
}
+
// SHI 참석자 총 인원수 계산 함수
export async function getTotalShiAttendees(shiAttendees: Record<string, unknown> | null): Promise<number> {
if (!shiAttendees) return 0
-
+
let total = 0
Object.entries(shiAttendees).forEach(([key, value]) => {
if (value && typeof value === 'object' && 'checked' in value && 'count' in value) {
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 78d2479a..f536a142 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3292,6 +3292,7 @@ async function processSingleVendor({ currentUser, designAttachments }); + console.log("tbeSession 생성 완료", tbeSession); // 이메일 발송 처리 (사용자가 선택한 경우에만) let emailSent = null; if (hasToSendEmail) { diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index e5c1f51e..55549a6d 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -765,11 +765,11 @@ export function RfqVendorTable({ } const statusConfig = { - "진행중": { variant: "default", icon: <Clock className="h-3 w-3 mr-1" />, color: "text-blue-600" }, - "검토중": { variant: "secondary", icon: <Eye className="h-3 w-3 mr-1" />, color: "text-orange-600" }, - "보류": { variant: "outline", icon: <AlertCircle className="h-3 w-3 mr-1" />, color: "text-yellow-600" }, - "완료": { variant: "success", icon: <CheckCircle className="h-3 w-3 mr-1" />, color: "text-green-600" }, - "취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" />, color: "text-red-600" }, + "진행중": { variant: "default", icon: <Clock className="h-3 w-3 mr-1" />}, + "검토중": { variant: "secondary", icon: <Eye className="h-3 w-3 mr-1" /> }, + "보류": { variant: "outline", icon: <AlertCircle className="h-3 w-3 mr-1" /> }, + "완료": { variant: "success", icon: <CheckCircle className="h-3 w-3 mr-1" /> }, + "취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" /> }, }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; return ( diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts index da0a5a4c..346576e5 100644 --- a/lib/tbe-last/service.ts +++ b/lib/tbe-last/service.ts @@ -1,11 +1,11 @@ // lib/tbe-last/service.ts 'use server' -import { revalidatePath, unstable_cache } from "next/cache"; +import { revalidatePath, revalidateTag, unstable_cache } from "next/cache"; import db from "@/db/db"; import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm"; import { tbeLastView, tbeDocumentsView } from "@/db/schema"; -import { rfqPrItems } from "@/db/schema/rfqLast"; +import { rfqPrItems, rfqsLast } from "@/db/schema/rfqLast"; import {rfqLastDetails, rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments,rfqLastTbeSessions } from "@/db/schema"; import { filterColumns } from "@/lib/filter-columns"; import { GetTBELastSchema } from "./validations"; @@ -320,10 +320,22 @@ export async function updateTbeEvaluation( // 상태 업데이트 if (data.status !== undefined) { updateData.status = data.status - + // 완료 상태로 변경 시 종료일 설정 if (data.status === "완료") { updateData.actualEndDate = new Date() + + // TBE 완료 시 연결된 RFQ 상태를 "TBE 완료"로 업데이트 + if (currentTbeSession.rfqsLastId) { + await db + .update(rfqsLast) + .set({ + status: "TBE 완료", + updatedBy: userId, + updatedAt: new Date() + }) + .where(eq(rfqsLast.id, currentTbeSession.rfqsLastId)) + } } } @@ -337,10 +349,11 @@ export async function updateTbeEvaluation( // 캐시 초기화 revalidateTag(`tbe-session-${tbeSessionId}`) revalidateTag(`tbe-sessions`) - + // RFQ 관련 캐시도 초기화 if (currentTbeSession.rfqsLastId) { revalidateTag(`rfq-${currentTbeSession.rfqsLastId}`) + revalidateTag(`rfqs`) } return { diff --git a/lib/tbe-last/vendor/tbe-table.tsx b/lib/tbe-last/vendor/tbe-table.tsx index d7ee0a06..48242088 100644 --- a/lib/tbe-last/vendor/tbe-table.tsx +++ b/lib/tbe-last/vendor/tbe-table.tsx @@ -21,6 +21,7 @@ import { VendorQADialog } from "./vendor-comment-dialog" import { VendorDocumentsSheet } from "./vendor-documents-sheet" import { VendorPrItemsDialog } from "./vendor-pr-items-dialog" import { getTBEforVendor } from "../vendor-tbe-service" +import { VendorEvaluationViewDialog } from "./vendor-evaluation-view-dialog" interface TbeVendorTableProps { promises: Promise<[ @@ -217,6 +218,13 @@ export function TbeVendorTable({ promises }: TbeVendorTableProps) { onOpenChange={setPrItemsOpen} rfqId={selectedRfqId} /> + {/* Evaluation View Dialog */} + <VendorEvaluationViewDialog + open={evaluationViewOpen} + onOpenChange={setEvaluationViewOpen} + selectedSession={selectedSession} + sessionDetail={sessionDetail} + /> </> ) }
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx index fe7f55c7..76d66960 100644 --- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx +++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx @@ -1,4 +1,4 @@ -// import-from-dolce-button.tsx - 최적화된 버전 +// import-from-dolce-button.tsx - 리비전/첨부파일 포함 버전 "use client" import * as React from "react" @@ -223,20 +223,30 @@ export function ImportFromDOLCEButton({ } }, [debouncedProjectIds, fetchAllImportStatus]) - - - // 🔥 전체 통계 메모이제이션 + // 🔥 전체 통계 메모이제이션 - 리비전과 첨부파일 추가 const totalStats = React.useMemo(() => { const statuses = Array.from(importStatusMap.values()) return statuses.reduce((acc, status) => ({ availableDocuments: acc.availableDocuments + (status.availableDocuments || 0), newDocuments: acc.newDocuments + (status.newDocuments || 0), updatedDocuments: acc.updatedDocuments + (status.updatedDocuments || 0), + availableRevisions: acc.availableRevisions + (status.availableRevisions || 0), + newRevisions: acc.newRevisions + (status.newRevisions || 0), + updatedRevisions: acc.updatedRevisions + (status.updatedRevisions || 0), + availableAttachments: acc.availableAttachments + (status.availableAttachments || 0), + newAttachments: acc.newAttachments + (status.newAttachments || 0), + updatedAttachments: acc.updatedAttachments + (status.updatedAttachments || 0), importEnabled: acc.importEnabled || status.importEnabled }), { availableDocuments: 0, newDocuments: 0, updatedDocuments: 0, + availableRevisions: 0, + newRevisions: 0, + updatedRevisions: 0, + availableAttachments: 0, + newAttachments: 0, + updatedAttachments: 0, importEnabled: false }) }, [importStatusMap]) @@ -347,7 +357,14 @@ export function ImportFromDOLCEButton({ } }, [projectIds, fetchAllImportStatus, onImportComplete, t]) - // 🔥 상태 뱃지 메모이제이션 + // 🔥 전체 변경 사항 계산 + const totalChanges = React.useMemo(() => { + return totalStats.newDocuments + totalStats.updatedDocuments + + totalStats.newRevisions + totalStats.updatedRevisions + + totalStats.newAttachments + totalStats.updatedAttachments + }, [totalStats]) + + // 🔥 상태 뱃지 메모이제이션 - 리비전과 첨부파일 포함 const statusBadge = React.useMemo(() => { if (loadingVendorProjects) { return <Badge variant="secondary">{t('dolceImport.status.loadingProjectInfo')}</Badge> @@ -365,7 +382,7 @@ export function ImportFromDOLCEButton({ return <Badge variant="secondary">{t('dolceImport.status.importDisabled')}</Badge> } - if (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) { + if (totalChanges > 0) { return ( <Badge variant="samsung" className="gap-1"> <AlertTriangle className="w-3 h-3" /> @@ -380,10 +397,10 @@ export function ImportFromDOLCEButton({ {t('dolceImport.status.synchronized')} </Badge> ) - }, [loadingVendorProjects, statusLoading, importStatusMap.size, totalStats, projectIds.length, t]) + }, [loadingVendorProjects, statusLoading, importStatusMap.size, totalStats.importEnabled, totalChanges, projectIds.length, t]) - const canImport = totalStats.importEnabled && - (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) + // 🔥 가져오기 가능 여부 - 리비전과 첨부파일도 체크 + const canImport = totalStats.importEnabled && totalChanges > 0 // 🔥 새로고침 핸들러 최적화 const handleRefresh = React.useCallback(() => { @@ -391,24 +408,20 @@ export function ImportFromDOLCEButton({ fetchAllImportStatus() }, [fetchAllImportStatus]) - - // 🔥 자동 동기화 실행 (기존 useEffect들 다음에 추가) - React.useEffect(() => { + // 🔥 자동 동기화 실행 (기존 useEffect들 다음에 추가) + React.useEffect(() => { // 조건: 가져오기 가능하고, 동기화할 항목이 있고, 현재 진행중이 아닐 때 - if (canImport && - (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) && - !isImporting && - !isDialogOpen) { + if (canImport && totalChanges > 0 && !isImporting && !isDialogOpen) { // 상태 로딩이 완료된 후 잠깐 대기 (사용자가 상태를 확인할 수 있도록) const timer = setTimeout(() => { - console.log(`🔄 자동 동기화 시작: 새 문서 ${totalStats.newDocuments}개, 업데이트 ${totalStats.updatedDocuments}개`) + console.log(`🔄 자동 동기화 시작: ${totalChanges}개 항목`) // 동기화 시작 알림 toast.info( - '새로운 문서가 발견되어 자동 동기화를 시작합니다', + '새로운 변경사항이 발견되어 자동 동기화를 시작합니다', { - description: `새 문서 ${totalStats.newDocuments}개, 업데이트 ${totalStats.updatedDocuments}개`, + description: `총 ${totalChanges}개 항목 (문서/리비전/첨부파일)`, duration: 3000 } ) @@ -424,9 +437,8 @@ export function ImportFromDOLCEButton({ return () => clearTimeout(timer) } - }, [canImport, totalStats.newDocuments, totalStats.updatedDocuments, isImporting, isDialogOpen, handleImport]) + }, [canImport, totalChanges, isImporting, isDialogOpen, handleImport]) - // 로딩 중이거나 projectIds가 없으면 버튼을 표시하지 않음 if (projectIds.length === 0) { return null @@ -449,12 +461,12 @@ export function ImportFromDOLCEButton({ <Download className="w-4 h-4" /> )} <span className="hidden sm:inline">{t('dolceImport.buttons.getList')}</span> - {totalStats.newDocuments + totalStats.updatedDocuments > 0 && ( + {totalChanges > 0 && ( <Badge variant="samsung" className="h-5 w-5 p-0 text-xs flex items-center justify-center" > - {totalStats.newDocuments + totalStats.updatedDocuments} + {totalChanges} </Badge> )} </Button> @@ -493,21 +505,75 @@ export function ImportFromDOLCEButton({ <div className="space-y-3"> <Separator /> - <div className="grid grid-cols-2 gap-4 text-sm"> - <div> - <div className="text-muted-foreground">{t('dolceImport.labels.newDocuments')}</div> - <div className="font-medium">{totalStats.newDocuments || 0}</div> - </div> - <div> - <div className="text-muted-foreground">{t('dolceImport.labels.updates')}</div> - <div className="font-medium">{totalStats.updatedDocuments || 0}</div> + {/* 문서 정보 */} + <div className="space-y-2"> + <div className="font-medium text-sm">{t('dolceImport.labels.documents')}</div> + <div className="grid grid-cols-3 gap-3 text-sm"> + <div> + <div className="text-muted-foreground">{t('dolceImport.labels.total')}</div> + <div className="font-medium">{totalStats.availableDocuments || 0}</div> + </div> + <div> + <div className="text-muted-foreground">{t('dolceImport.labels.new')}</div> + <div className="font-medium text-green-600">{totalStats.newDocuments || 0}</div> + </div> + <div> + <div className="text-muted-foreground">{t('dolceImport.labels.updates')}</div> + <div className="font-medium text-blue-600">{totalStats.updatedDocuments || 0}</div> + </div> </div> </div> - <div className="text-sm"> - <div className="text-muted-foreground">{t('dolceImport.labels.totalDocuments')}</div> - <div className="font-medium">{totalStats.availableDocuments || 0}</div> - </div> + {/* 리비전 정보 */} + {(totalStats.availableRevisions > 0 || totalStats.newRevisions > 0 || totalStats.updatedRevisions > 0) && ( + <div className="space-y-2"> + <div className="font-medium text-sm">{t('dolceImport.labels.revisions')}</div> + <div className="grid grid-cols-3 gap-3 text-sm"> + <div> + <div className="text-muted-foreground">{t('dolceImport.labels.total')}</div> + <div className="font-medium">{totalStats.availableRevisions || 0}</div> + </div> + <div> + <div className="text-muted-foreground">{t('dolceImport.labels.new')}</div> + <div className="font-medium text-green-600">{totalStats.newRevisions || 0}</div> + </div> + <div> + <div className="text-muted-foreground">{t('dolceImport.labels.updates')}</div> + <div className="font-medium text-blue-600">{totalStats.updatedRevisions || 0}</div> + </div> + </div> + </div> + )} + + {/* 첨부파일 정보 */} + {(totalStats.availableAttachments > 0 || totalStats.newAttachments > 0 || totalStats.updatedAttachments > 0) && ( + <div className="space-y-2"> + <div className="font-medium text-sm">{t('dolceImport.labels.attachments')}</div> + <div className="grid grid-cols-3 gap-3 text-sm"> + <div> + <div className="text-muted-foreground">{t('dolceImport.labels.total')}</div> + <div className="font-medium">{totalStats.availableAttachments || 0}</div> + </div> + <div> + <div className="text-muted-foreground">{t('dolceImport.labels.new')}</div> + <div className="font-medium text-green-600">{totalStats.newAttachments || 0}</div> + </div> + <div> + <div className="text-muted-foreground">{t('dolceImport.labels.updates')}</div> + <div className="font-medium text-blue-600">{totalStats.updatedAttachments || 0}</div> + </div> + </div> + </div> + )} + + {/* 요약 */} + {totalChanges > 0 && ( + <div className="bg-muted/50 rounded-lg p-3"> + <div className="text-sm font-medium"> + {t('dolceImport.labels.totalChanges')}: <span className="text-primary">{totalChanges}</span> + </div> + </div> + )} {/* 각 프로젝트별 세부 정보 */} {projectIds.length > 1 && ( @@ -522,11 +588,29 @@ export function ImportFromDOLCEButton({ <div key={projectId} className="text-xs"> <div className="font-medium">{t('dolceImport.labels.projectLabel', { projectId })}</div> {status ? ( - <div className="text-muted-foreground"> - {t('dolceImport.descriptions.projectDetails', { - newDocuments: status.newDocuments, - updatedDocuments: status.updatedDocuments - })} + <div className="text-muted-foreground space-y-1"> + <div> + {t('dolceImport.descriptions.projectDocuments', { + newDocuments: status.newDocuments, + updatedDocuments: status.updatedDocuments + })} + </div> + {(status.newRevisions > 0 || status.updatedRevisions > 0) && ( + <div> + {t('dolceImport.descriptions.projectRevisions', { + newRevisions: status.newRevisions, + updatedRevisions: status.updatedRevisions + })} + </div> + )} + {(status.newAttachments > 0 || status.updatedAttachments > 0) && ( + <div> + {t('dolceImport.descriptions.projectAttachments', { + newAttachments: status.newAttachments, + updatedAttachments: status.updatedAttachments + })} + </div> + )} </div> ) : ( <div className="text-destructive">{t('dolceImport.status.statusCheckFailed')}</div> @@ -595,14 +679,28 @@ export function ImportFromDOLCEButton({ <div className="rounded-lg border p-4 space-y-3"> <div className="flex items-center justify-between text-sm"> <span>{t('dolceImport.labels.itemsToImport')}</span> - <span className="font-medium"> - {totalStats.newDocuments + totalStats.updatedDocuments} - </span> + <span className="font-medium">{totalChanges}</span> + </div> + + <div className="space-y-1 text-xs text-muted-foreground"> + {totalStats.newDocuments + totalStats.updatedDocuments > 0 && ( + <div> + • {t('dolceImport.labels.documents')}: {totalStats.newDocuments} {t('dolceImport.labels.new')}, {totalStats.updatedDocuments} {t('dolceImport.labels.updates')} + </div> + )} + {totalStats.newRevisions + totalStats.updatedRevisions > 0 && ( + <div> + • {t('dolceImport.labels.revisions')}: {totalStats.newRevisions} {t('dolceImport.labels.new')}, {totalStats.updatedRevisions} {t('dolceImport.labels.updates')} + </div> + )} + {totalStats.newAttachments + totalStats.updatedAttachments > 0 && ( + <div> + • {t('dolceImport.labels.attachments')}: {totalStats.newAttachments} {t('dolceImport.labels.new')}, {totalStats.updatedAttachments} {t('dolceImport.labels.updates')} + </div> + )} </div> <div className="text-xs text-muted-foreground"> - {t('dolceImport.descriptions.includesNewAndUpdated')} - <br /> {t('dolceImport.descriptions.b4DocumentsNote')} {projectIds.length > 1 && ( <> |
