Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
366 lines
15 KiB
TypeScript
366 lines
15 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` · 화면 고정 자리)
|
|
* ========================================================================== */
|
|
|
|
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[] };
|
|
|
|
const NEW_NAME = "새 줄";
|
|
const NEW_SECTION = "새 공종";
|
|
/** 고칠 칸 — 자동 줄은 덮개 */
|
|
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";
|
|
|
|
/** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */
|
|
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);
|
|
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);
|
|
return linkItems(ctx, ref, applyRef(ctx, rows, i));
|
|
}
|
|
|
|
/** 줄 우클릭 메뉴 — 더함 · 연결 · 옮김 · 레벨 · 자동값으로 · 지움(모든 줄 · 지킬 것 안만) */
|
|
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) => {
|
|
if (!e.parent) return false;
|
|
const at = find(w.bill.rows, key);
|
|
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent));
|
|
});
|
|
add(0, "아래에 줄 추가", (w, e) => {
|
|
if (!e.parent || key === "fee") return false;
|
|
w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent));
|
|
});
|
|
add(0, "아래에 하위 줄 추가", (w) => {
|
|
if (!isGroup(w, key)) return false;
|
|
w.bill.rows.push(newItem(key));
|
|
});
|
|
add(0, "새 공종 추가(1단)", (w, e) => {
|
|
const list = w.bill.rows;
|
|
const from = find(list, key) < 0 ? String(e.parent) : key;
|
|
const top = chain(list, from).at(-1)!;
|
|
const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1);
|
|
list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION });
|
|
});
|
|
if (linkable(row)) {
|
|
const [picks, more] = linkFor(ctx, rows, i);
|
|
parts[1].push({ label: "연결", children: 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 || !isGroup(w, 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?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴)
|
|
e.parent = up.parent;
|
|
pull(w.bill.rows, key);
|
|
w.bill.rows.splice(find(w.bill.rows, up.key) + 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;
|
|
}
|
|
|
|
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);
|
|
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
|
|
}
|
|
|
|
/** 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);
|
|
});
|
|
}
|