Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts
T
eomsangdonandClaude Opus 5 43d72588f6 fix(M01): 시험 계산 수 고르기 글로 보냄 · 저장이 파일 글 모양을 지킴
- 화면 [계산] — 고르기 값은 원래 값(수면 수) 그대로 보냄 · 고르기 칸 고칠 때 모두 수면 수로 저장
- 엔진 입력 — 글로 온 수를 고르기 원래 값·Decimal 로 받음
- 저장 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소 한 줄 · 들여쓰기 · 줄바꿈) · 고친 요소만 새로 씀
- 시험 — 13-4-1 글 입력 계산 · 저장 차이가 고친 줄만 · 3-3-1 시험에 지역·보정작업 입력

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
2026-09-19 19:41:59 +09:00

426 lines
14 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_Logic_Edit.ts
* 로직 한 줄 고치기 — 실무 일위대가표(호표) 모양
* 머리(이름·결과단위·출처·소유) · 설계에서 받을 값 · 호표 줄 · 중간 값 · 결과 식 · 덧줄 · 끝수
*
* 칸을 고치면 `row` 를 그 자리에서 바꾸고 `onChange` 만 부름(다시 그리지 않음).
* 줄 더하기·지우기 · 요소 고르기처럼 모양이 바뀔 때만 `rerender`.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type {
CalcLine,
ElementBrief,
HoLine,
LogicInput,
LogicRow,
NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export type PickMode = "element" | "table";
export type PickDone = (item: ElementBrief, column?: string) => void;
export interface EditContext {
row: LogicRow;
file: string;
isNew: boolean;
files: string[];
reasons: string[];
prices: Record<string, ElementBrief | null>;
/** 마지막 시험 계산의 줄 — 호표 차례와 같음(덧줄은 뒤에 붙음) */
lines: CalcLine[] | null;
onChange: () => void;
onFile: (file: string) => void;
onPick: (mode: PickMode, group: string, done: PickDone) => void;
}
const KINDS = ["인력", "재료", "기계", "로직"];
const COSTS = ["노무비", "재료비", "경비"];
const COST_OF: Record<string, string> = { 인력: "노무비", 재료: "재료비", 기계: "경비" };
export function formatNumber(value: unknown): string {
if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
if (value === null || value === undefined) return "";
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
function textBox(value: unknown, onInput: (v: string) => void, className = ""): HTMLInputElement {
const input = el("input", {
className: `m01-logic__input ${className}`,
attrs: { type: "text" },
});
input.value = value === null || value === undefined ? "" : String(value);
input.addEventListener("input", () => onInput(input.value));
return input;
}
function choice(
options: string[],
value: string | undefined,
onPick: (v: string) => void,
): HTMLSelectElement {
const select = el("select", { className: "m01-logic__input" });
for (const option of value && !options.includes(value) ? [value, ...options] : options) {
select.append(el("option", { text: option, attrs: { value: option } }));
}
select.value = value ?? options[0];
select.addEventListener("change", () => onPick(select.value));
return select;
}
function dropButton(onClick: () => void): HTMLButtonElement {
const button = el("button", {
className: "m01-logic__drop",
text: "×",
attrs: { type: "button", title: tx("DeleteRow") },
});
button.addEventListener("click", onClick);
return button;
}
function section(title: string, body: HTMLElement, onAdd?: () => void): HTMLElement {
const head = el("div", {
className: "m01-logic__section-head",
children: [el("h3", { text: title })],
});
if (onAdd) head.append(createButton({ label: tx("AddRow"), variant: "ghost", onClick: onAdd }));
return el("section", { className: "m01-logic__section", children: [head, body] });
}
function grid(headers: string[], rows: HTMLElement[][], className: string): HTMLElement {
const table = el("table", { className: `m01-logic__grid ${className}` });
table.append(
el("thead", { children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })] }),
);
const body = el("tbody");
for (const cells of rows)
body.append(el("tr", { children: cells.map((c) => el("td", { children: [c] })) }));
table.append(body);
return el("div", { className: "m01-logic__scroll", children: [table] });
}
export function qtyKind(qty: string): string {
const text = qty.trim();
if (/^-?\d+(\.\d+)?$/.test(text)) return tx("Ho_QtyNumber");
if (text.startsWith("찾기(")) return tx("Ho_QtyTable");
return tx("Ho_QtyFormula");
}
export function buildEditor(host: HTMLElement, ctx: EditContext): void {
const rerender = (): void => {
ctx.onChange();
buildEditor(host, ctx);
};
const row = ctx.row;
const set = (patch: () => void): void => {
patch();
ctx.onChange();
};
host.innerHTML = "";
host.append(head(ctx, set, rerender));
if (ctx.reasons.length) {
host.append(
el("div", {
className: "m01-logic__reasons",
children: [
el("strong", { text: tx("Head_Blocked") }),
...ctx.reasons.map((r) => el("div", { text: r })),
],
}),
);
}
host.append(inputsSection(row, set, rerender));
const money = !("결과" in row) && (row.결과단위 ?? "").startsWith("원");
if (money) host.append(hoSection(ctx, set, rerender));
host.append(middleSection(row, set, rerender));
if (!money) {
const formula = textBox(row.결과?. ?? "", (v) =>
set(() => {
row.결과 = { : v };
}),
);
host.append(section(tx("Result_Title"), formula));
} else {
host.append(extraSection(row, set, rerender));
const rounding = textBox(row.끝수 ?? "", (v) =>
set(() => {
row.끝수 = v.trim() ? v : null;
}),
);
host.append(section(tx("Rounding"), rounding));
}
}
function field(label: string, control: HTMLElement, wide = false): HTMLElement {
return el("label", {
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
children: [el("span", { text: label }), control],
});
}
function head(ctx: EditContext, set: (p: () => void) => void, rerender: () => void): HTMLElement {
const row = ctx.row;
const file = ctx.isNew
? choice(ctx.files, ctx.file, (v) => ctx.onFile(v))
: el("span", { className: "m01-logic__file", text: ctx.file });
const unit = textBox(row.결과단위, (v) => set(() => (row.결과단위 = v)));
unit.addEventListener("change", rerender); // 돈 로직 ↔ 돈 아닌 로직 칸이 바뀜
const note = el("textarea", { className: "m01-logic__input m01-logic__note" });
note.value = row.비고 ?? "";
note.addEventListener("input", () =>
set(() => {
if (note.value.trim()) row.비고 = note.value;
else delete row.비고;
}),
);
return el("div", {
className: "m01-logic__head",
children: [
field(tx("Head_File"), file, true),
field(
tx("Head_Key"),
textBox(row.열쇠, (v) => set(() => (row.열쇠 = v))),
),
field(
tx("Head_Name"),
textBox(row.이름, (v) => set(() => (row.이름 = v))),
),
field(tx("Head_Unit"), unit),
field(
tx("Head_Source"),
textBox(row.출처, (v) => set(() => (row.출처 = v))),
),
field(
tx("Head_Owner"),
choice(["공용", "개인"], row.소유, (v) => set(() => (row.소유 = v))),
),
field(tx("Head_Note"), note, true),
],
});
}
function inputsSection(
row: LogicRow,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = row.입력 ?? [];
const rows = list.map((spec: LogicInput, i) => {
const low = textBox(spec.범위?.[0] ?? "", () => range(), "m01-logic__num");
const high = textBox(spec.범위?.[1] ?? "", () => range(), "m01-logic__num");
const range = (): void =>
set(() => {
if (low.value.trim() === "" && high.value.trim() === "") delete spec.범위;
else spec.범위 = [Number(low.value), Number(high.value)];
});
return [
textBox(spec.이름, (v) => set(() => (spec.이름 = v))),
textBox(spec.단위 ?? "", (v) =>
set(() => {
if (v.trim()) spec.단위 = v;
else delete spec.단위;
}),
),
textBox((spec.고르기 ?? []).join(", "), (v) =>
set(() => {
const items = v
.split(",")
.map((x) => x.trim())
.filter(Boolean);
// 모두 수면 수로 — 글 "35" 로 적으면 표의 35 와 안 맞음
const numbers = items.every((x) => !Number.isNaN(Number(x)));
if (items.length) spec.고르기 = numbers ? items.map(Number) : items;
else delete spec.고르기;
}),
),
el("div", {
className: "m01-logic__range",
children: [low, el("span", { text: "" }), high],
}),
dropButton(() => {
list.splice(i, 1);
rerender();
}),
];
});
const headers = [
tx("Head_Name"),
tx("Inputs_Unit"),
tx("Inputs_Choices"),
tx("Inputs_Range"),
"",
];
return section(tx("Inputs_Title"), grid(headers, rows, "m01-logic__grid--inputs"), () => {
(row.입력 ??= []).push({ 이름: "" });
rerender();
});
}
function priceCell(ctx: EditContext, item: HoLine, i: number): HTMLElement {
const line = ctx.lines?.[i];
if (line) return el("span", { className: "m01-logic__money", text: formatNumber(line.단가) });
if (item.종류 === "로직" || item.요소.includes("{")) {
return el("span", { className: "m01-logic__muted", text: tx("Ho_PriceLater") });
}
const brief = ctx.prices[item.요소];
if (brief === undefined) return el("span", { className: "m01-logic__muted", text: "" });
if (brief === null) return el("span", { className: "m01-logic__bad", text: tx("Ho_Missing") });
return el("span", { className: "m01-logic__money", text: formatNumber(brief.) });
}
function hoSection(
ctx: EditContext,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = ctx.row.호표 ?? [];
const rows = list.map((item: HoLine, i) => {
const element = textBox(item.요소, (v) => set(() => (item.요소 = v)), "m01-logic__ref");
const pick = createButton({
label: tx("Ho_Pick"),
variant: "ghost",
onClick: () =>
ctx.onPick("element", item.종류, (found) => {
const [group, book] = found.ref.split(":");
const key = found.ref.slice(group.length + book.length + 2);
item.요소 = group === "로직" ? `로직(${book}:${key})` : found.ref;
if (KINDS.includes(group)) item.종류 = group;
item.이름 = found.이름 ?? item.이름;
if (found.규격) item.규격 = found.규격;
else delete item.규격;
if (!item.비목 && COST_OF[item.종류]) item.비목 = COST_OF[item.종류];
if (group !== "로직") ctx.prices[found.ref] = found;
rerender();
}),
});
const brief = ctx.prices[item.요소];
const spec = item.규격 ?? brief?.규격 ?? "";
const qty = textBox(item.수량, (v) => {
set(() => (item.수량 = v));
tag.textContent = qtyKind(v);
});
const tag = el("span", { className: "m01-logic__tag", text: qtyKind(item.수량) });
const table = createButton({
label: tx("Ho_PickTable"),
variant: "ghost",
onClick: () =>
ctx.onPick("table", "소요량", (found, column) => {
const ref = found.ref;
item.수량 = `찾기(${ref})${column ? `.${column}` : ""}`;
rerender();
}),
});
const cost =
item.종류 === "로직" && !item.비목
? choice(["", ...COSTS], "", (v) => set(() => (item.비목 = v || undefined)))
: choice(COSTS, item.비목, (v) => set(() => (item.비목 = v)));
return [
choice(KINDS, item.종류, (v) => {
item.종류 = v;
rerender();
}),
el("div", { className: "m01-logic__pair", children: [element, pick] }),
el("div", {
className: "m01-logic__stack",
children: [
textBox(item.이름 ?? "", (v) => set(() => (item.이름 = v))),
el("span", { className: "m01-logic__muted", text: spec }),
],
}),
textBox(item.단위 ?? "", (v) => set(() => (item.단위 = v)), "m01-logic__unit"),
el("div", { className: "m01-logic__pair", children: [tag, qty, table] }),
priceCell(ctx, item, i),
el("span", { className: "m01-logic__money", text: formatNumber(ctx.lines?.[i]?.금액 ?? "") }),
cost,
dropButton(() => {
list.splice(i, 1);
rerender();
}),
];
});
const headers = [
tx("Ho_Kind"),
tx("Ho_Element"),
tx("Ho_Name"),
tx("Ho_Unit"),
tx("Ho_Qty"),
tx("Ho_Price"),
tx("Ho_Amount"),
tx("Ho_Cost"),
"",
];
return section(tx("Ho_Title"), grid(headers, rows, "m01-logic__grid--ho"), () => {
(ctx.row.호표 ??= []).push({
종류: "인력",
요소: "",
이름: "",
단위: "인",
수량: "",
비목: "노무비",
});
rerender();
});
}
function formulaRows(
list: NamedFormula[],
set: (p: () => void) => void,
rerender: () => void,
withCost: boolean,
): HTMLElement[][] {
return list.map((item, i) => {
const cells: HTMLElement[] = [
textBox(item.이름, (v) => set(() => (item.이름 = v))),
textBox(item., (v) => set(() => (item. = v)), "m01-logic__formula"),
];
if (withCost) {
cells.push(
choice(COSTS, item.비목, (v) => set(() => (item.비목 = v))),
textBox(item.출처 ?? "", (v) => set(() => (item.출처 = v))),
);
}
cells.push(
dropButton(() => {
list.splice(i, 1);
rerender();
}),
);
return cells;
});
}
function middleSection(
row: LogicRow,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = row.중간 ?? [];
const table = grid(
[tx("Head_Name"), tx("Formula"), ""],
formulaRows(list, set, rerender, false),
"",
);
return section(tx("Middle_Title"), table, () => {
(row.중간 ??= []).push({ 이름: "", : "" });
rerender();
});
}
function extraSection(
row: LogicRow,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = row.덧줄 ?? [];
const headers = [tx("Head_Name"), tx("Formula"), tx("Ho_Cost"), tx("Head_Source"), ""];
return section(
tx("Extra_Title"),
grid(headers, formulaRows(list, set, rerender, true), ""),
() => {
(row.덧줄 ??= []).push({ 이름: "", : "", 비목: "재료비", 출처: "" });
rerender();
},
);
}