summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx48
-rw-r--r--app/api/revisions/max-serial-no/route.ts175
-rw-r--r--components/form-data/form-data-table.tsx10
-rw-r--r--components/form-data/import-excel-form.tsx312
-rw-r--r--components/form-data/spreadJS-dialog copy.tsx2
-rw-r--r--components/form-data/spreadJS-dialog.tsx71
-rw-r--r--components/pq-input/pq-input-tabs.tsx278
-rw-r--r--components/pq-input/pq-review-wrapper.tsx63
-rw-r--r--lib/forms/stat.ts50
-rw-r--r--lib/pq/service.ts19
-rw-r--r--lib/rfq-last/service.ts1
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx10
-rw-r--r--lib/tbe-last/service.ts21
-rw-r--r--lib/tbe-last/vendor/tbe-table.tsx8
-rw-r--r--lib/vendor-document-list/ship/import-from-dolce-button.tsx188
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 && (
<>