Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts
T
eomsangdonandClaude Opus 5.5 53bca68ea4 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>
2026-11-01 17:23:02 +09:00

214 lines
8.3 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 일위대가 모달도 씀) — 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,
],
});
}