- 카드 셋 머리 [삭제] · 일위대가 줄 [삭제] 두 곳 = ui-btn--danger(테마 토큰) - 화면 확인 시나리오 45-6 추가(밝음 5.44 · 어두움 5.79 대비 · 옆 단추와 같은 키) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
439 lines
17 KiB
TypeScript
439 lines
17 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Labor_Rows.ts
|
|
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
|
|
* 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
|
|
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
|
|
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조).
|
|
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
|
|
* 금액은 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 SPAN = 4 + COSTS.length;
|
|
|
|
const num = (v: number): string =>
|
|
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
|
|
|
/** 수량 글 — 「수량 손 3 개소」 · 「수량 12.5 ㎡(B6)」(칸 = 그 알약을 이은 면적산출 칸 · 44-6) ·
|
|
* 이은 칸이 비었으면 「수량 빈 칸(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")} ${tc("In_QtyEmpty")}(${at})` : "";
|
|
}
|
|
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")} ${v}(${cell})`;
|
|
}
|
|
|
|
/** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */
|
|
export function baseQtyText(
|
|
line: { 배?: number | null; 분모?: string | null } | undefined,
|
|
): string {
|
|
if (!line?.분모) return "";
|
|
return `${num(line.배 || 1)} ${line.분모}`;
|
|
}
|
|
|
|
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「12.5(B6)」 · 「빈 칸(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 ? `${tc("In_QtyEmpty")}(${at})` : "—";
|
|
if (line.수량출처 === "손")
|
|
return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`;
|
|
return `${num(line.수량)}(${at ?? tc("In_QtyCell")})`;
|
|
}
|
|
|
|
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「12.5(B6)」 · 「—」) ·
|
|
* `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;
|
|
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
|
|
alias?: (i: number, value: string | null) => void;
|
|
}
|
|
|
|
/** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */
|
|
function aliasCell(
|
|
value: string | null | undefined,
|
|
set: ((v: string | null) => void) | null,
|
|
): HTMLElement {
|
|
if (!set)
|
|
return el("td", {
|
|
className: "m02-labrow__alias",
|
|
text: value ?? "",
|
|
attrs: { "data-label": tc("Col_Alias") },
|
|
});
|
|
const input = el("input", {
|
|
className: "m02-labrow__aliasin",
|
|
attrs: {
|
|
type: "text",
|
|
maxlength: "40", // 서버 `ALIAS_MAX` 와 같음
|
|
title: tc("Alias_Edit"),
|
|
"aria-label": tc("Col_Alias"),
|
|
"data-labor-alias": "",
|
|
},
|
|
});
|
|
input.value = value ?? "";
|
|
input.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(입력 모달)과 따로
|
|
input.addEventListener("input", () => set(input.value.trim() || null));
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") input.blur();
|
|
});
|
|
return el("td", {
|
|
className: "m02-labrow__alias",
|
|
attrs: { "data-label": tc("Col_Alias") },
|
|
children: [input],
|
|
});
|
|
}
|
|
|
|
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.flatMap((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,
|
|
variant: "ghost" | "danger" = "ghost",
|
|
): HTMLElement =>
|
|
createButton({
|
|
label,
|
|
variant,
|
|
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.로직 }),
|
|
...(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}`,
|
|
}),
|
|
]
|
|
: []),
|
|
],
|
|
}),
|
|
aliasCell(row.별칭, on?.alias ? (v) => on.alias?.(i, v) : null),
|
|
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), "danger")] : []),
|
|
],
|
|
}),
|
|
]
|
|
: []),
|
|
],
|
|
});
|
|
if (on) tr.addEventListener("click", () => on.open(i));
|
|
if (!note) return [tr];
|
|
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
|
|
const opt = el("tr", {
|
|
className: on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow",
|
|
attrs: { "data-labor-opt": row.로직 },
|
|
children: [
|
|
el("td", { className: "m02-labrow__no", text: "" }),
|
|
el("td", {
|
|
className: "m02-labrow__opt",
|
|
text: note,
|
|
attrs: { colspan: String(SPAN), title: note },
|
|
}),
|
|
...(tools ? [el("td", { text: "" })] : []),
|
|
],
|
|
});
|
|
if (on) opt.addEventListener("click", () => on.open(i));
|
|
return [tr, opt];
|
|
});
|
|
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__alias", text: "" }),
|
|
el("td", { className: "m02-labrow__qty", text: "" }),
|
|
el("td", { className: "m02-labrow__unit", text: "" }),
|
|
...COSTS.map((c) => {
|
|
const td = moneyCell(
|
|
sum(data.calc?.기준합, c),
|
|
sum(data.calc?.반영합, c),
|
|
wait,
|
|
tc(HEAD[c]),
|
|
);
|
|
if (c === "계") td.classList.add("m02-labrow__total"); // 전체 합계 강조(44-6)
|
|
return td;
|
|
}),
|
|
...(tools ? [el("td", { text: "" })] : []),
|
|
],
|
|
}),
|
|
]
|
|
: [];
|
|
const heads = [
|
|
tc("Col_Order"),
|
|
tc("Col_Name"),
|
|
tc("Col_Alias"),
|
|
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>;
|
|
}
|
|
|
|
/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */
|
|
export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const;
|
|
|
|
/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금
|
|
* 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) ·
|
|
* 보기 갈래는 `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.코드);
|
|
// 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1
|
|
const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine;
|
|
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
|
|
const base = !!line?.기준칸 && line.기준칸 === slot;
|
|
return el("td", {
|
|
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
|
|
text: show(line?.가격?.[slot]),
|
|
attrs: {
|
|
"data-label": slot,
|
|
"data-price-slot": slot,
|
|
...(base ? { title: tc("Labor_BasePrice") } : {}),
|
|
},
|
|
});
|
|
};
|
|
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.규격 ?? "" }),
|
|
qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null),
|
|
el("td", {
|
|
className: "m02-labrow__unit",
|
|
text: mat.단위 ?? "",
|
|
attrs: { "data-label": tc("Labor_Unit") },
|
|
}),
|
|
...PRICE_SLOTS.map(priceCell),
|
|
moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")),
|
|
...(remove
|
|
? [
|
|
el("td", {
|
|
className: "m02-labrow__move",
|
|
children: [
|
|
createButton({
|
|
label: tc("Remove"),
|
|
variant: "danger",
|
|
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_QtyCol"),
|
|
tc("Labor_Unit"),
|
|
...PRICE_SLOTS,
|
|
`${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") });
|
|
}
|