From defda07c0bb4b0bd444ca8dc4fd3f89322bda0ce Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 3 Oct 2025 04:48:47 +0000 Subject: (대표님) edp, tbe, dolce 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/form-data/form-data-table.tsx | 10 +- components/form-data/import-excel-form.tsx | 312 +++++++++++++------------- components/form-data/spreadJS-dialog copy.tsx | 2 +- components/form-data/spreadJS-dialog.tsx | 71 ++++-- components/pq-input/pq-input-tabs.tsx | 278 ++++++++++++++++------- components/pq-input/pq-review-wrapper.tsx | 63 +++--- 6 files changed, 446 insertions(+), 290 deletions(-) (limited to 'components') 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 | null>(null); @@ -115,14 +115,14 @@ export default function DynamicTable({ const [formStats, setFormStats] = React.useState(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 && ( +
{/* Tag Count */} @@ -810,7 +810,7 @@ export default function DynamicTable({
- )} + ): 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 = {}; 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(); - + // 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
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 ( SEDP Template - {formCode}
- {availableTemplates.length > 1 && ( + {availableTemplates.length > 0 ? ( + // 템플릿이 2개 이상일 때: Select 박스 표시
Template:
- )} + ) : availableTemplates.length === 1 ? ( + // 템플릿이 정확히 1개일 때: 템플릿 이름을 텍스트로 표시 +
+ Template: + + {availableTemplates[0].NAME} ({availableTemplates[0].TMPL_TYPE}) + +
+ ) : null} {selectedTemplate && (
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()} - {/* Top Controls */} -
- - {data.map((group) => ( - + {/* Filter Controls */} +
+ 필터: +
+
+ { + const newOptions = { ...filterOptions, showAll: !!checked }; + if (!checked && !filterOptions.showSaved && !filterOptions.showNotSaved) { + // 최소 하나는 체크되어 있어야 함 + newOptions.showSaved = true; + } + setFilterOptions(newOptions); + }} + /> + +
+
+ { + const newOptions = { ...filterOptions, showSaved: !!checked }; + if (!checked && !filterOptions.showAll && !filterOptions.showNotSaved) { + // 최소 하나는 체크되어 있어야 함 + newOptions.showAll = true; + } + setFilterOptions(newOptions); + }} + /> + +
+
+ { + const newOptions = { ...filterOptions, showNotSaved: !!checked }; + if (!checked && !filterOptions.showAll && !filterOptions.showSaved) { + // 최소 하나는 체크되어 있어야 함 + newOptions.showAll = true; + } + setFilterOptions(newOptions); + }} + /> + +
+
+
+ +
+ + {data.map((group) => ( + +
+ {/* Mobile: truncated version */} + + {group.groupName.length > 5 + ? group.groupName.slice(0, 5) + "..." + : group.groupName} + + {/* Desktop: full text */} + {group.groupName} + + {group.items.length} + +
+
+ ))} +
+ +
+ {/* Save All button */} + - - {/* Submit PQ button */} - + {isSaving ? ( + <> + + 저장 중... + + ) : ( + <> + + 임시 저장 + + )} + + + {/* Submit PQ button */} + +
@@ -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 ( @@ -698,7 +802,6 @@ export function PQInputTabs({ {code} - {checkPoint} -
{description && ( @@ -731,14 +834,16 @@ export function PQInputTabs({ )} + {/* 개별 저장 버튼 주석처리 + */}
@@ -798,7 +903,7 @@ export function PQInputTabs({ { field.onChange(e) @@ -811,14 +916,18 @@ export function PQInputTabs({ /> ); case "PHONE": + case "FAX": return ( { - 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({ { // 숫자만 허용 @@ -853,7 +962,7 @@ export function PQInputTabs({