# Conflicts: # B09_Estimation/B09_Estimation_UI_Page.css # B09_Estimation/B09_Estimation_UI_Shell.ts # B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts
388 lines
16 KiB
TypeScript
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);
|
|
});
|
|
}
|