Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
364 lines
13 KiB
TypeScript
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"),
|
|
);
|
|
}
|