Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts
eomsangdonandClaude Opus 5.5 2e7328d3a5 feat(794-1): M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 칠 때마다 검사 · 기존 [저장] 길
- 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전
- 식 글 = 이름 꼴(M02 단가산출 시트와 같은 번역 · 새 서버 M01_MasterData_Sheet.py · POST /api/m01/logic/sheet · /logic/sheet/check · 저장 때와 같은 엔진 검사 · 쓰기 없음)
- 식 고침 = 시스템 관리자만 · 되는 식만 줄 사본에 · 캐시 → 기존 [저장](개정 확인 · 서버 검사 422) · [원래로]
- 식 칸 몸 = 공용 A00_Common/sheet_formula_cell.ts — M02 단가산출 시트도 같은 것을 씀(동작 그대로)
- 자체 로직(GX) 편집기 식 날 입력칸 → 읽기만(시트 식 칸으로 갈음)
- 시험: test_794_1_m01_sheet 새로 · test_790_1 식 칸 문구 자리를 공용 파일로 옮김(식 칸 몸이 공용으로 감) · test_717_2b · test_722 LogicLab.ts 줄 상한 627 → 637(시트 이음 10 줄 · 셈 · 고침은 시트 파일로 뺌 · 700 아래)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:45:48 +09:00

446 lines
15 KiB
TypeScript
Raw Permalink 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 { createCalcFormula } from "@ui/ui_template_calc_row";
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] });
}
/** 식 칸 — 읽기만(고침은 「시트」 보기 식 칸 · 칠 때마다 검사 · 794-1) */
const sheetOnly = (formula: string): HTMLElement => {
const box = createCalcFormula(formula || "—");
box.dataset.sheetOnly = "";
return box;
};
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 = sheetOnly(item.수량); // 794-1 식 = 「시트」 보기 식 칸(날 입력칸 갈음)
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))),
sheetOnly(item.식),
];
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();
},
);
}