Files
frontend/apps/st/src/components/util/gridExcelExport.ts
T

346 lines
12 KiB
TypeScript

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<string | number | undefined>,
): 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<string>();
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<string>();
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<string, string | number>[],
importedRows: Record<string, string | number>[],
options?: { preserveFieldNames?: readonly string[] },
): Record<string, string | number>[] {
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<string, string | number> = {
...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<string, boolean>;
/** 엑셀보내기 직전: 제외 컬럼 숨김, 그리드용 숨김(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<string, boolean>();
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>,
): 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<string>();
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<string> {
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 };
}