- Calc_Bill 짝(py · ts) — user.bill qty 덮어쓰기 · 손 줄 끼우기 · ref_price 하나(dg · sg · logic) - Sangeun — 손 줄 M01 로직 = 새 산근(사본 뒤 번호) · sg_hand · 손 줄 자원 키 - Store — bill 다듬기 · Router — 단가 수요에 손 줄 로직 · 호표 목록 넘김 - UI_Shell 짝 셈에 호표 목록 · 새 UI_Tabs_Bill_Edit(우클릭 메뉴 · 고르기 창) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
243 lines
10 KiB
TypeScript
243 lines
10 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 창 재사용
|
|
* ========================================================================== */
|
|
|
|
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"];
|
|
|
|
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);
|
|
}
|
|
|
|
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종) */
|
|
function slot(rows: DataRow[], i: number, below: boolean): { section: string; after: string } {
|
|
const row = rows[i];
|
|
const section = String(row.section);
|
|
if (row.kind === "title") return { section, after: `sec:${section}` };
|
|
if (below) return { section, after: String(row.key) };
|
|
const prev = rows[i - 1];
|
|
const same = prev && prev.kind !== "title" && prev.section === section;
|
|
return { section, after: same ? String(prev.key) : `sec:${section}` };
|
|
}
|
|
|
|
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
|
|
const bill = billOf(ctx.user);
|
|
const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
const at = slot(rows, i, below);
|
|
bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null });
|
|
put(ctx, bill);
|
|
}
|
|
|
|
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 옮김 */
|
|
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;
|
|
put(ctx, bill);
|
|
}
|
|
|
|
/** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */
|
|
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.이름));
|
|
}
|
|
|
|
/** 줄 우클릭 메뉴 — 위 · 아래 줄 추가 · (손 줄) 연결 · 연결 풀기 · 지우기 · (자동 줄) 수량 자동으로 */
|
|
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.hand) {
|
|
const ref: Row | null = bill.rows.find((h) => h.key === key)?.ref ?? null;
|
|
items.push("-", {
|
|
label: "연결",
|
|
children: [
|
|
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
|
|
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
|
|
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
|
|
],
|
|
});
|
|
if (ref?.tab === "logic")
|
|
items.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
|
|
if (ref) items.push(["연결 풀기", () => setRef(ctx, key, null)]);
|
|
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;
|
|
|
|
/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */
|
|
export function attachRowMenu(
|
|
root: HTMLElement,
|
|
menu: (index: number) => MapContextMenuItem[],
|
|
): void {
|
|
shared ??= createMapContextMenu("b09-estimation");
|
|
const ctxMenu = shared;
|
|
if (!ctxMenu.element.isConnected) {
|
|
ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
|
|
document.body.append(ctxMenu.element);
|
|
}
|
|
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);
|
|
});
|
|
}
|