Files
Aislo/A00_Common/line_stack_edit.ts
T
eomsangdonandClaude Opus 5.5 abe722f378 feat(804-7): 줄 쌓기 「수식」 탭 수정 2차(요구 687 · 688)
- 로직 삭제 = 서버 정본에서 바로(로직 줄 + 수식 문서 · `/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>
2026-11-08 13:33:36 +09:00

465 lines
18 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_edit.ts
* 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(띠 · 번호 · 제목 · 가나다 · [+ 줄] · 접기 · X) 안 줄 카드(⋮⋮ · X)
* 차림(804-5 · 요구 682) = 항목 머리 띠 · 줄 = 들여쓰기 + 왼쪽 세로 선 · 순서 = 손잡이 끌기(`line_stack_drag.ts`) ·
* X = 확인 없이 지우고 알림 눌러 되돌리기 한 번 · 자원 이름 = 단추(누르면 찾아 넣기 창 → 바꿈)
* 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식)
* · 공식 묶음(`line_stack_rows.ts`) · 글 줄
* 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때)
* 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 =
* 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
import { createRowMenu } from "@ui/ui_template_context_menu";
import { eqText } from "@ui/ui_template_equation";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc";
import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import {
dropSyms,
docOf,
freshId,
kindOf,
moveItem,
moveLine,
newVar,
ownedOf,
stackOf,
type MoneyKind,
type StackDoc,
type StackItem,
} from "../common_util/common_util_line_stack";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import {
addPicked,
aliasText,
repick,
shownUnit,
tableAlias,
} from "../common_util/common_util_line_stack_pick";
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
export interface EditHooks {
doc: StackDoc;
answer: DocAnswer;
items: StackItem[];
change: (fn: (d: StackDoc, items: StackItem[]) => void) => void;
/** 품셈 표 하나(표 고르기 창) */
table: (key: string) => Promise<DocTable | null>;
subDoc: (key: string) => FormulaDoc | null;
name: (key: string) => string;
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
open: Set<string>;
/** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */
pair: (loc: string) => void;
/** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */
take?: StackTake;
}
const field = (
label: string,
value: unknown,
set: (t: string) => void,
size: "s" | "m" | "l" = "m",
): HTMLElement => {
const f = createInputField({
placeholder: label,
value: value === undefined || value === null ? "" : String(value),
compact: true,
size: 24,
});
f.root.classList.add(`lstk-f-${size}`);
f.input.setAttribute("aria-label", label);
f.input.title = label;
f.input.addEventListener("change", () => set(f.input.value));
return f.root;
};
const btn = (label: string, onClick: () => void) =>
createButton({ label, variant: "ghost", size: 24, onClick });
const KIND_KEY: Record<MoneyKind, LsKey> = {
인력: "K_인력",
재료: "K_재료",
기계: "K_기계",
단가산출: "K_단가산출",
율: "K_율",
};
/** 금액 줄 몸 — 한 줄(804-7 · 요구 687-7): [종류][이름 단추 | 이름 칸 · 단가 식][수량][단위][비목] · 좁으면 접힘 */
function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const d = h.doc;
const line = (d.금액 ?? [])[i];
const kind = kindOf(d, i, h.subDoc);
const own = ownedOf(d).get(i);
const v = own ? d.변수?.find((x) => varId(x) === own) : undefined;
const put = (fn: (l: MoneyLine & { 규격?: string }) => void): void =>
h.change((doc) => fn(doc.금액![i]));
// 표 값 기호 = 화면 이름(요구 683) — 칸 글은 화면 이름 ↔ 기호로 오감
const alias = tableAlias(d);
const shown = (t: unknown): string => aliasText(String(t ?? ""), alias);
const real = (t: string): string => aliasText(t.trim(), alias, true);
const head: HTMLElement[] = [];
if (own && v) {
// 이름 단추(요구 687-9) — 누르면 찾아 넣기 창이 그 이름을 찾은 채 열림 · 고르면 바꿈
const link = v.연결 ?? {};
const mat = link.재료 as Record<string, unknown> | undefined;
const size = (line as { 규격?: string }).규격;
const name = String(line.이름 ?? "") || String(mat?.검색어 ?? mat?.이름 ?? "");
const res = link.키
? link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: "";
const pick = createButton({
label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—",
variant: "ghost",
tone: "link",
size: 24,
iconStart: "search",
onClick: () =>
openStackPick(
kind,
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
undefined,
String(mat?.검색어 ?? line.이름 ?? ""),
),
});
pick.classList.add("lstk-res");
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
pick.dataset.repick = String(i);
head.push(pick);
} else
head.push(
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"),
);
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
// 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5)
const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null;
const read = (t: unknown): string =>
shown(t).replace(/번지\.([^.\s()]+)\./g, (all, p: string) =>
places?.[p] ? `${places[p]} ` : all,
);
const unit = el("span", { className: "lstk-upick" });
const drawUnit = (): void =>
unit.replaceChildren(
createUnitPick({
value: String(line.단위 ?? "") || NO_UNIT,
size: 24,
free: true,
ariaLabel: lt("Unit"),
extra: [...new Set((d.금액 ?? []).map((m) => String(m.단위 ?? "")).filter(Boolean))],
onPick: (u) => put((l) => (u === NO_UNIT ? delete l.단위 : (l.단위 = u))),
}),
);
drawUnit();
return [
el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }),
...head,
// 율 · 식 줄(자원 없음)에 수량이 없으면 칸 안 보임(한 줄 유지)
...(own || String(line.수량 ?? "") ? [qtyBox(h, i, shown, real)] : []),
unit,
...(line.비목 === "묶음"
? []
: [
createSelectField({
options: ["노무비", "재료비", "경비"].map((x) => ({ value: x, text: x })),
value: line.비목 ?? "노무비",
compact: true,
size: 24,
ariaLabel: lt("Cost"),
onChange: (x) => put((l) => (l.비목 = x)),
}).root,
]),
...(places
? [
el("div", {
className: "lstk-hint lstk-wide",
text: `= ${[line.수량, line.단가].filter(Boolean).map(read).join(" × ")}`,
}),
]
: []),
...(why ? [el("div", { className: "lstk-miss lstk-wide", text: why })] : []),
];
}
/** 수량 — 기호 하나면 값 + 출처 꼬리표(「0.39 ← 표 · 잡석 수량」 · 요구 687-12 · 기호는 마우스 글) · 누르면 그 자리에서 식 고침 */
function qtyBox(
h: EditHooks,
i: number,
shown: (t: unknown) => string,
real: (t: string) => string,
): HTMLElement {
const line = (h.doc.금액 ?? [])[i];
const text = String(line.수량 ?? "");
const v = h.doc.변수?.find((x) => varId(x) === text);
const at = `qty:${i}`;
const edit = (): HTMLElement => {
const f = field(lt("Qty"), shown(text), (t) =>
h.change((doc) => {
h.open.delete(at);
const l = doc.금액![i];
if (t.trim()) l.수량 = real(t);
else delete l.수량;
}),
);
f.classList.add("lstk-qty");
f.dataset.qty = String(i);
return f;
};
if (!v || h.open.has(at)) return edit();
const val = (h.answer.값 ?? {})[text];
const link = v.연결 ?? {};
const kind = link.갈래 ?? "식";
const from =
v.이름 || (link.키 || link.표 ? h.name(String(link.키 ?? link.표)) : "") || shown(text);
const b = createButton({
label: `${isNum(val) ? eqText(val) : "—"}${v.단위 && shownUnit(v.단위) ? ` ${shownUnit(v.단위)}` : ""} ← ${kind} · ${from}`,
variant: "ghost",
size: 24,
onClick: () => {
h.open.add(at);
h.change(() => undefined);
requestAnimationFrame(() =>
document.querySelector<HTMLInputElement>(`[data-qty="${i}"] input`)?.focus(),
);
},
});
b.classList.add("lstk-qty");
b.title = `${lt("Qty")} = ${shown(text)}`;
b.dataset.qty = String(i);
return b;
}
/** [+ 줄] 고르개 값 → 새 줄 */
function addLine(h: EditHooks, at: number, kind: string): void {
if (["인력", "재료", "기계", "단가산출"].includes(kind))
return openStackPick(
kind as MoneyKind,
(p) => h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)),
h.take,
);
h.change((d, items) => {
const it = items[at];
const n = (d.금액 ??= []).length;
const money = (line: MoneyLine): void => {
d.금액!.push(line);
it.줄.push({ 꼴: "금액", i: n });
};
if (kind === "율")
money({ 이름: "공구손료 및 경장비", 단위: "식", 비목: "경비", 단가: "노무비 × 3 %" });
else if (kind === "공식") {
const id = freshId(d, "x");
(d.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
it.줄.push({ 꼴: "공식", 기호: [id] });
h.open.add(`${at}.${it.줄.length - 1}`);
} else if (kind === "글") it.줄.push({ 꼴: "글", 글: "" });
});
}
/** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */
function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
const line = items[i].줄[j];
if (line.꼴 === "금액") {
const own = ownedOf(d).get(line.i);
if (own) dropSyms(d, [own]);
} else if (line.꼴 === "공식") dropSyms(d, line.기호);
items[i].줄.splice(j, 1);
}
export function buildEdit(h: EditHooks): HTMLElement {
// [+] 줄 더함 메뉴(요구 687-13 · 18) — 인력 · 재료 · 기계 · 단가산출 · 율 · 식 · 근거 글
const add = (at: number): HTMLElement => {
const kinds: [string, LsKey][] = [
["인력", "K_인력"],
["재료", "K_재료"],
["기계", "K_기계"],
["단가산출", h.take ? "K_복사" : "K_단가산출"],
["율", "Add_율"],
["공식", "Add_식"],
["글", "K_글"],
];
const menu = createRowMenu(
kinds.map(([k, label]) => ({ label: lt(label), onClick: () => addLine(h, at, k) })),
{ icon: "plus", title: lt("Add_Line"), size: 24 },
);
menu.classList.add("lstk-add");
return menu;
};
// X 지움 — 확인 없이 · 알림 누르면 되돌림(그 뒤 다른 고침이 없을 때만)
const drop = (what: string, fn: (d: StackDoc, items: StackItem[]) => void): void => {
const snap = JSON.stringify(h.doc);
let after = "";
h.change((d, items) => {
fn(d, items);
after = JSON.stringify(docOf(d, items));
});
showToast(`${what || lt("K_줄")} ${lt("Dropped")}`, "info", 6000, () =>
h.change((d, items) => {
if (JSON.stringify(docOf(d, items)) !== after)
return void showToast(lt("Undo_Late"), "warning");
const back = JSON.parse(snap) as StackDoc;
for (const k of Object.keys(d)) delete (d as Record<string, unknown>)[k];
Object.assign(d, back);
items.splice(0, items.length, ...stackOf(back));
}),
);
};
const x = (title: string, onClick: () => void): HTMLElement => {
const b = createIconButton({ icon: "x", title, size: 24, onClick });
b.classList.add("lstk-x");
return b;
};
const grip = (): HTMLElement =>
el("span", { className: "ui-list-row__grip lstk-grip", attrs: { title: lt("Drag") } });
const root = el("div", { className: "lstk-edit" });
/** 항목 접힘 표시(화면 상태)를 항목 차례 바뀜에 맞춤 */
const refold = (fn: (flags: boolean[]) => void): void => {
const flags = h.items.map((_, k) => h.open.has(`fold:${k}`));
fn(flags);
h.items.forEach((_, k) => h.open.delete(`fold:${k}`));
flags.forEach((f, k) => f && h.open.add(`fold:${k}`));
};
// 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 옛 가나다 항목 = 가. 나. · 체크 걷음 요구 687-5)
let n = 0;
let sub = 0;
const nos = h.items.map((it) => {
if (!it.제목.trim()) return "";
const no = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"}.` : `${++n}.`;
if (!it.가나다) sub = 0;
return no;
});
// 만약(IF) 안 탄 갈래(요구 687-10) — 갈래를 다 열면 닿고 지금 값으로는 안 닿는 기호 · 꺼진 금액 줄 = 숨김(산출근거와 같음)
const vals = (h.answer.값 ?? {}) as Values;
const taken = new Set(reach(h.doc, vals));
const every = new Set(reach(h.doc, {}, true));
const steps = lineMap(h.doc);
const off = (line: StackItem["줄"][number]): boolean =>
line.꼴 === "금액"
? offLine((h.doc.금액 ?? [])[line.i] ?? {}, vals, steps)
: line.꼴 === "공식" &&
line.기호.length > 0 &&
line.기호.every((id) => every.has(id) && !taken.has(id));
const items = h.items.map((it, i) => {
const folded = h.open.has(`fold:${i}`);
const itemGrip = grip();
// 그룹 머리 한 줄(요구 687-6 · 688) — 손잡이 · 접기 · 번호 · 이름 · [+] · X · 접어도 자리 그대로
const head = el("div", {
className: "lstk-ihead",
children: [
itemGrip,
createIconButton({
icon: folded ? "chevronRight" : "chevronDown",
title: folded ? lt("Unfold_Item") : lt("Fold_Item"),
size: 24,
onClick: () => {
if (folded) h.open.delete(`fold:${i}`);
else h.open.add(`fold:${i}`);
h.change(() => undefined);
},
}),
el("b", { className: "lstk-no", text: nos[i] }),
field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"),
el("span", {
className: "lstk-hint lstk-count",
text: folded ? `${lt("K_줄")} ${it.줄.length}` : "",
}),
add(i),
x(lt("Del_Item"), () =>
drop(it.제목.trim() || lt("Item"), (d, items) => {
refold((f) => f.splice(i, 1));
for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j);
items.splice(i, 1);
}),
),
],
});
const lines = it.줄.flatMap((line, j) => {
if (off(line)) return [];
const lineGrip = grip();
const del = x(lt("Del"), () =>
drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) =>
dropLine(d, items, i, j),
),
);
const body =
line.꼴 === "금액"
? moneyBody(h, line.i)
: line.꼴 === "글"
? [
el("span", { className: "lstk-kind", text: lt("K_글") }),
field(
lt("Text_Ph"),
line.글,
(t) => h.change((_, items) => ((items[i].줄[j] as { 글: string }).글 = t)),
"l",
),
]
: buildBlock(h, i, j, line, (id) => every.has(id) && !taken.has(id));
// 금액 · 글 = 한 줄(X 끝) · 공식 묶음 = 머리 줄 끝에 X
const card = el("div", {
className: `lstk-line k-${line.꼴}`,
children:
line.꼴 === "공식"
? [lineGrip, ...body.slice(0, 1).map((hd) => (hd.append(del), hd)), ...body.slice(1)]
: [lineGrip, ...body, del],
});
card.dataset.loc = `${i}.${j}`;
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
attachDrag(lineGrip, {
what: "line",
root,
self: card,
drop: (to) => {
const [k, p] = to.split(".").map(Number);
h.change((_, items) => moveLine(items, i, j, k, p));
},
});
return [card];
});
const box = el("div", {
className: `lstk-item${it.가나다 ? " is-sub" : ""}${folded ? " is-folded" : ""}`,
attrs: { "data-loc": String(i) },
children: [
head,
...(folded
? []
: [
el("div", {
className: "lstk-lines",
children: lines.length
? lines
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })],
}),
]),
],
});
head.addEventListener("pointerdown", () => h.pair(String(i)));
attachDrag(itemGrip, {
what: "item",
root,
self: box,
drop: (to) => {
const p = Number(to);
refold((f) => moveItem(f, i, p));
h.change((_, items) => moveItem(items, i, p));
},
});
return box;
});
root.append(
...items,
btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))),
);
return root;
}