Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts
T
eomsangdonandClaude Opus 5.5 a1342124c6 feat(717-2b): SK 더한 재료 줄 = 기본 계 안(재료 소계 · 계에 더함) · 줄 「별도계상」 고름만 계 밖
- 서버 M02_Sangeun_Doc — 더한줄 재료 칸 별도계상(true 만 남김) · patched 가 더한 재료 줄 자재표시를 직접계산(기본) | 별도계상(칸 켬)으로 둠 · 원문 로직 줄 별도계상은 그대로
- 화면 — SK 상세 고침 표 더한 재료 줄 로직 칸에 「별도계상」 칩(켬 · 끔) · 순수 함수 setApart · 행 apartPick
- 지킴 시험 — test_715_sangeun_engine 더한 재료 계 안 · 별도계상 계 밖 · test_717_2b 칩 꼴

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:34:36 +09:00

361 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_MasterTemplete_Sangeun_Cover.ts
* 단가산출(SK) 고침 표 · 셈 길 부름(PLAN 717-2b · 717-3 · 요구서 내역12 4장 4 · 24) — 두 표가 셈 길 한 벌을 씀.
* · 셈 = `calcSangeun`(`POST /layers/{층}/sangeun/calc` · 저장 안 함 · 부르는 쪽이 최신 응답만 받음) ·
* [단가산출로 저장] = `sangeunFromLine`(`/sangeun/from-line`)
* · `coverTable`(717-3 일위대가 모달) = 이름 · 품 · 단위 · 단가 · 금액 한 표 · 고친 줄 = 표 부품 「바뀜」
* (tr.is-changed) + 원래 값 곁 + 꼬리표 · 단가 칸은 한 비목 줄만 · 덧줄(%) = 율만(서버 `_covered` 규칙)
* · `skCoverTable`(717-2b SK 상세) = M01 「단가산출 상세」 꼴(인력 · 재료 · 경비 · 하위 묶음 + 소계 · 계 ·
* 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액) · 품 · 단가 칸 고침 · 줄 빼기 · 고친 칸 = `is-edited`
* + 바탕 값 곁 글 + 꼬리표 · 값 = 서버 셈 답 그대로
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
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 } from "./logic/M02_Sangeun_Cover_Model";
import {
priceKey,
qtyOf,
type CoverKey,
type LinePatch,
type PatchRow,
type SkCalc,
type SkLine,
} from "./M02_Sangeun_Cover_Model";
import "./M02_MasterTemplete_Sangeun.css";
export interface SkScope {
layer: Layer;
owner: string | null;
}
const q = (scope: SkScope): string =>
scope.owner && scope.layer !== "system" ? `?owner=${encodeURIComponent(scope.owner)}` : "";
/** 셈(저장 안 함) — 문서 = 화면 문서 · `given` = 시험 입력(빈 칸 뺌) · 서버가 M01 엔진 한 벌로 셈 ·
* 답 꼴 = 717-2b `SkAnswer`(기본) · 717-3 `SkCalc` */
export function calcSangeun<T = SkAnswer>(
scope: SkScope,
doc: Record<string, unknown>,
given: Record<string, string> = {},
): Promise<T> {
const 입력값 = Object.fromEntries(Object.entries(given).filter(([, v]) => String(v).trim()));
return call<T>(`/layers/${scope.layer}/sangeun/calc${q(scope)}`, {
method: "POST",
body: JSON.stringify({ 문서: doc, 입력값 }),
});
}
/** [단가산출로 저장] — 그 층 새 SK · 답 = 새 키 + 그 SK 참조 줄 */
export const sangeunFromLine = (
scope: SkScope,
line: PatchRow & Record<string, unknown>,
이름: string,
단위: string,
): Promise<{ 이름: string; 줄: Record<string, unknown> }> =>
call(`/layers/${scope.layer}/sangeun/from-line${q(scope)}`, {
method: "POST",
body: JSON.stringify({ 줄: line, 이름, 단위 }),
});
const num = (v: number | null | undefined): string =>
v == null ? "—" : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
export interface CoverTableText {
name: string;
qty: string;
unit: string;
price: string;
amount: string;
/** 원래 값 곁 글 머리(「원래」) */
was: string;
/** 고친 줄 꼬리표(「이 일위대가에서 고침」) */
tag: string;
empty: string;
}
export interface CoverTableOptions {
edit: boolean;
text: CoverTableText;
/** 칸 고침 — 빈 글 = null(그 칸 원래 값) */
onCover: (cid: string, key: CoverKey, value: number | null) => void;
}
/** 고칠 칸 — 글 칸(Enter · 떠남 때 넘김 · 숫자 아니면 그대로 되돌림) · 곁에 원래 값 */
function cell(
shown: number | null | undefined,
was: number | null | undefined,
covered: boolean,
o: CoverTableOptions,
set: ((v: number | null) => void) | null,
): HTMLElement {
const note =
covered && was != null && was !== shown ? [createText(`${o.text.was} ${num(was)}`)] : [];
if (!o.edit || !set) return el("div", { children: [el("span", { text: num(shown) }), ...note] });
const box = createInputField({ value: shown == null ? "" : String(shown), compact: true });
box.input.inputMode = "decimal";
box.input.dataset.coverCell = "";
box.input.addEventListener("keydown", (e) => e.key === "Enter" && box.input.blur());
box.input.addEventListener("change", () => {
const raw = box.input.value.replace(/,/g, "").trim();
const v = raw === "" ? null : Number(raw);
if (v !== null && (!Number.isFinite(v) || v < 0)) {
box.input.value = shown == null ? "" : String(shown);
return;
}
set(v);
});
return el("div", { children: [box.root, ...note] });
}
/** 고침 표 — `calc` = 지금 셈 답 · `orig` = 고치기 전 셈 줄(cid → 줄) · `patch` = 지금 줄 패치 */
export function coverTable(
calc: SkCalc,
orig: ReadonlyMap<string, SkLine>,
patch: LinePatch | null,
o: CoverTableOptions,
): HTMLElement {
const cols: Column[] = [
{ label: o.text.name },
{ label: o.text.qty, align: "end", width: "10rem" },
{ label: o.text.unit, width: "5rem" },
{ label: o.text.price, align: "end", width: "10rem" },
{ label: o.text.amount, align: "end", width: "9rem" },
];
const { root, tbody } = tableShell(cols, { density: "data" });
for (const line of calc.줄) {
const mine = patch?.덮개?.[line.cid] ?? {};
const was = orig.get(line.cid);
const pk = priceKey(line);
const covered = Object.keys(mine).length > 0 || (line.덮개?.length ?? 0) > 0;
const name = el("div", {
children: [
el("span", { text: line.이름, attrs: { title: line.출처 ?? line.이름 } }),
...(Object.keys(mine).length ? [createBadge(o.text.tag, "warning", { size: 18 })] : []),
],
});
const tr = tableRow(cols, [
name,
// 고친 칸 = 적은 값(서버가 아직 안 받는 칸도 보임) · 그 밖 = 서버 셈 값
cell(mine.qty ?? qtyOf(line), was && qtyOf(was), "qty" in mine, o, (v) =>
o.onCover(line.cid, "qty", v),
),
line.율 != null ? "%" : (line.단위 ?? ""),
cell(
(pk && mine[pk]) ?? line.단가,
was?.단가,
!!pk && pk in mine,
o,
pk ? (v) => o.onCover(line.cid, pk, v) : null,
),
line.금액 == null ? "—" : formatMoney(line.금액),
]);
tr.dataset.cid = line.cid;
if (covered) tr.classList.add("is-changed");
tbody.append(tr);
}
if (!calc.줄.length)
tbody.append(tableRow(cols, [{ content: calc.까닭 ?? o.text.empty, colSpan: cols.length }]));
return root;
}
/* ── 717-2b SK 상세 고침 표 ── */
export interface SkCoverOptions {
/** 고칠 수 있음(쓰기 층 · 읽기만 아님) */
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;
/** 더한 재료 줄 별도계상 켬 · 끔 */
onApart: (row: CoverRow, on: boolean) => 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: SkCoverOptions): (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")] : []),
...(can && r.apartPick
? [
createCheckbox(L("M02_SangeunApart"), {
chip: true,
size: 22,
checked: r.apart,
onChange: (on) => o.onApart(r, on),
}).root,
]
: 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 skCoverTable(rows: CoverRow[], o: SkCoverOptions): 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,
],
});
}