/* ============================================================================= * M02_DF_Standards.ts * 단가산출 흐름도 — 단위 · 금액 표준 표(정함 11 · 12) * 처음 값 = `_standard/단위표준.json` · `금액단위표준.json`(산림품셈 1-2-2 · 원문 흠 그대로 — 사용자가 고침) * 서버 = `GET · PUT · DELETE /api/m02/df/standards` → {unit, money, saved} · 고친 것은 `_data`(마스터에 안 씀) * `renderStandards(host, on)` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로) * `standardsList()` = 단위 고르기 · 끝수 제안이 부르는 목록(동기 · 처음엔 번들 값 · 판이 서버 값을 받으면 그것) * 이 폴더째 지워도 됨. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; 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 = (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); /* ── 서버 ─────────────────────────────────────────────────────────────── */ async function call(method: "GET" | "PUT" | "DELETE", body?: unknown): Promise { const response = await fetch(`${API_BASE_URL}/m02/df/standards`, { method, credentials: "include", headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const data = (await response.json().catch(() => ({}))) as Standards & { detail?: unknown }; if (!response.ok) throw new Error(String(data.detail ?? response.statusText)); return data; } /** 서버 표 받기 — 못 받으면 처음 값 */ export async function loadStandards(): Promise { current = await call("GET"); 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 function renderStandards(host: HTMLElement, on: StandardsOn = {}): 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 = (row: T, key: string, text: string): boolean => { const v = parseCell(key, text); if (v === undefined) return false; (row as Record)[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 { save.disabled = true; try { take(await call("PUT", { unit: work.unit, money: work.money })); showToast(tx("Saved"), "success"); } catch { showToast(tx("SaveFailed"), "error"); mark(); } } async function doReset(): Promise { if (!(await showConfirmDialog(tx("ResetAsk"), tx("Reset")))) return; try { take(await call("DELETE")); } 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: [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"), ); }