Files
Aislo/B09_Estimation/B09_Estimation_UI_Page.ts
T
eomsangdonandClaude Opus 5 573d7bbae2 feat(B09): 예산내역서 화면 탭 신설 + ITEM NO. 겹침 수정
화면
- 「설계내역서」 탭을 켜고 [B08 수량 불러오기] 로 표를 세움.
  계층은 들여쓰기, 머리글 줄은 굵게
- 못 세운 줄은 0 으로 채우지 않고 **사유째** 표와 목록에 보임
  (「일위대가 없음」·「하위 공종에 있음(후보 2건)」·「밑수를 못 찾은 표」)
- 검산용 줄(보정량계·무대)은 수량만 보이고 금액칸이 빔
- ⚠ 「사급 자재 단가가 없어 자재비가 빠져 있음 — 지금 합계는 모자란 값」을
  합계 옆에 상시 표시

ITEM NO. 겹침 수정
- 같은 공종코드가 지반만 달리해 두 번 오면 번호가 겹치고 있었음
  (실물: 도자운반 토사/리핑암이 둘 다 `3-1`). 잎 줄은 번호를 재사용하지 않음

실측(공용 브라우저 5174, 운반이 실린 실물 자료):
  16줄 · 겹친 번호 0 · 합계 488,926원
  운반 4줄이 3-1~3-4 로 서고 무대 2줄은 검산줄로 빠짐 (㉡ 가 실물에서 돎)

검증: pytest 175 통과(신규 1), tsc 0건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 01:36:11 +09:00

983 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B09_Estimation_UI_Page.ts
* 로그인 후 09: 6차 워크플로우 (원가계산)
*
* 화면 규칙 (PLAN 8-13 · 화면 기획)
* - 3단 레이아웃: 상단 타이틀·스텝바 / 좌측 고정폭 입력 / 우측 탭 + 표.
* - 원가계산서 줄은 **「비목 · 금액 · 요율 · 산출근거」 네 칸**을 다 보인다.
* 결과 숫자만 보이면 설계자가 검산을 못 한다.
* - **안전관리비는 A·B 두 줄을 나란히 두고 채택한 쪽을 표시**한다
* (실무 `안전관리비검토` 시트와 같은 서식, PLAN 8-12).
* - **어느 판 요율로 계산했는지**를 좌측에 남긴다 — 재현성(PLAN 9-2).
* - 이윤 조정액은 **설계자가 직접 넣을 때만** 반영. 목표 도급액을 넣으면 필요액을
* 보여만 준다 (★법대로 PLAN 8-10).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/* -----------------------------------------------------------------------------
* 타입 — 라우터 응답과 1:1
* -------------------------------------------------------------------------- */
interface CostLineDto {
key: string;
name: string;
base_label: string;
base_amount_krw: string;
rate_percent: string | null;
flat_amount_krw: string;
amount_krw: string;
formula_text: string;
note: string;
}
interface CostSheetDto {
status: string;
direct_cost_source: "manual" | "quantities";
missing_unit_prices: string[];
lines: CostLineDto[];
totals: Record<string, string>;
rate_version: { dataset_id: string; effective_date: string; sha256: string };
notes: string[];
suggested_profit_adjustment_krw?: string;
}
interface UnitPriceRow {
code: string;
name: string;
spec: string;
unit: string;
material: string;
labor: string;
expense: string;
total: string;
}
interface UnitPriceListDto {
status: string;
summary: {
titles: number;
unit_prices: number;
machine_hourly: number;
notes: string[];
};
rows: UnitPriceRow[];
}
interface UnitPriceDetailRow extends UnitPriceRow {
ref_code: string;
source_label: string;
source_index: number;
drillable: boolean;
quantity: string;
unit_total: string;
note: string;
}
interface UnitPriceDetailDto {
status: string;
precise_total: string;
code: string;
name: string;
spec: string;
unit: string;
material: string;
labor: string;
expense: string;
total: string;
sum_matches: boolean;
rows: UnitPriceDetailRow[];
}
/** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */
interface CostFormState {
direct_material_krw: string;
direct_labor_krw: string;
direct_expense_krw: string;
duration_days: string;
owner_supplied_material_krw: string;
procurement_fee_krw: string;
profit_adjustment_krw: string;
target_contract_amount_krw: string;
/** 「공종코드=수량」 한 줄씩. 비어 있으면 위 직접비 3칸을 그대로 쓴다. */
quantities_text: string;
}
const INITIAL_FORM: CostFormState = {
direct_material_krw: "0",
direct_labor_krw: "0",
direct_expense_krw: "0",
duration_days: "183",
owner_supplied_material_krw: "0",
procurement_fee_krw: "0",
profit_adjustment_krw: "0",
target_contract_amount_krw: "",
quantities_text: "",
};
/** 총계 성격의 줄 — 표에서 굵게 띄운다. */
const TOTAL_KEYS = new Set([
"material_cost",
"labor_cost",
"expense",
"net_construction_cost",
"total_cost",
"contract_amount",
"grand_total",
]);
/* -----------------------------------------------------------------------------
* 스타일 — 공통 토큰만 사용 (frontend.md §1 하드코딩 금지)
* -------------------------------------------------------------------------- */
const STYLE_ID = "b09-estimation-styles";
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); }
.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); }
.b09-panel__legend {
font-size: var(--font-size-xs, 12px); letter-spacing: .06em;
color: var(--color-text-secondary); text-transform: uppercase;
}
.b09-panel__readonly {
font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary);
display: flex; justify-content: space-between; gap: var(--space-sm, 8px);
border-bottom: 1px solid var(--color-border); padding: 2px 0;
}
.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); }
.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); }
.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; }
.b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; }
.b09-tab {
font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer;
border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary);
}
.b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); }
.b09-tab:disabled { cursor: not-allowed; opacity: .55; }
.b09-sheet { overflow: auto; min-height: 0; flex: 1; }
.b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); }
.b09-sheet th, .b09-sheet td {
border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right;
white-space: nowrap; font-variant-numeric: tabular-nums;
}
.b09-sheet th { text-align: center; color: var(--color-text-secondary); font-weight: 600; }
.b09-sheet td.b09-left, .b09-sheet th.b09-left { text-align: left; white-space: normal; }
.b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); }
.b09-sheet tr.is-adopted td { background: var(--color-surface); }
.b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; }
.b09-qty { min-height: 64px; font-family: monospace; font-size: var(--font-size-xs, 12px); }
.b09-clickable { cursor: pointer; }
.b09-clickable:hover td { background: var(--color-surface); }
.b09-up-list { max-height: 45%; }
.b09-up-detail { border-top: 2px solid var(--color-border); padding-top: 6px; }
.b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); }
`;
document.head.append(style);
}
/* -----------------------------------------------------------------------------
* 표 그리기
* -------------------------------------------------------------------------- */
function formatWon(value: string): string {
const n = Number(value);
if (!Number.isFinite(n)) return value;
return n.toLocaleString("ko-KR");
}
function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-sheet";
const table = document.createElement("table");
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
const headers: Array<[string, boolean]> = [
[L("B09_Estimation_Col_Item"), true],
[L("B09_Estimation_Col_Amount"), false],
[L("B09_Estimation_Col_Rate"), false],
[L("B09_Estimation_Col_Basis"), true],
[L("B09_Estimation_Col_Note"), true],
];
for (const [text, left] of headers) {
const th = document.createElement("th");
th.textContent = text;
if (left) th.className = "b09-left";
headRow.append(th);
}
thead.append(headRow);
table.append(thead);
const tbody = document.createElement("tbody");
for (const line of sheet.lines) {
const tr = document.createElement("tr");
if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total");
if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted");
if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped");
const name = document.createElement("td");
name.className = "b09-left";
name.textContent = line.name;
const amount = document.createElement("td");
amount.textContent = formatWon(line.amount_krw);
const rate = document.createElement("td");
rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`;
const basis = document.createElement("td");
basis.className = "b09-left";
basis.textContent = line.formula_text;
const note = document.createElement("td");
note.className = "b09-left";
note.textContent = line.note;
tr.append(name, amount, rate, basis, note);
tbody.append(tr);
}
table.append(tbody);
wrap.append(table);
return wrap;
}
/** 일위대가 **목록표** — 「무엇이 있나」. 고르면 아래에 본표가 뜬다(9-3 제목+상세). */
function buildUnitPriceList(
list: UnitPriceListDto,
selected: string | null,
onPick: (code: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-sheet b09-up-list";
const caption = document.createElement("div");
caption.className = "b09-hint";
caption.textContent = `${L("B09_Estimation_UP_List")} · ${list.summary.unit_prices}`;
wrap.append(caption);
const table = document.createElement("table");
const head = document.createElement("tr");
for (const [key, left] of [
["B09_Estimation_Col_Name", true],
["B09_Estimation_Col_Unit", true],
["B09_Estimation_Col_Material", false],
["B09_Estimation_Col_Labor", false],
["B09_Estimation_Col_Expense", false],
["B09_Estimation_Col_Total", false],
] as Array<[keyof typeof ui_locales, boolean]>) {
const th = document.createElement("th");
th.textContent = L(key);
if (left) th.className = "b09-left";
head.append(th);
}
const thead = document.createElement("thead");
thead.append(head);
table.append(thead);
const body = document.createElement("tbody");
for (const row of list.rows) {
const tr = document.createElement("tr");
tr.className = "b09-clickable";
if (row.code === selected) tr.classList.add("is-adopted");
tr.addEventListener("click", () => onPick(row.code));
const name = document.createElement("td");
name.className = "b09-left";
name.textContent = row.name;
const unit = document.createElement("td");
unit.className = "b09-left";
unit.textContent = row.unit;
tr.append(name, unit);
for (const value of [row.material, row.labor, row.expense, row.total]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
tr.append(cell);
}
body.append(tr);
}
table.append(body);
wrap.append(table);
return wrap;
}
/** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */
function buildUnitPriceDetail(
detail: UnitPriceDetailDto,
onDrill: (code: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-sheet b09-up-detail";
const caption = document.createElement("div");
caption.className = "b09-hint";
caption.textContent =
`${L("B09_Estimation_UP_Detail")} · ${detail.name}` +
(detail.spec ? ` (${detail.spec})` : "") +
` · ${formatWon(detail.total)}` +
` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`;
wrap.append(caption);
// 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.**
// 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다.
if (detail.precise_total !== detail.total) {
const gap = document.createElement("div");
gap.className = "b09-hint";
gap.textContent = `${L("B09_Estimation_UP_RoundGap")} ${formatWon(detail.precise_total)}`;
wrap.append(gap);
}
const table = document.createElement("table");
const head = document.createElement("tr");
for (const [key, left] of [
["B09_Estimation_Col_Name", true],
["B09_Estimation_Col_Spec", true],
["B09_Estimation_Col_Source", true],
["B09_Estimation_Col_Unit", true],
["B09_Estimation_Col_Qty", false],
["B09_Estimation_Col_Material", false],
["B09_Estimation_Col_Labor", false],
["B09_Estimation_Col_Expense", false],
["B09_Estimation_Col_Total", false],
] as Array<[keyof typeof ui_locales, boolean]>) {
const th = document.createElement("th");
th.textContent = L(key);
if (left) th.className = "b09-left";
head.append(th);
}
const thead = document.createElement("thead");
thead.append(head);
table.append(thead);
const body = document.createElement("tbody");
for (const row of detail.rows) {
const tr = document.createElement("tr");
if (row.drillable) {
tr.className = "b09-clickable";
tr.title = L("B09_Estimation_UP_Drill");
tr.addEventListener("click", () => onDrill(row.ref_code));
}
const name = document.createElement("td");
name.className = "b09-left";
name.textContent = row.drillable ? `▸ ${row.name}` : row.name;
const spec = document.createElement("td");
spec.className = "b09-left";
spec.textContent = row.spec;
const source = document.createElement("td");
source.className = "b09-left";
source.textContent = `${row.source_label} (${row.source_index})`;
const unit = document.createElement("td");
unit.className = "b09-left";
unit.textContent = row.unit;
tr.append(name, spec, source, unit);
for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
tr.append(cell);
}
body.append(tr);
}
const sum = document.createElement("tr");
sum.className = "is-total";
const label = document.createElement("td");
label.className = "b09-left";
label.colSpan = 5;
label.textContent = L("B09_Estimation_Col_Total");
sum.append(label);
for (const value of [detail.material, detail.labor, detail.expense, detail.total]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
sum.append(cell);
}
body.append(sum);
table.append(body);
wrap.append(table);
return wrap;
}
/* -----------------------------------------------------------------------------
* 좌측 패널
* -------------------------------------------------------------------------- */
interface PanelHandles {
root: HTMLElement;
rateVersionBox: HTMLElement;
hintBox: HTMLElement;
}
function buildSidePanel(
form: CostFormState,
onRecalc: () => void,
onConfirm: () => void,
): PanelHandles {
const root = document.createElement("div");
root.className = "b09-panel";
const addGroup = (
legendKey: keyof typeof ui_locales,
fields: Array<[keyof CostFormState, keyof typeof ui_locales]>,
): void => {
const group = document.createElement("div");
group.className = "b09-panel__group";
const legend = document.createElement("span");
legend.className = "b09-panel__legend";
legend.textContent = L(legendKey);
group.append(legend);
for (const [field, labelKey] of fields) {
const handle = createInputField({
label: L(labelKey),
type: "number",
min: 0,
value: form[field],
onInput: (value) => {
form[field] = value;
},
});
group.append(handle.root);
}
root.append(group);
};
addGroup("B09_Estimation_Group_Condition", [
["direct_material_krw", "B09_Estimation_Field_DirectMaterial"],
["direct_labor_krw", "B09_Estimation_Field_DirectLabor"],
["direct_expense_krw", "B09_Estimation_Field_DirectExpense"],
["duration_days", "B09_Estimation_Field_Duration"],
]);
// 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다.
const rateGroup = document.createElement("div");
rateGroup.className = "b09-panel__group";
const rateLegend = document.createElement("span");
rateLegend.className = "b09-panel__legend";
rateLegend.textContent = L("B09_Estimation_Group_RateVersion");
const rateVersionBox = document.createElement("div");
rateGroup.append(rateLegend, rateVersionBox);
root.append(rateGroup);
addGroup("B09_Estimation_Group_Supplied", [
["owner_supplied_material_krw", "B09_Estimation_Field_OwnerMaterial"],
["procurement_fee_krw", "B09_Estimation_Field_ProcurementFee"],
]);
addGroup("B09_Estimation_Group_Profit", [
["profit_adjustment_krw", "B09_Estimation_Field_ProfitAdjust"],
["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"],
]);
// 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다.
const quantityGroup = document.createElement("div");
quantityGroup.className = "b09-panel__group";
const quantityLegend = document.createElement("span");
quantityLegend.className = "b09-panel__legend";
quantityLegend.textContent = L("B09_Estimation_Group_Quantity");
const quantityLabel = document.createElement("label");
quantityLabel.className = "ui-field__label";
quantityLabel.textContent = L("B09_Estimation_Field_Quantities");
const quantityInput = document.createElement("textarea");
quantityInput.className = "ui-input b09-qty";
quantityInput.rows = 4;
quantityInput.placeholder = "FP-09-21=500";
quantityInput.addEventListener("input", () => {
form.quantities_text = quantityInput.value;
});
quantityGroup.append(quantityLegend, quantityLabel, quantityInput);
root.append(quantityGroup);
const hintBox = document.createElement("div");
hintBox.className = "b09-hint";
root.append(hintBox);
const actions = document.createElement("div");
actions.className = "b09-panel__actions";
actions.append(
createButton({
label: L("B09_Estimation_Btn_Recalc"),
variant: "filled",
onClick: onRecalc,
}),
createButton({
label: L("B09_Estimation_Btn_Confirm"),
onClick: onConfirm,
}),
);
root.append(actions);
return { root, rateVersionBox, hintBox };
}
function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void {
box.replaceChildren();
if (!sheet) return;
const rows: Array<[string, string]> = [
["적용일", sheet.rate_version.effective_date || "—"],
["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}…` : "—"],
];
for (const [label, value] of rows) {
const row = document.createElement("div");
row.className = "b09-panel__readonly";
const left = document.createElement("span");
left.textContent = label;
const right = document.createElement("span");
right.textContent = value;
row.append(left, right);
box.append(row);
}
}
/* -----------------------------------------------------------------------------
* 탭
* -------------------------------------------------------------------------- */
const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
["cost_sheet", "B09_Estimation_Tab_CostSheet", true],
["boq", "B09_Estimation_Tab_Boq", true],
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
["machine", "B09_Estimation_Tab_Machine", false],
["duration", "B09_Estimation_Tab_Duration", false],
["supply", "B09_Estimation_Tab_Supply", false],
["base_data", "B09_Estimation_Tab_BaseData", false],
];
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement {
const bar = document.createElement("div");
bar.className = "b09-tabs";
for (const [key, labelKey, enabled] of TAB_KEYS) {
const button = document.createElement("button");
button.type = "button";
button.className = "b09-tab";
button.dataset.tab = key;
button.textContent = L(labelKey);
button.disabled = !enabled;
if (!enabled) button.title = L("B09_Estimation_Tab_Pending");
if (key === active) button.classList.add("is-active");
button.addEventListener("click", () => onSelect(key));
bar.append(button);
}
return bar;
}
/* -----------------------------------------------------------------------------
* API
* -------------------------------------------------------------------------- */
/** 「공종코드=수량」 여러 줄을 객체로. 형식이 아닌 줄은 조용히 버리지 않고 건너뛴다. */
function parseQuantities(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const [code, value] = trimmed.split(/[=\t,]/);
if (!code || !value) continue;
const qty = value.trim();
if (!/^\d+(\.\d+)?$/.test(qty)) continue;
out[code.trim()] = qty;
}
return out;
}
function toRequestBody(form: CostFormState): Record<string, unknown> {
const num = (value: string): string => (value.trim() === "" ? "0" : value.trim());
const body: Record<string, unknown> = {
direct_material_krw: num(form.direct_material_krw),
direct_labor_krw: num(form.direct_labor_krw),
direct_expense_krw: num(form.direct_expense_krw),
duration_days: Number(num(form.duration_days)),
owner_supplied_material_krw: num(form.owner_supplied_material_krw),
procurement_fee_krw: num(form.procurement_fee_krw),
profit_adjustment_krw: num(form.profit_adjustment_krw),
};
if (form.target_contract_amount_krw.trim() !== "") {
body.target_contract_amount_krw = form.target_contract_amount_krw.trim();
}
const quantities = parseQuantities(form.quantities_text);
if (Object.keys(quantities).length > 0) body.quantities = quantities;
return body;
}
async function fetchCostSheet(projectId: string, form: CostFormState): Promise<CostSheetDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toRequestBody(form)),
},
);
if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`);
return (await response.json()) as CostSheetDto;
}
async function fetchUnitPriceList(projectId: string): Promise<UnitPriceListDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`unit price list failed: ${response.status}`);
return (await response.json()) as UnitPriceListDto;
}
async function fetchUnitPriceDetail(projectId: string, code: string): Promise<UnitPriceDetailDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`);
return (await response.json()) as UnitPriceDetailDto;
}
/** ④ 예산내역서 한 줄. 금액이 `null` 이면 **못 세운 것**이지 0 이 아니다. */
interface BillRowDto {
item_no: string;
level: number;
code: string | null;
name: string;
spec: string;
unit: string;
quantity: string | null;
unit_price_krw: string | null;
amount_krw: string | null;
is_group: boolean;
in_bill: boolean;
note: string;
}
interface BillDto {
rows: BillRowDto[];
excluded: BillRowDto[];
materials: BillRowDto[];
summary: {
rows: number;
detail_rows: number;
body_total_krw: string;
missing: Array<{ name: string; reason: string; unit?: string; quantity?: string }>;
notes: string[];
};
}
async function fetchBill(projectId: string): Promise<BillDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`estimation bill failed: ${response.status}`);
return (await response.json()) as BillDto;
}
async function confirmEstimationStage(projectId: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`,
{ method: "POST", credentials: "include" },
);
if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`);
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
injectStyles();
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const form: CostFormState = { ...INITIAL_FORM };
let activeTab = "cost_sheet";
let sheet: CostSheetDto | null = null;
let unitPriceList: UnitPriceListDto | null = null;
let unitPriceDetail: UnitPriceDetailDto | null = null;
let selectedUnitPrice: string | null = null;
let bill: BillDto | null = null;
const main = document.createElement("div");
main.className = "b09-main";
const body = document.createElement("div");
body.style.flex = "1";
body.style.minHeight = "0";
body.style.display = "flex";
body.style.flexDirection = "column";
/** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */
const openUnitPrice = async (code: string): Promise<void> => {
if (!projectId) return;
try {
unitPriceDetail = await fetchUnitPriceDetail(projectId, code);
selectedUnitPrice = code;
drawBody();
} catch {
showToast(L("B09_Estimation_UP_Load_Failed"), "error");
}
};
const drawUnitPriceTab = (): void => {
if (!unitPriceList) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Tab_Pending");
body.append(empty);
return;
}
// 산출 요약을 **화면에도** 낸다 — 무엇이 안 선 상태인지 사용자가 알아야 한다.
for (const note of unitPriceList.summary.notes) {
const line = document.createElement("div");
line.className = "b09-hint";
line.textContent = note;
body.append(line);
}
body.append(
buildUnitPriceList(unitPriceList, selectedUnitPrice, (code) => {
void openUnitPrice(code);
}),
);
if (unitPriceDetail) {
body.append(
buildUnitPriceDetail(unitPriceDetail, (code) => {
void openUnitPrice(code);
}),
);
} else {
const hint = document.createElement("div");
hint.className = "b09-empty";
hint.textContent = L("B09_Estimation_UP_Pick");
body.append(hint);
}
};
/** ④ 예산내역서 — B08 수량에 단가를 붙인 표. 못 세운 줄은 **그대로 보인다**. */
const drawBoqTab = (): void => {
if (!bill) {
if (!projectId) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Boq_Failed");
body.append(empty);
return;
}
const load = document.createElement("button");
load.type = "button";
load.className = "b09-btn";
load.textContent = L("B09_Estimation_Boq_Load");
load.addEventListener("click", () => {
void (async () => {
try {
bill = await fetchBill(projectId);
} catch {
bill = null;
window.alert(L("B09_Estimation_Boq_Failed"));
}
drawBody();
})();
});
body.append(load);
return;
}
const table = document.createElement("table");
table.className = "b09-sheet";
const head = document.createElement("thead");
head.innerHTML =
"<tr><th>No.</th><th>공종</th><th>규격</th><th>단위</th>" +
"<th>수량</th><th>단가</th><th>금액</th><th>비고</th></tr>";
const tbody = document.createElement("tbody");
for (const row of bill.rows) {
const tr = document.createElement("tr");
// 계층은 들여쓰기로 보인다 — 번호만으로는 깊이가 안 읽힌다.
const indent = " ".repeat(Math.max(0, (row.level - 1) * 2));
const cells = row.is_group
? [row.item_no, indent + row.name, "", "", "", "", "", ""]
: [
row.item_no,
indent + row.name,
row.spec,
row.unit,
row.quantity ?? "",
row.unit_price_krw ?? "",
row.amount_krw ?? "",
row.note,
];
for (const text of cells) {
const td = document.createElement("td");
td.textContent = text;
tr.append(td);
}
if (row.is_group) tr.style.fontWeight = "600";
tbody.append(tr);
}
table.append(head, tbody);
body.append(table);
const total = document.createElement("div");
total.className = "b09-hint";
total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`;
body.append(total);
// ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다.
const shortfall = document.createElement("div");
shortfall.className = "b09-hint";
shortfall.textContent = L("B09_Estimation_Boq_NoMaterialPrice");
body.append(shortfall);
if (bill.excluded.length > 0) {
const note = document.createElement("div");
note.className = "b09-hint";
note.textContent =
`${L("B09_Estimation_Boq_Excluded")}: ` +
bill.excluded.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", ");
body.append(note);
}
if (bill.summary.missing.length > 0) {
const note = document.createElement("div");
note.className = "b09-hint";
note.textContent = `${L("B09_Estimation_Boq_Missing")} (${bill.summary.missing.length})`;
body.append(note);
const list = document.createElement("ul");
for (const item of bill.summary.missing) {
const li = document.createElement("li");
li.textContent = `${item.name}${item.reason}`;
list.append(li);
}
body.append(list);
}
if (bill.materials.length > 0) {
const note = document.createElement("div");
note.className = "b09-hint";
note.textContent =
`${L("B09_Estimation_Boq_Materials")}: ` +
bill.materials.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", ");
body.append(note);
}
};
const drawBody = (): void => {
body.replaceChildren();
if (activeTab === "unit_price") {
drawUnitPriceTab();
return;
}
if (activeTab === "boq") {
drawBoqTab();
return;
}
if (activeTab !== "cost_sheet") {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Tab_Pending");
body.append(empty);
return;
}
if (!sheet) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Btn_Recalc");
body.append(empty);
return;
}
// 어느 값으로 계산했는지 화면에 남긴다 — 안 보이면 나중에 못 가른다.
const source = document.createElement("div");
source.className = "b09-hint";
source.textContent =
sheet.direct_cost_source === "quantities"
? L("B09_Estimation_Src_Quantities")
: L("B09_Estimation_Src_Manual");
body.append(source);
// 수량은 있는데 단가가 없는 공종 — 총액에서 빠졌으므로 **반드시 보인다**.
if (sheet.missing_unit_prices.length > 0) {
const missing = document.createElement("div");
missing.className = "b09-hint";
missing.textContent = `${L("B09_Estimation_Missing_UP")} ${sheet.missing_unit_prices.join(", ")}`;
body.append(missing);
}
body.append(buildCostSheetTable(sheet));
for (const note of sheet.notes) {
const line = document.createElement("div");
line.className = "b09-hint";
line.textContent = note;
body.append(line);
}
};
const drawTabs = (): void => {
const bar = buildTabs(activeTab, (key) => {
activeTab = key;
drawTabs();
drawBody();
if (key === "unit_price" && !unitPriceList && projectId) {
void fetchUnitPriceList(projectId)
.then((data) => {
unitPriceList = data;
drawBody();
})
.catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error"));
}
});
const old = main.querySelector(".b09-tabs");
if (old) old.replaceWith(bar);
else main.prepend(bar);
};
const panel = buildSidePanel(
form,
async () => {
if (!projectId) return;
try {
sheet = await fetchCostSheet(projectId, form);
renderRateVersion(panel.rateVersionBox, sheet);
panel.hintBox.textContent =
sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0"
? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}`
: "";
drawBody();
} catch {
showToast(L("B09_Estimation_Calc_Failed"), "error");
}
},
async () => {
if (!projectId) return;
try {
await confirmEstimationStage(projectId);
showToast(L("B09_Estimation_Confirm_Success"), "success");
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[6]);
} catch {
showToast(L("B09_Estimation_Confirm_Failed"), "error");
}
},
);
main.append(body);
drawTabs();
drawBody();
const layout = createWorkflowLayout({
title: L("B09_Estimation_Title"),
steps: workflowSteps(),
activeStep: 6,
leftPanel: panel.root,
mainContent: main,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
root.append(layout.root);
}