diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/form-data/add-formTag-dialog.tsx | 4 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 25 | ||||
| -rw-r--r-- | components/form-data/sedp-compare-dialog.tsx | 21 | ||||
| -rw-r--r-- | components/form-data/spreadJS-dialog.tsx | 1157 | ||||
| -rw-r--r-- | components/form-data/update-form-sheet.tsx | 6 | ||||
| -rw-r--r-- | components/pq-input/pq-input-tabs.tsx | 2 | ||||
| -rw-r--r-- | components/pq/client-pq-input-wrapper.tsx | 90 | ||||
| -rw-r--r-- | components/pq/pq-input-tabs.tsx | 884 | ||||
| -rw-r--r-- | components/pq/pq-review-detail.tsx | 888 | ||||
| -rw-r--r-- | components/pq/pq-review-table.tsx | 344 | ||||
| -rw-r--r-- | components/pq/project-select-wrapper.tsx | 35 | ||||
| -rw-r--r-- | components/pq/project-select.tsx | 173 | ||||
| -rw-r--r-- | components/ui/file-list.tsx | 4 | ||||
| -rw-r--r-- | components/vendor-regular-registrations/additional-info-dialog.tsx | 502 | ||||
| -rw-r--r-- | components/vendor-regular-registrations/document-status-dialog.tsx | 265 | ||||
| -rw-r--r-- | components/vendor-regular-registrations/skip-reason-dialog.tsx | 98 |
16 files changed, 1539 insertions, 2959 deletions
diff --git a/components/form-data/add-formTag-dialog.tsx b/components/form-data/add-formTag-dialog.tsx index 9d80de8c..5a462d2b 100644 --- a/components/form-data/add-formTag-dialog.tsx +++ b/components/form-data/add-formTag-dialog.tsx @@ -95,6 +95,7 @@ interface AddFormTagDialogProps { formCode: string; formName?: string; contractItemId: number; + packageCode: string; open?: boolean; onOpenChange?: (open: boolean) => void; } @@ -104,6 +105,7 @@ export function AddFormTagDialog({ formCode, formName, contractItemId, + packageCode, open: externalOpen, onOpenChange: externalOnOpenChange }: AddFormTagDialogProps) { @@ -361,7 +363,7 @@ export function AddFormTagDialog({ }; try { - const res = await createTagInForm(tagData, contractItemId, formCode); + const res = await createTagInForm(tagData, contractItemId, formCode, packageCode); if ("error" in res) { failedTags.push({ tag: row.tagNo, error: res.error }); } else { diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index be37de7a..b2fadacf 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -33,6 +33,8 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { + getPackageCodeById, + getProjectById, getProjectCodeById, getReportTempList, sendFormDataToSEDP, @@ -220,6 +222,8 @@ export default function DynamicTable({ // SEDP compare dialog state const [sedpCompareOpen, setSedpCompareOpen] = React.useState(false); const [projectCode, setProjectCode] = React.useState<string>(''); + const [projectType, setProjectType] = React.useState<string>('plant'); + const [packageCode, setPackageCode] = React.useState<string>(''); // 새로 추가된 Template 다이얼로그 상태 const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false); @@ -255,12 +259,26 @@ export default function DynamicTable({ getTempCount(); }, [contractItemId, formId, tempUpDialog]); + React.useEffect(() => { + const getPackageCode = async () => { + try { + const packageCode = await getPackageCodeById(contractItemId); + setPackageCode(packageCode || ''); // 빈 문자열이나 다른 기본값 + } catch (error) { + console.error('패키지 조회 실패:', error); + setPackageCode(''); + } + }; + + getPackageCode(); + }, [contractItemId]) // Get project code when component mounts React.useEffect(() => { const getProjectCode = async () => { try { - const code = await getProjectCodeById(projectId); - setProjectCode(code); + const project = await getProjectById(projectId); + setProjectCode(project.code); + setProjectType(project.type); } catch (error) { console.error("Error fetching project code:", error); toast.error("Failed to fetch project code"); @@ -911,6 +929,7 @@ export default function DynamicTable({ formCode={formCode} formName={`Form ${formCode}`} contractItemId={contractItemId} + packageCode={packageCode} open={addTagDialogOpen} onOpenChange={setAddTagDialogOpen} /> @@ -982,6 +1001,8 @@ export default function DynamicTable({ projectCode={projectCode} formCode={formCode} fetchTagDataFromSEDP={fetchTagDataFromSEDP} + projectType={projectType} + packageCode={packageCode} /> {/* Other dialogs */} diff --git a/components/form-data/sedp-compare-dialog.tsx b/components/form-data/sedp-compare-dialog.tsx index 1a9938bd..9a6c8098 100644 --- a/components/form-data/sedp-compare-dialog.tsx +++ b/components/form-data/sedp-compare-dialog.tsx @@ -22,6 +22,8 @@ interface SEDPCompareDialogProps { projectCode: string; formCode: string; fetchTagDataFromSEDP: (projectCode: string, formCode: string) => Promise<any>; + projectType:string; + packageCode:string; } interface ComparisonResult { @@ -67,6 +69,8 @@ export function SEDPCompareDialog({ projectCode, formCode, fetchTagDataFromSEDP, + projectType, + packageCode }: SEDPCompareDialogProps) { const params = useParams() || {} @@ -256,7 +260,22 @@ export function SEDPCompareDialog({ // Create a map of SEDP data by TAG_NO for quick lookup const sedpTagMap = new Map(); - sedpTagEntries.forEach((entry: any) => { + + const packageCodeAttId = projectType === "ship" ? "CM3003" : "ME5074"; + + + const tagEntries = sedpTagEntries.filter(entry => { + if (Array.isArray(entry.ATTRIBUTES)) { + const packageCodeAttr = entry.ATTRIBUTES.find(attr => attr.ATT_ID === packageCodeAttId); + if (packageCodeAttr && packageCodeAttr.VALUE === packageCode) { + return true; + } + } + return false; + }); + + + tagEntries.forEach((entry: any) => { const tagNo = entry.TAG_NO; const attributesMap = new Map(); diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index 11d37911..54a70d9d 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -13,7 +13,6 @@ import { Loader, Save, AlertTriangle } from "lucide-react"; import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css'; import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns"; -// SpreadSheets를 동적으로 import (SSR 비활성화) const SpreadSheets = dynamic( () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets), { @@ -27,7 +26,6 @@ const SpreadSheets = dynamic( } ); -// 라이센스 키 설정을 클라이언트에서만 실행 if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; } @@ -88,8 +86,8 @@ interface TemplateViewDialogProps { isOpen: boolean; onClose: () => void; templateData: TemplateItem[] | any; - selectedRow?: GenericData; // SPREAD_ITEM용 - tableData?: GenericData[]; // SPREAD_LIST용 + selectedRow?: GenericData; + tableData?: GenericData[]; formCode: string; columnsJSON: DataTableColumnJSON[] contractItemId: number; @@ -97,6 +95,44 @@ interface TemplateViewDialogProps { onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void; } +// 🚀 로딩 프로그레스 컴포넌트 +interface LoadingProgressProps { + phase: string; + progress: number; + total: number; + isVisible: boolean; +} + +const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => { + const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; + + if (!isVisible) return null; + + return ( + <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]"> + <div className="flex items-center space-x-3 mb-4"> + <Loader className="h-5 w-5 animate-spin text-blue-600" /> + <span className="font-medium text-gray-900">Loading Template</span> + </div> + + <div className="space-y-2"> + <div className="text-sm text-gray-600">{phase}</div> + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out" + style={{ width: `${percentage}%` }} + /> + </div> + <div className="text-xs text-gray-500 text-right"> + {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) + </div> + </div> + </div> + </div> + ); +}; + export function TemplateViewDialog({ isOpen, onClose, @@ -123,13 +159,41 @@ export function TemplateViewDialog({ const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); + + // 🆕 로딩 상태 추가 + const [loadingProgress, setLoadingProgress] = React.useState<{ + phase: string; + progress: number; + total: number; + } | null>(null); + const [isInitializing, setIsInitializing] = React.useState(false); + + // 🔄 진행상황 업데이트 함수 + const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { + setLoadingProgress({ phase, progress, total }); + }, []); + + 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)) { + return 'SPREAD_LIST'; + } + 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) { + return 'GRD_LIST'; + } + return null; + }, [columnsJSON]); + + const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { + return determineTemplateType(template) !== null; + }, [determineTemplateType]); - // 클라이언트 사이드에서만 렌더링되도록 보장 React.useEffect(() => { setIsClient(true); }, []); - // 사용 가능한 템플릿들을 필터링하고 설정 React.useEffect(() => { if (!templateData) return; @@ -140,134 +204,158 @@ export function TemplateViewDialog({ templates = [templateData as TemplateItem]; } - // 유효한 템플릿들 필터링 - const validTemplates = templates.filter(template => { - const hasSpreadListContent = template.SPR_LST_SETUP?.CONTENT; - const hasSpreadItemContent = template.SPR_ITM_LST_SETUP?.CONTENT; - const hasGrdListSetup = template.GRD_LST_SETUP && columnsJSON.length > 0; // GRD_LIST 조건: GRD_LST_SETUP 존재 + columnsJSON 있음 - - const isValidType = template.TMPL_TYPE === "SPREAD_LIST" || - template.TMPL_TYPE === "SPREAD_ITEM" || - template.TMPL_TYPE === "GRD_LIST"; // GRD_LIST 타입 추가 - - return isValidType && (hasSpreadListContent || hasSpreadItemContent || hasGrdListSetup); - }); - + const validTemplates = templates.filter(isValidTemplate); setAvailableTemplates(validTemplates); - // 첫 번째 유효한 템플릿을 기본으로 선택 if (validTemplates.length > 0 && !selectedTemplateId) { const firstTemplate = validTemplates[0]; + const templateTypeToSet = determineTemplateType(firstTemplate); setSelectedTemplateId(firstTemplate.TMPL_ID); - - // 템플릿 타입 결정 - let templateTypeToSet: 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST'; - if (firstTemplate.GRD_LST_SETUP && columnsJSON.length > 0) { - templateTypeToSet = 'GRD_LIST'; - } else { - templateTypeToSet = firstTemplate.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'; - } - setTemplateType(templateTypeToSet); } - }, [templateData, selectedTemplateId]); + }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]); - // 선택된 템플릿 변경 처리 const handleTemplateChange = (templateId: string) => { const template = availableTemplates.find(t => t.TMPL_ID === templateId); if (template) { + const templateTypeToSet = determineTemplateType(template); setSelectedTemplateId(templateId); - - // 템플릿 타입 결정 - let templateTypeToSet: 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST'; - if (template.GRD_LST_SETUP && columnsJSON.length > 0) { - templateTypeToSet = 'GRD_LIST'; - } else { - templateTypeToSet = template.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'; - } - setTemplateType(templateTypeToSet); setHasChanges(false); setValidationErrors([]); - // SpreadSheets 재초기화 - if (currentSpread) { - const template = availableTemplates.find(t => t.TMPL_ID === templateId); - if (template) { - initSpread(currentSpread, template); - } + if (currentSpread && template) { + initSpread(currentSpread, template); } } }; - // 현재 선택된 템플릿 가져오기 const selectedTemplate = React.useMemo(() => { return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId); }, [availableTemplates, selectedTemplateId]); - // 편집 가능한 필드 목록 계산 const editableFields = React.useMemo(() => { - // SPREAD_ITEM인 경우: selectedRow의 TAG_NO로 확인 if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { if (!editableFieldsMap.has(selectedRow.TAG_NO)) { return []; } return editableFieldsMap.get(selectedRow.TAG_NO) || []; } - - // SPREAD_LIST 또는 GRD_LIST인 경우: 첫 번째 행의 TAG_NO를 기준으로 처리 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { const firstRowTagNo = tableData[0]?.TAG_NO; if (firstRowTagNo && editableFieldsMap.has(firstRowTagNo)) { return editableFieldsMap.get(firstRowTagNo) || []; } } - + return []; }, [templateType, selectedRow?.TAG_NO, tableData, editableFieldsMap]); - // 필드가 편집 가능한지 판별하는 함수 const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { - // columnsJSON에서 해당 attId의 shi 값 확인 const columnConfig = columnsJSON.find(col => col.key === attId); if (columnConfig?.shi === true) { - return false; // columnsJSON에서 shi가 true이면 편집 불가 + return false; } - // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (columnsJSON의 shi가 false인 경우) - if (attId === "TAG_NO" || attId === "TAG_DESC") { - return true; + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; } - // SPREAD_ITEM인 경우: editableFields 체크 - if (templateType === 'SPREAD_ITEM') { - return editableFields.includes(attId); - } - - // SPREAD_LIST 또는 GRD_LIST인 경우: 개별 행의 편집 가능성도 고려 if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - // 기본적으로 editableFields에 포함되어야 함 - if (!editableFields.includes(attId)) { - return false; - } - - // rowData가 제공된 경우 해당 행의 shi 상태도 확인 if (rowData && rowData.shi === true) { return false; } - return true; } - + return true; - }, [templateType, columnsJSON, editableFields]); + }, [templateType, columnsJSON]); - // 편집 가능한 필드 개수 계산 const editableFieldsCount = React.useMemo(() => { return cellMappings.filter(m => m.isEditable).length; }, [cellMappings]); - // 셀 주소를 행과 열로 변환하는 함수 + // 🚀 배치 처리 함수들 + const setBatchValues = React.useCallback(( + activeSheet: any, + valuesToSet: Array<{row: number, col: number, value: any}> + ) => { + console.log(`🚀 Setting ${valuesToSet.length} values in batch`); + + const columnGroups = new Map<number, Array<{row: number, value: any}>>(); + + valuesToSet.forEach(({row, col, value}) => { + if (!columnGroups.has(col)) { + columnGroups.set(col, []); + } + columnGroups.get(col)!.push({row, value}); + }); + + columnGroups.forEach((values, col) => { + values.sort((a, b) => a.row - b.row); + + let start = 0; + while (start < values.length) { + let end = start; + while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { + end++; + } + + const rangeValues = values.slice(start, end + 1).map(v => v.value); + const startRow = values[start].row; + + try { + if (rangeValues.length === 1) { + activeSheet.setValue(startRow, col, rangeValues[0]); + } else { + const dataArray = rangeValues.map(v => [v]); + activeSheet.setArray(startRow, col, dataArray); + } + } catch (error) { + for (let i = start; i <= end; i++) { + try { + activeSheet.setValue(values[i].row, col, values[i].value); + } catch (cellError) { + console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); + } + } + } + + start = end + 1; + } + }); + }, []); + + const setBatchStyles = React.useCallback(( + activeSheet: any, + stylesToSet: Array<{row: number, col: number, isEditable: boolean}> + ) => { + console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); + + const editableStyle = createCellStyle(true); + const readonlyStyle = createCellStyle(false); + + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) + stylesToSet.forEach(({row, col, isEditable}) => { + try { + const cell = activeSheet.getCell(row, col); + const style = isEditable ? editableStyle : readonlyStyle; + + activeSheet.setStyle(row, col, style); + cell.locked(!isEditable); // 편집 가능하면 잠금 해제 + + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 + if (isEditable) { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(row, col, textCellType); + } + } catch (error) { + console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); + } + }); + }, [createCellStyle]); + const parseCellAddress = (address: string): { row: number, col: number } | null => { if (!address || address.trim() === "") return null; @@ -283,11 +371,9 @@ export function TemplateViewDialog({ col -= 1; const row = parseInt(rowStr) - 1; - return { row, col }; }; - // 행과 열을 셀 주소로 변환하는 함수 (GRD_LIST용) const getCellAddress = (row: number, col: number): string => { let colStr = ''; let colNum = col; @@ -298,10 +384,9 @@ export function TemplateViewDialog({ return colStr + (row + 1); }; - // 데이터 타입 검증 함수 const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { if (value === undefined || value === null || value === "") { - return null; // 빈 값은 별도 required 검증에서 처리 + return null; } switch (columnType) { @@ -316,17 +401,14 @@ export function TemplateViewDialog({ } break; case "STRING": - // STRING 타입은 대부분의 값을 허용 break; default: - // 커스텀 타입의 경우 추가 검증 로직이 필요할 수 있음 break; } return null; }; - // 전체 데이터 검증 함수 const validateAllData = React.useCallback(() => { if (!currentSpread || !selectedTemplate) return []; @@ -340,68 +422,44 @@ export function TemplateViewDialog({ const cellPos = parseCellAddress(mapping.cellAddress); if (!cellPos) return; - if (templateType === 'SPREAD_ITEM') { - // 단일 행 검증 - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - - if (errorMessage) { - errors.push({ - cellAddress: mapping.cellAddress, - attId: mapping.attId, - value: cellValue, - expectedType: columnConfig.type, - message: errorMessage - }); - } - } else if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - // 복수 행 검증 - 각 매핑은 이미 개별 행을 가리킴 - const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); - if (errorMessage) { - errors.push({ - cellAddress: mapping.cellAddress, - attId: mapping.attId, - value: cellValue, - expectedType: columnConfig.type, - message: errorMessage - }); - } + if (errorMessage) { + errors.push({ + cellAddress: mapping.cellAddress, + attId: mapping.attId, + value: cellValue, + expectedType: columnConfig.type, + message: errorMessage + }); } }); setValidationErrors(errors); return errors; - }, [currentSpread, selectedTemplate, cellMappings, columnsJSON, templateType]); - - // ═══════════════════════════════════════════════════════════════════════════════ - // 🛠️ 헬퍼 함수들 - // ═══════════════════════════════════════════════════════════════════════════════ + }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); - // 🎨 셀 스타일 생성 const createCellStyle = React.useCallback((isEditable: boolean) => { const style = new GC.Spread.Sheets.Style(); if (isEditable) { - style.backColor = "#f0fdf4"; // 연한 초록 (편집 가능) + style.backColor = "#f0fdf4"; } else { - style.backColor = "#f9fafb"; // 연한 회색 (읽기 전용) + style.backColor = "#f9fafb"; style.foreColor = "#6b7280"; } return style; }, []); - // 🎯 드롭다운 설정 const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { try { console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); - // ✅ options 정규화 const safeOptions = options - .filter(opt => opt !== null && opt !== undefined && opt !== '') + .filter(opt => opt !== null && opt !== undefined && opt !== '') .map(opt => String(opt).trim()) .filter(opt => opt.length > 0) - .filter((opt, index, arr) => arr.indexOf(opt) === index) + .filter((opt, index, arr) => arr.indexOf(opt) === index) .slice(0, 20); if (safeOptions.length === 0) { @@ -409,280 +467,333 @@ export function TemplateViewDialog({ return; } - console.log(`📋 Safe options:`, safeOptions); - - // ✅ DataValidation용 문자열 준비 const optionsString = safeOptions.join(','); - // 🔑 핵심 수정: 각 셀마다 개별 ComboBox 인스턴스 생성! for (let i = 0; i < rowCount; i++) { try { const targetRow = cellPos.row + i; - // ✅ 각 셀마다 새로운 ComboBox 인스턴스 생성 + // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성 const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBoxCellType.items(safeOptions); // 배열로 전달 + comboBoxCellType.items(safeOptions); comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - - // ✅ 각 셀마다 새로운 DataValidation 인스턴스 생성 + + // 🔧 DataValidation 설정 const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); - - // ComboBox + DataValidation 둘 다 적용 + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + // ComboBox와 Validator 적용 activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); - // 셀 잠금 해제 + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 const cell = activeSheet.getCell(targetRow, cellPos.col); cell.locked(false); - - console.log(`✅ Individual dropdown applied to [${targetRow}, ${cellPos.col}]`); - + + console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); + } catch (cellError) { - console.warn(`⚠️ Failed to apply to row ${cellPos.row + i}:`, cellError); + console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); } } - console.log(`✅ Safe dropdown setup completed for ${rowCount} cells`); + console.log(`✅ Dropdown setup completed for ${rowCount} cells`); } catch (error) { console.error('❌ Dropdown setup failed:', error); } }, []); - // 🚀 행 용량 확보 + const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + if (!spread) return null; + + try { + let activeSheet = spread.getActiveSheet(); + if (!activeSheet) { + const sheetCount = spread.getSheetCount(); + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + } + } + } + return activeSheet; + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } + }, []); + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { - const currentRowCount = activeSheet.getRowCount(); - if (requiredRowCount > currentRowCount) { - activeSheet.setRowCount(requiredRowCount + 10); // 여유분 추가 - console.log(`📈 Expanded sheet to ${requiredRowCount + 10} rows`); + try { + if (!activeSheet) return false; + + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + return false; } }, []); - // 🆕 GRD_LIST용 동적 테이블 생성 함수 - const createGrdListTable = React.useCallback((activeSheet: any, template: TemplateItem) => { - console.log('🏗️ Creating GRD_LIST table'); - - // columnsJSON의 visible 컬럼들을 seq 순서로 정렬하여 사용 - const visibleColumns = columnsJSON - .filter(col => col.hidden !== true) // hidden이 true가 아닌 것들만 - .sort((a, b) => { - // seq가 없는 경우 999999로 처리하여 맨 뒤로 보냄 - const seqA = a.seq !== undefined ? a.seq : 999999; - const seqB = b.seq !== undefined ? b.seq : 999999; - return seqA - seqB; - }); + const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + if (!activeSheet) return false; + + const currentColumnCount = activeSheet.getColumnCount(); + if (requiredColumnCount > currentColumnCount) { + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + return false; + } + }, []); - console.log('📊 Using columns:', visibleColumns.map(c => `${c.key}(seq:${c.seq})`)); + const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; + activeSheet.setColumnWidth(targetCol, optimalWidth); + }); + }, []); - if (visibleColumns.length === 0) { - console.warn('❌ No visible columns found in columnsJSON'); - return []; - } + // 🚀 최적화된 GRD_LIST 생성 + const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table'); - // 테이블 생성 시작 + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; const mappings: CellMapping[] = []; - const startCol = 1; // A열 제외하고 B열부터 시작 - - // 🔍 그룹 헤더 분석 - const groupInfo = analyzeColumnGroups(visibleColumns); - const hasGroups = groupInfo.groups.length > 0; - - // 헤더 행 계산: 그룹이 있으면 2행, 없으면 1행 - const groupHeaderRow = 0; - const columnHeaderRow = hasGroups ? 1 : 0; - const dataStartRow = hasGroups ? 2 : 1; - - // 🎨 헤더 스타일 생성 - const groupHeaderStyle = new GC.Spread.Sheets.Style(); - groupHeaderStyle.backColor = "#1e40af"; // 더 진한 파란색 - groupHeaderStyle.foreColor = "#ffffff"; - groupHeaderStyle.font = "bold 13px Arial"; - groupHeaderStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; - groupHeaderStyle.vAlign = GC.Spread.Sheets.VerticalAlign.center; - - const columnHeaderStyle = new GC.Spread.Sheets.Style(); - columnHeaderStyle.backColor = "#3b82f6"; // 기본 파란색 - columnHeaderStyle.foreColor = "#ffffff"; - columnHeaderStyle.font = "bold 12px Arial"; - columnHeaderStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; - columnHeaderStyle.vAlign = GC.Spread.Sheets.VerticalAlign.center; - - let currentCol = startCol; - - // 🏗️ 그룹 헤더 및 컬럼 헤더 생성 - if (hasGroups) { - // 그룹 헤더가 있는 경우 - groupInfo.groups.forEach(group => { - if (group.isGroup) { - // 그룹 헤더 생성 및 병합 - const groupStartCol = currentCol; - const groupEndCol = currentCol + group.columns.length - 1; - - // 그룹 헤더 셀 설정 - const groupHeaderCell = activeSheet.getCell(groupHeaderRow, groupStartCol); - groupHeaderCell.value(group.head); - - // 그룹 헤더 병합 - if (group.columns.length > 1) { - activeSheet.addSpan(groupHeaderRow, groupStartCol, 1, group.columns.length); - } - - // 그룹 헤더 스타일 적용 - for (let col = groupStartCol; col <= groupEndCol; col++) { - activeSheet.setStyle(groupHeaderRow, col, groupHeaderStyle); - activeSheet.getCell(groupHeaderRow, col).locked(true); - } - - console.log(`📝 Group Header [${groupHeaderRow}, ${groupStartCol}-${groupEndCol}]: ${group.head}`); - - // 그룹 내 개별 컬럼 헤더 생성 - group.columns.forEach((column, index) => { - const colIndex = groupStartCol + index; - const columnHeaderCell = activeSheet.getCell(columnHeaderRow, colIndex); - columnHeaderCell.value(column.label); - activeSheet.setStyle(columnHeaderRow, colIndex, columnHeaderStyle); - columnHeaderCell.locked(true); - - console.log(`📝 Column Header [${columnHeaderRow}, ${colIndex}]: ${column.label}`); - }); - - currentCol += group.columns.length; - } else { - // 그룹이 아닌 단일 컬럼 - const column = group.columns[0]; - - // 그룹 헤더 행에는 빈 셀 (개별 컬럼이므로) - const groupHeaderCell = activeSheet.getCell(groupHeaderRow, currentCol); - groupHeaderCell.value(""); - activeSheet.setStyle(groupHeaderRow, currentCol, groupHeaderStyle); - groupHeaderCell.locked(true); - - // 컬럼 헤더 생성 - const columnHeaderCell = activeSheet.getCell(columnHeaderRow, currentCol); - columnHeaderCell.value(column.label); - activeSheet.setStyle(columnHeaderRow, currentCol, columnHeaderStyle); - columnHeaderCell.locked(true); - - console.log(`📝 Single Column [${columnHeaderRow}, ${currentCol}]: ${column.label}`); - currentCol++; - } - }); - } else { - // 그룹이 없는 경우 - 기존 로직 - visibleColumns.forEach((column, colIndex) => { - const headerCol = startCol + colIndex; - const headerCell = activeSheet.getCell(columnHeaderRow, headerCol); - headerCell.value(column.label); - activeSheet.setStyle(columnHeaderRow, headerCol, columnHeaderStyle); - headerCell.locked(true); - - console.log(`📝 Header [${columnHeaderRow}, ${headerCol}]: ${column.label}`); - }); - } - // 데이터 행 생성 - const dataRowCount = tableData.length; - ensureRowCapacity(activeSheet, dataStartRow + dataRowCount); + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; - tableData.forEach((rowData, rowIndex) => { - const targetRow = dataStartRow + rowIndex; + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - const cellAddress = getCellAddress(targetRow, targetCol); - const isEditable = isFieldEditable(column.key, rowData); + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { + editableRows.push(dataStartRow + rowIndex); + } + }); - // 매핑 정보 추가 + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); + const value = rowData[column.key]; + mappings.push({ attId: column.key, - cellAddress: cellAddress, - isEditable: isEditable, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, dataRowIndex: rowIndex }); - // 셀 값 설정 - const cell = activeSheet.getCell(targetRow, targetCol); - const value = rowData[column.key]; - cell.value(value ?? null); - - // 스타일 적용 - const style = createCellStyle(isEditable); - activeSheet.setStyle(targetRow, targetCol, style); - cell.locked(!isEditable); - - console.log(`📝 Data [${targetRow}, ${targetCol}]: ${column.key} = "${value}" (${isEditable ? 'Editable' : 'ReadOnly'})`); + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); - // LIST 타입 드롭다운 설정 - if (column.type === "LIST" && column.options && isEditable) { - setupOptimizedListValidation(activeSheet, { row: targetRow, col: targetCol }, column.options, 1); - } + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable + }); }); }); - console.log(`🏗️ GRD_LIST table created with ${mappings.length} mappings, hasGroups: ${hasGroups}`); - return mappings; - }, [tableData, columnsJSON, isFieldEditable, createCellStyle, ensureRowCapacity, setupOptimizedListValidation]); - - // 🔍 컬럼 그룹 분석 함수 - const analyzeColumnGroups = React.useCallback((columns: DataTableColumnJSON[]) => { - const groups: Array<{ - head: string; - isGroup: boolean; - columns: DataTableColumnJSON[]; - }> = []; + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); - let i = 0; - while (i < columns.length) { - const currentCol = columns[i]; - - // head가 없거나 빈 문자열인 경우 단일 컬럼으로 처리 - if (!currentCol.head || !currentCol.head.trim()) { - groups.push({ - head: '', - isGroup: false, - columns: [currentCol] + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); + + if (safeOptions.length === 0) return; + + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } }); - i++; - continue; + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); } + }); - // 같은 head를 가진 연속된 컬럼들을 찾기 - const groupHead = currentCol.head.trim(); - const groupColumns: DataTableColumnJSON[] = [currentCol]; - let j = i + 1; - - while (j < columns.length && columns[j].head && columns[j].head.trim() === groupHead) { - groupColumns.push(columns[j]); - j++; - } + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + + return mappings; + }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); - // 그룹 추가 - groups.push({ - head: groupHead, - isGroup: groupColumns.length > 1, - columns: groupColumns - }); + const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); - i = j; // 다음 그룹으로 이동 - } + // 🔧 시트 보호 완전 해제 후 편집 권한 설정 + activeSheet.options.isProtected = false; - return { groups }; - }, []); + // 🔧 편집 가능한 셀들을 위한 강화된 설정 + mappings.forEach((mapping) => { + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + + if (mapping.isEditable) { + // 🚀 편집 가능한 셀 설정 강화 + cell.locked(false); + + if (columnConfig?.type === "LIST" && columnConfig.options) { + // LIST 타입: 새 ComboBox 인스턴스 생성 + const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBox.items(columnConfig.options); + comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); + + // DataValidation도 추가 + const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); + activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); + } else if (columnConfig?.type === "NUMBER") { + // NUMBER 타입: 숫자 입력 허용 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + + // 숫자 validation 추가 (에러 메시지 없이) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(false); + numberValidator.showErrorMessage(false); + activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); + } else { + // 기본 TEXT 타입: 자유 텍스트 입력 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + } + + // 편집 가능 스타일 재적용 + const editableStyle = createCellStyle(true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); + } else { + // 읽기 전용 셀 + cell.locked(true); + const readonlyStyle = createCellStyle(false); + activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); + } + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); - // 🛡️ 시트 보호 및 이벤트 설정 - const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { - console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); - - // 시트 보호 설정 + // 🛡️ 시트 보호 재설정 (편집 허용 모드로) activeSheet.options.isProtected = true; activeSheet.options.protectionOptions = { allowSelectLockedCells: true, allowSelectUnlockedCells: true, allowSort: false, allowFilter: false, - allowEditObjects: false, + allowEditObjects: true, // ✅ 편집 객체 허용 allowResizeRows: false, - allowResizeColumns: false + allowResizeColumns: false, + allowFormatCells: false, + allowInsertRows: false, + allowInsertColumns: false, + allowDeleteRows: false, + allowDeleteColumns: false }; // 🎯 변경 감지 이벤트 @@ -699,11 +810,10 @@ export function TemplateViewDialog({ }); }); - // 🚫 편집 시작 권한 확인 (수정됨) + // 🚫 편집 시작 권한 확인 activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => { console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`); - - // ✅ 정확한 매핑 찾기 (행/열 정확히 일치) + const exactMapping = mappings.find(m => { const cellPos = parseCellAddress(m.cellAddress); return cellPos && cellPos.row === info.row && cellPos.col === info.col; @@ -711,12 +821,11 @@ export function TemplateViewDialog({ if (!exactMapping) { console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); - return; // 매핑이 없으면 허용 (템플릿 영역 밖) + return; // 매핑이 없으면 허용 } - console.log(`📋 Found mapping: ${exactMapping.attId} at ${exactMapping.cellAddress}`); + console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`); - // 기본 편집 권한 확인 if (!exactMapping.isEditable) { console.log(`🚫 Field ${exactMapping.attId} is not editable`); toast.warning(`${exactMapping.attId} field is read-only`); @@ -724,12 +833,9 @@ export function TemplateViewDialog({ return; } - // SPREAD_LIST 또는 GRD_LIST 개별 행 SHI 확인 + // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인 if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { const dataRowIndex = exactMapping.dataRowIndex; - - console.log(`🔍 Checking SHI for data row ${dataRowIndex}`); - if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { const rowData = tableData[dataRowIndex]; if (rowData?.shi === true) { @@ -738,41 +844,31 @@ export function TemplateViewDialog({ info.cancel = true; return; } - } else { - console.warn(`⚠️ Invalid dataRowIndex: ${dataRowIndex} (tableData.length: ${tableData.length})`); } } console.log(`✅ Edit allowed for ${exactMapping.attId}`); }); - // ✅ 편집 완료 검증 (수정됨) + // ✅ 편집 완료 검증 activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => { - console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}`); - - // ✅ 정확한 매핑 찾기 + console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`); + const exactMapping = mappings.find(m => { const cellPos = parseCellAddress(m.cellAddress); return cellPos && cellPos.row === info.row && cellPos.col === info.col; }); - if (!exactMapping) { - console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - skipping validation`); - return; - } + if (!exactMapping) return; const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); if (columnConfig) { const cellValue = activeSheet.getValue(info.row, info.col); - console.log(`🔍 Validating ${exactMapping.attId}: "${cellValue}"`); - const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); const cell = activeSheet.getCell(info.row, info.col); if (errorMessage) { - console.log(`❌ Validation failed: ${errorMessage}`); - - // 🚨 에러 스타일 적용 (편집 가능 상태 유지) + // 🚨 에러 스타일 적용 const errorStyle = new GC.Spread.Sheets.Style(); errorStyle.backColor = "#fef2f2"; errorStyle.foreColor = "#dc2626"; @@ -780,222 +876,221 @@ export function TemplateViewDialog({ errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); - + activeSheet.setStyle(info.row, info.col, errorStyle); - cell.locked(!exactMapping.isEditable); - - toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}. Please correct the value.`, { duration: 5000 }); + cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지 + toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 }); } else { - console.log(`✅ Validation passed`); - // ✅ 정상 스타일 복원 const normalStyle = createCellStyle(exactMapping.isEditable); activeSheet.setStyle(info.row, info.col, normalStyle); cell.locked(!exactMapping.isEditable); } } + + setHasChanges(true); }); - console.log(`🛡️ Protection and events configured for ${mappings.length} mappings`); + console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); - // ═══════════════════════════════════════════════════════════════════════════════ - // 🏗️ 메인 SpreadSheets 초기화 함수 - // ═══════════════════════════════════════════════════════════════════════════════ - - const initSpread = React.useCallback((spread: any, template?: TemplateItem) => { + // 🚀 최적화된 initSpread + const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { const workingTemplate = template || selectedTemplate; - if (!spread || !workingTemplate) return; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template'); + return; + } try { - // 🔄 초기 설정 + console.log('🚀 Starting optimized spread initialization...'); + setIsInitializing(true); + updateProgress('Initializing...', 0, 100); + setCurrentSpread(spread); setHasChanges(false); setValidationErrors([]); - // 성능을 위한 렌더링 일시 중단 + // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 spread.suspendPaint(); + spread.suspendEvent(); + spread.suspendCalcService(); + + updateProgress('Setting up workspace...', 10, 100); try { - const activeSheet = spread.getActiveSheet(); - - // 시트 보호 해제 (편집을 위해) - activeSheet.options.isProtected = false; + let activeSheet = getSafeActiveSheet(spread, 'initSpread'); + if (!activeSheet) { + throw new Error('Failed to get initial activeSheet'); + } + activeSheet.options.isProtected = false; let mappings: CellMapping[] = []; - // 🆕 GRD_LIST 처리 - if (templateType === 'GRD_LIST' && workingTemplate.GRD_LST_SETUP) { - console.log('🏗️ Processing GRD_LIST template'); + if (templateType === 'GRD_LIST') { + updateProgress('Creating dynamic table...', 20, 100); - // 기본 워크북 설정 spread.clearSheets(); spread.addSheet(0); const sheet = spread.getSheet(0); sheet.name('Data'); spread.setActiveSheet('Data'); - // 동적 테이블 생성 - mappings = createGrdListTable(sheet, workingTemplate); + updateProgress('Processing table data...', 50, 100); + mappings = createGrdListTableOptimized(sheet, workingTemplate); } else { - // 기존 SPREAD_LIST 및 SPREAD_ITEM 처리 - let contentJson = null; - let dataSheets = null; - - // SPR_LST_SETUP.CONTENT 우선 사용 - if (workingTemplate.SPR_LST_SETUP?.CONTENT) { - contentJson = workingTemplate.SPR_LST_SETUP.CONTENT; - dataSheets = workingTemplate.SPR_LST_SETUP.DATA_SHEETS; - console.log('✅ Using SPR_LST_SETUP.CONTENT for template:', workingTemplate.NAME); - } - // SPR_ITM_LST_SETUP.CONTENT 대안 사용 - else if (workingTemplate.SPR_ITM_LST_SETUP?.CONTENT) { - contentJson = workingTemplate.SPR_ITM_LST_SETUP.CONTENT; - dataSheets = workingTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS; - console.log('✅ Using SPR_ITM_LST_SETUP.CONTENT for template:', workingTemplate.NAME); - } + updateProgress('Loading template structure...', 20, 100); + + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; + let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; - if (!contentJson) { - console.warn('❌ No CONTENT found in template:', workingTemplate.NAME); - return; + if (!contentJson || !dataSheets) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); } - // 🏗️ SpreadSheets 초기화 const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; - // 템플릿 구조 로드 + updateProgress('Loading template layout...', 40, 100); spread.fromJSON(jsonData); + + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + activeSheet.options.isProtected = false; - // 📊 셀 매핑 및 데이터 처리 - if (dataSheets && dataSheets.length > 0) { + if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + updateProgress('Processing data rows...', 60, 100); - // 🔄 각 데이터 시트의 매핑 정보 처리 dataSheets.forEach(dataSheet => { - if (dataSheet.MAP_CELL_ATT) { - dataSheet.MAP_CELL_ATT.forEach(mapping => { + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { const { ATT_ID, IN } = mapping; - - if (IN && IN.trim() !== "") { - const cellPos = parseCellAddress(IN); - if (cellPos) { - const columnConfig = columnsJSON.find(col => col.key === ATT_ID); - - // 🎯 템플릿 타입별 데이터 처리 - if (templateType === 'SPREAD_ITEM' && selectedRow) { - // 📝 단일 행 처리 (SPREAD_ITEM) - const isEditable = isFieldEditable(ATT_ID); - - // 매핑 정보 저장 - mappings.push({ - attId: ATT_ID, - cellAddress: IN, - isEditable: isEditable, - dataRowIndex: 0 - }); - - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - const value = selectedRow[ATT_ID]; - - // 값 설정 - cell.value(value ?? null); - - // 🎨 스타일 및 편집 권한 설정 - cell.locked(!isEditable); - const style = createCellStyle(isEditable); - activeSheet.setStyle(cellPos.row, cellPos.col, style); - - // 📋 LIST 타입 드롭다운 설정 - if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); - } - - } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { - // 📊 복수 행 처리 (SPREAD_LIST) - 성능 최적화됨 - console.log(`🔄 Processing SPREAD_LIST for ${ATT_ID} with ${tableData.length} rows`); - - // 🚀 행 확장 (필요시) - ensureRowCapacity(activeSheet, cellPos.row + tableData.length); - - // 📋 각 행마다 개별 매핑 생성 - tableData.forEach((rowData, index) => { - const targetRow = cellPos.row + index; - const targetCellAddress = `${String.fromCharCode(65 + cellPos.col)}${targetRow + 1}`; - const cellEditable = isFieldEditable(ATT_ID, rowData); - - // 개별 매핑 추가 - mappings.push({ - attId: ATT_ID, - cellAddress: targetCellAddress, // 각 행마다 다른 주소 - isEditable: cellEditable, - dataRowIndex: index // 원본 데이터 인덱스 - }); - - console.log(`📝 Mapping ${ATT_ID} Row ${index}: ${targetCellAddress} (${cellEditable ? 'Editable' : 'ReadOnly'})`); - }); - - // 📋 LIST 타입 드롭다운 설정 (조건부) - if (columnConfig?.type === "LIST" && columnConfig.options) { - // 편집 가능한 행이 하나라도 있으면 드롭다운 설정 - const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); - if (hasEditableRows) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); - } - } - - // 🎨 개별 셀 데이터 및 스타일 설정 - tableData.forEach((rowData, index) => { - const targetRow = cellPos.row + index; - const cell = activeSheet.getCell(targetRow, cellPos.col); - const value = rowData[ATT_ID]; - - // 값 설정 - cell.value(value ?? null); - // console.log(`📝 Row ${targetRow}: ${ATT_ID} = "${value}"`); - - // 편집 권한 및 스타일 설정 - const cellEditable = isFieldEditable(ATT_ID, rowData); - cell.locked(!cellEditable); - const style = createCellStyle(cellEditable); - activeSheet.setStyle(targetRow, cellPos.col, style); - }); - } - - // console.log(`📌 Mapped ${ATT_ID} → ${IN} (${templateType})`); + if (!ATT_ID || !IN || IN.trim() === "") return; + + const cellPos = parseCellAddress(IN); + if (!cellPos) return; + + const requiredRows = cellPos.row + tableData.length; + if (!ensureRowCapacity(activeSheet, requiredRows)) return; + + // 🚀 배치 데이터 준비 + const valuesToSet: Array<{row: number, col: number, value: any}> = []; + const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; + + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cellEditable = isFieldEditable(ATT_ID, rowData); + const value = rowData[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: getCellAddress(targetRow, cellPos.col), + isEditable: cellEditable, + dataRowIndex: index + }); + + valuesToSet.push({ + row: targetRow, + col: cellPos.col, + value: value ?? null + }); + + stylesToSet.push({ + row: targetRow, + col: cellPos.col, + isEditable: cellEditable + }); + }); + + // 🚀 배치 처리 + setBatchValues(activeSheet, valuesToSet); + setBatchStyles(activeSheet, stylesToSet); + + // 드롭다운 설정 + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); } } }); } }); + + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { + updateProgress('Setting up form fields...', 60, 100); + + dataSheets.forEach(dataSheet => { + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + const cellPos = parseCellAddress(IN); + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + cell.value(value ?? null); + + const style = createCellStyle(isEditable); + activeSheet.setStyle(cellPos.row, cellPos.col, style); + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + } + } + }); + }); } } - // 💾 매핑 정보 저장 및 이벤트 설정 + updateProgress('Configuring interactions...', 90, 100); setCellMappings(mappings); - setupSheetProtectionAndEvents(activeSheet, mappings); + + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } + + updateProgress('Finalizing...', 100, 100); + console.log(`✅ Optimized initialization completed with ${mappings.length} mappings`); } finally { - // 렌더링 재개 + // 🚀 올바른 순서로 재개 + spread.resumeCalcService(); + spread.resumeEvent(); spread.resumePaint(); } } catch (error) { - console.error('❌ Error initializing spread:', error); - toast.error('Failed to load template'); - if (spread?.resumePaint) { - spread.resumePaint(); - } + console.error('❌ Error in optimized spread initialization:', error); + if (spread?.resumeCalcService) spread.resumeCalcService(); + if (spread?.resumeEvent) spread.resumeEvent(); + if (spread?.resumePaint) spread.resumePaint(); + toast.error(`Template loading failed: ${error.message}`); + } finally { + setIsInitializing(false); + setLoadingProgress(null); } - }, [selectedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON, createCellStyle, setupOptimizedListValidation, ensureRowCapacity, setupSheetProtectionAndEvents, createGrdListTable]); + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]); - // 변경사항 저장 함수 const handleSaveChanges = React.useCallback(async () => { if (!currentSpread || !hasChanges) { toast.info("No changes to save"); return; } - // 저장 전 데이터 검증 const errors = validateAllData(); if (errors.length > 0) { toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`); @@ -1004,11 +1099,9 @@ export function TemplateViewDialog({ try { setIsPending(true); - const activeSheet = currentSpread.getActiveSheet(); if (templateType === 'SPREAD_ITEM' && selectedRow) { - // 단일 행 저장 const dataToSave = { ...selectedRow }; cellMappings.forEach(mapping => { @@ -1038,7 +1131,6 @@ export function TemplateViewDialog({ onUpdateSuccess?.(dataToSave); } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { - // 복수 행 저장 (SPREAD_LIST와 GRD_LIST 동일 처리) const updatedRows: GenericData[] = []; let saveCount = 0; @@ -1047,7 +1139,6 @@ export function TemplateViewDialog({ const dataToSave = { ...originalRow }; let hasRowChanges = false; - // 각 매핑에 대해 해당 행의 값 확인 cellMappings.forEach(mapping => { if (mapping.dataRowIndex === i && mapping.isEditable) { const columnConfig = columnsJSON.find(col => col.key === mapping.attId); @@ -1058,8 +1149,6 @@ export function TemplateViewDialog({ const cellPos = parseCellAddress(mapping.cellAddress); if (cellPos) { const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); - - // 값이 변경되었는지 확인 if (cellValue !== originalRow[mapping.attId]) { dataToSave[mapping.attId] = cellValue; hasRowChanges = true; @@ -1069,10 +1158,8 @@ export function TemplateViewDialog({ } }); - // 변경사항이 있는 행만 저장 if (hasRowChanges) { - dataToSave.TAG_NO = originalRow.TAG_NO; // TAG_NO는 절대 변경되지 않도록 - + dataToSave.TAG_NO = originalRow.TAG_NO; const { success } = await updateFormDataInDB( formCode, contractItemId, @@ -1084,7 +1171,7 @@ export function TemplateViewDialog({ saveCount++; } } else { - updatedRows.push(originalRow); // 변경사항이 없으면 원본 유지 + updatedRows.push(originalRow); } } @@ -1109,21 +1196,18 @@ export function TemplateViewDialog({ if (!isOpen) return null; - // 데이터 유효성 검사 const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0; const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length; - + return ( <Dialog open={isOpen} onOpenChange={onClose}> <DialogContent - className="w-[80%] max-w-none h-[80vh] flex flex-col" - style={{ maxWidth: "80vw" }} + className="w-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50" > <DialogHeader className="flex-shrink-0"> <DialogTitle>SEDP Template - {formCode}</DialogTitle> <DialogDescription> <div className="space-y-3"> - {/* 템플릿 선택 */} {availableTemplates.length > 1 && ( <div className="flex items-center gap-4"> <span className="text-sm font-medium">Template:</span> @@ -1134,11 +1218,7 @@ export function TemplateViewDialog({ <SelectContent> {availableTemplates.map(template => ( <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> - {template.NAME} ({ - template.GRD_LST_SETUP && columnsJSON.length > 0 - ? 'GRD_LIST' - : template.TMPL_TYPE - }) + {template.NAME} ({template.TMPL_TYPE}) </SelectItem> ))} </SelectContent> @@ -1146,14 +1226,13 @@ export function TemplateViewDialog({ </div> )} - {/* 템플릿 정보 */} {selectedTemplate && ( <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)' : - templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : - 'Grid List View (GRD_LIST)' + templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : + 'Grid List View (GRD_LIST)' } </span> {templateType === 'SPREAD_ITEM' && selectedRow && ( @@ -1176,7 +1255,6 @@ export function TemplateViewDialog({ </div> )} - {/* 범례 */} <div className="flex items-center gap-4 text-xs"> <span className="text-muted-foreground"> <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span> @@ -1200,8 +1278,15 @@ export function TemplateViewDialog({ </DialogDescription> </DialogHeader> - {/* SpreadSheets 컴포넌트 영역 */} - <div className="flex-1 overflow-hidden"> + <div className="flex-1 overflow-hidden relative"> + {/* 🆕 로딩 프로그레스 오버레이 */} + <LoadingProgress + phase={loadingProgress?.phase || ''} + progress={loadingProgress?.progress || 0} + total={loadingProgress?.total || 100} + isVisible={isInitializing && !!loadingProgress} + /> + {selectedTemplate && isClient && isDataValid ? ( <SpreadSheets key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index 91cb7a07..6de68a1a 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -104,8 +104,10 @@ export function UpdateTagSheet({ const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => { if (column.shi === true) return false; // SHI‑only - if (column.key === "TAG_NO" || column.key === "TAG_DESC") return true; - return editableFields.includes(column.key); + if (column.key === "TAG_NO" || column.key === "TAG_DESC") return false; + if (column.key === "status") return false; + // return editableFields.includes(column.key); + return true }, [editableFields]); const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]); diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx index 1bc2fc38..0c3b2276 100644 --- a/components/pq-input/pq-input-tabs.tsx +++ b/components/pq-input/pq-input-tabs.tsx @@ -505,7 +505,7 @@ export function PQInputTabs({ description: "Your PQ information has been submitted successfully", }); // 제출 후 PQ 목록 페이지로 리디렉션 - window.location.href = "/partners/pq"; + window.location.href = "/partners/pq_new"; } else { toast({ title: "Submit Error", diff --git a/components/pq/client-pq-input-wrapper.tsx b/components/pq/client-pq-input-wrapper.tsx deleted file mode 100644 index 42d2420d..00000000 --- a/components/pq/client-pq-input-wrapper.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"use client" - -import * as React from "react" -import { Shell } from "@/components/shell" -import { Skeleton } from "@/components/ui/skeleton" -import { PQInputTabs } from "@/components/pq/pq-input-tabs" -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { PQGroupData, ProjectPQ } from "@/lib/pq/service" -import { useRouter, useSearchParams } from "next/navigation" - -interface ClientPQWrapperProps { - pqData: PQGroupData[] // 변경: allPQData → pqData (현재 선택된 PQ 데이터) - projectPQs: ProjectPQ[] - vendorId: number - rawSearchParams: { - projectId?: string - } -} - -export function ClientPQWrapper({ - pqData, - projectPQs, - vendorId, - rawSearchParams -}: ClientPQWrapperProps) { - const searchParams = useSearchParams() - const projectIdParam = searchParams?.get('projectId') - - // 클라이언트 측에서 projectId 파싱 - const projectId = projectIdParam ? parseInt(projectIdParam, 10) : undefined - - // 현재 프로젝트 정보 찾기 - const currentProject = projectId - ? projectPQs.find(p => p.projectId === projectId) - : null - - return ( - <Shell className="gap-2"> - {/* 헤더 - 프로젝트 정보 포함 */} - <div className="space-y-2"> - <h2 className="text-2xl font-bold tracking-tight"> - Pre-Qualification Check Sheet - {currentProject && ( - <span className="ml-2 text-muted-foreground"> - - {currentProject.projectCode} - </span> - )} - </h2> - <p className="text-muted-foreground"> - PQ에 적절한 응답을 제출하시기 바랍니다. - </p> - </div> - - {/* 일반/프로젝트 PQ 선택 탭 */} - {projectPQs.length > 0 && ( - <div className="border-b"> - <Tabs defaultValue={projectId ? `project-${projectId}` : "general"}> - <TabsList> - <TabsTrigger value="general" asChild> - <a href="/partners/pq">일반 PQ</a> - </TabsTrigger> - - {projectPQs.map(project => ( - <TabsTrigger - key={project.projectId} - value={`project-${project.projectId}`} - asChild - > - <a href={`/partners/pq?projectId=${project.projectId}`}> - {project.projectCode} - </a> - </TabsTrigger> - ))} - </TabsList> - </Tabs> - </div> - )} - - {/* PQ 입력 탭 */} - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - <PQInputTabs - data={pqData} - vendorId={vendorId} - projectId={projectId} - projectData={currentProject} - /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx deleted file mode 100644 index d72eff92..00000000 --- a/components/pq/pq-input-tabs.tsx +++ /dev/null @@ -1,884 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { useToast } from "@/hooks/use-toast" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" - -// Form components -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" - -// Custom Dropzone, FileList components -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, -} from "@/components/ui/file-list" - -// Dialog components -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog" - -// Additional UI -import { Separator } from "@/components/ui/separator" -import { Badge } from "@/components/ui/badge" - -// Server actions -import { - uploadFileAction, - savePQAnswersAction, - submitPQAction, - ProjectPQ, -} from "@/lib/pq/service" -import { PQGroupData } from "@/lib/pq/service" -import { useRouter } from "next/navigation" - -// ---------------------------------------------------------------------- -// 1) Define client-side file shapes -// ---------------------------------------------------------------------- -interface UploadedFileState { - fileName: string - url: string - size?: number -} - -interface LocalFileState { - fileObj: File - uploaded: boolean -} - -// ---------------------------------------------------------------------- -// 2) Zod schema for the entire form -// ---------------------------------------------------------------------- -const pqFormSchema = z.object({ - answers: z.array( - z.object({ - criteriaId: z.number(), - // Must have at least 1 char - answer: z.string().min(1, "Answer is required"), - - // Existing, uploaded files - uploadedFiles: z - .array( - z.object({ - fileName: z.string(), - url: z.string(), - size: z.number().optional(), - }) - ) - .min(1, "At least one file attachment is required"), - - // Local (not-yet-uploaded) files - newUploads: z.array( - z.object({ - fileObj: z.any(), - uploaded: z.boolean().default(false), - }) - ), - - // track saved state - saved: z.boolean().default(false), - }) - ), -}) - -type PQFormValues = z.infer<typeof pqFormSchema> - -// ---------------------------------------------------------------------- -// 3) Main Component: PQInputTabs -// ---------------------------------------------------------------------- -export function PQInputTabs({ - data, - vendorId, - projectId, - projectData, -}: { - data: PQGroupData[] - vendorId: number - projectId?: number - projectData?: ProjectPQ | null -}) { - const [isSaving, setIsSaving] = React.useState(false) - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [allSaved, setAllSaved] = React.useState(false) - const [showConfirmDialog, setShowConfirmDialog] = React.useState(false) - - const { toast } = useToast() - const router = useRouter() - // ---------------------------------------------------------------------- - // A) Create initial form values - // Mark items as "saved" if they have existing answer or attachments - // ---------------------------------------------------------------------- - function createInitialFormValues(): PQFormValues { - const answers: PQFormValues["answers"] = [] - - data.forEach((group) => { - group.items.forEach((item) => { - // Check if the server item is already "complete" - const hasExistingAnswer = item.answer && item.answer.trim().length > 0 - const hasExistingAttachments = item.attachments && item.attachments.length > 0 - - // If either is present, we consider it "saved" initially - const isAlreadySaved = hasExistingAnswer || hasExistingAttachments - - answers.push({ - criteriaId: item.criteriaId, - answer: item.answer || "", - uploadedFiles: item.attachments.map((attach) => ({ - fileName: attach.fileName, - url: attach.filePath, - size: attach.fileSize, - })), - newUploads: [], - saved: isAlreadySaved, - }) - }) - }) - - return { answers } - } - - // ---------------------------------------------------------------------- - // B) Set up react-hook-form - // ---------------------------------------------------------------------- - const form = useForm<PQFormValues>({ - resolver: zodResolver(pqFormSchema), - defaultValues: createInitialFormValues(), - mode: "onChange", - }) - - // ---------------------------------------------------------------------- - // C) Track if all items are saved => controls Submit PQ button - // ---------------------------------------------------------------------- - React.useEffect(() => { - const values = form.getValues() - // We consider items "saved" if `saved===true` AND they have an answer or attachments - const allItemsSaved = values.answers.every( - (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0) - ) - setAllSaved(allItemsSaved) - }, [form.watch()]) - - // Helper to find the array index by criteriaId - const getAnswerIndex = (criteriaId: number): number => { - return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId) - } - - // ---------------------------------------------------------------------- - // D) Handling File Drops, Removal - // ---------------------------------------------------------------------- - const handleDropAccepted = (criteriaId: number, files: File[]) => { - const answerIndex = getAnswerIndex(criteriaId) - if (answerIndex === -1) return - - // Convert each dropped file into a LocalFileState - const newLocalFiles: LocalFileState[] = files.map((f) => ({ - fileObj: f, - uploaded: false, - })) - - const current = form.getValues(`answers.${answerIndex}.newUploads`) - form.setValue(`answers.${answerIndex}.newUploads`, [...current, ...newLocalFiles], { - shouldDirty: true, - }) - - // Mark unsaved - form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) - } - - const handleDropRejected = () => { - toast({ - title: "File upload rejected", - description: "Please check file size and type.", - variant: "destructive", - }) - } - - const removeNewUpload = (answerIndex: number, fileIndex: number) => { - const current = [...form.getValues(`answers.${answerIndex}.newUploads`)] - current.splice(fileIndex, 1) - form.setValue(`answers.${answerIndex}.newUploads`, current, { shouldDirty: true }) - - form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) - } - - const removeUploadedFile = (answerIndex: number, fileIndex: number) => { - const current = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)] - current.splice(fileIndex, 1) - form.setValue(`answers.${answerIndex}.uploadedFiles`, current, { shouldDirty: true }) - - form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) - } - - // ---------------------------------------------------------------------- - // E) Saving a Single Item - // ---------------------------------------------------------------------- - const handleSaveItem = async (answerIndex: number) => { - try { - const answerData = form.getValues(`answers.${answerIndex}`) - - // Validation - if (!answerData.answer) { - toast({ - title: "Validation Error", - description: "Answer is required", - variant: "destructive", - }) - return - } - - // Upload new files (if any) - if (answerData.newUploads.length > 0) { - setIsSaving(true) - - for (const localFile of answerData.newUploads) { - try { - const uploadResult = await uploadFileAction(localFile.fileObj) - const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`) - currentUploaded.push({ - fileName: uploadResult.fileName, - url: uploadResult.url, - size: uploadResult.size, - }) - form.setValue(`answers.${answerIndex}.uploadedFiles`, currentUploaded, { - shouldDirty: true, - }) - } catch (error) { - console.error("File upload error:", error) - toast({ - title: "Upload Error", - description: "Failed to upload file", - variant: "destructive", - }) - } - } - - // Clear newUploads - form.setValue(`answers.${answerIndex}.newUploads`, [], { shouldDirty: true }) - } - - // Save to DB - const updatedAnswer = form.getValues(`answers.${answerIndex}`) - const saveResult = await savePQAnswersAction({ - vendorId, - projectId, // 프로젝트 ID 전달 - answers: [ - { - criteriaId: updatedAnswer.criteriaId, - answer: updatedAnswer.answer, - attachments: updatedAnswer.uploadedFiles.map((f) => ({ - fileName: f.fileName, - url: f.url, - size: f.size, - })), - }, - ], - }) - - if (saveResult.ok) { - // Mark as saved - form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false }) - toast({ - title: "Saved", - description: "Item saved successfully", - }) - } - } catch (error) { - console.error("Save error:", error) - toast({ - title: "Save Error", - description: "Failed to save item", - variant: "destructive", - }) - } finally { - setIsSaving(false) - } - } - - // For convenience - const answers = form.getValues().answers - const dirtyFields = form.formState.dirtyFields.answers - - // Check if any item is dirty or has new uploads - const isAnyItemDirty = answers.some((answer, i) => { - const itemDirty = !!dirtyFields?.[i] - const hasNewUploads = answer.newUploads.length > 0 - return itemDirty || hasNewUploads - }) - - // ---------------------------------------------------------------------- - // F) Save All Items - // ---------------------------------------------------------------------- - const handleSaveAll = async () => { - try { - setIsSaving(true) - const answers = form.getValues().answers - - // Only save items that are dirty or have new uploads - for (let i = 0; i < answers.length; i++) { - const itemDirty = !!dirtyFields?.[i] - const hasNewUploads = answers[i].newUploads.length > 0 - if (!itemDirty && !hasNewUploads) continue - - await handleSaveItem(i) - } - - toast({ - title: "All Saved", - description: "All items saved successfully", - }) - } catch (error) { - console.error("Save all error:", error) - toast({ - title: "Save Error", - description: "Failed to save all items", - variant: "destructive", - }) - } finally { - setIsSaving(false) - } - } - - // ---------------------------------------------------------------------- - // G) Submission with Confirmation Dialog - // ---------------------------------------------------------------------- - const handleSubmitPQ = () => { - if (!allSaved) { - toast({ - title: "Cannot Submit", - description: "Please save all items before submitting", - variant: "destructive", - }) - return - } - setShowConfirmDialog(true) - } - - const handleConfirmSubmission = async () => { - try { - setIsSubmitting(true) - setShowConfirmDialog(false) - - const result = await submitPQAction({ - vendorId, - projectId, // 프로젝트 ID 전달 - }) - - if (result.ok) { - toast({ - title: "PQ Submitted", - description: "Your PQ information has been submitted successfully", - }) - // 제출 후 페이지 새로고침 또는 리디렉션 처리 - router.refresh() - // window.location.reload() - } else { - toast({ - title: "Submit Error", - description: result.error || "Failed to submit PQ", - variant: "destructive", - }) - } - } catch (error) { - console.error("Submit error:", error) - toast({ - title: "Submit Error", - description: "Failed to submit PQ information", - variant: "destructive", - }) - } finally { - setIsSubmitting(false) - } - } - - // 프로젝트 정보 표시 섹션 - const renderProjectInfo = () => { - if (!projectData) return null; - - return ( - <div className="mb-6 bg-muted p-4 rounded-md"> - <div className="flex items-center justify-between mb-2"> - <h3 className="text-lg font-semibold">프로젝트 정보</h3> - <Badge variant={getStatusVariant(projectData.status)}> - {getStatusLabel(projectData.status)} - </Badge> - </div> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div> - <p className="text-sm font-medium text-muted-foreground">프로젝트 코드</p> - <p>{projectData.projectCode}</p> - </div> - <div> - <p className="text-sm font-medium text-muted-foreground">프로젝트명</p> - <p>{projectData.projectName}</p> - </div> - {projectData.submittedAt && ( - <div className="col-span-1 md:col-span-2"> - <p className="text-sm font-medium text-muted-foreground">제출일</p> - <p>{formatDate(projectData.submittedAt)}</p> - </div> - )} - </div> - </div> - ); - }; - - // 상태 표시용 함수 - const getStatusLabel = (status: string) => { - switch (status) { - case "REQUESTED": return "요청됨"; - case "IN_PROGRESS": return "진행중"; - case "SUBMITTED": return "제출됨"; - case "APPROVED": return "승인됨"; - case "REJECTED": return "반려됨"; - default: return status; - } - }; - - const getStatusVariant = (status: string) => { - switch (status) { - case "REQUESTED": return "secondary"; - case "IN_PROGRESS": return "default"; - case "SUBMITTED": return "outline"; - case "APPROVED": return "outline"; - case "REJECTED": return "destructive"; - default: return "secondary"; - } - }; - - // 날짜 형식화 함수 - const formatDate = (date: Date) => { - if (!date) return "-"; - return new Date(date).toLocaleDateString("ko-KR", { - year: "numeric", - month: "long", - day: "numeric", - }); - }; - - // ---------------------------------------------------------------------- - // H) Render - // ---------------------------------------------------------------------- - return ( - <Form {...form}> - <form> - {/* 프로젝트 정보 섹션 */} - {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" - > - <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} - onClick={handleSaveAll} - > - {isSaving ? "Saving..." : "Save All"} - <Save className="ml-2 h-4 w-4" /> - </Button> - - {/* Submit PQ button */} - <Button - type="button" - disabled={!allSaved || isSubmitting} - onClick={handleSubmitPQ} - > - {isSubmitting ? "Submitting..." : "Submit PQ"} - <CheckCircle2 className="ml-2 h-4 w-4" /> - </Button> - </div> - </div> - - {/* Render each group */} - {data.map((group) => ( - <TabsContent key={group.groupName} value={group.groupName}> - {/* 2-column grid */} - <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4"> - {group.items.map((item) => { - const { criteriaId, code, checkPoint, description, contractInfo, additionalRequirement } = item - const answerIndex = getAnswerIndex(criteriaId) - if (answerIndex === -1) return null - - const isSaved = form.watch(`answers.${answerIndex}.saved`) - const hasAnswer = form.watch(`answers.${answerIndex}.answer`) - const newUploads = form.watch(`answers.${answerIndex}.newUploads`) - const dirtyFieldsItem = form.formState.dirtyFields.answers?.[answerIndex] - - const isItemDirty = !!dirtyFieldsItem - const hasNewUploads = newUploads.length > 0 - const canSave = isItemDirty || hasNewUploads - - // For "Not Saved" vs. "Saved" status label - const hasUploads = - form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 || - newUploads.length > 0 - const isValid = !!hasAnswer || hasUploads - - return ( - <Collapsible key={criteriaId} defaultOpen={!isSaved} className="w-full"> - <Card className={isSaved ? "border-green-200" : ""}> - <CardHeader className="pb-1"> - <div className="flex justify-between"> - <div className="flex-1"> - <div className="flex items-center gap-2"> - <CollapsibleTrigger asChild> - <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> - <ChevronsUpDown className="h-4 w-4" /> - <span className="sr-only">Toggle</span> - </Button> - </CollapsibleTrigger> - <CardTitle className="text-md"> - {code} - {checkPoint} - </CardTitle> - </div> - {description && ( - <CardDescription className="mt-1 whitespace-pre-wrap"> - {description} - </CardDescription> - )} - </div> - - {/* Save Status & Button */} - <div className="flex items-center gap-2"> - {!isSaved && canSave && ( - <span className="text-amber-600 text-xs flex items-center"> - <AlertTriangle className="h-4 w-4 mr-1" /> - Not Saved - </span> - )} - {isSaved && ( - <span className="text-green-600 text-xs flex items-center"> - <CheckCircle2 className="h-4 w-4 mr-1" /> - Saved - </span> - )} - - <Button - size="sm" - variant="outline" - disabled={isSaving || !canSave} - onClick={() => handleSaveItem(answerIndex)} - > - Save - </Button> - </div> - </div> - </CardHeader> - - <CollapsibleContent> - <CardContent className="pt-3 space-y-3"> - {/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */} - {projectId && contractInfo && ( - <div className="space-y-1"> - <FormLabel className="text-sm font-medium">계약 정보</FormLabel> - <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> - {contractInfo} - </div> - </div> - )} - - {projectId && additionalRequirement && ( - <div className="space-y-1"> - <FormLabel className="text-sm font-medium">추가 요구사항</FormLabel> - <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> - {additionalRequirement} - </div> - </div> - )} - - {/* Answer Field */} - <FormField - control={form.control} - name={`answers.${answerIndex}.answer`} - render={({ field }) => ( - <FormItem className="mt-2"> - <FormLabel>Answer</FormLabel> - <FormControl> - <Textarea - {...field} - className="min-h-24" - placeholder="Enter your answer here" - onChange={(e) => { - field.onChange(e) - form.setValue( - `answers.${answerIndex}.saved`, - false, - { shouldDirty: true } - ) - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - - {/* Attachments / Dropzone */} - <div className="grid gap-2 mt-3"> - <FormLabel>Attachments</FormLabel> - <Dropzone - maxSize={6e8} // 600MB - onDropAccepted={(files) => - handleDropAccepted(criteriaId, files) - } - onDropRejected={handleDropRejected} - > - {() => ( - <FormItem> - <DropzoneZone className="flex justify-center h-24"> - <FormControl> - <DropzoneInput /> - </FormControl> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop files here</DropzoneTitle> - <DropzoneDescription> - Max size: 600MB - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - <FormDescription> - Or click to browse files - </FormDescription> - <FormMessage /> - </FormItem> - )} - </Dropzone> - </div> - - {/* Existing + Pending Files */} - <div className="mt-4 space-y-4"> - {/* 1) Not-yet-uploaded files */} - {newUploads.length > 0 && ( - <div className="grid gap-2"> - <h6 className="text-sm font-medium"> - Pending Files ({newUploads.length}) - </h6> - <FileList> - {newUploads.map((f, fileIndex) => { - const fileObj = f.fileObj - if (!fileObj) return null - - return ( - <FileListItem key={fileIndex}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{fileObj.name}</FileListName> - <FileListDescription> - {prettyBytes(fileObj.size)} - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={() => - removeNewUpload(answerIndex, fileIndex) - } - > - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ) - })} - </FileList> - </div> - )} - - {/* 2) Already uploaded files */} - {form - .watch(`answers.${answerIndex}.uploadedFiles`) - .map((file, fileIndex) => ( - <FileListItem key={fileIndex}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.fileName}</FileListName> - {/* If you want to display the path: - <FileListDescription>{file.url}</FileListDescription> - */} - </FileListInfo> - {file.size && ( - <span className="text-xs text-muted-foreground"> - {prettyBytes(file.size)} - </span> - )} - <FileListAction - onClick={() => - removeUploadedFile(answerIndex, fileIndex) - } - > - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ))} - </div> - </CardContent> - </CollapsibleContent> - </Card> - </Collapsible> - ) - })} - </div> - </TabsContent> - ))} - </Tabs> - </form> - - {/* Confirmation Dialog */} - <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> - <DialogContent> - <DialogHeader> - <DialogTitle>Confirm Submission</DialogTitle> - <DialogDescription> - {projectId - ? `${projectData?.projectCode} 프로젝트의 PQ 응답을 제출하시겠습니까?` - : "일반 PQ 응답을 제출하시겠습니까?" - } 제출 후에는 수정이 불가능합니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4 max-h-[600px] overflow-y-auto "> - {data.map((group) => ( - <Collapsible key={group.groupName} defaultOpen> - <CollapsibleTrigger asChild> - <div className="flex justify-between items-center p-2 mb-1 cursor-pointer "> - <p className="font-semibold">{group.groupName}</p> - <ChevronsUpDown className="h-4 w-4 ml-2" /> - </div> - </CollapsibleTrigger> - - <CollapsibleContent> - {group.items.map((item) => { - const answerObj = form - .getValues() - .answers.find((a) => a.criteriaId === item.criteriaId) - - if (!answerObj) return null - - return ( - <div key={item.criteriaId} className="mb-2 p-2 ml-2 border rounded-md text-sm"> - {/* code & checkPoint */} - <p className="font-semibold"> - {item.code} - {item.checkPoint} - </p> - - {/* user's typed answer */} - <p className="text-sm font-medium mt-2">Answer:</p> - <p className="whitespace-pre-wrap text-sm"> - {answerObj.answer || "(no answer)"} - </p> - {/* attachments */} - <p>Attachments:</p> - {answerObj.uploadedFiles.length > 0 ? ( - <ul className="list-disc list-inside ml-4 text-xs"> - {answerObj.uploadedFiles.map((file, idx) => ( - <li key={idx}>{file.fileName}</li> - ))} - </ul> - ) : ( - <p className="text-xs text-muted-foreground">(none)</p> - )} - </div> - ) - })} - </CollapsibleContent> - </Collapsible> - ))} - </div> - - <DialogFooter> - <Button - variant="outline" - onClick={() => setShowConfirmDialog(false)} - disabled={isSubmitting} - > - Cancel - </Button> - <Button onClick={handleConfirmSubmission} disabled={isSubmitting}> - {isSubmitting ? "Submitting..." : "Confirm Submit"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </Form> - ) -}
\ No newline at end of file diff --git a/components/pq/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx deleted file mode 100644 index 4f897a2b..00000000 --- a/components/pq/pq-review-detail.tsx +++ /dev/null @@ -1,888 +0,0 @@ -"use client" - -import React from "react" -import { Button } from "@/components/ui/button" -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" -import { Textarea } from "@/components/ui/textarea" -import { useToast } from "@/hooks/use-toast" -import { - PQGroupData, - requestPqChangesAction, - updateVendorStatusAction, - updateProjectPQStatusAction, - getItemReviewLogsAction -} from "@/lib/pq/service" -import { Vendor } from "@/db/schema/vendors" -import { Separator } from "@/components/ui/separator" -import { Badge } from "@/components/ui/badge" -import { ChevronsUpDown, MessagesSquare, Download, Loader2, X } from "lucide-react" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { Card } from "@/components/ui/card" -import { formatDate } from "@/lib/utils" -import { downloadFileAction } from "@/lib/downloadFile" -import { useSession } from "next-auth/react" // Importando o hook do next-auth - -// 코멘트 상태를 위한 인터페이스 정의 -interface PendingComment { - answerId: number; - checkPoint: string; - code: string; - comment: string; - createdAt: Date; -} - -interface ReviewLog { - id: number - reviewerComment: string - reviewerName: string | null - createdAt: Date -} - -// Updated props interface to support both general and project PQs -interface VendorPQAdminReviewProps { - data: PQGroupData[] - vendor: Vendor - projectId?: number - projectName?: string - projectStatus?: string - // loadData: () => Promise<PQGroupData[]> - loadData: (vendorId: number, projectId?: number) => Promise<PQGroupData[]> - - pqType: 'general' | 'project' -} - -export default function VendorPQAdminReview({ - data, - vendor, - projectId, - projectName, - projectStatus, - loadData, - pqType -}: VendorPQAdminReviewProps) { - const { toast } = useToast() - const { data: session } = useSession() - const reviewerName = session?.user?.name || "Unknown Reviewer" - const reviewerId = session?.user?.id - - - // State for dynamically loaded data - const [pqData, setPqData] = React.useState<PQGroupData[]>(data) - const [isDataLoading, setIsDataLoading] = React.useState(false) - - // Load data if not provided initially (for tab switching) - React.useEffect(() => { - if (data.length === 0) { - const fetchData = async () => { - setIsDataLoading(true) - try { - const freshData = await loadData(vendor.id, projectId) - - setPqData(freshData) - } catch (error) { - console.error("Error loading PQ data:", error) - toast({ - title: "Error", - description: "Failed to load PQ data", - variant: "destructive" - }) - } finally { - setIsDataLoading(false) - } - } - fetchData() - } else { - setPqData(data) - } - }, [data, loadData, vendor.id, projectId, toast]) - - // 다이얼로그 상태들 - const [showRequestDialog, setShowRequestDialog] = React.useState(false) - const [showApproveDialog, setShowApproveDialog] = React.useState(false) - const [showRejectDialog, setShowRejectDialog] = React.useState(false) - - // 코멘트 상태들 - const [requestComment, setRequestComment] = React.useState("") - const [approveComment, setApproveComment] = React.useState("") - const [rejectComment, setRejectComment] = React.useState("") - const [isLoading, setIsLoading] = React.useState(false) - - // 항목별 코멘트 상태 추적 (메모리에만 저장) - const [pendingComments, setPendingComments] = React.useState<PendingComment[]>([]) - - // 코멘트 추가 핸들러 - 실제 서버 저장이 아닌 메모리에 저장 - const handleCommentAdded = (newComment: PendingComment) => { - setPendingComments(prev => [...prev, newComment]); - toast({ - title: "Comment Added", - description: `Comment added for ${newComment.code}. Please "Request Changes" to save.` - }); - } - - // 코멘트 삭제 핸들러 - const handleRemoveComment = (index: number) => { - setPendingComments(prev => prev.filter((_, i) => i !== index)); - } - - // 1) 승인 다이얼로그 표시 - const handleApprove = () => { - // 코멘트가 있는데 승인하려고 하면 경고 - if (pendingComments.length > 0) { - if (!confirm('You have unsaved comments. Are you sure you want to approve without requesting changes?')) { - return; - } - } - setShowApproveDialog(true) - } - - // 실제 승인 처리 - 일반 PQ와 프로젝트 PQ 분리 - const handleSubmitApprove = async () => { - try { - setIsLoading(true) - setShowApproveDialog(false) - - let res; - - if (pqType === 'general') { - // 일반 PQ 승인 - res = await updateVendorStatusAction(vendor.id, "PQ_APPROVED") - } else if (projectId) { - // 프로젝트 PQ 승인 - res = await updateProjectPQStatusAction({ - vendorId: vendor.id, - projectId, - status: "APPROVED", - comment: approveComment.trim() || undefined - }) - } - - if (res?.ok) { - toast({ - title: "Approved", - description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been approved.` - }) - // 코멘트 초기화 - setPendingComments([]); - } else { - toast({ - title: "Error", - description: res?.error || "An error occurred", - variant: "destructive" - }) - } - } catch (error) { - toast({ title: "Error", description: String(error), variant: "destructive" }) - } finally { - setIsLoading(false) - setApproveComment("") - } - } - - // 2) 거부 다이얼로그 표시 - const handleReject = () => { - // 코멘트가 있는데 거부하려고 하면 경고 - if (pendingComments.length > 0) { - if (!confirm('You have unsaved comments. Are you sure you want to reject without requesting changes?')) { - return; - } - } - setShowRejectDialog(true) - } - - // 실제 거부 처리 - 일반 PQ와 프로젝트 PQ 분리 - const handleSubmitReject = async () => { - try { - setIsLoading(true) - setShowRejectDialog(false) - - if (!rejectComment.trim()) { - toast({ - title: "Error", - description: "Please provide a reason for rejection", - variant: "destructive" - }) - return; - } - - let res; - - if (pqType === 'general') { - // 일반 PQ 거부 - res = await updateVendorStatusAction(vendor.id, "REJECTED") - } else if (projectId) { - // 프로젝트 PQ 거부 - res = await updateProjectPQStatusAction({ - vendorId: vendor.id, - projectId, - status: "REJECTED", - comment: rejectComment - }) - } - - if (res?.ok) { - toast({ - title: "Rejected", - description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been rejected.` - }) - // 코멘트 초기화 - setPendingComments([]); - } else { - toast({ - title: "Error", - description: res?.error || "An error occurred", - variant: "destructive" - }) - } - } catch (error) { - toast({ title: "Error", description: String(error), variant: "destructive" }) - } finally { - setIsLoading(false) - setRejectComment("") - } - } - - // 3) 변경 요청 다이얼로그 표시 - const handleRequestChanges = () => { - setShowRequestDialog(true) - } - - // 4) 변경 요청 처리 - 이제 프로젝트 ID 포함 - const handleSubmitRequestChanges = async () => { - try { - setIsLoading(true); - setShowRequestDialog(false); - - // 항목별 코멘트 준비 - answerId와 함께 checkPoint와 code도 전송 - const itemComments = pendingComments.map(pc => ({ - answerId: pc.answerId, - checkPoint: pc.checkPoint, - code: pc.code, - comment: pc.comment - })); - - // 서버 액션 호출 (프로젝트 ID 추가) - const res = await requestPqChangesAction({ - vendorId: vendor.id, - projectId: pqType === 'project' ? projectId : undefined, - comment: itemComments, - generalComment: requestComment || undefined, - reviewerName, - reviewerId - }); - - if (res.ok) { - toast({ - title: "Changes Requested", - description: `${pqType === 'general' ? 'Vendor' : 'Project'} was notified of your comments.`, - }); - // 코멘트 초기화 - setPendingComments([]); - } else { - toast({ - title: "Error", - description: res.error, - variant: "destructive" - }); - } - } catch (error) { - toast({ - title: "Error", - description: String(error), - variant: "destructive" - }); - } finally { - setIsLoading(false); - setRequestComment(""); - } - }; - - // 현재 상태에 따른 액션 버튼 비활성화 여부 판단 - const getDisabledState = () => { - if (pqType === 'general') { - // 일반 PQ는 vendor 상태에 따라 결정 - return vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED'; - } else if (pqType === 'project' && projectStatus) { - // 프로젝트 PQ는 project 상태에 따라 결정 - return projectStatus === 'APPROVED' || projectStatus === 'REJECTED'; - } - return false; - }; - - const areActionsDisabled = getDisabledState(); - - return ( - <div className="space-y-4"> - {/* PQ Type indicators and status */} - {pqType === 'project' && projectName && ( - <div className="flex flex-col space-y-1 mb-4"> - <div className="flex items-center gap-2"> - <Badge variant="outline">{projectName}</Badge> - {projectStatus && ( - <Badge className={ - projectStatus === 'APPROVED' ? 'bg-green-100 text-green-800' : - projectStatus === 'REJECTED' ? 'bg-red-100 text-red-800' : - 'bg-blue-100 text-blue-800' - }> - {projectStatus} - </Badge> - )} - </div> - {areActionsDisabled && ( - <p className="text-sm text-muted-foreground"> - This PQ has already been { - pqType !== 'project' - ? (vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED' ? 'approved' : 'rejected') - : (projectStatus === 'APPROVED' ? 'approved' : 'rejected') - }. No further actions can be taken. - </p> - )} - </div> - )} - - {/* Loading indicator */} - {isDataLoading && ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-8 w-8 animate-spin text-primary" /> - </div> - )} - - {!isDataLoading && ( - <> - {/* Top header */} - <div className="flex items-center justify-between"> - <h2 className="text-2xl font-bold"> - {vendor.vendorCode} - {vendor.vendorName} {pqType === 'project' ? 'Project' : 'General'} PQ Review - </h2> - <div className="flex gap-2"> - <Button - variant="outline" - disabled={isLoading || areActionsDisabled} - onClick={handleReject} - > - Reject - </Button> - <Button - variant={pendingComments.length > 0 ? "default" : "outline"} - disabled={isLoading || areActionsDisabled} - onClick={handleRequestChanges} - > - Request Changes - {pendingComments.length > 0 && ( - <span className="ml-2 bg-white text-primary rounded-full h-5 min-w-5 inline-flex items-center justify-center text-xs px-1"> - {pendingComments.length} - </span> - )} - </Button> - <Button - disabled={isLoading || areActionsDisabled} - onClick={handleApprove} - > - Approve - </Button> - </div> - </div> - - <p className="text-sm text-muted-foreground"> - Review the submitted PQ items below, then approve, reject, or request more info. - </p> - - {/* 코멘트가 있을 때 알림 표시 */} - {pendingComments.length > 0 && ( - <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-yellow-800"> - <p className="text-sm font-medium flex items-center"> - <span className="mr-2">⚠️</span> - You have {pendingComments.length} pending comments. Click "Request Changes" to save them. - </p> - </div> - )} - - <Separator /> - - {/* PQ 데이터 표시 */} - {pqData.length > 0 ? ( - <VendorPQReviewPageIntegrated - data={pqData} - onCommentAdded={handleCommentAdded} - /> - ) : ( - <div className="text-center py-10"> - <p className="text-muted-foreground">No PQ data available for review.</p> - </div> - )} - </> - )} - - {/* 변경 요청 다이얼로그 */} - <Dialog open={showRequestDialog} onOpenChange={setShowRequestDialog}> - <DialogContent className="max-w-3xl"> - <DialogHeader> - <DialogTitle>Request PQ Changes</DialogTitle> - <DialogDescription> - Review your comments and add any additional notes. The vendor will receive these changes. - </DialogDescription> - </DialogHeader> - - {/* 항목별 코멘트 목록 */} - {pendingComments.length > 0 && ( - <div className="border rounded-md p-2 space-y-2 max-h-[300px] overflow-y-auto"> - <h3 className="font-medium text-sm">Item Comments:</h3> - {pendingComments.map((comment, index) => ( - <div key={index} className="flex items-start gap-2 p-2 border rounded-md bg-muted/50"> - <div className="flex-1"> - <div className="flex items-center gap-2"> - <span className="text-sm font-medium">{comment.code}</span> - <span className="text-sm">{comment.checkPoint}</span> - </div> - <p className="text-sm mt-1">{comment.comment}</p> - <p className="text-xs text-muted-foreground mt-1"> - {formatDate(comment.createdAt, "KR")} - </p> - </div> - <Button - variant="ghost" - size="sm" - className="p-0 h-8 w-8" - onClick={() => handleRemoveComment(index)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ))} - </div> - )} - - {/* 추가 코멘트 입력 */} - <div className="space-y-2 mt-2"> - <label className="text-sm font-medium"> - {pendingComments.length > 0 - ? "Additional comments (optional):" - : "Enter details about what should be modified:"} - </label> - <Textarea - value={requestComment} - onChange={(e) => setRequestComment(e.target.value)} - placeholder={pendingComments.length > 0 - ? "Add any additional notes..." - : "Please correct item #1, etc..."} - className="min-h-[100px]" - /> - </div> - - <DialogFooter> - <Button - variant="outline" - onClick={() => setShowRequestDialog(false)} - disabled={isLoading} - > - Cancel - </Button> - <Button - onClick={handleSubmitRequestChanges} - disabled={isLoading || (pendingComments.length === 0 && !requestComment.trim())} - > - Submit Changes - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - - {/* 승인 확인 다이얼로그 */} - <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}> - <DialogContent> - <DialogHeader> - <DialogTitle>Confirm Approval</DialogTitle> - <DialogDescription> - Are you sure you want to approve this {pqType === 'project' ? 'project' : 'vendor'} PQ? You can add a comment if needed. - </DialogDescription> - </DialogHeader> - - <div className="space-y-2"> - <Textarea - value={approveComment} - onChange={(e) => setApproveComment(e.target.value)} - placeholder="Optional: Add any comments about this approval" - className="min-h-[100px]" - /> - </div> - - <DialogFooter> - <Button - variant="outline" - onClick={() => setShowApproveDialog(false)} - disabled={isLoading} - > - Cancel - </Button> - <Button - onClick={handleSubmitApprove} - disabled={isLoading} - > - Confirm Approval - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - - {/* 거부 확인 다이얼로그 */} - <Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}> - <DialogContent> - <DialogHeader> - <DialogTitle>Confirm Rejection</DialogTitle> - <DialogDescription> - Are you sure you want to reject this {pqType === 'project' ? 'project' : 'vendor'} PQ? Please provide a reason. - </DialogDescription> - </DialogHeader> - - <div className="space-y-2"> - <Textarea - value={rejectComment} - onChange={(e) => setRejectComment(e.target.value)} - placeholder="Required: Provide reason for rejection" - className="min-h-[150px]" - /> - </div> - - <DialogFooter> - <Button - variant="outline" - onClick={() => setShowRejectDialog(false)} - disabled={isLoading} - > - Cancel - </Button> - <Button - onClick={handleSubmitReject} - disabled={isLoading || !rejectComment.trim()} - variant="destructive" - > - Confirm Rejection - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </div> - ) -} - -// 코멘트 추가 함수 인터페이스 -interface VendorPQReviewPageIntegratedProps { - data: PQGroupData[]; - onCommentAdded: (comment: PendingComment) => void; -} - -// 통합된 VendorPQReviewPage 컴포넌트 -function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPageIntegratedProps) { - const { toast } = useToast(); - - // 파일 다운로드 함수 - 서버 액션 사용 - const handleFileDownload = async (filePath: string, fileName: string) => { - try { - toast({ - title: "Download Started", - description: `Preparing ${fileName} for download...`, - }); - - // 서버 액션 호출 - const result = await downloadFileAction(filePath); - - if (!result.ok || !result.data) { - throw new Error(result.error || 'Failed to download file'); - } - - // Base64 디코딩하여 Blob 생성 - const binaryString = atob(result.data.content); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - // Blob 생성 및 다운로드 - const blob = new Blob([bytes.buffer], { type: result.data.mimeType }); - const url = URL.createObjectURL(blob); - - // 다운로드 링크 생성 및 클릭 - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - - // 정리 - URL.revokeObjectURL(url); - document.body.removeChild(a); - - toast({ - title: "Download Complete", - description: `${fileName} downloaded successfully`, - }); - } catch (error) { - console.error('Download error:', error); - toast({ - title: "Download Error", - description: error instanceof Error ? error.message : "Failed to download file", - variant: "destructive" - }); - } - }; - - return ( - <div className="space-y-4"> - {data.map((group) => ( - <Collapsible key={group.groupName} defaultOpen> - <CollapsibleTrigger asChild> - <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded"> - <h2 className="font-semibold text-lg">{group.groupName}</h2> - <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> - <ChevronsUpDown className="h-4 w-4" /> - </Button> - </div> - </CollapsibleTrigger> - - <CollapsibleContent> - <Card className="mt-2 p-4"> - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[60px]">Code</TableHead> - <TableHead>Check Point</TableHead> - <TableHead>Answer</TableHead> - <TableHead className="w-[180px]">Attachments</TableHead> - <TableHead className="w-[60px] text-center">Comments</TableHead> - </TableRow> - </TableHeader> - - <TableBody> - {group.items.map((item) => ( - <TableRow key={item.criteriaId}> - <TableCell className="font-medium">{item.code}</TableCell> - <TableCell>{item.checkPoint}</TableCell> - - <TableCell> - {item.answer ? ( - <p className="whitespace-pre-wrap text-sm"> - {item.answer} - </p> - ) : ( - <p className="text-sm text-muted-foreground">(no answer)</p> - )} - </TableCell> - - <TableCell> - {item.attachments.length > 0 ? ( - <ul className="list-none space-y-1"> - {item.attachments.map((file) => ( - <li key={file.attachId} className="text-sm flex items-center"> - <button - className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]" - onClick={() => handleFileDownload(file.filePath, file.fileName)} - > - <Download className="h-3 w-3 mr-1 flex-shrink-0" /> - <span className="truncate">{file.fileName}</span> - </button> - </li> - ))} - </ul> - ) : ( - <p className="text-sm text-muted-foreground">(none)</p> - )} - </TableCell> - - <TableCell className="text-center"> - <ItemCommentButton - item={item} - onCommentAdded={onCommentAdded} - /> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </Card> - </CollapsibleContent> - </Collapsible> - ))} - </div> - ); -} - -// 항목 코멘트 버튼 컴포넌트 props -interface ItemCommentButtonProps { - item: any; // 항목 데이터 - onCommentAdded: (comment: PendingComment) => void; -} - -// 항목별 코멘트 버튼 컴포넌트 (기존 로그 표시 + 메모리에 새 코멘트 저장) -function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { - const { toast } = useToast(); - const [open, setOpen] = React.useState(false); - const [logs, setLogs] = React.useState<ReviewLog[]>([]); - const [newComment, setNewComment] = React.useState(""); - const [isLoading, setIsLoading] = React.useState(false); - const [hasComments, setHasComments] = React.useState(false); - - // If there's no answerId, item wasn't answered - if (!item.answerId) { - return <p className="text-xs text-muted-foreground">N/A</p>; - } - - // 기존 로그 가져오기 - const fetchLogs = React.useCallback(async () => { - try { - setIsLoading(true); - const res = await getItemReviewLogsAction({ answerId: item.answerId }); - - if (res.ok && res.data) { - setLogs(res.data); - // 코멘트 존재 여부 설정 - setHasComments(res.data.length > 0); - } else { - console.error("Error response:", res.error); - toast({ title: "Error", description: res.error, variant: "destructive" }); - } - } catch (error) { - console.error("Fetch error:", error); - toast({ title: "Error", description: String(error), variant: "destructive" }); - } finally { - setIsLoading(false); - } - }, [item.answerId, toast]); - - // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용) - React.useEffect(() => { - const checkComments = async () => { - try { - const res = await getItemReviewLogsAction({ answerId: item.answerId }); - if (res.ok && res.data) { - setHasComments(res.data.length > 0); - } - } catch (error) { - console.error("Error checking comments:", error); - } - }; - - checkComments(); - }, [item.answerId]); - - // open 상태가 변경될 때 로그 가져오기 - React.useEffect(() => { - if (open) { - fetchLogs(); - } - }, [open, fetchLogs]); - - // 다이얼로그 열기 - const handleButtonClick = React.useCallback(() => { - setOpen(true); - }, []); - - // 다이얼로그 상태 변경 - const handleOpenChange = React.useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - }, []); - - // 코멘트 추가 처리 (메모리에만 저장) - const handleAddComment = React.useCallback(() => { - if (!newComment.trim()) return; - - setIsLoading(true); - - // 새 코멘트 생성 - const pendingComment: PendingComment = { - answerId: item.answerId, - checkPoint: item.checkPoint, - code: item.code, - comment: newComment.trim(), - createdAt: new Date() - }; - - // 부모 컴포넌트에 전달 - onCommentAdded(pendingComment); - - // 상태 초기화 - setNewComment(""); - setOpen(false); - setIsLoading(false); - }, [item, newComment, onCommentAdded]); - - return ( - <> - <Button variant="ghost" size="sm" onClick={handleButtonClick}> - <MessagesSquare - className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`} - /> - </Button> - - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent> - <DialogHeader> - <DialogTitle>{item.checkPoint}</DialogTitle> - <DialogDescription> - Review existing comments and add new ones - </DialogDescription> - </DialogHeader> - - {/* 기존 로그 섹션 */} - <div className="max-h-[200px] overflow-y-auto space-y-2"> - {isLoading ? ( - <div className="flex justify-center p-4"> - <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> - </div> - ) : logs.length > 0 ? ( - <div className="space-y-2"> - <h3 className="text-sm font-medium">Previous Comments:</h3> - {logs.map((log) => ( - <div key={log.id} className="p-2 border rounded text-sm"> - <p className="font-medium">{log.reviewerName}</p> - <p>{log.reviewerComment}</p> - <p className="text-xs text-muted-foreground"> - {formatDate(log.createdAt, "KR")} - </p> - </div> - ))} - </div> - ) : ( - <p className="text-sm text-muted-foreground">No previous comments yet.</p> - )} - </div> - - {/* 구분선 */} - {/* <Separator /> */} - - {/* 새 코멘트 추가 섹션 */} - <div className="space-y-2 mt-2"> - <div className="flex items-center justify-between"> - {/* <h3 className="text-sm font-medium">Add New Comment:</h3> */} - {/* <p className="text-xs text-muted-foreground"> - Comments will be saved when you click "Request Changes" - </p> */} - </div> - <Textarea - placeholder="Add your comment..." - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - className="min-h-[100px]" - /> - <Button - onClick={handleAddComment} - disabled={isLoading || !newComment.trim()} - > - Add Comment - </Button> - </div> - </DialogContent> - </Dialog> - </> - ); -}
\ No newline at end of file diff --git a/components/pq/pq-review-table.tsx b/components/pq/pq-review-table.tsx deleted file mode 100644 index ce30bac0..00000000 --- a/components/pq/pq-review-table.tsx +++ /dev/null @@ -1,344 +0,0 @@ -"use client" - -import * as React from "react" -import { ChevronsUpDown, MessagesSquare, Download, Loader2 } from "lucide-react" - -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { Card } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { PQGroupData } from "@/lib/pq/service" -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Textarea } from "@/components/ui/textarea" -import { addReviewCommentAction, getItemReviewLogsAction } from "@/lib/pq/service" -import { useToast } from "@/hooks/use-toast" -import { formatDate } from "@/lib/utils" -import { downloadFileAction } from "@/lib/downloadFile" -import { useSession } from "next-auth/react" - -interface ReviewLog { - id: number - reviewerComment: string - reviewerName: string | null - createdAt: Date -} - -interface VendorPQReviewPageProps { - data: PQGroupData[]; - onCommentAdded?: () => void; // 코멘트 추가 콜백 -} - -export default function VendorPQReviewPage({ data, onCommentAdded }: VendorPQReviewPageProps) { - const { toast } = useToast() - - // 파일 다운로드 함수 - 서버 액션 사용 - const handleFileDownload = async (filePath: string, fileName: string) => { - try { - toast({ - title: "Download Started", - description: `Preparing ${fileName} for download...`, - }); - - // 서버 액션 호출 - const result = await downloadFileAction(filePath); - - if (!result.ok || !result.data) { - throw new Error(result.error || 'Failed to download file'); - } - - // Base64 디코딩하여 Blob 생성 - const binaryString = atob(result.data.content); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - // Blob 생성 및 다운로드 - const blob = new Blob([bytes.buffer], { type: result.data.mimeType }); - const url = URL.createObjectURL(blob); - - // 다운로드 링크 생성 및 클릭 - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - - // 정리 - URL.revokeObjectURL(url); - document.body.removeChild(a); - - toast({ - title: "Download Complete", - description: `${fileName} downloaded successfully`, - }); - } catch (error) { - console.error('Download error:', error); - toast({ - title: "Download Error", - description: error instanceof Error ? error.message : "Failed to download file", - variant: "destructive" - }); - } - }; - - return ( - <div className="space-y-4"> - {data.map((group) => ( - <Collapsible key={group.groupName} defaultOpen> - <CollapsibleTrigger asChild> - <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded"> - <h2 className="font-semibold text-lg">{group.groupName}</h2> - <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> - <ChevronsUpDown className="h-4 w-4" /> - </Button> - </div> - </CollapsibleTrigger> - - <CollapsibleContent> - <Card className="mt-2 p-4"> - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[60px]">Code</TableHead> - <TableHead>Check Point</TableHead> - <TableHead>Answer</TableHead> - <TableHead className="w-[180px]">Attachments</TableHead> - <TableHead className="w-[60px] text-center">Comments</TableHead> - </TableRow> - </TableHeader> - - <TableBody> - {group.items.map((item) => ( - <TableRow key={item.criteriaId}> - <TableCell className="font-medium">{item.code}</TableCell> - <TableCell>{item.checkPoint}</TableCell> - - <TableCell> - {item.answer ? ( - <p className="whitespace-pre-wrap text-sm"> - {item.answer} - </p> - ) : ( - <p className="text-sm text-muted-foreground">(no answer)</p> - )} - </TableCell> - - <TableCell> - {item.attachments.length > 0 ? ( - <ul className="list-none space-y-1"> - {item.attachments.map((file) => ( - <li key={file.attachId} className="text-sm flex items-center"> - <button - className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]" - onClick={() => handleFileDownload(file.filePath, file.fileName)} - > - <Download className="h-3 w-3 mr-1 flex-shrink-0" /> - <span className="truncate">{file.fileName}</span> - </button> - </li> - ))} - </ul> - ) : ( - <p className="text-sm text-muted-foreground">(none)</p> - )} - </TableCell> - - <TableCell className="text-center"> - <ItemReviewButton - answerId={item.answerId ?? undefined} - checkPoint={item.checkPoint} - onCommentAdded={onCommentAdded} - /> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </Card> - </CollapsibleContent> - </Collapsible> - ))} - </div> - ) -} - -interface ItemReviewButtonProps { - answerId?: number; - checkPoint: string; // Check Point 추가 - onCommentAdded?: () => void; -} - -/** - * A button that opens a dialog to show logs + add new comment for a single item (vendorPqCriteriaAnswers). - */ -function ItemReviewButton({ answerId, checkPoint, onCommentAdded }: ItemReviewButtonProps) { - const { toast } = useToast(); - const [open, setOpen] = React.useState(false); - const [logs, setLogs] = React.useState<ReviewLog[]>([]); - const [newComment, setNewComment] = React.useState(""); - const [isLoading, setIsLoading] = React.useState(false); - const [hasComments, setHasComments] = React.useState(false); - const { data: session } = useSession() - const reviewerName = session?.user?.name || "Unknown Reviewer" - const reviewerId = session?.user?.id - - // If there's no answerId, item wasn't answered - if (!answerId) { - return <p className="text-xs text-muted-foreground">N/A</p>; - } - - // fetchLogs 함수를 useCallback으로 메모이제이션 - const fetchLogs = React.useCallback(async () => { - try { - setIsLoading(true); - const res = await getItemReviewLogsAction({ answerId }); - - if (res.ok && res.data) { - setLogs(res.data); - // 코멘트 존재 여부 설정 - setHasComments(res.data.length > 0); - } else { - console.error("Error response:", res.error); - toast({ title: "Error", description: res.error, variant: "destructive" }); - } - } catch (error) { - console.error("Fetch error:", error); - toast({ title: "Error", description: String(error), variant: "destructive" }); - } finally { - setIsLoading(false); - } - }, [answerId, toast]); - - // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용) - React.useEffect(() => { - const checkComments = async () => { - try { - const res = await getItemReviewLogsAction({ answerId }); - if (res.ok && res.data) { - setHasComments(res.data.length > 0); - } - } catch (error) { - console.error("Error checking comments:", error); - } - }; - - checkComments(); - }, [answerId]); - - // open 상태가 변경될 때 로그 가져오기 - React.useEffect(() => { - if (open) { - fetchLogs(); - } - }, [open, fetchLogs]); - - // 버튼 클릭 핸들러 - 다이얼로그 열기 - const handleButtonClick = React.useCallback(() => { - setOpen(true); - }, []); - - // 다이얼로그 상태 변경 핸들러 - const handleOpenChange = React.useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - }, []); - - // 코멘트 추가 핸들러 - const handleAddComment = React.useCallback(async () => { - try { - setIsLoading(true); - - const res = await addReviewCommentAction({ - answerId, - comment: newComment, - reviewerName, - }); - - if (res.ok) { - toast({ title: "Comment added", description: "New review comment saved" }); - setNewComment(""); - setHasComments(true); // 코멘트 추가 성공 시 상태 업데이트 - - // 코멘트가 추가되었음을 부모 컴포넌트에 알림 - if (onCommentAdded) { - onCommentAdded(); - } - - // 로그 다시 가져오기 - fetchLogs(); - } else { - toast({ title: "Error", description: res.error, variant: "destructive" }); - } - } catch (error) { - toast({ title: "Error", description: String(error), variant: "destructive" }); - } finally { - setIsLoading(false); - } - }, [answerId, newComment, onCommentAdded, fetchLogs, toast]); - - return ( - <> - <Button variant="ghost" size="sm" onClick={handleButtonClick}> - <MessagesSquare - className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`} - /> - </Button> - - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent> - <DialogHeader> - <DialogTitle>{checkPoint} Comments</DialogTitle> - </DialogHeader> - - {/* Logs section */} - <div className="max-h-[200px] overflow-y-auto space-y-2"> - {isLoading ? ( - <div className="flex justify-center p-4"> - <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> - </div> - ) : logs.length > 0 ? ( - logs.map((log) => ( - <div key={log.id} className="p-2 border rounded text-sm"> - <p className="font-medium">{log.reviewerName}</p> - <p>{log.reviewerComment}</p> - <p className="text-xs text-muted-foreground"> - {formatDate(log.createdAt, "KR")} - </p> - </div> - )) - ) : ( - <p className="text-sm text-muted-foreground">No comments yet.</p> - )} - </div> - - {/* Add new comment */} - <div className="space-y-2"> - <Textarea - placeholder="Add a new comment..." - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - /> - <Button - size="sm" - onClick={handleAddComment} - disabled={isLoading || !newComment.trim()} - > - Add Comment - </Button> - </div> - </DialogContent> - </Dialog> - </> - ); -}
\ No newline at end of file diff --git a/components/pq/project-select-wrapper.tsx b/components/pq/project-select-wrapper.tsx deleted file mode 100644 index 1405ab02..00000000 --- a/components/pq/project-select-wrapper.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import { type Project } from "@/lib/rfqs/service" -import { ProjectSelector } from "./project-select" - -interface ProjectSelectorWrapperProps { - selectedProjectId?: number | null -} - -export function ProjectSelectorWrapper({ selectedProjectId }: ProjectSelectorWrapperProps) { - const router = useRouter() - - const handleProjectSelect = (project: Project | null) => { - if (project && project.id) { - router.push(`/evcp/pq-criteria/${project.id}`) - } else { - // 프로젝트가 null인 경우 (선택 해제) - router.push(`/evcp/pq-criteria`) - } - } - - return ( - <div className="w-[400px]"> - <ProjectSelector - selectedProjectId={selectedProjectId} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트를 선택하세요" - showClearOption={true} - clearOptionText="일반 PQ 보기" - /> - </div> - ) -}
\ No newline at end of file diff --git a/components/pq/project-select.tsx b/components/pq/project-select.tsx deleted file mode 100644 index 0d6e6445..00000000 --- a/components/pq/project-select.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client" - -import * as React from "react" -import { Check, ChevronsUpDown, X } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from "@/components/ui/command" -import { cn } from "@/lib/utils" -import { getProjects, type Project } from "@/lib/rfqs/service" - -interface ProjectSelectorProps { - selectedProjectId?: number | null; - onProjectSelect: (project: Project | null) => void; - placeholder?: string; - showClearOption?: boolean; - clearOptionText?: string; -} - -export function ProjectSelector({ - selectedProjectId, - onProjectSelect, - placeholder = "프로젝트 선택...", - showClearOption = true, - clearOptionText = "일반 PQ 보기" -}: ProjectSelectorProps) { - const [open, setOpen] = React.useState(false) - const [searchTerm, setSearchTerm] = React.useState("") - const [projects, setProjects] = React.useState<Project[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) - - // 모든 프로젝트 데이터 로드 (한 번만) - React.useEffect(() => { - async function loadAllProjects() { - setIsLoading(true); - try { - const allProjects = await getProjects(); - setProjects(allProjects); - - // 초기 선택된 프로젝트가 있으면 설정 - if (selectedProjectId) { - const selected = allProjects.find(p => p.id === selectedProjectId); - if (selected) { - setSelectedProject(selected); - } - } - } catch (error) { - console.error("프로젝트 목록 로드 오류:", error); - } finally { - setIsLoading(false); - } - } - - loadAllProjects(); - }, [selectedProjectId]); - - // 클라이언트 측에서 검색어로 필터링 - const filteredProjects = React.useMemo(() => { - if (!searchTerm.trim()) return projects; - - const lowerSearch = searchTerm.toLowerCase(); - return projects.filter( - project => - project.projectCode.toLowerCase().includes(lowerSearch) || - project.projectName.toLowerCase().includes(lowerSearch) - ); - }, [projects, searchTerm]); - - // 프로젝트 선택 처리 - const handleSelectProject = (project: Project) => { - setSelectedProject(project); - onProjectSelect(project); - setOpen(false); - }; - - // 선택 해제 처리 - const handleClearSelection = () => { - setSelectedProject(null); - onProjectSelect(null); - setOpen(false); - }; - - return ( - <div className="space-y-1"> - {/* 선택된 프로젝트 정보 표시 (선택된 경우에만) */} - {selectedProject && ( - <div className="flex items-center justify-between px-2"> - <div className="flex flex-col"> - <div className="text-sm font-medium">{selectedProject.projectCode}</div> - <div className="text-xs text-muted-foreground truncate max-w-[300px]"> - {selectedProject.projectName} - </div> - </div> - <Button - variant="ghost" - size="sm" - className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive" - onClick={handleClearSelection} - > - <X className="h-4 w-4" /> - <span className="sr-only">선택 해제</span> - </Button> - </div> - )} - - {/* 셀렉터 컴포넌트 */} - <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> - <Button - variant="outline" - role="combobox" - aria-expanded={open} - className="w-full justify-between" - > - {selectedProject ? "프로젝트 변경..." : placeholder} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </PopoverTrigger> - - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput - placeholder="프로젝트 코드/이름 검색..." - onValueChange={setSearchTerm} - /> - <CommandList className="max-h-[300px]"> - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> - - {showClearOption && selectedProject && ( - <> - <CommandGroup> - <CommandItem - onSelect={handleClearSelection} - className="text-blue-600 font-medium" - > - {clearOptionText} - </CommandItem> - </CommandGroup> - <CommandSeparator /> - </> - )} - - {isLoading ? ( - <div className="py-6 text-center text-sm">로딩 중...</div> - ) : ( - <CommandGroup> - {filteredProjects.map((project) => ( - <CommandItem - key={project.id} - value={`${project.projectCode} ${project.projectName}`} - onSelect={() => handleSelectProject(project)} - > - <Check - className={cn( - "mr-2 h-4 w-4", - selectedProject?.id === project.id - ? "opacity-100" - : "opacity-0" - )} - /> - <span className="font-medium">{project.projectCode}</span> - <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span> - </CommandItem> - ))} - </CommandGroup> - )} - </CommandList> - </Command> - </PopoverContent> - </Popover> - </div> - ); -}
\ No newline at end of file diff --git a/components/ui/file-list.tsx b/components/ui/file-list.tsx index b6ade7f5..b469a08e 100644 --- a/components/ui/file-list.tsx +++ b/components/ui/file-list.tsx @@ -23,7 +23,7 @@ export const FileListItem = React.forwardRef< <div ref={ref} className={cn( - "grid gap-4 rounded-xl border bg-card p-4 text-card-foreground shadow", + "flex items-center justify-between gap-4 rounded-xl border bg-card p-4 text-card-foreground shadow", className )} {...props} @@ -64,7 +64,7 @@ export const FileListInfo = React.forwardRef< React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div"> >(({ className, ...props }, ref) => ( - <div ref={ref} className={cn("grid flex-1 gap-1", className)} {...props} /> + <div ref={ref} className={cn("flex flex-1 items-center gap-2", className)} {...props} /> )) FileListInfo.displayName = "FileListInfo" diff --git a/components/vendor-regular-registrations/additional-info-dialog.tsx b/components/vendor-regular-registrations/additional-info-dialog.tsx new file mode 100644 index 00000000..fbd60515 --- /dev/null +++ b/components/vendor-regular-registrations/additional-info-dialog.tsx @@ -0,0 +1,502 @@ +"use client";
+
+import * as React from "react";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Plus, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+import {
+ saveVendorBusinessContacts,
+ saveVendorAdditionalInfo,
+ fetchVendorRegistrationStatus
+} from "@/lib/vendor-regular-registrations/service";
+
+// 업무담당자 정보 스키마
+const businessContactSchema = z.object({
+ contactType: z.enum(["sales", "design", "delivery", "quality", "tax_invoice"]),
+ contactName: z.string().min(1, "담당자명은 필수입니다"),
+ position: z.string().min(1, "직급은 필수입니다"),
+ department: z.string().min(1, "부서는 필수입니다"),
+ responsibility: z.string().min(1, "담당업무는 필수입니다"),
+ email: z.string().email("올바른 이메일 형식이 아닙니다"),
+});
+
+// 추가정보 스키마
+const additionalInfoSchema = z.object({
+ businessType: z.string().optional(),
+ industryType: z.string().optional(),
+ companySize: z.string().optional(),
+ revenue: z.string().optional(),
+ factoryEstablishedDate: z.string().optional(),
+ preferredContractTerms: z.string().optional(),
+});
+
+// 전체 폼 스키마
+const formSchema = z.object({
+ businessContacts: z.array(businessContactSchema).min(5, "모든 업무담당자 정보를 입력해주세요"),
+ additionalInfo: additionalInfoSchema,
+});
+
+type FormData = z.infer<typeof formSchema>;
+
+interface AdditionalInfoDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ vendorId: number;
+ onSave?: () => void;
+}
+
+const contactTypes = [
+ { value: "sales", label: "영업", required: true },
+ { value: "design", label: "설계", required: true },
+ { value: "delivery", label: "납기", required: true },
+ { value: "quality", label: "품질", required: true },
+ { value: "tax_invoice", label: "세금계산서", required: true },
+];
+
+const businessTypes = [
+ { value: "manufacturing", label: "제조업" },
+ { value: "trading", label: "무역업" },
+ { value: "service", label: "서비스업" },
+ { value: "construction", label: "건설업" },
+ { value: "other", label: "기타" },
+];
+
+const industryTypes = [
+ { value: "shipbuilding", label: "조선업" },
+ { value: "marine", label: "해양플랜트" },
+ { value: "energy", label: "에너지" },
+ { value: "automotive", label: "자동차" },
+ { value: "other", label: "기타" },
+];
+
+const companySizes = [
+ { value: "large", label: "대기업" },
+ { value: "medium", label: "중견기업" },
+ { value: "small", label: "중소기업" },
+ { value: "startup", label: "스타트업" },
+];
+
+export function AdditionalInfoDialog({
+ open,
+ onOpenChange,
+ vendorId,
+ onSave,
+}: AdditionalInfoDialogProps) {
+ const [saving, setSaving] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const form = useForm<FormData>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ businessContacts: contactTypes.map(type => ({
+ contactType: type.value as any,
+ contactName: "",
+ position: "",
+ department: "",
+ responsibility: "",
+ email: "",
+ })),
+ additionalInfo: {
+ businessType: "",
+ industryType: "",
+ companySize: "",
+ revenue: "",
+ factoryEstablishedDate: "",
+ preferredContractTerms: "",
+ },
+ },
+ });
+
+ // 기존 데이터 로드
+ const loadExistingData = async () => {
+ if (!vendorId || !open) return;
+
+ setLoading(true);
+ try {
+ const result = await fetchVendorRegistrationStatus(vendorId);
+ if (result.success && result.data) {
+ const { businessContacts, additionalInfo } = result.data;
+
+ // 업무담당자 데이터 설정
+ const contactsData = contactTypes.map(type => {
+ const existingContact = businessContacts.find(c => c.contactType === type.value);
+ return existingContact ? {
+ contactType: existingContact.contactType,
+ contactName: existingContact.contactName,
+ position: existingContact.position,
+ department: existingContact.department,
+ responsibility: existingContact.responsibility,
+ email: existingContact.email,
+ } : {
+ contactType: type.value as any,
+ contactName: "",
+ position: "",
+ department: "",
+ responsibility: "",
+ email: "",
+ };
+ });
+
+ // 추가정보 데이터 설정
+ const additionalData = {
+ businessType: additionalInfo?.businessType || "",
+ industryType: additionalInfo?.industryType || "",
+ companySize: additionalInfo?.companySize || "",
+ revenue: additionalInfo?.revenue || "",
+ factoryEstablishedDate: additionalInfo?.factoryEstablishedDate
+ ? new Date(additionalInfo.factoryEstablishedDate).toISOString().split('T')[0]
+ : "",
+ preferredContractTerms: additionalInfo?.preferredContractTerms || "",
+ };
+
+ // 폼 데이터 업데이트
+ form.reset({
+ businessContacts: contactsData,
+ additionalInfo: additionalData,
+ });
+ }
+ } catch (error) {
+ console.error("Error loading existing data:", error);
+ toast.error("기존 데이터를 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 다이얼로그가 열릴 때 데이터 로드
+ React.useEffect(() => {
+ loadExistingData();
+ }, [vendorId, open]);
+
+ const handleSave = async (data: FormData) => {
+ setSaving(true);
+ try {
+ // 업무담당자 정보 저장
+ const contactsResult = await saveVendorBusinessContacts(vendorId, data.businessContacts);
+ if (!contactsResult.success) {
+ throw new Error(contactsResult.error);
+ }
+
+ // 추가정보 저장
+ const additionalResult = await saveVendorAdditionalInfo(vendorId, data.additionalInfo);
+ if (!additionalResult.success) {
+ throw new Error(additionalResult.error);
+ }
+
+ toast.success("추가정보가 저장되었습니다.");
+ onSave?.();
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Error saving additional info:", error);
+ toast.error(error instanceof Error ? error.message : "추가정보 저장 중 오류가 발생했습니다.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>추가정보 입력</DialogTitle>
+ <p className="text-sm text-muted-foreground">
+ 정규업체 등록을 위한 추가정보를 입력해주세요. <span className="text-red-500">*</span> 표시는 필수 입력 항목입니다.
+ </p>
+ </DialogHeader>
+
+ {loading ? (
+ <div className="p-8 text-center">
+ <div className="text-sm text-muted-foreground">데이터를 불러오는 중...</div>
+ </div>
+ ) : (
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSave)}>
+ <Tabs defaultValue="contacts" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="contacts">업무담당자 정보</TabsTrigger>
+ <TabsTrigger value="additional">추가정보</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="contacts" className="space-y-4">
+ <div className="space-y-4">
+ {contactTypes.map((contactType, index) => (
+ <Card key={contactType.value}>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-lg flex items-center gap-2">
+ {contactType.label} 담당자
+ <Badge variant="destructive" className="text-xs">필수</Badge>
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.contactName`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="담당자명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.position`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>직급 *</FormLabel>
+ <FormControl>
+ <Input placeholder="직급 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.department`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>부서 *</FormLabel>
+ <FormControl>
+ <Input placeholder="부서명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.email`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email *</FormLabel>
+ <FormControl>
+ <Input placeholder="이메일 입력" type="email" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.responsibility`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당업무 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="담당업무 상세 입력"
+ className="h-20"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </TabsContent>
+
+ <TabsContent value="additional" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>회사 추가정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="additionalInfo.businessType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>사업유형</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="사업유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {businessTypes.map((type) => (
+ <SelectItem key={type.value} value={type.value}>
+ {type.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="additionalInfo.industryType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>산업유형</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="산업유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {industryTypes.map((type) => (
+ <SelectItem key={type.value} value={type.value}>
+ {type.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="additionalInfo.companySize"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기업규모</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="기업규모 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {companySizes.map((size) => (
+ <SelectItem key={size.value} value={size.value}>
+ {size.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="additionalInfo.revenue"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>매출액 (억원)</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="매출액 입력"
+ type="number"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="additionalInfo.factoryEstablishedDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장설립일</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="YYYY-MM-DD"
+ type="date"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="additionalInfo.preferredContractTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선호계약조건</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="선호하는 계약조건을 상세히 입력해주세요"
+ className="h-32"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+
+ <DialogFooter className="mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={saving}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={saving}>
+ {saving ? "저장 중..." : "저장"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ )}
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/components/vendor-regular-registrations/document-status-dialog.tsx b/components/vendor-regular-registrations/document-status-dialog.tsx new file mode 100644 index 00000000..f8e2e1cd --- /dev/null +++ b/components/vendor-regular-registrations/document-status-dialog.tsx @@ -0,0 +1,265 @@ +"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { FileText, Download, CheckCircle, XCircle, Clock } from "lucide-react";
+import { toast } from "sonner";
+
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
+import {
+ documentStatusColumns,
+ contractAgreementColumns,
+} from "@/config/vendorRegularRegistrationsColumnsConfig";
+import { downloadFile } from "@/lib/file-download";
+
+interface DocumentStatusDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ registration: VendorRegularRegistration | null;
+}
+
+const StatusIcon = ({ status }: { status: string | boolean }) => {
+ if (typeof status === "boolean") {
+ return status ? (
+ <CheckCircle className="w-4 h-4 text-green-600" />
+ ) : (
+ <XCircle className="w-4 h-4 text-red-500" />
+ );
+ }
+
+ switch (status) {
+ case "completed":
+ return <CheckCircle className="w-4 h-4 text-green-600" />;
+ case "reviewing":
+ return <Clock className="w-4 h-4 text-yellow-600" />;
+ case "not_submitted":
+ default:
+ return <XCircle className="w-4 h-4 text-red-500" />;
+ }
+};
+
+const StatusBadge = ({ status }: { status: string | boolean }) => {
+ if (typeof status === "boolean") {
+ return (
+ <Badge variant={status ? "default" : "destructive"}>
+ {status ? "제출완료" : "미제출"}
+ </Badge>
+ );
+ }
+
+ const statusConfig = {
+ completed: { label: "완료", variant: "default" as const },
+ reviewing: { label: "검토중", variant: "secondary" as const },
+ not_submitted: { label: "미제출", variant: "destructive" as const },
+ };
+
+ const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.not_submitted;
+
+ return <Badge variant={config.variant}>{config.label}</Badge>;
+};
+
+export function DocumentStatusDialog({
+ open,
+ onOpenChange,
+ registration,
+}: DocumentStatusDialogProps) {
+ if (!registration) return null;
+
+ // 파일 다운로드 핸들러
+ const handleFileDownload = async (docKey: string, fileIndex: number = 0) => {
+ try {
+ const files = registration.documentFiles[docKey as keyof typeof registration.documentFiles];
+ if (!files || files.length === 0) {
+ toast.error("다운로드할 파일이 없습니다.");
+ return;
+ }
+
+ const file = files[fileIndex];
+ if (!file) {
+ toast.error("파일을 찾을 수 없습니다.");
+ return;
+ }
+
+ // filePath와 fileName 추출
+ const filePath = file.filePath || file.path;
+ const fileName = file.originalFileName || file.fileName || file.name;
+
+ if (!filePath || !fileName) {
+ toast.error("파일 정보가 올바르지 않습니다.");
+ return;
+ }
+
+ console.log(`📥 파일 다운로드 시작:`, { filePath, fileName, docKey });
+
+ // downloadFile 함수를 사용하여 파일 다운로드
+ const result = await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error("파일 다운로드 오류:", error);
+ toast.error(`파일 다운로드 실패: ${error}`);
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`✅ 파일 다운로드 성공:`, { fileName, fileSize });
+ }
+ });
+
+ if (!result.success) {
+ console.error("파일 다운로드 실패:", result.error);
+ }
+ } catch (error) {
+ console.error("파일 다운로드 중 오류 발생:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 문서/자료 접수 현황 - {registration.companyName}
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
+ <div>
+ <span className="text-sm font-medium text-gray-600">업체명:</span>
+ <span className="ml-2">{registration.companyName}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">사업자번호:</span>
+ <span className="ml-2">{registration.businessNumber}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">대표자:</span>
+ <span className="ml-2">{registration.representative || "-"}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">현재상태:</span>
+ <Badge className="ml-2">{registration.status}</Badge>
+ </div>
+ </div>
+
+ {/* 문서 제출 현황 */}
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold">문서 제출 현황</h3>
+ </div>
+ <div className="border rounded-lg">
+ <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
+ <div>문서유형</div>
+ <div>상태</div>
+ <div>제출일자</div>
+ <div>액션</div>
+ </div>
+ {documentStatusColumns.map((doc) => {
+ const isSubmitted = registration.documentSubmissions[
+ doc.key as keyof typeof registration.documentSubmissions
+ ] as boolean;
+
+ return (
+ <div
+ key={doc.key}
+ className="grid grid-cols-4 gap-4 p-4 border-t items-center"
+ >
+ <div className="flex items-center gap-2">
+ <StatusIcon status={isSubmitted} />
+ {doc.label}
+ </div>
+ <div>
+ <StatusBadge status={isSubmitted} />
+ </div>
+ <div className="text-sm text-gray-600">
+ {isSubmitted ? "2024.01.01" : "-"}
+ </div>
+ <div>
+ {isSubmitted && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => handleFileDownload(doc.key)}
+ >
+ <Download className="w-4 h-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* 계약 동의 현황 */}
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold">계약 동의 현황</h3>
+ </div>
+ <div className="border rounded-lg">
+ <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
+ <div>계약유형</div>
+ <div>상태</div>
+ <div>서약일자</div>
+ <div>액션</div>
+ </div>
+ {contractAgreementColumns.map((agreement) => {
+ const status = registration.contractAgreements[
+ agreement.key as keyof typeof registration.contractAgreements
+ ] as string;
+
+ return (
+ <div
+ key={agreement.key}
+ className="grid grid-cols-4 gap-4 p-4 border-t items-center"
+ >
+ <div className="flex items-center gap-2">
+ <StatusIcon status={status} />
+ {agreement.label}
+ </div>
+ <div>
+ <StatusBadge status={status} />
+ </div>
+ <div className="text-sm text-gray-600">
+ {status === "completed" ? "2024.01.01" : "-"}
+ </div>
+ <div>
+ {status === "completed" && (
+ <Button size="sm" variant="outline">
+ <Download className="w-4 h-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* 추가 정보 */}
+ <div>
+ <h3 className="text-lg font-semibold mb-4">추가 정보</h3>
+ <div className="p-4 border rounded-lg">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <StatusIcon status={registration.additionalInfo} />
+ <span>추가 정보 등록</span>
+ </div>
+ <StatusBadge status={registration.additionalInfo} />
+ </div>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/components/vendor-regular-registrations/skip-reason-dialog.tsx b/components/vendor-regular-registrations/skip-reason-dialog.tsx new file mode 100644 index 00000000..f47d8929 --- /dev/null +++ b/components/vendor-regular-registrations/skip-reason-dialog.tsx @@ -0,0 +1,98 @@ +"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { useState } from "react";
+import { toast } from "sonner";
+
+interface SkipReasonDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: string;
+ onConfirm: (reason: string) => Promise<void>;
+ loading: boolean;
+}
+
+export function SkipReasonDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ onConfirm,
+ loading,
+}: SkipReasonDialogProps) {
+ const [reason, setReason] = useState("");
+
+ const handleConfirm = async () => {
+ if (!reason.trim()) {
+ toast.error("Skip 사유를 입력해주세요.");
+ return;
+ }
+
+ try {
+ await onConfirm(reason.trim());
+ setReason(""); // 성공 시 초기화
+ onOpenChange(false);
+ } catch (error) {
+ // 에러는 상위 컴포넌트에서 처리
+ }
+ };
+
+ const handleCancel = () => {
+ setReason("");
+ onOpenChange(false);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <p className="text-sm text-muted-foreground">
+ {description}
+ </p>
+
+ <div className="space-y-2">
+ <Label htmlFor="reason">Skip 사유</Label>
+ <Textarea
+ id="reason"
+ placeholder="Skip 사유를 입력해주세요..."
+ value={reason}
+ onChange={(e) => setReason(e.target.value)}
+ rows={4}
+ disabled={loading}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={handleCancel}
+ disabled={loading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleConfirm}
+ disabled={loading || !reason.trim()}
+ >
+ {loading ? "처리 중..." : "확인"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
|
