- 한 줄 표(compact) 머리만 · 상세 표 머리는 그대로 - 글 줄만: 넣을 값 빈 글 · 전체 항목 · 복사 생성 「단가산출 로직 수」 · 면적산출 알약 없음 · 실무문서 locale · B05 고르기 모달 일위대가 카드 · 면적 알약 없음 · 일위대가 문서 검사 글(「단가산출 로직 n째」) - 시험 기대값: test_m02_item_list 머리 글 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
476 lines
18 KiB
TypeScript
476 lines
18 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 ?? {};
|
|
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]))),
|
|
...(move && remove
|
|
? [
|
|
el("td", {
|
|
className: "m02-labrow__move",
|
|
children: [
|
|
small(tc("Up"), () => move(i, -1)),
|
|
small(tc("Down"), () => move(i, 1)),
|
|
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") });
|
|
}
|
|
|
|
export interface CompactData {
|
|
rows: LaborRow[];
|
|
mats: LaborMat[];
|
|
logics: Map<string, ItemLogic>;
|
|
calc: LaborCalc | null;
|
|
fail?: string | null;
|
|
cells?: ReadonlyMap<string, string>;
|
|
}
|
|
|
|
/** 한 줄 표(카드용 · 40-1) — 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 ·
|
|
* 반영이 없으면 기준 값 흐리게(`is-base`) · 자재 줄은 같은 표 아래(재료비 · 합계만) · 맨 아래 합계 줄 */
|
|
export function compactTable(data: CompactData): HTMLElement {
|
|
const wait = !data.calc && !data.fail;
|
|
const cell = (v: number | null | undefined, base: boolean, label: string): HTMLElement =>
|
|
el("td", {
|
|
className: `m02-labrow__num${base ? " is-base" : ""}`,
|
|
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
|
|
attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) },
|
|
});
|
|
// 머리 글 = 사용자 글 그대로(40-1 · 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계)
|
|
const heads = [
|
|
tc("Labor_Item"),
|
|
tc("Labor_QtyCol"),
|
|
tc("Labor_Unit"),
|
|
...COSTS.map((c) => tc(HEAD[c])),
|
|
];
|
|
const lines = data.rows.map((row) => {
|
|
const line = data.calc?.줄.find((l) => l.로직 === row.로직);
|
|
const money = line?.반영 ?? line?.기준;
|
|
const base = !line?.반영 && !!line?.기준;
|
|
const logic = data.logics.get(row.로직);
|
|
return el("tr", {
|
|
className: "m02-labrow__row",
|
|
attrs: { "data-labor-logic": row.로직 },
|
|
children: [
|
|
el("td", {
|
|
className: "m02-labrow__name",
|
|
text: logic?.이름 ?? row.로직,
|
|
attrs: { title: `${row.로직}${line?.까닭 ? ` · ${line.까닭}` : ""}` },
|
|
}),
|
|
el("td", {
|
|
className: "m02-labrow__qty",
|
|
text: qtyShort(line, line?.분모 ?? "", data.cells),
|
|
attrs: { "data-label": tc("Labor_QtyCol") },
|
|
}),
|
|
el("td", { className: "m02-labrow__unit", text: logic?.결과단위 ?? "" }),
|
|
...COSTS.map((c) => cell(money?.[c], base, tc(HEAD[c]))),
|
|
],
|
|
});
|
|
});
|
|
const mats = data.mats.map((mat) => {
|
|
const line = data.calc?.자재.find((m) => m.코드 === mat.코드);
|
|
const has = line?.반영 !== null && line?.반영 !== undefined;
|
|
const v = has ? line?.반영 : line?.기준단가;
|
|
return el("tr", {
|
|
className: "m02-labrow__row is-mat",
|
|
attrs: { "data-labor-mat": mat.코드 },
|
|
children: [
|
|
el("td", {
|
|
className: "m02-labrow__name",
|
|
text: mat.이름 ?? mat.코드,
|
|
attrs: { title: [mat.코드, mat.규격].filter(Boolean).join(" · ") },
|
|
}),
|
|
el("td", {
|
|
className: "m02-labrow__qty",
|
|
text: qtyShort(line, mat.단위 ?? "", data.cells),
|
|
attrs: { "data-label": tc("Labor_QtyCol") },
|
|
}),
|
|
el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "" }),
|
|
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.노무비) } }),
|
|
cell(v, !has && v !== null && v !== undefined, tc(HEAD.재료비)),
|
|
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.경비) } }),
|
|
cell(v, !has && v !== null && v !== undefined, tc(HEAD.계)),
|
|
],
|
|
});
|
|
});
|
|
const applied = data.calc?.반영합;
|
|
const anyApplied = !!applied && (applied.계 ?? 0) !== 0;
|
|
const total = anyApplied ? applied : data.calc?.기준합;
|
|
const foot =
|
|
data.rows.length + data.mats.length
|
|
? [
|
|
el("tr", {
|
|
className: "m02-labrow__sum",
|
|
children: [
|
|
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) => cell(total?.[c], !anyApplied && !!total, tc(HEAD[c]))),
|
|
],
|
|
}),
|
|
]
|
|
: [];
|
|
return data.rows.length + data.mats.length
|
|
? el("div", {
|
|
className: "ui-general-block__table-wrap m02-labcompact",
|
|
children: [
|
|
el("table", {
|
|
className: "ui-general-block__table m02-labline",
|
|
children: [
|
|
el("thead", {
|
|
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
|
}),
|
|
el("tbody", { children: [...lines, ...mats] }),
|
|
...(foot.length ? [el("tfoot", { children: foot })] : []),
|
|
],
|
|
}),
|
|
],
|
|
})
|
|
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
|
}
|