Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
/* 단가산출 흐름도 — 표준 표 판(M02_DF_Standards.ts) · 단추 · 칸 크기는 공용 그대로(덮지 않음) */
|
||||
.m02-df-std {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.m02-df-std__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.m02-df-std__head h3 {
|
||||
margin: 0 auto 0 0;
|
||||
}
|
||||
|
||||
.m02-df-std__state,
|
||||
.m02-df-std__hint,
|
||||
.m02-df-std__line {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.m02-df-std__hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m02-df-std__scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.m02-df-std__table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-df-std__table th,
|
||||
.m02-df-std__table td {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
padding: 2px 6px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-df-std__table td[tabindex] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-df-std__table th {
|
||||
background: var(--color-surface-muted, rgba(127, 127, 127, 0.08));
|
||||
}
|
||||
|
||||
.m02-df-std__notes {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/* =============================================================================
|
||||
* 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 = <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);
|
||||
|
||||
/* ── 서버 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function call(method: "GET" | "PUT" | "DELETE", body?: unknown): Promise<Standards> {
|
||||
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<Standards> {
|
||||
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 = <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(await call("PUT", { unit: work.unit, money: work.money }));
|
||||
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(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"),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user