import type { GridView } from "realgrid"; import type { ColumnConfig, ColumnLayoutItem, ExcelExportSearchContext, } from "@/components/type/DataGrid.type"; function flattenColumnLayoutNames(layout: ColumnLayoutItem[]): string[] { const result: string[] = []; for (const item of layout) { if (typeof item === "string") { result.push(item); continue; } if (item && typeof item === "object") { if ("column" in item && typeof item.column === "string") { result.push(item.column); continue; } if ("items" in item && Array.isArray(item.items)) { result.push( ...flattenColumnLayoutNames( item.items as ColumnLayoutItem[], ), ); } } } return result; } export type ExcelImportColumnFieldNamesOptions = { /** false면 editable:false 컬럼도 import (기본 false — 보내기한 값을 그대로 반영) */ skipNonEditable?: boolean; }; const ADMDST_CD_FIELD = "admdstCd"; export function hasAdmdstCdColumn(columns: ColumnConfig[]): boolean { return columns.some((col) => col.fieldName === ADMDST_CD_FIELD); } /** 엑셀에서 읽은 admdstCd만 검증 — 빈 값 또는 sewerBzmnCd 앞 2자리 불일치 시 true(실패) */ export function isExcelImportAdmdstCdInvalid( sewerBzmnCd: string | null | undefined, admdstCdFromExcel: ReadonlyArray, ): boolean { const expectedPrefix = sewerBzmnCd?.trim().slice(0, 2) ?? ""; if (expectedPrefix.length < 2) return false; return admdstCdFromExcel.some((raw) => { const admdstCd = String(raw ?? "").trim(); if (!admdstCd) return true; return admdstCd.slice(0, 2) !== expectedPrefix; }); } /** 엑셀 가져오기 — N번째 열을 그리드 N번째 fieldName에 매핑 (보내기 컬럼 순서와 동일) */ export function getExcelImportColumnFieldNames( columns: ColumnConfig[], columnLayout?: ColumnLayoutItem[], options?: ExcelImportColumnFieldNamesOptions, ): string[] { /** true일 때만 읽기전용 컬럼 스킵. 기본(미지정/false)은 보내기 값 그대로 반영 */ const skipNonEditable = options?.skipNonEditable === true; const excludedNames = new Set( columns.filter((c) => c.excludeFromExcelExport).map((c) => c.name), ); const columnByName = new Map(columns.map((c) => [c.name, c])); const fieldByName = new Map( columns .filter((c) => c.fieldName) .map((c) => [c.name, c.fieldName as string]), ); const layoutNames = columnLayout && columnLayout.length > 0 ? flattenColumnLayoutNames(columnLayout) : columns.map((c) => c.name); const seen = new Set(); const fieldNames: string[] = []; for (const name of layoutNames) { if (excludedNames.has(name) || seen.has(name)) continue; const fieldName = fieldByName.get(name); if (!fieldName) continue; seen.add(name); const col = columnByName.get(name); if (skipNonEditable && col?.editable === false) { fieldNames.push(""); continue; } fieldNames.push(fieldName); } return fieldNames; } /** 엑셀 import 시 기존 값을 유지할 fieldName 목록 (editable:false) */ export function getNonEditableColumnFieldNames( columns: ColumnConfig[], columnLayout?: ColumnLayoutItem[], ): string[] { const excludedNames = new Set( columns.filter((c) => c.excludeFromExcelExport).map((c) => c.name), ); const columnByName = new Map(columns.map((c) => [c.name, c])); const layoutNames = columnLayout && columnLayout.length > 0 ? flattenColumnLayoutNames(columnLayout) : columns.map((c) => c.name); const seen = new Set(); const fieldNames: string[] = []; for (const name of layoutNames) { if (excludedNames.has(name) || seen.has(name)) continue; const col = columnByName.get(name); const fieldName = col?.fieldName; if (!fieldName || col.editable !== false) continue; seen.add(name); fieldNames.push(fieldName); } return fieldNames; } /** 엑셀 import 결과를 기존 행과 병합 — preserveFieldNames는 기존 그리드 값 유지 */ export function mergeExcelImportWithExistingRows( existingRows: Record[], importedRows: Record[], options?: { preserveFieldNames?: readonly string[] }, ): Record[] { const preserveSet = options?.preserveFieldNames && options.preserveFieldNames.length > 0 ? new Set(options.preserveFieldNames) : null; return importedRows.map((importedRow, index) => { const existingRow = existingRows[index]; if (!existingRow) return { ...importedRow }; const merged: Record = { ...existingRow, ...importedRow, }; if (preserveSet) { for (const fieldName of preserveSet) { const preserved = existingRow[fieldName]; if (preserved !== undefined) { merged[fieldName] = preserved; } } } // 엑셀 반영 후에는 이전 검증오류 메타를 제거 (cellStyleCallback 스타일/툴팁 초기화) delete (merged as { verificationResult?: unknown }).verificationResult; return merged; }); } export type ExcelExportVisibilitySnapshot = Map; /** 엑셀보내기 직전: 제외 컬럼 숨김, 그리드용 숨김(visible:false) 컬럼은 export에만 표시 */ export function prepareGridColumnsForExcelExport( gridView: GridView, columns: ColumnConfig[], ): ExcelExportVisibilitySnapshot { const excludedNames = new Set( columns.filter((c) => c.excludeFromExcelExport).map((c) => c.name), ); const snapshot = new Map(); columns.forEach((col) => { const gridCol = gridView.columnByName?.(col.name); if (!gridCol) return; const wasVisible = gridCol.visible !== false; snapshot.set(col.name, wasVisible); if (excludedNames.has(col.name)) { gridCol.visible = false; } else if (col.visible === false) { gridCol.visible = true; } }); return snapshot; } export function restoreGridColumnsAfterExcelExport( gridView: GridView, snapshot: ExcelExportVisibilitySnapshot, ): void { snapshot.forEach((visible, name) => { const gridCol = gridView.columnByName?.(name); if (gridCol) gridCol.visible = visible; }); } export function shouldPrepareColumnsForExcelExport(columns: ColumnConfig[]): boolean { return columns.some((c) => c.excludeFromExcelExport || c.visible === false); } /** 고정(frozen) 컬럼 표시용 — rg-cell-readonly와 동일한 화면 스타일 */ export const FIXED_CELL_STYLE_NAME = "rg-cell-readonly"; /** 고정 컬럼 styleName에 rg-cell-readonly 추가 */ export function appendFixedCellStyle(styleName: string | undefined): string { const parts = (styleName ?? "").split(/\s+/).filter(Boolean); if (!parts.includes(FIXED_CELL_STYLE_NAME)) { parts.push(FIXED_CELL_STYLE_NAME); } return parts.join(" "); } export function resolveColumnStyleName( column: { name: string; styleName?: string }, fixedColumnNameSet: Set, ): string { const base = column.styleName ?? ""; return fixedColumnNameSet.has(column.name) ? appendFixedCellStyle(base) : base; } /** 화면 고정(colCount)과 동일한 앞쪽 visible 리프 컬럼명 */ export function getFixedLeafColumnNames( columns: ColumnConfig[], columnLayout: ColumnLayoutItem[] | undefined, fixedColumns: number | undefined, ): string[] { if (!fixedColumns || fixedColumns <= 0) return []; const excludedNames = new Set( columns.filter((c) => c.excludeFromExcelExport).map((c) => c.name), ); const hiddenNames = new Set( columns.filter((c) => c.visible === false).map((c) => c.name), ); const knownNames = new Set(columns.map((c) => c.name)); const layoutNames = columnLayout && columnLayout.length > 0 ? flattenColumnLayoutNames(columnLayout) : columns.map((c) => c.name); const frozen: string[] = []; const seen = new Set(); for (const name of layoutNames) { if (seen.has(name) || !knownNames.has(name)) continue; if (hiddenNames.has(name) || excludedNames.has(name)) continue; seen.add(name); frozen.push(name); if (frozen.length >= fixedColumns) break; } return frozen; } export function buildFixedColumnNameSet( columns: ColumnConfig[], columnLayout: ColumnLayoutItem[] | undefined, fixedColumns: number | undefined, ): Set { return new Set(getFixedLeafColumnNames(columns, columnLayout, fixedColumns)); } type GridViewWithFixedOptions = GridView & { setFixedOptions?: (options: { colCount?: number }) => void; getFixedOptions?: () => { colCount?: number }; }; /** 엑셀 export 직전: RealGrid 고정영역 기본 회색이 export에 섞이지 않도록 해제 */ export function releaseFixedOptionsForExcelExport( gridView: GridView, fixedColumns: number | undefined, ): number | undefined { if (!fixedColumns || fixedColumns <= 0) return undefined; const gv = gridView as GridViewWithFixedOptions; const colCount = gv.getFixedOptions?.()?.colCount ?? fixedColumns; gv.setFixedOptions?.({ colCount: 0 }); return colCount; } export function restoreFixedOptionsAfterExcelExport( gridView: GridView, colCount: number | undefined, ): void { if (colCount && colCount > 0) { (gridView as GridViewWithFixedOptions).setFixedOptions?.({ colCount }); } } /** RealGrid 기본 교차행(rg-alternate-row) 배경이 엑셀에 들어가지 않도록 끔 */ export function disableAlternateRowStyle( gridView: GridView, refresh = false, ): void { const gv = gridView as { displayOptions?: { useAlternateRowStyle?: boolean }; refresh?: () => void; }; if (gv.displayOptions) gv.displayOptions.useAlternateRowStyle = false; if (refresh) gv.refresh?.(); } const INVALID_EXCEL_FILE_NAME_CHARS = /[\\/:*?"<>|]/g; function sanitizeExcelFileNamePart(value: string): string { return value.replace(INVALID_EXCEL_FILE_NAME_CHARS, "").trim(); } /** 엑셀 파일명: `{메뉴명}_{탭접미사}_{시도}_{시군구}_{연도}` */ export function buildExcelExportFileBaseName( menuName: string, tabSuffix?: string, searchContext?: ExcelExportSearchContext | null, ): string { const parts: string[] = [sanitizeExcelFileNamePart(menuName)]; if (tabSuffix?.trim()) { parts.push(sanitizeExcelFileNamePart(tabSuffix.trim())); } const ctpvNm = searchContext?.ctpvNm?.trim(); const sggNm = searchContext?.sggNm?.trim(); const statsYr = searchContext?.statsYr?.trim(); if (ctpvNm) parts.push(sanitizeExcelFileNamePart(ctpvNm)); if (sggNm && sggNm !== "ALL") parts.push(sanitizeExcelFileNamePart(sggNm)); if (statsYr) parts.push(sanitizeExcelFileNamePart(statsYr)); return parts.filter(Boolean).join("_"); } export function toExcelExportSearchContextFromSearchForm( values: { sido?: string; gugun?: string; year?: string } | null | undefined, ): ExcelExportSearchContext | undefined { if (!values) return undefined; const ctpvNm = values.sido?.trim(); const sggNm = values.gugun === "ALL" ? "" : (values.gugun?.trim() ?? ""); const statsYr = values.year?.trim(); if (!ctpvNm && !sggNm && !statsYr) return undefined; return { ctpvNm, sggNm, statsYr }; }