Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
129 lines
5.3 KiB
TypeScript
129 lines
5.3 KiB
TypeScript
/* =============================================================================
|
|
* Z01_MasterData_UI_Cells.ts
|
|
* 마스터 표 칸 글자 — 숨김 열 · 열 제목 · 칸 값 · 쪽 수 (import 없음 · 시험이 그대로 돌림)
|
|
*
|
|
* 한글 이름은 API 가 `label` 로 줌(이름표 파일 `resources/data_master_labels/`) — 화면은
|
|
* 사전을 두지 않고 받은 대로 보임. 두 벌이 되면 어긋나기 때문(2026-09-15 브레인 Z01 ①).
|
|
* ========================================================================== */
|
|
|
|
export interface MasterColumn {
|
|
key: string;
|
|
label: string;
|
|
unit?: string | null;
|
|
/** 내부 id · sha · 생성시각 — 기본으로 안 보임 */
|
|
hidden?: boolean;
|
|
}
|
|
|
|
export function shownColumns(columns: MasterColumn[], showHidden: boolean): MasterColumn[] {
|
|
return showHidden ? columns : columns.filter((column) => !column.hidden);
|
|
}
|
|
|
|
export function columnTitle(column: MasterColumn): string {
|
|
return column.unit ? `${column.label} (${column.unit})` : column.label;
|
|
}
|
|
|
|
/** 단위가 붙은 수만 쉼표 — 연도·코드 같은 수(단위 없음)에 쉼표가 끼면 틀린 값처럼 보임. */
|
|
export function cellText(value: unknown, unit?: string | null): string {
|
|
if (value === null || value === undefined) return "";
|
|
if (typeof value === "boolean") return value ? "예" : "아니오";
|
|
if (typeof value === "number" && unit) {
|
|
return value.toLocaleString("ko-KR", { maximumFractionDigits: 10 });
|
|
}
|
|
if (typeof value === "object") return JSON.stringify(value);
|
|
return String(value);
|
|
}
|
|
|
|
/** 한 줄의 칸들 — columns 차례대로만(columns 에 없는 열은 안 그림) · 줄에 그 열이 없으면 「—」. */
|
|
export function rowCells(row: Record<string, unknown>, columns: MasterColumn[]): string[] {
|
|
return columns.map((column) =>
|
|
Object.hasOwn(row, column.key) ? cellText(row[column.key], column.unit) : "—",
|
|
);
|
|
}
|
|
|
|
/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */
|
|
export interface TableMeta {
|
|
editable?: string[];
|
|
/** 열key → 왜 못 고치나 */
|
|
locked?: Record<string, string>;
|
|
/** 열key → 식 자체 */
|
|
formula?: Record<string, string>;
|
|
}
|
|
|
|
/** 덮개 판정 낱말 셋 — 서버가 줌 · 이것만 씀(2026-09-15 브레인). */
|
|
export type OverrideState = "edited" | "source_changed" | "orphan";
|
|
|
|
export interface CellInfo {
|
|
editable: boolean;
|
|
reason: string | null;
|
|
overridden: boolean;
|
|
/** 고쳐진 칸만 — 사람이 고칠 때의 원본 값 */
|
|
original?: unknown;
|
|
/** 고친 칸의 서버 판정 — 안 고친 칸 null · 서버가 state 를 안 주면 「missing」(때우지 않음) */
|
|
state: OverrideState | "missing" | null;
|
|
/** source_changed 칸만 — 지금 원본 값 */
|
|
current?: unknown;
|
|
rule: string | null;
|
|
/** 이 줄 값을 넣은 식 — 줄마다 다를 때만 옴 */
|
|
filled: string | null;
|
|
}
|
|
|
|
/** 칸 하나의 고침 상태 — 줄의 `@overrides`·`@formula` 는 해당할 때만 실려 옴. */
|
|
export function cellInfo(meta: TableMeta, row: Record<string, unknown>, key: string): CellInfo {
|
|
const overrides = row["@overrides"] as
|
|
Record<string, { original: unknown; current?: unknown; state?: OverrideState }> | undefined;
|
|
const filled = row["@formula"] as Record<string, string> | undefined;
|
|
const override = overrides?.[key];
|
|
// 판정은 서버 state 만 — 화면이 값을 견줘 따로 정하면 판정이 두 벌이 됨(2026-09-15 브레인).
|
|
const state = override ? (override.state ?? "missing") : null;
|
|
return {
|
|
editable: meta.editable?.includes(key) ?? false,
|
|
reason: meta.locked?.[key] ?? null,
|
|
overridden: override !== undefined,
|
|
...(override ? { original: override.original } : {}),
|
|
state,
|
|
...(state === "source_changed" ? { current: override?.current } : {}),
|
|
rule: meta.formula?.[key] ?? null,
|
|
filled: filled?.[key] ?? null,
|
|
};
|
|
}
|
|
|
|
const NUMBER_TEXT = /^-?[\d,]+(\.\d+)?$/;
|
|
|
|
/** 고친 글자를 보낼 값으로 — 수 칸은 쉼표를 떼 수로, 못 읽으면 undefined(저장 안 함). */
|
|
export function parseCellInput(text: string, original: unknown): unknown {
|
|
const trimmed = text.trim();
|
|
const numeric = typeof original === "number" || (original == null && NUMBER_TEXT.test(trimmed));
|
|
if (!numeric) return text;
|
|
if (!NUMBER_TEXT.test(trimmed)) return undefined;
|
|
return Number(trimmed.replaceAll(",", ""));
|
|
}
|
|
|
|
export function pageCount(total: number, size: number): number {
|
|
return Math.max(1, Math.ceil(total / size));
|
|
}
|
|
|
|
/** 쪽 번호 칸에 적은 값을 1~끝 쪽으로 — 큰 표(800쪽)는 한 칸씩 넘길 수 없음. */
|
|
export function clampPage(value: number, pages: number): number {
|
|
return Number.isFinite(value) ? Math.min(pages, Math.max(1, Math.round(value))) : 1;
|
|
}
|
|
|
|
/**
|
|
* 최신 요청의 응답만 — 검색 글자가 바뀌어 옛 요청이 늦게 오면 새 표를 덮음.
|
|
* 옛 요청은 undefined 로 끝나고(오류도 삼킴) 최신 요청의 오류만 던짐.
|
|
*/
|
|
export function latestOnly<A extends unknown[], T>(
|
|
fn: (...args: A) => Promise<T>,
|
|
): (...args: A) => Promise<T | undefined> {
|
|
let seq = 0;
|
|
return async (...args) => {
|
|
const mine = ++seq;
|
|
try {
|
|
const value = await fn(...args);
|
|
return mine === seq ? value : undefined;
|
|
} catch (error) {
|
|
if (mine === seq) throw error;
|
|
return undefined;
|
|
}
|
|
};
|
|
}
|