- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 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
284 lines
9.8 KiB
TypeScript
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);
|
|
},
|
|
};
|