diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/document-lists/vendor-doc-list-client.tsx | 15 | ||||
| -rw-r--r-- | components/documents/project-swicher.tsx | 14 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 6 | ||||
| -rw-r--r-- | components/form-data/spreadJS-dialog copy 2.tsx | 1002 | ||||
| -rw-r--r-- | components/form-data/spreadJS-dialog copy 3.tsx | 1916 | ||||
| -rw-r--r-- | components/form-data/spreadJS-dialog.tsx | 24 | ||||
| -rw-r--r-- | components/information/information-button.tsx | 9 | ||||
| -rw-r--r-- | components/information/information-client.tsx | 4 | ||||
| -rw-r--r-- | components/ship-vendor-document/edit-revision-dialog.tsx | 726 | ||||
| -rw-r--r-- | components/ship-vendor-document/user-vendor-document-table-container.tsx | 615 | ||||
| -rw-r--r-- | components/signup/join-form.tsx | 12 | ||||
| -rw-r--r-- | components/vendor-data/vendor-data-container copy.tsx | 464 | ||||
| -rw-r--r-- | components/vendor-data/vendor-data-container.tsx | 11 | ||||
| -rw-r--r-- | components/vendor-regular-registrations/document-status-dialog.tsx | 213 | ||||
| -rw-r--r-- | components/vendor-regular-registrations/registration-request-dialog.tsx | 691 |
15 files changed, 5462 insertions, 260 deletions
diff --git a/components/document-lists/vendor-doc-list-client.tsx b/components/document-lists/vendor-doc-list-client.tsx index 0c4af106..4aa00ad7 100644 --- a/components/document-lists/vendor-doc-list-client.tsx +++ b/components/document-lists/vendor-doc-list-client.tsx @@ -5,6 +5,7 @@ import { useRouter, useParams } from "next/navigation" import DocumentContainer from "@/components/documents/document-container" import { ProjectInfo, ProjectSwitcher } from "@/components/documents/project-swicher" import { InformationButton } from "@/components/information/information-button" +import { useTranslation } from "@/i18n/client" interface VendorDocumentsClientProps { projects: ProjectInfo[] children: React.ReactNode @@ -16,6 +17,8 @@ export default function VendorDocumentListClient({ }: VendorDocumentsClientProps) { const router = useRouter() const params = useParams() + const lng = (params?.lng as string) || "ko" + const { t } = useTranslation(lng, "engineering") // Get the contractId from route parameters const contractIdFromUrl = React.useMemo(() => { @@ -51,7 +54,7 @@ export default function VendorDocumentListClient({ setProjectType("plant") // Navigate to the contract's documents page - router.push(`/partners/document-list-only/${contractId}?projectType=plant`) + router.push(`/${lng}/partners/document-list-only/${contractId}?projectType=plant`) } return ( @@ -61,15 +64,15 @@ export default function VendorDocumentListClient({ {/* 왼쪽: 타이틀 & 설명 */} <div> <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight">협력업체 문서 리스트 관리</h2> + <h2 className="text-2xl font-bold tracking-tight">{t("vendorDocuments.title")}</h2> <InformationButton pagePath="partners/document-list" /> </div> - {/* <p className="text-muted-foreground"> + <p className="text-muted-foreground"> {projectType === "ship" - ? "삼성중공업 문서시스템으로부터 목록을 가져오고 문서 파일을 등록하여 삼성중공업으로 전달할 수 있습니다." - : "문서리스트와 이슈스테이지를 생성하고 관리할 수 있으며 문서 파일을 등록하여 삼성중공업으로 전달할 수 있습니다." + ? t("vendorDocuments.shipDescription") + : t("vendorDocuments.plantDescription") } - </p> */} + </p> </div> {/* 오른쪽: ProjectSwitcher */} diff --git a/components/documents/project-swicher.tsx b/components/documents/project-swicher.tsx index 5c70ea88..45300b56 100644 --- a/components/documents/project-swicher.tsx +++ b/components/documents/project-swicher.tsx @@ -1,7 +1,9 @@ "use client" import * as React from "react" +import { useParams } from "next/navigation" import { cn } from "@/lib/utils" +import { useTranslation } from "@/i18n/client" import { Select, SelectContent, @@ -54,6 +56,10 @@ export function ProjectSwitcher({ selectedContractId, onSelectContract, }: ProjectSwitcherProps) { + const params = useParams() + const lng = (params?.lng as string) || "ko" + const { t } = useTranslation(lng, "engineering") + // Select value = stringified contractId const selectValue = selectedContractId ? String(selectedContractId) : "" @@ -69,8 +75,8 @@ export function ProjectSwitcher({ return null }, [projects, selectedContractId]) - // Trigger Label => 계약 이름 or "Select a contract" - const triggerLabel = selectedContract?.contractName ?? "Select a contract" + // Trigger Label => 계약 이름 or placeholder + const triggerLabel = selectedContract?.contractName ?? t("vendorDocuments.projectSwitcher.selectContractPlaceholder") function handleValueChange(val: string) { const contractId = Number(val) @@ -98,9 +104,9 @@ export function ProjectSwitcher({ isCollapsed && "flex h-9 w-9 shrink-0 items-center justify-center p-0", "max-w-[300px] whitespace-nowrap overflow-hidden text-ellipsis" )} - aria-label="Select Contract" + aria-label={t("vendorDocuments.projectSwitcher.selectContract")} > - <SelectValue placeholder="Select a contract"> + <SelectValue placeholder={t("vendorDocuments.projectSwitcher.selectContractPlaceholder")}> {/* 실제 표시부분에도 ellipsis 처리. */} <span className={cn( diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index b2fadacf..4f101b45 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -747,10 +747,10 @@ export default function DynamicTable({ <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" disabled={isAnyOperationPending}> - {(isSyncingTags || isLoadingTags) && ( + {(isSyncingTags || isLoadingTags) ? ( <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - <TagsIcon className="size-4" /> + ): + <TagsIcon className="size-4" />} {t("buttons.tagOperations")} </Button> </DropdownMenuTrigger> diff --git a/components/form-data/spreadJS-dialog copy 2.tsx b/components/form-data/spreadJS-dialog copy 2.tsx new file mode 100644 index 00000000..520362ff --- /dev/null +++ b/components/form-data/spreadJS-dialog copy 2.tsx @@ -0,0 +1,1002 @@ +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GenericData } from "./export-excel-form"; +import * as GC from "@mescius/spread-sheets"; +import { toast } from "sonner"; +import { updateFormDataInDB } from "@/lib/forms/services"; +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), + { + ssr: false, + loading: () => ( + <div className="flex items-center justify-center h-full"> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading SpreadSheets... + </div> + ) + } +); + +// 라이센스 키 설정을 클라이언트에서만 실행 +if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { + GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; +} + +interface TemplateItem { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP: { + REG_TYPE_ID: string; + SPR_ITM_IDS: Array<string>; + ATTS: Array<{}>; + }; + SPR_ITM_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; +} + +interface ValidationError { + cellAddress: string; + attId: string; + value: any; + expectedType: ColumnType; + message: string; +} + +interface CellMapping { + attId: string; + cellAddress: string; + isEditable: boolean; + dataRowIndex?: number; +} + +interface TemplateViewDialogProps { + isOpen: boolean; + onClose: () => void; + templateData: TemplateItem[] | any; + selectedRow?: GenericData; // SPREAD_ITEM용 + tableData?: GenericData[]; // SPREAD_LIST용 + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map<string, string[]>; + onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void; +} + +export function TemplateViewDialog({ + isOpen, + onClose, + templateData, + selectedRow, + tableData = [], + formCode, + contractItemId, + columnsJSON, + editableFieldsMap = new Map(), + onUpdateSuccess +}: TemplateViewDialogProps) { + const [hostStyle, setHostStyle] = React.useState({ + width: '100%', + height: '100%' + }); + + const [isPending, setIsPending] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const [currentSpread, setCurrentSpread] = React.useState<any>(null); + const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]); + const [isClient, setIsClient] = React.useState(false); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | null>(null); + const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); + const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); + const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); + + // 클라이언트 사이드에서만 렌더링되도록 보장 + React.useEffect(() => { + setIsClient(true); + }, []); + + // 사용 가능한 템플릿들을 필터링하고 설정 + React.useEffect(() => { + if (!templateData) return; + + let templates: TemplateItem[]; + if (Array.isArray(templateData)) { + templates = templateData as TemplateItem[]; + } else { + templates = [templateData as TemplateItem]; + } + + // CONTENT가 있는 템플릿들 필터링 + const validTemplates = templates.filter(template => { + const hasSpreadListContent = template.SPR_LST_SETUP?.CONTENT; + const hasSpreadItemContent = template.SPR_ITM_LST_SETUP?.CONTENT; + const isValidType = template.TMPL_TYPE === "SPREAD_LIST" || template.TMPL_TYPE === "SPREAD_ITEM"; + + return isValidType && (hasSpreadListContent || hasSpreadItemContent); + }); + + setAvailableTemplates(validTemplates); + + // 첫 번째 유효한 템플릿을 기본으로 선택 + if (validTemplates.length > 0 && !selectedTemplateId) { + setSelectedTemplateId(validTemplates[0].TMPL_ID); + setTemplateType(validTemplates[0].TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'); + } + }, [templateData, selectedTemplateId]); + + // 선택된 템플릿 변경 처리 + const handleTemplateChange = (templateId: string) => { + const template = availableTemplates.find(t => t.TMPL_ID === templateId); + if (template) { + setSelectedTemplateId(templateId); + setTemplateType(template.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'); + setHasChanges(false); + setValidationErrors([]); + + // SpreadSheets 재초기화 + if (currentSpread) { + const template = availableTemplates.find(t => t.TMPL_ID === templateId); + if (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인 경우: 첫 번째 행의 TAG_NO를 기준으로 처리 + if (templateType === 'SPREAD_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이면 편집 불가 + } + + // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (columnsJSON의 shi가 false인 경우) + if (attId === "TAG_NO" || attId === "TAG_DESC") { + return true; + } + + // SPREAD_ITEM인 경우: editableFields 체크 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + + // SPREAD_LIST인 경우: 개별 행의 편집 가능성도 고려 + if (templateType === 'SPREAD_LIST') { + // 기본적으로 editableFields에 포함되어야 함 + if (!editableFields.includes(attId)) { + return false; + } + + // rowData가 제공된 경우 해당 행의 shi 상태도 확인 + if (rowData && rowData.shi === true) { + return false; + } + + return true; + } + + // 기본적으로는 editableFields 체크 + // return editableFields.includes(attId); + return true; + }, [templateType, columnsJSON, editableFields]); + + // 편집 가능한 필드 개수 계산 + const editableFieldsCount = React.useMemo(() => { + return cellMappings.filter(m => m.isEditable).length; + }, [cellMappings]); + + // 셀 주소를 행과 열로 변환하는 함수 + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; + + const row = parseInt(rowStr) - 1; + + return { row, col }; + }; + + // 데이터 타입 검증 함수 + const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { + if (value === undefined || value === null || value === "") { + return null; // 빈 값은 별도 required 검증에서 처리 + } + + switch (columnType) { + case "NUMBER": + if (isNaN(Number(value))) { + return "Value must be a valid number"; + } + break; + case "LIST": + if (options && !options.includes(String(value))) { + return `Value must be one of: ${options.join(", ")}`; + } + break; + case "STRING": + // STRING 타입은 대부분의 값을 허용 + break; + default: + // 커스텀 타입의 경우 추가 검증 로직이 필요할 수 있음 + break; + } + + return null; + }; + + // 전체 데이터 검증 함수 + const validateAllData = React.useCallback(() => { + if (!currentSpread || !selectedTemplate) return []; + + const activeSheet = currentSpread.getActiveSheet(); + const errors: ValidationError[] = []; + + cellMappings.forEach(mapping => { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + if (!columnConfig) return; + + 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') { + // 복수 행 검증 - 각 매핑은 이미 개별 행을 가리킴 + 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 + }); + } + } + }); + + setValidationErrors(errors); + return errors; + }, [currentSpread, selectedTemplate, cellMappings, columnsJSON, templateType]); + + // ═══════════════════════════════════════════════════════════════════════════════ + // 🛠️ 헬퍼 함수들 + // ═══════════════════════════════════════════════════════════════════════════════ + + // 🎨 셀 스타일 생성 + const createCellStyle = React.useCallback((isEditable: boolean) => { + const style = new GC.Spread.Sheets.Style(); + if (isEditable) { + style.backColor = "#f0fdf4"; // 연한 초록 (편집 가능) + } else { + style.backColor = "#f9fafb"; // 연한 회색 (읽기 전용) + style.foreColor = "#6b7280"; + } + return style; + }, []); + + +// 🎯 간소화된 드롭다운 설정 - setupSimpleValidation 완전 제거 + +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 !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + 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 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); // 배열로 전달 + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // ✅ 각 셀마다 새로운 DataValidation 인스턴스 생성 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + + // ComboBox + DataValidation 둘 다 적용 + 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}]`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Safe dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } +}, []); + // 🚀 행 용량 확보 + 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`); + } + }, []); + + // 🛡️ 시트 보호 및 이벤트 설정 + 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, + allowResizeRows: false, + allowResizeColumns: false + }; + + // 🎯 변경 감지 이벤트 + const changeEvents = [ + GC.Spread.Sheets.Events.CellChanged, + GC.Spread.Sheets.Events.ValueChanged, + GC.Spread.Sheets.Events.ClipboardPasted + ]; + + changeEvents.forEach(eventType => { + activeSheet.bind(eventType, () => { + console.log(`📝 ${eventType} detected`); + setHasChanges(true); + }); + }); + + // 🚫 편집 시작 권한 확인 (수정됨) + 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; + }); + + if (!exactMapping) { + console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); + return; // 매핑이 없으면 허용 (템플릿 영역 밖) + } + + console.log(`📋 Found mapping: ${exactMapping.attId} at ${exactMapping.cellAddress}`); + + // 기본 편집 권한 확인 + if (!exactMapping.isEditable) { + console.log(`🚫 Field ${exactMapping.attId} is not editable`); + toast.warning(`${exactMapping.attId} field is read-only`); + info.cancel = true; + return; + } + + // SPREAD_LIST 개별 행 SHI 확인 + if (templateType === 'SPREAD_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) { + console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); + toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); + 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}`); + + // ✅ 정확한 매핑 찾기 + 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; + } + + 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"; + errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + 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 }); + } else { + console.log(`✅ Validation passed`); + + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + }); + + console.log(`🛡️ Protection and events configured for ${mappings.length} mappings`); + }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + + // ═══════════════════════════════════════════════════════════════════════════════ + // 🏗️ 메인 SpreadSheets 초기화 함수 + // ═══════════════════════════════════════════════════════════════════════════════ + + const initSpread = React.useCallback((spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) return; + + try { + // 🔄 초기 설정 + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 📋 템플릿 콘텐츠 및 데이터 시트 추출 + 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); + } + + if (!contentJson) { + console.warn('❌ No CONTENT found in template:', workingTemplate.NAME); + return; + } + + // 🏗️ SpreadSheets 초기화 + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + // 성능을 위한 렌더링 일시 중단 + spread.suspendPaint(); + + try { + // 템플릿 구조 로드 + spread.fromJSON(jsonData); + const activeSheet = spread.getActiveSheet(); + + // 시트 보호 해제 (편집을 위해) + activeSheet.options.isProtected = false; + + // 📊 셀 매핑 및 데이터 처리 + if (dataSheets && dataSheets.length > 0) { + const mappings: CellMapping[] = []; + + // 🔄 각 데이터 시트의 매핑 정보 처리 + dataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT) { + dataSheet.MAP_CELL_ATT.forEach(mapping => { + 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})`); + } + } + }); + } + }); + + // 💾 매핑 정보 저장 및 이벤트 설정 + setCellMappings(mappings); + setupSheetProtectionAndEvents(activeSheet, mappings); + } + + } finally { + // 렌더링 재개 + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error initializing spread:', error); + toast.error('Failed to load template'); + if (spread?.resumePaint) { + spread.resumePaint(); + } + } + }, [selectedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON, createCellStyle, setupOptimizedListValidation, ensureRowCapacity, setupSheetProtectionAndEvents]); + + // 변경사항 저장 함수 + 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.`); + return; + } + + try { + setIsPending(true); + + const activeSheet = currentSpread.getActiveSheet(); + + if (templateType === 'SPREAD_ITEM' && selectedRow) { + // 단일 행 저장 + const dataToSave = { ...selectedRow }; + + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); + + dataToSave.TAG_NO = selectedRow.TAG_NO; + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success("Changes saved successfully!"); + onUpdateSuccess?.(dataToSave); + + } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + // 복수 행 저장 + const updatedRows: GenericData[] = []; + let saveCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + // 각 매핑에 대해 해당 행의 값 확인 + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + const isColumnEditable = columnConfig?.shi !== true; + const isRowEditable = originalRow.shi !== true; + + if (isColumnEditable && isRowEditable) { + 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; + } + } + } + } + }); + + // 변경사항이 있는 행만 저장 + if (hasRowChanges) { + dataToSave.TAG_NO = originalRow.TAG_NO; // TAG_NO는 절대 변경되지 않도록 + + const { success } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + } + } else { + updatedRows.push(originalRow); // 변경사항이 없으면 원본 유지 + } + } + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + toast.info("No changes to save"); + } + } + + setHasChanges(false); + setValidationErrors([]); + + } catch (error) { + console.error("Error saving changes:", error); + toast.error("An unexpected error occurred while saving"); + } finally { + setIsPending(false); + } + }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]); + + 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" }} + > + <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> + <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> + <SelectTrigger className="w-64"> + <SelectValue placeholder="Select a template" /> + </SelectTrigger> + <SelectContent> + {availableTemplates.map(template => ( + <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> + {template.NAME} ({template.TMPL_TYPE}) + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + )} + + {/* 템플릿 정보 */} + {selectedTemplate && ( + <div className="flex items-center gap-4 text-sm"> + <span className="font-medium text-blue-600"> + Template Type: {selectedTemplate.TMPL_TYPE === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : 'Item View (SPREAD_ITEM)'} + </span> + {templateType === 'SPREAD_ITEM' && selectedRow && ( + <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span> + )} + {templateType === 'SPREAD_LIST' && ( + <span>• {dataCount} rows</span> + )} + {hasChanges && ( + <span className="text-orange-600 font-medium"> + • Unsaved changes + </span> + )} + {validationErrors.length > 0 && ( + <span className="text-red-600 font-medium flex items-center"> + <AlertTriangle className="w-4 h-4 mr-1" /> + {validationErrors.length} validation errors + </span> + )} + </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> + Editable fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> + Read-only fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span> + Validation errors + </span> + {cellMappings.length > 0 && ( + <span className="text-blue-600"> + {editableFieldsCount} of {cellMappings.length} fields editable + </span> + )} + </div> + </div> + </DialogDescription> + </DialogHeader> + + {/* SpreadSheets 컴포넌트 영역 */} + <div className="flex-1 overflow-hidden"> + {selectedTemplate && isClient && isDataValid ? ( + <SpreadSheets + key={`${selectedTemplate.TMPL_TYPE}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} + workbookInitialized={initSpread} + hostStyle={hostStyle} + /> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + {!isClient ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading... + </> + ) : !selectedTemplate ? ( + "No template available" + ) : !isDataValid ? ( + `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` + ) : ( + "Template not ready" + )} + </div> + )} + </div> + + <DialogFooter className="flex-shrink-0"> + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={onClose}> + Close + </Button> + + {hasChanges && ( + <Button + variant="default" + onClick={handleSaveChanges} + disabled={isPending || validationErrors.length > 0} + > + {isPending ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Saving... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + Save Changes + </> + )} + </Button> + )} + + {validationErrors.length > 0 && ( + <Button + variant="outline" + onClick={validateAllData} + className="text-red-600 border-red-300 hover:bg-red-50" + > + <AlertTriangle className="mr-2 h-4 w-4" /> + Check Errors ({validationErrors.length}) + </Button> + )} + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data/spreadJS-dialog copy 3.tsx b/components/form-data/spreadJS-dialog copy 3.tsx new file mode 100644 index 00000000..1ea8232b --- /dev/null +++ b/components/form-data/spreadJS-dialog copy 3.tsx @@ -0,0 +1,1916 @@ +"use client"; + +import * as React from "react"; +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { GenericData } from "./export-excel-form"; +import * as GC from "@mescius/spread-sheets"; +import { toast } from "sonner"; +import { updateFormDataInDB } from "@/lib/forms/services"; +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), + { + ssr: false, + loading: () => ( + <div className="flex items-center justify-center h-full"> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading SpreadSheets... + </div> + ) + } +); + +// 라이센스 키 설정을 클라이언트에서만 실행 +if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) { + GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE; +} + +interface TemplateItem { + TMPL_ID: string; + NAME: string; + TMPL_TYPE: string; + SPR_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; + GRD_LST_SETUP: { + REG_TYPE_ID: string; + SPR_ITM_IDS: Array<string>; + ATTS: Array<{}>; + }; + SPR_ITM_LST_SETUP: { + ACT_SHEET: string; + HIDN_SHEETS: Array<string>; + CONTENT?: string; + DATA_SHEETS: Array<{ + SHEET_NAME: string; + REG_TYPE_ID: string; + MAP_CELL_ATT: Array<{ + ATT_ID: string; + IN: string; + }>; + }>; + }; +} + +interface ValidationError { + cellAddress: string; + attId: string; + value: any; + expectedType: ColumnType; + message: string; +} + +interface CellMapping { + attId: string; + cellAddress: string; + isEditable: boolean; + dataRowIndex?: number; +} + +interface TemplateViewDialogProps { + isOpen: boolean; + onClose: () => void; + templateData: TemplateItem[] | any; + selectedRow?: GenericData; // SPREAD_ITEM용 + tableData?: GenericData[]; // SPREAD_LIST용 + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map<string, string[]>; + onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void; +} + +export function TemplateViewDialog({ + isOpen, + onClose, + templateData, + selectedRow, + tableData = [], + formCode, + contractItemId, + columnsJSON, + editableFieldsMap = new Map(), + onUpdateSuccess +}: TemplateViewDialogProps) { + const [hostStyle, setHostStyle] = React.useState({ + width: '100%', + height: '100%' + }); + + const [isPending, setIsPending] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const [currentSpread, setCurrentSpread] = React.useState<any>(null); + const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]); + const [isClient, setIsClient] = React.useState(false); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); + const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); + const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); + const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); + + const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { + // 1. SPREAD_LIST: TMPL_TYPE이 SPREAD_LIST이고 SPR_LST_SETUP.CONTENT가 있음 + if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_LIST'; + } + + // 2. SPREAD_ITEM: TMPL_TYPE이 SPREAD_ITEM이고 SPR_ITM_LST_SETUP.CONTENT가 있음 + if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_ITEM'; + } + + // 3. GRD_LIST: GRD_LST_SETUP이 있고 columnsJSON이 있음 (동적 테이블) + 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; + + let templates: TemplateItem[]; + if (Array.isArray(templateData)) { + templates = templateData as TemplateItem[]; + } else { + templates = [templateData as TemplateItem]; + } + + // 유효한 템플릿들만 필터링 + const validTemplates = templates.filter(isValidTemplate); + + setAvailableTemplates(validTemplates); + + // 첫 번째 유효한 템플릿을 기본으로 선택 + if (validTemplates.length > 0 && !selectedTemplateId) { + const firstTemplate = validTemplates[0]; + const templateTypeToSet = determineTemplateType(firstTemplate); + + setSelectedTemplateId(firstTemplate.TMPL_ID); + setTemplateType(templateTypeToSet); + } + }, [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); + setTemplateType(templateTypeToSet); + setHasChanges(false); + setValidationErrors([]); + + // SpreadSheets 재초기화 + 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이면 편집 불가 + } + + // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (columnsJSON의 shi가 false인 경우) + if (attId === "TAG_NO" || attId === "TAG_DESC") { + return false; + } + + if (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]); + + // 편집 가능한 필드 개수 계산 + const editableFieldsCount = React.useMemo(() => { + return cellMappings.filter(m => m.isEditable).length; + }, [cellMappings]); + + // 셀 주소를 행과 열로 변환하는 함수 + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + const match = address.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + const [, colStr, rowStr] = match; + + let col = 0; + for (let i = 0; i < colStr.length; i++) { + col = col * 26 + (colStr.charCodeAt(i) - 65 + 1); + } + col -= 1; + + const row = parseInt(rowStr) - 1; + + return { row, col }; + }; + + // 행과 열을 셀 주소로 변환하는 함수 (GRD_LIST용) + const getCellAddress = (row: number, col: number): string => { + let colStr = ''; + let colNum = col; + while (colNum >= 0) { + colStr = String.fromCharCode((colNum % 26) + 65) + colStr; + colNum = Math.floor(colNum / 26) - 1; + } + return colStr + (row + 1); + }; + + // 데이터 타입 검증 함수 + const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { + if (value === undefined || value === null || value === "") { + return null; // 빈 값은 별도 required 검증에서 처리 + } + + switch (columnType) { + case "NUMBER": + if (isNaN(Number(value))) { + return "Value must be a valid number"; + } + break; + case "LIST": + if (options && !options.includes(String(value))) { + return `Value must be one of: ${options.join(", ")}`; + } + break; + case "STRING": + // STRING 타입은 대부분의 값을 허용 + break; + default: + // 커스텀 타입의 경우 추가 검증 로직이 필요할 수 있음 + break; + } + + return null; + }; + + // 전체 데이터 검증 함수 + const validateAllData = React.useCallback(() => { + if (!currentSpread || !selectedTemplate) return []; + + const activeSheet = currentSpread.getActiveSheet(); + const errors: ValidationError[] = []; + + cellMappings.forEach(mapping => { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + if (!columnConfig) return; + + 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); + + 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]); + + // ═══════════════════════════════════════════════════════════════════════════════ + // 🛠️ 헬퍼 함수들 + // ═══════════════════════════════════════════════════════════════════════════════ + + // 🎨 셀 스타일 생성 + const createCellStyle = React.useCallback((isEditable: boolean) => { + const style = new GC.Spread.Sheets.Style(); + if (isEditable) { + style.backColor = "#f0fdf4"; // 연한 초록 (편집 가능) + } else { + 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 !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + 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 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); // 배열로 전달 + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // ✅ 각 셀마다 새로운 DataValidation 인스턴스 생성 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + + // ComboBox + DataValidation 둘 다 적용 + 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}]`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Safe dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + // 🛡️ 안전한 시트 검증 함수 추가 +const validateActiveSheet = React.useCallback((activeSheet: any, functionName: string = 'unknown') => { + console.log(`🔍 Validating activeSheet for ${functionName}:`); + + if (!activeSheet) { + console.error(`❌ activeSheet is null/undefined in ${functionName}`); + return false; + } + + console.log(`✅ activeSheet exists (type: ${typeof activeSheet})`); + console.log(`✅ constructor: ${activeSheet.constructor?.name}`); + + // 핵심 메서드들 존재 여부 확인 + const requiredMethods = ['getRowCount', 'getColumnCount', 'setRowCount', 'setColumnCount', 'getCell', 'getValue', 'setStyle']; + const missingMethods = requiredMethods.filter(method => typeof activeSheet[method] !== 'function'); + + if (missingMethods.length > 0) { + console.error(`❌ Missing methods in ${functionName}:`, missingMethods); + console.log(`📋 Available methods:`, Object.getOwnPropertyNames(activeSheet).filter(prop => typeof activeSheet[prop] === 'function').slice(0, 20)); + return false; + } + + console.log(`✅ All required methods available for ${functionName}`); + return true; +}, []); +// 🛡️ 안전한 ActiveSheet 가져오기 함수 +const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + console.log(`🔍 Getting safe activeSheet for ${functionName}`); + + if (!spread) { + console.error(`❌ Spread is null/undefined in ${functionName}`); + return null; + } + + try { + // 현재 활성 시트 가져오기 + let activeSheet = spread.getActiveSheet(); + + if (!activeSheet) { + console.warn(`⚠️ ActiveSheet is null, attempting to get first sheet in ${functionName}`); + + // 첫 번째 시트 시도 + const sheetCount = spread.getSheetCount(); + console.log(`📊 Total sheets: ${sheetCount}`); + + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + console.log(`✅ Successfully got first sheet in ${functionName}`); + } + } + } + + if (!activeSheet) { + console.error(`❌ Failed to get any valid sheet in ${functionName}`); + return null; + } + + // 시트 유효성 검증 + const validation = validateActiveSheet(activeSheet, functionName); + if (!validation) { + console.error(`❌ Sheet validation failed in ${functionName}`); + return null; + } + + console.log(`✅ Got valid activeSheet for ${functionName}: ${activeSheet.name?.() || 'unnamed'}`); + return activeSheet; + + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } +}, [validateActiveSheet]); + +// 🛡️ 수정된 ensureRowCapacity 함수 +const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + try { + // 🔍 상세한 null/undefined 체크 + if (!activeSheet) { + console.error('❌ activeSheet is null/undefined in ensureRowCapacity'); + return false; + } + + console.log('🔍 ActiveSheet validation in ensureRowCapacity:'); + console.log(' - Type:', typeof activeSheet); + console.log(' - Constructor:', activeSheet.constructor?.name); + console.log(' - Is null:', activeSheet === null); + console.log(' - Is undefined:', activeSheet === undefined); + + // 🔍 메서드 존재 여부 확인 + if (typeof activeSheet.getRowCount !== 'function') { + console.error('❌ getRowCount method does not exist on activeSheet'); + console.log('📋 Available properties:', Object.getOwnPropertyNames(activeSheet).slice(0, 20)); + return false; + } + + // 🔍 시트 상태 확인 + const currentRowCount = activeSheet.getRowCount(); + console.log(`📊 Current row count: ${currentRowCount} (type: ${typeof currentRowCount})`); + + if (typeof currentRowCount !== 'number' || isNaN(currentRowCount)) { + console.error('❌ getRowCount returned invalid value:', currentRowCount); + return false; + } + + if (requiredRowCount > currentRowCount) { + // 🔍 setRowCount 메서드 확인 + if (typeof activeSheet.setRowCount !== 'function') { + console.error('❌ setRowCount method does not exist on activeSheet'); + return false; + } + + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + console.log(`📈 Expanded sheet: ${currentRowCount} → ${newRowCount} rows`); + + // 🔍 설정 후 검증 + const verifyRowCount = activeSheet.getRowCount(); + console.log(`✅ Verified new row count: ${verifyRowCount}`); + + return true; + } else { + console.log(`✅ Sheet already has sufficient rows: ${currentRowCount} >= ${requiredRowCount}`); + return true; + } + + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + console.error('❌ Error stack:', error.stack); + return false; + } +}, []); + +// 🛡️ 안전한 컬럼 용량 확보 함수 +const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + // 🔍 상세한 null/undefined 체크 + if (!activeSheet) { + console.error('❌ activeSheet is null/undefined in ensureColumnCapacity'); + return false; + } + + console.log('🔍 ActiveSheet validation in ensureColumnCapacity:'); + console.log(' - Type:', typeof activeSheet); + console.log(' - Constructor:', activeSheet.constructor?.name); + console.log(' - Is null:', activeSheet === null); + console.log(' - Is undefined:', activeSheet === undefined); + + // 🔍 메서드 존재 여부 확인 + if (typeof activeSheet.getColumnCount !== 'function') { + console.error('❌ getColumnCount method does not exist on activeSheet'); + console.log('📋 Available properties:', Object.getOwnPropertyNames(activeSheet).slice(0, 20)); + return false; + } + + const currentColumnCount = activeSheet.getColumnCount(); + console.log(`📊 Current column count: ${currentColumnCount} (type: ${typeof currentColumnCount})`); + + if (typeof currentColumnCount !== 'number' || isNaN(currentColumnCount)) { + console.error('❌ getColumnCount returned invalid value:', currentColumnCount); + return false; + } + + if (requiredColumnCount > currentColumnCount) { + if (typeof activeSheet.setColumnCount !== 'function') { + console.error('❌ setColumnCount method does not exist on activeSheet'); + return false; + } + + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + console.log(`📈 Expanded columns: ${currentColumnCount} → ${newColumnCount}`); + + // 🔍 설정 후 검증 + const verifyColumnCount = activeSheet.getColumnCount(); + console.log(`✅ Verified new column count: ${verifyColumnCount}`); + + return true; + } else { + console.log(`✅ Sheet already has sufficient columns: ${currentColumnCount} >= ${requiredColumnCount}`); + return true; + } + + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + console.error('❌ Error stack:', error.stack); + return false; + } +}, []); + + +// 🎯 텍스트 너비 계산 함수들 (createGrdListTable 함수 위에 추가) +const measureTextWidth = React.useCallback((text: string, fontSize: number = 12, fontFamily: string = 'Arial'): number => { + // Canvas를 사용한 정확한 텍스트 너비 측정 + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) return text.length * 8; // fallback + + context.font = `${fontSize}px ${fontFamily}`; + const metrics = context.measureText(text || ''); + return Math.ceil(metrics.width); +}, []); + +const calculateColumnWidth = React.useCallback(( + headerText: string, + dataValues: any[] = [], + minWidth: number = 80, + maxWidth: number = 300, + padding: number = 20 +): number => { + // 헤더 텍스트 너비 계산 + const headerWidth = measureTextWidth(headerText, 12, 'Arial'); + + // 데이터 값들의 최대 너비 계산 + let maxDataWidth = 0; + if (dataValues.length > 0) { + maxDataWidth = Math.max( + ...dataValues + .slice(0, 10) // 성능을 위해 처음 10개만 샘플링 + .map(value => measureTextWidth(String(value || ''), 11, 'Arial')) + ); + } + + // 헤더와 데이터 중 더 큰 너비 + 패딩 적용 + const calculatedWidth = Math.max(headerWidth, maxDataWidth) + padding; + + // 최소/최대 너비 제한 적용 + return Math.min(Math.max(calculatedWidth, minWidth), maxWidth); +}, [measureTextWidth]); + +const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + console.log('🎨 Setting optimal column widths...'); + + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 해당 컬럼의 데이터 값들 추출 + const dataValues = tableData.map(row => row[column.key]).filter(val => val != null); + + // 최적 너비 계산 + const optimalWidth = calculateColumnWidth( + column.label || column.key, + dataValues, + column.type === 'NUMBER' ? 100 : 80, // 숫자는 좀 더 넓게 + column.type === 'STRING' ? 250 : 200, // 문자열은 더 넓게 + column.type === 'LIST' ? 30 : 20 // 드롭다운은 여유 패딩 + ); + + // 컬럼 너비 설정 + activeSheet.setColumnWidth(targetCol, optimalWidth); + + console.log(`📏 Column ${targetCol} (${column.key}): width set to ${optimalWidth}px`); + }); +}, [calculateColumnWidth]); + + // 🔍 컬럼 그룹 분석 함수 + const analyzeColumnGroups = React.useCallback((columns: DataTableColumnJSON[]) => { + const groups: Array<{ + head: string; + isGroup: boolean; + columns: DataTableColumnJSON[]; + }> = []; + + 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] + }); + i++; + continue; + } + + // 같은 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++; + } + + // 그룹 추가 + groups.push({ + head: groupHead, + isGroup: groupColumns.length > 1, + columns: groupColumns + }); + + i = j; // 다음 그룹으로 이동 + } + + return { groups }; + }, []); + + +// 🆕 수정된 createGrdListTable 함수 +// 🆕 개선된 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) + .sort((a, b) => { + const seqA = a.seq !== undefined ? a.seq : 999999; + const seqB = b.seq !== undefined ? b.seq : 999999; + return seqA - seqB; + }); + + console.log('📊 Using columns:', visibleColumns.map(c => `${c.key}(seq:${c.seq})`)); + console.log(`📊 Total visible columns: ${visibleColumns.length}`); + + if (visibleColumns.length === 0) { + console.warn('❌ No visible columns found in columnsJSON'); + return []; + } + + // ⭐ 컬럼 용량 확보 + const startCol = 1; + const requiredColumnCount = startCol + visibleColumns.length; + ensureColumnCapacity(activeSheet, requiredColumnCount); + + // 테이블 생성 시작 + const mappings: CellMapping[] = []; + + // 🔍 그룹 헤더 분석 + 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 targetCol = startCol + colIndex; + const columnConfig = columnsJSON.find(col => col.key === column.key); + + // 📋 각 행마다 개별 셀 설정 + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cell = activeSheet.getCell(targetRow, targetCol); + const value = rowData[column.key]; + const cellEditable = isFieldEditable(column.key, rowData); + + // 🔧 새로 추가: 셀 타입 및 편집기 설정 + if (columnConfig) { + setupCellTypeAndEditor(activeSheet, { row: targetRow, col: targetCol }, columnConfig, cellEditable, 1); + } + + // 값 설정 + cell.value(value ?? null); + + // 스타일 설정 + const style = createCellStyle(cellEditable); + activeSheet.setStyle(targetRow, targetCol, style); + + // 개별 매핑 추가 + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + }); + }); + } + + // 🔄 데이터 행 및 매핑 생성 (SPREAD_LIST 방식과 동일한 로직) + const dataRowCount = tableData.length; + ensureRowCapacity(activeSheet, dataStartRow + dataRowCount); + + // 📋 각 컬럼별로 매핑 생성 (SPREAD_LIST와 동일한 방식) + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + console.log(`🔄 Processing column ${column.key} with ${dataRowCount} rows`); + + // 📋 각 행마다 개별 매핑 생성 (SPREAD_LIST와 동일) + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellAddress = getCellAddress(targetRow, targetCol); + + // 🛡️ readonly 체크 (SPREAD_LIST와 동일한 로직) + const cellEditable = isFieldEditable(column.key, rowData); + + // 개별 매핑 추가 + mappings.push({ + attId: column.key, + cellAddress: cellAddress, + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + console.log(`📝 Mapping ${column.key} Row ${rowIndex}: ${cellAddress} (${cellEditable ? 'Editable' : 'ReadOnly'})`); + }); + + // 📋 LIST 타입 드롭다운 설정 (편집 가능한 행이 있는 경우만) + if (column.type === "LIST" && column.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(column.key, rowData)); + if (hasEditableRows) { + const cellPos = { row: dataStartRow, col: targetCol }; + setupOptimizedListValidation(activeSheet, cellPos, column.options, dataRowCount); + console.log(`📋 Dropdown set for ${column.key}: ${hasEditableRows ? 'Has editable rows' : 'All readonly'}`); + } + } + }); + + // 🎨 개별 셀 데이터 및 스타일 설정 (SPREAD_LIST와 동일한 방식) + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(targetRow, targetCol); + const value = rowData[column.key]; + + // 값 설정 + cell.value(value ?? null); + + // 🛡️ 편집 권한 및 스타일 재확인 (SPREAD_LIST와 동일) + const cellEditable = isFieldEditable(column.key, rowData); + cell.locked(!cellEditable); + const style = createCellStyle(cellEditable); + activeSheet.setStyle(targetRow, targetCol, style); + + // 🔍 디버깅: readonly 상태 로깅 + if (!cellEditable) { + const columnConfig = columnsJSON.find(col => col.key === column.key); + const reasons = []; + + if (columnConfig?.shi === true) { + reasons.push('column.shi=true'); + } + if (rowData.shi === true) { + reasons.push('row.shi=true'); + } + if (!editableFields.includes(column.key) && column.key !== "TAG_NO" && column.key !== "TAG_DESC") { + reasons.push('not in editableFields'); + } + + console.log(`🔒 ReadOnly [${targetRow}, ${targetCol}] ${column.key}: ${reasons.join(', ')}`); + } + }); + }); + + // 🎨 컬럼 너비 자동 설정 + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`🏗️ GRD_LIST table created with ${mappings.length} mappings, hasGroups: ${hasGroups}`); + console.log(`📊 Readonly analysis:`); + console.log(` Total cells: ${mappings.length}`); + console.log(` Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` Readonly cells: ${mappings.filter(m => !m.isEditable).length}`); + + return mappings; +}, [tableData, columnsJSON, isFieldEditable, createCellStyle, ensureRowCapacity, ensureColumnCapacity, setupOptimizedListValidation, setOptimalColumnWidths, editableFields, getCellAddress, analyzeColumnGroups]); + +// 🛡️ 추가: readonly 상태 확인 헬퍼 함수 +const analyzeReadonlyStatus = React.useCallback((column: DataTableColumnJSON, rowData: GenericData) => { + const reasons: string[] = []; + + // 1. 컬럼 자체가 readonly인지 확인 + if (column.shi === true) { + reasons.push('Column marked as readonly (shi=true)'); + } + + // 2. 행 자체가 readonly인지 확인 + if (rowData.shi === true) { + reasons.push('Row marked as readonly (shi=true)'); + } + + // 3. editableFields에 포함되지 않은 경우 + if (!editableFields.includes(column.key) && column.key !== "TAG_NO" && column.key !== "TAG_DESC") { + reasons.push('Not in editable fields list'); + } + + // 4. 특수 필드 체크 + if (column.key === "TAG_NO" || column.key === "TAG_DESC") { + // TAG_NO와 TAG_DESC는 기본 편집 가능하지만 다른 조건들은 적용됨 + if (column.shi === true || rowData.shi === true) { + // 다른 readonly 조건이 있으면 적용 + } else { + return { isEditable: true, reasons: ['Default editable field'] }; + } + } + + const isEditable = reasons.length === 0; + + return { + isEditable, + reasons: isEditable ? ['Editable'] : reasons + }; +}, [editableFields]); + + + +// 🛡️ 수정된 시트 보호 및 이벤트 설정 함수 +const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); + + // 🔧 1단계: 먼저 시트 보호를 완전히 해제하고 강력한 잠금 해제 실행 + console.log('🔓 Step 1: Forcing unlock all editable cells...'); + activeSheet.options.isProtected = false; + + // 🔧 2단계: 모든 편집 가능한 셀에 대해 강제 잠금 해제 및 CellType 설정 + mappings.forEach((mapping, index) => { + if (!mapping.isEditable) return; + + 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); + + // 강제 잠금 해제 + cell.locked(false); + + // CellType 명시적 설정 + 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); + console.log(`📋 ComboBox set for ${mapping.attId} at ${mapping.cellAddress}`); + } else { + // 다른 모든 타입: 기본 텍스트 편집기 설정 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + console.log(`📝 Text editor set for ${mapping.attId} at ${mapping.cellAddress}`); + + // NUMBER 타입인 경우에만 validation 추가 (편집은 가능하게) + if (columnConfig?.type === "NUMBER") { + 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); + } + } + + // 편집 가능 스타일 명확히 표시 + const editableStyle = createCellStyle(true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Forced unlock: ${mapping.attId} at ${mapping.cellAddress}`); + + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); + + // 🔧 3단계: 시트 보호 재설정 (편집 허용하는 설정으로) + activeSheet.options.isProtected = true; + activeSheet.options.protectionOptions = { + allowSelectLockedCells: true, + allowSelectUnlockedCells: true, + allowSort: false, + allowFilter: false, + allowEditObjects: true, // ✅ 편집 객체 허용 + allowResizeRows: false, + allowResizeColumns: false, + allowFormatCells: false, + allowInsertRows: false, + allowInsertColumns: false, + allowDeleteRows: false, + allowDeleteColumns: false + }; + + // 🔧 4단계: 편집 테스트 실행 + console.log('🧪 Testing cell editability...'); + const editableMapping = mappings.find(m => m.isEditable); + if (editableMapping) { + const cellPos = parseCellAddress(editableMapping.cellAddress); + if (cellPos) { + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const testValue = 'TEST_' + Math.random().toString(36).substr(2, 5); + const originalValue = cell.value(); + + console.log(`🧪 Testing ${editableMapping.attId} at ${editableMapping.cellAddress}`); + console.log(`🧪 Locked status: ${cell.locked()}`); + + // 직접 값 설정 테스트 + cell.value(testValue); + const newValue = cell.value(); + + if (newValue === testValue) { + console.log('✅ Cell edit test PASSED'); + cell.value(originalValue); // 원래 값 복원 + } else { + console.log(`❌ Cell edit test FAILED: ${newValue} !== ${testValue}`); + } + } catch (testError) { + console.error('❌ Edit test error:', testError); + } + } + } + + // 🎯 변경 감지 이벤트 + const changeEvents = [ + GC.Spread.Sheets.Events.CellChanged, + GC.Spread.Sheets.Events.ValueChanged, + GC.Spread.Sheets.Events.ClipboardPasted + ]; + + changeEvents.forEach(eventType => { + activeSheet.bind(eventType, () => { + console.log(`📝 ${eventType} detected`); + setHasChanges(true); + }); + }); + + // 🚫 편집 시작 권한 확인 (수정됨) + 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; + }); + + if (!exactMapping) { + console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`); + return; // 매핑이 없으면 허용 (템플릿 영역 밖) + } + + console.log(`📋 Found mapping: ${exactMapping.attId} at ${exactMapping.cellAddress}, isEditable: ${exactMapping.isEditable}`); + + // 🔍 추가 디버깅: 셀의 실제 상태 확인 + const cell = activeSheet.getCell(info.row, info.col); + const isLocked = cell.locked(); + const cellValue = cell.value(); + + console.log(`🔍 Cell state check:`, { + attId: exactMapping.attId, + isEditable: exactMapping.isEditable, + isLocked: isLocked, + currentValue: cellValue + }); + + // 🔧 추가: EditStarting 시점에서도 강제 잠금 해제 재시도 + if (exactMapping.isEditable && isLocked) { + console.log(`🔓 Re-unlocking cell during EditStarting...`); + cell.locked(false); + + // CellType도 재설정 + const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); + if (columnConfig?.type !== "LIST") { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(info.row, info.col, textCellType); + } + } + + // 기본 편집 권한 확인 + if (!exactMapping.isEditable) { + console.log(`🚫 Field ${exactMapping.attId} is not editable`); + toast.warning(`${exactMapping.attId} field is read-only`); + info.cancel = true; + return; + } + + // 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) { + console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); + toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); + 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}, 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; + } + + 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"; + errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick); + 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 }); + } else { + console.log(`✅ Validation passed`); + + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + + // 🔄 변경 상태 업데이트 + setHasChanges(true); + }); + + // 🔧 5단계: 설정 완료 후 1초 뒤에 추가 잠금 해제 실행 (안전장치) + setTimeout(() => { + console.log('🔄 Running safety unlock after 1 second...'); + mappings.forEach(mapping => { + if (!mapping.isEditable) return; + + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + if (cell.locked()) { + console.log(`🔓 Safety unlock: ${mapping.attId}`); + cell.locked(false); + } + } catch (error) { + console.error(`❌ Safety unlock error for ${mapping.cellAddress}:`, error); + } + }); + }, 1000); + + console.log(`🛡️ Protection and events configured for ${mappings.length} mappings`); + console.log(`🔓 Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(`🔒 Readonly cells: ${mappings.filter(m => !m.isEditable).length}`); +}, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + +// 🔧 셀 타입 및 편집기 설정 함수 (initSpread 함수 내부에 추가) +const setupCellTypeAndEditor = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, columnConfig: DataTableColumnJSON, isEditable: boolean, rowCount: number = 1) => { + console.log(`🔧 Setting up cell type for ${columnConfig.key} (${columnConfig.type}) at [${cellPos.row}, ${cellPos.col}]`); + + try { + // 편집 가능한 셀에만 적절한 셀 타입 설정 + if (isEditable) { + for (let i = 0; i < rowCount; i++) { + const targetRow = cellPos.row + i; + const cell = activeSheet.getCell(targetRow, cellPos.col); + + // 셀 잠금 해제 + cell.locked(false); + + switch (columnConfig.type) { + case "LIST": + // 드롭다운은 기존 setupOptimizedListValidation 함수에서 처리 + break; + + case "NUMBER": + // 숫자 입력용 셀 타입 설정 + const numberCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(targetRow, cellPos.col, numberCellType); + + // 숫자 validation 설정 (선택사항) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(true); + numberValidator.inputTitle("Number Input"); + numberValidator.inputMessage("Please enter a valid number"); + activeSheet.setDataValidator(targetRow, cellPos.col, numberValidator); + break; + + case "STRING": + default: + // 기본 텍스트 입력용 셀 타입 설정 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(targetRow, cellPos.col, textCellType); + break; + } + + console.log(`✅ Cell type set for [${targetRow}, ${cellPos.col}]: ${columnConfig.type}`); + } + } else { + // 읽기 전용 셀 설정 + for (let i = 0; i < rowCount; i++) { + const targetRow = cellPos.row + i; + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(true); + } + } + + } catch (error) { + console.error(`❌ Error setting cell type for ${columnConfig.key}:`, error); + } +}, []); + + // ═══════════════════════════════════════════════════════════════════════════════ + // 🏗️ 메인 SpreadSheets 초기화 함수 + // ═══════════════════════════════════════════════════════════════════════════════ + +// 🛡️ 수정된 initSpread 함수 - activeSheet 참조 문제 해결 +const initSpread = React.useCallback((spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template in initSpread'); + return; + } + + try { + // 🔄 초기 설정 + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 성능을 위한 렌더링 일시 중단 + spread.suspendPaint(); + + try { + // ⚠️ 초기 activeSheet 가져오기 + let activeSheet = getSafeActiveSheet(spread, 'initSpread-initial'); + 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'); + + // 기본 워크북 설정 + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + // 동적 테이블 생성 + mappings = createGrdListTable(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 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 for template:', workingTemplate.NAME); + } + + if (!contentJson) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); + } + + if (!dataSheets || dataSheets.length === 0) { + throw new Error(`No data sheets configuration found for ${workingTemplate.NAME}`); + } + + console.log('🔍 Template info:', { + templateName: workingTemplate.NAME, + templateType: templateType, + dataSheetsCount: dataSheets.length, + hasSelectedRow: !!selectedRow, + tableDataLength: tableData.length + }); + + // 🏗️ SpreadSheets 템플릿 로드 + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + console.log('📥 Loading template JSON...'); + spread.fromJSON(jsonData); + console.log('✅ Template JSON loaded'); + + // ⚠️ 중요: 템플릿 로드 후 activeSheet 다시 가져오기 + activeSheet = getSafeActiveSheet(spread, 'initSpread-after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + console.log('🔍 Active sheet after template load:', { + name: activeSheet.name?.() || 'unnamed', + rowCount: activeSheet.getRowCount(), + colCount: activeSheet.getColumnCount() + }); + + // 시트 보호 다시 해제 (템플릿 로드 후 다시 설정될 수 있음) + activeSheet.options.isProtected = false; + + // 📊 데이터 매핑 및 로딩 처리 + console.log(`🔄 Processing ${dataSheets.length} data sheets`); + + dataSheets.forEach((dataSheet, sheetIndex) => { + console.log(`📋 Processing data sheet ${sheetIndex}:`, { + sheetName: dataSheet.SHEET_NAME, + mappingCount: dataSheet.MAP_CELL_ATT?.length || 0 + }); + + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping, mappingIndex) => { + const { ATT_ID, IN } = mapping; + + if (!ATT_ID || !IN || IN.trim() === "") { + console.warn(`⚠️ Invalid mapping: ATT_ID=${ATT_ID}, IN=${IN}`); + return; + } + + const cellPos = parseCellAddress(IN); + if (!cellPos) { + console.warn(`⚠️ Invalid cell address: ${IN}`); + return; + } + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + + // 🎯 템플릿 타입별 데이터 처리 + if (templateType === 'SPREAD_ITEM' && selectedRow) { + console.log(`📝 Processing SPREAD_ITEM for ${ATT_ID}`); + + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + // 매핑 정보 저장 + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + // ⚠️ 안전한 셀 참조 및 값 설정 + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + console.log(`🔄 Setting SPREAD_ITEM cell [${cellPos.row}, ${cellPos.col}] ${ATT_ID}: "${value}"`); + + // 🔧 새로 추가: 셀 타입 및 편집기 설정 + setupCellTypeAndEditor(activeSheet, cellPos, columnConfig, isEditable, 1); + + // 값 설정 + cell.value(value ?? null); + + // 스타일 설정 + 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); + } + + console.log(`✅ SPREAD_ITEM cell set successfully`); + } catch (cellError) { + console.error(`❌ Error setting SPREAD_ITEM cell:`, cellError); + } + } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + console.log(`📊 Processing SPREAD_LIST for ${ATT_ID} with ${tableData.length} rows`); + + // 🚀 행 확장 - 안전한 방법으로 + const requiredRows = cellPos.row + tableData.length; + console.log(`🚀 Ensuring ${requiredRows} rows for SPREAD_LIST`); + + // ⚠️ activeSheet 유효성 재검증 + const currentActiveSheet = getSafeActiveSheet(spread, 'ensureRowCapacity'); + if (!currentActiveSheet) { + console.error(`❌ ActiveSheet is null before ensureRowCapacity`); + return; + } + + if (!ensureRowCapacity(currentActiveSheet, requiredRows)) { + console.error(`❌ Failed to ensure row capacity for ${requiredRows} rows`); + return; + } + + // activeSheet 참조 업데이트 + activeSheet = currentActiveSheet; + + // 매핑 생성 + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const targetCellAddress = getCellAddress(targetRow, cellPos.col); + const cellEditable = isFieldEditable(ATT_ID, rowData); + + mappings.push({ + attId: ATT_ID, + cellAddress: targetCellAddress, + isEditable: cellEditable, + dataRowIndex: index + }); + }); + + // 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; + + try { + const cell = activeSheet.getCell(targetRow, cellPos.col); + const value = rowData[ATT_ID]; + const cellEditable = isFieldEditable(ATT_ID, rowData); + + console.log(`🔄 Setting SPREAD_LIST Row ${index} ${ATT_ID}: "${value}"`); + + // 🔧 새로 추가: 각 셀에 대한 타입 및 편집기 설정 + setupCellTypeAndEditor(activeSheet, { row: targetRow, col: cellPos.col }, columnConfig, cellEditable, 1); + + // 값 설정 + cell.value(value ?? null); + + // 스타일 설정 + const style = createCellStyle(cellEditable); + activeSheet.setStyle(targetRow, cellPos.col, style); + + } catch (cellError) { + console.error(`❌ Error setting SPREAD_LIST cell Row ${index}:`, cellError); + } +}); + + + console.log(`✅ SPREAD_LIST processing completed for ${ATT_ID}`); + } + }); + } + }); + } + + // 💾 매핑 정보 저장 및 이벤트 설정 + setCellMappings(mappings); + + // ⚠️ 최종 activeSheet 재확인 후 이벤트 설정 + const finalActiveSheet = getSafeActiveSheet(spread, 'setupSheetProtectionAndEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } else { + console.error('❌ Failed to get activeSheet for events setup'); + } + + console.log(`✅ Template initialization completed with ${mappings.length} mappings`); + + } finally { + // 렌더링 재개 + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error initializing spread:', error); + // toast.error(`Failed to load template: ${error.message}`); + if (spread?.resumePaint) { + spread.resumePaint(); + } + } +}, [selectedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON, createCellStyle, setupOptimizedListValidation, ensureRowCapacity, setupSheetProtectionAndEvents, createGrdListTable, getCellAddress, getSafeActiveSheet, validateActiveSheet]); + // 변경사항 저장 함수 + 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.`); + return; + } + + try { + setIsPending(true); + + const activeSheet = currentSpread.getActiveSheet(); + + if (templateType === 'SPREAD_ITEM' && selectedRow) { + // 단일 행 저장 + const dataToSave = { ...selectedRow }; + + cellMappings.forEach(mapping => { + if (mapping.isEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + dataToSave[mapping.attId] = cellValue; + } + } + }); + + dataToSave.TAG_NO = selectedRow.TAG_NO; + + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (!success) { + toast.error(message); + return; + } + + toast.success("Changes saved successfully!"); + onUpdateSuccess?.(dataToSave); + + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + // 복수 행 저장 (SPREAD_LIST와 GRD_LIST 동일 처리) + const updatedRows: GenericData[] = []; + let saveCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + // 각 매핑에 대해 해당 행의 값 확인 + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + const isColumnEditable = columnConfig?.shi !== true; + const isRowEditable = originalRow.shi !== true; + + if (isColumnEditable && isRowEditable) { + 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; + } + } + } + } + }); + + // 변경사항이 있는 행만 저장 + if (hasRowChanges) { + dataToSave.TAG_NO = originalRow.TAG_NO; // TAG_NO는 절대 변경되지 않도록 + + const { success } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + } + } else { + updatedRows.push(originalRow); // 변경사항이 없으면 원본 유지 + } + } + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + toast.info("No changes to save"); + } + } + + setHasChanges(false); + setValidationErrors([]); + + } catch (error) { + console.error("Error saving changes:", error); + toast.error("An unexpected error occurred while saving"); + } finally { + setIsPending(false); + } + }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON, validateAllData]); + + 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" }} + > + <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> + <Select value={selectedTemplateId} onValueChange={handleTemplateChange}> + <SelectTrigger className="w-64"> + <SelectValue placeholder="Select a template" /> + </SelectTrigger> + <SelectContent> + {availableTemplates.map(template => ( + <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> + {template.NAME} ({ + template.TMPL_TYPE + }) + </SelectItem> + ))} + </SelectContent> + </Select> + </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)' + } + </span> + {templateType === 'SPREAD_ITEM' && selectedRow && ( + <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span> + )} + {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( + <span>• {dataCount} rows</span> + )} + {hasChanges && ( + <span className="text-orange-600 font-medium"> + • Unsaved changes + </span> + )} + {validationErrors.length > 0 && ( + <span className="text-red-600 font-medium flex items-center"> + <AlertTriangle className="w-4 h-4 mr-1" /> + {validationErrors.length} validation errors + </span> + )} + </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> + Editable fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span> + Read-only fields + </span> + <span className="text-muted-foreground"> + <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span> + Validation errors + </span> + {cellMappings.length > 0 && ( + <span className="text-blue-600"> + {editableFieldsCount} of {cellMappings.length} fields editable + </span> + )} + </div> + </div> + </DialogDescription> + </DialogHeader> + + {/* SpreadSheets 컴포넌트 영역 */} + <div className="flex-1 overflow-hidden"> + {selectedTemplate && isClient && isDataValid ? ( + <SpreadSheets + key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} + workbookInitialized={initSpread} + hostStyle={hostStyle} + /> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + {!isClient ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Loading... + </> + ) : !selectedTemplate ? ( + "No template available" + ) : !isDataValid ? ( + `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available` + ) : ( + "Template not ready" + )} + </div> + )} + </div> + + <DialogFooter className="flex-shrink-0"> + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={onClose}> + Close + </Button> + + {hasChanges && ( + <Button + variant="default" + onClick={handleSaveChanges} + disabled={isPending || validationErrors.length > 0} + > + {isPending ? ( + <> + <Loader className="mr-2 h-4 w-4 animate-spin" /> + Saving... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + Save Changes + </> + )} + </Button> + )} + + {validationErrors.length > 0 && ( + <Button + variant="outline" + onClick={validateAllData} + className="text-red-600 border-red-300 hover:bg-red-50" + > + <AlertTriangle className="mr-2 h-4 w-4" /> + Check Errors ({validationErrors.length}) + </Button> + )} + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index 54a70d9d..a223a849 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -327,6 +327,17 @@ export function TemplateViewDialog({ }); }, []); + const createCellStyle = React.useCallback((isEditable: boolean) => { + const style = new GC.Spread.Sheets.Style(); + if (isEditable) { + style.backColor = "#f0fdf4"; + } else { + style.backColor = "#f9fafb"; + style.foreColor = "#6b7280"; + } + return style; + }, []); + const setBatchStyles = React.useCallback(( activeSheet: any, stylesToSet: Array<{row: number, col: number, isEditable: boolean}> @@ -440,16 +451,7 @@ export function TemplateViewDialog({ return errors; }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]); - const createCellStyle = React.useCallback((isEditable: boolean) => { - const style = new GC.Spread.Sheets.Style(); - if (isEditable) { - style.backColor = "#f0fdf4"; - } else { - style.backColor = "#f9fafb"; - style.foreColor = "#6b7280"; - } - return style; - }, []); + const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { try { @@ -780,7 +782,7 @@ export function TemplateViewDialog({ }); // 🛡️ 시트 보호 재설정 (편집 허용 모드로) - activeSheet.options.isProtected = true; + activeSheet.options.isProtected = false; activeSheet.options.protectionOptions = { allowSelectLockedCells: true, allowSelectUnlockedCells: true, diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx index 5a9dc4d4..52079767 100644 --- a/components/information/information-button.tsx +++ b/components/information/information-button.tsx @@ -111,7 +111,14 @@ export function InformationButton({ // 파일 다운로드 핸들러
const handleDownload = () => {
if (information?.attachmentFilePath) {
- window.open(information.attachmentFilePath, '_blank')
+ // window.open 대신 link 요소 사용
+ const link = document.createElement('a')
+ link.href = information.attachmentFilePath
+ link.target = '_blank'
+ link.rel = 'noopener noreferrer'
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
}
}
diff --git a/components/information/information-client.tsx b/components/information/information-client.tsx index 69835599..d863175f 100644 --- a/components/information/information-client.tsx +++ b/components/information/information-client.tsx @@ -1,6 +1,7 @@ "use client"
import { useState, useEffect, useTransition } from "react"
+import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@@ -34,6 +35,7 @@ type SortField = "pageName" | "pagePath" | "createdAt" type SortDirection = "asc" | "desc"
export function InformationClient({ initialData = [] }: InformationClientProps) {
+ const router = useRouter()
const [informations, setInformations] = useState<PageInformation[]>(initialData)
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
@@ -185,7 +187,7 @@ export function InformationClient({ initialData = [] }: InformationClientProps) </Button>
<Button
variant="outline"
- onClick={() => window.location.reload()}
+ onClick={() => router.refresh()}
>
새로고침
</Button>
diff --git a/components/ship-vendor-document/edit-revision-dialog.tsx b/components/ship-vendor-document/edit-revision-dialog.tsx new file mode 100644 index 00000000..313a27bc --- /dev/null +++ b/components/ship-vendor-document/edit-revision-dialog.tsx @@ -0,0 +1,726 @@ +"use client" + +import React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Edit, + FileText, + Loader2, + AlertTriangle, + Trash2, + CheckCircle, + Clock +} from "lucide-react" +import { toast } from "sonner" +import { updateRevisionAction, deleteRevisionAction } from "@/lib/vendor-document-list/enhanced-document-service" // ✅ 서버 액션 import + +/* ------------------------------------------------------------------------------------------------- + * Schema & Types + * -----------------------------------------------------------------------------------------------*/ + +interface RevisionInfo { + id: number + issueStageId: number + revision: string + uploaderType: string + uploaderId: number | null + uploaderName: string | null + comment: string | null + usage: string | null + usageType: string | null + revisionStatus: string + submittedDate: string | null + approvedDate: string | null + uploadedAt: string | null + reviewStartDate: string | null + rejectedDate: string | null + reviewerId: number | null + reviewerName: string | null + reviewComments: string | null + createdAt: Date + updatedAt: Date + stageName?: string + attachments: AttachmentInfo[] +} + +interface AttachmentInfo { + id: number + revisionId: number + fileName: string + filePath: string + dolceFilePath: string | null + fileSize: number | null + fileType: string | null + createdAt: Date + updatedAt: Date +} + +// drawingKind에 따른 동적 스키마 생성 (수정용) +const createEditRevisionSchema = (drawingKind: string) => { + const baseSchema = { + usage: z.string().min(1, "Please select a usage"), + revision: z.string().min(1, "Please enter a revision").max(50, "Revision must be 50 characters or less"), // ✅ revision 필드 추가 + comment: z.string().optional(), + } + + // B3인 경우에만 usageType 필드 추가 + if (drawingKind === 'B3') { + return z.object({ + ...baseSchema, + usageType: z.string().min(1, "Please select a usage type"), + }) + } else { + return z.object({ + ...baseSchema, + usageType: z.string().optional(), + }) + } +} + +// drawingKind에 따른 용도 옵션 +const getUsageOptions = (drawingKind: string) => { + switch (drawingKind) { + case 'B3': + return [ + { value: "Approval", label: "Approval" }, + { value: "Working", label: "Working" }, + { value: "Comments", label: "Comments" }, + ] + case 'B4': + return [ + { value: "Pre", label: "Pre" }, + { value: "Working", label: "Working" }, + ] + case 'B5': + return [ + { value: "Pre", label: "Pre" }, + { value: "Working", label: "Working" }, + ] + default: + return [ + { value: "Pre", label: "Pre" }, + { value: "Working", label: "Working" }, + ] + } +} + +// B3 전용 용도 타입 옵션 +const getUsageTypeOptions = (usage: string) => { + switch (usage) { + case 'Approval': + return [ + { value: "Full", label: "Full" }, + { value: "Partial", label: "Partial" }, + ] + case 'Working': + return [ + { value: "Full", label: "Full" }, + { value: "Partial", label: "Partial" }, + ] + case 'Comments': + return [ + { value: "Comments", label: "Comments" }, + ] + default: + return [] + } +} + +// ✅ 리비전 가이드 생성 (NewRevisionDialog와 동일) +const getRevisionGuide = () => { + return "Enter in R01, R02, R03... format" +} + +interface EditRevisionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + revision: RevisionInfo | null + drawingKind?: string + onSuccess: (action: 'update' | 'delete', result?: any) => void +} + +/* ------------------------------------------------------------------------------------------------- + * Revision Info Display Component + * -----------------------------------------------------------------------------------------------*/ +function RevisionInfoDisplay({ revision }: { revision: RevisionInfo }) { + const canEdit = React.useMemo(() => { + if (!revision.attachments || revision.attachments.length === 0) { + return true + } + return revision.attachments.every(attachment => + !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + ) + }, [revision.attachments]) + + const processedCount = revision.attachments?.filter(attachment => + attachment.dolceFilePath && attachment.dolceFilePath.trim() !== '' + ).length || 0 + + const getStatusColor = (status: string) => { + switch (status) { + case 'APPROVED': return 'bg-green-100 text-green-800' + case 'UPLOADED': return 'bg-blue-100 text-blue-800' + case 'REJECTED': return 'bg-red-100 text-red-800' + default: return 'bg-gray-100 text-gray-800' + } + } + + return ( + <div className="space-y-3 p-4 bg-gray-50 rounded-lg border"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <Badge variant="outline" className="text-base font-mono"> + {revision.revision} + </Badge> + <Badge className={`text-xs ${getStatusColor(revision.revisionStatus)}`}> + {revision.revisionStatus} + </Badge> + {!canEdit && ( + <Badge variant="secondary" className="text-xs"> + Partially Processed + </Badge> + )} + </div> + + <div className="flex items-center gap-2 text-sm text-gray-600"> + <FileText className="h-4 w-4" /> + <span>{revision.attachments?.length || 0} file(s)</span> + {processedCount > 0 && ( + <span className="text-blue-600"> + ({processedCount} processed) + </span> + )} + </div> + </div> + + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="text-gray-500">Uploader:</span> + <span className="ml-2 font-medium">{revision.uploaderName || '-'}</span> + </div> + <div> + <span className="text-gray-500">Upload Date:</span> + <span className="ml-2"> + {revision.uploadedAt + ? new Date(revision.uploadedAt).toLocaleDateString() + : '-' + } + </span> + </div> + </div> + + {revision.comment && ( + <div className="pt-2 border-t"> + <span className="text-gray-500 text-sm">Current Comment:</span> + <p className="mt-1 text-sm bg-white p-2 rounded border"> + {revision.comment} + </p> + </div> + )} + + {!canEdit && ( + <div className="flex items-center gap-2 p-2 bg-yellow-50 border border-yellow-200 rounded text-sm"> + <AlertTriangle className="h-4 w-4 text-yellow-600" /> + <span className="text-yellow-800"> + Some files have been processed. Editing is limited. + </span> + </div> + )} + </div> + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Main Dialog Component + * -----------------------------------------------------------------------------------------------*/ +export function EditRevisionDialog({ + open, + onOpenChange, + revision, + drawingKind = 'B4', + onSuccess +}: EditRevisionDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [isDeleting, setIsDeleting] = React.useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false) + + // drawingKind에 따른 동적 스키마 및 옵션 생성 + const editRevisionSchema = React.useMemo(() => createEditRevisionSchema(drawingKind), [drawingKind]) + const usageOptions = React.useMemo(() => getUsageOptions(drawingKind), [drawingKind]) + const showUsageType = drawingKind === 'B3' + + type EditRevisionSchema = z.infer<typeof editRevisionSchema> + + const form = useForm<EditRevisionSchema>({ + resolver: zodResolver(editRevisionSchema), + defaultValues: { + usage: "", + revision: "", // ✅ revision 기본값 추가 + comment: "", + usageType: showUsageType ? "" : undefined, + }, + }) + + const watchedUsage = form.watch("usage") + + // 용도 선택에 따른 용도 타입 옵션 업데이트 + const usageTypeOptions = React.useMemo(() => { + if (drawingKind === 'B3' && watchedUsage) { + return getUsageTypeOptions(watchedUsage) + } + return [] + }, [drawingKind, watchedUsage]) + + // ✅ 리비전 가이드 텍스트 + const revisionGuide = React.useMemo(() => { + return getRevisionGuide() + }, []) + + // revision이 변경될 때 폼 데이터 초기화 + React.useEffect(() => { + if (revision) { + form.reset({ + usage: revision.usage || "", + revision: revision.revision || "", // ✅ revision 값 설정 + comment: revision.comment || "", + usageType: showUsageType ? (revision.usageType || "") : undefined, + }) + } + }, [revision, showUsageType, form]) + + // 용도 변경 시 용도 타입 초기화 또는 자동 설정 (NewRevisionDialog와 동일한 로직) + React.useEffect(() => { + if (showUsageType && watchedUsage) { + if (watchedUsage === "Comments") { + form.setValue("usageType", "Comments") + } else { + // Comments가 아닌 경우, 초기 로드가 아니라면 초기화 + const currentValue = form.getValues("usageType") + if (revision && watchedUsage !== revision.usage) { + form.setValue("usageType", "") + } + } + } + }, [watchedUsage, showUsageType, form, revision]) + + // 수정 가능 여부 확인 + const canEdit = React.useMemo(() => { + if (!revision?.attachments || revision.attachments.length === 0) { + return true + } + return revision.attachments.every(attachment => + !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + ) + }, [revision?.attachments]) + + // 폼 변경 체크 + const hasChanges = React.useMemo(() => { + if (!revision) return false + const currentValues = form.getValues() + return ( + currentValues.comment !== (revision.comment || '') || + currentValues.usage !== (revision.usage || '') || + currentValues.revision !== (revision.revision || '') || // ✅ revision 변경 체크 추가 + (showUsageType && currentValues.usageType !== (revision.usageType || '')) + ) + }, [revision, form, showUsageType]) + + const handleDialogClose = () => { + if (!isLoading && !isDeleting) { + setShowDeleteConfirm(false) + form.reset() + onOpenChange(false) + } + } + + const onSubmit = async (data: EditRevisionSchema) => { + if (!revision || !canEdit) { + toast.error("Cannot edit this revision") + return + } + + setIsLoading(true) + + try { + // ✅ 서버 액션 호출 - revision 필드 추가 + const result = await updateRevisionAction({ + revisionId: revision.id, + revision: data.revision.trim(), // ✅ revision 추가 + comment: data.comment?.trim() || null, + usage: data.usage.trim(), + usageType: showUsageType && 'usageType' in data ? data.usageType?.trim() || null : null, + }) + + if (!result.success) { + throw new Error(result.error || 'Failed to update revision') + } + + toast.success( + result.message || + `Revision ${data.revision} updated successfully` // ✅ 새 revision 값 사용 + ) + + setTimeout(() => { + handleDialogClose() + onSuccess('update', result) + }, 1000) + + } catch (error) { + console.error('❌ Update error:', error) + toast.error(error instanceof Error ? error.message : "An error occurred during update") + } finally { + setIsLoading(false) + } + } + + const handleDelete = async () => { + if (!revision || !canEdit) { + toast.error("Cannot delete this revision") + return + } + + setIsDeleting(true) + + try { + // ✅ 서버 액션 호출 + const result = await deleteRevisionAction({ + revisionId: revision.id, + }) + + if (!result.success) { + throw new Error(result.error || 'Failed to delete revision') + } + + toast.success( + result.message || + `Revision ${revision.revision} deleted successfully` + ) + + setTimeout(() => { + handleDialogClose() + onSuccess('delete', result) + }, 1000) + + } catch (error) { + console.error('❌ Delete error:', error) + toast.error(error instanceof Error ? error.message : "An error occurred during deletion") + } finally { + setIsDeleting(false) + } + } + + if (!revision) return null + + return ( + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="max-w-2xl h-[85vh] flex flex-col overflow-hidden"> + {/* 고정 헤더 */} + <DialogHeader className="flex-shrink-0 pb-4 border-b"> + <DialogTitle className="flex items-center gap-2"> + <Edit className="h-5 w-5" /> + Edit Revision + </DialogTitle> + <DialogDescription className="text-sm"> + Modify revision details and metadata + </DialogDescription> + </DialogHeader> + + {!showDeleteConfirm ? ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden"> + {/* 스크롤 가능한 중간 영역 */} + <div className="flex-1 overflow-y-auto px-1 py-4 space-y-6"> + + {/* 리비전 정보 표시 */} + <RevisionInfoDisplay revision={revision} /> + + {/* ✅ 리비전 필드 추가 */} + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">Revision</FormLabel> + <FormControl> + <Input + placeholder={revisionGuide} + disabled={!canEdit} + {...field} + /> + </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + {revisionGuide} + </div> + <FormMessage /> + </FormItem> + )} + /> + + {/* 용도 선택 */} + <FormField + control={form.control} + name="usage" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">Usage</FormLabel> + <Select onValueChange={field.onChange} value={field.value} disabled={!canEdit}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select usage" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {usageOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 용도 타입 선택 (B3만) */} + {showUsageType && watchedUsage && ( + <FormField + control={form.control} + name="usageType" + render={({ field }) => ( + <FormItem> + <FormLabel className="required">Usage Type</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value || ""} + disabled={!canEdit || watchedUsage === "Comments"} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select usage type" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {usageTypeOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + {watchedUsage === "Comments" && ( + <div className="text-xs text-muted-foreground mt-1"> + Automatically set to "Comments" for this usage + </div> + )} + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 코멘트 */} + <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>Comment</FormLabel> + <FormControl> + <Textarea + placeholder="Enter description or changes for this revision (optional)" + className="resize-none" + rows={4} + disabled={!canEdit} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 첨부파일 목록 */} + {revision.attachments && revision.attachments.length > 0 && ( + <div className="space-y-2"> + <FormLabel>Attachments ({revision.attachments.length})</FormLabel> + <div className="space-y-2 max-h-32 overflow-y-auto border rounded-lg p-3"> + {revision.attachments.map((file, index) => ( + <div + key={file.id} + className="flex items-center justify-between p-2 bg-gray-50 rounded text-sm" + > + <div className="flex items-center gap-2 flex-1 min-w-0"> + <FileText className="h-4 w-4 text-gray-500 flex-shrink-0" /> + <span className="truncate" title={file.fileName}> + {file.fileName} + </span> + </div> + {file.dolceFilePath && file.dolceFilePath.trim() !== '' && ( + <Badge variant="secondary" className="text-xs ml-2"> + Processed + </Badge> + )} + </div> + ))} + </div> + </div> + )} + + {/* 성공/로딩 상태 */} + {isLoading && ( + <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded"> + <Loader2 className="h-4 w-4 animate-spin text-blue-600" /> + <span className="text-blue-800 text-sm">Updating revision...</span> + </div> + )} + </div> + + {/* 고정 버튼 영역 */} + <DialogFooter className="flex-shrink-0 border-t pt-4"> + <div className="flex items-center justify-between w-full"> + {/* 삭제 버튼 */} + <Button + type="button" + variant="outline" + onClick={() => setShowDeleteConfirm(true)} + disabled={isLoading || !canEdit} + className="text-red-600 border-red-200 hover:bg-red-50" + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete + </Button> + + {/* 저장/취소 버튼 */} + <div className="flex gap-2"> + <Button + type="button" + variant="outline" + onClick={handleDialogClose} + disabled={isLoading} + > + Cancel + </Button> + <Button + type="submit" + disabled={isLoading || !hasChanges || !canEdit} + className="min-w-[120px]" + > + {isLoading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Updating... + </> + ) : ( + <> + <CheckCircle className="mr-2 h-4 w-4" /> + Update + </> + )} + </Button> + </div> + </div> + </DialogFooter> + </form> + </Form> + ) : ( + // 삭제 확인 화면 + <div className="flex flex-col flex-1 overflow-hidden"> + <div className="flex-1 overflow-y-auto px-1 py-4"> + <div className="space-y-4"> + <div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg"> + <AlertTriangle className="h-6 w-6 text-red-600 flex-shrink-0" /> + <div> + <h4 className="font-medium text-red-900">Delete Revision</h4> + <p className="text-sm text-red-700 mt-1"> + Are you sure you want to delete revision <strong>{revision.revision}</strong>? + </p> + </div> + </div> + + <div className="space-y-3 text-sm text-gray-600"> + <p>This action will permanently delete:</p> + <ul className="list-disc list-inside space-y-1 ml-4"> + <li>Revision metadata and settings</li> + <li>All {revision.attachments?.length || 0} attached file(s)</li> + <li>Upload history and comments</li> + </ul> + <p className="font-medium text-red-600"> + This action cannot be undone. + </p> + </div> + + {/* 성공/로딩 상태 */} + {isDeleting && ( + <div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded"> + <Loader2 className="h-4 w-4 animate-spin text-red-600" /> + <span className="text-red-800 text-sm">Deleting revision...</span> + </div> + )} + </div> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4"> + <div className="flex gap-2 w-full justify-end"> + <Button + variant="outline" + onClick={() => setShowDeleteConfirm(false)} + disabled={isDeleting} + > + Cancel + </Button> + <Button + variant="destructive" + onClick={handleDelete} + disabled={isDeleting} + className="min-w-[120px]" + > + {isDeleting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Deleting... + </> + ) : ( + <> + <Trash2 className="mr-2 h-4 w-4" /> + Delete Revision + </> + )} + </Button> + </div> + </DialogFooter> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/ship-vendor-document/user-vendor-document-table-container.tsx b/components/ship-vendor-document/user-vendor-document-table-container.tsx index 4e133696..61d52c28 100644 --- a/components/ship-vendor-document/user-vendor-document-table-container.tsx +++ b/components/ship-vendor-document/user-vendor-document-table-container.tsx @@ -27,7 +27,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { Building, FileText, AlertCircle, Eye, Download, Loader2, Plus } from "lucide-react" +import { Building, FileText, AlertCircle, Eye, Download, Loader2, Plus, Trash2, Edit } from "lucide-react" import { SimplifiedDocumentsTable } from "@/lib/vendor-document-list/ship/enhanced-documents-table" import { getUserVendorDocuments, @@ -38,6 +38,7 @@ import { WebViewerInstance } from "@pdftron/webviewer" import { NewRevisionDialog } from "./new-revision-dialog" import { useRouter } from 'next/navigation' import { AddAttachmentDialog } from "./add-attachment-dialog" // ✅ import 추가 +import { EditRevisionDialog } from "./edit-revision-dialog" // ✅ 추가 /* ------------------------------------------------------------------------------------------------- * Types & Constants @@ -91,6 +92,7 @@ interface AttachmentInfo { revisionId: number fileName: string filePath: string + dolceFilePath: string | null fileSize: number | null fileType: string | null createdAt: Date @@ -129,7 +131,7 @@ export const DocumentSelectionContext = React.createContext<DocumentSelectionCon function getUsageTypeDisplay(usageType: string | null): string { if (!usageType) return '-' - + // B3 용도 타입 축약 표시 const abbreviations: Record<string, string> = { 'Approval Submission Full': 'AS-F', @@ -143,18 +145,20 @@ function getUsageTypeDisplay(usageType: string | null): string { 'Reference Series Full': 'RS-F', 'Reference Series Partial': 'RS-P', } - + return abbreviations[usageType] || usageType } -function RevisionTable({ - revisions, +function RevisionTable({ + revisions, onViewRevision, - onNewRevision -}: { + onNewRevision, + onEditRevision, // ✅ 수정 함수 prop 추가 +}: { revisions: RevisionInfo[] onViewRevision: (revision: RevisionInfo) => void onNewRevision: () => void + onEditRevision: (revision: RevisionInfo) => void // ✅ 수정 함수 타입 추가 }) { const { selectedRevisionId, setSelectedRevisionId } = React.useContext(DocumentSelectionContext) @@ -163,6 +167,38 @@ function RevisionTable({ setSelectedRevisionId(revisionId === selectedRevisionId ? null : revisionId) } + // ✅ 리비전 수정 가능 여부 확인 함수 + const canEditRevision = React.useCallback((revision: RevisionInfo) => { + // 첨부파일이 없으면 수정 가능 + if ((!revision.attachments || revision.attachments.length === 0)&&revision.uploaderType ==="vendor") { + return true + } + + // 모든 첨부파일의 dolceFilePath가 null이거나 빈값이어야 수정 가능 + return revision.attachments.every(attachment => + !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + ) + }, []) + + // ✅ 리비전 상태 표시 함수 (처리된 파일이 있는지 확인) + const getRevisionProcessStatus = React.useCallback((revision: RevisionInfo) => { + if (!revision.attachments || revision.attachments.length === 0) { + return 'no-files' + } + + const processedCount = revision.attachments.filter(attachment => + attachment.dolceFilePath && attachment.dolceFilePath.trim() !== '' + ).length + + if (processedCount === 0) { + return 'not-processed' + } else if (processedCount === revision.attachments.length) { + return 'fully-processed' + } else { + return 'partially-processed' + } + }, []) + return ( <Card className="flex-1"> <CardHeader> @@ -182,14 +218,14 @@ function RevisionTable({ </CardHeader> <CardContent> <div className="overflow-x-auto"> - <Table className="tbl-compact"> + <Table className="tbl-compact"> <TableHeader> <TableRow> <TableHead className="w-12">Select</TableHead> <TableHead>Revision</TableHead> <TableHead>Category</TableHead> <TableHead>Usage</TableHead> - <TableHead>Type</TableHead> {/* ✅ usageType 컬럼 */} + <TableHead>Type</TableHead> <TableHead>Status</TableHead> <TableHead>Uploader</TableHead> <TableHead>Comment</TableHead> @@ -199,94 +235,144 @@ function RevisionTable({ </TableRow> </TableHeader> <TableBody> - {revisions.map((revision) => ( - <TableRow - key={revision.id} - className={`revision-table-row ${ - selectedRevisionId === revision.id ? 'selected' : '' - }`} - > - <TableCell> - <input - type="checkbox" - checked={selectedRevisionId === revision.id} - onChange={() => toggleSelect(revision.id)} - className="h-4 w-4 cursor-pointer" - /> - </TableCell> - <TableCell className="font-mono font-medium"> - {revision.revision} - </TableCell> - <TableCell className="text-sm"> - {revision.uploaderType === "vendor" ? "To SHI" : "From SHI"} - </TableCell> - <TableCell> - <span className="text-sm"> - {revision.usage || '-'} - </span> - </TableCell> - {/* ✅ usageType 표시 */} - <TableCell> - <span className="text-sm"> - {revision.usageType ? - + {revisions.map((revision) => { + const canEdit = canEditRevision(revision) + const processStatus = getRevisionProcessStatus(revision) + + return ( + <TableRow + key={revision.id} + className={`revision-table-row ${selectedRevisionId === revision.id ? 'selected' : '' + }`} + > + <TableCell> + <input + type="checkbox" + checked={selectedRevisionId === revision.id} + onChange={() => toggleSelect(revision.id)} + className="h-4 w-4 cursor-pointer" + /> + </TableCell> + <TableCell className="font-mono font-medium"> + <div className="flex items-center gap-2"> + {revision.revision} + {/* ✅ 처리 상태 인디케이터 */} + {processStatus === 'fully-processed' && ( + <div + className="w-2 h-2 bg-blue-500 rounded-full" + title="All files processed" + /> + )} + {processStatus === 'partially-processed' && ( + <div + className="w-2 h-2 bg-yellow-500 rounded-full" + title="Some files processed" + /> + )} + </div> + </TableCell> + <TableCell className="text-sm"> + {revision.uploaderType === "vendor" ? "To SHI" : "From SHI"} + </TableCell> + <TableCell> + <span className="text-sm"> + {revision.usage || '-'} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {revision.usageType ? ( revision.usageType - - : ( + ) : ( + <span className="text-gray-400 text-xs">-</span> + )} + </span> + </TableCell> + <TableCell> + <Badge + variant={ + revision.revisionStatus === 'APPROVED' + ? 'default' + : 'secondary' + } + className="text-xs" + > + {revision.revisionStatus} + </Badge> + </TableCell> + <TableCell> + <span className="text-sm">{revision.uploaderName || '-'}</span> + </TableCell> + <TableCell className="py-1 px-2"> + {revision.comment ? ( + <div className="max-w-24"> + <p className="text-xs text-gray-700 bg-gray-50 p-1 rounded truncate" title={revision.comment}> + {revision.comment} + </p> + </div> + ) : ( <span className="text-gray-400 text-xs">-</span> )} - </span> - </TableCell> - <TableCell> - <Badge - variant={ - revision.revisionStatus === 'APPROVED' - ? 'default' - : 'secondary' - } - className="text-xs" - > - {revision.revisionStatus} - </Badge> - </TableCell> - <TableCell> - <span className="text-sm">{revision.uploaderName || '-'}</span> - </TableCell> - <TableCell className="py-1 px-2"> - {revision.comment ? ( - <div className="max-w-24"> - <p className="text-xs text-gray-700 bg-gray-50 p-1 rounded truncate" title={revision.comment}> - {revision.comment} - </p> + </TableCell> + <TableCell> + <span className="text-sm"> + {revision.uploadedAt + ? new Date(revision.uploadedAt).toLocaleDateString() + : '-'} + </span> + </TableCell> + <TableCell className="text-center"> + <div className="flex items-center justify-center gap-1"> + <span>{revision.attachments.length}</span> + {/* ✅ 처리된 파일 수 표시 */} + {processStatus === 'partially-processed' && ( + <span className="text-xs text-gray-500"> + ({revision.attachments.filter(att => + att.dolceFilePath && att.dolceFilePath.trim() !== '' + ).length} processed) + </span> + )} </div> - ) : ( - <span className="text-gray-400 text-xs">-</span> - )} - </TableCell> - <TableCell> - <span className="text-sm"> - {revision.uploadedAt - ? new Date(revision.uploadedAt).toLocaleDateString() - : '-'} - </span> - </TableCell> - <TableCell className="text-center"> - {revision.attachments.length} - </TableCell> - <TableCell> - {revision.attachments.length > 0 && ( - <Button - variant="ghost" - size="sm" - onClick={() => onViewRevision(revision)} - className="h-8 px-2" - > - <Eye className="h-4 w-4" /> - </Button> - )} - </TableCell> - </TableRow> - ))} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {/* 보기 버튼 */} + {revision.attachments.length > 0 && ( + <Button + variant="ghost" + size="sm" + onClick={() => onViewRevision(revision)} + className="h-8 px-2" + title="View attachments" + > + <Eye className="h-4 w-4" /> + </Button> + )} + + {/* ✅ 수정 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => onEditRevision(revision)} + className={`h-8 px-2 ${ + canEdit + ? 'text-blue-600 hover:text-blue-700 hover:bg-blue-50' + : 'text-gray-400 cursor-not-allowed' + }`} + disabled={!canEdit} + title={ + canEdit + ? 'Edit revision' + : 'Cannot edit - some files have been processed' + } + > + <Edit className="h-4 w-4" /> + </Button> + </div> + </TableCell> + </TableRow> + ) + })} </TableBody> </Table> </div> @@ -295,21 +381,24 @@ function RevisionTable({ ) } -function AttachmentTable({ - attachments, - onDownloadFile -}: { +function AttachmentTable({ + attachments, + onDownloadFile, + onDeleteFile, // ✅ 삭제 함수 prop 추가 +}: { attachments: AttachmentInfo[] onDownloadFile: (attachment: AttachmentInfo) => void + onDeleteFile: (attachment: AttachmentInfo) => Promise<void> // ✅ 삭제 함수 추가 }) { const { selectedRevisionId, allData, setAllData } = React.useContext(DocumentSelectionContext) - const [addAttachmentDialogOpen, setAddAttachmentDialogOpen] = React.useState(false) // ✅ 추가 - const router = useRouter() // ✅ 추가 + const [addAttachmentDialogOpen, setAddAttachmentDialogOpen] = React.useState(false) + const [deletingFileId, setDeletingFileId] = React.useState<number | null>(null) // ✅ 삭제 중인 파일 ID + const router = useRouter() - // ✅ 선택된 리비전 정보 가져오기 + // 선택된 리비전 정보 가져오기 const selectedRevisionInfo = React.useMemo(() => { if (!selectedRevisionId || !allData) return null - + for (const doc of allData) { if (doc.allStages) { for (const stage of doc.allStages as StageInfo[]) { @@ -321,14 +410,43 @@ function AttachmentTable({ return null }, [selectedRevisionId, allData]) - // ✅ 첨부파일 추가 핸들러 + // 첨부파일 추가 핸들러 const handleAddAttachment = React.useCallback(() => { if (selectedRevisionInfo) { setAddAttachmentDialogOpen(true) } }, [selectedRevisionInfo]) - // ✅ 첨부파일 업로드 성공 핸들러 + // ✅ 삭제 가능 여부 확인 함수 + const canDeleteFile = React.useCallback((attachment: AttachmentInfo) => { + return !attachment.dolceFilePath || attachment.dolceFilePath.trim() === '' + }, []) + + // ✅ 파일 삭제 핸들러 + const handleDeleteFile = React.useCallback(async (attachment: AttachmentInfo) => { + if (!canDeleteFile(attachment)) { + alert('This file cannot be deleted because it has been processed by the system.') + return + } + + const confirmDelete = window.confirm( + `Are you sure you want to delete "${attachment.fileName}"?\nThis action cannot be undone.` + ) + + if (!confirmDelete) return + + try { + setDeletingFileId(attachment.id) + await onDeleteFile(attachment) + } catch (error) { + console.error('Delete file error:', error) + alert(`Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setDeletingFileId(null) + } + }, [canDeleteFile, onDeleteFile]) + + // 첨부파일 업로드 성공 핸들러 const handleAttachmentUploadSuccess = React.useCallback((uploadResult?: any) => { if (!selectedRevisionId || !allData || !uploadResult?.data) { console.log('🔄 Full refresh') @@ -343,6 +461,7 @@ function AttachmentTable({ revisionId: selectedRevisionId, fileName: file.fileName, filePath: file.filePath, + dolceFilePath: null, // ✅ 새 파일은 dolceFilePath가 없음 fileSize: file.fileSize, fileType: file.fileType || null, createdAt: new Date(), @@ -352,10 +471,10 @@ function AttachmentTable({ // allData에서 해당 리비전을 찾아서 첨부파일 추가 const updatedData = allData.map(doc => { const updatedDoc = { ...doc } - + if (updatedDoc.allStages) { const stages = [...updatedDoc.allStages as StageInfo[]] - + for (const stage of stages) { const revisionIndex = stage.revisions.findIndex(r => r.id === selectedRevisionId) if (revisionIndex !== -1) { @@ -369,7 +488,7 @@ function AttachmentTable({ } } } - + return updatedDoc }) @@ -393,7 +512,7 @@ function AttachmentTable({ <CardHeader> <div className="flex items-center justify-between"> <CardTitle className="text-lg">Attachments</CardTitle> - {/* ✅ + 버튼 추가 */} + {/* + 버튼 */} {selectedRevisionId && selectedRevisionInfo && ( <Button onClick={handleAddAttachment} @@ -408,7 +527,7 @@ function AttachmentTable({ </div> </CardHeader> <CardContent> - <Table className="tbl-compact"> + <Table className="tbl-compact"> <TableHeader> <TableRow> <TableHead>File Name</TableHead> @@ -426,7 +545,7 @@ function AttachmentTable({ ? 'Please select a revision' : 'No attached files'} </span> - {/* ✅ 리비전이 선택된 경우 추가 버튼 표시 */} + {/* 리비전이 선택된 경우 추가 버튼 표시 */} {selectedRevisionId && selectedRevisionInfo && ( <Button onClick={handleAddAttachment} @@ -456,17 +575,51 @@ function AttachmentTable({ : `${(file.fileSize / 1024).toFixed(1)}KB` : '-'} </div> + {/* ✅ dolceFilePath 상태 표시 */} + {file.dolceFilePath && file.dolceFilePath.trim() !== '' && ( + <div className="text-xs text-blue-600 font-medium"> + Processed + </div> + )} </div> </TableCell> <TableCell> - <Button - variant="ghost" - size="sm" - onClick={() => onDownloadFile(file)} - className="h-8 px-2" - > - <Download className="h-4 w-4" /> - </Button> + <div className="flex items-center gap-1"> + {/* 다운로드 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => onDownloadFile(file)} + className="h-8 px-2" + title="Download file" + > + <Download className="h-4 w-4" /> + </Button> + + {/* ✅ 삭제 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => handleDeleteFile(file)} + className={`h-8 px-2 ${ + canDeleteFile(file) + ? 'text-red-600 hover:text-red-700 hover:bg-red-50' + : 'text-gray-400 cursor-not-allowed' + }`} + disabled={!canDeleteFile(file) || deletingFileId === file.id} + title={ + canDeleteFile(file) + ? 'Delete file' + : 'Cannot delete processed file' + } + > + {deletingFileId === file.id ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Trash2 className="h-4 w-4" /> + )} + </Button> + </div> </TableCell> </TableRow> )) @@ -476,7 +629,7 @@ function AttachmentTable({ </CardContent> </Card> - {/* ✅ AddAttachmentDialog 추가 */} + {/* AddAttachmentDialog */} {selectedRevisionInfo && ( <AddAttachmentDialog open={addAttachmentDialogOpen} @@ -490,12 +643,10 @@ function AttachmentTable({ ) } -/* ------------------------------------------------------------------------------------------------- - * Derived Sub Tables Wrapper - * -----------------------------------------------------------------------------------------------*/ +// SubTables 컴포넌트 - 중복 정의 제거 및 통합 function SubTables() { const router = useRouter() - const { selectedDocumentId, selectedRevisionId, allData, setAllData } = // ✅ setAllData 추가 + const { selectedDocumentId, selectedRevisionId, setSelectedRevisionId, allData, setAllData } = React.useContext(DocumentSelectionContext) // PDF 뷰어 상태 관리 @@ -509,11 +660,166 @@ function SubTables() { const isCancelled = React.useRef(false) const [newRevisionDialogOpen, setNewRevisionDialogOpen] = React.useState(false) + + // ✅ 리비전 수정 다이얼로그 상태 + const [editRevisionDialogOpen, setEditRevisionDialogOpen] = React.useState(false) + const [editingRevision, setEditingRevision] = React.useState<RevisionInfo | null>(null) const handleNewRevision = React.useCallback(() => { setNewRevisionDialogOpen(true) }, []) + // ✅ 리비전 수정 핸들러 + const handleEditRevision = React.useCallback((revision: RevisionInfo) => { + setEditingRevision(revision) + setEditRevisionDialogOpen(true) + }, []) + + // ✅ 리비전 수정 성공 핸들러 + const handleRevisionEditSuccess = React.useCallback((action: 'update' | 'delete', result?: any) => { + if (!allData || !editingRevision) { + // fallback: 전체 새로고침 + setTimeout(() => router.refresh(), 500) + return + } + + try { + if (action === 'delete') { + // 리비전 삭제: allData에서 해당 리비전 제거 + const updatedData = allData.map(doc => { + const updatedDoc = { ...doc } + + if (updatedDoc.allStages) { + const stages = [...updatedDoc.allStages as StageInfo[]] + + for (const stage of stages) { + const revisionIndex = stage.revisions.findIndex(r => r.id === editingRevision.id) + if (revisionIndex !== -1) { + // 해당 리비전 제거 + stage.revisions.splice(revisionIndex, 1) + updatedDoc.allStages = stages + break + } + } + } + + return updatedDoc + }) + + setAllData(updatedData) + + // 삭제된 리비전이 선택되어 있었으면 선택 해제 + if (selectedRevisionId === editingRevision.id) { + setSelectedRevisionId(null) + } + + console.log('✅ Revision deleted and state updated') + + } else if (action === 'update') { + // 리비전 업데이트: allData에서 해당 리비전 정보 수정 + const updatedData = allData.map(doc => { + const updatedDoc = { ...doc } + + if (updatedDoc.allStages) { + const stages = [...updatedDoc.allStages as StageInfo[]] + + for (const stage of stages) { + const revisionIndex = stage.revisions.findIndex(r => r.id === editingRevision.id) + if (revisionIndex !== -1) { + // 해당 리비전 업데이트 + stage.revisions[revisionIndex] = { + ...stage.revisions[revisionIndex], + comment: result?.updatedRevision?.comment || stage.revisions[revisionIndex].comment, + usage: result?.updatedRevision?.usage || stage.revisions[revisionIndex].usage, + usageType: result?.updatedRevision?.usageType || stage.revisions[revisionIndex].usageType, + updatedAt: new Date(), + } + updatedDoc.allStages = stages + break + } + } + } + + return updatedDoc + }) + + setAllData(updatedData) + console.log('✅ Revision updated and state updated') + } + + // 약간의 지연 후 서버 데이터 새로고침 + setTimeout(() => { + router.refresh() + }, 1000) + + } catch (error) { + console.error('❌ Revision edit state update failed:', error) + // 실패 시 전체 새로고침 + setTimeout(() => router.refresh(), 500) + } + }, [allData, editingRevision, setAllData, selectedRevisionId, setSelectedRevisionId, router]) + + // 파일 삭제 함수 + const handleDeleteFile = React.useCallback(async (attachment: AttachmentInfo) => { + try { + const response = await fetch(`/api/attachment-delete`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + attachmentId: attachment.id, + revisionId: attachment.revisionId, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to delete file.') + } + + // 성공시 로컬 상태 업데이트 + if (allData && selectedRevisionId) { + const updatedData = allData.map(doc => { + const updatedDoc = { ...doc } + + if (updatedDoc.allStages) { + const stages = [...updatedDoc.allStages as StageInfo[]] + + for (const stage of stages) { + const revisionIndex = stage.revisions.findIndex(r => r.id === selectedRevisionId) + if (revisionIndex !== -1) { + // 해당 리비전에서 첨부파일 제거 + stage.revisions[revisionIndex] = { + ...stage.revisions[revisionIndex], + attachments: stage.revisions[revisionIndex].attachments.filter( + att => att.id !== attachment.id + ) + } + updatedDoc.allStages = stages + break + } + } + } + + return updatedDoc + }) + + setAllData(updatedData) + console.log('✅ File deleted and state updated') + } + + // 약간의 지연 후 서버 데이터 새로고침 + setTimeout(() => { + router.refresh() + }, 1000) + + } catch (error) { + console.error('Delete file error:', error) + throw error // AttachmentTable에서 에러 핸들링 + } + }, [allData, selectedRevisionId, setAllData, router]) + const handleRevisionUploadSuccess = React.useCallback(async (uploadResult?: any) => { if (!selectedDocumentId || !allData || !uploadResult?.data) { // fallback: 전체 새로고침 @@ -530,7 +836,7 @@ function SubTables() { uploaderType: "vendor", uploaderId: null, uploaderName: uploadResult.data.uploaderName || null, - comment: uploadResult.data.comment || null, // ✅ comment도 포함 + comment: uploadResult.data.comment || null, usage: uploadResult.data.usage, usageType: uploadResult.data.usageType || null, revisionStatus: "UPLOADED", @@ -550,6 +856,7 @@ function SubTables() { revisionId: uploadResult.data.revisionId, fileName: file.fileName, filePath: file.filePath, + dolceFilePath: null, fileSize: file.fileSize, fileType: file.fileType || null, createdAt: new Date(), @@ -561,26 +868,26 @@ function SubTables() { const updatedData = allData.map(doc => { if (doc.documentId === selectedDocumentId) { const updatedDoc = { ...doc } - + // allStages가 있으면 해당 stage에 새 revision 추가 if (updatedDoc.allStages) { - const stages = [...updatedDoc.allStages as StageInfo[]] // ✅ 배열 복사 - const targetStage = stages.find(stage => - stage.stageName === uploadResult.data.stage || + const stages = [...updatedDoc.allStages as StageInfo[]] + const targetStage = stages.find(stage => + stage.stageName === uploadResult.data.stage || stage.stageName === uploadResult.data.usage ) - + if (targetStage) { // 기존 revision과 중복 체크 (같은 revision, usage, usageType) - const isDuplicate = targetStage.revisions.some(rev => + const isDuplicate = targetStage.revisions.some(rev => rev.revision === newRevision.revision && rev.usage === newRevision.usage && rev.usageType === newRevision.usageType ) - + if (!isDuplicate) { targetStage.revisions = [newRevision, ...targetStage.revisions] - updatedDoc.allStages = stages // ✅ 업데이트된 stages 할당 + updatedDoc.allStages = stages } } else { // 첫 번째 stage에 추가 (fallback) @@ -590,7 +897,7 @@ function SubTables() { } } } - + return updatedDoc } return doc @@ -598,9 +905,9 @@ function SubTables() { // State 업데이트 setAllData(updatedData) - + console.log('✅ RevisionTable data update complete') - + } catch (error) { console.error('❌ RevisionTable update failed:', error) // 실패 시 전체 새로고침 @@ -611,7 +918,7 @@ function SubTables() { router.refresh() // 서버 컴포넌트 재렌더링으로 최신 데이터 가져오기 }, 1500) // 1.5초 후 새로고침 (사용자가 업데이트를 확인할 시간) - }, [selectedDocumentId, allData, setAllData]) + }, [selectedDocumentId, allData, setAllData, router]) const selectedDocument = React.useMemo(() => { if (!selectedDocumentId || !allData) return null @@ -738,7 +1045,7 @@ function SubTables() { } setTimeout(() => cleanupHtmlStyle(), 500) } - }, [viewerOpen, cleanupHtmlStyle]) + }, [viewerOpen, cleanupHtmlStyle, instance]) // 문서 로드 React.useEffect(() => { @@ -803,14 +1110,16 @@ function SubTables() { return ( <> <div className="flex gap-4"> - <RevisionTable - revisions={allRevisions} + <RevisionTable + revisions={allRevisions} onViewRevision={handleViewRevision} onNewRevision={handleNewRevision} + onEditRevision={handleEditRevision} // ✅ 수정 함수 전달 /> - <AttachmentTable - attachments={selectedRevisionData?.attachments || []} + <AttachmentTable + attachments={selectedRevisionData?.attachments || []} onDownloadFile={handleDownloadFile} + onDeleteFile={handleDeleteFile} /> </div> @@ -847,6 +1156,14 @@ function SubTables() { drawingKind={selectedDocument.drawingKind || 'B4'} onSuccess={handleRevisionUploadSuccess} /> + + {/* ✅ 리비전 수정 다이얼로그 */} + <EditRevisionDialog + open={editRevisionDialogOpen} + onOpenChange={setEditRevisionDialogOpen} + revision={editingRevision} + onSuccess={handleRevisionEditSuccess} + /> </> ) } @@ -970,15 +1287,15 @@ export function UserVendorDocumentDisplay({ return ( <DocumentSelectionContext.Provider value={ctx}> <div className="space-y-4"> - <Card> - <CardContent className="flex items-center justify-center py-8"> - <SimplifiedDocumentsTable - allPromises={allPromises} - onDataLoaded={setAllData} - onDocumentSelect={handleDocumentSelect} - /> - </CardContent> - </Card> + <Card> + <CardContent className="flex items-center justify-center py-8"> + <SimplifiedDocumentsTable + allPromises={allPromises} + onDataLoaded={setAllData} + onDocumentSelect={handleDocumentSelect} + /> + </CardContent> + </Card> <SelectedDocumentInfo /> <SubTables /> diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index c94d435e..2dcde518 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -386,7 +386,7 @@ export default function JoinForm() { address: "", email: "", phone: "", - country: "KR", // 기본값을 한국으로 설정 + country: "", // 기본값을 빈 문자열로 설정하여 계정 데이터에서 가져오도록 함 website: "", representativeName: "", representativeBirth: "", @@ -545,7 +545,11 @@ export default function JoinForm() { {currentStep === 3 && ( <VendorStep - data={vendorData} + data={{ + ...vendorData, + // 업체 국가가 설정되지 않았다면 계정의 국가를 사용 + country: vendorData.country || accountData.country, + }} onChange={setVendorData} onBack={() => setCurrentStep(2)} onComplete={() => { @@ -897,7 +901,7 @@ function CompleteVendorForm({ if (data.country !== "KR") { bankAccountFiles.forEach(file => { - formData.append('bankAccount', file); + formData.append('bankAccountCopy', file); }); } @@ -1009,7 +1013,7 @@ function CompleteVendorForm({ disabled={isSubmitting} /> <p className="text-xs text-gray-500 mt-1"> - {data.country === "KR" + {(data.country || accountData.country) === "KR" ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요." : "해외 업체의 경우 영문 회사명을 입력하세요."} </p> diff --git a/components/vendor-data/vendor-data-container copy.tsx b/components/vendor-data/vendor-data-container copy.tsx new file mode 100644 index 00000000..5b4967b5 --- /dev/null +++ b/components/vendor-data/vendor-data-container copy.tsx @@ -0,0 +1,464 @@ +"use client" + +import * as React from "react" +import { TooltipProvider } from "@/components/ui/tooltip" +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" +import { cn } from "@/lib/utils" +import { ProjectSwitcher } from "./project-swicher" +import { Sidebar } from "./sidebar" +import { usePathname, useRouter, useSearchParams, useParams } from "next/navigation" +import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" +import { Separator } from "@/components/ui/separator" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Button } from "@/components/ui/button" +import { FormInput } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { selectedModeAtom } from '@/atoms' +import { useAtom } from 'jotai' + +interface PackageData { + itemId: number + itemName: string +} + +interface ContractData { + contractId: number + contractName: string + packages: PackageData[] +} + +interface ProjectData { + projectId: number + projectCode: string + projectName: string + projectType: string + contracts: ContractData[] +} + +interface VendorDataContainerProps { + projects: ProjectData[] + defaultLayout?: number[] + defaultCollapsed?: boolean + navCollapsedSize: number + children: React.ReactNode +} + +function getTagIdFromPathname(path: string | null): number | null { + if (!path) return null; + + // 태그 패턴 검사 (/tag/123) + const tagMatch = path.match(/\/tag\/(\d+)/) + if (tagMatch) return parseInt(tagMatch[1], 10) + + // 폼 패턴 검사 (/form/123/...) + const formMatch = path.match(/\/form\/(\d+)/) + if (formMatch) return parseInt(formMatch[1], 10) + + return null +} + +export function VendorDataContainer({ + projects, + defaultLayout = [20, 80], + defaultCollapsed = false, + navCollapsedSize, + children +}: VendorDataContainerProps) { + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + const params = useParams() + const currentLng = params?.lng as string || 'en' + + const tagIdNumber = getTagIdFromPathname(pathname) + + // 기본 상태 + const [selectedProjectId, setSelectedProjectId] = React.useState(projects[0]?.projectId || 0) + const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) + const [selectedContractId, setSelectedContractId] = React.useState( + projects[0]?.contracts[0]?.contractId || 0 + ) + const [selectedPackageId, setSelectedPackageId] = React.useState<number | null>(null) + const [formList, setFormList] = React.useState<FormInfo[]>([]) + const [selectedFormCode, setSelectedFormCode] = React.useState<string | null>(null) + const [isLoadingForms, setIsLoadingForms] = React.useState(false) + + // 현재 선택된 프로젝트/계약/패키지 + const currentProject = projects.find((p) => p.projectId === selectedProjectId) ?? projects[0] + const currentContract = currentProject?.contracts.find((c) => c.contractId === selectedContractId) + ?? currentProject?.contracts[0] + + // 프로젝트 타입 확인 - ship인 경우 항상 ENG 모드 + const isShipProject = currentProject?.projectType === "ship" + + const [selectedMode, setSelectedMode] = useAtom(selectedModeAtom) + + // URL에서 모드 추출 (ship 프로젝트면 무조건 ENG로, 아니면 URL 또는 기본값) + const modeFromUrl = searchParams?.get('mode') + const initialMode = isShipProject + ? "ENG" + : (modeFromUrl === "ENG" || modeFromUrl === "IM") ? modeFromUrl : "IM" + + // 모드 초기화 (기존의 useState 초기값 대신) + React.useEffect(() => { + setSelectedMode(initialMode as "IM" | "ENG") + }, [initialMode, setSelectedMode]) + + const isTagOrFormRoute = pathname ? (pathname.includes("/tag/") || pathname.includes("/form/")) : false + const currentPackageName = isTagOrFormRoute + ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" + : "None" + + // 폼 목록에서 고유한 폼 이름만 추출 + const formNames = React.useMemo(() => { + return [...new Set(formList.map((form) => form.formName))] + }, [formList]) + + // URL에서 현재 폼 코드 추출 + const getCurrentFormCode = (path: string): string | null => { + const segments = path.split("/").filter(Boolean) + const formIndex = segments.indexOf("form") + if (formIndex !== -1 && segments[formIndex + 2]) { + return segments[formIndex + 2] + } + return null + } + + const currentFormCode = React.useMemo(() => { + return pathname ? getCurrentFormCode(pathname) : null + }, [pathname]) + + const createUrlWithLng = (path: string, queryParams?: Record<string, string>) => { + // 기본 경로에 언어 파라미터 포함 + const basePath = `/${currentLng}${path.startsWith('/') ? path : '/' + path}` + + if (queryParams) { + const searchParams = new URLSearchParams(queryParams) + return `${basePath}?${searchParams.toString()}` + } + + return basePath + } + + // URL에서 모드가 변경되면 상태도 업데이트 (ship 프로젝트가 아닐 때만) + React.useEffect(() => { + if (!isShipProject) { + const modeFromUrl = searchParams?.get('mode') + if (modeFromUrl === "ENG" || modeFromUrl === "IM") { + setSelectedMode(modeFromUrl) + } + } + }, [searchParams, isShipProject]) + + // 프로젝트 타입이 변경될 때 모드 업데이트 + React.useEffect(() => { + if (isShipProject) { + setSelectedMode("ENG") + + // URL 모드 파라미터도 업데이트 + const url = new URL(window.location.href); + url.searchParams.set('mode', 'ENG'); + router.replace(url.pathname + url.search); + } + }, [isShipProject, router]) + + // (1) 새로고침 시 URL 파라미터(tagIdNumber) → selectedPackageId 세팅 + React.useEffect(() => { + if (!currentContract) return + + if (tagIdNumber) { + setSelectedPackageId(tagIdNumber) + } else { + // tagIdNumber가 없으면, 현재 계약의 첫 번째 패키지로 + if (currentContract.packages?.length) { + setSelectedPackageId(currentContract.packages[0].itemId) + } else { + setSelectedPackageId(null) + } + } + }, [tagIdNumber, currentContract]) + + // (2) 프로젝트 변경 시 계약 초기화 + React.useEffect(() => { + if (currentProject?.contracts.length) { + setSelectedContractId(currentProject.contracts[0].contractId) + } else { + setSelectedContractId(0) + } + }, [currentProject]) + + // (3) 패키지 ID와 모드가 변경될 때마다 폼 로딩 + React.useEffect(() => { + const packageId = getTagIdFromPathname(pathname) + + if (packageId) { + setSelectedPackageId(packageId) + + // URL에서 패키지 ID를 얻었을 때 즉시 폼 로드 + loadFormsList(packageId, selectedMode); + } else if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; + setSelectedPackageId(firstPackageId); + loadFormsList(firstPackageId, selectedMode); + } + }, [pathname, currentContract, selectedMode]) + + // 모드에 따른 폼 로드 함수 + const loadFormsList = async (packageId: number, mode: "IM" | "ENG") => { + if (!packageId) return; + + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(packageId, mode); + setFormList(result.forms || []); + } catch (error) { + console.error(`폼 로딩 오류 (${mode} 모드):`, error); + setFormList([]); + } finally { + setIsLoadingForms(false); + } + }; + + // 핸들러들 + function handleSelectContract(projId: number, cId: number) { + setSelectedProjectId(projId) + setSelectedContractId(cId) + } + + function handleSelectPackage(itemId: number) { + setSelectedPackageId(itemId) + } + + function handleSelectForm(formName: string) { + const form = formList.find((f) => f.formName === formName) + if (form) { + setSelectedFormCode(form.formCode) + } + } + + // 모드 변경 핸들러 +// 모드 변경 핸들러 +const handleModeChange = async (mode: "IM" | "ENG") => { + // ship 프로젝트인 경우 모드 변경 금지 + if (isShipProject && mode !== "ENG") return; + + setSelectedMode(mode); + + // 모드가 변경될 때 자동 네비게이션 + if (currentContract?.packages?.length) { + const firstPackageId = currentContract.packages[0].itemId; + + if (mode === "IM") { + // IM 모드: 첫 번째 패키지로 이동 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); + router.push(`/${baseSegments}/tag/${firstPackageId}?mode=${mode}`); + } else { + // ENG 모드: 폼 목록을 먼저 로드 + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(firstPackageId, mode); + setFormList(result.forms || []); + + // 폼이 있으면 첫 번째 폼으로 이동 + if (result.forms && result.forms.length > 0) { + const firstForm = result.forms[0]; + setSelectedFormCode(firstForm.formCode); + + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); + router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + } else { + // 폼이 없으면 모드만 변경 + const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); + router.push(`/${baseSegments}/form/0/0/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + } + } catch (error) { + console.error(`폼 로딩 오류 (${mode} 모드):`, error); + // 오류 발생 시 모드만 변경 + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); + } finally { + setIsLoadingForms(false); + } + } + } else { + // 패키지가 없는 경우, 모드만 변경 + const url = new URL(window.location.href); + url.searchParams.set('mode', mode); + router.replace(url.pathname + url.search); + } +}; + + return ( + <TooltipProvider delayDuration={0}> + <ResizablePanelGroup direction="horizontal" className="h-full"> + <ResizablePanel + defaultSize={defaultLayout[0]} + collapsedSize={navCollapsedSize} + collapsible + minSize={15} + maxSize={25} + onCollapse={() => setIsCollapsed(true)} + onResize={() => setIsCollapsed(false)} + className={cn(isCollapsed && "min-w-[50px] transition-all duration-300 ease-in-out")} + > + <div + className={cn( + "flex h-[52px] items-center justify-center gap-2", + isCollapsed ? "h-[52px]" : "px-2" + )} + > + <ProjectSwitcher + isCollapsed={isCollapsed} + projects={projects} + selectedContractId={selectedContractId} + onSelectContract={handleSelectContract} + /> + </div> + <Separator /> + + {!isCollapsed ? ( + isShipProject ? ( + // 프로젝트 타입이 ship인 경우: 탭 없이 ENG 모드 사이드바만 바로 표시 + <div className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedProjectId={selectedProjectId} + selectedContractId={selectedContractId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="ENG" + className="hidden lg:block" + /> + </div> + ) : ( + // 프로젝트 타입이 ship이 아닌 경우: 기존 탭 UI 표시 + <Tabs + defaultValue={initialMode} + value={selectedMode} + onValueChange={(value) => handleModeChange(value as "IM" | "ENG")} + className="w-full" + > + <TabsList className="w-full"> + <TabsTrigger value="IM" className="flex-1">H/O</TabsTrigger> + <TabsTrigger value="ENG" className="flex-1">ENG</TabsTrigger> + </TabsList> + + <TabsContent value="IM" className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedContractId={selectedContractId} + selectedProjectId={selectedProjectId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="IM" + className="hidden lg:block" + /> + </TabsContent> + + <TabsContent value="ENG" className="mt-0"> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedContractId={selectedContractId} + selectedProjectId={selectedProjectId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode="ENG" + className="hidden lg:block" + /> + </TabsContent> + </Tabs> + ) + ) : ( + // 접혀있을 때 UI + <> + {!isShipProject && ( + // ship 프로젝트가 아닐 때만 모드 선택 버튼 표시 + <div className="flex justify-center space-x-1 my-2"> + <Button + variant={selectedMode === "IM" ? "default" : "ghost"} + size="sm" + className="h-8 px-2" + onClick={() => handleModeChange("IM")} + > + H/O + </Button> + <Button + variant={selectedMode === "ENG" ? "default" : "ghost"} + size="sm" + className="h-8 px-2" + onClick={() => handleModeChange("ENG")} + > + ENG + </Button> + </div> + )} + + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + selectedProjectId={selectedProjectId} + selectedContractId={selectedContractId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + mode={isShipProject ? "ENG" : selectedMode} + className="hidden lg:block" + /> + </> + )} + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel defaultSize={defaultLayout[1]} minSize={40}> + <div className="p-4 h-full overflow-auto flex flex-col"> + <div className="flex items-center justify-between mb-4"> + <h2 className="text-lg font-bold"> + {isShipProject || selectedMode === "ENG" + ? "Engineering Mode" + : `Package: ${currentPackageName}`} + </h2> + </div> + {children} + </div> + </ResizablePanel> + </ResizablePanelGroup> + </TooltipProvider> + ) +}
\ No newline at end of file diff --git a/components/vendor-data/vendor-data-container.tsx b/components/vendor-data/vendor-data-container.tsx index 47397c72..a549594c 100644 --- a/components/vendor-data/vendor-data-container.tsx +++ b/components/vendor-data/vendor-data-container.tsx @@ -6,7 +6,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { cn } from "@/lib/utils" import { ProjectSwitcher } from "./project-swicher" import { Sidebar } from "./sidebar" -import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { usePathname, useRouter, useSearchParams, useParams } from "next/navigation" import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" import { Separator } from "@/components/ui/separator" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" @@ -68,6 +68,8 @@ export function VendorDataContainer({ const pathname = usePathname() const router = useRouter() const searchParams = useSearchParams() + const params = useParams() + const currentLng = params?.lng as string || 'en' const tagIdNumber = getTagIdFromPathname(pathname) @@ -127,6 +129,7 @@ export function VendorDataContainer({ return pathname ? getCurrentFormCode(pathname) : null }, [pathname]) + // URL에서 모드가 변경되면 상태도 업데이트 (ship 프로젝트가 아닐 때만) React.useEffect(() => { if (!isShipProject) { @@ -238,7 +241,7 @@ const handleModeChange = async (mode: "IM" | "ENG") => { if (mode === "IM") { // IM 모드: 첫 번째 패키지로 이동 const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); - router.push(`/${baseSegments}/tag/${firstPackageId}?mode=${mode}`); + router.push(`/${currentLng}/${baseSegments}/tag/${firstPackageId}?mode=${mode}`); } else { // ENG 모드: 폼 목록을 먼저 로드 setIsLoadingForms(true); @@ -252,11 +255,11 @@ const handleModeChange = async (mode: "IM" | "ENG") => { setSelectedFormCode(firstForm.formCode); const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); - router.push(`/${baseSegments}/form/0/${firstForm.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + router.push(`/${currentLng}/${baseSegments}/form/0/${firstForm.formCode}/${selectedProjectId}/${selectedContractId}?mode=${mode}`); } else { // 폼이 없으면 모드만 변경 const baseSegments = pathname?.split("/").filter(Boolean).slice(0, pathname.split("/").filter(Boolean).indexOf("vendor-data") + 1).join("/"); - router.push(`/${baseSegments}/form/0/0/${selectedProjectId}/${selectedContractId}?mode=${mode}`); + router.push(`/${currentLng}/${baseSegments}/form/0/0/${selectedProjectId}/${selectedContractId}?mode=${mode}`); } } catch (error) { console.error(`폼 로딩 오류 (${mode} 모드):`, error); diff --git a/components/vendor-regular-registrations/document-status-dialog.tsx b/components/vendor-regular-registrations/document-status-dialog.tsx index f8e2e1cd..db3defe6 100644 --- a/components/vendor-regular-registrations/document-status-dialog.tsx +++ b/components/vendor-regular-registrations/document-status-dialog.tsx @@ -8,13 +8,12 @@ import { } 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 { FileText, Download, CheckCircle, XCircle, Clock, RefreshCw } 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";
@@ -22,6 +21,7 @@ interface DocumentStatusDialogProps { open: boolean;
onOpenChange: (open: boolean) => void;
registration: VendorRegularRegistration | null;
+ onRefresh?: () => void;
}
const StatusIcon = ({ status }: { status: string | boolean }) => {
@@ -68,63 +68,87 @@ export function DocumentStatusDialog({ open,
onOpenChange,
registration,
+ onRefresh,
}: DocumentStatusDialogProps) {
if (!registration) return null;
+ // 디버깅: registration 데이터 확인
+ console.log(`📋 DocumentStatusDialog - Partners 등록 데이터:`, {
+ companyName: registration.companyName,
+ businessNumber: registration.businessNumber,
+ representative: registration.representative,
+ safetyQualificationContent: registration.safetyQualificationContent,
+ basicContractsLength: registration.basicContracts?.length || 0,
+ additionalInfo: registration.additionalInfo
+ });
+
// 파일 다운로드 핸들러
- 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 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;
- }
+ // 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;
+ // // filePath와 fileName 추출
+ // const filePath = file.filePath || file.path;
+ // const fileName = file.originalFileName || file.fileName || file.name;
- if (!filePath || !fileName) {
- toast.error("파일 정보가 올바르지 않습니다.");
- return;
- }
+ // if (!filePath || !fileName) {
+ // toast.error("파일 정보가 올바르지 않습니다.");
+ // return;
+ // }
- console.log(`📥 파일 다운로드 시작:`, { filePath, fileName, docKey });
+ // 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 });
- }
- });
+ // // 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("파일 다운로드 중 오류가 발생했습니다.");
- }
- };
+ // 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 className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 문서/자료 접수 현황 - {registration.companyName}
+ </div>
+ {onRefresh && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ className="flex items-center gap-2"
+ >
+ <RefreshCw className="w-4 h-4" />
+ 새로고침
+ </Button>
+ )}
</DialogTitle>
</DialogHeader>
@@ -160,12 +184,18 @@ export function DocumentStatusDialog({ <div>상태</div>
<div>제출일자</div>
<div>액션</div>
- </div>
+ </div>
{documentStatusColumns.map((doc) => {
const isSubmitted = registration.documentSubmissions[
doc.key as keyof typeof registration.documentSubmissions
] as boolean;
+ // 내자인 경우 통장사본은 표시하지 않음
+ const isForeign = registration.country !== 'KR';
+ if (doc.key === 'bankCopy' && !isForeign) {
+ return null;
+ }
+
return (
<div
key={doc.key}
@@ -174,6 +204,9 @@ export function DocumentStatusDialog({ <div className="flex items-center gap-2">
<StatusIcon status={isSubmitted} />
{doc.label}
+ {doc.key === 'bankCopy' && isForeign && (
+ <span className="text-xs text-blue-600">(외자 필수)</span>
+ )}
</div>
<div>
<StatusBadge status={isSubmitted} />
@@ -182,7 +215,7 @@ export function DocumentStatusDialog({ {isSubmitted ? "2024.01.01" : "-"}
</div>
<div>
- {isSubmitted && (
+ {/* {isSubmitted && (
<Button
size="sm"
variant="outline"
@@ -191,7 +224,7 @@ export function DocumentStatusDialog({ <Download className="w-4 h-4 mr-1" />
다운로드
</Button>
- )}
+ )} */}
</div>
</div>
);
@@ -211,37 +244,63 @@ export function DocumentStatusDialog({ <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>
- )}
+ {!registration.basicContracts || registration.basicContracts.length === 0 ? (
+ <div className="p-4 border-t text-center text-gray-500">
+ 요청된 기본계약이 없습니다.
+ </div>
+ ) : (
+ registration.basicContracts.map((contract, index) => {
+ const isCompleted = contract.status === "COMPLETED";
+
+ return (
+ <div
+ key={`${contract.templateId}-${index}`}
+ className="grid grid-cols-4 gap-4 p-4 border-t items-center"
+ >
+ <div className="flex items-center gap-2">
+ <StatusIcon status={isCompleted} />
+ {contract.templateName || "템플릿명 없음"}
+ </div>
+ <div>
+ <StatusBadge status={isCompleted} />
+ </div>
+ <div className="text-sm text-gray-600">
+ {isCompleted && contract.createdAt
+ ? new Intl.DateTimeFormat('ko-KR').format(new Date(contract.createdAt))
+ : "-"
+ }
+ </div>
+ <div>
+ {isCompleted && (
+ <Button size="sm" variant="outline">
+ <Download className="w-4 h-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
</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.safetyQualificationContent} />
+ <span>안전적격성 평가</span>
+ </div>
+ <StatusBadge status={!!registration.safetyQualificationContent} />
+ </div>
+ {registration.safetyQualificationContent && (
+ <div className="mt-3 p-3 bg-gray-50 rounded">
+ <p className="text-sm">{registration.safetyQualificationContent}</p>
+ </div>
+ )}
</div>
</div>
diff --git a/components/vendor-regular-registrations/registration-request-dialog.tsx b/components/vendor-regular-registrations/registration-request-dialog.tsx new file mode 100644 index 00000000..2a79189a --- /dev/null +++ b/components/vendor-regular-registrations/registration-request-dialog.tsx @@ -0,0 +1,691 @@ +"use client"; + +import { useState, useEffect } from "react"; +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 { Checkbox } from "@/components/ui/checkbox"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { FileText, Building2, User, Phone, Mail } from "lucide-react"; + +import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig"; +import { fetchRegistrationRequestData } from "@/lib/vendor-regular-registrations/service"; + +interface RegistrationRequestDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + registration: VendorRegularRegistration | null; + onSubmit?: (data: RegistrationRequestData) => void; +} + +export interface RegistrationRequestData { + // 업체정보 + companyNameKor: string; + companyNameEng: string; + businessNumber: string; + corporateNumber: string; + majorItems: string; + establishmentDate: string; + + // 소재지 + headOfficeAddress: string; + headOfficePhone: string; + factoryAddress: string; + factoryPhone: string; + salesOfficeAddress: string; + salesOfficePhone: string; + + // 대표자 정보 + representativeNameKor: string; + representativeNameEng: string; + representativeContact: string; + representativeBirthDate: string; + representativeEmail: string; + isWorkingAtCompany: boolean; + isInternalPartner: boolean; + + // 대표자 경력 + representativeCareer: Array<{ + date: string; + content: string; + }>; + + // 업무담당자 + businessContacts: { + sales: { name: string; position: string; department: string; responsibility: string; email: string; }; + design: { name: string; position: string; department: string; responsibility: string; email: string; }; + delivery: { name: string; position: string; department: string; responsibility: string; email: string; }; + quality: { name: string; position: string; department: string; responsibility: string; email: string; }; + taxInvoice: { name: string; position: string; department: string; responsibility: string; email: string; }; + }; +} + +export function RegistrationRequestDialog({ + open, + onOpenChange, + registration, + onSubmit, +}: RegistrationRequestDialogProps) { + const [loading, setLoading] = useState(false); + const [dataLoading, setDataLoading] = useState(false); + const [formData, setFormData] = useState<RegistrationRequestData>({ + // 업체정보 (기본값 설정) + companyNameKor: "", + companyNameEng: "", + businessNumber: "", + corporateNumber: "", + majorItems: "", + establishmentDate: "", + + // 소재지 + headOfficeAddress: "", + headOfficePhone: "", + factoryAddress: "", + factoryPhone: "", + salesOfficeAddress: "", + salesOfficePhone: "", + + // 대표자 정보 + representativeNameKor: "", + representativeNameEng: "", + representativeContact: "", + representativeBirthDate: "", + representativeEmail: "", + isWorkingAtCompany: false, + isInternalPartner: false, + + // 대표자 경력 + representativeCareer: [{ date: "", content: "" }], + + // 업무담당자 + businessContacts: { + sales: { name: "", position: "", department: "", responsibility: "", email: "" }, + design: { name: "", position: "", department: "", responsibility: "", email: "" }, + delivery: { name: "", position: "", department: "", responsibility: "", email: "" }, + quality: { name: "", position: "", department: "", responsibility: "", email: "" }, + taxInvoice: { name: "", position: "", department: "", responsibility: "", email: "" }, + }, + }); + + // 기존 데이터 로드 및 자동 입력 + useEffect(() => { + if (open && registration?.id) { + loadExistingData(); + } + }, [open, registration?.id]); + + const loadExistingData = async () => { + if (!registration?.id) return; + + setDataLoading(true); + try { + const result = await fetchRegistrationRequestData(registration.id); + if (result.success && result.data) { + const { vendor, businessContacts, additionalInfo } = result.data; + + // 업무담당자 데이터 변환 + const contactsMap = { + sales: { name: "", position: "", department: "", responsibility: "", email: "" }, + design: { name: "", position: "", department: "", responsibility: "", email: "" }, + delivery: { name: "", position: "", department: "", responsibility: "", email: "" }, + quality: { name: "", position: "", department: "", responsibility: "", email: "" }, + taxInvoice: { name: "", position: "", department: "", responsibility: "", email: "" }, + }; + + console.log("🔍 업무담당자 데이터 매핑:", { + businessContacts: businessContacts.map(c => ({ + contactType: c.contactType, + contactName: c.contactName + })), + contactsMapKeys: Object.keys(contactsMap) + }); + + businessContacts.forEach(contact => { + let mappedType = contact.contactType; + + // DB의 snake_case를 camelCase로 변환 + if (contact.contactType === 'tax_invoice') { + mappedType = 'taxInvoice'; + } + + if (mappedType in contactsMap) { + contactsMap[mappedType as keyof typeof contactsMap] = { + name: contact.contactName || "", + position: contact.position || "", + department: contact.department || "", + responsibility: contact.responsibility || "", + email: contact.email || "", + }; + + console.log(`✅ 매핑 성공: ${contact.contactType} -> ${mappedType}`, { + name: contact.contactName, + position: contact.position + }); + } else { + console.warn(`❌ 매핑 실패: ${contact.contactType} -> ${mappedType} not found in contactsMap`); + } + }); + + // 날짜 포맷팅 함수 + const formatDate = (date: Date | string | null) => { + if (!date) return ""; + try { + const d = new Date(date); + // Invalid Date 체크 + if (isNaN(d.getTime())) return ""; + return d.toISOString().split('T')[0]; // YYYY-MM-DD 형식 + } catch (error) { + console.warn("날짜 포맷팅 오류:", date, error); + return ""; + } + }; + + // 폼 데이터 업데이트 + setFormData({ + // 업체정보 + companyNameKor: vendor.vendorName || "", + companyNameEng: "", // TODO: 영문명이 있다면 추가 + businessNumber: vendor.taxId || "", + corporateNumber: vendor.corporateRegistrationNumber || "", + majorItems: registration.majorItems || "", + establishmentDate: "", // vendors 테이블에 establishmentDate 필드가 없음 + + // 소재지 (vendors 테이블에는 address, phone만 있음) + headOfficeAddress: vendor.address || "", + headOfficePhone: vendor.phone || "", + factoryAddress: "", // vendors 테이블에 공장주소 필드가 없음 + factoryPhone: "", // vendors 테이블에 공장전화 필드가 없음 + salesOfficeAddress: "", // vendors 테이블에 영업소주소 필드가 없음 + salesOfficePhone: "", // vendors 테이블에 영업소전화 필드가 없음 + + // 대표자 정보 + representativeNameKor: vendor.representativeName || "", + representativeNameEng: "", // vendors 테이블에 영문명 필드가 없음 + representativeContact: vendor.representativePhone || "", + representativeBirthDate: vendor.representativeBirth || "", // 문자열로 직접 사용 + representativeEmail: vendor.representativeEmail || "", + isWorkingAtCompany: vendor.representativeWorkExpirence || false, + isInternalPartner: false, // vendors 테이블에 해당 필드가 없음 + + // 대표자 경력 (기본값 유지) + representativeCareer: [{ date: "", content: "" }], + + // 업무담당자 + businessContacts: contactsMap, + }); + + console.log("✅ 기존 데이터 로드 완료:", { + vendor: vendor.vendorName, + contactsCount: businessContacts.length, + hasAdditionalInfo: !!additionalInfo, + vendorData: { + representativeBirth: vendor.representativeBirth, + representativeBirthType: typeof vendor.representativeBirth, + createdAt: vendor.createdAt, + createdAtType: typeof vendor.createdAt + } + }); + + } else { + toast.error(result.error || "데이터 로드 실패"); + } + } catch (error) { + console.error("데이터 로드 오류:", error); + toast.error("데이터 로드 중 오류가 발생했습니다."); + } finally { + setDataLoading(false); + } + }; + + if (!registration) return null; + + const handleInputChange = (field: string, value: any) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + }; + + const handleBusinessContactChange = (type: keyof typeof formData.businessContacts, field: 'name' | 'position' | 'department' | 'responsibility' | 'email', value: string) => { + setFormData(prev => ({ + ...prev, + businessContacts: { + ...prev.businessContacts, + [type]: { + ...prev.businessContacts[type], + [field]: value + } + } + })); + }; + + const handleCareerChange = (index: number, field: 'date' | 'content', value: string) => { + setFormData(prev => ({ + ...prev, + representativeCareer: prev.representativeCareer.map((career, i) => + i === index ? { ...career, [field]: value } : career + ) + })); + }; + + const addCareerEntry = () => { + setFormData(prev => ({ + ...prev, + representativeCareer: [...prev.representativeCareer, { date: "", content: "" }] + })); + }; + + const removeCareerEntry = (index: number) => { + if (formData.representativeCareer.length > 1) { + setFormData(prev => ({ + ...prev, + representativeCareer: prev.representativeCareer.filter((_, i) => i !== index) + })); + } + }; + + const handleSubmit = async () => { + setLoading(true); + try { + // 필수 필드 검증 + const requiredFields = [ + { field: formData.companyNameKor, name: "업체명(국문)" }, + { field: formData.businessNumber, name: "사업자번호" }, + { field: formData.representativeNameKor, name: "대표자 성명(국문)" }, + { field: formData.representativeContact, name: "대표자 연락처" }, + { field: formData.representativeBirthDate, name: "대표자 생년월일" }, + { field: formData.representativeEmail, name: "대표자 이메일" }, + ]; + + const missingFields = requiredFields.filter(({ field }) => !field?.trim()); + if (missingFields.length > 0) { + toast.error(`다음 필수 항목을 입력해주세요: ${missingFields.map(f => f.name).join(", ")}`); + return; + } + + if (onSubmit) { + await onSubmit(formData); + } else { + // TODO: 실제 정규업체 등록 요청 API 호출 + toast.success("정규업체 등록 요청이 제출되었습니다."); + onOpenChange(false); + } + } catch (error) { + console.error("정규업체 등록 요청 오류:", error); + toast.error("정규업체 등록 요청 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 정규업체 등록 요청 + </DialogTitle> + <p className="text-sm text-muted-foreground"> + 정규업체 등록을 요청할 협력업체 정보 확인 및 누락정보를 입력하세요. + </p> + </DialogHeader> + + {dataLoading ? ( + <div className="flex items-center justify-center p-8"> + <div className="text-center"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> + <p>기존 데이터를 불러오는 중...</p> + </div> + </div> + ) : ( + <div className="space-y-6"> + {/* 신규 협력사 등록 카드 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Building2 className="w-5 h-5" /> + 신규 협력사 등록 카드 + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + + {/* 업체정보 */} + <div> + <h4 className="font-semibold mb-3">업체정보</h4> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <Label htmlFor="companyNameKor">업체명(국문) <span className="text-red-500">*</span></Label> + <Input + id="companyNameKor" + value={formData.companyNameKor} + onChange={(e) => handleInputChange('companyNameKor', e.target.value)} + className="bg-yellow-50" + /> + </div> + <div> + <Label htmlFor="businessNumber">사업자번호 <span className="text-red-500">*</span></Label> + <Input + id="businessNumber" + value={formData.businessNumber} + onChange={(e) => handleInputChange('businessNumber', e.target.value)} + className="bg-yellow-50" + /> + </div> + <div> + <Label htmlFor="companyNameEng">업체명(영문)</Label> + <Input + id="companyNameEng" + value={formData.companyNameEng} + onChange={(e) => handleInputChange('companyNameEng', e.target.value)} + /> + </div> + <div> + <Label htmlFor="corporateNumber">법인등록번호</Label> + <Input + id="corporateNumber" + value={formData.corporateNumber} + onChange={(e) => handleInputChange('corporateNumber', e.target.value)} + /> + </div> + <div> + <Label htmlFor="majorItems">주요품목</Label> + <Input + id="majorItems" + value={formData.majorItems} + onChange={(e) => handleInputChange('majorItems', e.target.value)} + /> + </div> + <div> + <Label htmlFor="establishmentDate">설립일자</Label> + <Input + id="establishmentDate" + type="date" + value={formData.establishmentDate} + onChange={(e) => handleInputChange('establishmentDate', e.target.value)} + /> + </div> + </div> + </div> + + {/* 소재지 */} + <div> + <h4 className="font-semibold mb-3">소재지</h4> + <div className="space-y-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <Label htmlFor="headOfficeAddress">본사 주소</Label> + <Input + id="headOfficeAddress" + value={formData.headOfficeAddress} + onChange={(e) => handleInputChange('headOfficeAddress', e.target.value)} + placeholder="PQ 내 회사주소" + /> + </div> + <div> + <Label htmlFor="headOfficePhone">본사 전화번호</Label> + <Input + id="headOfficePhone" + value={formData.headOfficePhone} + onChange={(e) => handleInputChange('headOfficePhone', e.target.value)} + placeholder="PQ 내 회사전화" + /> + </div> + </div> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <Label htmlFor="factoryAddress">공장 주소</Label> + <Input + id="factoryAddress" + value={formData.factoryAddress} + onChange={(e) => handleInputChange('factoryAddress', e.target.value)} + placeholder="PQ 내 공장주소" + /> + </div> + <div> + <Label htmlFor="factoryPhone">공장 전화번호</Label> + <Input + id="factoryPhone" + value={formData.factoryPhone} + onChange={(e) => handleInputChange('factoryPhone', e.target.value)} + placeholder="PQ 내 공장전화" + /> + </div> + </div> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <Label htmlFor="salesOfficeAddress">영업소 주소</Label> + <Input + id="salesOfficeAddress" + value={formData.salesOfficeAddress} + onChange={(e) => handleInputChange('salesOfficeAddress', e.target.value)} + placeholder="필요시 입력" + /> + </div> + <div> + <Label htmlFor="salesOfficePhone">영업소 전화번호</Label> + <Input + id="salesOfficePhone" + value={formData.salesOfficePhone} + onChange={(e) => handleInputChange('salesOfficePhone', e.target.value)} + placeholder="필요시 입력" + /> + </div> + </div> + </div> + </div> + + {/* 대표자 정보 */} + <div> + <h4 className="font-semibold mb-3">대표자 정보</h4> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <Label htmlFor="representativeNameKor">대표자 성명(국문) <span className="text-red-500">*</span></Label> + <Input + id="representativeNameKor" + value={formData.representativeNameKor} + onChange={(e) => handleInputChange('representativeNameKor', e.target.value)} + className="bg-yellow-50" + /> + </div> + <div> + <Label htmlFor="representativeContact">대표자 연락처 <span className="text-red-500">*</span></Label> + <Input + id="representativeContact" + value={formData.representativeContact} + onChange={(e) => handleInputChange('representativeContact', e.target.value)} + className="bg-yellow-50" + placeholder="010-0000-0000" + /> + </div> + <div> + <Label htmlFor="representativeNameEng">대표자 성명(영문)</Label> + <Input + id="representativeNameEng" + value={formData.representativeNameEng} + onChange={(e) => handleInputChange('representativeNameEng', e.target.value)} + /> + </div> + <div> + <Label htmlFor="representativeBirthDate">대표자 생년월일 <span className="text-red-500">*</span></Label> + <Input + id="representativeBirthDate" + type="date" + value={formData.representativeBirthDate} + onChange={(e) => handleInputChange('representativeBirthDate', e.target.value)} + className="bg-yellow-50" + /> + </div> + <div> + <Label htmlFor="representativeEmail">대표자 이메일 <span className="text-red-500">*</span></Label> + <Input + id="representativeEmail" + type="email" + value={formData.representativeEmail} + onChange={(e) => handleInputChange('representativeEmail', e.target.value)} + className="bg-yellow-50" + /> + </div> + </div> + + <div className="mt-4 space-y-3"> + <div className="flex items-center space-x-2"> + <Checkbox + id="isWorkingAtCompany" + checked={formData.isWorkingAtCompany} + onCheckedChange={(checked) => handleInputChange('isWorkingAtCompany', checked)} + /> + <Label htmlFor="isWorkingAtCompany">당사근무여부 <span className="text-red-500">*</span></Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="isInternalPartner" + checked={formData.isInternalPartner} + onCheckedChange={(checked) => handleInputChange('isInternalPartner', checked)} + /> + <Label htmlFor="isInternalPartner">사내협력사 <span className="text-red-500">*</span></Label> + </div> + </div> + </div> + + {/* 대표자 경력 */} + <div> + <h4 className="font-semibold mb-3">대표자 경력</h4> + <div className="space-y-3"> + {formData.representativeCareer.map((career, index) => ( + <div key={index} className="flex gap-3 items-center"> + <div className="flex-1"> + <Input + placeholder="일자 (예: 24.01.01)" + value={career.date} + onChange={(e) => handleCareerChange(index, 'date', e.target.value)} + /> + </div> + <div className="flex-[3]"> + <Input + placeholder="대표자 이력내용 (직장이력)" + value={career.content} + onChange={(e) => handleCareerChange(index, 'content', e.target.value)} + /> + </div> + {formData.representativeCareer.length > 1 && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={() => removeCareerEntry(index)} + > + 삭제 + </Button> + )} + </div> + ))} + <Button + type="button" + variant="outline" + size="sm" + onClick={addCareerEntry} + > + 경력 추가 + </Button> + </div> + </div> + + {/* 업무담당자 */} + <div> + <h4 className="font-semibold mb-3">업무담당자</h4> + <div className="space-y-4"> + {Object.entries(formData.businessContacts).map(([type, contact]) => { + const labels = { + sales: "영업", + design: "설계", + delivery: "납기", + quality: "품질", + taxInvoice: "세금계산서" + }; + + return ( + <div key={type} className="border rounded-lg p-4 space-y-4"> + <h5 className="font-medium text-sm">{labels[type as keyof typeof labels]} 담당자</h5> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + <div> + <Label>담당자명</Label> + <Input + value={contact.name} + onChange={(e) => handleBusinessContactChange(type as keyof typeof formData.businessContacts, 'name', e.target.value)} + /> + </div> + <div> + <Label>직급</Label> + <Input + value={contact.position} + onChange={(e) => handleBusinessContactChange(type as keyof typeof formData.businessContacts, 'position', e.target.value)} + placeholder="예: 부장, 과장" + /> + </div> + <div> + <Label>부서</Label> + <Input + value={contact.department} + onChange={(e) => handleBusinessContactChange(type as keyof typeof formData.businessContacts, 'department', e.target.value)} + placeholder="예: 영업부, 기술부" + /> + </div> + <div> + <Label>담당업무</Label> + <Input + value={contact.responsibility} + onChange={(e) => handleBusinessContactChange(type as keyof typeof formData.businessContacts, 'responsibility', e.target.value)} + placeholder="예: 고객 대응, 품질 관리" + /> + </div> + <div> + <Label>이메일</Label> + <Input + type="email" + value={contact.email} + onChange={(e) => handleBusinessContactChange(type as keyof typeof formData.businessContacts, 'email', e.target.value)} + placeholder="example@company.com" + /> + </div> + </div> + </div> + ); + })} + </div> + </div> + + </CardContent> + </Card> + </div> + )} + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={loading} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={loading} + > + {loading ? "요청 중..." : "등록요청"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} |
