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
@@ -2,76 +2,29 @@
* M01_MasterData_UI_LogicLab_Detail.ts
* 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
* ③ 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계) ④ 텍스트 수식(자리 — `_LogicLab_Formula.ts`)
* 호표 줄을 누르면 그 줄이 쓰는 자료 모달(`_LogicLab_Modal.ts`)
* ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`)
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
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 { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
import type { LogicRow } from "./M01_MasterData_UI_Logic_Api";
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
import { buildEst } from "./M01_MasterData_UI_LogicLab_Est";
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, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
import { buildHo, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho";
import { trial } from "./M01_MasterData_UI_LogicLab_Cover";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_LogicLab_Style.css";
import "./M01_MasterData_UI_LogicLab_Pill.css";
import { createBadge } from "@ui/ui_template_badge";
import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View";
/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
function toFragments(pieces: TextPiece[], ctx: DetailContext): 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 };
});
}
export { trial };
export interface DetailContext {
row: LogicRow;
export interface DetailContext extends HoContext {
file: string;
reasons: string[];
prices: Record<string, ElementBrief | null>;
/** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */
lines: CalcLine[] | null;
/** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */
text: TextAnswer | null;
/** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */
lastText?: TextAnswer;
values: Record<string, string>;
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
ownHost: HTMLElement;
onChange: () => void;
@@ -82,58 +35,12 @@ export interface DetailContext {
flowHost?: HTMLElement;
/** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */
admin?: boolean;
/** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */
onInput?: (name: string, value: string) => void;
/** 722-2 시험 고침 뒤 — 시험 계산 칸을 고친 줄로 다시 그림(있으면 그 자리 고침 켜짐 · 셈은 여기서 누름) */
onTrial?: () => void;
/** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
cover?: { ho: HTMLElement; est: HTMLElement };
}
type Group = "labor" | "material" | "cost" | "other";
const GROUPS: Group[] = ["labor", "material", "cost", "other"];
const OF_KIND: Record<string, Group> = { 인력: "labor", 재료: "material", 기계: "cost" };
const OF_COST: Record<string, Group> = { 노무비: "labor", 재료비: "material", 경비: "cost" };
/** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */
const groupOfHo = (item: HoLine): Group =>
OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other";
interface Entry {
/** 호표 줄 차례(덧줄은 호표 수 + 덧줄 차례) — 서버 조각을 찾는 열쇠 */
idx: number;
group: Group;
name: string;
spec: string;
unit: string;
qty: string;
/** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */
qtyValue: string;
qtyTag: string;
qtyFrag: Fragment[];
/** 한 줄이 비목 몫으로 갈렸을 때 이 묶음의 몫 이름(예 「노무비」) */
share?: string;
price: string;
amount: number | null;
/** 덧줄이면 true */
extra: boolean;
open: (self: Entry) => void;
/** 서버 조각으로 그린 단가 자리 · 수량 풀이 — 줄 모달이 씀(붙기 전엔 없음) */
unitFrag?: Fragment[];
piecesFrag?: Fragment[];
/** 서버 요소조각의 첫 요소 — 자리 표시 든 줄이 입력으로 풀린 키 · 글 */
unitRef?: string;
unitText?: string;
/** 자재 단위가 줄 단위와 성격이 다름 — 「줄 m · 자재 Ton」 */
warn?: string;
}
/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */
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 groupName = (group: Group): string => tl(`G_${group}` as LabTextKey);
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
el("section", {
className: "m01-logic__section m01lab__box",
@@ -238,220 +145,6 @@ function infoBox(ctx: DetailContext): HTMLElement[] {
return [fold, ...reasons];
}
/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */
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",
amount: v,
...(parts.length > 1
? {
share: k,
// 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게
...(line && line.금액 && line.단가 != null
? { price: formatPrice((line.단가 * v) / line.금액) }
: {}),
}
: {}),
}));
};
function entries(ctx: DetailContext): Entry[] {
const middles: NamedFormula[] = ctx.row.중간 ?? [];
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 entry: Entry = {
idx: i,
group: groupOfHo(item),
name: item.이름 ?? (cond ? condLabel(cond) : item.요소),
spec: item.규격 ?? brief?.규격 ?? "",
unit: item.단위 ?? "",
qty: item.수량,
qtyValue: qtyOf(line),
qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량),
qtyFrag: [{ text: item.수량 }],
price: price === undefined || price === null ? "" : formatPrice(price),
amount: line ? line.금액 : null,
warn: line?.단위경고,
extra: false,
open: (self) =>
openMaterialModal({
unitFrag: self.unitFrag,
unitRef: line?.품목?.키 ?? self.unitRef,
unitText: self.unitText,
qtyFrag: self.piecesFrag,
title: item.이름 ?? (cond ? condLabel(cond) : item.요소),
expr: item.수량,
...(cond ? { cond, unit: item.단위 } : { element: item.요소 }),
prices: ctx.prices,
middles,
values: ctx.values,
// 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜)
kind: item.종류,
usedSlot: line?.품목?.값칸,
...(item.종류 === "재료"
? { onPick: (it) => pickMaterial(ctx.calcHost, ctx.values, i, it) }
: {}),
}),
};
return spread(entry, line);
});
(ctx.row.덧줄 ?? []).forEach((extra, i) => {
const line = ctx.lines?.[ho.length + i];
const entry: Entry = {
idx: ho.length + i,
group: OF_COST[extra.비목 ?? ""] ?? "other",
name: extra.이름,
spec: "",
unit: line ? pctView(line).unit : "",
qty: extra.식,
qtyValue: qtyOf(line),
qtyTag: tl("Extra"),
qtyFrag: [{ text: extra.식 }],
price: "",
amount: line ? line.금액 : null,
extra: true,
open: (self) =>
openMaterialModal({
unitFrag: self.unitFrag,
qtyFrag: self.piecesFrag,
title: extra.이름,
expr: extra.식,
prices: ctx.prices,
middles,
values: ctx.values,
}),
};
out.push(...spread(entry, line));
});
attachPieces(out, ctx);
return out;
}
/** 서버 `/text` 줄의 조각을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 묶음마다 호표 차례대로 줄을 주므로
* 이름을 차례로 따라가며 줄 차례에 맞춤 · 금액 0 로직 부름 줄처럼 묶음이 달라도 찾음 ·
* 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */
function attachPieces(all: Entry[], ctx: DetailContext): 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 ho = ctx.row.호표 ?? [];
const names = [
...ho.map((item) => item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소)),
...(ctx.row.덧줄 ?? []).map((extra) => extra.이름),
];
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);
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?.글;
}
}
}
const cellOf = (text: string, className = ""): HTMLElement => el("td", { className, text });
function lineRow(e: Entry): HTMLElement {
const tr = el("tr", {
className: `m01lab__line${e.extra ? " m01lab__line--extra" : ""}`,
attrs: { tabindex: "0", "data-line": e.name },
children: [
el("td", {
children: [
el("div", { text: e.name }),
...(e.share ? [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")] : []),
],
}),
cellOf(e.qtyValue, "m01-logic__money"),
cellOf(e.unit),
el("td", {
className: "m01lab__qty-cell",
children: [createBadge(e.qtyTag, "accent"), fragmentRow(e.qtyFrag)],
}),
cellOf(e.price, "m01-logic__money"),
cellOf(e.amount === null ? "" : formatMoney(e.amount), "m01-logic__money"),
],
});
tr.addEventListener("click", () => e.open(e));
tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e));
return tr;
}
function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLElement[] {
const sub = mine.reduce((s, e) => s + (e.amount ?? 0), 0);
const empty = el("tr", {
children: [
el("td", {
attrs: { colspan: "6" },
className: "ui-text ui-text--muted",
text: tl("Empty_Group"),
}),
],
});
const subtotal = el("tr", {
className: "m01lab__subtotal",
attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" },
children: [
el("td", { attrs: { colspan: "5" }, text: `${groupName(group)} ${tl("Subtotal")}` }),
cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"),
],
});
const headers = [
tl("Col_Name"),
tl("Col_Qty"),
tl("Col_Unit"),
tl("Col_Logic"),
tl("Col_Price"),
tl("Col_Amount"),
];
return [
el("h4", { className: "m01lab__group", text: groupName(group) }),
el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "ui-table ui-table--data m01-logic__grid m01lab__ho",
children: [
el("thead", {
children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })],
}),
el("tbody", { children: [...(mine.length ? mine.map(lineRow) : [empty]), subtotal] }),
],
}),
],
}),
];
}
/** 자체 로직(키 GX…) — 정본은 아님 */
export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX");
@@ -477,6 +170,23 @@ function ownEditor(ctx: DetailContext): HTMLElement {
});
}
/** M01 그 자리 고침 고리 — 시험 값(줄 객체 열쇠) · 고치면 시험 계산을 다시 그리고 셈 */
function trialEdit(ctx: DetailContext): HoEdit | undefined {
const redraw = ctx.onTrial;
if (!redraw) return undefined;
const covers = trial.of(ctx.row);
return {
covers,
scope: "m01",
set: (idx, cover) => {
if (cover) covers[idx] = cover;
else delete covers[idx];
redraw();
ctx.calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
},
};
}
function hoBox(ctx: DetailContext): HTMLElement {
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
if (!money) {
@@ -488,26 +198,17 @@ function hoBox(ctx: DetailContext): HTMLElement {
"ho",
);
}
const all = entries(ctx);
const calculated = ctx.lines !== null;
const tables = GROUPS.flatMap((group) => {
const mine = all.filter((e) => e.group === group);
return group === "other" && !mine.length ? [] : groupTable(group, mine, calculated); // 그 밖은 있을 때만
});
const total = all.reduce((s, e) => s + (e.amount ?? 0), 0);
const totalLine = el("p", {
className: "m01lab__total",
attrs: { "data-sum": calculated ? String(total) : "" },
text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`,
});
// 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임)
const body = buildHo({ ...ctx, edit: trialEdit(ctx) });
const totalLine = body.pop()!; // 계 줄 — 자체 로직은 접힌 표 밖에 둠
// 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 박스는 접어 둠(펼치면 소계가 보임)
const fold = el("details", {
className: "m01lab__fold",
attrs: ctx.ownHost.dataset.fold ? { open: "" } : {},
children: [el("summary", { text: tl("Ho_Fold") }), ...tables],
children: [el("summary", { text: tl("Ho_Fold") }), ...body],
});
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine];
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...body, totalLine];
const calculated = ctx.lines !== null;
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho");
}