- SK 셈 답 → 공용 상세 줄(logic/M02_Sangeun_Cover_Model.hoView · 정체 차례 · 바탕 줄 그대로 · 더한줄) · 고침 고리 HoEdit → setLineCover(덮개 · 더한줄 · 열린입력 = 배율) - HoEdit 덧칸 more(줄 빼기 · 별도계상) · M02 = 덧줄 율 고침 · 꼬리표 「변경 항목」 · 뺀 줄 띠 = Sangeun_Cover.removedStrip - 시험 계산 · 텍스트 수식 = 바탕 로직 + 덮개 품 · 단가 사본(applyCovers) · 뺀 바탕 줄 글 거름(dropText — 같은 이름 더한줄이 안 받음) - 717-3 openRowSangeun = 줄 모드 상세(mountRowSk) 얇은 모달(Labor_Detail 부름 그대로) · skCoverTable · coverTable · 뿌리 M02_Sangeun_Cover_Model.ts 지움(Labor_Api import 한 줄) - 낡은 시험: test_717_2b(setQty · setPrice → setLineCover · hoView · dropText) · test_717_3(모델 경로 · setCover · priceKey · sheetGroups 걷힘 → cleanPatch · 화면 이음 새 꼴) - 안내 글(M02_SangeunBaseViews) = 덮개 사본 반영 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
663 lines
26 KiB
TypeScript
663 lines
26 KiB
TypeScript
/* =============================================================================
|
||
* 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")] : []),
|
||
],
|
||
});
|
||
}
|
||
|
||
/** 보통 줄 · 몫 줄 — 누르면 그 줄 모달 */
|
||
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,
|
||
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) : "—"}`,
|
||
}),
|
||
];
|
||
}
|