refactor(M01): Z01_MasterData 폴더째 old_code 로 옮기고 등록·문구 끊음 (PLAN 7-2)
라우터·라우트·문구에서 Z01 을 끊음 — M01 만 남김. 시험 여섯도 old_code/resources/tester 로 같이 옮김. Z01 이 읽던 옛 자료 셋은 디스크에 이미 없음 · master_data/old 는 B08·B09 도 읽어 남김. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/* =============================================================================
|
||||
* 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 (Array.isArray(value)) return value.map((each) => cellText(each, unit)).join(", ");
|
||||
if (typeof value === "number" && unit) {
|
||||
return value.toLocaleString("ko-KR", { maximumFractionDigits: 10 });
|
||||
}
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
// 부동소수 끝자리 떠돌이(0.00018109999999999998)를 15자리에서 잘라 원래 수로 보임.
|
||||
if (typeof value === "number") return String(Number(value.toPrecision(15)));
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** 줄마다 판의 기준일 — 기초단가 한 표에 시기가 다른 판이 섞여 줄마다 보임(2026-09-15 브레인). */
|
||||
export const EFFECTIVE_DATE_KEY = "effective_date";
|
||||
|
||||
/** 한 줄의 칸들 — columns 차례대로만(columns 에 없는 열은 안 그림) · 줄에 그 열이 없으면 「—」. */
|
||||
export function rowCells(row: Record<string, unknown>, columns: MasterColumn[]): string[] {
|
||||
return columns.map((column) => {
|
||||
// 기준일 없는 자료(machine_operating 등)는 빈칸이 아니라 모른다고 말함.
|
||||
if (column.key === EFFECTIVE_DATE_KEY) return cellText(row[column.key]) || "판 모름";
|
||||
return Object.hasOwn(row, column.key) ? cellText(row[column.key], column.unit) : "—";
|
||||
});
|
||||
}
|
||||
|
||||
/** 단추 글자·칸 설명에 넣는 값 — 수는 늘 쉼표 · 단위(이름표)를 뒤에 붙여 무엇인지 보이게. */
|
||||
export function valueWithUnit(value: unknown, unit?: string | null): string {
|
||||
const text =
|
||||
typeof value === "number"
|
||||
? value.toLocaleString("ko-KR", { maximumFractionDigits: 10 })
|
||||
: cellText(value, unit);
|
||||
return text && unit ? `${text} ${unit}` : text;
|
||||
}
|
||||
|
||||
/** 자료 출처표 한 줄 — 어디서 받고 최신이 무엇인지(데스크탑 서브 출처표 · 메인이 표 수준에 실음). */
|
||||
export interface TableSource {
|
||||
source_id?: string;
|
||||
/** 원본 이름 — 한 표가 원본 여럿을 모아 어느 것이 뒤처졌는지 가려야 함 */
|
||||
name?: string;
|
||||
publisher: string;
|
||||
where: string;
|
||||
cycle?: string;
|
||||
latest_published?: string;
|
||||
our_edition?: string;
|
||||
checked_at?: string;
|
||||
/** 뒤처짐 판정 — 서버만 함 · null 은 「모름」이라 띠를 안 세움 */
|
||||
outdated?: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 뒤처진 자료 띠 글자 — 서버가 outdated 라 할 때만. 화면은 날짜를 견주지 않음(판정 두 벌 금지).
|
||||
* 받는 자리를 꼭 적음 — 최신이 무엇인지·어디서 받는지가 없어 건설 노임이 반 년 뒤처졌음.
|
||||
*/
|
||||
export function outdatedBanner(sources?: TableSource[]): string[] {
|
||||
return (sources ?? [])
|
||||
.filter((source) => source.outdated === true)
|
||||
.map((source) => {
|
||||
const head = source.name ? `${source.name} — ` : "";
|
||||
const where = source.where ? ` ${source.where}` : "";
|
||||
return `${head}이 자료는 ${source.our_edition ?? "판 모름"} 판 · 최신은 ${source.latest_published ?? "?"} 공표 · ${source.publisher}${where}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 서버가 주는 표 한 벌 — 갈래·kind·한글 이름·줄 수. 화면은 제 목록을 안 가짐(늘면 저절로 섬). */
|
||||
export interface ServedKind {
|
||||
group: string;
|
||||
/** 갈래 한글 이름(이름표 `base_price_groups`) — 안 오면 group 키가 그대로 떠 빠진 것이 드러남 */
|
||||
group_label?: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
type KindBox = { group: string; label: string; items: ServedKind[] };
|
||||
|
||||
/** 받은 차례 그대로 갈래별로 묶음 — 갈래 차례도 서버가 낸 순서를 따름. */
|
||||
export function groupKinds(kinds: ServedKind[]): KindBox[] {
|
||||
const boxes: KindBox[] = [];
|
||||
for (const kind of kinds) {
|
||||
const box = boxes.find((each) => each.group === kind.group);
|
||||
if (box) box.items.push(kind);
|
||||
else boxes.push({ group: kind.group, label: kind.group_label ?? kind.group, items: [kind] });
|
||||
}
|
||||
return boxes;
|
||||
}
|
||||
|
||||
/** 열 제목을 눌러 세운 차례 — null 이면 원본 차례(서버가 sort 없이 주는 그대로). */
|
||||
export interface SortOrder {
|
||||
key: string;
|
||||
desc: boolean;
|
||||
}
|
||||
|
||||
/** 같은 열을 누르면 오름 → 내림 → 원본 차례, 다른 열이면 그 열 오름부터. */
|
||||
export function nextSort(current: SortOrder | null, key: string): SortOrder | null {
|
||||
if (current?.key !== key) return { key, desc: false };
|
||||
return current.desc ? null : { key, desc: true };
|
||||
}
|
||||
|
||||
/** 알림은 한 줄로도 목록으로도 옴 — 받은 대로 줄 목록으로. */
|
||||
export function noticeLines(notice?: string | string[]): string[] {
|
||||
if (!notice) return [];
|
||||
return Array.isArray(notice) ? notice.filter(Boolean) : [notice];
|
||||
}
|
||||
|
||||
/** 서버 글자의 `**굵게**` 를 조각으로 — 별표가 화면에 글자로 남으면 안 됨. */
|
||||
export function boldSegments(text: string): { text: string; bold: boolean }[] {
|
||||
return text
|
||||
.split(/\*\*(.+?)\*\*/g)
|
||||
.map((part, index) => ({ text: part, bold: index % 2 === 1 }))
|
||||
.filter((part) => part.text !== "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 주는 거르기 한 벌(건설품셈 「임도가 쓰는 것」 따위) — 이름·선택지·줄 수·기본값 모두 서버 것.
|
||||
* 어느 줄을 거를지도 서버가 판정함 — 화면은 고른 값을 `key=value` 로 보내기만(2026-09-17 브레인).
|
||||
*/
|
||||
export interface TableFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
options: { value: string; label: string; rows?: number }[];
|
||||
/** 값을 안 보냈을 때 서버가 쓴 선택지 — 화면이 이것을 보여야 보이는 것과 걸린 것이 같음 */
|
||||
default: string;
|
||||
}
|
||||
|
||||
/** 거르기 상자에 보일 값 — 사람이 고른 것 · 안 골랐으면 서버가 이미 건 기본값. */
|
||||
export function filterValue(filter: TableFilter, picked: Record<string, string>): string {
|
||||
return picked[filter.key] ?? filter.default;
|
||||
}
|
||||
|
||||
/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */
|
||||
export interface TableMeta {
|
||||
filters?: TableFilter[];
|
||||
/** 한 표가 모은 원본마다 한 줄 */
|
||||
source?: TableSource[];
|
||||
/**
|
||||
* 표 머리 알림 — 서버가 한 벌로 줌(첫 줄 「새로 만드는 프로젝트부터」 · 요율은 법정값 경고까지).
|
||||
* 화면이 사전을 가지면 두 벌이 됨.
|
||||
*/
|
||||
notice?: string | string[];
|
||||
/**
|
||||
* 서버가 세워 줄 수 있는 열 — 이 목록에 있는 제목만 눌림.
|
||||
* 없으면 제목이 안 눌림(원본 차례) — 눌렀는데 그대로인 화면을 안 만들려고 서버에 물어서 켬.
|
||||
*/
|
||||
sortable?: string[];
|
||||
editable?: string[];
|
||||
/** 열key → 왜 못 고치나 */
|
||||
locked?: Record<string, string>;
|
||||
/** 열key → 식 자체 */
|
||||
formula?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 덮개 판정 낱말 넷 — 서버가 줌 · 이것만 씀(2026-09-15~16 브레인).
|
||||
* meaning_changed = 번호는 그대로인데 줄의 뜻이 바뀜(문화재목공 → 한식목공) — 같은 코드에 딴 물건.
|
||||
*/
|
||||
export type OverrideState = "edited" | "source_changed" | "meaning_changed" | "orphan";
|
||||
|
||||
export interface CellInfo {
|
||||
editable: boolean;
|
||||
reason: string | null;
|
||||
overridden: boolean;
|
||||
/** 고쳐진 칸만 — 사람이 고칠 때의 원본 값 */
|
||||
original?: unknown;
|
||||
/** 고친 칸의 서버 판정 — 안 고친 칸 null · 서버가 state 를 안 주면 「missing」(때우지 않음) */
|
||||
state: OverrideState | "missing" | null;
|
||||
/** source_changed·meaning_changed 칸만 — 지금 원본 값 */
|
||||
current?: unknown;
|
||||
/** meaning_changed 칸만 — 고칠 때 줄 이름 */
|
||||
was?: string;
|
||||
/** meaning_changed 칸만 — 지금 줄 이름 */
|
||||
now?: string;
|
||||
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; was?: string; now?: string }
|
||||
>
|
||||
| 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" || state === "meaning_changed"
|
||||
? { current: override?.current }
|
||||
: {}),
|
||||
...(override?.was !== undefined ? { was: override.was } : {}),
|
||||
...(override?.now !== undefined ? { now: override.now } : {}),
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user