Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts
T
eomsangdonandClaude Opus 5.5 86f40dedbd fix(705-2 붙여넣기 · 다시 잇기): 화면 검증에서 나온 흠 — 첫 줄 머리 짐작 · 열 짝 한 칸 밀림 · 저절로 고름 · 칸 폭 · B09 최신 캐시
- 첫 줄이 수량 없음이어도 금액 없으면(레미콘 별도계산) 줄 — 머리는 금액 있을 때만
- 기억한 열 짝을 다른 꼴(엑셀꼴 호표 칸 · STmate 꼴)에 붙이면 서버가 한 칸 밀어 맞춤
- 다시 잇기 저절로 고름 = 후보 하나 · 붙인 단가 있음만(별도계상 0 원 · 후보 여럿은 사람이 고름) · 빈 칸 글 없앰 · 고름 칸 앞으로 · 칸 폭 1100 안
- B09 알림에서 연 다시 잇기가 붙이기 전 캐시를 읽던 흠 — 붙인 뒤 캐시를 들고 감

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:42:28 +09:00

394 lines
16 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Fold.ts
* B09 설계내역서 줄 펼침(PLAN 702-1 · 702-2) — 내역 줄 누름 = 표 아래에 그 줄의 일위대가 구성.
*
* 값 = 일위대가표와 같은 셈(`sheets.daega_blocks` · `B09_Estimation_Calc_Labor`) — 펼친 값 = 일위대가표 값
* 줄 = 호표 줄(품명 · 규격 · 품 · 수량 · 단위 · 노 · 재 · 경 단가 · 금액 · 품셈 값) · 부른 대가 = 그 아래 「ㄴ」(3단까지)
* 고친 칸 덮개(702-2) = 품 · 수량 · 단가 칸 두 번 눌러 고침 → 사용자 값 `daega_cover`
* {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 ·
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
* 엑셀 붙여넣기(705-2) = 표를 누르고 Ctrl+V → 서버가 붙인 글을 읽어 지금 호표 끝 고정 줄(덮개 `@add` · 캐시 ·
* [저장] 때 서버가 다시 셈) · 열 짝 = M02 와 같은 물음 · 기억 · 알림 누름 = 「이 공사 단가로 다시 잇기」(이 공사 단가 책)
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { createCard } from "@ui/ui_template_card";
import { createText } from "@ui/ui_template_text";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { cellColumn, createDataTable, type DataRow } from "@ui/ui_template_data_table";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { Row } from "./B09_Estimation_Calc_Base";
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu";
import { API_BASE_URL } from "@config/config_frontend";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_toast";
import { currentProjectId } from "../A00_Common/b_project_switch";
import { ADD } from "./B09_Estimation_Calc_Labor";
import { withCols } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Paste";
import {
openRelink,
type RelinkSource,
} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink";
import { relinkRows, type PasteCols } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
/** 고칠 칸 → 덮개 칸 */
const FIELD: Record<string, string> = {
qty: "qty",
labor_price: "labor",
material_price: "material",
expense_price: "expense",
};
const LABEL: Record<string, string> = { qty: "품", labor: "노", material: "재", expense: "경" };
/** 부른 대가 펼침 깊이 */
const DEPTH = 3;
/** 펼친 줄 — 덮개 자리(블록 cover · 줄 cid) + 셈 줄 */
interface FoldLine {
cover: string | null;
line: Row;
level: number;
}
/** 지금 펼친 내역 줄 키(다시 그려도 그대로) */
let open: string | null = null;
const plain = (v: unknown): string =>
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 */
function baseText(line: Row): string {
const covered: string[] = line.covered || [];
if (!covered.length) return "같음";
return covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · ");
}
function linesOf(block: Row, blocks: Map<string, Row>, level: number, trail: string[]): FoldLine[] {
const out: FoldLine[] = [];
for (const line of block.lines || []) {
out.push({ cover: block.cover ?? null, line, level });
const sub = line.kind === "daega" ? blocks.get(line.key) : undefined;
if (sub && level < DEPTH && !trail.includes(sub.id))
out.push(...linesOf(sub, blocks, level + 1, [...trail, sub.id]));
}
return out;
}
/** 덮개 한 칸 고침 · value null = 그 칸 품셈 값으로 */
function setCover(ctx: TabContext, at: FoldLine, field: string, value: number | null): void {
if (!at.cover) return;
const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
const lines: Row = all[at.cover] || {};
const cell: Row = { ...(lines[at.line.cid] || {}) };
if (value === null) delete cell[field];
else cell[field] = value;
if (Object.keys(cell).length) lines[at.line.cid] = cell;
else delete lines[at.line.cid];
if (Object.keys(lines).length) all[at.cover] = lines;
else delete all[at.cover];
ctx.setUser({ ...ctx.user, daega_cover: all });
}
/** 줄 하나 덮개 모두 지움 · 여럿이면 한 번에(다시 그림 한 번) */
function clearCovers(ctx: TabContext, ats: FoldLine[]): void {
const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
for (const at of ats) {
if (!at.cover || !all[at.cover]) continue;
delete all[at.cover][at.line.cid];
if (!Object.keys(all[at.cover]).length) delete all[at.cover];
}
ctx.setUser({ ...ctx.user, daega_cover: all });
}
const covered = (at: FoldLine | undefined, field: string): boolean =>
!!at && (at.line.covered || []).includes(field);
function foldCard(
ctx: TabContext,
raw: DataRow[],
row: Row,
block: Row,
close: () => void,
go: (key: string) => void,
): HTMLElement {
const blocks = new Map<string, Row>(
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]),
);
const lines = linesOf(block, blocks, 1, [block.id]);
const rows: DataRow[] = lines.map(({ line, level }) => ({
key: line.cid,
name: `${level > 1 ? "ㄴ " : ""}${line.name || ""}`,
spec: line.spec || "",
qty: line.qty,
unit: line.unit || "",
labor_price: line.prices?.labor,
material_price: line.prices?.material,
expense_price: line.prices?.expense,
amount: line.amount,
base: baseText(line),
note: line.ref_no || line.why || "",
}));
const edited = lines.filter((at) => (at.line.covered || []).length);
const columns = [
text("name", "품명", 200),
text("spec", "규격", 140),
qty("qty", "품 · 수량"),
text("unit", "단위", 48),
money("labor_price", "노무비", ["단가"], 1),
money("material_price", "재료비", ["단가"], 1),
money("expense_price", "경비", ["단가"], 1),
money("amount", "금액", undefined, 1),
text("base", "품셈 값", 150),
text("note", "비고", 120),
];
const table = createDataTable({
columns,
rows,
total: { name: "합 계", amount: block.sum, note: block.blank ? `빈 줄 ${block.blank}` : "" },
frozen: 1,
empty: "",
editOnDouble: true,
onCellClick: () => undefined, // 줄 번호 표식(data-i) — 두 번 눌러 고침 · 우클릭 · 풍선 자리
editable: (i, key) => {
const at = lines[i];
if (!at?.cover || !(key in FIELD)) return false;
return at.line.kind !== "pct" || key === "qty"; // % 줄 = 율만
},
onEdit: (i, key, value) => {
const at = lines[i];
const field = FIELD[key];
const v = parseNumber(value);
if (!at || !field || v === undefined) return;
const base = covered(at, field)
? at.line.base?.[field]
: field === "qty"
? at.line.qty
: at.line.prices?.[field];
if (v === null || v === base) {
if (covered(at, field)) setCover(ctx, at, field, null); // 빈칸 · 셈 값 = 품셈 값으로
return;
}
setCover(ctx, at, field, v);
},
cellClass: (i, key) => (covered(lines[i], FIELD[key]) ? "is-edited" : null),
level: { key: "name", of: (i) => lines[i]?.level },
});
table.root.dataset.b09Fold = "";
attachHoverInfo(
table.root,
(cell) => {
const key = columns[cellColumn(cell as HTMLTableCellElement)]?.key ?? "";
const at = lines[Number(cell.parentElement?.dataset.i)];
const field = FIELD[key];
if (!at || !field || !covered(at, field)) return null;
return `고친 칸 — 품셈 값 ${plain(at.line.base?.[field])} · 우클릭 [품셈 값으로]`;
},
"tr[data-i] td",
);
attachRowMenu(table.root, (i) => {
const at = lines[i];
const undo: MapContextMenuItem[] =
at && (at.line.covered || []).length
? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"]
: [];
return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)];
});
const actions = [
...(edited.length
? [
createButton({
label: `고친 칸 ${edited.length}줄 모두 품셈 값으로`,
variant: "ghost",
size: "sm",
onClick: () => clearCovers(ctx, edited),
}),
]
: []),
createButton({ label: "접기", variant: "ghost", size: "sm", onClick: close }),
];
const card = createCard({
title: `${row.name || ""} — 일위대가 구성 (대가${block.no} ${block.name || ""})`,
actions,
pad: 12,
body: [
table.root,
createText(
"품 · 수량 · 단가 칸을 두 번 눌러 고침 · 색 칸 = 고친 칸(이 공사만 · 새 단가 판 · [재적용] 뒤에도 남음) · 우클릭 [품셈 값으로] · [저장] 때 서버가 다시 셈",
"muted",
),
],
});
card.classList.add("b09-fold");
if (block.cover) {
table.root.tabIndex = 0; // 705-2 — 눌러 고르면 Ctrl+V 받음
card.addEventListener("paste", (ev) => {
if ((ev.target as Element | null)?.closest("input, textarea")) return;
const text = ev.clipboardData?.getData("text/plain") ?? "";
if (!text.trim()) return;
ev.preventDefault();
void pasteFold(ctx, block.cover, text);
});
}
return card;
}
async function post(path: string, body: Row): Promise<Row> {
const response = await fetch(
`${API_BASE_URL}/projects/${currentProjectId()}/estimation/${path}`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const got = ((await response.json().catch(() => null)) as Row | null) ?? {};
if (!response.ok)
throw new Error(typeof got.detail === "string" ? got.detail : `HTTP ${response.status}`);
return got;
}
const COST_PART = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const;
/** 붙인 줄 다시 잇기(705-2) — 후보 = 서버(M02 이음 짝) · 단가 = 이 공사 단가 책 · 바꿈 = 캐시 + 서버 셈 */
function relinkFold(ctx: TabContext, cover: string, from: Row): RelinkSource {
let now = from; // 붙인 뒤 캐시(ctx.user 는 그리기 전 값) — 바꿀 때마다 새 값
const added = (): Row[] => ((now.daega_cover || {})[cover] || {})[ADD] || [];
const book = (kind: "노임" | "자재", key: string): number | null | undefined =>
(ctx.payload.inputs?.[kind === "노임" ? "labor" : "materials"] || []).find(
(r: Row) => r.key === key,
)?.price;
return {
title: "이 공사 단가로 다시 잇기 — 붙인 줄",
priceLabel: "이 공사 단가",
price: book,
rows: () =>
relinkRows(
{ layer: "personal", owner: null },
{
문서: {
담은로직: added()
.filter((ln) => ln.kind === "fixed")
.map((ln) => ({
고정: {
이름: ln.name,
규격: ln.spec,
단위: ln.unit,
단가: Object.fromEntries(
Object.entries(COST_PART).map(([k, p]) => [k, ln.own?.[p] ?? 0]),
),
이음: { 노임: ln.link?.labor ?? null, 자재: ln.link?.material ?? null },
},
수량: { 손: ln.qty },
})),
},
},
),
apply: async (changes) => {
const lines: Row[] = JSON.parse(JSON.stringify(added()));
const back = [];
for (const c of changes) {
const ln = lines.find((x) => x.cid === `${ADD}|${c.줄}`);
if (!ln) continue;
back.push({
키: "",
줄: c.줄,
노임: ln.link?.labor ?? null,
자재: ln.link?.material ?? null,
});
ln.link = { ...(ln.link || {}) };
if ("노임" in c) ln.link.labor = c.노임 ?? null;
if ("자재" in c) ln.link.material = c.자재 ?? null;
}
const all: Row = JSON.parse(JSON.stringify(now.daega_cover || {}));
all[cover] = { ...(all[cover] || {}), [ADD]: lines };
const user = { ...now, daega_cover: all };
now = user;
Object.assign(ctx.payload, await post("calc", { user })); // 새 이음 키 = 이 공사 단가 책 수요
ctx.setUser(user);
return { back, note: `바뀐 줄 ${back.length} — [저장]으로 남김` };
},
};
}
/** 붙여넣기(705-2) — 붙인 글 그대로 서버로 · 답 = 새 캐시 + 그 캐시로 셈한 원가 자료 */
async function pasteFold(ctx: TabContext, cover: string, text: string): Promise<void> {
showLoadingOverlay();
try {
const got = await withCols(
(cols: PasteCols | null) =>
post("daega/paste", {
cover,
글: text,
...(cols ? { 열: cols } : {}),
user: ctx.user,
}) as Promise<Row & { 갈래: "줄" }>,
);
if (!got) return;
Object.assign(ctx.payload, got.payload);
ctx.setUser(got.user);
const won = Math.round(Number(got.엑셀) || 0).toLocaleString("ko-KR");
showToast(
`고정 줄 ${got.줄} 붙임 · 엑셀 합계 ${won} · 이음 ${got.이음} · 후보 ${got.이음후보} — [저장]으로 남김 · 이 알림을 누르면 이 공사 단가로 다시 잇기`,
"success",
9000,
() => void openRelink(relinkFold(ctx, cover, got.user)),
);
} catch (error) {
showToast(`붙여넣기 실패 — ${error instanceof Error ? error.message : String(error)}`, "error");
} finally {
hideLoadingOverlay();
}
}
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
const host = document.createElement("div");
/** 어디 쓰였나 → 그 줄로(보이게 · 펼침) */
const go = (key: string): void => {
const i = raw.findIndex((r) => String(r.key) === key);
table.querySelector(`tr[data-i="${i}"]`)?.scrollIntoView({ block: "center" });
open = key;
draw();
};
const draw = (): void => {
const row = raw.find((r) => r.key === open) as Row | undefined;
const ref: Row | undefined = row?.ref;
const block =
ref?.tab === "dg"
? (ctx.sheets.daega_blocks || []).find((b: Row) => String(b.key) === String(ref.key))
: undefined;
host.replaceChildren(
...(row && block
? [
foldCard(
ctx,
raw,
row,
block,
() => {
open = null;
draw();
},
go,
),
]
: []),
);
};
table.addEventListener("click", (event) => {
const target = event.target as Element;
if (target.closest("input, textarea")) return;
const tr = target.closest<HTMLElement>("tr[data-i]");
if (!tr || !table.contains(tr)) return;
const row = raw[Number(tr.dataset.i)] as Row | undefined;
open = row?.ref?.tab === "dg" ? String(row.key) : null;
draw();
});
draw();
return host;
}