- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로 - 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
273 lines
9.3 KiB
TypeScript
273 lines
9.3 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));
|
|
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")}`,
|
|
),
|
|
);
|
|
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);
|
|
},
|
|
};
|