- 로직 삭제 = 서버 정본에서 바로(로직 줄 + 수식 문서 · `/logic/remove`) → 로직 목록 · 부르는 곳(다른 수식 문서 · 일위대가 조합 · M02 단가산출 바탕)이 있으면 확인 창 전에 막고 까닭(`/logic/users`) - 머리 단추 다섯 — [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · [저장] 한 번 = 기본정보 + 수식 · [되돌림] = 둘 다 저장 뒤 상태 · 줄 쌓기 [고침 버림] · [수식 저장]은 M01 에서 걷음(M02 사본은 그대로) - 합계 상자 화면 아래 고정 · 원문 손잡이 브라우저 오른쪽 끝 - 왼쪽: 그룹 머리 한 줄(접어도 [+] · X 자리 그대로) · 「비우면 번호 없음」 · 가나다 체크 걷음 · 금액 줄 한 줄 배치 · 이름 단추 = 그 이름 찾은 찾기 창 · 수량 기호 = 값 + 출처 꼬리표 · [+] 메뉴(단가산출 · 율 · 식 · 근거 글 · 틀 찾기 걷음) · 식 글 눌러 그 자리 고침([식 고치기] 걷음) · 단위 = 공용 단위 고르기(친 글도 씀) · 만약 안 탄 갈래 숨김 - 오른쪽 기계 줄: 비목 수 링크 걷음 · 제목 줄 누름 = 시간당 경비 펼침 · 펼친 제목 보통 글 - 요소 키가 기호 이름으로 샌 것(`k_EQ0006`) — 변환기가 자원 이름으로(`k_하베스터`) · 수식 문서 다시 빌드(10 파일 · 78 기호 · 금액 같음) - 시험 페이지 `tools/line-stack-test` · main.py 마운트 · vite 프록시 · M01 왼쪽 「시험: 줄 쌓기」 걷음 - 단위 표준 표 읽기 = 로그인만(M02 일반 사용자 단위 목록) - 시험: test_804_7 새로 · 804_2 · 804_4 이음 글 고침(createStackScreen 줄 나뉨) · 552_3 여는 단추 기본 ⋯ 꼴로 고침(부르는 쪽이 [+] 로 바꿀 수 있음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* line_stack_rows.ts
|
||
* 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장]
|
||
* 보임 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
|
||
* 고침(804-7 · 요구 687-14) = [식 고치기] 걷음 — 이름 · 식 글을 누르면 그 줄만 그 자리에서 이름 · 식 · 단위 칸 + 지움 ·
|
||
* 바꾸면 닫힘 · 머리 [+] = 식 줄 더함 — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
|
||
* 만약 안 탄 갈래 기호(`hide` · 요구 687-10) = 안 보임(식 글은 그대로)
|
||
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 ·
|
||
* 빈 틀 = [찾아 넣기](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,
|
||
varId,
|
||
type FormulaDoc,
|
||
} from "../common_util/common_util_formula_doc";
|
||
import { isNum } from "../common_util/common_util_formula_doc_calc";
|
||
import {
|
||
dropSyms,
|
||
freshId,
|
||
newVar,
|
||
setRow,
|
||
shapeOf,
|
||
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";
|
||
|
||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||
type Block = Extract<StackLine, { 꼴: "공식" }>;
|
||
|
||
const LINK_TAG: Record<string, string> = {
|
||
표: "표",
|
||
요소: "요소",
|
||
단가산출: "단가산출",
|
||
선택: "선택",
|
||
입력: "입력",
|
||
};
|
||
|
||
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];
|
||
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 tableBtn = (id: string, link: NonNullable<FormulaDoc["변수"]>[number]["연결"] & object) => {
|
||
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,
|
||
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(d, link),
|
||
done: (row, col) => h.change((doc) => pickCell(doc, id, t, row, col)),
|
||
});
|
||
}),
|
||
});
|
||
b.dataset.tablePick = id;
|
||
b.title = h.name(String(link.표 ?? "")) || String(link.표 ?? "");
|
||
return b;
|
||
};
|
||
|
||
// ── 줄 ──
|
||
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 expr = lines.get(id);
|
||
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
|
||
const tag =
|
||
link?.갈래 === "표" && !expr
|
||
? tableBtn(id, link)
|
||
: link && LINK_TAG[link.갈래 ?? ""] && !expr
|
||
? el("span", {
|
||
className: "lstk-tag",
|
||
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
|
||
})
|
||
: null;
|
||
const tools: HTMLElement[] = [];
|
||
if (sub?.결과 && 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);
|
||
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), "="]
|
||
: []),
|
||
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 row = el("div", {
|
||
className: `lstk-frow ${open ? "is-open" : ""}`,
|
||
children: [...body, ...(tag ? [tag] : []), ...tools],
|
||
});
|
||
row.dataset.sym = id;
|
||
return row;
|
||
});
|
||
return [head, ...rows];
|
||
}
|