Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Standards.ts
T

364 lines
13 KiB
TypeScript

/* =============================================================================
* M02_DF_Standards.ts
* 단가산출 흐름도 — 단위 · 금액 표준 표(정함 11 · 12)
* 처음 값 = `_standard/단위표준.json` · `금액단위표준.json`(산림품셈 1-2-2 · 원문 흠 그대로 — 사용자가 고침)
* 서버 = `M02_DF_Api` cfStandards · cfStandardsSave · cfStandardsReset(`/api/m02/df/standards`) → {unit, money, saved} ·
* 고친 것은 `_data`(마스터에 안 씀)
* `renderStandards(host, on, { title })` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로)
* `standardsList()` = 단위 고르기 · 끝수 제안이 부르는 목록(동기 · 처음엔 번들 값 · 판이 서버 값을 받으면 그것)
* 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { cfStandards, cfStandardsReset, cfStandardsSave, type CfStandards } from "./M02_DF_Api";
import unitDefault from "./_standard/단위표준.json";
import moneyDefault from "./_standard/금액단위표준.json";
import "./M02_DF_Standards.css";
const TEXT = {
Title: ["표준 표", "Standards"],
UnitTitle: ["설계서의 단위 및 소수의 표준", "Units and decimals in design documents"],
MoneyTitle: ["금액의 단위표준", "Money rounding units"],
Source: ["원문 · 산림품셈 1-2-2", "Source · Forest standard 1-2-2"],
Hint: [
"칸을 눌러 고침 · 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 고친 것은 이 시험판에만 저장",
"Click a cell to edit · decimals “-” = 0 · unit “-” = none · edits are saved in this trial only",
],
Item: ["종목", "Item"],
SpecUnit: ["규격 단위", "Size unit"],
SpecPlaces: ["규격 소수자리", "Size decimals"],
QtyUnit: ["단위수량 단위", "Quantity unit"],
QtyPlaces: ["단위수량 소수자리", "Quantity decimals"],
Note: ["비고", "Note"],
Line: ["원문 줄", "Source line"],
Unit: ["단위", "Unit"],
Step: ["지위", "Step"],
Save: ["저장", "Save"],
Reset: ["처음 값으로", "Back to original"],
ResetAsk: [
"고친 표준 표를 지우고 원문 처음 값으로 되돌릴까요?",
"Discard edits and go back to the original values?",
],
Saved: ["표준 표를 저장했어요", "Standards saved"],
Restored: ["처음 값으로 되돌렸어요", "Back to the original values"],
Unsaved: ["저장 안 함", "Not saved"],
Edited: ["고친 표", "Edited"],
Original: ["원문 처음 값", "Original"],
Failed: [
"표준 표를 못 가져왔어요 — 처음 값을 보여 줘요",
"Could not load — showing the original",
],
SaveFailed: ["저장하지 못했어요", "Could not save"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Merged: ["위 종목과 한 칸(원문 병합)", "Same cell as the item above (merged in source)"],
} as const;
const tx = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0];
/* ── 모양 ─────────────────────────────────────────────────────────────── */
export interface UnitStdRow {
종목: string;
"규격 단위": string | null;
"규격 소수자리": number | null;
"단위수량 단위": string | null;
"단위수량 소수자리": number | null;
비고: string;
"원문 줄": number;
병합?: boolean;
}
export interface UnitStd {
이름: string;
출처: string;
원문: string;
바꿈?: string;
줄: UnitStdRow[];
주: string[];
}
export interface MoneyStdRow {
종목: string;
단위: string;
지위: number;
비고: string;
"원문 줄": number;
}
export interface MoneyStd {
이름: string;
출처: string;
원문: string;
줄: MoneyStdRow[];
}
export interface Standards {
unit: UnitStd;
money: MoneyStd;
/** `_data` 에 고친 것이 있음 */
saved: boolean;
}
/** 단위 고르기 · 끝수 제안이 쓰는 한 줄 */
export interface StandardItem {
종목: string;
갈래: "규격" | "단위수량" | "금액";
단위: string;
/** 규격 · 단위수량 = 소수자리 · 금액 = 지위에서 나온 자리(1,000 원 → -3 · 0.1 원 → 1) */
소수자리: number;
/** 금액만 — 지위(원) */
지위?: number;
방법: "반올림" | "버림";
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
const ORIGINAL: Standards = {
unit: unitDefault as UnitStd,
money: moneyDefault as MoneyStd,
saved: false,
};
let current: Standards = clone(ORIGINAL);
/* ── 목록(동기) ───────────────────────────────────────────────────────── */
/** 지위 → 소수자리 — 1,000 원 → -3 · 1 원 → 0 · 0.1 원 → 1 */
const placesOf = (step: number): number => -Math.round(Math.log10(step));
/** 단위 표준(주 ① 반올림) 줄마다 규격 · 단위수량 둘 + 금액(미만 버림) — 단위가 비었거나 「없음」 이면 뺌 */
export function standardsList(std: Standards = current): StandardItem[] {
const out: StandardItem[] = [];
for (const r of std.unit.줄) {
for (const [갈래, unit, places] of [
["규격", r["규격 단위"], r["규격 소수자리"]],
["단위수량", r["단위수량 단위"], r["단위수량 소수자리"]],
] as const) {
if (!unit || unit === "없음") continue;
out.push({ 종목: r.종목, 갈래, 단위: unit, 소수자리: places ?? 0, 방법: "반올림" });
}
}
for (const r of std.money.줄)
out.push({
종목: r.종목,
갈래: "금액",
단위: r.단위,
소수자리: placesOf(r.지위),
지위: r.지위,
방법: "버림",
});
return out;
}
/** 지금 표(판이 서버 값을 받았으면 그것) — 사본 */
export const standardsNow = (): Standards => clone(current);
/* ── 서버 ─────────────────────────────────────────────────────────────── */
/** 서버 답(`M02_DF_Api` CfStandards) → 이 판의 모양 */
const asStd = (v: CfStandards): Standards => v as unknown as Standards;
/** 서버 표 받기 — 못 받으면 처음 값 */
export async function loadStandards(): Promise<Standards> {
current = asStd(await cfStandards());
return clone(current);
}
/* ── 판 ───────────────────────────────────────────────────────────────── */
export interface StandardsOn {
/** 저장 · 되돌리기 뒤 — 단위 고르기 등이 새 목록을 다시 읽게 */
change?(std: Standards): void;
}
type UnitKey = "규격 단위" | "규격 소수자리" | "단위수량 단위" | "단위수량 소수자리" | "비고";
type MoneyKey = "단위" | "지위" | "비고";
/** 칸 글 → 값 — 소수자리 · 지위는 수(「-」 = 0 · 빈 칸 = null) · 단위는 글(「-」 = 없음 · 빈 칸 = null) */
function parseCell(key: string, text: string): string | number | null | undefined {
const t = text.trim();
if (key === "비고") return t;
if (key.includes("소수자리") || key === "지위") {
if (t === "") return key === "지위" ? undefined : null;
if (t === "-") return 0;
const n = Number(t.replace(/,/g, ""));
return Number.isFinite(n) ? n : undefined;
}
if (t === "-") return "없음";
return t || null;
}
const show = (v: unknown): string =>
v === null || v === undefined ? "" : typeof v === "number" ? v.toLocaleString() : String(v);
export interface StandardsOpts {
/** 판 제목 「표준 표」 — 모달처럼 겉에 제목이 있으면 false(기본 true) */
title?: boolean;
}
/** 시험판 안 「표준 표」 판 */
export function renderStandards(
host: HTMLElement,
on: StandardsOn = {},
opts: StandardsOpts = {},
): void {
let work = clone(current);
let dirty = false;
const state = el("span", { className: "m02-df-std__state" });
const save = createButton({ label: tx("Save"), size: "sm", onClick: () => void doSave() });
const reset = createButton({
label: tx("Reset"),
size: "sm",
variant: "ghost",
onClick: () => void doReset(),
});
const body = el("div", { className: "m02-df-std__body" });
const mark = (): void => {
state.textContent = dirty ? tx("Unsaved") : work.saved ? tx("Edited") : tx("Original");
save.disabled = !dirty;
};
/** 칸 하나 — 누르면 입력 칸 · Enter / 칸 밖 = 끝 · Esc = 취소 */
const cell = (value: unknown, commit: (text: string) => boolean, title = ""): HTMLElement => {
const td = el("td", {
text: show(value),
attrs: { tabindex: "0", ...(title ? { title } : {}) },
});
const edit = (): void => {
if (td.querySelector("input")) return;
const input = el("input", { className: "ui-input ui-input--sm" });
input.value = show(value).replace(/,/g, "");
td.replaceChildren(input);
input.focus();
let done = false;
const finish = (keep: boolean): void => {
if (done) return;
done = true;
if (keep && !commit(input.value)) {
showToast(tx("Bad"), "error");
done = false;
input.focus();
return;
}
draw();
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") finish(true);
if (ev.key === "Escape") finish(false);
});
input.addEventListener("blur", () => finish(true));
};
td.addEventListener("click", edit);
td.addEventListener("keydown", (ev) => ev.key === "Enter" && edit());
return td;
};
const table = (heads: string[], rows: HTMLElement[][]): HTMLElement =>
el("div", {
className: "m02-df-std__scroll",
children: [
el("table", {
className: "m02-df-std__table",
children: [
el("thead", {
children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })],
}),
el("tbody", { children: rows.map((cells) => el("tr", { children: cells })) }),
],
}),
],
});
const put = <T extends object>(row: T, key: string, text: string): boolean => {
const v = parseCell(key, text);
if (v === undefined) return false;
(row as Record<string, unknown>)[key] = v;
dirty = true;
return true;
};
const draw = (): void => {
const unitKeys: UnitKey[] = ["규격 단위", "규격 소수자리", "단위수량 단위", "단위수량 소수자리", "비고"]; // prettier-ignore
const unitRows = work.unit.줄.map((r) => [
el("td", { text: r.종목, attrs: r.병합 ? { title: tx("Merged") } : {} }),
...unitKeys.map((k) => cell(r[k], (t) => put(r, k, t))),
el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }),
]);
const moneyKeys: MoneyKey[] = ["단위", "지위", "비고"];
const moneyRows = work.money.줄.map((r) => [
el("td", { text: r.종목 }),
...moneyKeys.map((k) => cell(r[k], (t) => put(r, k, t))),
el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }),
]);
body.replaceChildren(
el("h4", { text: tx("UnitTitle") }),
table(
[tx("Item"), tx("SpecUnit"), tx("SpecPlaces"), tx("QtyUnit"), tx("QtyPlaces"), tx("Note"), tx("Line")], // prettier-ignore
unitRows,
),
el("ol", {
className: "m02-df-std__notes",
children: work.unit.주.map((n) => el("li", { text: n })),
}),
el("h4", { text: tx("MoneyTitle") }),
table([tx("Item"), tx("Unit"), tx("Step"), tx("Note"), tx("Line")], moneyRows),
);
mark();
};
const take = (std: Standards): void => {
current = std;
work = clone(std);
dirty = false;
draw();
on.change?.(clone(std));
};
async function doSave(): Promise<void> {
save.disabled = true;
try {
take(
asStd(
await cfStandardsSave({ unit: work.unit, money: work.money } as unknown as Omit<
CfStandards,
"saved"
>),
),
);
showToast(tx("Saved"), "success");
} catch {
showToast(tx("SaveFailed"), "error");
mark();
}
}
async function doReset(): Promise<void> {
if (!(await showConfirmDialog(tx("ResetAsk"), tx("Reset")))) return;
try {
take(asStd(await cfStandardsReset()));
} catch {
take(clone(ORIGINAL)); // 서버가 없으면 화면만 처음 값
}
showToast(tx("Restored"), "success");
}
host.replaceChildren(
el("div", {
className: "m02-df-std",
children: [
el("div", {
className: "m02-df-std__head",
children: [
...(opts.title === false ? [] : [el("h3", { text: tx("Title") })]),
state,
reset,
save,
],
}),
el("p", { className: "m02-df-std__hint", text: `${tx("Source")} · ${tx("Hint")}` }),
body,
],
}),
);
draw();
void loadStandards().then(
(std) => take(std),
() => showToast(tx("Failed"), "info"),
);
}