Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
469 lines
17 KiB
TypeScript
469 lines
17 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_LogicLab_Detail.ts
|
|
* 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로
|
|
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계)
|
|
* ③ 텍스트 수식(자리 — `_LogicLab_Formula.ts`) ④ 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
|
|
* 호표 줄을 누르면 그 줄이 쓰는 자료 모달(`_LogicLab_Modal.ts`)
|
|
* ========================================================================== */
|
|
|
|
import { el } from "@ui/ui_template_elements";
|
|
import type {
|
|
CalcLine,
|
|
ElementBrief,
|
|
HoLine,
|
|
LogicRow,
|
|
NamedFormula,
|
|
TextAnswer,
|
|
TextLine,
|
|
TextPiece,
|
|
} from "./M01_MasterData_UI_Logic_Api";
|
|
import { buildEditor, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
|
|
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
|
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
|
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
|
import { buildFormula } 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, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
|
|
import "./M01_MasterData_UI_LogicLab_Style.css";
|
|
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
|
|
|
/** 서버 조각(`/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.행, 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 interface DetailContext {
|
|
row: LogicRow;
|
|
file: string;
|
|
reasons: string[];
|
|
prices: Record<string, ElementBrief | null>;
|
|
/** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */
|
|
lines: CalcLine[] | null;
|
|
/** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */
|
|
text: TextAnswer | null;
|
|
values: Record<string, string>;
|
|
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
|
|
ownHost: HTMLElement;
|
|
onChange: () => void;
|
|
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
|
|
calcHost: 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;
|
|
qtyTag: string;
|
|
qtyFrag: Fragment[];
|
|
/** 한 줄이 비목 몫으로 갈렸을 때 이 묶음의 몫 이름(예 「노무비」) */
|
|
share?: string;
|
|
price: string;
|
|
amount: number | null;
|
|
/** 덧줄이면 true */
|
|
extra: boolean;
|
|
open: (self: Entry) => void;
|
|
/** 서버 조각으로 그린 단가 자리 · 수량 풀이 — 줄 모달이 씀(붙기 전엔 없음) */
|
|
unitFrag?: Fragment[];
|
|
piecesFrag?: Fragment[];
|
|
}
|
|
|
|
/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */
|
|
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",
|
|
children: [
|
|
el("div", {
|
|
className: "m01-logic__section-head",
|
|
children: [
|
|
el("h3", { text: title }),
|
|
...(hint ? [el("span", { className: "m01-logic__muted", text: hint })] : []),
|
|
],
|
|
}),
|
|
...body,
|
|
],
|
|
});
|
|
|
|
function infoBox(ctx: DetailContext): HTMLElement {
|
|
const row = ctx.row;
|
|
const own = isOwnLogic(row);
|
|
const field = (label: string, key: "이름" | "원문번호" | "결과단위" | "출처"): HTMLElement => {
|
|
if (!own) return cell(label, row[key] ?? "");
|
|
const box = el("input", { className: "m01-logic__input", attrs: { "data-own": key } });
|
|
box.value = row[key] ?? "";
|
|
box.addEventListener("input", () => {
|
|
row[key] = box.value;
|
|
ctx.onChange();
|
|
});
|
|
return el("label", {
|
|
className: "m01lab__cell",
|
|
children: [el("span", { className: "m01-logic__muted", text: label }), box],
|
|
});
|
|
};
|
|
const cell = (label: string, value: string): HTMLElement =>
|
|
el("div", {
|
|
className: "m01lab__cell",
|
|
children: [
|
|
el("span", { className: "m01-logic__muted", text: label }),
|
|
el("span", { text: value || "—" }),
|
|
],
|
|
});
|
|
const note = el("textarea", { className: "m01-logic__input m01-logic__note" });
|
|
note.value = row.비고 ?? "";
|
|
note.addEventListener("input", () => {
|
|
if (note.value.trim()) row.비고 = note.value;
|
|
else delete row.비고;
|
|
ctx.onChange();
|
|
});
|
|
const reasons = ctx.reasons.length
|
|
? [
|
|
el("div", {
|
|
className: "m01-logic__reasons",
|
|
children: [
|
|
el("strong", { text: tx("Head_Blocked") }),
|
|
...ctx.reasons.map((r) => el("div", { text: r })),
|
|
],
|
|
}),
|
|
]
|
|
: [];
|
|
return section(tl("Info_Title"), [
|
|
el("p", {
|
|
className: "m01-logic__muted",
|
|
text: tl(own ? "Info_Own" : "Info_ReadOnly"),
|
|
}),
|
|
...reasons,
|
|
el("div", {
|
|
className: "m01lab__info",
|
|
children: [
|
|
cell(tx("Head_File"), ctx.file),
|
|
cell(tx("Head_Key"), row.키),
|
|
field(tx("Head_Number"), "원문번호"),
|
|
field(tx("Head_Name"), "이름"),
|
|
field(tx("Head_Unit"), "결과단위"),
|
|
field(tx("Head_Source"), "출처"),
|
|
cell(tx("Head_Owner"), row.소유 ?? ""),
|
|
el("label", {
|
|
className: "m01lab__cell m01lab__cell--wide",
|
|
children: [el("span", { className: "m01-logic__muted", text: tx("Head_Note") }), note],
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
}
|
|
|
|
/** 계산된 줄은 서버가 준 비목별 몫으로 묶음에 나눠 넣음 — 로직을 부르는 줄도 노무비·재료비·경비로 갈림 */
|
|
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.수량,
|
|
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,
|
|
extra: false,
|
|
open: (self) =>
|
|
openMaterialModal({
|
|
unitFrag: self.unitFrag,
|
|
qtyFrag: self.piecesFrag,
|
|
title: item.이름 ?? (cond ? condLabel(cond) : item.요소),
|
|
expr: item.수량,
|
|
...(cond ? { cond, unit: item.단위 } : { element: item.요소 }),
|
|
prices: ctx.prices,
|
|
middles,
|
|
values: ctx.values,
|
|
}),
|
|
};
|
|
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: "",
|
|
qty: extra.식,
|
|
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 {
|
|
if (!ctx.text?.ok) 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 ctx.text.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 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
|
|
? [el("div", { className: "m01-logic__tag", text: `${e.share} ${tl("Share")}` })]
|
|
: []),
|
|
...(e.spec ? [el("div", { className: "m01-logic__muted", text: e.spec })] : []),
|
|
],
|
|
}),
|
|
cellOf(e.unit),
|
|
el("td", {
|
|
className: "m01lab__qty-cell",
|
|
children: [
|
|
el("span", { className: "m01-logic__tag", text: e.qtyTag }),
|
|
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: "5" },
|
|
className: "m01-logic__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: "4" }, text: `${groupName(group)} ${tl("Subtotal")}` }),
|
|
cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"),
|
|
],
|
|
});
|
|
const headers = [
|
|
tl("Col_Name"),
|
|
tl("Col_Unit"),
|
|
tl("Col_Qty"),
|
|
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: "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");
|
|
|
|
/** 자체 로직은 정본 화면과 같은 편집기를 호표 아래에 폄(머리 칸은 기본정보에서 고치므로 숨김) */
|
|
function ownEditor(ctx: DetailContext): HTMLElement {
|
|
if (!ctx.ownHost.childElementCount)
|
|
buildEditor(ctx.ownHost, {
|
|
row: ctx.row,
|
|
file: ctx.file,
|
|
isNew: false,
|
|
files: [],
|
|
reasons: [],
|
|
prices: ctx.prices,
|
|
lines: ctx.lines,
|
|
onChange: ctx.onChange,
|
|
onFile: () => undefined,
|
|
onPick: openPicker,
|
|
});
|
|
return el("details", {
|
|
className: "m01lab__edit",
|
|
attrs: { open: "" },
|
|
children: [el("summary", { text: tl("Edit_Title") }), ctx.ownHost],
|
|
});
|
|
}
|
|
|
|
function hoBox(ctx: DetailContext): HTMLElement {
|
|
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
|
|
if (!money) {
|
|
return section(tl("Ho_Title"), [
|
|
el("p", { className: "m01-logic__muted", text: tl("Not_Money") }),
|
|
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
|
|
]);
|
|
}
|
|
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 fold = el("details", {
|
|
className: "m01lab__fold",
|
|
attrs: ctx.ownHost.dataset.fold ? { open: "" } : {},
|
|
children: [el("summary", { text: tl("Ho_Fold") }), ...tables],
|
|
});
|
|
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
|
|
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine];
|
|
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
|
|
return section(tl("Ho_Title"), blocks, hint);
|
|
}
|
|
|
|
/** 상세 화면 넷을 `host` 에 쌓음 */
|
|
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
|
const formula = el("div", { className: "m01lab__formula" });
|
|
buildFormula(formula, { text: ctx.text });
|
|
// 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림
|
|
const active = document.activeElement;
|
|
const kept =
|
|
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
|
|
? { node: active, from: active.selectionStart, to: active.selectionEnd }
|
|
: null;
|
|
host.replaceChildren(
|
|
infoBox(ctx),
|
|
hoBox(ctx),
|
|
section(tl("Formula_Title"), [formula]),
|
|
section(tl("Calc_Title"), [ctx.calcHost]),
|
|
);
|
|
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
|
|
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
|
|
if (kept && back && (own || ctx.ownHost.contains(back))) {
|
|
back.focus();
|
|
if (kept.from !== null) back.setSelectionRange(kept.from, kept.to);
|
|
}
|
|
}
|