- 공용 화면 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>
373 lines
14 KiB
TypeScript
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;
|
|
}
|