feat(722-1·722-2): M01 단가산출 상세 개편 — 공용 상세 부품(하위 박스 · 계산식 · 박스 옵션) · 줄 모달 그 자리 고침(시험 값)

- 새 공용 단가산출 상세 `M01_MasterData_UI_LogicLab_Ho.ts` — 하위 박스 = 서버 묶음(인력 · 재료 · 경비(장비) · 하위) · 장비 · 하위 로직 = 한 줄 + 펼침(부른 로직 시간당 줄 · 몫 줄 · 다시 셈해도 펼친 채) · 「계산식」 열 = 값 든 식 + 알약 + 그 줄 입력 칩 · 이름 식 = 풍선 · 박스 제목 아래 옵션 고르기 · 어느 박스도 안 쓰는 입력 = 공통 옵션 · 맨 아래 비목 소계 셋 + 계
- 새 `M01_MasterData_UI_LogicLab_Cover.ts` — 줄 모달 「그 자리 고침」(수량 · 단가 · 로직 줄은 노 · 재 · 경) · M01 = 시험 값만(로직 줄 객체 열쇠 메모리 · 시험 계산 줄 사본에 손 단가 · 값묶음 · 수량 덮음 · 정본 · 요소 · 캐시 안 바뀜) · 고침 꼴 = M02 덮개 칸(724 고침 고리 `HoEdit`)
- `LogicLab_Detail.ts` 단가산출 상세 = 공용 부품(호표 묶음 표 코드 옮김 · 551 → 252 줄) · `LogicLab.ts` 시험 계산 줄 = trial.apply · dirty 에 시험 고침 · onTrial(줄 수 627 그대로)
- `calc_sheet.ts` varRow 내보냄 · 값 없으면 빈 첫 칸 · `LogicLab_Formula.ts` inputChip 내보냄 · `LogicLab_Modal.ts` 줄 모달 edit 칸
- 보기 고르기 넷 그대로(지우기는 사용자 확인 뒤)
- 지킴 시험 test_722_ho_detail(박스 묶기 · 고침 덮기 순수 함수 · 캐시 밖 · 부품 상자)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 21:39:17 +09:00
co-authored by Claude Opus 5.5
parent f7ebf1831f
commit 190789cdf7
9 changed files with 1030 additions and 344 deletions
@@ -0,0 +1,649 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Ho.ts
* 공용 「단가산출 상세」(PLAN 722-1 · 요구서 내역16 2장 6 · 7) — M01 단가산출 로직 상세가 씀 · (724) M02 단가산출 · 일위대가 모달도.
* 하위 박스 = 서버 `묶음`(인력 · 재료 · 경비(장비) · 하위 — `/text` 줄 덧칸 714-1) · 계산 전 = 호표 종류로
* · 장비 · 하위 로직 줄 = 박스 안 한 줄(노 · 재 · 경 몫 합) + 누르면 펼침(부른 로직 시간당 줄 · 몫 식 줄)
* 박스 제목 바로 아래 = 그 박스 줄들이 쓰는 입력 고르기(`calc_sheet` 변수 칸 꼴) · 어느 박스도 안 쓰는 입력 = 맨 위 「공통 옵션」
* 「계산식」 열 = 값 든 식(「268,908 × 0.12」 · 서버 `글`) + 표 · 요소 · 로직 알약 + 그 줄 입력 칩 · 변수 이름 식(`이름글`)은 마우스 올리면
* 맨 아래 = 비목 소계 셋(노무비 · 재료비 · 경비 — 장비 노무는 여기서 인력과 더해짐) + 계
* 줄 누름 = 그 줄 자료 모달(`_LogicLab_Modal.ts`) · 고침 고리(`edit`) 있으면 모달 안 「그 자리 고침」(`_LogicLab_Cover.ts`)
* 값 = 서버 셈 그대로(박스 소계 · 비목 소계만 줄 금액 합) · 화면에서 다시 셈 안 함
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createCalcFormulaRow } from "@ui/ui_template_calc_row";
import { createCard } from "@ui/ui_template_card";
import { el } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { emptyRow, tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { sheetFormula, varRow, type SheetUnfold } from "../A00_Common/calc_sheet";
import type {
CalcLine,
ElementBrief,
HoLine,
LogicRow,
NamedFormula,
TextAnswer,
TextLine,
TextPiece,
} from "./M01_MasterData_UI_Logic_Api";
import { pctView } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover";
import { inputChip } from "./M01_MasterData_UI_LogicLab_Formula";
import {
condText,
openMaterialModal,
openNoteModal,
openRefModal,
openLogic,
openTableModal,
type PickCond,
} from "./M01_MasterData_UI_LogicLab_Modal";
import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
/* ── 순수 셈 ── */
/** 박스로 묶을 줄 — 호표 차례 · 박스 이름 · 한 줄로 합칠지(장비 · 하위) · 금액 */
export interface HoRowLike {
idx: number;
bundle: string;
merge: boolean;
amount: number | null;
}
export interface HoMerged<T> {
idx: number;
몫: T[];
금액: number | null;
}
export interface HoBox<T> {
묶음: string;
줄: (T | HoMerged<T>)[];
소계: number;
}
const BOX_ORDER = ["인력", "재료", "경비", "하위"];
/** 줄 → 박스(인력 · 재료 · 경비 · 하위 차례) — 합칠 줄은 같은 박스 · 같은 호표 차례끼리 한 줄(몫 차례 그대로) ·
* `always` 박스는 줄이 없어도 빈 박스로 */
export function boxesOf<T extends HoRowLike>(list: T[], always: string[] = []): HoBox<T>[] {
const boxes = new Map<string, HoBox<T>>();
const take = (name: string): HoBox<T> => {
let b = boxes.get(name);
if (!b) boxes.set(name, (b = { 묶음: name, 줄: [], 소계: 0 }));
return b;
};
always.forEach(take);
const merged = new Map<string, HoMerged<T>>();
for (const e of list) {
const b = take(e.bundle);
b.소계 += e.amount ?? 0;
if (!e.merge) {
b.줄.push(e);
continue;
}
const id = `${e.bundle}\n${e.idx}`;
let m = merged.get(id);
if (!m) {
merged.set(id, (m = { idx: e.idx, 몫: [], 금액: null }));
b.줄.push(m);
}
m.몫.push(e);
m.금액 = m.금액 === null && e.amount === null ? null : (m.금액 ?? 0) + (e.amount ?? 0);
}
const rank = (x: string): number => (BOX_ORDER.includes(x) ? BOX_ORDER.indexOf(x) : 9);
return [...boxes.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음));
}
/* ── 순수 셈 끝 ── */
const TEXT = {
인력: ["인력", "Labor"],
재료: ["재료", "Material"],
경비: ["경비(장비)", "Expense (equipment)"],
하위: ["하위 로직", "Sub logic"],
Common: ["공통 옵션", "Common options"],
Basis: ["기준", "Basis"],
Formula: ["계산식", "Formula"],
Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"],
Fold: ["누르면 펼침", "Click to expand"],
Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"],
Edited: ["시험 고침", "Trial edit"],
} as const satisfies Record<string, readonly [string, string]>;
const th = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
const boxName = (b: string): string => (b in TEXT ? th(b as keyof typeof TEXT) : b);
/** 고침 고리 — M01 = 시험 값(줄 사본) · M02 = 덮개 보관 · 열쇠 = 호표 줄 차례 */
export interface HoEdit {
covers: Record<number, LineCover>;
set: (idx: number, cover: LineCover | null) => void;
scope?: "m01" | "m02";
}
export interface HoContext {
row: LogicRow;
prices: Record<string, ElementBrief | null>;
/** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */
lines: CalcLine[] | null;
/** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */
text: TextAnswer | null;
/** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */
lastText?: TextAnswer;
values: Record<string, string>;
/** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */
onInput?: (name: string, value: string) => void;
/** 시험 계산 칸 — 재료 후보 고르기가 씀(없으면 후보만 보임) */
calcHost?: HTMLElement;
/** 그 자리 고침 고리(없으면 모달은 보기만) */
edit?: HoEdit;
}
type Group = "labor" | "material" | "cost" | "other";
const OF_KIND: Record<string, Group> = { 인력: "labor", 재료: "material", 기계: "cost" };
const OF_COST: Record<string, Group> = { 노무비: "labor", 재료비: "material", 경비: "cost" };
const BOX_OF: Record<Group, string> = {
labor: "인력",
material: "재료",
cost: "경비",
other: "하위",
};
const COST_OF: Record<string, string> = { labor: "노무비", material: "재료비", cost: "경비" };
const PART_OF: Record<Group, CoverPart> = {
labor: "labor",
material: "material",
cost: "expense",
other: "expense",
};
/** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */
const groupOfHo = (item: HoLine): Group =>
OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other";
interface Entry extends HoRowLike {
group: Group;
name: string;
spec: string;
unit: string;
qty: string;
/** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */
qtyValue: string;
qtyFrag: Fragment[];
/** 한 줄이 비목 몫으로 갈렸을 때 이 몫 이름(예 「노무비」) */
share?: string;
price: string;
/** 갈리기 전 줄 단가(장비 한 줄 머리) */
linePrice: string;
extra: boolean;
/** 계산 답 줄 · 서버 읽는 식 줄(붙기 전엔 없음) */
line?: CalcLine;
textLine?: TextLine;
/** 고침 칸 — 로직 줄 = 노 · 재 · 경 · 그 밖 = 그 줄 비목 */
logic: boolean;
part: CoverPart;
open: (self: Entry) => void;
unitFrag?: Fragment[];
piecesFrag?: Fragment[];
unitRef?: string;
unitText?: string;
warn?: string;
}
/** 서버 조각(`/text` 줄의 `조각`) → 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
function toFragments(pieces: TextPiece[], ctx: HoContext): Fragment[] {
return pieces.map((piece): Fragment => {
const dim = piece.깊이 ? { dim: true } : {};
if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직")
return { text: piece.글, ...dim };
const ref = piece.참조;
const open = (): void => {
if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick"));
if (piece.kind === "표")
openTableModal({ title: piece.글, table: ref.키, row: ref.줄 ?? ref.행, col: ref.열 });
else if (piece.kind === "요소")
openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices });
else openLogic(ref.키);
};
return { pill: { label: piece.글, onClick: open }, ...dim };
});
}
/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */
const condOf = (item: HoLine): PickCond | null =>
typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null;
const condLabel = (cond: PickCond): string =>
[cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · ");
const nameOf = (item: HoLine): string =>
item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소);
/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */
const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—");
/** 계산된 줄은 서버가 준 비목별 몫으로 나눔 — 로직을 부르는 줄 · 장비 줄도 노무비·재료비·경비로 갈림 */
const spread = (e: Entry, line?: CalcLine): Entry[] => {
const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0);
if (!parts.length) return [e];
return parts.map(([k, v]) => ({
...e,
group: OF_COST[k] ?? "other",
bundle: BOX_OF[OF_COST[k] ?? "other"],
amount: v,
...(parts.length > 1
? {
share: k,
// 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게
...(line && line.금액 && line.단가 != null
? { price: formatPrice((line.단가 * v) / line.금액) }
: {}),
}
: {}),
}));
};
/** 줄 모달 — 자료 보기 + (고리 있으면) 그 자리 고침 */
function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLine }): void {
const middles: NamedFormula[] = ctx.row.중간 ?? [];
const { cond, item } = extra;
const line = e.line;
const edit = ctx.edit;
const perUnit = (k: string): number | null =>
line && line.수량 ? (line.비목?.[k] ?? 0) / line.수량 : null;
openMaterialModal({
unitFrag: e.unitFrag,
unitRef: line?.품목?.키 ?? e.unitRef,
unitText: e.unitText,
qtyFrag: e.piecesFrag,
title: e.name,
expr: e.qty,
...(item ? (cond ? { cond, unit: item.단위 } : { element: item.요소 }) : {}),
prices: ctx.prices,
middles,
values: ctx.values,
// 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜)
kind: item?.종류,
usedSlot: line?.품목?.값칸,
...(item?.종류 === "재료" && ctx.calcHost
? { onPick: (it) => pickMaterial(ctx.calcHost!, ctx.values, e.idx, it) }
: {}),
...(edit && item && !e.extra
? {
edit: (close: () => void) =>
coverForm({
qty: line ? line.수량 : null,
prices: e.logic
? {
labor: perUnit("노무비"),
material: perUnit("재료비"),
expense: perUnit("경비"),
}
: { [e.part]: line ? line.단가 : null },
cover: edit.covers[e.idx],
scope: edit.scope,
onApply: (c) => (close(), edit.set(e.idx, c)),
}),
}
: {}),
});
}
function entries(ctx: HoContext): Entry[] {
const ho = ctx.row.호표 ?? [];
const out: Entry[] = ho.flatMap((item, i) => {
const line = ctx.lines?.[i];
const cond = condOf(item);
const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소];
const price = line ? line.단가 : brief?.값;
const group = groupOfHo(item);
const shown = price === undefined || price === null ? "" : formatPrice(price);
const entry: Entry = {
idx: i,
group,
bundle: BOX_OF[group],
merge: false,
name: nameOf(item),
spec: item.규격 ?? brief?.규격 ?? "",
unit: item.단위 ?? "",
qty: item.수량,
qtyValue: qtyOf(line),
qtyFrag: [{ text: item.수량 }],
price: shown,
linePrice: shown,
amount: line ? line.금액 : null,
warn: line?.단위경고,
extra: false,
line,
logic: item.종류 === "로직",
part: OF_COST[item.비목 ?? ""] ? PART_OF[OF_COST[item.비목 ?? ""]] : PART_OF[group],
open: (self) => modalOf(self, ctx, { cond: cond ?? undefined, item }),
};
return spread(entry, line);
});
(ctx.row.덧줄 ?? []).forEach((extra, i) => {
const line = ctx.lines?.[ho.length + i];
const group = OF_COST[extra.비목 ?? ""] ?? "other";
const entry: Entry = {
idx: ho.length + i,
group,
bundle: BOX_OF[group],
merge: false,
name: extra.이름,
spec: "",
unit: line ? pctView(line).unit : "",
qty: extra.식,
qtyValue: qtyOf(line),
qtyFrag: [{ text: extra.식 }],
price: "",
linePrice: "",
amount: line ? line.금액 : null,
extra: true,
line,
logic: false,
part: PART_OF[group],
open: (self) => modalOf(self, ctx, {}),
};
out.push(...spread(entry, line));
});
attachPieces(out, ctx);
return out;
}
/** 서버 `/text` 줄을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 비목마다 호표 차례대로 줄을 주므로
* 이름을 차례로 따라가며 줄 차례에 맞춤 · 붙은 줄의 `묶음` · `장비` 로 박스를 정함 ·
* 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */
function attachPieces(all: Entry[], ctx: HoContext): void {
const source = ctx.text?.ok ? ctx.text : ctx.lastText;
if (!source?.ok) {
if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸
return;
}
const names = [...(ctx.row.호표 ?? []).map(nameOf), ...(ctx.row.덧줄 ?? []).map((x) => x.이름)];
const found = new Map<string, TextLine>();
for (const g of source.groups) {
let from = 0;
for (const line of g.줄) {
const at = names.findIndex((n, i) => i >= from && n === line.이름);
if (at < 0) continue;
found.set(`${g.비목}|${at}`, line);
from = at + 1;
}
}
const costs = Object.keys(OF_COST);
const fresh = ctx.lines !== null; // 묶음은 지금 셈 답에서만(옛 답의 묶음으로 박스를 안 옮김)
for (const e of all) {
const own = costs.find((c) => OF_COST[c] === e.group);
const line = [own, ...costs].map((c) => found.get(`${c}|${e.idx}`)).find(Boolean);
const pieces = line?.조각;
e.qtyFrag = e.piecesFrag = pieces?.length ? toFragments(pieces, ctx) : [];
if (line?.요소조각?.length) {
e.unitFrag = toFragments(line.요소조각, ctx);
const first = line.요소조각.find((piece) => piece.kind === "요소");
e.unitRef = first?.참조?.키;
e.unitText = first?.글;
}
if (!line || !fresh) continue;
e.textLine = line;
if (line.묶음) e.bundle = line.묶음;
e.merge = !!line.장비 || line.묶음 === "하위";
}
}
const COLS: Column[] = [
{ label: tl("Col_Name"), width: "20%" },
{ label: tl("Col_Qty"), width: "9%", align: "end" },
{ label: tl("Col_Unit"), width: "7%" },
{ label: th("Formula"), width: "38%" },
{ label: tl("Col_Price"), width: "13%", align: "end" },
{ label: tl("Col_Amount"), width: "13%", align: "end" },
];
const money = (v: number | null): string => (v === null ? "" : formatMoney(v));
/** 이 줄이 쓰는 입력 칩 — 서버 `쓰는입력` ∩ 로직 입력 */
function chips(e: Entry, ctx: HoContext): HTMLElement[] {
const onInput = ctx.onInput;
const uses = e.textLine?.쓰는입력 ?? [];
const specs = (ctx.row.입력 ?? []).filter((s) => uses.includes(s.이름));
if (!onInput || !specs.length) return [];
return [
el("div", {
className: "m01lab-fx__vars",
children: specs.map((s) => inputChip(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v))),
}),
];
}
/** 계산식 칸 — 값 든 식(계산 뒤) · 계산 전 = 수량 식 · 알약 · 입력 칩 · 이름 식은 풍선 */
function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement {
const tline = e.textLine;
const main = text || (tline ? sheetFormula(tline.글) : e.qty);
const pills = e.qtyFrag.some((f) => "pill" in f) ? [fragmentRow(e.qtyFrag)] : [];
const cell = el("div", {
className: "m01lab__qty-cell",
children: [
el("div", { className: "m01lab__qty-text", text: main }),
...(text ? [] : [...pills, ...chips(e, ctx)]),
],
});
cell.title = tline?.이름글 ?? e.qty;
return cell;
}
function nameCell(e: Entry, label: string, covered: boolean): HTMLElement {
return el("div", {
children: [
el("div", { text: label }),
...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []),
...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []),
...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []),
...(covered ? [createBadge(th("Edited"), "accent")] : []),
],
});
}
/** 보통 줄 · 몫 줄 — 누르면 그 줄 모달 */
function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement {
const covered = !!ctx.edit?.covers[e.idx];
const tr = tableRow(COLS, [
nameCell(e, label, covered && !e.share),
e.qtyValue,
e.unit,
formulaCell(e, ctx),
e.price,
money(e.amount),
]);
tr.classList.add("m01lab__line");
if (e.extra) tr.classList.add("m01lab__line--extra");
if (covered) tr.classList.add("is-changed");
tr.tabIndex = 0;
tr.dataset.line = e.name;
tr.addEventListener("click", () => e.open(e));
tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e));
return tr;
}
/** 펼친 장비 줄 — 로직 줄 객체마다 기억(다시 셈 · 다시 그려도 펼친 채) */
const opened = new WeakMap<object, Set<number>>();
const openOf = (row: object): Set<number> => {
let got = opened.get(row);
if (!got) opened.set(row, (got = new Set()));
return got;
};
/** 서버 줄 덧칸 — 장비 줄 펼침(714-1b · 부른 기계경비 로직 시간당 줄) */
type Unfoldable = TextLine & { 펼침?: SheetUnfold | null };
/** 장비 · 하위 한 줄 — 머리(노 · 재 · 경 몫 + 합) · 누르면 펼침(부른 로직 시간당 줄 · 몫 줄) */
function mergedRows(m: HoMerged<Entry>, ctx: HoContext): HTMLTableRowElement[] {
const first = m.몫[0];
const share = (cost: string): string =>
formatMoney(
m.몫
.filter((x) => (x.share ?? COST_OF[x.group]) === cost)
.reduce((s, x) => s + (x.amount ?? 0), 0),
);
const short = th("Short")
.replace("{a}", share("노무비"))
.replace("{b}", share("재료비"))
.replace("{c}", share("경비"));
const covered = !!ctx.edit?.covers[m.idx];
const head = tableRow(COLS, [
nameCell(first, `▸ ${first.name}`, covered),
first.qtyValue,
first.unit,
formulaCell(first, ctx, short),
first.linePrice,
money(m.금액),
]);
head.classList.add("m01lab__line");
if (covered) head.classList.add("is-changed");
head.dataset.equip = first.name;
head.title = th("Fold");
head.tabIndex = 0;
head.setAttribute("aria-expanded", "false");
const unfold = m.몫.map((x) => (x.textLine as Unfoldable | undefined)?.펼침).find(Boolean);
const subs: HTMLTableRowElement[] = [
...(unfold
? [
tableRow(COLS, [
{
content: createText(
th("Per")
.replace("{n}", unfold.이름)
.replace("{v}", formatPrice(unfold.계 ?? 0))
.replace("{u}", unfold.단위 || "-"),
"hint",
),
colSpan: COLS.length,
},
]),
...unfold.줄.map((x) =>
tableRow(COLS, [
`${x.이름} (${x.비목})`,
"",
"",
sheetFormula(x.글),
"",
money(x.금액),
]),
),
]
: []),
...m.몫.map((x) => lineRow(x, ctx, `${x.share ?? first.name}`)),
];
for (const s of subs) {
s.classList.add("is-sub");
s.hidden = true;
s.dataset.part = first.name;
}
const label = head.querySelector("td > div > div") as HTMLElement;
const keep = openOf(ctx.row);
const show = (open: boolean): void => {
head.setAttribute("aria-expanded", String(open));
label.textContent = `${open ? "▾" : "▸"} ${first.name}`;
subs.forEach((s) => (s.hidden = !open));
if (open) keep.add(m.idx);
else keep.delete(m.idx);
};
const toggle = (): void => show(head.getAttribute("aria-expanded") !== "true");
if (keep.has(m.idx)) show(true);
head.addEventListener("click", toggle);
head.addEventListener("keydown", (ev) => {
if (ev.key !== "Enter" && ev.key !== " ") return;
ev.preventDefault();
toggle();
});
return [head, ...subs];
}
/** 옵션 고르기 줄들 — 이름 차례 · 고르기 있으면 고르기 · 아니면 글 칸 */
function optionRows(names: string[], ctx: HoContext): HTMLElement[] {
const onInput = ctx.onInput;
if (!onInput) return [];
return (ctx.row.입력 ?? [])
.filter((s) => names.includes(s.이름))
.map((s) => varRow(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v)));
}
const usesOf = (b: HoBox<Entry>): string[] => [
...new Set(
b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r]).flatMap((e) => e.textLine?.쓰는입력 ?? [])),
),
];
function boxCard(b: HoBox<Entry>, ctx: HoContext, calculated: boolean): HTMLElement {
const name = boxName(b.묶음);
const lines = b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r]));
const basis = [...new Set(lines.map((e) => e.textLine?.기준단위).filter(Boolean))];
const notes = [...new Set(lines.map((e) => e.textLine?.환산근거).filter(Boolean))];
const { root, tbody } = tableShell(COLS, { density: "data" });
root.classList.add("m01lab__ho");
tbody.append(
...(b.줄.length
? b.줄.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)]))
: [emptyRow(COLS, tl("Empty_Group"))]),
);
const sub = tableRow(COLS, [
{ content: `${name} ${tl("Subtotal")}`, colSpan: 5 },
calculated ? formatMoney(b.소계) : "—",
]);
sub.classList.add("m01lab__subtotal");
sub.dataset.box = b.묶음;
sub.dataset.subtotal = calculated ? String(b.소계) : "";
tbody.append(sub);
const card = createCard({
title: name,
pad: 12,
body: [
...(basis.length ? [createText(`${th("Basis")} ${basis.join(" · ")}`, "muted")] : []),
...(notes.length ? [createText(notes.join(" · "), "hint")] : []),
...optionRows(usesOf(b), ctx),
root,
],
});
card.classList.add("m01lab__ho-box");
card.dataset.hoBox = b.묶음;
return card;
}
/** 어느 박스 줄도 안 쓰는 입력 — 맨 위 한 카드 */
function commonCard(rows: HTMLElement[]): HTMLElement {
const card = createCard({ title: th("Common"), pad: 12, body: rows });
card.classList.add("m01lab__ho-box");
card.dataset.hoBox = "common";
return card;
}
/** 공용 단가산출 상세 몸 — 공통 옵션 · 박스들 · 비목 소계 셋 · 계 */
export function buildHo(ctx: HoContext): HTMLElement[] {
const all = entries(ctx);
const calculated = ctx.lines !== null;
const boxes = boxesOf(all, ["인력", "재료", "경비"]);
const used = new Set(boxes.flatMap(usesOf));
const common = optionRows(
(ctx.row.입력 ?? []).map((s) => s.이름).filter((n) => !used.has(n)),
ctx,
);
const sums = (["labor", "material", "cost"] as Group[]).map((g) => {
const v = all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0);
const out = el("span", {
className: "ui-calc-row__out is-key",
text: calculated ? formatMoney(v) : "—",
});
const row = createCalcFormulaRow(COST_OF[g], null, out);
row.dataset.group = g;
row.dataset.subtotal = calculated ? String(v) : "";
return row;
});
const total = all.reduce((s, e) => s + (e.amount ?? 0), 0);
return [
...(common.length ? [commonCard(common)] : []),
...boxes.map((b) => boxCard(b, ctx, calculated)),
el("div", { className: "m01lab__sums", children: sums }),
el("p", {
className: "m01lab__total",
attrs: { "data-sum": calculated ? String(total) : "" },
text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`,
}),
];
}