summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-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
6 files changed, 446 insertions, 290 deletions
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}>