Files
Aislo/A00_Common/formula_doc_defs.ts
T
eomsangdonandClaude Opus 5.5 54fb55487c feat(802-3): M01 단가산출 기초 수식 문서 화면 — 왼쪽 기호 식 · 오른쪽 값 식 · 빈 문서 시작 · 연결 고르기
- 공용 화면 A00_Common/formula_doc_*(M01 · 뒤에 M02 = 길 앞머리만 바꿈) — 옛 수식 나무 화면은 M01 에서 걷음(M02 는 그대로)
- 보이는 수식 그 자리 고침(공용 editEq · 전체 고르기 = 한 줄 글) · 분수 · 첨자 · 만약 = 탄 갈래만 · 반올림 자리 숨김 · 값 식 단위
- 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 정의 영역 = 기호 : 이름 — 연결(키 · 값 · 단위 없음 · 탄 갈래로 닿는 기호만)
- 새 기호 = 정의 필요 → 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식 연결 · 기호 · 첨자 · 이름 고침 · 정의 지움
- 금액 = 경쟁사 꼴 이름 : 단가 × 수량 = 금액 · 묶음 하나 = 계 · 둘 이상 = 가. 이름 소계 + 합계 · 단가산출 연결 펼침 = 그 문서 식 · 값 식
- 셈 = 브라우저 계산기 즉시 · 연결 값 = 서버 기록 캐시(common_util_formula_doc_cache.ts ↔ master_doc_record.py) · 없는 연결만 뒤에서 미리 셈
- 서버 길 GET /api/m01/logic/doc · POST preview · save(서버가 정본으로 다시 셈 · 수식 문서 파일에 씀) · links · GET table (M01_MasterData_Doc.py)
- 연결 대상 안 고른 연결(표 · 요소 · 단가산출) = 연결 끊김 표지(파이썬 · TS 짝 가드 — 서버 500 걷음)
- 시험 test_802_3_doc_screen 11(읽기 · 빈 문서 · 저장 = 서버 다시 셈 · 관리자 아니면 막힘 · 지움 견딤 · 표지 갈래 · 후보)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:23:57 +09:00

373 lines
14 KiB
TypeScript

/* =============================================================================
* formula_doc_defs.ts
* 수식 문서 기호 정의 영역(PLAN 802-3 · 요구 660-2 · 660-5 · 660-6 · 664) — 값 식 아래 「기호 : 국문 이름 — 연결」 한 줄씩
* 같은 기호 한 번 · 값 · 단위 · DB 키 없음 · 지금 탄 갈래로 닿는 기호만(나머지는 접힌 「쓰지 않는 기호」)
* 식에 새 기호를 쓰면 「정의 필요」 줄이 생김 → 눌러 연결 고름(그 줄 안에서 · 창 없음):
* 숫자 · 선택(목록) · 입력(범위) · 요소(인력 · 재료 · 기계 찾기) · 표(표 · 조건 식 · 칸) · 단가산출(다른 기초 · 넘길 입력 · 칸) · 식(식 줄)
* 기호 · 아래 · 위 첨자 · 국문 이름 · 단위 고침 · 정의 지움(쓰는 자리는 「정의 필요」로 남음)
* 고친 것은 부른 쪽 훅으로 — 여기선 문서를 안 고침
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createBadge } from "@ui/ui_template_badge";
import { el } from "@ui/ui_template_elements";
import { createFindList, type FindRow } from "@ui/ui_template_find_list";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { LINKS, splitId, type DocVar, type Link } from "../common_util/common_util_formula_doc";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import { symNode } from "./formula_doc_render";
import { ft } from "./formula_doc_text";
const PLAIN = new Set(["적용", "값", "기준"]);
const ELEM_KINDS = ["인력", "재료", "기계"];
export interface Pick {
키: string;
이름?: string;
규격?: string;
단위?: string;
조건?: string[];
칸?: string[];
입력?: string[];
}
export interface DefsHooks {
admin: boolean;
vars: Map<string, DocVar>;
names: Map<string, string>;
lines: Set<string>;
shown: string[];
unused: string[];
need: Set<string>;
open: string | null;
onOpen: (id: string | null) => void;
setLink: (id: string, link: Link) => void;
setVar: (id: string, patch: Partial<DocVar>) => void;
rename: (id: string, sign: string, low: string, up: string) => string | null;
dropVar: (id: string) => void;
makeLine: (id: string) => void;
ask: (kind: string, words: string, group: string) => Promise<{ 후보: Pick[]; 더있음: boolean }>;
table: (key: string) => Promise<DocTable | null>;
subInputs: (key: string) => string[];
sub: (id: string) => HTMLElement | null;
}
const span = (cls: string, text?: string): HTMLSpanElement =>
el("span", { className: cls, ...(text !== undefined ? { text } : {}) });
/** 연결 글 — 갈래 + 대상 이름(키 · 값 · 단위 없음) */
export function linkText(
v: DocVar | undefined,
names: Map<string, string>,
isLine: boolean,
): string {
const link = v?.연결;
const kind = link?.갈래 ?? "식";
if (kind === "식") return isLine ? "식" : ft("Need");
const ref = String(link?.키 ?? link?.표 ?? "");
const named = (r: string): string =>
r.includes("{") ? r.replace(/^[A-Z]+:/, "").replace(/[{}]/g, "") : names.get(r) || "";
const parts: string[] = [kind];
if (kind === "요소" && link?.재료) {
const m = link.재료 as Record<string, unknown>;
parts.push(String(m.검색어 ?? m.이름 ?? m.상세구분 ?? m.구분 ?? ""));
} else if (ref) parts.push(named(ref));
if (link?.칸 && !PLAIN.has(String(link.칸))) parts.push(String(link.칸));
return parts.filter(Boolean).join(" · ");
}
function field(label: string, value: string, onChange: (v: string) => void, width = "") {
const f = createInputField({ label, value, compact: true, size: 24 });
f.input.addEventListener("change", () => onChange(f.input.value));
if (width) f.root.style.width = width;
return f;
}
/** 찾아 고르기(요소 · 표 · 단가산출) */
function finder(h: DefsHooks, kind: string, onPick: (p: Pick) => void, group = { now: "인력" }) {
const filters: HTMLElement[] = [];
if (kind === "요소")
filters.push(
createSegment({
options: ELEM_KINDS.map((k) => ({ value: k, label: k })),
value: group.now,
size: 24,
onChange: (v) => {
group.now = v;
void run();
},
}).root,
);
const list = createFindList({ placeholder: ft("Find"), filters });
let timer = 0;
let seq = 0;
const run = async (): Promise<void> => {
const mine = ++seq;
const words = list.search.value.trim();
if (!words && kind !== "요소") return list.setRows([]);
const got = await h
.ask(kind === "단가산출" ? "단가산출" : kind, words, group.now)
.catch(() => null);
if (mine !== seq || !got) return;
list.hint.textContent = got.더있음 ? ft("More") : got.후보.length ? "" : ft("Nothing");
list.setRows(
got.후보.map((p): FindRow => ({
key: p.키,
name: p.이름 ?? "",
sub: p.규격 ?? "",
unit: p.단위 ?? "",
onPick: () => onPick(p),
})),
);
};
list.search.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void run(), 250);
});
return list.root;
}
function kindBody(id: string, v: DocVar | undefined, h: DefsHooks): HTMLElement[] {
const link: Link = { ...(v?.연결 ?? { 갈래: "식" }) };
const kind = link.갈래 ?? "식";
const set = (patch: Partial<Link>): void => h.setLink(id, { ...link, ...patch });
const num = (s: string): number | string => {
const n = Number(s.replace(/,/g, "").trim());
return s.trim() !== "" && Number.isFinite(n) ? n : s.trim();
};
switch (kind) {
case "숫자":
return [field(ft("Value"), String(link.값 ?? ""), (s) => set({ 값: num(s) })).root];
case "선택": {
const list = (link.목록 ?? []).map(String);
const pick = createSelectField({
label: ft("Default"),
options: list.map((x) => ({ value: x, text: x })),
value: String(link.값 ?? ""),
compact: true,
size: 24,
onChange: (x) => set({ 값: link.목록?.find((y) => String(y) === x) ?? x }),
});
const items = field(ft("List"), list.join(" · "), (s) =>
set({
목록: s
.split(/\s*[·,]\s*/)
.filter(Boolean)
.map((x) => num(x)),
}),
);
return [items.root, pick.root];
}
case "입력": {
const [low, high] = link.범위 ?? [null, null];
const bound = (i: 0 | 1) => (s: string) => {
const r: [number | null, number | null] = [...(link.범위 ?? [null, null])] as never;
const n = num(s);
r[i] = typeof n === "number" ? n : null;
set(r[0] === null && r[1] === null ? { 범위: undefined } : { 범위: r });
};
return [
field(ft("Value"), String(link.값 ?? ""), (s) => set({ 값: num(s) })).root,
field(ft("Low_Bound"), low === null ? "" : String(low), bound(0), "7em").root,
field(ft("High_Bound"), high === null ? "" : String(high), bound(1), "7em").root,
];
}
case "요소": {
const out: HTMLElement[] = [];
if (link.재료)
out.push(
span("ui-text ui-text--muted", `${ft("Material")} · ${linkText(v, h.names, false)}`),
);
out.push(
finder(h, "요소", (p) => {
h.names.set(p.키, [p.이름, p.규격].filter(Boolean).join(" "));
h.setLink(id, { 갈래: "요소", 키: p.키, ...(link.지역 ? { 지역: link.지역 } : {}) });
}),
field(ft("Column"), String(link.칸 ?? ""), (s) => set({ 칸: s.trim() || undefined }), "9em")
.root,
field(
ft("Region"),
String(link.지역 ?? ""),
(s) => set({ 지역: s.trim() || undefined }),
"9em",
).root,
);
return out;
}
case "표": {
const out: HTMLElement[] = [
finder(h, "표", (p) => {
h.names.set(p.키, p.이름 ?? "");
void h
.table(p.키)
.then(() => h.setLink(id, { 갈래: "표", 표: p.키, 조건: {}, 칸: p.칸?.[0] ?? "" }));
}),
];
const tbl = link.표 ? h.table(String(link.표)) : null;
const cols = el("div", { className: "fdoc__fields" });
out.push(cols);
void tbl?.then((t) => {
if (!t) return;
const names = [
...new Set([...Object.keys(t.값칸 ?? {}), ...t.줄.flatMap((r) => Object.keys(r))]),
].filter((k) => !(k in (t.조건 ?? {})));
cols.append(
createSelectField({
label: ft("Column"),
options: names.map((x) => ({ value: x, text: x })),
value: String(link.칸 ?? ""),
compact: true,
size: 24,
onChange: (x) => set({ 칸: x }),
}).root,
...Object.keys(t.조건 ?? {}).map((c) => {
const seen = [...new Set(t.줄.map((r) => r[c]).filter((x) => typeof x === "string"))];
const f = field(`${ft("Cond")} ${c}`, String(link.조건?.[c] ?? ""), (s) => {
const box = { ...(link.조건 ?? {}) };
if (s.trim()) box[c] = s.trim();
else delete box[c];
set({ 조건: box });
});
f.input.placeholder = seen
.slice(0, 4)
.map((x) => `'${x}'`)
.join(" · ");
return f.root;
}),
);
});
return out;
}
case "단가산출": {
const ins = link.키 ? h.subInputs(String(link.키)) : [];
return [
finder(h, "단가산출", (p) => {
h.names.set(p.키, p.이름 ?? "");
h.setLink(id, { 갈래: "단가산출", 키: p.키, 입력: {} });
}),
createSelectField({
label: ft("Column"),
options: [
{ value: "", text: ft("None") },
...["노무비", "재료비", "경비", "계"].map((x) => ({ value: x, text: x })),
],
value: String(link.칸 ?? ""),
compact: true,
size: 24,
onChange: (x) => set({ 칸: x || undefined }),
}).root,
...ins.map(
(k) =>
field(`${ft("Inputs")} ${k}`, String(link.입력?.[k] ?? ""), (s) => {
const box = { ...(link.입력 ?? {}) };
if (s.trim()) box[k] = s.trim();
else delete box[k];
set({ 입력: box });
}).root,
),
];
}
default:
return h.lines.has(id)
? [span("ui-text ui-text--muted", ft("By_Line"))]
: [
createButton({
label: ft("Make_Line"),
variant: "ghost",
size: 24,
onClick: () => h.makeLine(id),
}),
];
}
}
function editor(id: string, h: DefsHooks): HTMLElement {
const v = h.vars.get(id);
const [sign, low, up] = v ? [v.기호 ?? "", v.아래 ?? "", v.위 ?? ""] : splitId(id);
const box = el("div", { className: "fdoc__defedit" });
const names = el("div", { className: "fdoc__fields" });
const sym = { sign, low, up };
const signF = field(ft("Sign"), sign, (s) => tryRename({ ...sym, sign: s.trim() }), "6em");
const lowF = field(ft("Low"), low, (s) => tryRename({ ...sym, low: s.trim() }), "8em");
const upF = field(ft("Up"), up, (s) => tryRename({ ...sym, up: s.trim() }), "6em");
const tryRename = (now: typeof sym): void => {
const why = h.rename(id, now.sign, now.low, now.up);
if (why) signF.setError(why);
};
names.append(
signF.root,
lowF.root,
upF.root,
field(ft("Name"), v?.이름 ?? "", (s) => h.setVar(id, { 이름: s.trim() })).root,
field(ft("Unit"), v?.단위 ?? "", (s) => h.setVar(id, { 단위: s.trim() }), "7em").root,
);
const kind = v?.연결?.갈래 ?? "식";
const kinds = createSegment({
options: LINKS.map((k) => ({ value: k, label: k })),
value: kind,
size: 24,
ariaLabel: ft("Link"),
onChange: (k) => {
const base: Link = { 갈래: k };
if (k === "숫자" || k === "입력") base.값 = "";
if (k === "선택") Object.assign(base, { 목록: [], 값: "" });
h.setLink(id, base);
if (k === "식" && !h.lines.has(id)) h.makeLine(id);
},
});
const body = el("div", { className: "fdoc__fields", children: kindBody(id, v, h) });
const drop = createButton({
label: ft("Drop_Var"),
variant: "danger-ghost",
size: 24,
onClick: () => h.dropVar(id),
});
box.append(names, kinds.root, body, drop);
return box;
}
function row(id: string, h: DefsHooks): HTMLElement {
const v = h.vars.get(id);
const need = h.need.has(id);
const head = el("div", { className: "fdoc__def", attrs: { "data-def": id } });
const sym = span("fdoc__defsym");
sym.append(symNode(id));
head.append(sym, span("ui-eq__op", ":"));
if (need) head.append(createBadge(ft("Need"), "warning"));
else head.append(span("fdoc__defname", v?.이름 || ""));
const own = v?.이름 ?? "";
// 이름에 이미 든 대상 이름 · 칸은 연결 글에서 뺌(같은 말 두 번 없음)
const text = linkText(v, h.names, h.lines.has(id))
.split(" · ")
.filter((x, i) => i === 0 || !own.includes(x))
.join(" · ");
if (!need) head.append(span("ui-text ui-text--muted fdoc__deflink", `— ${text}`));
const wrap = el("div", { className: "fdoc__defrow", children: [head] });
if (h.open === id) wrap.classList.add("is-open");
head.addEventListener("click", () => h.onOpen(h.open === id ? null : id));
if (h.open === id) {
const sub = h.sub(id);
if (sub) wrap.append(sub);
if (h.admin) wrap.append(editor(id, h));
}
return wrap;
}
/** 정의 영역 — 닿는 기호 줄 + 접힌 쓰지 않는 기호 */
export function buildDefs(h: DefsHooks): HTMLElement {
const box = el("div", { className: "fdoc__defs" });
box.append(el("div", { className: "fdoc__colhead", text: ft("Defs_Head") }));
h.shown.forEach((id) => box.append(row(id, h)));
if (h.unused.length) {
const fold = el("details", {
className: "fdoc__unused",
attrs: h.open && h.unused.includes(h.open) ? { open: "" } : {},
children: [el("summary", { text: `${ft("Unused")} ${h.unused.length}` })],
});
h.unused.forEach((id) => fold.append(row(id, h)));
box.append(fold);
}
return box;
}