- 새 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>
214 lines
8.3 KiB
TypeScript
214 lines
8.3 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
],
|
||
});
|
||
}
|