Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts
T
eomsangdonandClaude Opus 5.5 ca4fcf0364 feat(M02): 구조물 조합 일위대가 카드 = 상세 그대로 — 단가산출 로직 2행 표 + 자재목록(Labor_Detail 카드 갈래 · 순서 · 로직 추가 · 다이어그램 · 정보 칸 없음 · 빼기 · 자재 추가 그대로) · 카드 [저장] · Ctrl+S
43-4 — 줄 누름 = 넣을 값 모달 · 수량산출 칸 = 수량 모달(상세와 같은 함수) · 저장 차례 = 면적 먼저 → 일위대가 · 표 감싸개 안 가로 스크롤 · 40-2 한 줄 표(compactTable · Labor_View compact · m02-labline css) 지움 · 빼기만 있는 줄도 끝 칸 · 화면 시나리오 ui_checks/43-4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 00:33:55 +09:00

366 lines
14 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Labor_Rows.ts
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
* 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄.
* 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」).
* 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type {
LaborCalc,
LaborCalcLine,
LaborCalcMat,
LaborCost,
LaborMat,
LaborRow,
} from "./M02_MasterTemplete_Labor_Api";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_MasterTemplete_Labor.css";
import { valueLine } from "./M02_Labor_Values_Model";
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
노무비: "Prev_Labor",
재료비: "Prev_Material",
경비: "Prev_Expense",
계: "Prev_Sum",
};
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) ·
* 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
export function qtyText(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
cells: ReadonlyMap<string, string> = new Map(),
): string {
if (!line) return "";
if (line.수량 === null || line.수량 === undefined) {
const at = line.알약 ? cells.get(line.알약) : undefined;
return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : "";
}
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
return `${tc("Labor_Qty")} ${cell} = ${v}`;
}
/** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */
export function baseQtyText(
line: { 배?: number | null; 분모?: string | null } | undefined,
): string {
if (!line?.분모) return "";
return `${num(line.배 || 1)} ${line.분모}`;
}
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「B6 = 12.5」 · 「B6 = 빈 칸」 · 없으면 「—」 */
export function qtyShort(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
cells: ReadonlyMap<string, string> = new Map(),
): string {
const at = line?.알약 ? cells.get(line.알약) : undefined;
if (!line || line.수량 === null || line.수량 === undefined)
return at ? `${at} = ${tc("In_QtyEmpty")}` : "—";
if (line.수량출처 === "손")
return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`;
return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`;
}
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) ·
* `open` 이 있으면 단추(줄 누름과 따로) */
function qtyCell(
line: LaborCalcLine | undefined,
cells: ReadonlyMap<string, string> | undefined,
open: (() => void) | null,
): HTMLElement {
const parts = [
el("small", { className: "m02-labrow__base", text: baseQtyText(line) || "—" }),
el("span", { className: "m02-labrow__applied", text: qtyShort(line, line?.분모 ?? "", cells) }),
];
const body = open
? el("button", {
className: "m02-labrow__qtybtn",
attrs: { type: "button", title: tc("Labor_QtyPick"), "data-labor-qty-open": "" },
children: parts,
})
: el("span", { children: parts });
if (open)
body.addEventListener("click", (e) => {
e.stopPropagation(); // 줄 누름(입력 모달)과 따로
open();
});
return el("td", {
className: "m02-labrow__qty",
attrs: { "data-label": tc("Labor_QtyCol") },
children: [body],
});
}
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */
function moneyCell(
base: number | null | undefined,
applied: number | null | undefined,
wait: boolean,
label: string,
): HTMLElement {
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
return el("td", {
className: "m02-labrow__money",
attrs: { "data-label": label },
children: [
el("small", {
className: "m02-labrow__base",
text: show(base),
attrs: { title: tc("Labor_Base") },
}),
el("span", {
className: "m02-labrow__applied",
text: show(applied),
attrs: { title: tc("Labor_Applied") },
}),
],
});
}
const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap",
children: [
el("table", {
className: "ui-general-block__table m02-labrow",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
...(foot.length ? [el("tfoot", { children: foot })] : []),
],
}),
],
});
/** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */
export interface RowHandlers {
open: (i: number) => void;
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
qty?: (i: number) => void;
move?: (i: number, d: number) => void;
remove?: (i: number) => void;
}
export interface LogicTableData {
rows: LaborRow[];
logics: Map<string, ItemLogic>;
/** calc 답 — null = 계산 중 · 못 셈은 `fail` */
calc: LaborCalc | null;
fail?: string | null;
/** 알약 키 → 이은 면적산출 칸(「B6」) */
cells?: ReadonlyMap<string, string>;
}
/** 담은 로직 표 — 보기 갈래는 `on` 없이 */
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
const wait = !data.calc && !data.fail;
const { move, remove } = on ?? {};
// 빼기만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만
const tools = !!(move || remove);
const lineOf = (key: string): LaborCalcLine | undefined =>
data.calc?.줄.find((l) => l.로직 === key);
const trs = data.rows.map((row, i) => {
const logic = data.logics.get(row.로직);
const line = lineOf(row.로직);
const unit = logic?.결과단위 ?? "";
const values = valueLine(logic?.입력 ?? [], row.입력값);
const note = values.text;
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
const small = (label: string, run: () => void): HTMLElement =>
createButton({
label,
variant: "ghost",
onClick: (e) => {
e.stopPropagation(); // 줄 누름(입력 모달)과 따로
run();
},
});
const tr = el("tr", {
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
attrs: { "data-labor-logic": row.로직 },
children: [
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
el("td", {
className: "m02-labrow__name",
children: [
el("span", { className: "m02-labrow__key", text: row.로직 }),
el("strong", { text: logic?.이름 ?? row.로직 }),
...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []),
...(values.empty
? [
el("small", {
className: "m02-labrow__empty",
text: tc("Labor_Empty").replace("{n}", String(values.empty)),
}),
]
: []),
...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []),
...(why
? [
el("small", {
className: "m02-labrow__empty",
text: `${tc("Labor_Calc_Fail")}: ${why}`,
}),
]
: []),
],
}),
qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null),
el("td", { className: "m02-labrow__unit", text: unit }),
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))),
...(tools
? [
el("td", {
className: "m02-labrow__move",
children: [
...(move
? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))]
: []),
...(remove ? [small(tc("Remove"), () => remove(i))] : []),
],
}),
]
: []),
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
});
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
part?.[c] ?? null;
const foot = data.rows.length
? [
el("tr", {
className: "m02-labrow__sum",
children: [
el("td", { className: "m02-labrow__no", text: "" }),
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
el("td", { className: "m02-labrow__qty", text: "" }),
el("td", { className: "m02-labrow__unit", text: "" }),
...COSTS.map((c) =>
moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])),
),
...(tools ? [el("td", { text: "" })] : []),
],
}),
]
: [];
const heads = [
tc("Col_Order"),
tc("Col_Name"),
tc("Labor_QtyCol"),
tc("Col_Unit"),
...COSTS.map((c) => tc(HEAD[c])),
...(tools ? [tc("Col_Move")] : []),
];
return data.rows.length
? table(heads, trs, foot)
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
}
export interface MatTableData {
mats: LaborMat[];
calc: LaborCalc | null;
fail?: string | null;
cells?: ReadonlyMap<string, string>;
}
/** 자재목록 표 — 보기 갈래는 `on` 없이 */
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
const wait = !data.calc && !data.fail;
const remove = on?.remove;
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
const trs = data.mats.map((mat, i) => {
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량);
const tr = el("tr", {
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
attrs: { "data-labor-mat": mat.코드 },
children: [
el("td", { className: "m02-labrow__code", text: mat.코드 }),
el("td", {
className: "m02-labrow__name",
children: [
el("strong", { text: mat.이름 ?? "" }),
...(line?.까닭
? [el("small", { className: "m02-labrow__empty", text: line.까닭 })]
: []),
],
}),
el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "" }),
el("td", {
className: "m02-labrow__unit",
text: mat.단위 ?? "",
attrs: { "data-label": tc("Labor_Unit") },
}),
el("td", {
className: "m02-labrow__num",
text: qty,
attrs: {
title: qtyText(line, mat.단위 ?? "", data.cells),
"data-label": tc("Labor_Qty"),
},
}),
el("td", {
className: "m02-labrow__num",
text: show(line?.기준단가),
attrs: { "data-label": tc("Labor_BasePrice") },
}),
el("td", {
className: "m02-labrow__num",
text: show(line?.반영),
attrs: { "data-label": tc("Labor_AppliedSum") },
}),
...(remove
? [
el("td", {
className: "m02-labrow__move",
children: [
createButton({
label: tc("Remove"),
variant: "ghost",
onClick: (e) => {
e.stopPropagation();
remove(i);
},
}),
],
}),
]
: []),
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
});
const heads = [
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Labor_Unit"),
tc("Labor_Qty"),
tc("Labor_BasePrice"),
tc("Labor_AppliedSum"),
...(remove ? [tc("Col_Move")] : []),
];
return data.mats.length
? table(heads, trs, [])
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
}