diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-30 10:08:53 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-30 10:08:53 +0000 |
| commit | 2c02afd48a4d9276a4f5c132e088540a578d0972 (patch) | |
| tree | e5efdd3f48fad33681c139a4c58481f4514fb38e | |
| parent | 19794b32a6e3285fdeda7519ededdce451966f3d (diff) | |
(대표님) 폼리스트, spreadjs 관련 변경사항, 벤더문서 뷰 쿼리 수정, 이메일 템플릿 추가 등
22 files changed, 3365 insertions, 424 deletions
diff --git a/app/[lng]/evcp/(evcp)/(eng)/form-list/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/form-list/page.tsx index 7f04cc3e..7f04cc3e 100644 --- a/app/[lng]/evcp/(evcp)/(eng)/form-list/page.tsx +++ b/app/[lng]/evcp/(evcp)/(master-data)/form-list/page.tsx diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx index 20281f9c..6dcbf018 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx @@ -107,6 +107,22 @@ export default async function RfqLayout({ const dueDateStatus = rfq?.dueDate ? getDueDateStatus(rfq.dueDate) : null; + const getRfqCategory = (rfqCode: string | null | undefined): string => { + if (!rfqCode || rfqCode.length === 0) return 'itb'; // 기본값 + + const firstChar = rfqCode[0].toUpperCase(); + switch (firstChar) { + case 'I': + return 'itb'; + case 'R': + return 'rfq'; + case 'F': + return 'general'; + default: + return 'itb'; // 기본값 + } + }; + return ( <> <div className="container py-6"> @@ -122,7 +138,7 @@ export default async function RfqLayout({ : `견적 상세 관리 ${rfq.rfqCode ?? ""}` : "Loading RFQ..."} </h2> - <Link href={`/${lng}/evcp/rfq-last`} passHref> + <Link href={`/${lng}/evcp/rfq-last?rfqCategory=${getRfqCategory(rfq?.rfqCode)}`} passHref> <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors"> <ArrowLeft className="mr-1 h-4 w-4" /> <span>견적 목록으로 돌아가기</span> diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx index af5a8d95..ab63c14f 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx @@ -64,8 +64,11 @@ export default async function RfqPage(props: RfqPageProps) { // nuqs 기반 파라미터 파싱 const search = searchParamsRfqCache.parse(searchParams); + // 탭별 데이터 카운트 가져오기 const tabCounts = await getTabCounts(); + + console.log(search.rfqCategory ,"search.rfqCategory ") // 현재 선택된 탭 (URL 파라미터에서 가져오거나 기본값 'all') const currentTab = search.rfqCategory || "itb"; @@ -89,7 +92,7 @@ export default async function RfqPage(props: RfqPageProps) { </div> {/* 탭 컨테이너 */} - <Tabs defaultValue="itb"className="w-full flex-1 flex flex-col overflow-hidden"> + <Tabs defaultValue={currentTab} className="w-full flex-1 flex flex-col overflow-hidden"> <TabsList className="grid w-full max-w-[600px] grid-cols-3 flex-shrink-0"> diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index a2645679..591ba66a 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -19,14 +19,19 @@ import { Upload, Plus, Tag, - TagsIcon, + TagsIcon, FileOutput, Clipboard, Send, GitCompareIcon, RefreshCcw, Trash2, - Eye + Eye, + FileText, + Target, + CheckCircle2, + AlertCircle, + Clock } from "lucide-react"; import { toast } from "sonner"; import { @@ -54,6 +59,13 @@ import { SEDPCompareDialog } from "./sedp-compare-dialog"; import { DeleteFormDataDialog } from "./delete-form-data-dialog"; import { TemplateViewDialog } from "./spreadJS-dialog"; import { fetchTemplateFromSEDP } from "@/lib/forms/sedp-actions"; +import { FormStatusByVendor, getFormStatusByVendor } from "@/lib/forms/stat"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@/components/ui/card"; interface GenericData { [key: string]: unknown; @@ -99,6 +111,33 @@ export default function DynamicTable({ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [deleteTarget, setDeleteTarget] = React.useState<GenericData[]>([]); + const [formStats, setFormStats] = React.useState<FormStatusByVendor | null>(null); + const [isLoadingStats, setIsLoadingStats] = React.useState(true); + + + React.useEffect(() => { + const fetchFormStats = async () => { + try { + setIsLoadingStats(true); + // getFormStatusByVendor 서버 액션 직접 호출 + const data = await getFormStatusByVendor(projectId, formCode); + + if (data && data.length > 0) { + setFormStats(data[0]); + } + } catch (error) { + console.error("Failed to fetch form stats:", error); + toast.error("통계 데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoadingStats(false); + } + }; + + if (projectId && formCode) { + fetchFormStats(); + } + }, [projectId, formCode]); + // Update tableData when dataJSON changes React.useEffect(() => { setTableData(dataJSON); @@ -180,7 +219,7 @@ export default function DynamicTable({ setPackageCode(''); } }; - + getPackageCode(); }, [contractItemId]) // Get project code when component mounts @@ -633,6 +672,142 @@ export default function DynamicTable({ return ( <> + <div className="mb-6"> + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6"> + {/* Tag Count */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Total Tags + </CardTitle> + <FileText className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + formStats?.tagCount || 0 + )} + </div> + <p className="text-xs text-muted-foreground"> + Total Tag Count + </p> + </CardContent> + </Card> + + {/* Completion Rate */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Completion + </CardTitle> + <Target className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + `${formStats?.completionRate || 0}%` + )} + </div> + <p className="text-xs text-muted-foreground"> + {formStats ? `${formStats.completedFields} / ${formStats.totalFields}` : '-'} + </p> + </CardContent> + </Card> + + {/* Completed Fields */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Completed + </CardTitle> + <CheckCircle2 className="h-4 w-4 text-green-600" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + formStats?.completedFields || 0 + )} + </div> + <p className="text-xs text-muted-foreground"> + Completed Fields + </p> + </CardContent> + </Card> + + {/* Remaining Fields */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Remaining + </CardTitle> + <Clock className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + (formStats?.totalFields || 0) - (formStats?.completedFields || 0) + )} + </div> + <p className="text-xs text-muted-foreground"> + Remaining Fields + </p> + </CardContent> + </Card> + + {/* Upcoming (7 days) */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Upcoming + </CardTitle> + <AlertCircle className="h-4 w-4 text-yellow-600" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-yellow-600"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + formStats?.upcomingCount || 0 + )} + </div> + <p className="text-xs text-muted-foreground"> + Due in 7 Days + </p> + </CardContent> + </Card> + + {/* Overdue */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + Overdue + </CardTitle> + <AlertCircle className="h-4 w-4 text-red-600" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600"> + {isLoadingStats ? ( + <span className="animate-pulse">-</span> + ) : ( + formStats?.overdueCount || 0 + )} + </div> + <p className="text-xs text-muted-foreground"> + Overdue + </p> + </CardContent> + </Card> + </div> + </div> + <ClientDataTable data={tableData} columns={columns} @@ -661,8 +836,8 @@ export default function DynamicTable({ <Button variant="outline" size="sm" disabled={isAnyOperationPending}> {(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> @@ -679,8 +854,8 @@ export default function DynamicTable({ {t("buttons.getTags")} </DropdownMenuItem> )} - <DropdownMenuItem - onClick={() => setAddTagDialogOpen(true)} + <DropdownMenuItem + onClick={() => setAddTagDialogOpen(true)} disabled={isAnyOperationPending || isAddTagDisabled} > <Plus className="mr-2 h-4 w-4" /> diff --git a/components/form-data/spreadJS-dialog copy.tsx b/components/form-data/spreadJS-dialog copy.tsx new file mode 100644 index 00000000..d001463e --- /dev/null +++ b/components/form-data/spreadJS-dialog copy.tsx @@ -0,0 +1,1514 @@ +"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"; +import { setupSpreadJSLicense } from "@/lib/spread-js/license-utils"; + +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') { + setupSpreadJSLicense(GC); +} + +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; + tableData?: GenericData[]; + formCode: string; + columnsJSON: DataTableColumnJSON[] + contractItemId: number; + editableFieldsMap?: Map<string, string[]>; + onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void; +} + +// 🚀 로딩 프로그레스 컴포넌트 +interface LoadingProgressProps { + phase: string; + progress: number; + total: number; + isVisible: boolean; +} + +const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => { + const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; + + if (!isVisible) return null; + + return ( + <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]"> + <div className="flex items-center space-x-3 mb-4"> + <Loader className="h-5 w-5 animate-spin text-blue-600" /> + <span className="font-medium text-gray-900">Loading Template</span> + </div> + + <div className="space-y-2"> + <div className="text-sm text-gray-600">{phase}</div> + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out" + style={{ width: `${percentage}%` }} + /> + </div> + <div className="text-xs text-gray-500 text-right"> + {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%) + </div> + </div> + </div> + </div> + ); +}; + +export function TemplateViewDialog({ + isOpen, + onClose, + 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 [loadingProgress, setLoadingProgress] = React.useState<{ + phase: string; + progress: number; + total: number; + } | null>(null); + const [isInitializing, setIsInitializing] = React.useState(false); + + // 🔄 진행상황 업데이트 함수 + const updateProgress = React.useCallback((phase: string, progress: number, total: number) => { + setLoadingProgress({ phase, progress, total }); + }, []); + + const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => { + if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_LIST'; + } + if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) { + return 'SPREAD_ITEM'; + } + if (template.GRD_LST_SETUP && columnsJSON.length > 0) { + return 'GRD_LIST'; + } + return null; + }, [columnsJSON]); + + const isValidTemplate = React.useCallback((template: TemplateItem): boolean => { + return determineTemplateType(template) !== null; + }, [determineTemplateType]); + + React.useEffect(() => { + setIsClient(true); + }, []); + + React.useEffect(() => { + if (!templateData) return; + + 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([]); + + 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의 경우에만 전역 editableFields 사용 + if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) { + if (!editableFieldsMap.has(selectedRow.TAG_NO)) { + return []; + } + return editableFieldsMap.get(selectedRow.TAG_NO) || []; + } + + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 + return []; + }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); + + +const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { + return false; + } + + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { + return false; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return false; + } + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { + return false; + } + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; + } + return true; + } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + + return true; +}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + +const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 + return cellMappings.filter(m => m.isEditable).length; + } + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; + + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } + } + }); + }); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; +}, [cellMappings, templateType, tableData, isFieldEditable]); + + // 🚀 배치 처리 함수들 + const setBatchValues = React.useCallback(( + activeSheet: any, + valuesToSet: Array<{row: number, col: number, value: any}> + ) => { + console.log(`🚀 Setting ${valuesToSet.length} values in batch`); + + const columnGroups = new Map<number, Array<{row: number, value: any}>>(); + + valuesToSet.forEach(({row, col, value}) => { + if (!columnGroups.has(col)) { + columnGroups.set(col, []); + } + columnGroups.get(col)!.push({row, value}); + }); + + columnGroups.forEach((values, col) => { + values.sort((a, b) => a.row - b.row); + + let start = 0; + while (start < values.length) { + let end = start; + while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { + end++; + } + + const rangeValues = values.slice(start, end + 1).map(v => v.value); + const startRow = values[start].row; + + try { + if (rangeValues.length === 1) { + activeSheet.setValue(startRow, col, rangeValues[0]); + } else { + const dataArray = rangeValues.map(v => [v]); + activeSheet.setArray(startRow, col, dataArray); + } + } catch (error) { + for (let i = start; i <= end; i++) { + try { + activeSheet.setValue(values[i].row, col, values[i].value); + } catch (cellError) { + console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError); + } + } + } + + start = end + 1; + } + }); + }, []); + + const createCellStyle = React.useCallback((activeSheet: any, row: number, col: number, isEditable: boolean) => { + // 기존 스타일 가져오기 (없으면 새로 생성) + const existingStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + // backColor만 수정 + if (isEditable) { + existingStyle.backColor = "#bbf7d0"; + } else { + existingStyle.backColor = "#e5e7eb"; + // 읽기 전용일 때만 텍스트 색상 변경 (선택사항) + existingStyle.foreColor = "#4b5563"; + } + + return existingStyle; + }, []); + + const setBatchStyles = React.useCallback(( + activeSheet: any, + stylesToSet: Array<{row: number, col: number, isEditable: boolean}> + ) => { + console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); + + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) + stylesToSet.forEach(({row, col, isEditable}) => { + try { + const cell = activeSheet.getCell(row, col); + const style = createCellStyle(activeSheet, row, col, isEditable); + + activeSheet.setStyle(row, col, style); + cell.locked(!isEditable); // 편집 가능하면 잠금 해제 + + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 + if (isEditable) { + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(row, col, textCellType); + } + } catch (error) { + console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error); + } + }); + }, [createCellStyle]); + + const parseCellAddress = (address: string): { row: number, col: number } | null => { + if (!address || address.trim() === "") return null; + + 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 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; + } + + 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": + 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; + + 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]); + + + + 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); + + 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; + } + + const optionsString = safeOptions.join(','); + + 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); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + // ComboBox와 Validator 적용 + activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); + + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(false); + + console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError); + } + } + + console.log(`✅ Dropdown setup completed for ${rowCount} cells`); + + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { + if (!spread) return null; + + try { + let activeSheet = spread.getActiveSheet(); + if (!activeSheet) { + const sheetCount = spread.getSheetCount(); + if (sheetCount > 0) { + activeSheet = spread.getSheet(0); + if (activeSheet) { + spread.setActiveSheetIndex(0); + } + } + } + return activeSheet; + } catch (error) { + console.error(`❌ Error getting activeSheet in ${functionName}:`, error); + return null; + } + }, []); + + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + try { + if (!activeSheet) return false; + + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + const newRowCount = requiredRowCount + 10; + activeSheet.setRowCount(newRowCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureRowCapacity:', error); + return false; + } + }, []); + + const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { + try { + if (!activeSheet) return false; + + const currentColumnCount = activeSheet.getColumnCount(); + if (requiredColumnCount > currentColumnCount) { + const newColumnCount = requiredColumnCount + 10; + activeSheet.setColumnCount(newColumnCount); + } + return true; + } catch (error) { + console.error('❌ Error in ensureColumnCapacity:', error); + return false; + } + }, []); + + const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => { + columns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120; + activeSheet.setColumnWidth(targetCol, optimalWidth); + }); + }, []); + + // 🚀 최적화된 GRD_LIST 생성 + // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) +const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); + + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; + + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) + const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); + let freezeColumnCount = 0; + + if (tagDescColumnIndex !== -1) { + // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) + freezeColumnCount = startCol + tagDescColumnIndex + 1; + console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); + } else { + // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) + const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); + if (tagNoColumnIndex !== -1) { + freezeColumnCount = startCol + tagNoColumnIndex + 1; + console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); + } + } + + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{row: number, col: number, value: any}> = []; + const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); + } + }); + + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); + } + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, + col: targetCol, + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable + }); + }); + }); + + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); + + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); + + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); + + if (safeOptions.length === 0) return; + + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); + + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); + + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + // 🧊 틀고정 설정 + if (freezeColumnCount > 0) { + try { + activeSheet.frozenColumnCount(freezeColumnCount); + activeSheet.frozenRowCount(1); // 헤더 행도 고정 + + console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); + + // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) + for (let col = 0; col < freezeColumnCount; col++) { + for (let row = 0; row <= tableData.length; row++) { + try { + const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + if (row === 0) { + // 헤더는 기존 스타일 유지 + continue; + } else { + // 데이터 셀에 고정 구분선 추가 + if (col === freezeColumnCount - 1) { + currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); + activeSheet.setStyle(row, col, currentStyle); + } + } + } catch (styleError) { + console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); + } + } + } + } catch (freezeError) { + console.error('❌ Failed to apply freeze:', freezeError); + } + } + + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created with freeze:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + console.log(` - Frozen columns: ${freezeColumnCount}`); + + return mappings; +}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + + const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { + console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); + + // 🔧 시트 보호 완전 해제 후 편집 권한 설정 + activeSheet.options.isProtected = false; + + // 🔧 편집 가능한 셀들을 위한 강화된 설정 + mappings.forEach((mapping) => { + const cellPos = parseCellAddress(mapping.cellAddress); + if (!cellPos) return; + + try { + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const columnConfig = columnsJSON.find(col => col.key === mapping.attId); + + if (mapping.isEditable) { + // 🚀 편집 가능한 셀 설정 강화 + cell.locked(false); + + if (columnConfig?.type === "LIST" && columnConfig.options) { + // LIST 타입: 새 ComboBox 인스턴스 생성 + const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBox.items(columnConfig.options); + comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); + + // DataValidation도 추가 + const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); + activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); + } else if (columnConfig?.type === "NUMBER") { + // NUMBER 타입: 숫자 입력 허용 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + + // 숫자 validation 추가 (에러 메시지 없이) + const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( + GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, + -999999999, 999999999, true + ); + numberValidator.showInputMessage(false); + numberValidator.showErrorMessage(false); + activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator); + } else { + // 기본 TEXT 타입: 자유 텍스트 입력 + const textCellType = new GC.Spread.Sheets.CellTypes.Text(); + activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); + } + + // 편집 가능 스타일 재적용 + const editableStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, true); + activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); + + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); + } else { + // 읽기 전용 셀 + cell.locked(true); + const readonlyStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, false); + activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle); + } + } catch (error) { + console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error); + } + }); + + // 🛡️ 시트 보호 재설정 (편집 허용 모드로) + activeSheet.options.isProtected = false; + 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 + }; + + // 🎯 변경 감지 이벤트 + 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}, isEditable: ${exactMapping.isEditable}`); + + 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; + if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { + const rowData = tableData[dataRowIndex]; + if (rowData?.shi === "OUT" || rowData?.shi === null ) { + 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; + } + } + } + + 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) return; + + const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId); + if (columnConfig) { + const cellValue = activeSheet.getValue(info.row, info.col); + const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); + const cell = activeSheet.getCell(info.row, info.col); + + if (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}`, { duration: 5000 }); + } else { + // ✅ 정상 스타일 복원 + const normalStyle = createCellStyle(activeSheet, info.row, info.col, exactMapping.isEditable); + activeSheet.setStyle(info.row, info.col, normalStyle); + cell.locked(!exactMapping.isEditable); + } + } + + setHasChanges(true); + }); + + console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`); + }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]); + + // 🚀 최적화된 initSpread + const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => { + const workingTemplate = template || selectedTemplate; + if (!spread || !workingTemplate) { + console.error('❌ Invalid spread or template'); + return; + } + + try { + console.log('🚀 Starting optimized spread initialization...'); + setIsInitializing(true); + updateProgress('Initializing...', 0, 100); + + setCurrentSpread(spread); + setHasChanges(false); + setValidationErrors([]); + + // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단 + spread.suspendPaint(); + spread.suspendEvent(); + spread.suspendCalcService(); + + updateProgress('Setting up workspace...', 10, 100); + + try { + let activeSheet = getSafeActiveSheet(spread, 'initSpread'); + if (!activeSheet) { + throw new Error('Failed to get initial activeSheet'); + } + + activeSheet.options.isProtected = false; + let mappings: CellMapping[] = []; + + if (templateType === 'GRD_LIST') { + updateProgress('Creating dynamic table...', 20, 100); + + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + updateProgress('Processing table data...', 50, 100); + mappings = createGrdListTableOptimized(sheet, workingTemplate); + + } else { + updateProgress('Loading template structure...', 20, 100); + + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; + let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; + + if (!contentJson || !dataSheets) { + throw new Error(`No template content found for ${workingTemplate.NAME}`); + } + + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + updateProgress('Loading template layout...', 40, 100); + spread.fromJSON(jsonData); + + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); + if (!activeSheet) { + throw new Error('ActiveSheet became null after loading template'); + } + + activeSheet.options.isProtected = false; + + if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + updateProgress('Processing data rows...', 60, 100); + + const activeSheetName = workingTemplate.SPR_LST_SETUP?.ACT_SHEET; + + const matchingDataSheets = dataSheets.filter(ds => + ds.SHEET_NAME === activeSheetName + ); + + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + } + + matchingDataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + if (!ATT_ID || !IN || IN.trim() === "") return; + + const cellPos = parseCellAddress(IN); + if (!cellPos) return; + + const requiredRows = cellPos.row + tableData.length; + if (!ensureRowCapacity(activeSheet, requiredRows)) return; + + // 🚀 배치 데이터 준비 + const valuesToSet: Array<{row: number, col: number, value: any}> = []; + const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; + + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cellEditable = isFieldEditable(ATT_ID, rowData); + const value = rowData[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: getCellAddress(targetRow, cellPos.col), + isEditable: cellEditable, + dataRowIndex: index + }); + + valuesToSet.push({ + row: targetRow, + col: cellPos.col, + value: value ?? null + }); + + stylesToSet.push({ + row: targetRow, + col: cellPos.col, + isEditable: cellEditable + }); + }); + + // 🚀 배치 처리 + setBatchValues(activeSheet, valuesToSet); + setBatchStyles(activeSheet, stylesToSet); + + // 드롭다운 설정 + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options) { + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); + } + } + }); + } + }); + + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { + updateProgress('Setting up form fields...', 60, 100); + const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET; + + const matchingDataSheets = dataSheets.filter(ds => + ds.SHEET_NAME === activeSheetName + ); + + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + } + + matchingDataSheets.forEach(dataSheet => { + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { + const { ATT_ID, IN } = mapping; + const cellPos = parseCellAddress(IN); + if (cellPos) { + const isEditable = isFieldEditable(ATT_ID); + const value = selectedRow[ATT_ID]; + + mappings.push({ + attId: ATT_ID, + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 + }); + + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + cell.value(value ?? null); + + const style = createCellStyle(activeSheet, cellPos.row, cellPos.col, isEditable); + activeSheet.setStyle(cellPos.row, cellPos.col, style); + + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + } + } + }); + }); + } + } + + updateProgress('Configuring interactions...', 90, 100); + setCellMappings(mappings); + + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); + if (finalActiveSheet) { + setupSheetProtectionAndEvents(finalActiveSheet, mappings); + } + + updateProgress('Finalizing...', 100, 100); + console.log(`✅ Optimized initialization completed with ${mappings.length} mappings`); + + } finally { + // 🚀 올바른 순서로 재개 + spread.resumeCalcService(); + spread.resumeEvent(); + spread.resumePaint(); + } + + } catch (error) { + console.error('❌ Error in optimized spread initialization:', error); + if (spread?.resumeCalcService) spread.resumeCalcService(); + if (spread?.resumeEvent) spread.resumeEvent(); + if (spread?.resumePaint) spread.resumePaint(); + toast.error(`Template loading failed: ${error.message}`); + } finally { + setIsInitializing(false); + setLoadingProgress(null); + } + }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings, createCellStyle, isFieldEditable, columnsJSON, setupOptimizedListValidation, parseCellAddress, ensureRowCapacity, getCellAddress]); + + 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) { + console.log('🔍 Starting batch save process...'); + + const updatedRows: GenericData[] = []; + let saveCount = 0; + let checkedCount = 0; + + for (let i = 0; i < tableData.length; i++) { + const originalRow = tableData[i]; + const dataToSave = { ...originalRow }; + let hasRowChanges = false; + + console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`); + + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === i && mapping.isEditable) { + checkedCount++; + + // 🔧 isFieldEditable과 동일한 로직 사용 + const rowData = tableData[i]; + const fieldEditable = isFieldEditable(mapping.attId, rowData); + + console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`); + + if (fieldEditable) { + const cellPos = parseCellAddress(mapping.cellAddress); + if (cellPos) { + const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); + const originalValue = originalRow[mapping.attId]; + + // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리) + const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim(); + const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim(); + + console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`); + + if (normalizedCellValue !== normalizedOriginalValue) { + dataToSave[mapping.attId] = cellValue; + hasRowChanges = true; + console.log(` ✅ Change detected for ${mapping.attId}`); + } + } + } + } + }); + + if (hasRowChanges) { + console.log(`💾 Saving row ${i} with changes`); + dataToSave.TAG_NO = originalRow.TAG_NO; + + try { + const { success, message } = await updateFormDataInDB( + formCode, + contractItemId, + dataToSave + ); + + if (success) { + updatedRows.push(dataToSave); + saveCount++; + console.log(`✅ Row ${i} saved successfully`); + } else { + console.error(`❌ Failed to save row ${i}: ${message}`); + toast.error(`Failed to save row ${i + 1}: ${message}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } catch (error) { + console.error(`❌ Error saving row ${i}:`, error); + toast.error(`Error saving row ${i + 1}`); + updatedRows.push(originalRow); // 원본 데이터 유지 + } + } else { + updatedRows.push(originalRow); + console.log(`ℹ️ No changes in row ${i}`); + } + } + + console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`); + + if (saveCount > 0) { + toast.success(`${saveCount} rows saved successfully!`); + onUpdateSuccess?.(updatedRows); + } else { + console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`); + toast.warning("No actual changes were found to save. Please check if the values were properly edited."); + } + } + + 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, + isFieldEditable // 🔧 의존성 추가 + ]); + + 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-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50" + > + <DialogHeader className="flex-shrink-0"> + <DialogTitle>SEDP Template - {formCode}</DialogTitle> + <DialogDescription> + <div className="space-y-3"> + {availableTemplates.length > 1 && ( + <div className="flex items-center gap-4"> + <span className="text-sm font-medium">Template:</span> + <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> + + <div className="flex-1 overflow-hidden relative"> + {/* 🆕 로딩 프로그레스 오버레이 */} + <LoadingProgress + phase={loadingProgress?.phase || ''} + progress={loadingProgress?.progress || 0} + total={loadingProgress?.total || 100} + isVisible={isInitializing && !!loadingProgress} + /> + + {selectedTemplate && isClient && isDataValid ? ( + <SpreadSheets + key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} + 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 d001463e..91d5672c 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -107,9 +107,9 @@ interface LoadingProgressProps { const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => { const percentage = total > 0 ? Math.round((progress / total) * 100) : 0; - + if (!isVisible) return null; - + return ( <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50"> <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]"> @@ -117,11 +117,11 @@ const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, tota <Loader className="h-5 w-5 animate-spin text-blue-600" /> <span className="font-medium text-gray-900">Loading Template</span> </div> - + <div className="space-y-2"> <div className="text-sm text-gray-600">{phase}</div> <div className="w-full bg-gray-200 rounded-full h-2"> - <div + <div className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out" style={{ width: `${percentage}%` }} /> @@ -161,7 +161,7 @@ export function TemplateViewDialog({ const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); - + // 🆕 로딩 상태 추가 const [loadingProgress, setLoadingProgress] = React.useState<{ phase: string; @@ -244,102 +244,102 @@ export function TemplateViewDialog({ } return editableFieldsMap.get(selectedRow.TAG_NO) || []; } - + // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음 return []; }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]); - -const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { - const columnConfig = columnsJSON.find(col => col.key === attId); - if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { - return false; - } - - if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { - return false; - } - if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 - if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => { + const columnConfig = columnsJSON.find(col => col.key === attId); + if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) { return false; } - - const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; - if (!rowEditableFields.includes(attId)) { + + if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") { return false; } - - if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { - return false; + + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단 + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return false; + } + + const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || []; + if (!rowEditableFields.includes(attId)) { + return false; + } + + if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) { + return false; + } + return true; } + + // SPREAD_ITEM의 경우 기존 로직 유지 + if (templateType === 'SPREAD_ITEM') { + return editableFields.includes(attId); + } + return true; - } + }, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 - // SPREAD_ITEM의 경우 기존 로직 유지 - if (templateType === 'SPREAD_ITEM') { - return editableFields.includes(attId); - } + const editableFieldsCount = React.useMemo(() => { + if (templateType === 'SPREAD_ITEM') { + // SPREAD_ITEM의 경우 기존 로직 유지 + return cellMappings.filter(m => m.isEditable).length; + } - return true; -}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거 + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { + // 각 행별로 편집 가능한 필드 수를 계산 + let totalEditableCount = 0; -const editableFieldsCount = React.useMemo(() => { - if (templateType === 'SPREAD_ITEM') { - // SPREAD_ITEM의 경우 기존 로직 유지 - return cellMappings.filter(m => m.isEditable).length; - } - - if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { - // 각 행별로 편집 가능한 필드 수를 계산 - let totalEditableCount = 0; - - tableData.forEach((rowData, rowIndex) => { - cellMappings.forEach(mapping => { - if (mapping.dataRowIndex === rowIndex) { - if (isFieldEditable(mapping.attId, rowData)) { - totalEditableCount++; + tableData.forEach((rowData, rowIndex) => { + cellMappings.forEach(mapping => { + if (mapping.dataRowIndex === rowIndex) { + if (isFieldEditable(mapping.attId, rowData)) { + totalEditableCount++; + } } - } + }); }); - }); - - return totalEditableCount; - } - - return cellMappings.filter(m => m.isEditable).length; -}, [cellMappings, templateType, tableData, isFieldEditable]); + + return totalEditableCount; + } + + return cellMappings.filter(m => m.isEditable).length; + }, [cellMappings, templateType, tableData, isFieldEditable]); // 🚀 배치 처리 함수들 const setBatchValues = React.useCallback(( - activeSheet: any, - valuesToSet: Array<{row: number, col: number, value: any}> + activeSheet: any, + valuesToSet: Array<{ row: number, col: number, value: any }> ) => { console.log(`🚀 Setting ${valuesToSet.length} values in batch`); - - const columnGroups = new Map<number, Array<{row: number, value: any}>>(); - - valuesToSet.forEach(({row, col, value}) => { + + const columnGroups = new Map<number, Array<{ row: number, value: any }>>(); + + valuesToSet.forEach(({ row, col, value }) => { if (!columnGroups.has(col)) { columnGroups.set(col, []); } - columnGroups.get(col)!.push({row, value}); + columnGroups.get(col)!.push({ row, value }); }); - + columnGroups.forEach((values, col) => { values.sort((a, b) => a.row - b.row); - + let start = 0; while (start < values.length) { let end = start; while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) { end++; } - + const rangeValues = values.slice(start, end + 1).map(v => v.value); const startRow = values[start].row; - + try { if (rangeValues.length === 1) { activeSheet.setValue(startRow, col, rangeValues[0]); @@ -356,7 +356,7 @@ const editableFieldsCount = React.useMemo(() => { } } } - + start = end + 1; } }); @@ -365,7 +365,7 @@ const editableFieldsCount = React.useMemo(() => { const createCellStyle = React.useCallback((activeSheet: any, row: number, col: number, isEditable: boolean) => { // 기존 스타일 가져오기 (없으면 새로 생성) const existingStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); - + // backColor만 수정 if (isEditable) { existingStyle.backColor = "#bbf7d0"; @@ -374,25 +374,25 @@ const editableFieldsCount = React.useMemo(() => { // 읽기 전용일 때만 텍스트 색상 변경 (선택사항) existingStyle.foreColor = "#4b5563"; } - + return existingStyle; }, []); const setBatchStyles = React.useCallback(( - activeSheet: any, - stylesToSet: Array<{row: number, col: number, isEditable: boolean}> + activeSheet: any, + stylesToSet: Array<{ row: number, col: number, isEditable: boolean }> ) => { console.log(`🎨 Setting ${stylesToSet.length} styles in batch`); - + // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장) - stylesToSet.forEach(({row, col, isEditable}) => { + stylesToSet.forEach(({ row, col, isEditable }) => { try { const cell = activeSheet.getCell(row, col); const style = createCellStyle(activeSheet, row, col, isEditable); - + activeSheet.setStyle(row, col, style); cell.locked(!isEditable); // 편집 가능하면 잠금 해제 - + // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정 if (isEditable) { const textCellType = new GC.Spread.Sheets.CellTypes.Text(); @@ -511,7 +511,7 @@ const editableFieldsCount = React.useMemo(() => { 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); @@ -525,7 +525,7 @@ const editableFieldsCount = React.useMemo(() => { // ComboBox와 Validator 적용 activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); - + // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정 const cell = activeSheet.getCell(targetRow, cellPos.col); cell.locked(false); @@ -546,7 +546,7 @@ const editableFieldsCount = React.useMemo(() => { const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => { if (!spread) return null; - + try { let activeSheet = spread.getActiveSheet(); if (!activeSheet) { @@ -568,7 +568,7 @@ const editableFieldsCount = React.useMemo(() => { const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { try { if (!activeSheet) return false; - + const currentRowCount = activeSheet.getRowCount(); if (requiredRowCount > currentRowCount) { const newRowCount = requiredRowCount + 10; @@ -584,7 +584,7 @@ const editableFieldsCount = React.useMemo(() => { const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => { try { if (!activeSheet) return false; - + const currentColumnCount = activeSheet.getColumnCount(); if (requiredColumnCount > currentColumnCount) { const newColumnCount = requiredColumnCount + 10; @@ -606,206 +606,206 @@ const editableFieldsCount = React.useMemo(() => { }, []); // 🚀 최적화된 GRD_LIST 생성 - // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) -const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { - console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); - - const visibleColumns = columnsJSON - .filter(col => col.hidden !== true) - .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); - - if (visibleColumns.length === 0) return []; - - const startCol = 1; - const dataStartRow = 1; - const mappings: CellMapping[] = []; - - ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); - ensureRowCapacity(activeSheet, dataStartRow + tableData.length); - - // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) - const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); - let freezeColumnCount = 0; - - if (tagDescColumnIndex !== -1) { - // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) - freezeColumnCount = startCol + tagDescColumnIndex + 1; - console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); - } else { - // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) - const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); - if (tagNoColumnIndex !== -1) { - freezeColumnCount = startCol + tagNoColumnIndex + 1; - console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); + // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함) + const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze'); + + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) + .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999)); + + if (visibleColumns.length === 0) return []; + + const startCol = 1; + const dataStartRow = 1; + const mappings: CellMapping[] = []; + + ensureColumnCapacity(activeSheet, startCol + visibleColumns.length); + ensureRowCapacity(activeSheet, dataStartRow + tableData.length); + + // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용) + const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC'); + let freezeColumnCount = 0; + + if (tagDescColumnIndex !== -1) { + // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1) + freezeColumnCount = startCol + tagDescColumnIndex + 1; + console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`); + } else { + // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼) + const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO'); + if (tagNoColumnIndex !== -1) { + freezeColumnCount = startCol + tagNoColumnIndex + 1; + console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`); + } } - } - // 헤더 생성 - const headerStyle = new GC.Spread.Sheets.Style(); - headerStyle.backColor = "#3b82f6"; - headerStyle.foreColor = "#ffffff"; - headerStyle.font = "bold 12px Arial"; - headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; - - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - const cell = activeSheet.getCell(0, targetCol); - cell.value(column.label); - cell.locked(true); - activeSheet.setStyle(0, targetCol, headerStyle); - }); + // 헤더 생성 + const headerStyle = new GC.Spread.Sheets.Style(); + headerStyle.backColor = "#3b82f6"; + headerStyle.foreColor = "#ffffff"; + headerStyle.font = "bold 12px Arial"; + headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; - // 🚀 데이터 배치 처리 준비 - const allValues: Array<{row: number, col: number, value: any}> = []; - const allStyles: Array<{row: number, col: number, isEditable: boolean}> = []; - - // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) - const dropdownConfigs: Array<{ - startRow: number; - col: number; - rowCount: number; - options: string[]; - editableRows: number[]; // 편집 가능한 행만 추적 - }> = []; - - visibleColumns.forEach((column, colIndex) => { - const targetCol = startCol + colIndex; - - // 드롭다운 설정을 위한 편집 가능한 행 찾기 - if (column.type === "LIST" && column.options) { - const editableRows: number[] = []; - tableData.forEach((rowData, rowIndex) => { - if (isFieldEditable(column.key, rowData)) { // rowData 전달 - editableRows.push(dataStartRow + rowIndex); + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cell = activeSheet.getCell(0, targetCol); + cell.value(column.label); + cell.locked(true); + activeSheet.setStyle(0, targetCol, headerStyle); + }); + + // 🚀 데이터 배치 처리 준비 + const allValues: Array<{ row: number, col: number, value: any }> = []; + const allStyles: Array<{ row: number, col: number, isEditable: boolean }> = []; + + // 🔧 편집 가능한 셀 정보 수집 (드롭다운용) + const dropdownConfigs: Array<{ + startRow: number; + col: number; + rowCount: number; + options: string[]; + editableRows: number[]; // 편집 가능한 행만 추적 + }> = []; + + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + + // 드롭다운 설정을 위한 편집 가능한 행 찾기 + if (column.type === "LIST" && column.options) { + const editableRows: number[] = []; + tableData.forEach((rowData, rowIndex) => { + if (isFieldEditable(column.key, rowData)) { // rowData 전달 + editableRows.push(dataStartRow + rowIndex); + } + }); + + if (editableRows.length > 0) { + dropdownConfigs.push({ + startRow: dataStartRow, + col: targetCol, + rowCount: tableData.length, + options: column.options, + editableRows: editableRows + }); } - }); - - if (editableRows.length > 0) { - dropdownConfigs.push({ - startRow: dataStartRow, + } + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; + const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 + const value = rowData[column.key]; + + mappings.push({ + attId: column.key, + cellAddress: getCellAddress(targetRow, targetCol), + isEditable: cellEditable, + dataRowIndex: rowIndex + }); + + allValues.push({ + row: targetRow, col: targetCol, - rowCount: tableData.length, - options: column.options, - editableRows: editableRows + value: value ?? null + }); + + allStyles.push({ + row: targetRow, + col: targetCol, + isEditable: cellEditable }); - } - } - - tableData.forEach((rowData, rowIndex) => { - const targetRow = dataStartRow + rowIndex; - const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달 - const value = rowData[column.key]; - - mappings.push({ - attId: column.key, - cellAddress: getCellAddress(targetRow, targetCol), - isEditable: cellEditable, - dataRowIndex: rowIndex - }); - - allValues.push({ - row: targetRow, - col: targetCol, - value: value ?? null - }); - - allStyles.push({ - row: targetRow, - col: targetCol, - isEditable: cellEditable }); }); - }); - // 🚀 배치로 값과 스타일 설정 - setBatchValues(activeSheet, allValues); - setBatchStyles(activeSheet, allStyles); + // 🚀 배치로 값과 스타일 설정 + setBatchValues(activeSheet, allValues); + setBatchStyles(activeSheet, allStyles); - // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) - dropdownConfigs.forEach(({ col, options, editableRows }) => { - try { - console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); - - const safeOptions = options - .filter(opt => opt !== null && opt !== undefined && opt !== '') - .map(opt => String(opt).trim()) - .filter(opt => opt.length > 0) - .slice(0, 20); + // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만) + dropdownConfigs.forEach(({ col, options, editableRows }) => { + try { + console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`); - if (safeOptions.length === 0) return; + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .slice(0, 20); - // 편집 가능한 행에만 드롭다운 적용 - editableRows.forEach(targetRow => { - try { - const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); - comboBoxCellType.items(safeOptions); - comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + if (safeOptions.length === 0) return; - const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); - cellValidator.showInputMessage(false); - cellValidator.showErrorMessage(false); + // 편집 가능한 행에만 드롭다운 적용 + editableRows.forEach(targetRow => { + try { + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); - activeSheet.setCellType(targetRow, col, comboBoxCellType); - activeSheet.setDataValidator(targetRow, col, cellValidator); - - // 🚀 편집 권한 명시적 설정 - const cell = activeSheet.getCell(targetRow, col); - cell.locked(false); + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(',')); + cellValidator.showInputMessage(false); + cellValidator.showErrorMessage(false); - console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); - } catch (cellError) { - console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); - } - }); - } catch (error) { - console.error(`❌ Dropdown config failed for column ${col}:`, error); - } - }); + activeSheet.setCellType(targetRow, col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, col, cellValidator); - // 🧊 틀고정 설정 - if (freezeColumnCount > 0) { - try { - activeSheet.frozenColumnCount(freezeColumnCount); - activeSheet.frozenRowCount(1); // 헤더 행도 고정 - - console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); - - // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) - for (let col = 0; col < freezeColumnCount; col++) { - for (let row = 0; row <= tableData.length; row++) { - try { - const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); - - if (row === 0) { - // 헤더는 기존 스타일 유지 - continue; - } else { - // 데이터 셀에 고정 구분선 추가 - if (col === freezeColumnCount - 1) { - currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); - activeSheet.setStyle(row, col, currentStyle); + // 🚀 편집 권한 명시적 설정 + const cell = activeSheet.getCell(targetRow, col); + cell.locked(false); + + console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`); + } catch (cellError) { + console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError); + } + }); + } catch (error) { + console.error(`❌ Dropdown config failed for column ${col}:`, error); + } + }); + + // 🧊 틀고정 설정 + if (freezeColumnCount > 0) { + try { + activeSheet.frozenColumnCount(freezeColumnCount); + activeSheet.frozenRowCount(1); // 헤더 행도 고정 + + console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`); + + // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항) + for (let col = 0; col < freezeColumnCount; col++) { + for (let row = 0; row <= tableData.length; row++) { + try { + const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style(); + + if (row === 0) { + // 헤더는 기존 스타일 유지 + continue; + } else { + // 데이터 셀에 고정 구분선 추가 + if (col === freezeColumnCount - 1) { + currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium); + activeSheet.setStyle(row, col, currentStyle); + } } + } catch (styleError) { + console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); } - } catch (styleError) { - console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError); } } + } catch (freezeError) { + console.error('❌ Failed to apply freeze:', freezeError); } - } catch (freezeError) { - console.error('❌ Failed to apply freeze:', freezeError); } - } - setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); - - console.log(`✅ Optimized GRD_LIST created with freeze:`); - console.log(` - Total mappings: ${mappings.length}`); - console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); - console.log(` - Dropdown configs: ${dropdownConfigs.length}`); - console.log(` - Frozen columns: ${freezeColumnCount}`); - - return mappings; -}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); + setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData); + + console.log(`✅ Optimized GRD_LIST created with freeze:`); + console.log(` - Total mappings: ${mappings.length}`); + console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`); + console.log(` - Dropdown configs: ${dropdownConfigs.length}`); + console.log(` - Frozen columns: ${freezeColumnCount}`); + + return mappings; + }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]); const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => { console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`); @@ -817,22 +817,22 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat mappings.forEach((mapping) => { const cellPos = parseCellAddress(mapping.cellAddress); if (!cellPos) return; - + try { const cell = activeSheet.getCell(cellPos.row, cellPos.col); const columnConfig = columnsJSON.find(col => col.key === mapping.attId); - + if (mapping.isEditable) { // 🚀 편집 가능한 셀 설정 강화 cell.locked(false); - + if (columnConfig?.type === "LIST" && columnConfig.options) { // LIST 타입: 새 ComboBox 인스턴스 생성 const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox(); comboBox.items(columnConfig.options); comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); activeSheet.setCellType(cellPos.row, cellPos.col, comboBox); - + // DataValidation도 추가 const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(',')); activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); @@ -840,7 +840,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat // NUMBER 타입: 숫자 입력 허용 const textCellType = new GC.Spread.Sheets.CellTypes.Text(); activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); - + // 숫자 validation 추가 (에러 메시지 없이) const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator( GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between, @@ -854,11 +854,11 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat const textCellType = new GC.Spread.Sheets.CellTypes.Text(); activeSheet.setCellType(cellPos.row, cellPos.col, textCellType); } - + // 편집 가능 스타일 재적용 const editableStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, true); activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle); - + console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`); } else { // 읽기 전용 셀 @@ -930,7 +930,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat const dataRowIndex = exactMapping.dataRowIndex; if (dataRowIndex >= 0 && dataRowIndex < tableData.length) { const rowData = tableData[dataRowIndex]; - if (rowData?.shi === "OUT" || rowData?.shi === null ) { + if (rowData?.shi === "OUT" || rowData?.shi === null) { console.log(`🚫 Row ${dataRowIndex} is in SHI mode`); toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`); info.cancel = true; @@ -998,7 +998,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat console.log('🚀 Starting optimized spread initialization...'); setIsInitializing(true); updateProgress('Initializing...', 0, 100); - + setCurrentSpread(spread); setHasChanges(false); setValidationErrors([]); @@ -1007,7 +1007,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat spread.suspendPaint(); spread.suspendEvent(); spread.suspendCalcService(); - + updateProgress('Setting up workspace...', 10, 100); try { @@ -1021,19 +1021,19 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat if (templateType === 'GRD_LIST') { updateProgress('Creating dynamic table...', 20, 100); - + spread.clearSheets(); spread.addSheet(0); const sheet = spread.getSheet(0); sheet.name('Data'); spread.setActiveSheet('Data'); - + updateProgress('Processing table data...', 50, 100); mappings = createGrdListTableOptimized(sheet, workingTemplate); - + } else { updateProgress('Loading template structure...', 20, 100); - + let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT; let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS; @@ -1042,10 +1042,10 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat } const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; - + updateProgress('Loading template layout...', 40, 100); spread.fromJSON(jsonData); - + activeSheet = getSafeActiveSheet(spread, 'after-fromJSON'); if (!activeSheet) { throw new Error('ActiveSheet became null after loading template'); @@ -1058,15 +1058,24 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat const activeSheetName = workingTemplate.SPR_LST_SETUP?.ACT_SHEET; - const matchingDataSheets = dataSheets.filter(ds => - ds.SHEET_NAME === activeSheetName - ); - if (activeSheetName && spread.getSheetFromName(activeSheetName)) { - spread.setActiveSheet(activeSheetName); - } + // 🔧 각 DATA_SHEET별로 처리 + dataSheets.forEach(dataSheet => { + const sheetName = dataSheet.SHEET_NAME; + + // 해당 시트가 존재하는지 확인 + const targetSheet = spread.getSheetFromName(sheetName); + if (!targetSheet) { + console.warn(`⚠️ Sheet '${sheetName}' not found in template`); + return; + } + + console.log(`📋 Processing sheet: ${sheetName}`); + + // 해당 시트로 전환 + spread.setActiveSheet(sheetName); + const currentSheet = spread.getActiveSheet(); - matchingDataSheets.forEach(dataSheet => { if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { const { ATT_ID, IN } = mapping; @@ -1076,11 +1085,11 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat if (!cellPos) return; const requiredRows = cellPos.row + tableData.length; - if (!ensureRowCapacity(activeSheet, requiredRows)) return; + if (!ensureRowCapacity(currentSheet, requiredRows)) return; // 🚀 배치 데이터 준비 - const valuesToSet: Array<{row: number, col: number, value: any}> = []; - const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = []; + const valuesToSet: Array<{ row: number, col: number, value: any }> = []; + const stylesToSet: Array<{ row: number, col: number, isEditable: boolean }> = []; tableData.forEach((rowData, index) => { const targetRow = cellPos.row + index; @@ -1108,67 +1117,95 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat }); // 🚀 배치 처리 - setBatchValues(activeSheet, valuesToSet); - setBatchStyles(activeSheet, stylesToSet); + setBatchValues(currentSheet, valuesToSet); + setBatchStyles(currentSheet, stylesToSet); // 드롭다운 설정 const columnConfig = columnsJSON.find(col => col.key === ATT_ID); if (columnConfig?.type === "LIST" && columnConfig.options) { const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); if (hasEditableRows) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); + setupOptimizedListValidation(currentSheet, cellPos, columnConfig.options, tableData.length); } } }); } }); - + + // 🔧 마지막에 activeSheetName으로 다시 전환 + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + activeSheet = spread.getActiveSheet(); + } + } else if (templateType === 'SPREAD_ITEM' && selectedRow) { updateProgress('Setting up form fields...', 60, 100); - const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET; + const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET; - const matchingDataSheets = dataSheets.filter(ds => - ds.SHEET_NAME === activeSheetName - ); - - if (activeSheetName && spread.getSheetFromName(activeSheetName)) { - spread.setActiveSheet(activeSheetName); + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + } + + dataSheets.forEach(dataSheet => { + + const sheetName = dataSheet.SHEET_NAME; + // 해당 시트가 존재하는지 확인 + const targetSheet = spread.getSheetFromName(sheetName); + if (!targetSheet) { + console.warn(`⚠️ Sheet '${sheetName}' not found in template`); + return; } - matchingDataSheets.forEach(dataSheet => { + console.log(`📋 Processing sheet: ${sheetName}`); + + // 해당 시트로 전환 + spread.setActiveSheet(sheetName); + const currentSheet = spread.getActiveSheet(); + + dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { const { ATT_ID, IN } = mapping; const cellPos = parseCellAddress(IN); + + if (cellPos) { const isEditable = isFieldEditable(ATT_ID); const value = selectedRow[ATT_ID]; - + mappings.push({ attId: ATT_ID, cellAddress: IN, isEditable: isEditable, dataRowIndex: 0 }); - - const cell = activeSheet.getCell(cellPos.row, cellPos.col); + + const cell = currentSheet.getCell(cellPos.row, cellPos.col); cell.value(value ?? null); - - const style = createCellStyle(activeSheet, cellPos.row, cellPos.col, isEditable); - activeSheet.setStyle(cellPos.row, cellPos.col, style); - + + const style = createCellStyle(currentSheet, cellPos.row, cellPos.col, isEditable); + currentSheet.setStyle(cellPos.row, cellPos.col, style); + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + setupOptimizedListValidation(currentSheet, cellPos, columnConfig.options, 1); } } }); + + // 🔧 마지막에 activeSheetName으로 다시 전환 + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + activeSheet = spread.getActiveSheet(); + } + + }); } } updateProgress('Configuring interactions...', 90, 100); setCellMappings(mappings); - + const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents'); if (finalActiveSheet) { setupSheetProtectionAndEvents(finalActiveSheet, mappings); @@ -1201,20 +1238,20 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat 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); @@ -1224,59 +1261,59 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat } } }); - + 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) { console.log('🔍 Starting batch save process...'); - + const updatedRows: GenericData[] = []; let saveCount = 0; let checkedCount = 0; - + for (let i = 0; i < tableData.length; i++) { const originalRow = tableData[i]; const dataToSave = { ...originalRow }; let hasRowChanges = false; - + console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`); - + cellMappings.forEach(mapping => { if (mapping.dataRowIndex === i && mapping.isEditable) { checkedCount++; - + // 🔧 isFieldEditable과 동일한 로직 사용 const rowData = tableData[i]; const fieldEditable = isFieldEditable(mapping.attId, rowData); - + console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`); - + if (fieldEditable) { const cellPos = parseCellAddress(mapping.cellAddress); if (cellPos) { const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); const originalValue = originalRow[mapping.attId]; - + // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리) const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim(); const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim(); - + console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`); - + if (normalizedCellValue !== normalizedOriginalValue) { dataToSave[mapping.attId] = cellValue; hasRowChanges = true; @@ -1286,18 +1323,18 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat } } }); - + if (hasRowChanges) { console.log(`💾 Saving row ${i} with changes`); dataToSave.TAG_NO = originalRow.TAG_NO; - + try { const { success, message } = await updateFormDataInDB( formCode, contractItemId, dataToSave ); - + if (success) { updatedRows.push(dataToSave); saveCount++; @@ -1317,9 +1354,9 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat console.log(`ℹ️ No changes in row ${i}`); } } - + console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`); - + if (saveCount > 0) { toast.success(`${saveCount} rows saved successfully!`); onUpdateSuccess?.(updatedRows); @@ -1328,10 +1365,10 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat toast.warning("No actual changes were found to save. Please check if the values were properly edited."); } } - + setHasChanges(false); setValidationErrors([]); - + } catch (error) { console.error("Error saving changes:", error); toast.error("An unexpected error occurred while saving"); @@ -1339,16 +1376,16 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat setIsPending(false); } }, [ - currentSpread, - hasChanges, - templateType, - selectedRow, - tableData, - formCode, - contractItemId, - onUpdateSuccess, - cellMappings, - columnsJSON, + currentSpread, + hasChanges, + templateType, + selectedRow, + tableData, + formCode, + contractItemId, + onUpdateSuccess, + cellMappings, + columnsJSON, validateAllData, isFieldEditable // 🔧 의존성 추가 ]); @@ -1357,11 +1394,11 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat 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-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50" + className="w-[95vw] max-w-[95vw] h-[90vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50" > <DialogHeader className="flex-shrink-0"> <DialogTitle>SEDP Template - {formCode}</DialogTitle> @@ -1439,13 +1476,13 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat <div className="flex-1 overflow-hidden relative"> {/* 🆕 로딩 프로그레스 오버레이 */} - <LoadingProgress + <LoadingProgress phase={loadingProgress?.phase || ''} progress={loadingProgress?.progress || 0} total={loadingProgress?.total || 100} isVisible={isInitializing && !!loadingProgress} /> - + {selectedTemplate && isClient && isDataValid ? ( <SpreadSheets key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} diff --git a/db/schema/rfqLastTBE.ts b/db/schema/rfqLastTBE.ts index 8800cd3d..e690ce4b 100644 --- a/db/schema/rfqLastTBE.ts +++ b/db/schema/rfqLastTBE.ts @@ -34,7 +34,7 @@ export const rfqLastTbeSessions = pgTable( // 상태 관리 status: varchar("status", { length: 30 }) - .$type<"준비중" | "진행중" | "검토중" | "보류" | "완료" | "취소">() + .$type<"생성중"|"준비중" | "진행중" | "검토중" | "보류" | "완료" | "취소">() .notNull() .default("준비중"), diff --git a/db/schema/vendorDocu.ts b/db/schema/vendorDocu.ts index 3d9ad46c..1c634f64 100644 --- a/db/schema/vendorDocu.ts +++ b/db/schema/vendorDocu.ts @@ -1137,7 +1137,7 @@ export const simplifiedDocumentsView = pgView("simplified_documents_view", { -- projects, vendors 테이블 JOIN (projectId가 이제 documents에 직접 있음) LEFT JOIN projects p ON d.project_id = p.id AND p.type = 'ship' LEFT JOIN contracts c ON d.contract_id = c.id - LEFT JOIN vendors v ON c.vendor_id = v.id + LEFT JOIN vendors v ON d.vendor_id = v.id -- 스테이지 정보 JOIN LEFT JOIN first_stage_info fsi ON d.id = fsi.document_id diff --git a/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx b/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx index 93681c09..31337675 100644 --- a/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx +++ b/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx @@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { toast } from "sonner" import * as z from "zod" -import { Plus } from "lucide-react" +import { Plus, Check, ChevronsUpDown } from "lucide-react" import { Button } from "@/components/ui/button" import { @@ -25,12 +25,62 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" -import { Input } from "@/components/ui/input" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { useParams } from "next/navigation" import { createDocumentClassOptionItem } from "@/lib/docu-list-rule/document-class/service" +import { getProjectCode } from "@/lib/projects/service" + +// API 응답 타입 +interface ScheduleSetting { + COL_NM: string + DC_OBX_USE_YN: string + PROJ_COL_NM: string + PROJ_COL_NM_EN: string + SCD_VIEW_MGNT: string + USE_YN1: string + USE_YN2: string +} + +// 프로젝트 일정 설정을 가져오는 함수 +async function getProjectKindScheduleSetting(projectCode: string): Promise<ScheduleSetting[]> { + try { + const response = await fetch( + `http://60.100.99.217/DDP/Services/VNDRService.svc/GetProjectKindScheduleSetting?PROJ_NO=${projectCode}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + throw new Error('Failed to fetch schedule settings') + } + + const data = await response.json() + return data.GetProjectKindScheduleSettingResult || [] + } catch (error) { + console.error('Error fetching schedule settings:', error) + return [] + } +} const createOptionSchema = z.object({ - optionCode: z.string().min(1, "코드는 필수입니다."), + optionCode: z.string().min(1, "옵션을 선택해주세요."), }) type CreateOptionSchema = z.infer<typeof createOptionSchema> @@ -42,7 +92,12 @@ interface DocumentClassOptionAddDialogProps { export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: DocumentClassOptionAddDialogProps) { const [open, setOpen] = React.useState(false) + const [comboboxOpen, setComboboxOpen] = React.useState(false) const [isPending, startTransition] = React.useTransition() + const [scheduleSettings, setScheduleSettings] = React.useState<ScheduleSetting[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const params = useParams() + const projectId = Number(params?.projectId) const form = useForm<CreateOptionSchema>({ resolver: zodResolver(createOptionSchema), @@ -51,6 +106,35 @@ export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: Doc }, }) + // Dialog가 열릴 때 데이터 로드 + React.useEffect(() => { + if (open && projectId) { + loadScheduleSettings() + } + }, [open, projectId]) + + const loadScheduleSettings = async () => { + setIsLoading(true) + try { + // 먼저 projectId로 프로젝트 코드 가져오기 + const projectCode = await getProjectCode(projectId) + + if (!projectCode) { + toast.error("프로젝트 코드를 찾을 수 없습니다.") + return + } + + // 프로젝트 코드로 일정 설정 가져오기 + const settings = await getProjectKindScheduleSetting(projectCode) + setScheduleSettings(settings) + } catch (error) { + console.error("Error loading schedule settings:", error) + toast.error("옵션 목록을 불러오는 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + const handleSubmit = (data: CreateOptionSchema) => { startTransition(async () => { try { @@ -79,6 +163,10 @@ export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: Doc form.reset() } + const selectedOption = scheduleSettings.find( + (setting) => setting.COL_NM === form.watch("optionCode") + ) + return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> @@ -100,11 +188,66 @@ export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: Doc control={form.control} name="optionCode" render={({ field }) => ( - <FormItem> - <FormLabel>코드</FormLabel> - <FormControl> - <Input {...field} placeholder="옵션 코드" /> - </FormControl> + <FormItem className="flex flex-col"> + <FormLabel>옵션 선택</FormLabel> + <Popover open={comboboxOpen} onOpenChange={setComboboxOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={comboboxOpen} + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + disabled={isLoading} + > + {isLoading + ? "로딩 중..." + : selectedOption + ? `${selectedOption.COL_NM} - ${selectedOption.PROJ_COL_NM}` + : "옵션을 선택하세요"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="옵션 검색..." /> + <CommandEmpty> + {isLoading ? "로딩 중..." : "검색 결과가 없습니다."} + </CommandEmpty> + <CommandGroup className="max-h-[200px] overflow-auto"> + {scheduleSettings.map((setting) => ( + <CommandItem + key={setting.COL_NM} + value={`${setting.COL_NM} ${setting.PROJ_COL_NM}`} + onSelect={() => { + form.setValue("optionCode", setting.COL_NM) + setComboboxOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + field.value === setting.COL_NM + ? "opacity-100" + : "opacity-0" + )} + /> + <div className="flex flex-col"> + <span className="font-medium">{setting.COL_NM}</span> + <span className="text-sm text-muted-foreground"> + {setting.PROJ_COL_NM} + </span> + </div> + </CommandItem> + ))} + </CommandGroup> + </Command> + </PopoverContent> + </Popover> <FormMessage /> </FormItem> )} @@ -122,4 +265,4 @@ export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: Doc </DialogContent> </Dialog> ) -}
\ No newline at end of file +}
\ No newline at end of file diff --git a/lib/evaluation-criteria/stat.ts b/lib/evaluation-criteria/stat.ts new file mode 100644 index 00000000..c39c8627 --- /dev/null +++ b/lib/evaluation-criteria/stat.ts @@ -0,0 +1,413 @@ +"use server" + +import db from "@/db/db" +import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes, projects } from "@/db/schema" +import { eq, and, inArray } from "drizzle-orm" +import { getEditableFieldsByTag } from "./services" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +interface VendorFormStatus { + vendorId: number + vendorName: string + formCount: number // 벤더가 가진 form 개수 + tagCount: number // 벤더가 가진 tag 개수 + totalFields: number // 입력해야 하는 총 필드 개수 + completedFields: number // 입력 완료된 필드 개수 + completionRate: number // 완료율 (%) +} + +export interface FormStatusByVendor { + tagCount: number; + totalFields: number; + completedFields: number; + completionRate: number; + upcomingCount: number; // 7일 이내 임박한 개수 + overdueCount: number; // 지연된 개수 +} + +export async function getProjectsWithContracts() { + try { + const projectList = await db + .selectDistinct({ + id: projects.id, + projectCode: projects.code, + projectName: projects.name, + }) + .from(projects) + .innerJoin(contracts, eq(contracts.projectId, projects.id)) + .orderBy(projects.code) + + return projectList + } catch (error) { + console.error('Error getting projects with contracts:', error) + throw new Error('계약이 있는 프로젝트 조회 중 오류가 발생했습니다.') + } +} + + + +export async function getVendorFormStatus(projectId?: number): Promise<VendorFormStatus[]> { + try { + // 1. 벤더 조회 쿼리 수정 + const vendorList = projectId + ? await db + .selectDistinct({ + vendorId: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) + .where(eq(contracts.projectId, projectId)) + : await db + .selectDistinct({ + vendorId: vendors.id, + vendorName: vendors.vendorName, + }) + .from(vendors) + .innerJoin(contracts, eq(contracts.vendorId, vendors.id)) + + + const vendorStatusList: VendorFormStatus[] = [] + + for (const vendor of vendorList) { + let vendorFormCount = 0 + let vendorTagCount = 0 + let vendorTotalFields = 0 + let vendorCompletedFields = 0 + const uniqueTags = new Set<string>() + + // 2. 계약 조회 시 projectId 필터 추가 + const vendorContracts = projectId + ? await db + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where( + and( + eq(contracts.vendorId, vendor.vendorId), + eq(contracts.projectId, projectId) + ) + ) + : await db + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where(eq(contracts.vendorId, vendor.vendorId)) + + + for (const contract of vendorContracts) { + // 3. 계약별 contractItems 조회 + const contractItemsList = await db + .select({ + id: contractItems.id + }) + .from(contractItems) + .where(eq(contractItems.contractId, contract.id)) + + for (const contractItem of contractItemsList) { + // 4. contractItem별 forms 조회 + const formsList = await db + .select({ + id: forms.id, + formCode: forms.formCode, + contractItemId: forms.contractItemId + }) + .from(forms) + .where(eq(forms.contractItemId, contractItem.id)) + + vendorFormCount += formsList.length + + // 5. formEntries 조회 + const entriesList = await db + .select({ + id: formEntries.id, + formCode: formEntries.formCode, + data: formEntries.data + }) + .from(formEntries) + .where(eq(formEntries.contractItemId, contractItem.id)) + + // 6. TAG별 편집 가능 필드 조회 + const editableFieldsByTag = await getEditableFieldsByTag(contractItem.id, contract.projectId) + + for (const entry of entriesList) { + // formMetas에서 해당 formCode의 columns 조회 + const metaResult = await db + .select({ + columns: formMetas.columns + }) + .from(formMetas) + .where( + and( + eq(formMetas.formCode, entry.formCode), + eq(formMetas.projectId, contract.projectId) + ) + ) + .limit(1) + + if (metaResult.length === 0) continue + + const metaColumns = metaResult[0].columns as any[] + + // shi가 'IN' 또는 'BOTH'인 필드 찾기 + const inputRequiredFields = metaColumns + .filter(col => col.shi === 'IN' || col.shi === 'BOTH') + .map(col => col.key) + + // entry.data 분석 (배열로 가정) + const dataArray = Array.isArray(entry.data) ? entry.data : [] + + for (const dataItem of dataArray) { + if (typeof dataItem !== 'object' || !dataItem) continue + + const tagNo = dataItem.TAG_NO + if (tagNo) { + uniqueTags.add(tagNo) + + // TAG별 편집 가능 필드 가져오기 + const tagEditableFields = editableFieldsByTag.get(tagNo) || [] + + // 최종 입력 필요 필드 = shi 기반 필드 + TAG 기반 편집 가능 필드 + const allRequiredFields = inputRequiredFields.filter(field => + tagEditableFields.includes(field) + ) + // 각 필드별 입력 상태 체크 + for (const fieldKey of allRequiredFields) { + vendorTotalFields++ + + const fieldValue = dataItem[fieldKey] + // 값이 있고, 빈 문자열이 아니고, null이 아니면 입력 완료 + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { + vendorCompletedFields++ + } + } + } + } + } + } + } + + // 완료율 계산 + const completionRate = vendorTotalFields > 0 + ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10 + : 0 + + vendorStatusList.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName || '이름 없음', + formCount: vendorFormCount, + tagCount: uniqueTags.size, + totalFields: vendorTotalFields, + completedFields: vendorCompletedFields, + completionRate + }) + } + + return vendorStatusList + + } catch (error) { + console.error('Error getting vendor form status:', error) + throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.') + } +} + + + +export async function getFormStatusByVendor(projectId: number, formCode: string): Promise<FormStatusByVendor[]> { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const vendorStatusList: FormStatusByVendor[] = [] + const vendorId = Number(session.user.companyId) + + const vendorContracts = await db + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where( + and( + eq(contracts.vendorId, vendorId), + eq(contracts.projectId, projectId) + ) + ) + + const contractIds = vendorContracts.map(v => v.id) + + const contractItemsList = await db + .select({ + id: contractItems.id + }) + .from(contractItems) + .where(inArray(contractItems.contractId, contractIds)) + + const contractItemIds = contractItemsList.map(v => v.id) + + let vendorFormCount = 0 + let vendorTagCount = 0 + let vendorTotalFields = 0 + let vendorCompletedFields = 0 + let vendorUpcomingCount = 0 // 7일 이내 임박한 개수 + let vendorOverdueCount = 0 // 지연된 개수 + const uniqueTags = new Set<string>() + const processedTags = new Set<string>() // 중복 처리 방지용 + + // 현재 날짜와 7일 후 날짜 계산 + const today = new Date() + today.setHours(0, 0, 0, 0) // 시간 부분 제거 + const sevenDaysLater = new Date(today) + sevenDaysLater.setDate(sevenDaysLater.getDate() + 7) + + // 4. contractItem별 forms 조회 + const formsList = await db + .select({ + id: forms.id, + formCode: forms.formCode, + contractItemId: forms.contractItemId + }) + .from(forms) + .where( + and( + inArray(forms.contractItemId, contractItemIds), + eq(forms.formCode, formCode) + ) + ) + + vendorFormCount += formsList.length + + // 5. formEntries 조회 + const entriesList = await db + .select({ + id: formEntries.id, + formCode: formEntries.formCode, + data: formEntries.data + }) + .from(formEntries) + .where( + and( + inArray(formEntries.contractItemId, contractItemIds), + eq(formEntries.formCode, formCode) + ) + ) + + // 6. TAG별 편집 가능 필드 조회 + const editableFieldsByTag = new Map<string, string[]>() + + for (const contractItemId of contractItemIds) { + const tagFields = await getEditableFieldsByTag(contractItemId, projectId) + + tagFields.forEach((fields, tagNo) => { + if (!editableFieldsByTag.has(tagNo)) { + editableFieldsByTag.set(tagNo, fields) + } else { + const existingFields = editableFieldsByTag.get(tagNo) || [] + const mergedFields = [...new Set([...existingFields, ...fields])] + editableFieldsByTag.set(tagNo, mergedFields) + } + }) + } + + for (const entry of entriesList) { + const metaResult = await db + .select({ + columns: formMetas.columns + }) + .from(formMetas) + .where( + and( + eq(formMetas.formCode, entry.formCode), + eq(formMetas.projectId, projectId) + ) + ) + .limit(1) + + if (metaResult.length === 0) continue + + const metaColumns = metaResult[0].columns as any[] + + const inputRequiredFields = metaColumns + .filter(col => col.shi === 'IN' || col.shi === 'BOTH') + .map(col => col.key) + + const dataArray = Array.isArray(entry.data) ? entry.data : [] + + for (const dataItem of dataArray) { + if (typeof dataItem !== 'object' || !dataItem) continue + + const tagNo = dataItem.TAG_NO + if (tagNo) { + uniqueTags.add(tagNo) + + // TAG별 편집 가능 필드 가져오기 + const tagEditableFields = editableFieldsByTag.get(tagNo) || [] + + const allRequiredFields = inputRequiredFields.filter(field => + tagEditableFields.includes(field) + ) + + // 해당 TAG의 필드 완료 상태 체크 + let tagHasIncompleteFields = false + + for (const fieldKey of allRequiredFields) { + vendorTotalFields++ + + const fieldValue = dataItem[fieldKey] + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { + vendorCompletedFields++ + } else { + tagHasIncompleteFields = true + } + } + + // 미완료 TAG에 대해서만 날짜 체크 (TAG당 한 번만 처리) + if (!processedTags.has(tagNo) && tagHasIncompleteFields) { + processedTags.add(tagNo) + + const targetDate = dataItem.target_date + if (targetDate) { + const target = new Date(targetDate) + target.setHours(0, 0, 0, 0) // 시간 부분 제거 + + if (target < today) { + // 미완료이면서 지연된 경우 (오늘보다 이전) + vendorOverdueCount++ + } else if (target >= today && target <= sevenDaysLater) { + // 미완료이면서 7일 이내 임박한 경우 + vendorUpcomingCount++ + } + } + } + } + } + } + + // 완료율 계산 + const completionRate = vendorTotalFields > 0 + ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10 + : 0 + + vendorStatusList.push({ + tagCount: uniqueTags.size, + totalFields: vendorTotalFields, + completedFields: vendorCompletedFields, + completionRate, + upcomingCount: vendorUpcomingCount, + overdueCount: vendorOverdueCount + }) + + return vendorStatusList + + } catch (error) { + console.error('Error getting vendor form status:', error) + throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.') + } +}
\ No newline at end of file diff --git a/lib/forms/stat.ts b/lib/forms/stat.ts index 80193c48..054f2462 100644 --- a/lib/forms/stat.ts +++ b/lib/forms/stat.ts @@ -2,8 +2,10 @@ import db from "@/db/db" import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes, projects } from "@/db/schema" -import { eq, and } from "drizzle-orm" +import { eq, and, inArray } from "drizzle-orm" import { getEditableFieldsByTag } from "./services" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" interface VendorFormStatus { vendorId: number @@ -15,6 +17,15 @@ interface VendorFormStatus { completionRate: number // 완료율 (%) } +export interface FormStatusByVendor { + tagCount: number; + totalFields: number; + completedFields: number; + completionRate: number; + upcomingCount: number; // 7일 이내 임박한 개수 + overdueCount: number; // 지연된 개수 +} + export async function getProjectsWithContracts() { try { const projectList = await db @@ -204,3 +215,199 @@ export async function getVendorFormStatus(projectId?: number): Promise<VendorFor throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.') } } + + + +export async function getFormStatusByVendor(projectId: number, formCode: string): Promise<FormStatusByVendor[]> { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const vendorStatusList: FormStatusByVendor[] = [] + const vendorId = Number(session.user.companyId) + + const vendorContracts = await db + .select({ + id: contracts.id, + projectId: contracts.projectId + }) + .from(contracts) + .where( + and( + eq(contracts.vendorId, vendorId), + eq(contracts.projectId, projectId) + ) + ) + + const contractIds = vendorContracts.map(v => v.id) + + const contractItemsList = await db + .select({ + id: contractItems.id + }) + .from(contractItems) + .where(inArray(contractItems.contractId, contractIds)) + + const contractItemIds = contractItemsList.map(v => v.id) + + let vendorFormCount = 0 + let vendorTagCount = 0 + let vendorTotalFields = 0 + let vendorCompletedFields = 0 + let vendorUpcomingCount = 0 // 7일 이내 임박한 개수 + let vendorOverdueCount = 0 // 지연된 개수 + const uniqueTags = new Set<string>() + const processedTags = new Set<string>() // 중복 처리 방지용 + + // 현재 날짜와 7일 후 날짜 계산 + const today = new Date() + today.setHours(0, 0, 0, 0) // 시간 부분 제거 + const sevenDaysLater = new Date(today) + sevenDaysLater.setDate(sevenDaysLater.getDate() + 7) + + // 4. contractItem별 forms 조회 + const formsList = await db + .select({ + id: forms.id, + formCode: forms.formCode, + contractItemId: forms.contractItemId + }) + .from(forms) + .where( + and( + inArray(forms.contractItemId, contractItemIds), + eq(forms.formCode, formCode) + ) + ) + + vendorFormCount += formsList.length + + // 5. formEntries 조회 + const entriesList = await db + .select({ + id: formEntries.id, + formCode: formEntries.formCode, + data: formEntries.data + }) + .from(formEntries) + .where( + and( + inArray(formEntries.contractItemId, contractItemIds), + eq(formEntries.formCode, formCode) + ) + ) + + // 6. TAG별 편집 가능 필드 조회 + const editableFieldsByTag = new Map<string, string[]>() + + for (const contractItemId of contractItemIds) { + const tagFields = await getEditableFieldsByTag(contractItemId, projectId) + + tagFields.forEach((fields, tagNo) => { + if (!editableFieldsByTag.has(tagNo)) { + editableFieldsByTag.set(tagNo, fields) + } else { + const existingFields = editableFieldsByTag.get(tagNo) || [] + const mergedFields = [...new Set([...existingFields, ...fields])] + editableFieldsByTag.set(tagNo, mergedFields) + } + }) + } + + for (const entry of entriesList) { + const metaResult = await db + .select({ + columns: formMetas.columns + }) + .from(formMetas) + .where( + and( + eq(formMetas.formCode, entry.formCode), + eq(formMetas.projectId, projectId) + ) + ) + .limit(1) + + if (metaResult.length === 0) continue + + const metaColumns = metaResult[0].columns as any[] + + const inputRequiredFields = metaColumns + .filter(col => col.shi === 'IN' || col.shi === 'BOTH') + .map(col => col.key) + + const dataArray = Array.isArray(entry.data) ? entry.data : [] + + for (const dataItem of dataArray) { + if (typeof dataItem !== 'object' || !dataItem) continue + + const tagNo = dataItem.TAG_NO + if (tagNo) { + uniqueTags.add(tagNo) + + // TAG별 편집 가능 필드 가져오기 + const tagEditableFields = editableFieldsByTag.get(tagNo) || [] + + const allRequiredFields = inputRequiredFields.filter(field => + tagEditableFields.includes(field) + ) + + // 해당 TAG의 필드 완료 상태 체크 + let tagHasIncompleteFields = false + + for (const fieldKey of allRequiredFields) { + vendorTotalFields++ + + const fieldValue = dataItem[fieldKey] + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { + vendorCompletedFields++ + } else { + tagHasIncompleteFields = true + } + } + + // 미완료 TAG에 대해서만 날짜 체크 (TAG당 한 번만 처리) + if (!processedTags.has(tagNo) && tagHasIncompleteFields) { + processedTags.add(tagNo) + + const targetDate = dataItem.DUE_DATE + if (targetDate) { + const target = new Date(targetDate) + target.setHours(0, 0, 0, 0) // 시간 부분 제거 + + if (target < today) { + // 미완료이면서 지연된 경우 (오늘보다 이전) + vendorOverdueCount++ + } else if (target >= today && target <= sevenDaysLater) { + // 미완료이면서 7일 이내 임박한 경우 + vendorUpcomingCount++ + } + } + } + } + } + } + + // 완료율 계산 + const completionRate = vendorTotalFields > 0 + ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10 + : 0 + + vendorStatusList.push({ + tagCount: uniqueTags.size, + totalFields: vendorTotalFields, + completedFields: vendorCompletedFields, + completionRate, + upcomingCount: vendorUpcomingCount, + overdueCount: vendorOverdueCount + }) + + return vendorStatusList + + } catch (error) { + console.error('Error getting vendor form status:', error) + throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.') + } +}
\ No newline at end of file diff --git a/lib/mail/templates/pq.hbs b/lib/mail/templates/pq.hbs index 02523696..abdff056 100644 --- a/lib/mail/templates/pq.hbs +++ b/lib/mail/templates/pq.hbs @@ -61,9 +61,6 @@ 아래의 해당 링크를 통해 당사 eVCP시스템에 접속하시어 요청드린 PQ 항목 및 자료에 대한 제출 요청드립니다.
</p>
- <p style="font-size:16px; line-height:32px;">
- 별도의 견적을 제출하시어 당사에서 적극 검토할 수 있도록 협조 바랍니다.
- </p>
<p style="font-size:16px; line-height:32px;">
귀사의 제출 자료 및 정보는 아래의 제출 마감일 이전에 당사로 제출 되어야 하며,
diff --git a/lib/mail/templates/vendor-rejected.hbs b/lib/mail/templates/vendor-rejected.hbs new file mode 100644 index 00000000..22a1d6f3 --- /dev/null +++ b/lib/mail/templates/vendor-rejected.hbs @@ -0,0 +1,195 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>eVCP 메일</title> + <style> + body { + margin: 0 !important; + padding: 20px !important; + background-color: #f4f4f4; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + } + .email-container { + max-width: 600px; + margin: 0 auto; + background-color: #ffffff; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + .rejected-badge { + display: inline-block; + background-color: #ef4444; + color: white; + padding: 4px 12px; + border-radius: 16px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 16px; + } + .cta-button { + display: inline-block; + width: 250px; + padding: 12px 20px; + background-color: #163CC4; + color: #ffffff !important; + text-decoration: none; + border-radius: 8px; + text-align: center; + line-height: 28px; + margin: 8px 0; + } + .highlight-box { + background-color: #fef2f2; + border-left: 4px solid #ef4444; + padding: 16px; + margin: 16px 0; + border-radius: 0 8px 8px 0; + } + </style> +</head> +<body> + <div class="email-container"> + <!-- Header --> + <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;"> + <tr> + <td align="center"> + <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span> + </td> + </tr> + </table> + + <!-- Rejected Badge --> + <div class="rejected-badge"> + {{#if (eq language 'ko')}}등록 거절{{else}}REGISTRATION REJECTED{{/if}} + </div> + + <!-- Title --> + <h1 style="font-size:28px; margin-bottom:16px; color:#111827;"> + {{#if (eq language 'ko')}} + 업체 등록 신청이 거절되었습니다 + {{else}} + Your Vendor Registration Has Been Rejected + {{/if}} + </h1> + + <!-- Greeting --> + <p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{#if (eq language 'ko')}} + 안녕하세요, <strong>{{vendorName}}</strong> 담당자님. + {{else}} + Hello, <strong>{{vendorName}}</strong> representative. + {{/if}} + </p> + + <!-- Main Content --> + <p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{#if (eq language 'ko')}} + 귀하의 업체 등록 신청이 검토 결과 거절되었습니다. + 등록 기준에 부합하지 않거나 추가 정보 확인이 필요하여 거절 처리되었습니다. + {{else}} + After review, your vendor registration application has been rejected. + The application did not meet our registration criteria or required additional verification. + {{/if}} + </p> + + <!-- Highlight Box --> + <div class="highlight-box"> + <h3 style="margin-top:0; margin-bottom:12px; color:#dc2626;"> + {{#if (eq language 'ko')}}거절 사유 및 향후 절차{{else}}Rejection Reason & Next Steps{{/if}} + </h3> + <ol style="margin:0; padding-left:20px;"> + <li style="margin-bottom:8px;"> + {{#if (eq language 'ko')}} + 등록 기준에 부합하지 않거나 제출 정보가 불충분합니다 + {{else}} + The application did not meet registration criteria or submitted information was insufficient + {{/if}} + </li> + <li style="margin-bottom:8px;"> + {{#if (eq language 'ko')}} + 추가 정보 확인 및 보완이 필요한 경우 재신청 가능합니다 + {{else}} + You may reapply if you can provide additional information and meet requirements + {{/if}} + </li> + <li style="margin-bottom:8px;"> + {{#if (eq language 'ko')}} + 재신청을 원하시면 모든 필요 서류를 준비하여 다시 신청해주세요 + {{else}} + If you wish to reapply, please prepare all required documents and submit again + {{/if}} + </li> + </ol> + </div> + + <!-- Action Button --> + <div style="text-align: center; margin: 24px 0;"> + <a href="{{loginUrl}}" target="_blank" class="cta-button"> + {{#if (eq language 'ko')}} + eVCP 플랫폼 방문하기 + {{else}} + Visit eVCP Platform + {{/if}} + </a> + </div> + + <!-- Account Info --> + <div style="background-color:#f9fafb; padding:16px; border-radius:8px; margin:16px 0;"> + <h4 style="margin-top:0; margin-bottom:12px; color:#374151;"> + {{#if (eq language 'ko')}}신청 정보{{else}}Application Information{{/if}} + </h4> + <p style="margin:4px 0; font-size:14px; color:#6b7280;"> + <strong>{{#if (eq language 'ko')}}업체명{{else}}Company{{/if}}:</strong> {{vendorName}} + </p> + <p style="margin:4px 0; font-size:14px; color:#6b7280;"> + <strong>{{#if (eq language 'ko')}}이메일{{else}}Email{{/if}}:</strong> {{email}} + </p> + <p style="margin:4px 0; font-size:14px; color:#6b7280;"> + <strong>{{#if (eq language 'ko')}}신청 상태{{else}}Application Status{{/if}}:</strong> + <span style="color:#ef4444; font-weight:600;"> + {{#if (eq language 'ko')}}거절됨{{else}}Rejected{{/if}} + </span> + </p> + </div> + + <!-- Support Message --> + <p style="font-size:16px; line-height:24px; margin-top:24px; color:#6b7280;"> + {{#if (eq language 'ko')}} + 등록 기준에 대한 자세한 내용은 eVCP 플랫폼을 방문하시거나 + <a href="mailto:{{supportEmail}}" style="color:#163CC4;">{{supportEmail}}</a>로 + 문의해 주세요. + {{else}} + For more information about registration criteria, please visit the eVCP platform or + contact us at <a href="mailto:{{supportEmail}}" style="color:#163CC4;">{{supportEmail}}</a>. + {{/if}} + </p> + + <!-- Footer --> + <table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;"> + <tr> + <td align="center"> + <p style="font-size:14px; color:#6b7280; margin:4px 0;"> + © {{currentYear}} EVCP. + {{#if (eq language 'ko')}} + 모든 권리 보유. + {{else}} + All rights reserved. + {{/if}} + </p> + <p style="font-size:14px; color:#6b7280; margin:4px 0;"> + {{#if (eq language 'ko')}} + 본 이메일은 발신 전용입니다. 회신하지 마세요. + {{else}} + This is an automated email. Please do not reply. + {{/if}} + </p> + </td> + </tr> + </table> + </div> +</body> +</html> diff --git a/lib/projects/service.ts b/lib/projects/service.ts index 3f562e20..4685fce4 100644 --- a/lib/projects/service.ts +++ b/lib/projects/service.ts @@ -112,4 +112,27 @@ export async function getAllProjectInfoByProjectCode(projectCode: string) { .from(projects) .where(eq(projects.code, projectCode)) .limit(1); +} + +/** + * projectId로 프로젝트 코드를 가져오는 함수 + * @param projectId - 프로젝트 ID + * @returns 프로젝트 코드 또는 null + */ +export async function getProjectCode(projectId: number): Promise<string | null> { + try { + const project = await db.project.findUnique({ + where: { + id: projectId, + }, + select: { + code: true, + }, + }) + + return project?.code || null + } catch (error) { + console.error("Error fetching project code:", error) + return null + } }
\ No newline at end of file diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx index 8be5210f..076fb153 100644 --- a/lib/rfq-last/attachment/vendor-response-table.tsx +++ b/lib/rfq-last/attachment/vendor-response-table.tsx @@ -159,7 +159,6 @@ export function VendorResponseTable({ const [isUpdating, setIsUpdating] = React.useState(false); const [showTypeDialog, setShowTypeDialog] = React.useState(false); const [selectedType, setSelectedType] = React.useState<"구매" | "설계" | "">(""); - console.log(data,"data") const [selectedVendor, setSelectedVendor] = React.useState<string | null>(null); diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 8eed9bee..09d707d7 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3643,7 +3643,7 @@ async function handleTbeSession({ sessionCode: sessionCode, sessionTitle: `${rfqData.rfqCode} - ${vendor.vendorName} 기술검토`, sessionType: "initial", - status: "준비중", + status: "생성중", evaluationResult: null, plannedStartDate: rfqData.dueDate ? addDays(new Date(rfqData.dueDate), 1) @@ -4738,13 +4738,12 @@ export async function updateShortList( // 트랜잭션으로 처리 const result = await db.transaction(async (tx) => { - // 해당 RFQ의 모든 벤더들의 shortList를 먼저 false로 설정 (선택적) - // 만약 선택된 것만 true로 하고 나머지는 그대로 두려면 이 부분 제거 + // 1. 해당 RFQ의 모든 벤더들의 shortList를 먼저 false로 설정 await tx .update(rfqLastDetails) .set({ shortList: false, - updatedBy: session.user.id, + updatedBy: Number(session.user.id), updatedAt: new Date() }) .where( @@ -4754,15 +4753,16 @@ export async function updateShortList( ) ); - // 선택된 벤더들의 shortList를 true로 설정 + // 2. 선택된 벤더들 처리 if (vendorIds.length > 0) { - const updates = await Promise.all( + // 2-1. 선택된 벤더들의 shortList를 true로 설정 + const updatedDetails = await Promise.all( vendorIds.map(vendorId => tx .update(rfqLastDetails) .set({ shortList: shortListStatus, - updatedBy: session.user.id, + updatedBy: Number(session.user.id), updatedAt: new Date() }) .where( @@ -4776,17 +4776,84 @@ export async function updateShortList( ) ); + // 2-2. TBE 세션 처리 (shortList가 true인 경우에만) + if (shortListStatus) { + // 각 벤더에 대한 rfqLastDetailsId 추출 + const detailsMap = new Map( + updatedDetails.flat().map(detail => [detail.vendorsId, detail.id]) + ); + + // TBE 세션 생성 또는 업데이트 + await Promise.all( + vendorIds.map(async (vendorId) => { + const rfqLastDetailsId = detailsMap.get(vendorId); + + if (!rfqLastDetailsId) { + console.warn(`rfqLastDetailsId not found for vendorId: ${vendorId}`); + return; + } + + // 기존 활성 TBE 세션이 있는지 확인 + const existingSession = await tx + .select() + .from(rfqLastTbeSessions) + .where( + and( + eq(rfqLastTbeSessions.rfqsLastId, rfqId), + eq(rfqLastTbeSessions.vendorId, vendorId), + inArray(rfqLastTbeSessions.status, ["생성중", "준비중", "진행중", "검토중", "보류"]) + ) + ) + .limit(1); + + if (existingSession.length > 0) { + // 기존 세션이 있으면 상태 업데이트 + await tx + .update(rfqLastTbeSessions) + .set({ + status: "준비중", + updatedBy: session.user.id, + updatedAt: new Date() + }) + .where(eq(rfqLastTbeSessions.id, existingSession[0].id)); + } + }) + ); + } else { + // shortList가 false인 경우, 해당 벤더들의 활성 TBE 세션을 취소 상태로 변경 + await Promise.all( + vendorIds.map(vendorId => + tx + .update(rfqLastTbeSessions) + .set({ + status: "취소", + updatedBy: Number(session.user.id), + updatedAt: new Date() + }) + .where( + and( + eq(rfqLastTbeSessions.rfqsLastId, rfqId), + eq(rfqLastTbeSessions.vendorId, vendorId), + inArray(rfqLastTbeSessions.status, ["생성중", "준비중", "진행중", "검토중", "보류"]) + ) + ) + ) + ); + } + return { success: true, - updatedCount: updates.length, - vendorIds + updatedCount: updatedDetails.length, + vendorIds, + tbeSessionsUpdated: shortListStatus }; } return { success: true, updatedCount: 0, - vendorIds: [] + vendorIds: [], + tbeSessionsUpdated: false }; }); diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts index 5615db7a..6a5816d4 100644 --- a/lib/rfq-last/validations.ts +++ b/lib/rfq-last/validations.ts @@ -56,7 +56,7 @@ import { RfqLastAttachments } from "@/db/schema"; search: parseAsString.withDefault(""), // RFQ 카테고리 (전체/일반견적/ITB/RFQ) - rfqCategory: parseAsStringEnum(["all", "general", "itb", "rfq"]).withDefault("all"), + rfqCategory: parseAsStringEnum(["all", "general", "itb", "rfq"]), }); // ============= 타입 정의 ============= diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 89a42602..17433773 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -360,7 +360,7 @@ export function RfqVendorTable({ // 선택된 벤더 ID들 추출 const selectedVendorIds = rfqCode?.startsWith("I") ? selectedRows - .filter(v => v.shortList) + // .filter(v => v.shortList) .map(row => row.vendorId) .filter(id => id != null) : selectedRows @@ -1218,7 +1218,7 @@ export function RfqVendorTable({ }, size: 80, }, - ...(rfqCode?.startsWith("I") ? [{ + ...(!rfqCode?.startsWith("F") ? [{ accessorKey: "shortList", filterFn: createFilterFn("boolean"), // boolean으로 변경 header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />, @@ -1482,7 +1482,7 @@ export function RfqVendorTable({ label: "스페어파트", type: "boolean" }, - ...(rfqCode?.startsWith("I") ? [{ + ...(!rfqCode?.startsWith("I") ? [{ id: "shortList", label: "Short List", type: "select", @@ -1577,7 +1577,7 @@ export function RfqVendorTable({ </Button> {/* Short List 확정 버튼 */} - {rfqCode?.startsWith("I") && + {!rfqCode?.startsWith("F") && <Button variant="outline" size="sm" diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts index 310ef486..b81762c6 100644 --- a/lib/sedp/get-form-tags.ts +++ b/lib/sedp/get-form-tags.ts @@ -44,6 +44,96 @@ interface Column { shi?: string | null; } +interface newRegister { + PROJ_NO: string; + MAP_ID: string; + EP_ID: string; + CATEGORY: string; + BYPASS: boolean; + REG_TYPE_ID: string; + TOOL_ID: string; + TOOL_TYPE: string; + SCOPES: string[]; + MAP_CLS: { + TOOL_ATT_NAME: string; + ITEMS: ClassItmes[]; + }; + MAP_ATT: MapAttribute[]; + MAP_TMPLS: string[]; + CRTER_NO: string; + CRTE_DTM: string; + CHGER_NO: string; + _id: string; +} + +interface ClassItmes { + SEDP_OBJ_CLS_ID: string; + TOOL_VALS: string; + ISDEFALUT: boolean; +} + +interface MapAttribute { + SEDP_ATT_ID: string; + TOOL_ATT_NAME: string; + KEY_YN: boolean; + DUE_DATE: string; //"YYYY-MM-DDTHH:mm:ssZ" + INOUT: string | null; +} + + + +async function getNewRegisters(projectCode: string): Promise<newRegister[]> { + try { + // 토큰(API 키) 가져오기 + const apiKey = await getSEDPToken(); + + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + const response = await fetch( + `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + "TOOL_ID": "eVCP" + }) + } + ); + + if (!response.ok) { + throw new Error(`새 레지스터 요청 실패: ${response.status} ${response.statusText}`); + } + + // 안전하게 JSON 파싱 + let data; + try { + data = await response.json(); + } catch (parseError) { + console.error(`프로젝트 ${projectCode}의 새 레지스터 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + throw new Error(`새 레지스터 응답 파싱 실패: ${parseError instanceof Error ? parseError.message : String(parseError)}`); + } + + // 결과를 배열로 변환 (단일 객체인 경우 배열로 래핑) + let registers: newRegister[] = Array.isArray(data) ? data : [data]; + + console.log(`프로젝트 ${projectCode}에서 ${registers.length}개의 새 레지스터를 가져왔습니다.`); + return registers; + } catch (error) { + console.error(`프로젝트 ${projectCode}의 새 레지스터 가져오기 실패:`, error); + throw error; + } +} + + /** * 태그 가져오기 서비스 함수 * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장 @@ -70,6 +160,10 @@ export async function importTagsFromSEDP( // SEDP API에서 태그 데이터 가져오기 const tagData = await fetchTagDataFromSEDP(projectCode, formCode); + const newRegisters = await getNewRegisters(projectCode); + + const registerMatched = newRegisters.find(v => v.REG_TYPE_ID === formCode).MAP_ATT + // 트랜잭션으로 모든 DB 작업 처리 return await db.transaction(async (tx) => { @@ -459,7 +553,7 @@ export async function importTagsFromSEDP( } } - const packageCode = projectType === "ship" ? tagEntry.ATTRIBUTES.find(v=>v.ATT_ID === "CM3003")?.VALUE :tagEntry.ATTRIBUTES.find(v=>v.ATT_ID === "ME5074")?.VALUE + const packageCode = projectType === "ship" ? tagEntry.ATTRIBUTES.find(v => v.ATT_ID === "CM3003")?.VALUE : tagEntry.ATTRIBUTES.find(v => v.ATT_ID === "ME5074")?.VALUE // 기본 태그 데이터 객체 생성 (formEntries용) const tagObject: any = { @@ -470,9 +564,11 @@ export async function importTagsFromSEDP( VNDRCD: vendorRecord[0].vendorCode, VNDRNM_1: vendorRecord[0].vendorName, status: "From S-EDP", // SEDP에서 가져온 데이터임을 표시 - ...(projectType === "ship" ? { CM3003: packageCode } : { ME5074:packageCode }) + ...(projectType === "ship" ? { CM3003: packageCode } : { ME5074: packageCode }) } + let latestDueDate: Date | null = null; + // tags 테이블용 데이터 (UPSERT용) const tagRecord = { contractItemId: packageId, @@ -491,7 +587,7 @@ export async function importTagsFromSEDP( if (Array.isArray(tagEntry.ATTRIBUTES)) { for (const attr of tagEntry.ATTRIBUTES) { const columnInfo = columnsJSON.find(col => col.key === attr.ATT_ID); - if (columnInfo && (columnInfo.shi === "BOTH" ||columnInfo.shi === "OUT" ||columnInfo.shi === null )) { + if (columnInfo && (columnInfo.shi === "BOTH" || columnInfo.shi === "OUT" || columnInfo.shi === null)) { if (columnInfo.type === "NUMBER") { if (attr.VALUE !== undefined && attr.VALUE !== null) { if (typeof attr.VALUE === 'string') { @@ -512,9 +608,46 @@ export async function importTagsFromSEDP( tagObject[attr.ATT_ID] = attr.VALUE; } } + + // registerMatched에서 해당 SEDP_ATT_ID의 DUE_DATE 찾기 + if (registerMatched && Array.isArray(registerMatched)) { + const matchedAttribute = registerMatched.find( + regAttr => regAttr.SEDP_ATT_ID === attr.ATT_ID + ); + + if (matchedAttribute && matchedAttribute.DUE_DATE) { + try { + const dueDate = new Date(matchedAttribute.DUE_DATE); + + // 유효한 날짜인지 확인 + if (!isNaN(dueDate.getTime())) { + // 첫 번째 DUE_DATE이거나 현재까지 찾은 것보다 더 늦은 날짜인 경우 업데이트 + if (!latestDueDate || dueDate > latestDueDate) { + latestDueDate = dueDate; + } + } + } catch (dateError) { + console.warn(`Invalid DUE_DATE format for ${attr.ATT_ID}: ${matchedAttribute.DUE_DATE}`); + } + } + } + } } + if (latestDueDate) { + // ISO 형식의 문자열로 저장 (또는 원하는 형식으로 변경 가능) + tagObject.DUE_DATE = latestDueDate.toISOString(); + + // 또는 YYYY-MM-DD 형식을 원한다면: + // tagObject.DUE_DATE = latestDueDate.toISOString().split('T')[0]; + + // 또는 원본 형식 그대로 유지하려면: + // tagObject.DUE_DATE = latestDueDate.toISOString().replace('Z', ''); + } + + + // 기존 태그가 있는지 확인하고 처리 const existingTag = existingTagMap.get(tagEntry.TAG_IDX); @@ -550,8 +683,14 @@ export async function importTagsFromSEDP( continue; } + if (key === "DUE_DATE" && tagObject[key] !== existingTag.data[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + continue; + } + const columnInfo = columnsJSON.find(col => col.key === key); - if (columnInfo && (columnInfo.shi === "BOTH" ||columnInfo.shi === "OUT" ||columnInfo.shi === null )) { + if (columnInfo && (columnInfo.shi === "BOTH" || columnInfo.shi === "OUT" || columnInfo.shi === null)) { if (existingTag.data[key] !== tagObject[key]) { updates[key] = tagObject[key]; hasUpdates = true; diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts index 34c274f5..b69ab71c 100644 --- a/lib/tbe-last/service.ts +++ b/lib/tbe-last/service.ts @@ -49,7 +49,7 @@ export async function getAllTBELast(input: GetTBELastSchema) { } // 최종 WHERE - const finalWhere = and(advancedWhere, globalWhere); + const finalWhere = and(advancedWhere, globalWhere, ne(tbeLastView.status,"생성중")); // 정렬 const orderBy = input.sort?.length diff --git a/lib/vendor-document-list/ship/send-to-shi-button.tsx b/lib/vendor-document-list/ship/send-to-shi-button.tsx index 532aabf5..52874702 100644 --- a/lib/vendor-document-list/ship/send-to-shi-button.tsx +++ b/lib/vendor-document-list/ship/send-to-shi-button.tsx @@ -36,8 +36,8 @@ interface SendToSHIButtonProps { projectType: "ship" | "plant" } -export function SendToSHIButton({ - documents = [], +export function SendToSHIButton({ + documents = [], onSyncComplete, projectType }: SendToSHIButtonProps) { @@ -51,13 +51,13 @@ export function SendToSHIButton({ const { t } = useTranslation(lng, "engineering") const targetSystem = projectType === 'ship' ? "DOLCE" : "SWP" - + // 문서에서 유효한 계약 ID 목록 추출 (projectId 사용) const documentsContractIds = React.useMemo(() => { const validIds = documents .map(doc => (doc as any).projectId) .filter((id): id is number => typeof id === 'number' && id > 0) - + const uniqueIds = [...new Set(validIds)] return uniqueIds.sort() }, [documents]) @@ -66,7 +66,7 @@ export function SendToSHIButton({ // ✅ 클라이언트 전용 Hook 사용 (서버 사이드 렌더링 호환) const { contractStatuses, totalStats, refetchAll } = useClientSyncStatus( - documentsContractIds, + documentsContractIds, targetSystem ) @@ -79,14 +79,14 @@ export function SendToSHIButton({ toast.info(t('shiSync.messages.noContractsToSync')) return } - + setSyncProgress(0) let successfulSyncs = 0 let failedSyncs = 0 let totalSuccessCount = 0 let totalFailureCount = 0 const errors: string[] = [] - + try { // 동기화 가능한 계약들만 필터링 const contractsToSync = contractStatuses.filter(({ syncStatus, error }) => { @@ -112,14 +112,14 @@ export function SendToSHIButton({ for (let i = 0; i < contractsToSync.length; i++) { const { projectId } = contractsToSync[i] setCurrentSyncingContract(projectId) - + try { console.log(`Syncing contract ${projectId}...`) - const result = await triggerSync({ - projectId, - targetSystem + const result = await triggerSync({ + projectId, + targetSystem }) - + if (result?.success) { successfulSyncs++ totalSuccessCount += result.successCount || 0 @@ -143,12 +143,12 @@ export function SendToSHIButton({ } setCurrentSyncingContract(null) - + // 결과 처리 및 토스트 표시 setTimeout(() => { setSyncProgress(0) setIsDialogOpen(false) - + if (failedSyncs === 0) { toast.success( t('shiSync.messages.allSyncCompleted', { successCount: totalSuccessCount }), @@ -161,12 +161,12 @@ export function SendToSHIButton({ ) } else if (successfulSyncs > 0) { toast.warning( - t('shiSync.messages.partialSyncCompleted', { - successfulCount: successfulSyncs, - failedCount: failedSyncs + t('shiSync.messages.partialSyncCompleted', { + successfulCount: successfulSyncs, + failedCount: failedSyncs }), { - description: errors.slice(0, 3).join(', ') + + description: errors.slice(0, 3).join(', ') + (errors.length > 3 ? t('shiSync.messages.andMore') : '') } ) @@ -178,16 +178,16 @@ export function SendToSHIButton({ } ) } - + // 모든 contract 상태 갱신 refetchAll() onSyncComplete?.() }, 500) - + } catch (error) { setSyncProgress(0) setCurrentSyncingContract(null) - + const errorMessage = syncUtils.formatError(error as Error) toast.error(t('shiSync.messages.syncFailed'), { description: errorMessage @@ -259,8 +259,8 @@ export function SendToSHIButton({ )} <span className="hidden sm:inline">{t('shiSync.buttons.sendToSHI')}</span> {totalStats.totalPending > 0 && ( - <Badge - variant="destructive" + <Badge + variant="destructive" className="h-5 w-5 p-0 text-xs flex items-center justify-center ml-1" > {totalStats.totalPending} @@ -269,7 +269,7 @@ export function SendToSHIButton({ </Button> </div> </PopoverTrigger> - + <PopoverContent className="w-96" align="end"> <div className="space-y-4"> <div className="space-y-2"> @@ -289,16 +289,16 @@ export function SendToSHIButton({ )} </Button> </div> - + <div className="flex items-center justify-between"> <span className="text-sm text-muted-foreground">{t('shiSync.labels.overallStatus')}</span> {getSyncStatusBadge()} </div> - + <div className="text-xs text-muted-foreground"> - {t('shiSync.descriptions.targetInfo', { - contractCount: documentsContractIds.length, - targetSystem + {t('shiSync.descriptions.targetInfo', { + contractCount: documentsContractIds.length, + targetSystem })} </div> </div> @@ -311,8 +311,8 @@ export function SendToSHIButton({ {t('shiSync.descriptions.statusCheckError')} {process.env.NODE_ENV === 'development' && ( <div className="text-xs mt-1 font-mono"> - Debug: {t('shiSync.descriptions.contractsWithError', { - count: contractStatuses.filter(({ error }) => error).length + Debug: {t('shiSync.descriptions.contractsWithError', { + count: contractStatuses.filter(({ error }) => error).length })} </div> )} @@ -324,7 +324,7 @@ export function SendToSHIButton({ {!totalStats.hasError && documentsContractIds.length > 0 && ( <div className="space-y-3"> <Separator /> - + <div className="grid grid-cols-3 gap-4 text-sm"> <div className="text-center"> <div className="text-muted-foreground">{t('shiSync.labels.pending')}</div> @@ -409,7 +409,7 @@ export function SendToSHIButton({ </> )} </Button> - + <Button variant="outline" size="sm" @@ -451,12 +451,12 @@ export function SendToSHIButton({ <span>{t('shiSync.labels.syncTarget')}</span> <span className="font-medium">{t('shiSync.labels.itemCount', { count: totalStats.totalPending })}</span> </div> - + <div className="flex items-center justify-between text-sm"> <span>{t('shiSync.labels.targetContracts')}</span> <span className="font-medium">{t('shiSync.labels.contractCount', { count: documentsContractIds.length })}</span> </div> - + <div className="text-xs text-muted-foreground"> {t('shiSync.descriptions.includesChanges')} </div> diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx index 206846df..fd6da145 100644 --- a/lib/vendors/table/request-pq-dialog.tsx +++ b/lib/vendors/table/request-pq-dialog.tsx @@ -709,9 +709,22 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro date={dueDate ? new Date(dueDate) : undefined}
onSelect={(date?: Date) => {
if (date) {
+ // 현재 날짜 기준으로 이전 날짜는 선택 불가능
+ const today = new Date()
+ today.setHours(0, 0, 0, 0) // 오늘 날짜의 시작 시간으로 설정
+
+ const selectedDate = new Date(date)
+ selectedDate.setHours(0, 0, 0, 0) // 선택된 날짜의 시작 시간으로 설정
+
+ if (selectedDate < today) {
+ toast.error("마감일은 오늘 날짜 이후로 선택해주세요.")
+ return
+ } else {
+
// 한국 시간대로 날짜 변환 (UTC 변환으로 인한 날짜 변경 방지)
const kstDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
setDueDate(kstDate.toISOString().slice(0, 10))
+ }
} else {
setDueDate("")
}
|
