Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts
T
eomsangdonandClaude Sonnet 5.5 9e0746058e fix(751-1): 원화 꼴 흠 — M01 · M02 · 서버 글 — 공용 꼴 갈음 · 율 「4.0」 · 중간 값 · 마법사 콤마 · 서고 금액
- 파이썬 짝을 common_util/common_util_money.py 로 옮김(TS 와 같은 이름 · 서버 어디서나 불러옴)
- 시험 계산: 율 글자 「4.0 %」 → 숫자 4(서버 _pct) · 중간 값 경비 · 공구손료 등 비목 이름은 1,000 미만도 돈 꼴(870.2455 → 870)
- 기초 편집 단가 = 공용 단가 꼴 · 금액 = 소수 1 · 서고 기준 금액 · 구조물 조합 일위대가 금액 = 공용 정수 꼴(124,419.85 → 124,420)
- 마법사(확인 · 고르기 · 별칭 장부 · 엑셀 · 우리 금액): 콤마 없음 · 소수 셋 기본 · 부동소수 꼬리 걷음 · 품 비교 꼬리표 지수 꼴(1.235e+05) 걷음
- 다시 잇기 · 붙여넣기 대조 · 찾아 넣기 금액 열 끝 0 없음(22.9 원이 23 으로 숨지 않음) · 흐름 로직 텍스트 수식 비목 소계 정수
- 낡은 시험 고침: test_m02_hopyo_screen 품 비교 보통인부 0.0364 → 0.03636(수량 꼴 유효숫자 넷 규칙으로 파이썬 · TS 통일)

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

441 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, createSelectField, 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";
import { createBadge } from "@ui/ui_template_badge";
import { createText } from "@ui/ui_template_text";
import { createIconButton } from "@ui/ui_template_button";
import { formatLineMoney, formatNumber, formatPrice } from "../common_util/common_util_money";
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 = ["인력", "재료", "기계", "로직"];
/** 키 앞 테이블ID → 그룹(`resources/master_data/scripts/master_keys.py` TABLES). */
const groupOf = (key: string): string =>
key.startsWith("LB")
? "인력"
: key.startsWith("M")
? "재료"
: key.startsWith("EQ")
? "기계"
: key.startsWith("G")
? "로직"
: "";
const COSTS = ["노무비", "재료비", "경비"];
const COST_OF: Record<string, string> = { 인력: "노무비", 재료: "재료비", 기계: "경비" };
export { formatNumber };
function textBox(value: unknown, onInput: (v: string) => void, className = ""): HTMLInputElement {
const input = el("input", {
className: `ui-input ui-input--sm 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,
): HTMLElement {
const all = value && !options.includes(value) ? [value, ...options] : options;
return createSelectField({
options: all.map((o) => ({ value: o, text: o })),
value: value ?? options[0],
compact: true,
onChange: onPick,
}).root;
}
function dropButton(onClick: () => void): HTMLButtonElement {
const button = createIconButton({ glyph: "×", title: tx("DeleteRow"), size: 20, onClick });
button.classList.add("m01-logic__drop");
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: `ui-table ui-table--data 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: "ui-banner ui-banner--danger 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: "ui-input ui-input--sm 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"),
el("span", { className: "ui-text ui-text--muted", text: row.키 || tx("New_Key") }),
),
field(
tx("Head_Number"),
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: formatPrice(line.단가) });
if (item.종류 === "로직" || item.요소.includes("{")) {
return el("span", { className: "ui-text ui-text--muted", text: tx("Ho_PriceLater") });
}
const brief = ctx.prices[item.요소];
if (brief === undefined) return el("span", { className: "ui-text ui-text--muted", text: "" });
if (brief === null) return createText(tx("Ho_Missing"), "error");
return el("span", { className: "m01-logic__money", text: formatPrice(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 = groupOf(found.ref);
item.요소 = group === "로직" ? `로직(${found.ref})` : 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 = createBadge(qtyKind(item.수량), "accent");
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: "ui-text ui-text--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: formatLineMoney(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();
},
);
}