- 719-4 근거 탭 = 산근 블록마다 카드 · 공용 calcSheetRows 묶음 꼴(인력 · 재료 · 경비(장비 한 줄 + 중기사용료 펼침 넉 줄) · 하위 = 산근 n호표 한 줄) · 서버 줄 덧칸 sheet{쓰는입력 · 기준단위 · 환산근거}(master_sheet_line 한 벌) · 블록 기준단위 · 입력 · 중간값 글은 고치기 펼침 때 · 옛 풀이표는 곁 창에만
- 719-3 산근 덮개 sg_cover {산근 키: {「n:이름」: {qty · 노 · 재 · 경 단가}}} · 셈 짝 B09_Estimation_Sangeun_Cover.py ↔ .ts(서버 풀이 뒤 덧씌움 · 하위 산근 이어짐 · % 덧줄 밑수 비례 · 총계 · 목록표 · 빈칸 목록 · raw 로 두 번 덮어도 같음 · 짝 잃음 sg_orphans 알림)
- 고침 표(702 꼴) = 품 · 단가 두 번 눌러 고침 · 고친 칸 색 · 원래 값 칸 · 「이 공사에서 고침」 꼬리표 · 우클릭 · 카드 단추 [단가산출 값으로] · 고친 뒤 보던 자리 지킴
- 저장 다듬기 sg_cover · [업데이트] 알림 고친 칸 수에 산근 칸 · 셈 길(compute_sheets) · 화면 짝(computeSheets) 둘 다 덮개
- 금액 전후 같음 — 덮개 없음 = 서버 풀이 그대로 · 덧칸 뺀 옛 풀이와 13 블록 같음 · 일위대가 합 검증_랩탑메인 13,662,560 · 검증_sub5 10,149,562 그대로
- 시험 새로 test_719_3_sangeun_cover(8 · 거울 · 대가 · 원가계산서 · 업데이트 뒤 지킴) · test_719_4_sangeun_sheet(2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tabs_Sangeun_Sheet.ts
|
|
* B09 단가산출근거 탭(PLAN 719-4 · 719-3) — 산근 블록마다 카드 한 장 = 공용 「단가산출 계산서」(714-2
|
|
* `calcSheetRows` 묶음 꼴 · M01 「인력·재료·경비」 · M02 와 같은 꼴) + 품 · 단가 고침 표(702 꼴).
|
|
*
|
|
* 값 = 서버 풀이(`B09_Estimation_Sangeun`) + 이 공사 고친 칸 덮개(`sg_cover` · 짝 `…_Sangeun_Cover`) —
|
|
* 화면은 받은 줄을 계산서 줄 꼴로 옮기기만(셈 없음)
|
|
* 계산서 = 노무비 · 재료비 · 경비 묶음 → 공용 부품이 인력 · 재료 · 경비(장비) · 하위로 다시 묶음 ·
|
|
* 중기 줄 = 장비 한 줄(노 · 재 · 경) + 펼침(중기사용료 블록 손료 · 운전사 · 연료 · 잡품) ·
|
|
* 하위 산근 = 「산근 n호표」 한 줄 · 묶음 머리 = 기준단위 · 환산 근거(서버 줄 덧칸 `sheet`)
|
|
* 고침(719-3) = [품 · 단가 고치기] 표에서 두 번 눌러 고침 → `sg_cover` 캐시 · 짝이 바로 다시 셈 ·
|
|
* [저장] 때 서버가 정본으로 다시 셈 · 고친 칸 색(`is-edited`) + 「원래 값」 칸 + 「이 공사에서 고침」 ·
|
|
* 우클릭 · 카드 단추 [단가산출 값으로] = 덮개 지움(셈 값 = 단가산출 값) · 빈칸 입력도 되돌림
|
|
* 옛 표 꼴(`buildSg`)은 곁 창(행 누름 팝업)에만 남음
|
|
* ========================================================================== */
|
|
|
|
import { createBanner } from "@ui/ui_template_banner";
|
|
import { createCard } from "@ui/ui_template_card";
|
|
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
|
import {
|
|
cellColumn,
|
|
createDataTable,
|
|
fillViewport,
|
|
type DataRow,
|
|
} from "@ui/ui_template_data_table";
|
|
import { createButton, el } from "@ui/ui_template_elements";
|
|
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
|
import { createText } from "@ui/ui_template_text";
|
|
import { calcSheetRows, type SheetGroup, type SheetLine } from "../A00_Common/calc_sheet";
|
|
import type { Row } from "./B09_Estimation_Calc_Base";
|
|
import { fields, lineId } from "./B09_Estimation_Sangeun_Cover";
|
|
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
|
|
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
|
|
|
const PARTS = ["labor", "material", "expense"] as const;
|
|
const COST: Record<string, string> = { labor: "노무비", material: "재료비", expense: "경비" };
|
|
export const TAG = "이 공사에서 고침";
|
|
const BACK = "단가산출 값";
|
|
const LABEL: Record<string, string> = {
|
|
qty: "품",
|
|
labor: "노무비",
|
|
material: "재료비",
|
|
expense: "경비",
|
|
};
|
|
const FIELD: Record<string, string> = {
|
|
qty: "qty",
|
|
labor_price: "labor",
|
|
material_price: "material",
|
|
expense_price: "expense",
|
|
};
|
|
|
|
const fmt = (v: unknown): string =>
|
|
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
|
|
|
|
/* ── 순수 — 산근 블록 → 계산서 묶음 ── */
|
|
/** 줄이 쓰는 비목 */
|
|
export function partsOf(ln: Row): string[] {
|
|
if (ln.kind === "labor") return ["labor"];
|
|
if (ln.kind === "machine" || ln.kind === "sangeun") return [...PARTS];
|
|
const own = Object.keys(ln.price || {}).filter((p) => (PARTS as readonly string[]).includes(p));
|
|
if (own.length) return own;
|
|
const blank = PARTS.filter((p) => (ln[p] ?? null) === null);
|
|
return blank.length ? blank : ["material"];
|
|
}
|
|
|
|
/** 고친 줄 원래 값 글 — 「품 0.12 · 노무비 268,908」 */
|
|
export const baseText = (ln: Row): string =>
|
|
((ln.covered || []) as string[]).map((k) => `${LABEL[k]} ${fmt(ln.base?.[k])}`).join(" · ");
|
|
|
|
function lineText(ln: Row, part: string): string {
|
|
const back = ln.covered?.length ? ` (원래 ${baseText(ln)})` : "";
|
|
if (ln.kind === "extra")
|
|
return ln.unit === "%"
|
|
? `${String(ln.spec || "").replace("%", `${fmt(ln.qty)}%`)}${back} = `
|
|
: `${ln.spec || ""}${back} = `;
|
|
return `${fmt(ln.price?.[part] ?? null)} * ${fmt(ln.qty)}${back} = `;
|
|
}
|
|
|
|
/** 산근 블록 → 공용 계산서 묶음(노무비 · 재료비 · 경비) · 계 */
|
|
export function sheetGroups(
|
|
block: Row,
|
|
machines: Row[],
|
|
noOf: Record<string, number>,
|
|
): SheetGroup[] {
|
|
const byKey = new Map(machines.map((m) => [m.key as string, m]));
|
|
return PARTS.map((part): SheetGroup => {
|
|
const lines: SheetLine[] = [];
|
|
for (const ln of (block.lines || []) as Row[]) {
|
|
if (!partsOf(ln).includes(part)) continue;
|
|
const sub = ln.kind === "sangeun" ? noOf[ln.ref?.key] : undefined;
|
|
const name = `${ln.name || ""}${sub ? ` (산근 ${sub}호표)` : ""}${ln.covered?.length ? ` — ${TAG}` : ""}`;
|
|
const m = ln.kind === "machine" ? byKey.get(ln.ref?.key) : undefined;
|
|
lines.push({
|
|
이름: name,
|
|
글: lineText(ln, part),
|
|
금액: ln[part] ?? null,
|
|
...(ln.kind === "machine" ? { 묶음: "경비", 장비: true } : {}),
|
|
...(ln.kind === "sangeun" ? { 묶음: "하위" } : {}),
|
|
쓰는입력: ln.sheet?.쓰는입력 ?? [],
|
|
기준단위: ln.sheet?.기준단위 ?? null,
|
|
환산근거: ln.sheet?.환산근거 ?? "",
|
|
펼침: m
|
|
? {
|
|
키: m.key,
|
|
이름: `${m.name}${m.spec ? ` ${m.spec}` : ""}`,
|
|
단위: m.unit || "시간",
|
|
줄: ((m.lines || []) as Row[]).map((x) => ({
|
|
비목: COST[x.kind] ?? x.kind,
|
|
이름: x.name,
|
|
글: `${fmt(x.price)} * ${fmt(x.qty)}${x.unit === "%" ? "%" : ""} = `,
|
|
금액: x.amount ?? null,
|
|
})),
|
|
계: m.sum ?? null,
|
|
}
|
|
: null,
|
|
});
|
|
}
|
|
return { 비목: COST[part], 소계: block[part] ?? null, 줄: lines };
|
|
}).filter((g) => g.줄.length);
|
|
}
|
|
/* ── 순수 끝 ── */
|
|
|
|
const opened = new Set<string>(); // 고침 표 · 입력 펼친 블록(다시 그려도 그대로)
|
|
let lastTop = 0; // 고친 뒤 다시 그려도 보던 자리
|
|
|
|
function setCover(
|
|
ctx: TabContext,
|
|
key: string,
|
|
lid: string,
|
|
field: string,
|
|
value: number | null,
|
|
): void {
|
|
const all: Row = JSON.parse(JSON.stringify(ctx.user.sg_cover || {}));
|
|
const lines: Row = all[key] || {};
|
|
const cell: Row = { ...(lines[lid] || {}) };
|
|
if (value === null) delete cell[field];
|
|
else cell[field] = value;
|
|
if (Object.keys(cell).length) lines[lid] = cell;
|
|
else delete lines[lid];
|
|
if (Object.keys(lines).length) all[key] = lines;
|
|
else delete all[key];
|
|
ctx.setUser({ ...ctx.user, sg_cover: all });
|
|
}
|
|
|
|
function clearLines(ctx: TabContext, key: string, lids: string[]): void {
|
|
const all: Row = JSON.parse(JSON.stringify(ctx.user.sg_cover || {}));
|
|
for (const lid of lids) delete (all[key] || {})[lid];
|
|
if (all[key] && !Object.keys(all[key]).length) delete all[key];
|
|
ctx.setUser({ ...ctx.user, sg_cover: all });
|
|
}
|
|
|
|
/** 품 · 단가 고침 표(702 꼴) */
|
|
function editTable(ctx: TabContext, block: Row): HTMLElement {
|
|
const lines = (block.lines || []) as Row[];
|
|
const covered = (i: number, field: string | undefined): boolean =>
|
|
!!field && ((lines[i]?.covered || []) as string[]).includes(field);
|
|
const rows: DataRow[] = lines.map((ln, n) => ({
|
|
key: lineId(n, ln),
|
|
name: ln.name || "",
|
|
spec: ln.spec || "",
|
|
qty: ln.qty,
|
|
unit: ln.unit || "",
|
|
labor_price: partsOf(ln).includes("labor") ? (ln.price?.labor ?? null) : "",
|
|
material_price: partsOf(ln).includes("material") ? (ln.price?.material ?? null) : "",
|
|
expense_price: partsOf(ln).includes("expense") ? (ln.price?.expense ?? null) : "",
|
|
amount: PARTS.some((p) => (ln[p] ?? null) === null)
|
|
? null
|
|
: PARTS.reduce((s, p) => s + (ln[p] as number), 0),
|
|
base: ln.covered?.length ? baseText(ln) : "",
|
|
note: ln.covered?.length ? TAG : ln.why || "",
|
|
}));
|
|
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", "원래 값", 160),
|
|
text("note", "비고", 140),
|
|
];
|
|
const table = createDataTable({
|
|
columns,
|
|
rows,
|
|
total: { name: "총 계", amount: block.sum },
|
|
frozen: 1,
|
|
empty: "",
|
|
editOnDouble: true,
|
|
onCellClick: () => undefined, // 줄 표식(data-i) — 두 번 눌러 고침 · 우클릭 · 풍선 자리
|
|
editable: (i, key) => !!FIELD[key] && fields(lines[i] || {}).includes(FIELD[key]),
|
|
onEdit: (i, key, value) => {
|
|
const ln = lines[i];
|
|
const field = FIELD[key];
|
|
const v = parseNumber(value);
|
|
if (!ln || !field || v === undefined) return;
|
|
const was = covered(i, field)
|
|
? ln.base?.[field]
|
|
: field === "qty"
|
|
? ln.qty
|
|
: ln.price?.[field];
|
|
if (v === null || v === was) {
|
|
if (covered(i, field)) setCover(ctx, block.key, lineId(i, ln), field, null);
|
|
return;
|
|
}
|
|
setCover(ctx, block.key, lineId(i, ln), field, v);
|
|
},
|
|
cellClass: (i, key) => (covered(i, FIELD[key]) ? "is-edited" : null),
|
|
});
|
|
table.root.dataset.b09SgEdit = block.key;
|
|
attachHoverInfo(
|
|
table.root,
|
|
(cell) => {
|
|
const field = FIELD[columns[cellColumn(cell as HTMLTableCellElement)]?.key ?? ""];
|
|
const i = Number(cell.parentElement?.dataset.i);
|
|
if (!covered(i, field)) return null;
|
|
return `${TAG} — ${BACK} ${fmt(lines[i].base?.[field])} · 우클릭 [${BACK}으로]`;
|
|
},
|
|
"tr[data-i] td",
|
|
);
|
|
attachRowMenu(table.root, (i): MapContextMenuItem[] =>
|
|
lines[i]?.covered?.length
|
|
? [[`${BACK}으로`, () => clearLines(ctx, block.key, [lineId(i, lines[i])])]]
|
|
: [],
|
|
);
|
|
return table.root;
|
|
}
|
|
|
|
function blockCard(ctx: TabContext, block: Row, noOf: Record<string, number>): HTMLElement {
|
|
let card: HTMLElement | null = null;
|
|
const edited = ((block.lines || []) as Row[])
|
|
.map((ln, n) => (ln.covered?.length ? lineId(n, ln) : null))
|
|
.filter((x): x is string => !!x);
|
|
const sheet = el("div", { className: "b09-sg-sheet", attrs: { "data-sg-sheet": block.key } });
|
|
const groups = sheetGroups(block, ctx.sheets.machine_blocks || [], noOf);
|
|
const rows = groups.length
|
|
? calcSheetRows({
|
|
묶음: groups,
|
|
계: block.sum ?? null,
|
|
sumLabel: `계 (원/${block.unit || "식"})`,
|
|
bundle: true,
|
|
})
|
|
: [createText(block.why || "줄 없음", "warning")];
|
|
for (const r of rows)
|
|
if (r.firstElementChild?.textContent?.includes(TAG)) r.classList.add("is-edited");
|
|
sheet.append(...rows);
|
|
const heads = ((block.head || []) as string[]).slice(1).map((line) => createText(line, "hint"));
|
|
const edit = opened.has(block.key) ? editTable(ctx, block) : null;
|
|
heads.forEach((h) => (h.hidden = !opened.has(block.key)));
|
|
const toggle = createButton({
|
|
label: opened.has(block.key) ? "고치기 닫기" : "품 · 단가 고치기",
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => {
|
|
if (opened.has(block.key)) opened.delete(block.key);
|
|
else opened.add(block.key);
|
|
card?.replaceWith(blockCard(ctx, block, noOf)); // 그 카드만 다시(값 · 캐시 그대로)
|
|
},
|
|
});
|
|
const back = edited.length
|
|
? [
|
|
createButton({
|
|
label: `고친 칸 ${edited.length}줄 모두 ${BACK}으로`,
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => clearLines(ctx, block.key, edited),
|
|
}),
|
|
]
|
|
: [];
|
|
const meta = [
|
|
block.spec,
|
|
`1 ${block.unit || "식"} 당`,
|
|
block.source,
|
|
(block.uses || []).length ? `쓰는 곳 ${(block.uses as string[]).join(" · ")}` : "",
|
|
edited.length ? `${TAG} ${edited.length}줄` : "",
|
|
].filter(Boolean);
|
|
card = createCard({
|
|
title: `제 ${block.no} 호표 — ${block.name || ""}`,
|
|
actions: [...back, toggle],
|
|
pad: 12,
|
|
body: [
|
|
createText(meta.join(" · "), "muted"),
|
|
...heads,
|
|
sheet,
|
|
...(edit
|
|
? [
|
|
edit,
|
|
createText(
|
|
`품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [업데이트] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`,
|
|
"muted",
|
|
),
|
|
]
|
|
: []),
|
|
],
|
|
});
|
|
card.dataset.sgBlock = block.key;
|
|
return card;
|
|
}
|
|
|
|
/** 단가산출근거 탭 판 — focus = 목록표에서 온 산근 키(그 카드로 스크롤) */
|
|
export function renderSangeunSheets(ctx: TabContext, focus?: string): HTMLElement[] {
|
|
const blocks = (ctx.sheets.sg || []) as Row[];
|
|
if (!blocks.length)
|
|
return [
|
|
createText(((ctx.sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음", "muted", 14),
|
|
];
|
|
const noOf: Record<string, number> = Object.fromEntries(blocks.map((b) => [b.key, b.no]));
|
|
const orphans = (ctx.sheets.sg_orphans || []) as Row[];
|
|
const banner = orphans.length
|
|
? [
|
|
createBanner({
|
|
tone: "warning",
|
|
text: `짝 잃은 고친 칸 ${orphans.length} — 로직이 바뀌어 줄을 못 찾음(덮개는 남음 · 값은 단가산출 값)`,
|
|
}),
|
|
]
|
|
: [];
|
|
const list = el("div", {
|
|
className: "b09-sg-sheets",
|
|
children: blocks.map((b) => blockCard(ctx, b, noOf)),
|
|
});
|
|
let placed = false; // 첫 높이 맞춤 뒤 한 번 — 목록표에서 온 블록 · 아니면 보던 자리
|
|
fillViewport(list, () => {
|
|
if (placed) return;
|
|
placed = true;
|
|
const target = focus && list.querySelector(`[data-sg-block="${CSS.escape(focus)}"]`);
|
|
if (target) target.scrollIntoView({ block: "start" });
|
|
else list.scrollTop = lastTop;
|
|
});
|
|
list.addEventListener("scroll", () => (lastTop = list.scrollTop), { passive: true });
|
|
const notes = ((ctx.sheets.sg_notes as string[]) || []).join(" · ");
|
|
return [...banner, list, createText(notes, "hint")];
|
|
}
|