Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_Bill.ts
T
eomsangdonandClaude Opus 5 475ad7875e feat(b08·b05·b09): 10-A 우리가 정한 SW 규칙을 화면에 드러냄 — 훑기 21건 중 안 보이거나 반만 보이던 13건 채움 + 9-21 제근 굴착기 크기 칸
- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음」(등록부 default_basis 를 모든 칸 밑 회색 한 줄로) · ② 일반관리비 「임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음」 · ③ 모르타르 배합 「산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)」
- B08: 막자갈 비고 「칸을 새로 두는 것은 B05 등록부 몫」 · 일위대가 머리 5단 한도 · 야면석 계수 칸 밑 「돌 종류는 그대로」 · 식 저장 전 상태 줄 「제원을 바꿔도 따라감」 · 양식 장 머리 「반올림은 m당 값에 걸고 수량 = m당 × 연장」
- 산출 조건 제근 굴착기 크기 칸(임목축적 등급 밑 · 「안 정함」 · 회색 제안 0.7㎥ + 근거 + [제안값 넣기] 누른 때만) — 936be972 소림 + 0.7 → 뿌리뽑기 55원/㎡ × 17,873.80㎡ = 983,057원(되돌림)
- 라이브러리 이름표: [내 라이브러리에 저장]·[발행] 창이 장 이름(종류 + 제원 요약)을 채워 묻고 고친 이름으로 저장 · 취소면 안 씀 · 비우면 양식 이름
- B05 계곡 통과 시설 폼 머리 「[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸은 그대로 둠」 · B09 폐기물처리비 자리 칸 밑 「법정경비 밑수에는 안 넣음」 · 내역서 「금액을 못 세운 줄」 첫 줄 「사유는 아래 단계가 낸 것을 그대로 옮김」
- 남은 하나(표 형태 66표 까닭 form_basis 화면)는 자원 축 파일을 거쳐야 해 브레인 물음

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

284 lines
9.8 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";
import { rateForm, rateMark } from "./B09_Estimation_UI_BillRates";
/** 접힌 머리글 줄(공종 번호) — 탭을 오가도 남음. */
const collapsed = new Set<string>();
let levelLimit = 0; // 0 = 모두
/** 할증·일괄보정 칸을 켰는가 · 고른 줄(할증 칸 이름) — 2차 ④. */
let rateMode = false;
const selectedRates = new Set<string>();
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,
reload: () => void,
): HTMLTableCellElement {
const cell = el("td", "b09s-note");
const mark = ctx.projectId ? rateMark(ctx.projectId, row, reload) : null;
if (mark) cell.append(mark);
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,
reload: () => 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);
}
if (rateMode && !row.is_group && row.amount_krw !== null) {
// 할증 범위 「선택 항목만」 — 고른 줄의 할증 칸 이름을 모음.
const pick = el("input");
pick.type = "checkbox";
pick.checked = selectedRates.has(row.rate_key);
pick.addEventListener("click", (event) => event.stopPropagation());
pick.addEventListener("change", () => {
if (pick.checked) selectedRates.add(row.rate_key);
else selectedRates.delete(row.rate_key);
redraw();
});
numberTd.append(pick);
}
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, reload));
if (row.rate) tr.classList.add("is-user");
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));
// 치수 없이 기본값으로 선 줄 — 빨간 테두리 줄로 서고 금액·합계엔 안 듦(브레인 판정 2026-09-14).
if (bill.summary.unpriced_count)
bar.append(
el(
"span",
"b09s-badge",
`${L("B09_Sheet_Unconfirmed")} ${bill.summary.unpriced_count}${L("B09_Sheet_Count")}${L("B09_Sheet_Unpriced")}`,
),
);
const rateToggle = el("button", "b09s-undo", L("B09_Sheet_Rate"));
rateToggle.type = "button";
rateToggle.addEventListener("click", () => {
rateMode = !rateMode;
redraw();
});
bar.append(rateToggle);
ctx.body.append(bar);
if (rateMode && ctx.projectId) ctx.body.append(rateForm(ctx.projectId, selectedRates, reload));
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, reload);
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")}`,
),
// 10-A ⑳ 까닭은 그대로 올림(2026-09-14 브레인 규칙) — 규칙을 목록 머리에 드러냄.
hint(L("B09_Sheet_Missing_Passed")),
);
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);
},
};