Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts
T
eomsangdon 5e4e870ad2 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
# Conflicts:
#	B09_Estimation/B09_Estimation_UI_Page.css
#	B09_Estimation/B09_Estimation_UI_Shell.ts
#	B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts
2026-10-10 12:04:39 +09:00

388 lines
16 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Edit.ts
* B09 설계내역서 손질(PLAN 166-2) — 우클릭 메뉴 · 손 줄 더하기 · 지우기 · 연결 고르기 · 수량 덮어쓰기.
*
* 저장 = 사용자 값 `user.bill`(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
* qty{줄 키: 수} = 자동 줄 수량 덮어쓰기(빈칸 · [수량 자동으로] = 키 지움 → B08 값)
* rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref}
* 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가)
* 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용
* 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 ·
* 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
import {
createMapContextMenu,
type MapContextMenu,
type MapContextMenuItem,
} from "@ui/ui_template_context_menu";
import { showToast } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { openModal } from "@ui/ui_template_modal";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
import type { Row } from "./B09_Estimation_Calc_Base";
import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] };
const NEW_NAME = "새 줄";
const TEXT_KEYS = ["name", "spec", "unit"];
/** 182 손 묶음 깊이 — 묶음 안 묶음까지 */
const MAX_GROUP = 2;
interface Bill {
qty: Record<string, number>;
rows: Row[];
}
function billOf(user: Row): Bill {
const bill: Row = user.bill || {};
return { qty: { ...(bill.qty || {}) }, rows: (bill.rows || []).map((r: Row) => ({ ...r })) };
}
function put(ctx: TabContext, bill: Bill): void {
ctx.setUser({ ...ctx.user, bill });
}
/** 손 줄 고칠 칸 — 명칭 · 규격 · 단위 · 수량 */
export const handEditable = (key: string): boolean => key === "qty" || TEXT_KEYS.includes(key);
/** 수량 고침 — 손 줄 = 그 줄 수량 · 자동 줄 = 덮어쓰기(빈칸 = 자동으로) */
export function editQty(ctx: TabContext, row: DataRow, value: number | null): void {
const bill = billOf(ctx.user);
const key = String(row.key);
if (row.hand) {
const own = bill.rows.find((h) => h.key === key);
if (!own) return;
own.qty = value;
} else if (value === null) delete bill.qty[key];
else bill.qty[key] = value;
put(ctx, bill);
}
/** 손 줄 글 칸(명칭 · 규격 · 단위) */
export function editText(ctx: TabContext, key: string, field: string, value: string): void {
const bill = billOf(ctx.user);
const own = bill.rows.find((h) => h.key === key);
if (!own || !TEXT_KEYS.includes(field)) return;
own[field] = value.trim();
put(ctx, bill);
}
const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0);
/** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */
function subtreeEnd(rows: DataRow[], i: number): number {
let k = i + 1;
while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1;
return k;
}
/** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */
function reparent(h: Row, parent: unknown): void {
if (parent) h.parent = parent;
else delete h.parent;
}
/** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */
function fits(bill: Bill): boolean {
const byKey = new Map(bill.rows.map((h) => [String(h.key), h]));
return bill.rows.every((h) => {
let n = 0;
const seen = new Set([h.key]);
let at = byKey.get(String(h.parent ?? ""));
while (at && at.kind === "group" && !seen.has(at.key)) {
seen.add(at.key);
n += 1;
at = byKey.get(String(at.parent ?? ""));
}
return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP);
});
}
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */
function slot(rows: DataRow[], i: number, below: boolean): Row {
const row = rows[i];
const section = String(row.section);
if (row.kind === "title") return { section, after: `sec:${section}` };
const parent = row.hand && row.parent ? { parent: String(row.parent) } : {};
if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent };
const prev = rows[i - 1];
const same = prev && prev.kind !== "title" && prev.section === section;
return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent };
}
function newRow(ctx: TabContext, at: Row): void {
const bill = billOf(ctx.user);
const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null });
put(ctx, bill);
}
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
newRow(ctx, slot(rows, i, below));
}
/** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */
function addChild(ctx: TabContext, rows: DataRow[], i: number): void {
const after = String(rows[subtreeEnd(rows, i) - 1].key);
newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) });
}
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */
function removeRow(ctx: TabContext, key: string): void {
const bill = billOf(ctx.user);
const gone = bill.rows.find((h) => h.key === key);
if (!gone) return;
bill.rows = bill.rows.filter((h) => h.key !== key);
for (const h of bill.rows) if (h.after === key) h.after = gone.after;
for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent);
put(ctx, bill);
}
/** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */
function levelAction(
ctx: TabContext,
key: string,
change: (own: Row, bill: Bill) => void,
): (() => void) | null {
const bill = billOf(ctx.user);
const own = bill.rows.find((h) => h.key === key);
if (!own) return null;
change(own, bill);
return fits(bill) ? () => put(ctx, bill) : null;
}
/** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */
function indentTarget(rows: DataRow[], i: number): DataRow | null {
const row = rows[i];
for (let j = i - 1; j >= 0; j -= 1) {
const r = rows[j];
if (lv(r) > lv(row)) continue;
return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null;
}
return null;
}
/** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */
function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
const row = rows[i];
const key = String(row.key);
const out: MapContextMenuItem[] = [];
const add = (label: string, action: (() => void) | null): void => {
if (action) out.push([label, action]);
};
if (row.kind === "group")
add(
"항목으로 바꾸기",
levelAction(ctx, key, (own, bill) => {
delete own.kind;
for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent);
}),
);
else
add(
"묶음 줄로 바꾸기",
levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })),
);
const target = indentTarget(rows, i);
if (target)
add(
"들여쓰기 →",
levelAction(ctx, key, (own) => reparent(own, String(target.key))),
);
if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row))
add(
"내어쓰기 ←",
levelAction(ctx, key, (own, bill) =>
reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent),
),
);
return out;
}
/** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */
function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void {
const bill = billOf(ctx.user);
const own = bill.rows.find((h) => h.key === key);
if (!own) return;
own.ref = ref;
if (from && (!own.name || own.name === NEW_NAME)) {
own.name = from.name || own.name;
own.spec = own.spec || from.spec || "";
own.unit = own.unit || from.unit || "";
}
put(ctx, bill);
}
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
function pickHopyo(ctx: TabContext, key: string, tab: "dg" | "sg"): void {
const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || [];
if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error");
const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`);
openModal({
title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기",
closeLabel: "닫기",
mount: (body, close) => {
const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" });
const find = (): void => {
const q = finder.search.value.trim().toLowerCase();
const found = listing.filter(
(r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q),
);
finder.hint.textContent = `${found.length} / ${listing.length}`;
finder.setRows(
found.map((r) => ({
key: String(r.key),
tag: label(r),
name: String(r.name || ""),
sub: String(r.spec || ""),
unit: String(r.unit || ""),
onPick: () => {
close();
setRef(ctx, key, { tab, key: r.key }, r);
},
})),
);
};
finder.search.addEventListener("input", find);
find();
body.append(finder.root);
requestAnimationFrame(() => finder.search.focus());
},
});
}
/** M01 로직 → 입력값 창 → 손 줄 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
function askInputs(ctx: TabContext, key: string, code: string, name: string, old?: Row): void {
fetchLogicInputs([code]).then(
(got) => {
const logic = got.로직[0] ?? null;
openLaborInput({
title: `${code} ${name}`.trim(),
logic,
values: old?.given || {},
memo: null,
rate: old?.rate ?? null,
readOnly: false,
okLabel: "연결",
notice: "새 산근 = 단가산출근거 뒤 번호 · 단가는 [저장] 뒤 서버가 셈",
done: (next) => {
const unit = String(logic?.결과단위 || "")
.replace(/^원\//, "")
.replace(/^[\d.]+/, "");
const ref = { tab: "logic", code, name, given: next.입력값, rate: next.켜쌓기율 };
setRef(ctx, key, { ...ref, picks: old?.picks ?? null }, { name, unit });
showToast("연결함 — 새 산근 단가는 [저장] 뒤에 채워짐", "success");
},
});
},
(error: unknown) => showToast(`로직 입력을 못 읽음 — ${String(error)}`, "error"),
);
}
function pickLogic(ctx: TabContext, key: string): void {
openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름));
}
type Leaf = [string, () => void];
/** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */
function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] {
const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null;
const more: Leaf[] = [];
if (ref?.tab === "logic")
more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]);
const picks: Leaf[] = [
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
];
return [picks, more];
}
/** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */
export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
const row = rows[i];
if (!row) return [];
const key = String(row.key);
const bill = billOf(ctx.user);
const items: MapContextMenuItem[] = [];
if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]);
items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]);
if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]);
if (row.hand) {
if (row.kind !== "group") {
const [picks, more] = linkItems(ctx, key);
items.push("-", { label: "연결", children: picks }, ...more);
}
items.push("-", ...levelMenu(ctx, rows, i));
items.push("-", ["줄 지우기", () => removeRow(ctx, key)]);
} else if (row.kind === "item" && key in bill.qty) {
items.push("-", [
"수량 자동으로 되돌리기",
() => {
delete bill.qty[key];
put(ctx, bill);
},
]);
}
return items;
}
let shared: MapContextMenu | null = null;
/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */
let lastCell: Element | null = null;
/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */
export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void {
const row = rows[i];
const box = lastCell?.getBoundingClientRect();
if (!shared || !box || !row?.hand || row.kind !== "item") return;
const [picks, more] = linkItems(ctx, String(row.key));
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;
if (target.closest("input")) return;
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);
});
}