Files
Aislo/B09_Estimation/B09_Estimation_UI_Page.ts
T
eomsangdonandClaude Opus 5 d8cbd48baf feat(B09): ③ 단가산출서(D층) 신설 — 내역 줄에서 검산 경로가 이어짐
실무 내역서는 줄마다 비고에 「단산 46 참조」를 적고 그 산출서를 펴서 검산함(8-13).
STC 실측도 `D` 가 `B`(일위대가)를 참조하는 한 층 위였음(9-3)

- `PriceBook` 의 「제목 + 상세」 한 쌍에 `kind=PRICE_BASIS` 만 얹음 —
  표를 세 벌 만들지 않음. 화면도 일위대가 탭과 **같은 2단 모양**
- 번호는 **코드에 안 박음** — 실무 참조번호는 그 내역서 안의 차례라
  프로젝트마다 다름. 코드는 공종을 가리키고 번호는 조판할 때 매김
- 같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**, 두 줄이 같은 번호를 가리킴
- 내역 줄 비고에 「단산 N 참조」를 달음
- 지금은 일위대가를 그대로 한 줄로 참조 — 할증·기타 비용이 붙을 자리를 미리 열어 둠
- `GET …/estimation/price-basis/{code}` 로 본표 조회

화면 실측: 내역 줄 「2-2-1 측구터파기 488,926 / 단산 1 참조」,
  산출서 탭에 1장(토사 FP-09-12-01 ㎥ 5,288.6), 눌러 본표·참조 코드 확인

검증: pytest 195 통과, tsc 0건

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

1108 lines
39 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", true],
["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 PriceBasisEntryDto {
number: number;
label: string;
code: string;
name: string;
spec: string;
unit: string;
unit_price_krw: string;
ref_code: 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;
blocked_kind?: string;
}>;
notes: string[];
};
price_basis: { entries: PriceBasisEntryDto[] };
}
/**
* 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다.
*
* ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이
* 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다.
* 실무 서식이 수량을 몇 자리로 쓰는지는 기준 문서에 없어(미결) 2자리는 잠정이다.
*/
function formatQuantity(value: string | null): string {
if (value === null || value === "") return "";
const parsed = Number(value);
if (!Number.isFinite(parsed)) return value;
return parsed.toLocaleString("ko-KR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
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;
let priceBasis: string | 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,
formatQuantity(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 precision = document.createElement("div");
precision.className = "b09-hint";
precision.textContent = L("B09_Estimation_Boq_Precision");
body.append(precision);
// ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다.
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} ${formatQuantity(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 needsInput = bill.summary.missing.filter(
(item) => item.blocked_kind === "input_missing",
);
const rest = bill.summary.missing.filter((item) => item.blocked_kind !== "input_missing");
for (const [labelKey, group] of [
["B09_Estimation_Boq_NeedsInput", needsInput],
["B09_Estimation_Boq_NeedsWork", rest],
] as Array<[keyof typeof ui_locales, typeof bill.summary.missing]>) {
if (group.length === 0) continue;
const head = document.createElement("div");
head.className = "b09-hint";
head.textContent = `${L(labelKey)} (${group.length})`;
body.append(head);
const list = document.createElement("ul");
for (const item of group) {
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);
}
};
/** ③ 단가산출서 — 내역 줄의 단가가 **어떻게 나왔는지** 보이는 표(실무 「단산 46 참조」). */
const drawPriceBasisTab = (): void => {
if (!bill) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_PB_Empty");
body.append(empty);
return;
}
const entries = bill.price_basis?.entries ?? [];
// 일위대가 탭과 **같은 모양**으로 — 목록 위, 본표 아래 2단(PLAN 9-3 「표를 세 벌
// 만들지 않는다」와 같은 뜻: 화면도 한 벌로 쓴다).
const split = document.createElement("div");
const list = document.createElement("table");
list.className = "b09-sheet b09-up-list";
list.innerHTML = "<thead><tr><th>번호</th><th>공종</th><th>단위</th><th>단가</th></tr></thead>";
const tbody = document.createElement("tbody");
for (const entry of entries) {
const tr = document.createElement("tr");
for (const text of [
String(entry.number),
`${entry.name} ${entry.spec}`.trim(),
entry.unit,
entry.unit_price_krw,
]) {
const td = document.createElement("td");
td.textContent = text;
tr.append(td);
}
tr.style.cursor = "pointer";
if (entry.code === priceBasis) tr.style.fontWeight = "600";
tr.addEventListener("click", () => {
priceBasis = entry.code;
drawBody();
});
tbody.append(tr);
}
list.append(tbody);
split.append(list);
const picked = entries.find((entry) => entry.code === priceBasis) ?? null;
const panel = document.createElement("div");
panel.className = "b09-up-detail";
if (picked === null) {
panel.textContent = L("B09_Estimation_PB_Pick");
} else {
const head = document.createElement("div");
head.className = "b09-hint";
head.textContent = `${picked.label}${picked.name} ${picked.spec} (${picked.unit}) ${picked.unit_price_krw}`;
const ref = document.createElement("div");
ref.className = "b09-hint";
// 한 층 아래(일위대가)를 가리킨다 — 그 표는 일위대가 탭에서 그대로 본다.
ref.textContent = `${L("B09_Estimation_PB_Ref")}: ${picked.ref_code}`;
panel.append(head, ref);
}
split.append(panel);
body.append(split);
};
const drawBody = (): void => {
body.replaceChildren();
if (activeTab === "unit_price") {
drawUnitPriceTab();
return;
}
if (activeTab === "boq") {
drawBoqTab();
return;
}
if (activeTab === "price_basis") {
drawPriceBasisTab();
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);
}