- 화면: 왼쪽 로직 목록(원문·장·이름 찾기·막힘) / 가운데 머리·받을 값·호표·중간 값·덧줄·끝수 / 오른쪽 시험 계산 - 고친 것은 캐시에 쌓고 [저장] 한 번에 · [고친 것 버리기] · 줄 더하기·지우기 · 로직 새로 만들기·지우기 - 진입은 mountM01Logic(host) — 공용 진입 파일(sub_laptop_3 몫)이 한 줄로 붙임 - 서버: 로직 하나에 단가 자동(prices) · 요소 찾기(GET /elements) · 저장 전 시험 계산(calc 에 row·file) - 계약 문서 갱신 · 시험 1개 더함(9개 통과) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* 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);
|
||
if (items.length) spec.고르기 = 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();
|
||
},
|
||
);
|
||
}
|