346 lines
12 KiB
TypeScript
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 };
|
|
}
|