feat(M01-쉬운단가): 실무 일위대가표 · 설계 때 정하는 값 띠(Sheet)
읽기만 하는 일위대가표(명칭·규격·수량·단위·합계·노무비·재료비·경비·비고·합계 줄)와 시험값 띠 추가. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
.m01-easysheet__title {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easysheet__status,
|
||||
.m01-easysheet__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 좁은 폭은 표 안에서만 가로 스크롤 — 쪽 전체는 안 밀림 */
|
||||
.m01-easysheet__scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.m01-easysheet__table {
|
||||
width: 100%;
|
||||
min-width: 920px;
|
||||
border-collapse: collapse;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easysheet__table th,
|
||||
.m01-easysheet__table td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m01-easysheet__table thead th {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easysheet__table .m01-easysheet__group {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m01-easysheet__table .m01-easysheet__num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easysheet__table .m01-easysheet__note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-easysheet__table tfoot th,
|
||||
.m01-easysheet__table tfoot td {
|
||||
background: var(--color-surface);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m01-easysheet__strip-title {
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-easysheet__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-easysheet__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easysheet__ctl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easysheet__input {
|
||||
box-sizing: border-box;
|
||||
width: 160px;
|
||||
max-width: 100%;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easysheet__input[aria-invalid] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m01-easysheet__warn {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Sheet.ts
|
||||
* 쉬운 단가산출 — 실무 일위대가표(읽기만) · 「설계 때 정하는 값」 띠(시험값만, 저장 안 됨)
|
||||
* 계약 = docs/raw/M01_쉬운단가/설계.md 3절 Sheet. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
searchElements,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type LogicInput,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney, formatPrice } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
|
||||
import "./M01_EasyCalc_Sheet.css";
|
||||
|
||||
const TEXT = {
|
||||
Busy: ["계산 중", "Calculating"],
|
||||
Stopped: ["계산하지 못했어요", "Could not calculate"],
|
||||
Name: ["명칭", "Name"],
|
||||
Spec: ["규격", "Size"],
|
||||
Qty: ["수량", "Qty"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Total: ["합계", "Total"],
|
||||
Labor: ["노무비", "Labor"],
|
||||
Material: ["재료비", "Material"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Note: ["비고", "Note"],
|
||||
Price: ["단가", "Price"],
|
||||
Amount: ["금액", "Amount"],
|
||||
Strip: ["설계 때 정하는 값", "Values set at design time"],
|
||||
Trial: ["여기 값은 시험용 — 저장 안 됨", "Trial values only — not saved"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
OutOfRange: ["범위 밖이에요", "Out of range"],
|
||||
} as const;
|
||||
|
||||
const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const COST_HEAD = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const;
|
||||
|
||||
const qty = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
const cell = (text: string, className = ""): HTMLElement => el("td", { className, text });
|
||||
const money = (text: string): HTMLElement => cell(text, "m01-easysheet__num");
|
||||
|
||||
/** 준용 · 산정 글에 붙은 마스터 번호는 뺌 */
|
||||
const note = (line: CalcLine): string =>
|
||||
[line.출처?.replace(/[A-Z]{2}\d{6}\./g, ""), line.단위경고].filter(Boolean).join(" · ");
|
||||
|
||||
function lineRow(line: CalcLine): HTMLElement {
|
||||
// 비목별 단가 = 줄 단가 × (그 비목 금액 ÷ 줄 금액) — 금액 반올림 오차가 단가에 안 섞임
|
||||
const per = (k: string): number =>
|
||||
line.금액 ? (line.단가 * (line.비목?.[k] ?? 0)) / line.금액 : 0;
|
||||
return el("tr", {
|
||||
children: [
|
||||
cell(line.이름),
|
||||
cell(line.품목?.규격 ?? ""),
|
||||
money(qty(line.수량)),
|
||||
cell(line.단위),
|
||||
money(formatPrice(line.단가)),
|
||||
money(formatMoney(line.금액)),
|
||||
...COSTS.flatMap((k) => [
|
||||
money(formatPrice(per(k))),
|
||||
money(formatMoney(line.비목?.[k] ?? 0)),
|
||||
]),
|
||||
cell(note(line), "m01-easysheet__note"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 실무 일위대가표 — 읽기만 · 맨 아래 합계 줄 */
|
||||
export function renderSheet(
|
||||
host: HTMLElement,
|
||||
head: { name: string; resultUnit: string },
|
||||
ans: CalcAnswer | null,
|
||||
): void {
|
||||
const title = el("p", {
|
||||
className: "m01-easysheet__title",
|
||||
text: [head.name, head.resultUnit].filter(Boolean).join(" · "),
|
||||
});
|
||||
if (!ans || !ans.ok) {
|
||||
host.replaceChildren(
|
||||
title,
|
||||
el("p", {
|
||||
className: "m01-easysheet__status",
|
||||
text: ans ? `${ts("Stopped")} — ${safeReason(ans.reason)}` : ts("Busy"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lines = ans.lines ?? [];
|
||||
const sums = ans.sums ?? {};
|
||||
const group = (label: string): HTMLElement =>
|
||||
el("th", { className: "m01-easysheet__group", text: label, attrs: { colspan: "2" } });
|
||||
const one = (label: string): HTMLElement => el("th", { text: label, attrs: { rowspan: "2" } });
|
||||
const pair = (): HTMLElement[] => [ts("Price"), ts("Amount")].map((h) => el("th", { text: h }));
|
||||
const table = el("table", {
|
||||
className: "m01-easysheet__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
one(ts("Name")),
|
||||
one(ts("Spec")),
|
||||
one(ts("Qty")),
|
||||
one(ts("Unit")),
|
||||
group(ts("Total")),
|
||||
...COSTS.map((k) => group(ts(COST_HEAD[k]))),
|
||||
one(ts("Note")),
|
||||
],
|
||||
}),
|
||||
el("tr", { children: [...pair(), ...pair(), ...pair(), ...pair()] }),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: lines.map(lineRow) }),
|
||||
el("tfoot", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
el("th", { text: ts("Total"), attrs: { colspan: "4" } }),
|
||||
cell(""),
|
||||
money(formatMoney(sums["계"] ?? 0)),
|
||||
...COSTS.flatMap((k) => [cell(""), money(formatMoney(sums[k] ?? 0))]),
|
||||
cell(""),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(title, el("div", { className: "m01-easysheet__scroll", children: [table] }));
|
||||
}
|
||||
|
||||
/** 마스터 번호 꼴 고르기 값 → 이름(한 번 물은 것은 다시 안 물음) */
|
||||
const KEY_RE = /^[A-Z]{2}\d{6}$/;
|
||||
const KEY_GROUP: Record<string, string> = { MT: "재료", EQ: "기계", LB: "인력" };
|
||||
const nameCache = new Map<string, string | null>();
|
||||
|
||||
async function keyName(key: string): Promise<string | null> {
|
||||
if (nameCache.has(key)) return nameCache.get(key)!;
|
||||
const group = KEY_GROUP[key.slice(0, 2)];
|
||||
let name: string | null = null;
|
||||
if (group) {
|
||||
try {
|
||||
const hit = (await searchElements(group, key)).items.find((it) => it.ref === key);
|
||||
name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null;
|
||||
} catch {
|
||||
name = null;
|
||||
}
|
||||
}
|
||||
nameCache.set(key, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** state = 띠가 들고 있는 시험값(고칠 때마다 같은 객체를 바꿈) · emit = 바뀐 값을 밖에 알림 */
|
||||
type Trial = Record<string, unknown>;
|
||||
|
||||
function numberBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
|
||||
const [lo, hi] = spec.범위 ?? [];
|
||||
const hint = spec.범위 ? ts("Range").replace("{a}", String(lo)).replace("{b}", String(hi)) : "";
|
||||
const box = el("input", {
|
||||
className: "m01-easysheet__input",
|
||||
attrs: { type: "text", inputmode: "decimal", placeholder: hint },
|
||||
});
|
||||
const warn = el("span", { className: "m01-easysheet__warn" });
|
||||
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
const num = Number(raw);
|
||||
const bad = raw !== "" && Number.isNaN(num) ? ts("Bad") : "";
|
||||
const out = !bad && raw !== "" && spec.범위 && (num < lo! || num > hi!) ? ts("OutOfRange") : "";
|
||||
warn.textContent = bad || out;
|
||||
box.toggleAttribute("aria-invalid", !!(bad || out));
|
||||
if (bad) return;
|
||||
if (raw === "") delete state[spec.이름];
|
||||
else state[spec.이름] = num;
|
||||
emit();
|
||||
});
|
||||
return el("span", { className: "m01-easysheet__ctl", children: [box, warn] });
|
||||
}
|
||||
|
||||
function pickBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
|
||||
const picks = spec.고르기 ?? [];
|
||||
const optionText = (o: string | number): string =>
|
||||
(typeof o === "string" && nameCache.get(o)) || String(o);
|
||||
const options = (): { value: string; text: string }[] => [
|
||||
{ value: "", text: ts("Pick") },
|
||||
...picks.map((o) => ({ value: String(o), text: optionText(o) })),
|
||||
];
|
||||
const cur = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
const field = createSelectField({
|
||||
options: options(),
|
||||
value: cur,
|
||||
compact: true,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: (v) => {
|
||||
const hit = picks.find((o) => String(o) === v); // 원래 값(수) 그대로 보냄
|
||||
if (hit === undefined) delete state[spec.이름];
|
||||
else state[spec.이름] = hit;
|
||||
emit();
|
||||
},
|
||||
});
|
||||
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
|
||||
if (keys.length) {
|
||||
void Promise.all(keys.map(keyName)).then(() => field.setOptions(options(), cur));
|
||||
}
|
||||
return field.root;
|
||||
}
|
||||
|
||||
/** 「설계 때 정하는 값」 띠 — 바꾸면 onChange(새 시험값) · 값이 없으면 띠를 숨김 */
|
||||
export function renderTrialStrip(
|
||||
host: HTMLElement,
|
||||
inputs: LogicInput[],
|
||||
trial: Record<string, unknown>,
|
||||
onChange: (trial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
if (!inputs.length) {
|
||||
host.replaceChildren();
|
||||
host.hidden = true;
|
||||
return;
|
||||
}
|
||||
host.hidden = false;
|
||||
const state = { ...trial };
|
||||
const emit = (): void => onChange({ ...state });
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m01-easysheet__strip-title", text: ts("Strip") }),
|
||||
el("div", {
|
||||
className: "m01-easysheet__fields",
|
||||
children: inputs.map((spec) =>
|
||||
el("label", {
|
||||
className: "m01-easysheet__field",
|
||||
children: [
|
||||
el("span", { text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름 }),
|
||||
spec.고르기?.length ? pickBox(spec, state, emit) : numberBox(spec, state, emit),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
el("p", { className: "m01-easysheet__muted", text: ts("Trial") }),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user