Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts
T
eomsangdonandClaude Opus 5 85f7c9c6b3 feat(b09): 원가계산 화면 새 틀 — 설계내역서·일위대가·단가산출근거 탭(실무 서식 · 내역 → 호표 → 산근 들어가기)
- 틀 B09_Estimation_UI_Shell: 탭 줄과 등록만 · 탭마다 파일 하나(계약 _Shell_Types)
- 설계내역서: 실무 열(합계/노무/재료/경비 단가·금액) · 머리글 접기·레벨 고르개 · 줄 누르면 제 N 호표 · 단산 N 단추 · 미확정 빨간 테두리
- 일위대가·단가산출근거: 목록표(내역에 처음 쓰인 차례) + 본표 · 줄 누르면 하위 호표·산근·중기로 · 자취 눌러 되돌아감 · Q 식 글자 그대로
- 옛 탭 여섯(원가계산서·중기·관급사급·기초자료·설계서 구성·산출기초)은 옛 코드 그대로 이어 붙임 — 새 탭 파일이 서면 등록 한 줄씩 바꿈
- 본표 합계 줄 = 호표 성분 소계 원 미만 절사 값 · 비율 줄 금액도 0.1원 절사 표시
- 사전 ui_template_locale_b3 새 벌(b2 700줄 넘음)
- 검증: ORCA 검증 프로젝트 — 본체 122,848,989 · 지장목제거 865·15,460,837 · 제 9 호표 합계 1,708 = 산근 8호표 합계 1,708 · 옛 탭 여섯 다 뜸 · 접기 53→51·레벨1 11줄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:30:01 +09:00

234 lines
8.0 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tab_Bill.ts
* B09 설계내역서 탭 — STmate `wBoq` 본 (PLAN 12장 1차: 표시 + 들어가기)
*
* - 열은 실무 설계내역서 그대로: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계/노무비/재료비/경비(단가·금액) · 비고.
* - 머리글 줄은 ▸/▾ 로 접고, 「레벨」 고르개로 한 번에 접음(STmate `소계~총대계` 고르기의 우리 꼴).
* - ⭐ 줄을 누르면 그 줄 호표(일위대가) → 호표 안 줄을 누르면 단가산출근거로 들어감(브레인 판정 핵심).
* - 수동 단가로 선 줄 = 빨간 테두리 + 「미확정 N건」.
* - ⚠ 금액·합계는 서버가 실은 값 — 여기서 더하지 않음(머리글 줄 금액도 서버 합).
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import {
L,
el,
hint,
linkButton,
moneyCells,
numberCell,
quantity,
sheetHead,
sheetTable,
unconfirmedBadge,
won,
} from "./B09_Estimation_UI_Sheet";
import { loadBill, type BillDto, type BillRowDto } from "./B09_Estimation_UI_Store";
/** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */
const collapsed = new Set<string>();
let levelLimit = 0; // 0 = 모두
function rowMoney(row: BillRowDto): {
unit: [string, string, string, string] | null;
amount: [string, string, string, string] | null;
} {
const amount: [string, string, string, string] | null =
row.amount_krw === null
? null
: [row.amount_krw, row.labor_krw, row.material_krw, row.expense_krw];
if (row.is_group || row.unit_price_krw === null) return { unit: null, amount };
return {
unit: [
row.unit_price_krw,
row.unit_labor_krw ?? "",
row.unit_material_krw ?? "",
row.unit_expense_krw ?? "",
],
amount,
};
}
function isHidden(row: BillRowDto, rows: BillRowDto[]): boolean {
if (levelLimit > 0 && row.level > levelLimit) return true;
for (const group of rows) {
if (
group.is_group &&
collapsed.has(group.item_no) &&
row.item_no.startsWith(`${group.item_no}-`)
) {
return true;
}
}
return false;
}
function noteCell(row: BillRowDto, bill: BillDto, ctx: B09TabContext): HTMLTableCellElement {
const cell = el("td", "b09s-note");
const sheet = bill.unit_price_sheet.entries.find((entry) => entry.code === row.price_code);
if (sheet) cell.append(linkButton(sheet.label, () => ctx.open("unit_price", sheet.code)));
const basis = bill.price_basis.entries.filter((entry) =>
(entry.unit_price_codes ?? []).includes(row.price_code),
);
for (const entry of basis) {
cell.append(
linkButton(`${L("B09_Sheet_Basis_Short")} ${entry.number}`, () =>
ctx.open("price_basis", entry.code),
),
);
}
if (row.unconfirmed > 0) cell.append(unconfirmedBadge(row.unconfirmed));
// 비고 글 — 단산 번호는 위 단추로 보였으니 그 조각만 뺌.
const rest = row.notes
.map((note) => note.text)
.filter((text) => text && text !== row.price_basis_label)
.join(" / ");
if (rest) cell.append(el("span", "", rest));
return cell;
}
function drawRows(tbody: HTMLElement, bill: BillDto, ctx: B09TabContext, redraw: () => void): void {
for (const row of bill.rows) {
if (isHidden(row, bill.rows)) continue;
const tr = el("tr", row.is_group ? "is-group" : "");
const numberTd = el("td");
if (row.is_group) {
const toggle = el("button", "b09s-toggle", collapsed.has(row.item_no) ? "▸" : "▾");
toggle.type = "button";
toggle.addEventListener("click", (event) => {
event.stopPropagation();
if (collapsed.has(row.item_no)) collapsed.delete(row.item_no);
else collapsed.add(row.item_no);
redraw();
});
numberTd.append(toggle);
}
numberTd.append(document.createTextNode(row.item_no));
const name = el("td", "", row.name);
name.style.paddingLeft = `${6 + Math.max(0, row.level - 1) * 12}px`;
const { unit, amount } = rowMoney(row);
tr.append(
numberTd,
name,
el("td", "", row.spec),
numberCell(
row.is_group ? "" : quantity(row.quantity_shown ?? row.quantity, row.quantity_digits),
),
el("td", "", row.unit),
...moneyCells(unit, amount),
);
if (row.is_group) {
tr.append(el("td"));
} else {
tr.append(noteCell(row, bill, ctx));
if (row.unconfirmed > 0) tr.classList.add("is-manual");
if (row.price_code) {
tr.classList.add("is-clickable");
tr.title = L("B09_Sheet_Open_UnitPrice");
tr.addEventListener("click", () => ctx.open("unit_price", row.price_code));
}
}
tbody.append(tr);
}
}
function levelPicker(bill: BillDto, redraw: () => void): HTMLElement {
const select = el("select", "ui-input");
const deepest = Math.max(1, ...bill.rows.map((row) => row.level));
const all = el("option", "", L("B09_Sheet_Level_All"));
all.value = "0";
select.append(all);
for (let level = 1; level < deepest; level += 1) {
const option = el("option", "", `${level}${L("B09_Sheet_Level_Upto")}`);
option.value = String(level);
select.append(option);
}
select.value = String(levelLimit);
select.addEventListener("change", () => {
levelLimit = Number(select.value);
redraw();
});
const label = el("label", "b09s-bar", L("B09_Sheet_Level"));
label.append(select);
return label;
}
function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
const redraw = (): void => {
ctx.body.replaceChildren();
drawBill(ctx, bill, reload);
};
const bar = el("div", "b09s-bar");
bar.append(
createButton({ label: L("B09_Sheet_Reload"), variant: "ghost", onClick: reload }),
levelPicker(bill, redraw),
el(
"span",
"",
`${L("B09_Sheet_BodyTotal")} ${won(bill.summary.body_total_krw)}${L("B09_Sheet_Won")}`,
),
);
if (bill.summary.unconfirmed_count > 0)
bar.append(unconfirmedBadge(bill.summary.unconfirmed_count));
ctx.body.append(bar);
const { wrap, tbody } = sheetTable(
sheetHead([
L("B09_Sheet_Col_ItemNo"),
L("B09_Sheet_Col_Name"),
L("B09_Sheet_Col_Spec"),
L("B09_Sheet_Col_Quantity"),
L("B09_Sheet_Col_Unit"),
]),
);
drawRows(tbody, bill, ctx, redraw);
const sum = el("tr", "is-sum");
sum.append(el("td"), el("td", "", L("B09_Sheet_BodyTotal")), el("td"), el("td"), el("td"));
sum.append(...moneyCells(null, [bill.summary.body_total_krw, "", "", ""]), el("td"));
tbody.append(sum);
ctx.body.append(wrap);
// 금액을 못 세운 줄 — 0 으로 안 때우고 이름째 보임(서버 `missing`).
if (bill.summary.missing.length > 0) {
const details = el("details");
details.append(
el(
"summary",
"b09s-hint b09s-hint--warn",
`${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`,
),
);
for (const item of bill.summary.missing) {
details.append(hint(`${item.name}${item.reason}`));
}
ctx.body.append(details);
}
for (const note of bill.summary.notes) ctx.body.append(hint(note));
}
export const billTab: B09Tab = {
key: "boq",
label: () => L("B09_Estimation_Tab_Boq"),
render(ctx) {
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
const projectId = ctx.projectId;
const show = (force: boolean): void => {
ctx.body.replaceChildren(hint(L("B09_Sheet_Loading")));
loadBill(projectId, force)
.then((bill) => {
ctx.body.replaceChildren();
drawBill(ctx, bill, () => show(true));
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
showToast(L("B09_Sheet_LoadFailed"), "error");
});
};
show(false);
},
};