feat(717-2b): M02 단가산출 상세 · 고침 — M01 상세 부품(보기 넷)을 M02 길로 · 품 · 단가 칸 덮개 · 줄 더하기 · 빼기 · 바탕 M01 로직 고름

- 새 Sangeun_Detail(mountSangeunDetail) · Sangeun_Cover(고침 표 · 셈 길 최신 응답만) · logic/M02_Sangeun_Cover_Model(순수 꼴)
- 고친 칸 = 칸 색 + 바탕 값 곁 글 + 「단가산출에서 고침」 · M01 개정 배지(바탕판 · 원값) · 짝잃음 띠 · 별도계상 재료 = 계 밖 표기
- M01 buildLabDetail 덧칸 cover(단가산출 상세 · 인력·재료·경비 덩이 갈음) · 마스터 이동 단추 = 등록 없으면 숨김(hasMasterJump)
- Sangeun_View 자리표 · 옛 글 열쇠(준비 중 · 덮개 · 더한 줄 · 열린 입력) 걷음
- 시험 test_717_2b_sangeun_detail 3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 17:23:02 +09:00
co-authored by Claude Opus 5.5
parent 54ab449a9c
commit 53bca68ea4
10 changed files with 1136 additions and 56 deletions
@@ -0,0 +1,53 @@
/* M02 단가산출(SK) 상세 · 고침 표(PLAN 717-2b) — 표 · 단추 · 배지는 부품 상자 · 여기는 자리 · 고친 칸 색만 */
.m02-sk {
display: flex;
flex-direction: column;
gap: 12px;
}
.m02-sk__head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px;
}
.m02-sk__head .ui-field {
min-width: 120px;
}
.m02-sk__cell {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.m02-sk__cell .ui-input {
width: 9em;
text-align: end;
}
.m02-sk__tags,
.m02-sk__removed,
.m02-sk__removed-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.m02-sk__removed {
gap: 8px;
}
/* 고친 칸 — B09 고친 칸과 같은 꼴(색) · 곁 글 = 바탕 값 */
.m02-sk__cover td.is-edited {
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
}
.m02-sk__total {
text-align: end;
font-weight: var(--font-weight-bold);
}
@@ -0,0 +1,213 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Cover.ts
* 단가산출(SK) 고침 표(PLAN 717-2b · 717-3 일위대가 모달도 씀) — M01 「단가산출 상세」 꼴(인력 · 재료 · 경비 ·
* 하위 묶음 + 소계 · 계 · 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액)에 품 · 단가 칸 고침 · 줄 빼기.
* 고친 칸 = 칸 색(`is-edited`) + 바탕 값 곁 글 + 「단가산출에서 고침」 꼬리표 · 값 = 서버 셈 답 그대로
* 셈 길 = `calcSangeun`(`POST /layers/{층}/sangeun/calc` · 저장 안 함) — 부르는 쪽이 최신 응답만 받음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { formatNumber } from "../M01_MasterData/M01_MasterData_UI_Logic_Edit";
import { formatMoney, formatPrice } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { CoverRow, SkAnswer, SkDoc } from "./logic/M02_Sangeun_Cover_Model";
import "./M02_MasterTemplete_Sangeun.css";
/** SK 셈(저장 안 함) — 문서 = 화면 문서 · 입력값 = 시험 입력 */
export function calcSangeun(
scope: { layer: Layer; owner: string | null },
doc: SkDoc,
given: Record<string, string> = {},
): Promise<SkAnswer> {
const q =
scope.owner && scope.layer !== "system" ? `?owner=${encodeURIComponent(scope.owner)}` : "";
const 입력값 = Object.fromEntries(Object.entries(given).filter(([, v]) => String(v).trim()));
return call<SkAnswer>(`/layers/${scope.layer}/sangeun/calc${q}`, {
method: "POST",
body: JSON.stringify({ 문서: doc, 입력값 }),
});
}
export interface CoverTableOptions {
/** 고칠 수 있음(쓰기 층 · 읽기만 아님) */
edit: boolean;
/** 열린입력 있는 SK — 품 칸 = 배율 */
opened: boolean;
/** 고친 칸 꼬리표(「단가산출에서 고침」 · 717-3 = 「이 일위대가에서 고침」) */
tag: string;
total: number | null;
/** 뺀 줄(cid · 이름) — 되살림 */
removed: { cid: string; 이름: string }[];
onQty: (row: CoverRow, value: number | null) => void;
onPrice: (row: CoverRow, value: number | null) => void;
onDrop: (row: CoverRow) => void;
onRestore: (cid: string) => void;
}
const ORDER = ["인력", "재료", "경비", "하위"];
/** 칸에 넣을 값 — 유효 자리만(긴 소수 줄임) */
const shown = (v: number | null, digits: number): string =>
v == null ? "" : String(Number(v.toPrecision(digits)));
const parse = (raw: string): number | null => {
const v = Number(raw.replaceAll(",", "").trim());
return raw.trim() === "" || !Number.isFinite(v) || v < 0 ? null : v;
};
/** 고침 칸 — 숫자 입력(바뀜 = Enter · 칸 떠남) + 고쳤으면 바탕 값 곁 글 */
function editCell(
value: string,
was: string | null,
covered: boolean,
unit: string,
done: (v: number | null) => void,
): HTMLElement {
const box = createInputField({ value, compact: true, size: 24, ...(unit ? { unit } : {}) });
box.input.inputMode = "decimal";
box.input.dataset.cover = "";
const start = parse(value); // 보인 값 그대로면 고침 아님(자리 줄인 값)
box.input.addEventListener("change", () => {
const v = parse(box.input.value);
if (v !== start) done(v);
});
box.input.addEventListener("keydown", (ev) => ev.key === "Enter" && box.input.blur());
return el("div", {
className: "m02-sk__cell",
children: [
box.root,
...(covered && was !== null ? [createText(`${L("M02_SangeunWas")} ${was}`)] : []),
],
});
}
function rowCells(r: CoverRow, o: CoverTableOptions): (string | Node)[] {
const can = o.edit;
const qtyText = r.수량 == null ? "—" : formatNumber(r.수량);
const wasQty = r.was?.qty == null ? null : formatNumber(r.was.qty);
const wasPrice = r.was?.단가 == null ? null : formatPrice(r.was.단가);
const scaled = o.opened && r.added === null; // 열린입력 SK = 품 칸이 배율
const qty = can
? editCell(
scaled ? String(r.배율 ?? 1) : shown(r.수량, 6),
wasQty,
r.covered.qty,
scaled ? "×" : "",
(v) => o.onQty(r, v),
)
: qtyText;
const price =
can && r.priceEdit
? editCell(shown(r.단가, 9), wasPrice, r.covered.price, "", (v) => o.onPrice(r, v))
: r.단가 == null
? ""
: formatPrice(r.단가);
const tags = [
...(r.covered.qty || r.covered.price ? [createBadge(o.tag, "warning")] : []),
...(r.added !== null ? [createBadge(L("M02_SangeunAddedTag"), "accent")] : []),
...(r.pct ? [createBadge(L("M02_SangeunPct"), "neutral")] : []),
...(r.apart ? [createBadge(L("M02_SangeunApart"), "neutral")] : []),
...(r.revised ? [createBadge(L("M02_SangeunRevised"), "danger")] : []),
];
const drop = createButton({
label: L("M02_SangeunDrop"),
variant: "danger-ghost",
size: 22,
onClick: () => o.onDrop(r),
});
return [
r.이름,
qty,
r.단위,
el("div", { className: "m02-sk__tags", children: tags }),
price,
r.apart || r.금액 == null
? r.금액 == null
? ""
: `(${formatMoney(r.금액)})`
: formatMoney(r.금액),
...(o.edit ? [drop] : []),
];
}
/** 묶음 표 넷 + 계 + 뺀 줄 */
export function coverTable(rows: CoverRow[], o: CoverTableOptions): HTMLElement {
const cols: Column[] = [
{ label: L("M02_SangeunColName") },
{ label: L("M02_SangeunColQty"), align: "end" },
{ label: L("M02_SangeunColUnit") },
{ label: L("M02_SangeunColLogic") },
{ label: L("M02_SangeunColPrice"), align: "end" },
{ label: L("M02_SangeunColAmount"), align: "end" },
...(o.edit ? [{ label: "", width: "5em" }] : []),
];
const names = [...new Set([...ORDER, ...rows.map((r) => r.묶음)])];
const blocks = names.flatMap((name) => {
const mine = rows.filter((r) => r.묶음 === name);
if (!mine.length && !ORDER.slice(0, 3).includes(name)) return [];
const { root, tbody } = tableShell(cols, { density: "data" });
for (const r of mine) {
const tr = tableRow(cols, rowCells(r, o));
tr.dataset.cid = r.cid;
tr.querySelectorAll("td")[1]?.classList.toggle("is-edited", r.covered.qty);
tr.querySelectorAll("td")[4]?.classList.toggle("is-edited", r.covered.price);
tbody.append(tr);
}
const sub = mine.reduce((s, r) => s + (r.apart ? 0 : (r.금액 ?? 0)), 0);
const subRow = tableRow(cols, [
{ content: `${name} ${L("M02_SangeunSubtotal")}`, colSpan: 5 },
formatMoney(sub),
...(o.edit ? [""] : []),
]);
subRow.dataset.subtotal = String(sub);
subRow.dataset.group = name;
tbody.append(subRow);
return [el("h4", { className: "m01lab__group", text: name }), root];
});
const removed = o.removed.length
? [
el("div", {
className: "m02-sk__removed",
attrs: { "data-removed": "" },
children: [
createText(L("M02_SangeunRemoved")),
...o.removed.map((x) =>
el("span", {
className: "m02-sk__removed-item",
children: [
createBadge(x.이름, "neutral"),
...(o.edit
? [
createButton({
label: L("M02_SangeunRestore"),
variant: "ghost",
size: 22,
onClick: () => o.onRestore(x.cid),
}),
]
: []),
],
}),
),
],
}),
]
: [];
return el("div", {
className: "m02-sk__cover",
attrs: { "data-sk-cover": "" },
children: [
...blocks,
el("p", {
className: "m02-sk__total",
attrs: { "data-sum": o.total == null ? "" : String(o.total) },
text: `${L("M02_SangeunTotal")} ${o.total == null ? "—" : formatMoney(o.total)}`,
}),
...removed,
],
});
}
@@ -0,0 +1,324 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Detail.ts
* M02 단가산출(SK) 상세(PLAN 717-2b · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
* 보기 넷(`LogicLab_View`) = 단가산출 상세(고침 표 · `Sangeun_Cover`) · 인력·재료·경비(공용 계산서 · SK 셈 답) ·
* 시험 계산(`Logic_Calc` · 바탕 M01 로직) · 텍스트 수식(바탕 M01 로직 식) · 기본정보 = 바탕 로직(맨 위 접힘)
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
* 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
* 바탕 없는 SK = [M01 로직 고름] → 바탕 · 단위 · 바탕 없이 줄만도 됨
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { calcSheetRows } from "../A00_Common/calc_sheet";
import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc";
import {
fetchLogic,
setLogicApiPaths,
type LogicOne,
type LogicRow,
type TextAnswer,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { calcSangeun, coverTable } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import {
dropLine,
restoreLine,
rowsOf,
setPrice,
setQty,
sheetGroups,
type SkAnswer,
type SkDoc,
} from "./logic/M02_Sangeun_Cover_Model";
export interface SangeunDetailOptions {
layer: Layer;
owner: string | null;
readOnly: boolean;
onChanged: (dirty: boolean) => void;
}
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
/** 결과단위 「원/10㎡」 → SK 단위 「㎡」(1 단위 값 · 배는 서버가 나눔) */
const unitOf = (result: string): string => result.replace(/^원\s*\/\s*[\d.,]*\s*/, "").trim();
export function mountSangeunDetail(
host: HTMLElement,
doc0: unknown,
opt: SangeunDetailOptions,
): { getDoc: () => SkDoc; destroy: () => void } {
setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1)
const doc = clone((doc0 ?? {}) as SkDoc);
let saved = JSON.stringify(doc);
const edit = !opt.readOnly;
const scope = { layer: opt.layer, owner: opt.owner };
const values: Record<string, string> = Object.fromEntries(
Object.entries(doc.입력값 ?? {}).map(([k, v]) => [k, String(v)]),
);
let base: LogicOne | null = null;
let text: TextAnswer | null = null;
let lastText: TextAnswer | undefined;
let ans: SkAnswer | null = null;
let bare: SkAnswer | null = null;
let fail = "";
let seq = 0;
let alive = true;
const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } });
const notes = el("div");
const editor = el("div", { className: "m01-logic__editor" });
const views = createViewPicker(editor);
const calcHost = el("div", { className: "m01lab__calc" });
const ownHost = el("div");
const addButton = createButton({
label: L("M02_SangeunAddLine"),
variant: "ghost",
size: 28,
onClick: () =>
openSangeunLinePicker(scope, (line) => {
doc.더한줄 = [
...(doc.더한줄 ?? []),
line as unknown as NonNullable<SkDoc["더한줄"]>[number],
];
touch();
}),
});
addButton.hidden = !edit;
const baseHint = createText(L("M02_SangeunBaseViews"));
host.replaceChildren(
el("div", {
className: "m02-sk",
attrs: { "data-sangeun-detail": doc.키 ?? "" },
children: [
head,
notes,
el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }),
editor,
],
}),
);
const opened = (): boolean => (doc.열린입력 ?? []).length > 0;
const baseKey = (): string | null => doc.바탕?.로직 ?? null;
/** 바탕 없는 · 참조 바탕 SK 의 기본정보 자리 줄 */
const standIn = (): LogicRow => ({
키: doc.키 ?? "",
원문번호: "",
이름: doc.이름 ?? "",
결과단위: `원/${doc.단위 ?? ""}`,
출처: doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"),
입력: [],
중간: [],
호표: [],
덧줄: [],
});
const touch = (again = true): void => {
opt.onChanged(edit && JSON.stringify(doc) !== saved);
if (again) void recalc();
else drawHead();
};
async function recalc(): Promise<void> {
const mine = ++seq;
const plain = { ...doc, 덮개: {}, 뺀줄: [], 더한줄: [] };
try {
const [got, was] = await Promise.all([
calcSangeun(scope, doc, values),
doc.바탕 ? calcSangeun(scope, plain, values) : Promise.resolve(null),
]);
if (mine !== seq || !alive) return; // 늦게 온 답 — 버림
[ans, bare, fail] = [got, was, ""];
} catch (error) {
if (mine !== seq || !alive) return;
[ans, fail] = [null, error instanceof Error ? error.message : String(error)];
}
draw();
}
function drawHead(): void {
const field = (label: string, key: "이름" | "단위"): HTMLElement => {
const box = createInputField({ label, value: doc[key] ?? "", compact: true, size: 28 });
box.input.dataset.sk = key;
box.input.disabled = !edit;
box.input.addEventListener("change", () => {
doc[key] = box.input.value.trim();
box.setError(doc[key] ? undefined : L("M02_SangeunNeed"));
touch(key === "단위");
});
return box.root;
};
const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null;
const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase");
const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판);
const pick = createButton({
label: L("M02_SangeunPickBase"),
variant: "filled",
size: 28,
onClick: () =>
openLogicPicker((x) => {
doc.바탕 = { 로직: x.키 };
delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음
if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위;
touch(false);
void loadBase();
}),
});
pick.hidden = !edit || !!doc.바탕 || !!doc.뺀줄?.length || !!Object.keys(doc.덮개 ?? {}).length;
head.replaceChildren(
field(L("M02_Name"), "이름"),
field(L("M02_SangeunUnit"), "단위"),
el("div", {
className: "m02-sk__tags",
attrs: { "data-sk-base": baseKey() ?? "" },
children: [
createText(`${L("M02_SangeunBase")} · ${baseText}`, "muted", 14),
...(ans?.머리 ? [createBadge(ans.머리, "info")] : []),
...(revised ? [createBadge(L("M02_SangeunRevisedBase"), "danger")] : []),
...(opened() ? [createBadge(L("M02_SangeunScaleOnly"), "warning")] : []),
],
}),
pick,
);
}
function drawNotes(): void {
const lost = ans?.짝잃음 ?? [];
const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판);
notes.replaceChildren(
...(fail || ans?.까닭
? [createBanner({ tone: "danger", text: fail || String(ans?.까닭) })]
: []),
...(revised ? [createBanner({ tone: "warning", text: L("M02_SangeunRevisedNote") })] : []),
...(lost.length
? [
createBanner({
tone: "warning",
text: L("M02_SangeunLost"),
details: lost.map((x) => `${x.cid} — ${x.까닭}`).join("\n"),
}),
]
: []),
);
}
function draw(): void {
if (!alive) return;
drawHead();
drawNotes();
baseHint.hidden = !base;
const rows = ans ? rowsOf(ans, bare, doc) : [];
const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름]));
const ho = coverTable(rows, {
edit,
opened: opened(),
tag: L("M02_SangeunEditedHere"),
total: ans?.기준?.계 ?? null,
removed: (doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })),
onQty: (r, v) => (setQty(doc, r, v, opened()), touch()),
onPrice: (r, v) => (setPrice(doc, r, v), touch()),
onDrop: (r) => (dropLine(doc, r), touch()),
onRestore: (cid) => (restoreLine(doc, cid), touch()),
});
const row = base?.logic ?? standIn();
const est = el("div", {
attrs: { "data-est": "" },
children: ans
? calcSheetRows({
묶음: sheetGroups(rows),
계: ans.기준?.계 ?? null,
sumLabel: `${L("M02_SangeunTotal")} (${row.결과단위 || "원"})`,
bundle: true,
inputs: row.입력,
values,
onInput,
})
: [createText(L("M02_SangeunWait"))],
});
buildLabDetail(editor, {
row,
file: base?.file ?? "",
reasons: base?.reasons ?? [],
prices: base?.prices ?? {},
lines: null,
text,
lastText,
values,
ownHost,
onChange: () => undefined,
calcHost,
onInput,
cover: { ho, est },
});
}
/** 텍스트 수식 · 계산서 입력 단추 — 시험 입력 고침 → 시험 계산 다시 그림 · 셈 */
function onInput(name: string, v: string): void {
values[name] = v;
drawCalc();
calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
}
function drawCalc(): void {
if (!base) {
calcHost.replaceChildren(createText(L("M02_SangeunNoInputs")));
return;
}
buildCalc(calcHost, {
savedKey: base.logic.키,
file: base.file,
row: base.logic,
prices: base.prices,
dirty: () => false,
values,
onText: (t) => {
text = t;
if (t.ok) lastText = t;
draw();
},
onLines: () => void recalc(), // 시험 입력이 바뀜 — SK 도 같은 입력으로
});
}
async function loadBase(): Promise<void> {
const key = baseKey();
base = null;
text = null;
if (key) {
try {
base = await fetchLogic(key);
} catch (error) {
fail = error instanceof Error ? error.message : String(error);
}
}
if (!alive) return;
drawCalc();
draw();
await recalc();
}
void loadBase();
return {
getDoc: () => {
if (ans?.바탕판 && doc.바탕) doc.바탕판 = ans.바탕판; // 저장 = 지금 바탕 확인(서버가 다시 적음)
saved = JSON.stringify(doc);
return clone(doc);
},
destroy: () => {
alive = false;
host.replaceChildren();
},
};
}
@@ -1,61 +1,22 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_View.ts
* 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄).
* 지금 = 이름 · 단위 · 바탕 · 고친 줄 요약(덮개 · 더한 줄 · 뺀 줄 · 열린 입력) + 상세 자리표.
* 717-2b(sub2) = 자리표 줄을 `mountSangeunDetail(slot, doc, opt)`(`Sangeun_Detail.ts`)로 갈음 ·
* [저장]은 그 손잡이 `getDoc` · 고침 알림은 `opt.onChanged`. 지금 [저장] = 읽은 문서 그대로.
* 717-2b = 상세 · 고침(`Sangeun_Detail.ts` mountSangeunDetail) · [저장]은 그 손잡이 `getDoc` · 고침 알림 `opt.onChanged`.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { createKv } from "@ui/ui_template_kv";
import { t as L } from "@ui/ui_template_locale";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
/** SK 문서 중 이 자리가 읽는 칸(꼴 = 서버 `M02_Sangeun_Doc`) */
export interface SangeunDoc {
키?: string;
이름?: string;
단위?: string;
바탕?: { 로직?: string; 참조?: { 키?: string } } | null;
덮개?: Record<string, unknown>;
더한줄?: unknown[];
뺀줄?: unknown[];
열린입력?: string[];
}
/** 고름(층 · 주인 · SK 키 = name) + 읽기만 · 고침 알림 */
export type SangeunViewOptions = Pick<Selection, "layer" | "owner" | "name"> & {
readOnly: boolean;
onChanged: (dirty: boolean) => void;
};
/** 바탕 한 줄 — 로직 키 · 참조 SK 키 · 없으면 「바탕 없음」 */
export const baseText = (doc: SangeunDoc): string =>
doc.바탕?.로직 ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase");
export function mountSangeunView(
host: HTMLElement,
doc: unknown,
_opt: SangeunViewOptions,
opt: SangeunViewOptions,
): { getDoc: () => unknown; destroy: () => void } {
const sk = (doc ?? {}) as SangeunDoc;
const count = (v: unknown[] | Record<string, unknown> | undefined): number =>
Array.isArray(v) ? v.length : Object.keys(v ?? {}).length;
const head = createKv([
{ label: L("M02_Name"), value: sk.이름 ?? "" },
{ label: tc("Lib_Unit"), value: sk.단위 ?? "" },
{ label: L("M02_SangeunBase"), value: baseText(sk) },
{ label: L("M02_SangeunCover"), value: count(sk.덮개) },
{ label: L("M02_SangeunAdded"), value: count(sk.더한줄) },
{ label: L("M02_SangeunRemoved"), value: count(sk.뺀줄) },
{ label: L("M02_SangeunOpened"), value: (sk.열린입력 ?? []).join(" · ") || null },
]);
const slot = el("div", {
attrs: { "data-sangeun-detail": sk.키 ?? "" },
children: [createEmpty({ text: L("M02_SangeunDetailSoon") })],
});
host.replaceChildren(el("div", { attrs: { "data-sangeun-view": "" }, children: [head, slot] }));
return { getDoc: () => doc, destroy: () => host.replaceChildren() };
return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null });
}
@@ -0,0 +1,301 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts
* 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 같이 씀) — 서버 셈 답(`/layers/{층}/sangeun/calc`) → 표 줄 ·
* 고친 칸 → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`). 금액은 늘 서버가 셈 —
* 여기는 줄 모양 · 문서 칸 옮김만(값 셈 없음 · 로직 줄 단가를 노 · 재 · 경 몫 비로 나누는 것만).
* 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음
* (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { SheetGroup } from "../../A00_Common/calc_sheet";
export type CoverPart = "labor" | "material" | "expense";
export const PART_OF: Record<string, CoverPart> = {
노무비: "labor",
재료비: "material",
경비: "expense",
};
const PARTS: CoverPart[] = ["labor", "material", "expense"];
export interface SkCover {
qty?: number;
배율?: number;
labor?: number;
material?: number;
expense?: number;
원값?: { qty?: number; 단가?: number };
}
export interface SkAdded {
종류?: string;
수량: number;
단가?: number;
[k: string]: unknown;
}
/** SK 문서 중 고침이 만지는 칸 */
export interface SkDoc {
키?: string;
이름?: string;
단위?: string;
바탕?: { 로직?: string; 참조?: { 층?: string; 주인?: string | null; 키?: string } } | null;
입력값?: Record<string, unknown>;
열린입력?: string[];
덮개?: Record<string, SkCover>;
더한줄?: SkAdded[];
뺀줄?: string[];
고름?: Record<string, string>;
자재표시?: Record<string, unknown>;
바탕판?: string;
[k: string]: unknown;
}
export interface SkLine {
이름: string;
단위?: string;
수량: number | null;
단가: number | null;
금액: number | null;
비목?: Record<string, number | null>;
묶음?: string | null;
cid: string;
율?: number;
}
export interface SkMat {
차례: number;
이름: string;
단위?: string;
수량: number | null;
단가: number | null;
금액: number | null;
}
export interface SkAnswer {
기준: Record<string, number> | null;
줄: SkLine[];
재료?: SkMat[];
까닭?: string | null;
정체: string[];
짝잃음: { cid: string; 까닭: string; 바탕?: string }[];
바탕판: string;
머리?: string;
}
export interface CoverRow {
cid: string;
묶음: string;
이름: string;
단위: string;
수량: number | null;
단가: number | null;
금액: number | null;
/** 비목 몫 금액 */
parts: Record<string, number>;
/** % 덧줄 — 수량 = 율 · 단가 못 고침 */
pct: boolean;
/** 더한줄 차례 · 바탕 줄 = null */
added: number | null;
/** 기준 밖 재료(일위대가 자재목록으로 셈) — 금액이 계에 안 듦 */
apart: boolean;
covered: { qty: boolean; price: boolean };
/** 덮개 배율(열린입력 SK) */
배율: number | null;
/** 바탕 값(덮개 · 더한줄 · 뺀줄 없이 셈) · 없으면 null */
was: { qty: number | null; 단가: number | null } | null;
/** 저장 때 적힌 원값이 지금 바탕 값과 다름(M01 개정) */
revised: boolean;
priceEdit: boolean;
}
/* ── 순수 셈 ── */
const BUNDLE_OF: Record<string, string> = { 노무비: "인력", 재료비: "재료", 경비: "경비" };
const OWN_PART: Record<string, string> = { 인력: "노무비", 재료: "재료비" };
const ADD_RE = /^add:#(\d+)$/;
const COVER_KEYS = ["qty", "배율", ...PARTS] as const;
const partsOf = (l: { 비목?: Record<string, number | null> }): Record<string, number> =>
Object.fromEntries(Object.entries(l.비목 ?? {}).map(([k, v]) => [k, v ?? 0]));
/** 바탕 값 — 바탕 셈(덮개 · 더한줄 · 뺀줄 없이)의 줄마다 품(덧줄 = 율) · 단가 */
export function baseValues(
bare: SkAnswer | null,
): Map<string, { qty: number | null; 단가: number | null }> {
const out = new Map<string, { qty: number | null; 단가: number | null }>();
if (!bare) return out;
for (const l of bare.줄) out.set(l.cid, { qty: l.율 ?? l.수량, 단가: l.단가 });
for (const m of bare.재료 ?? []) {
const cid = bare.정체[m.차례];
if (cid && !out.has(cid)) out.set(cid, { qty: m.수량, 단가: m.단가 });
}
return out;
}
const near = (a: number | null | undefined, b: number | null | undefined): boolean =>
a != null && b != null && Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
/** 셈 답 → 표 줄(엔진 줄 + 기준 밖 재료) */
export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverRow[] {
const was = baseValues(bare);
const rows: CoverRow[] = [];
const row = (
cid: string,
l: {
이름: string;
단위?: string;
수량: number | null;
단가: number | null;
금액: number | null;
},
parts: Record<string, number>,
extra: { 묶음?: string | null; 율?: number; apart?: boolean },
): CoverRow => {
const hit = ADD_RE.exec(cid);
const added = hit ? Number(hit[1]) : null;
const one = added === null ? undefined : doc.더한줄?.[added];
const cover = added === null ? doc.덮개?.[cid] : undefined;
const pct = cid.startsWith("pct:");
const keys = Object.keys(parts);
const base = added === null ? (was.get(cid) ?? null) : null;
const old = cover?.원값;
return {
cid,
묶음: extra.묶음 || BUNDLE_OF[keys[0] ?? ""] || "하위",
이름: l.이름,
단위: pct ? "%" : (l.단위 ?? ""),
수량: pct ? (extra.율 ?? l.수량) : l.수량,
단가: pct ? null : l.단가,
금액: l.금액,
parts,
pct,
added,
apart: !!extra.apart,
covered: {
qty: !!cover && ("qty" in cover || "배율" in cover),
price: added === null ? PARTS.some((k) => cover?.[k] != null) : one?.단가 != null,
},
배율: cover?.배율 ?? null,
was: base,
revised:
!!old &&
!!base &&
((old.qty != null && !near(old.qty, base.qty)) ||
(old.단가 != null && !near(old.단가, base.단가))),
priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))),
};
};
for (const l of ans.줄) rows.push(row(l.cid, l, partsOf(l), l));
const seen = new Set(rows.map((r) => r.cid));
for (const m of ans.재료 ?? []) {
const cid = ans.정체[m.차례];
if (!cid || seen.has(cid)) continue;
rows.push(row(cid, m, { 재료비: m.금액 ?? 0 }, { 묶음: "재료", apart: true }));
}
return rows;
}
const memo = (base: CoverRow["was"]): SkCover["원값"] | undefined => {
if (!base) return undefined;
const out: { qty?: number; 단가?: number } = {};
if (base.qty != null) out.qty = base.qty;
if (base.단가 != null) out.단가 = base.단가;
return Object.keys(out).length ? out : undefined;
};
function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void {
const covers = { ...(doc.덮개 ?? {}) };
if (COVER_KEYS.some((k) => cover[k] != null)) {
const old = cover.원값 ?? memo(r.was);
covers[r.cid] = { ...cover, ...(old ? { 원값: old } : {}) };
} else delete covers[r.cid];
doc.덮개 = covers;
}
/** 품 고침 — 더한줄 = 그 줄 수량 · 바탕 줄 = 덮개 qty(열린입력 SK = 배율 · 1 = 지움) · 바탕 값과 같거나 빈 값 = 덮개 지움 */
export function setQty(doc: SkDoc, r: CoverRow, v: number | null, opened: boolean): void {
if (r.added !== null) {
const one = doc.더한줄?.[r.added];
if (one && v !== null) one.수량 = v;
return;
}
const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) };
delete cover.qty;
delete cover.배율;
if (v !== null && opened && v !== 1) cover.배율 = v;
if (v !== null && !opened && !near(v, r.was?.qty)) cover.qty = v;
putCover(doc, r, cover);
}
/** 단가 고침 — 요소 줄 = 그 비목 칸 · 로직 줄 = 노 · 재 · 경 몫 비로 나눔 · 더한 요소 줄 = 그 줄 단가 */
export function setPrice(doc: SkDoc, r: CoverRow, v: number | null): void {
if (!r.priceEdit) return;
if (r.added !== null) {
const one = doc.더한줄?.[r.added];
if (!one) return;
if (v === null) delete one.단가;
else one.단가 = v;
return;
}
const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) };
for (const k of PARTS) delete cover[k];
if (v !== null && !near(v, r.was?.단가)) {
const keys = Object.keys(r.parts).filter((k) => PART_OF[k]);
const sum = keys.reduce((s, k) => s + r.parts[k], 0);
const own = PART_OF[keys[0] ?? ""] ?? PART_OF[OWN_PART[r.묶음] ?? "경비"];
if (keys.length === 1 || !sum) cover[own] = v;
else for (const k of keys) cover[PART_OF[k]] = Math.round(((v * r.parts[k]) / sum) * 1e4) / 1e4;
}
putCover(doc, r, cover);
}
/** 줄 빼기 — 더한줄 = 그 줄 지움 · 바탕 줄 = 뺀줄에 cid(덮개 · 고름 · 자재표시도 지움) */
export function dropLine(doc: SkDoc, r: CoverRow): void {
if (r.added !== null) {
doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== r.added);
return;
}
doc.뺀줄 = [...(doc.뺀줄 ?? []).filter((c) => c !== r.cid), r.cid];
for (const k of ["덮개", "고름", "자재표시"] as const) {
const map = { ...((doc[k] as Record<string, unknown> | undefined) ?? {}) };
delete map[r.cid];
(doc as Record<string, unknown>)[k] = map;
}
}
/** 뺀 줄 되살림 */
export function restoreLine(doc: SkDoc, cid: string): void {
doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid);
}
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
const num = (v: number | null): string => (v == null ? "—" : NUM.format(v));
/** 표 줄 → 공용 계산서(`calc_sheet`) 비목 묶음 — 줄 글 「단가 * 품 = 금액」 · 기준 밖 재료 = 금액 없음 */
export function sheetGroups(rows: CoverRow[]): SheetGroup[] {
const groups = new Map<string, SheetGroup>();
const take = (k: string): SheetGroup => {
let g = groups.get(k);
if (!g) groups.set(k, (g = { 비목: k, 소계: 0, 줄: [] }));
return g;
};
for (const r of rows) {
const entries = Object.entries(r.parts);
for (const [k, v] of entries.length ? entries : [["경비", 0] as [string, number]]) {
const share = entries.length > 1 && r.금액 ? (r.단가 ?? 0) * (v / r.금액) : r.단가;
const amount = r.apart ? null : v;
const g = take(k);
g.소계 = (g.소계 ?? 0) + (amount ?? 0);
g.줄.push({
이름: r.이름,
글: r.pct ? `${num(r.수량)} %` : `${num(share)} * ${num(r.수량)} = ${num(amount)}`,
금액: amount,
묶음: r.묶음,
장비: entries.length > 1 && r.cid.startsWith("logic:"),
});
}
}
const order = Object.keys(PART_OF);
return [...groups.values()].sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목));
}
/* ── 순수 셈 끝 ── */