Files
Aislo/A00_Common/line_stack_rows.ts
T
eomsangdonandClaude Opus 5.5 f822a4c36a feat(840-2): 기계 보기 공용 — M01 기계 상세 · 내 기계를 왼쪽 제어 구역 + 오른쪽 시간당 경비 산출근거로 · 「복사해 내 기계로」 걷음 · 기계 줄 풀기 안 내놓음
- 기계 보기 부품(A00_Common/line_stack_machine_sheet) — 836-1 제어 구역 그대로(기계 표 꼴 훅 sheet · 잠금 lock) + 기본 정보 · 손료 근거 접이 카드 + 비슷한 기계에서 시작 · 값 바꾸면 브라우저 계산기로 즉시
- 서버 POST /api/m02/machine-sheet(M02_MyMachine_Sheet) — 식 고름 + 식 문서 + 처음 줄 값 + 연결 기록 · 쓰기 없음 · 파이썬 bind ↔ TS bindDoc 짝
- 덮음 ↔ 기계 줄(common_util_machine_sheet) — 고침 없음 = 줄 바이트 같음 · 내 기계 바로 · 직접 자리 맞춤
- 내 기계 = M01 에 없는 기계만 — 화면 만들기 · 고치기에서 M01 과 같은 이름 · 규격 409(불러오기 자동 생성은 그대로) · [새 기계] · 고침 열림
- 옛 기계 양식 부품(ui_template_machine_form) 지움 — 쓰는 곳 0(부품 모음 견본 줄도 걷음)
- 낡은 시험 test_821_3 test_stack_line_copy — 836-2 자동 따라옴(내 기계 고치면 SK 금액 따라감 · 갱신 할 것 없음)에 맞춤
- 지킴 시험 test_840_2 — M01 기계 655 대 박은 식 셈 = 요소 셈 · 줄 그대로 · 굴착기 덮음 · 내 기계 꼴 · M01 같은 이름 막음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 15:52:13 +09:00

383 lines
15 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.
/* =============================================================================
* line_stack_rows.ts
* 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장]
* 보임 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
* 고침(804-7 · 요구 687-14) = [식 고치기] 걷음 — 이름 · 식 글을 누르면 그 줄만 그 자리에서 이름 · 식 · 단위 칸 + 지움 ·
* 바꾸면 닫힘 · 머리 [+] = 식 줄 더함 — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 만약 안 탄 갈래 기호(`hide` · 요구 687-10) = 안 보임(식 글은 그대로)
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold` · 입력에 기계 있는 줄은 안 함 840-2) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 ·
* 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」
* 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`)
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import {
DocError,
rename,
splitId,
textOf,
varId,
type FormulaDoc,
} from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import { machineKey } from "../common_util/common_util_formula_doc_cover";
import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view";
import { machineText } from "./formula_doc_options";
import {
dropSyms,
freshId,
newVar,
setRow,
shapeOf,
sourceOf,
unfold,
type StackItem,
type StackLine,
} from "../common_util/common_util_line_stack";
import {
aliasText,
boundConds,
currentRow,
pickCell,
shownUnit,
tableAlias,
} from "../common_util/common_util_line_stack_pick";
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import type { EditHooks } from "./line_stack_edit";
import { openTablePick } from "./line_stack_pick";
import { lt } from "./line_stack_text";
import { whyText } from "./line_stack_why";
import { createText } from "@ui/ui_template_text";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
type Block = Extract<StackLine, { 꼴: "공식" }>;
const LINK_TAG: Record<string, string> = {
표: "표",
요소: "요소",
단가산출: "단가산출",
선택: "선택",
입력: "입력",
};
/** 출처 꼬리표 — 긴 글은 칸 안에서 줄임표 · 마우스 글에 전체(요구 692-3) */
const clipTag = (text: string): HTMLElement =>
el("span", { className: "lstk-tag", text, attrs: { title: text } });
type Link = NonNullable<NonNullable<FormulaDoc["변수"]>[number]["연결"]>;
/** 표 값 — [표 · 칸] 단추 → 표 고르기 창(804-3) */
function tableBtn(h: EditHooks, id: string, link: Link): HTMLElement {
const vals = (h.answer.값 ?? {}) as Record<string, unknown>;
const b = createButton({
label:
[
lt("Tbl"),
...Object.values(link.조건 ?? {}).flatMap((t) =>
/^'.*'$/.test(String(t)) ? [String(t).slice(1, -1)] : [],
),
link.칸,
]
.filter(Boolean)
.join(" · ") + " ▾", // 고른 줄(글 조건) · 칸
variant: "ghost",
size: 24,
clip: true, // 긴 줄 이름 = 칸 안에서 줄임(요구 692-3)
onClick: () =>
void h.table(String(link.표 ?? "")).then((t) => {
if (!t) return void showToast(lt("Tbl_Missing"), "warning");
openTablePick({
table: t,
now: currentRow(t, link, vals),
col: String(link.칸 ?? ""),
bound: boundConds(h.doc, link),
done: (row, col) => h.change((doc) => pickCell(doc, id, t, row, col)),
});
}),
});
b.dataset.tablePick = id;
b.title = `${b.title} — ${h.name(String(link.표 ?? "")) || String(link.표 ?? "")}`;
return b;
}
/** 값 기호의 출처 꼬리표(식 없는 기호) — 표 = 표 고르기 단추 · 표 값 직접 침 = 「직접 입력」 · 그 밖 연결 = 글 ·
* 공식 묶음 값 줄 · 금액 줄 수량 칸(요구 701-1) 같은 꼴 */
export function sourceTag(h: EditHooks, id: string, link: Link | undefined): HTMLElement | null {
if (!link) return null;
if (link.갈래 === "표") return tableBtn(h, id, link);
if (link.갈래 === "숫자" && link.표)
return el("span", {
className: "lstk-tag",
text: lt("Direct"),
attrs: { title: lt("Direct_Hint") },
});
if (!LINK_TAG[link.갈래 ?? ""]) return null;
const sub = link.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
return clipTag(
`${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
);
}
const input = (
value: string,
placeholder: string,
cls: string,
set: (t: string) => void,
): HTMLElement => {
const f = createInputField({ value, placeholder, compact: true, size: 24 });
f.root.classList.add(cls);
f.input.addEventListener("change", () => set(f.input.value));
return f.root;
};
export function buildBlock(
h: EditHooks,
i: number,
j: number,
line: Block,
hide: (id: string) => boolean = () => false,
): HTMLElement[] {
const d = h.doc;
const at = `${i}.${j}`;
const editing = (id: string): boolean => h.open.has(`${at}:${id}`);
/** 그 줄 고침 켬 · 끔(화면 상태) */
const flip = (id: string, on: boolean): void => {
if (on) h.open.add(`${at}:${id}`);
else h.open.delete(`${at}:${id}`);
};
const vals = (h.answer.값 ?? {}) as Record<string, unknown>;
const vars = new Map((d.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((d.식 ?? []).map((l) => [String(l.기호 ?? ""), String(l.식 ?? "")]));
const blockOf = (items: StackItem[]): Block => items[i].줄[j] as Block;
// 표 값 기호 = 화면 이름(요구 683 · 문서 기호 그대로) — 칸 글은 화면 이름 ↔ 기호로 오감
const alias = tableAlias(d);
const show = (id: string): string => alias[id] ?? id;
const shownText = (t: string): string => aliasText(t, alias);
const realText = (t: string): string => aliasText(t, alias, true);
const valText = (id: string): string => {
const v = vals[id];
const mach = machineText(v, () => h.change(() => undefined)); // 838-3 기계 원문번호 = 이름
if (mach) return mach;
if (isNum(v) || typeof v === "string")
return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim();
if (v && typeof v === "object")
return ["노무비", "재료비", "경비"]
.map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`)
.join(" · ");
return `— ${h.answer.빈값?.[id] ?? ""}`.trim();
};
// ── 이름표 ──
const tplName = (k: string): string =>
k.startsWith("my:") ? lt("My_Tpl") : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); // my: = 옛 내 틀(걷음 · 요구 685)
const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴;
const tableOnly = line.기호.every((id) => !!vars.get(id)?.연결?.표 && !lines.has(id)); // 직접 친 표 값도
const label = !line.틀
? lt(tableOnly && line.기호.length ? "K_표값" : "K_공식")
: changed
? `${lt("My_Tpl")} (${tplName(line.틀)} ${lt("Changed_From")})`
: tplName(line.틀);
const head = el("div", {
className: "lstk-fhead",
children: [
el("span", { className: "lstk-kind", text: label }),
createIconButton({
icon: "plus",
title: lt("Add_Row"),
size: 24,
onClick: () =>
h.change((doc, items) => {
const id = freshId(doc, "x");
(doc.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
blockOf(items).기호.push(id);
flip(id, true);
}),
}),
],
});
head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : "";
// ── 줄 ──
const renameRow = (old: string, now: string): void => {
const t = now.trim();
if (!t || t === old) return;
try {
const copy = clone(d);
const [s, lo, up] = splitId(t);
rename(copy, old, s, lo, up);
} catch (e) {
if (e instanceof DocError) return void showToast(lt("Bad_Name"), "warning");
throw e;
}
h.change((doc, items) => {
const [s, lo, up] = splitId(t);
rename(doc, old, s, lo, up);
for (const it of items)
for (const l of it.줄) if (l.꼴 === "공식") l.기호 = l.기호.map((x) => (x === old ? t : x));
});
};
const unfoldRow = (id: string, sub: FormulaDoc, key: string): void =>
h.change((doc, items) => {
const moved = unfold(doc, sub, id);
const blk = blockOf(items);
blk.기호 = blk.기호.filter((x) => x !== id);
items[i].줄.splice(j + 1, 0, { 꼴: "공식", 기호: moved, 틀: key, 틀꼴: shapeOf(doc, moved) });
if (!blk.기호.length) items[i].줄.splice(j, 1);
});
/** 누르면 그 줄 고침(보이는 글을 고침) */
const tap = (node: HTMLElement, id: string): HTMLElement => {
node.classList.add("lstk-tap");
node.tabIndex = 0;
node.setAttribute("role", "button");
node.title = lt("Edit_Hint");
const go = (): void => {
flip(id, true);
h.change(() => undefined);
};
node.addEventListener("click", go);
node.addEventListener("keydown", (e) => e.key === "Enter" && go());
return node;
};
/** 고친 뒤 닫음 */
const done = (id: string, fn: () => void): void => {
flip(id, false);
fn();
};
const rows = line.기호
.filter((id) => !hide(id))
.map((id) => {
const v = vars.get(id);
const link = v?.연결;
const raw = lines.get(id);
// 만약 식 = 결정된 식(지금 옵션 · 중간값으로 탄 갈래만 · 요구 695) — 고치면 그 글로 굳음(만약 사라짐 · 696)
const tree = raw !== undefined ? tryParse(raw) : null;
const iffy = !!tree && hasIf(tree);
const expr = iffy ? textOf(decide(tree!, vals)) : raw;
const was = h.before?.get(id);
const freed = !iffy && raw !== undefined && !!was && hasIf(tryParse(was) ?? ["num", 0, "0"]);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const src = v ? sourceOf(h.doc, v) : null; // 가져온 값(812-3) = 옵션 가로 칸과 같은 값 · 꼬리표만
const srcTag = src === "원파일" ? lt("Src_Native") : src === "엑셀" ? lt("Src_Excel") : src;
const tag = expr ? null : srcTag ? clipTag(srcTag) : sourceTag(h, id, link);
const tools: HTMLElement[] = [];
// 기계 줄(입력에 기계) = 풀기 안 내놓음(840-2 · 요구 864 — 기계 값 고침은 제어 구역 한 길 · 풀린 옛 줄은 그대로 열림)
if (sub?.결과 && link?.키 && !machineKey(link.입력))
tools.push(
createButton({
label: lt("Unfold"),
variant: "ghost",
size: 24,
onClick: () => unfoldRow(id, sub, link.키!),
}),
);
if (link?.갈래 === "단가산출" && !link.키)
tools.push(
createButton({
label: lt("Find_Title"),
variant: "ghost",
size: 24,
onClick: () =>
openFindPicker({
layer: "system",
owner: null,
title: lt("K_단가산출"),
kinds: ["logic"],
results: true,
logic: (x) =>
h.change((doc) => {
const y = doc.변수?.find((z) => varId(z) === id);
if (y) y.연결 = { 갈래: "단가산출", 키: x.키, 입력: {} };
}),
}),
}),
);
const name = tap(el("b", { className: "lstk-sym", text: show(id) }), id);
name.title = `${show(id)} — ${name.title}`; // 줄임표로 잘려도 전체 이름
const valueOf =
expr ??
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
let body: (HTMLElement | string)[];
const open = editing(id);
if (open)
body = [
input(show(id), lt("Row_Name"), "lstk-f-s", (t) =>
done(id, () => renameRow(id, t.trim() === show(id) ? id : t)),
),
"=",
input(shownText(valueOf), lt("Row_Expr"), "lstk-f-l", (t) =>
done(id, () => h.change((doc) => setRow(doc, id, realText(t)))),
),
input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) =>
done(id, () =>
h.change((doc) => {
let x = doc.변수?.find((y) => varId(y) === id);
if (!x) (doc.변수 ??= []).push((x = newVar(id, { 갈래: "식" })));
x.단위 = t.trim();
}),
),
),
el("span", { className: "lstk-val", text: `= ${valText(id)}` }),
createButton({
label: "×",
variant: "danger-ghost",
size: 24,
onClick: () => done(id, () => h.change((doc) => dropSyms(doc, [id]))),
}),
createIconButton({
icon: "check",
title: lt("Close"),
size: 24,
onClick: () => done(id, () => h.change(() => undefined)),
}),
];
else if (expr !== undefined || sub || link?.갈래 === "단가산출")
body = [
name,
"=",
...(expr ? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id)] : []),
// 결정된 식이 수 하나면(만약이 수를 탄 줄) 값을 또 적지 않음
...(expr && iffy && tryParse(expr)?.[0] === "num"
? []
: [...(expr ? ["="] : []), el("span", { className: "lstk-val", text: valText(id) })]),
];
else
body = [
name,
"=",
input(shownText(valueOf), valText(id), "lstk-f-m", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
...(shownUnit(v?.단위)
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
: []),
];
const why = iffy
? whyText(tree!, vals, {
label: (x) => vars.get(x)?.이름 || splitId(x)[1] || x,
shown: shownText,
fmt: (x) => eqText(x),
})
: "";
const notes = [
...(why ? [createText(why)] : []),
...(freed ? [createText(lt("If_Freed"), "warning")] : []),
];
notes.forEach((n) => n.classList.add("lstk-wide", "lstk-why"));
const row = el("div", {
className: `lstk-frow ${open ? "is-open" : ""}`,
children: [
...body,
...(tag ? [tag] : []),
...(tools.length ? [el("span", { className: "lstk-rtools", children: tools })] : []),
...notes,
],
});
row.dataset.sym = id;
return row;
});
return [head, ...rows];
}