Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts
T
eomsangdonandClaude Sonnet 5.5 6a429560fb fix(725-6): 바탕 없는 단가산출 공통 옵션 칸(더한줄 로직 입력 모음) · 상세 못 셈 줄 금액 「—」 + 까닭 풍선
- 바탕 없는 SK = 더한줄 로직 입력을 모아(같은 이름 하나) 공통 옵션 칸으로 보임 · 값 자리 = 시험 입력 그대로 · 바탕 있으면 지금 그대로
- 입력 바꾸면 SK 셈 바로(바탕 없음 = 시험 계산 단추 없음) · 셈 때 SK 줄 입력 아닌 이름은 거름(공통 옵션 지역이 SK 줄에서 「모르는 입력」으로 멈춤)
- 공용 상세 줄 금액 칸 = 까닭 있는 줄만 「—」 + 풍선(M01 줄은 까닭 없음 = 전후 같음)
- 시험 test_725_1 에 바탕 없음 지역 서울 5,297.37 · 화면 짜임 단언 더함

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 00:49:31 +09:00

666 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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"],
EditedM02: ["변경 항목", "Override"],
} 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";
/** (M02) 모달 고침 칸 아래 덧 동작(줄 빼기 · 별도계상 등) */
more?: (idx: number, close: () => void) => HTMLElement[];
}
/** 고친 줄 꼬리표 — M01 = 시험 고침 · M02 = 변경 항목 · 고침 없음 = "" */
const editTag = (ctx: HoContext, idx: number): string =>
ctx.edit?.covers[idx] ? th(ctx.edit.scope === "m02" ? "EditedM02" : "Edited") : "";
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) }
: {}),
// 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만)
...(edit && (e.extra ? edit.scope === "m02" : item)
? {
edit: (close: () => void) => {
const form = coverForm({
qty: line ? pctView(line).qty : null,
prices: e.extra
? {}
: 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)),
});
const more = edit.more?.(e.idx, close) ?? [];
return more.length ? el("div", { children: [form, ...more] }) : form;
},
}
: {}),
});
}
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, tag: string): 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")] : []),
...(tag ? [createBadge(tag, "accent")] : []),
],
});
}
/** 못 셈 줄(725 · M02 단가산출) — 금액 「—」 + 까닭 풍선 */
const gapCell = (why: string): HTMLElement => el("span", { text: "—", attrs: { title: why } });
/** 보통 줄 · 몫 줄 — 누르면 그 줄 모달 */
function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement {
const tag = editTag(ctx, e.idx);
const tr = tableRow(COLS, [
nameCell(e, label, e.share ? "" : tag),
e.qtyValue,
e.unit,
formulaCell(e, ctx),
e.price,
e.line?.까닭 ? gapCell(e.line.까닭) : money(e.amount),
]);
tr.classList.add("m01lab__line");
if (e.extra) tr.classList.add("m01lab__line--extra");
if (tag) 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 tag = editTag(ctx, m.idx);
const head = tableRow(COLS, [
nameCell(first, `▸ ${first.name}`, tag),
first.qtyValue,
first.unit,
formulaCell(first, ctx, short),
first.linePrice,
money(m.금액),
]);
head.classList.add("m01lab__line");
if (tag) 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) : "—"}`,
}),
];
}