- 서버 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>
361 lines
14 KiB
TypeScript
361 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
],
|
||
});
|
||
}
|