Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts
T
eomsangdonandClaude Opus 5.5 cb2db4ff49 feat(b09): 운반 단가 모달 · 로직 미리보기 길 (253-3)
- POST /api/projects/{id}/estimation/logic-preview — 로직 한 산근을 프로젝트 단가로 풀어 계산 절차 글 · 단가(저장 안 함)
- 「운반 단가」 모달 — 단가산출 이름 · [변경] · [다른 종류] · 옵션 값(로직 입력 + 사이클 입력 · 표 고르기 CC000054~058) · 계산 절차(사이클 식 + 미리보기)
- 여는 곳 = B08 거리이정표 표 우클릭 [단가연결] · B09 설계내역서 운반공 줄 단가연결 · 운반 항목에 거리(dist)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 14:10:38 +09:00

404 lines
18 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Edit.ts
* B09 설계내역서 손질(PLAN 166-2 · 182 · 184-3) — 모든 줄 우클릭 메뉴 · 칸 고침 · 연결 · 차례 · 레벨.
*
* 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
* {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]}
* 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천)
* 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위로 내어 쓴 줄(묶음 · cost direct)
* 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김)
* 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level ·
* 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비)
* 바뀌는 옮김 · 자재 줄 삭제(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 삭제
* 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리)
* 더함(246) = 「항목 추가」 하나 — 누른 줄이 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위 ·
* 맨 위 1단(새 공종) = 레벨 단추 · ← 로 내어 씀(관급자재대 앞)
* 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 ·
* 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
import {
createMapContextMenu,
type MapContextMenu,
type MapContextMenuItem,
} from "@ui/ui_template_context_menu";
import type { Row } from "./B09_Estimation_Calc_Base";
import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
export type BillTable = TabTable & {
menu?: (index: number) => MapContextMenuItem[];
/** 끌어 놓기(204) — 줄 index 를 parent(null = 맨 위) 아래 before 앞(null = 끝)으로 · 못 하면 null */
drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null;
};
const NEW_NAME = "새 줄";
/** 고칠 칸 — 자동 줄은 덮개 */
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
/** 깊이 한도 — 1 = 맨 위 공종 */
const MAX_LEVEL = 5;
interface Bill {
v: 2;
rows: Row[];
gone: string[];
}
/** 손질 한 번 = 굳힌 덮개 + 보이는 줄(키 → 셈 출력 줄) */
interface Work {
bill: Bill;
shown: Map<string, DataRow>;
}
/** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */
const fixed = (key: string): boolean => key === "fee" || key === "sec:G";
/** 자식을 둘 수 있는 줄 — 자재 · 조달수수료 빼고(항목은 저절로 묶음) */
const canHold = (key: string): boolean => key !== "fee" && !key.startsWith("mat:");
/** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */
export const linkable = (row: DataRow | undefined): boolean =>
row?.kind === "item" && !fixed(String(row.key)) && !String(row.key).startsWith("mat:");
const find = (list: Row[], key: string): number => list.findIndex((e) => e.key === key);
const pull = (list: Row[], key: string): Row => list.splice(find(list, key), 1)[0];
const same = (a: unknown, b: unknown): boolean =>
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
/** 옛 꼴(166-2 · 182) 줄 → v2 칸 — 자동 줄 = 수량 덮어쓰기 · 손 줄 = 모든 칸 */
function oldEntry(row: DataRow, bill: Row): Row {
const key = String(row.key);
if (row.auto) return key in (bill.qty || {}) ? { qty: bill.qty[key] } : {};
const hand: Row = (bill.rows || []).find((h: Row) => h.key === key) || {};
const out: Row = row.kind === "item" ? {} : { kind: "group" };
for (const f of FIELDS) if (f in hand) out[f] = hand[f];
return out;
}
/** 굳힘 — v2 면 그대로 + 덮개에 없는 보이는 줄(새 자동 줄)을 보이는 앞 줄 뒤에 · 옛 꼴이면 보이는 줄로 */
function freeze(user: Row, rows: DataRow[]): Work {
const bill: Row = user.bill || {};
const v2 = bill.v === 2;
const list: Row[] = v2 ? (bill.rows || []).map((e: Row) => ({ ...e })) : [];
const gone: string[] = v2 ? [...(bill.gone || [])] : [];
const shown = new Map(rows.map((r) => [String(r.key), r]));
let prev = -1;
for (const row of rows) {
const key = String(row.key);
if (key === "fee") continue; // 셈이 관급 묶음 끝에 둠
let at = find(list, key);
if (at < 0 && !gone.includes(key)) {
const entry: Row = { key, ...(row.parent ? { parent: row.parent } : {}) };
list.splice(prev + 1, 0, v2 ? entry : { ...entry, ...oldEntry(row, bill) });
at = prev + 1;
}
if (at >= 0) prev = at;
}
return { bill: { v: 2, rows: list, gone }, shown };
}
function put(ctx: TabContext, bill: Bill): void {
ctx.setUser({ ...ctx.user, bill });
}
/** 위로 올라가는 줄 — [자기, 부모, …, 맨 위] */
function chain(list: Row[], key: string): Row[] {
const out: Row[] = [];
let at: Row | undefined = list[find(list, key)];
while (at && out.length <= list.length) {
out.push(at);
at = at.parent ? list[find(list, at.parent)] : undefined;
}
return out;
}
/** 차례 바로잡음 — 부모 먼저 · 자손 이어서(목록 차례 = 형제 차례) · 못 찾는 부모 · 돎 = 맨 위 */
function tidy(list: Row[]): Row[] {
const keys = new Set(list.map((e) => e.key));
for (const e of list) if (!e.parent || !keys.has(e.parent)) delete e.parent;
for (const e of list) if (chain(list, e.key).length > list.length) delete e.parent;
const out: Row[] = [];
// ponytail: 형제 찾기 O(n²) — 내역 줄 수(수백)면 충분
const walk = (parent?: string): void => {
for (const e of list)
if (e.parent === parent) {
out.push(e);
walk(e.key);
}
};
walk();
return out;
}
/** 맨 위 묶음 돈 갈래 — direct(직접비) | supply(관급자재대) */
function costOf(w: Work, list: Row[], key: string): string {
const top = chain(list, key).at(-1);
return top?.cost ?? w.shown.get(String(top?.key))?.cost ?? "direct";
}
/** 바꾼 뒤 지킬 것 — 깊이 5 · 자재 줄 돈 갈래 그대로 */
function fits(w: Work, before: Row[]): boolean {
return w.bill.rows.every(
(e) =>
chain(w.bill.rows, e.key).length <= MAX_LEVEL &&
(!String(e.key).startsWith("mat:") ||
costOf(w, w.bill.rows, e.key) === costOf(w, before, e.key)),
);
}
/** 손질 시도 — 지킬 것 안이면 [동작] · 아니면 null(메뉴에 안 보임) */
function attempt(
ctx: TabContext,
rows: DataRow[],
change: (w: Work) => boolean | void,
): (() => void) | null {
const w = freeze(ctx.user, rows);
const before = w.bill.rows.map((e) => ({ ...e }));
if (change(w) === false) return null;
w.bill.rows = tidy(w.bill.rows);
// 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) — 자식이 생긴 항목 = 셈이 묶음으로
const had = new Set(before.map((e) => e.parent));
const has = new Set(w.bill.rows.map((e) => e.parent));
for (const e of w.bill.rows)
if (e.kind === "group" && e.parent && had.has(e.key) && !has.has(e.key)) delete e.kind;
return fits(w, before) ? () => put(ctx, w.bill) : null;
}
const entryOf = (w: Work, key: string): Row | undefined => w.bill.rows[find(w.bill.rows, key)];
const isGroup = (w: Work, key: string): boolean =>
(entryOf(w, key)?.kind ?? w.shown.get(key)?.kind ?? "item") !== "item";
/** 보이는 형제(차례대로) */
const siblings = (w: Work, parent: unknown): Row[] =>
w.bill.rows.filter((e) => (e.parent ?? null) === (parent ?? null) && w.shown.has(e.key));
const newKey = (): string =>
`hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newItem = (parent?: string): Row => ({
key: newKey(),
...(parent ? { parent } : {}),
name: NEW_NAME,
spec: "",
unit: "",
qty: null,
ref: null,
});
/** 칸 고침 — 자동 줄은 덮개(자동 값과 같으면 덮개 지움) · 새 줄은 그 칸 */
export function editCell(
ctx: TabContext,
rows: DataRow[],
i: number,
field: string,
value: unknown,
): void {
const row = rows[i];
if (!row || !FIELDS.includes(field)) return;
const w = freeze(ctx.user, rows);
const e = entryOf(w, String(row.key));
if (!e) return;
const auto = `auto_${field}` in row ? row[`auto_${field}`] : row[field];
if (row.auto && same(auto, value)) delete e[field];
else e[field] = value;
put(ctx, w.bill);
}
/** 연결 적용 — 새 줄이 「새 줄」 · 빈 이름이면 고른 호표 명칭 · 규격 · 단위를 채움 */
function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef {
return (ref, from) => {
const row = rows[i];
const w = freeze(ctx.user, rows);
const e = entryOf(w, String(row?.key));
if (!row || !e) return;
e.ref = ref;
if (from && !row.auto && (!e.name || e.name === NEW_NAME)) {
e.name = from.name || e.name;
e.spec = e.spec || from.spec || "";
e.unit = e.unit || from.unit || "";
}
put(ctx, w.bill);
};
}
/** 줄 연결 메뉴 — 지금 연결 = 덮개(로직 입력값 등) 있으면 덮개 · 없으면 셈 줄 */
function linkFor(ctx: TabContext, rows: DataRow[], i: number) {
const row = rows[i];
const e = entryOf(freeze(ctx.user, rows), String(row.key));
const ref: Row | null = e && "ref" in e ? e.ref : (row.ref ?? null);
const key = String(row.key);
// 253-3 운반공 줄 = 「운반 단가」 모달(거리 = 서버가 준 항목 `dist`)
const item = key.startsWith("haul:")
? ((ctx.payload.inputs?.bill?.items as Row[]) || []).find((it) => it.key === key)
: undefined;
const haul = item && { dist: item.dist ?? null, name: String(row.name ?? item.name ?? "") };
return linkItems(ctx, ref, applyRef(ctx, rows, i), haul);
}
/** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */
export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
const row = rows[i];
if (!row) return [];
const key = String(row.key);
const parts: MapContextMenuItem[][] = [[], [], [], []];
const add = (part: number, label: string, change: (w: Work, e: Row) => boolean | void): void => {
const run = attempt(ctx, rows, (w) => {
// 조달수수료 = 차례에 없음(셈이 관급자재대 끝에 둠) — 부모만 빌림
const e = entryOf(w, key) ?? (key === "fee" ? { key, parent: row.parent } : undefined);
return e ? change(w, e) : false;
});
if (run) parts[part].push([label, run]);
};
// 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모(조달수수료 = 관급 묶음 끝 · 셈이 그 뒤에 둠)
add(0, "항목 추가", (w, e) => {
const list = w.bill.rows;
const at = find(list, key);
if (isGroup(w, key)) list.splice(at + 1, 0, newItem(key));
else if (!e.parent) return false;
else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent));
});
if (linkable(row)) {
const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯)
parts[1].push(...picks, ...more);
}
// 옮김 · 레벨 — 같은 부모 형제와 자손째
if (!fixed(key)) {
add(2, "위로 ↑", (w, e) => {
const sib = siblings(w, e.parent);
const k = sib.indexOf(e);
if (k < 1) return false;
pull(w.bill.rows, key);
w.bill.rows.splice(find(w.bill.rows, sib[k - 1].key), 0, e);
});
add(2, "아래로 ↓", (w, e) => {
const sib = siblings(w, e.parent);
const k = sib.indexOf(e);
if (k < 0 || k + 1 >= sib.length) return false;
pull(w.bill.rows, key);
w.bill.rows.splice(find(w.bill.rows, sib[k + 1].key) + 1, 0, e);
});
add(2, "들여쓰기 →", (w, e) => {
const sib = siblings(w, e.parent);
const up = sib[sib.indexOf(e) - 1];
if (!up || !canHold(up.key)) return false;
e.parent = up.key;
w.bill.rows.push(pull(w.bill.rows, key));
});
add(2, "내어쓰기 ←", (w, e) => {
const up = e.parent ? entryOf(w, e.parent) : undefined;
if (!up || (!up.parent && !canHold(key))) return false; // 자재 줄 = 1단 안 됨
pull(w.bill.rows, key);
if (up.parent) e.parent = up.parent;
else {
// 새 공종(1단) = 직접비 맨 위 · 손 줄은 묶음(자재 줄 돈 갈래 바뀜 = fits 가 막음)
delete e.parent;
e.cost = "direct";
if (!row.auto) Object.assign(e, { kind: "group", qty: null, ref: null });
}
// 맨 위로 = 관급자재대 앞 · 그 밖 = 옛 부모 바로 뒤
const top = !up.parent && costOf(w, w.bill.rows, up.key) === "supply";
w.bill.rows.splice(find(w.bill.rows, up.key) + (top ? 0 : 1), 0, e);
});
}
if (!row.auto && row.kind === "item")
add(2, "묶음 줄로 바꾸기", (_w, e) => {
Object.assign(e, { kind: "group", qty: null, ref: null });
});
if (!row.auto && row.kind !== "item")
add(2, "항목으로 바꾸기", (w, e) => {
if (!e.parent) return false;
delete e.kind;
for (const c of w.bill.rows) if (c.parent === key) c.parent = e.parent;
});
// 자동값으로 — 그 줄 고친 칸 지움(차례 · 부모는 그대로)
if (row.auto && FIELDS.some((f) => `auto_${f}` in row))
add(3, "자동값으로 되돌리기", (_w, e) => {
for (const f of FIELDS) delete e[f];
});
// 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움
if (!fixed(key) && !key.startsWith("mat:"))
add(3, "줄 삭제", (w, e) => {
const list = w.bill.rows;
if (!e.parent && list.some((c) => c.parent === key)) return false;
for (const c of list) if (c.parent === key) c.parent = e.parent;
pull(list, key);
if (row.auto) w.bill.gone.push(key);
});
const out: MapContextMenuItem[] = [];
for (const part of parts)
if (part.length) out.push(...(out.length ? ["-" as const] : []), ...part);
return out;
}
/** 끌어 놓기(204) — 자손째 parent 아래 before 앞으로 · 지킬 것(깊이 · 자재 돈 갈래)은 메뉴와 같음 */
export function dropMove(
ctx: TabContext,
rows: DataRow[],
i: number,
parent: string | null,
before: string | null,
): (() => void) | null {
const key = String(rows[i]?.key);
if (!rows[i] || fixed(key) || (parent !== null && !canHold(parent))) return null;
return attempt(ctx, rows, (w) => {
const e = entryOf(w, key);
if (!e || (parent !== null && !entryOf(w, parent))) return false;
if (parent !== null && chain(w.bill.rows, parent).includes(e)) return false; // 자기 자손 아래
if (parent === null) delete e.parent;
else e.parent = parent;
pull(w.bill.rows, key);
const at = before === null ? -1 : find(w.bill.rows, before);
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, e);
});
}
let shared: MapContextMenu | null = null;
/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */
let lastCell: Element | null = null;
/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */
export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void {
const box = lastCell?.getBoundingClientRect();
if (!shared || !box || !linkable(rows[i])) return;
const [picks, more] = linkFor(ctx, rows, i);
if (!more.length) return picks[0][1](); // 251-6 아직 연결 없음 = 단가연결 모달 바로
shared.open(box.left, box.bottom, [...picks, "-", ...more]);
}
/** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */
function sharedMenu(): MapContextMenu {
shared ??= createMapContextMenu("b09-estimation");
if (!shared.element.isConnected) {
shared.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
document.body.append(shared.element);
}
return shared;
}
/** 화면 자리(x · y)에 B09 메뉴를 엶(183-3 칸 고르기) */
export function openMenuAt(x: number, y: number, items: MapContextMenuItem[]): void {
if (items.length) sharedMenu().open(x, y, items);
}
/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위에서도 줄 메뉴 */
export function attachRowMenu(
root: HTMLElement,
menu: (index: number) => MapContextMenuItem[],
): void {
const ctxMenu = sharedMenu();
// 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶
root.addEventListener(
"click",
(event) => (lastCell = (event.target as Element).closest("td")),
true,
);
root.addEventListener("contextmenu", (event) => {
const target = event.target as Element;
const row = target.closest<HTMLElement>("tr[data-i]");
if (!row || !root.contains(row)) return;
event.preventDefault();
const items = menu(Number(row.dataset.i));
if (items.length) ctxMenu.open(event.clientX, event.clientY, items);
});
}