Files
Aislo/B09_Estimation/B09_Estimation_UI_Page.ts
T
eomsangdonandClaude Opus 5 a6c9ca0185 feat(B09): 자재단가대비표·환율및기초자료를 기초자료 탭에 붙임
표를 냈는데 화면에 없으면 낸 것이 아니라 붙임. 목록표 넷 아래에 A9·A10 두 장 추가.

- 자재단가대비표: 원천마다 「단가·페이지」 두 칸으로 머리글 두 줄, 채택 원천 굵게
- 슬롯 6 이 곧 「적용 단가」라 「적 용」 칸을 따로 세우지 않음 (같은 값 두 번 서는 것 방지)
- 환율및기초자료: 환율·인건비(운전사 3)·단가 및 재료비 세 구획
- 유가 적용 범위 고르는 칸 — 지역 공시가는 자료가 없어 고를 수 없게 두고 까닭 표시 (확정 ⑮)
- 두 표는 목록표와 따로 받아 옴 — 못 받아도 목록표 넷은 그대로 남음
- 시간당 노임이 소수로 남는 까닭을 화면에 밝힘 (여기서 자르면 기계 사용료가 어긋남)

검증: tsc 통과 · pytest 267 통과 · 공용 브라우저 5174 에서 표 6장·줄 737 확인,
body.scrollWidth 1696 < innerWidth 1704 (가로 넘침 없음)

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

1287 lines
46 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 {
drawBaseDataTab,
drawMachineTab,
drawPriceSourcesPending,
drawPriceSourcesSections,
fetchBaseData,
fetchPriceSources,
type BaseDataDto,
type PriceSourcesDto,
} from "./B09_Estimation_UI_BaseData";
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); }
/* ⚠ min-width: 0 이 빠지면 **표가 넓은 만큼 이 칸이 통째로 밀려 나간다**(2026-09-08 실측:
창 620 에서 1,190px 넘침). flex 자식의 기본 최소폭이 auto 라 안쪽 표의 최소폭
(칸이 nowrap)을 그대로 물기 때문이다. 0 으로 끊어야 아래 .b09-sheet 의
overflow: auto 가 제 몫을 해서 **표만 제 안에서 가로로 넘어간다.**
⚠ 이 주석 안에 백틱을 쓰지 말 것 — 이 블록은 템플릿 문자열 안이라 거기서 끊긴다. */
.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; min-width: 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; min-width: 0; flex: 1; }
/* ⚠ 이 클래스가 **감싸는 칸(div)에 붙는 자리와 표(table)에 바로 붙는 자리**가 둘 다 있다
(내역서·자재대는 표에 직접 붙인다). 표는 그대로 두면 overflow 가 안 먹어 **표 폭만큼
바깥 칸을 밀어낸다**(2026-09-08 실측: 창 620 에서 1,190px). 블록으로 바꾸면 제 안에서
가로로 넘어가고 바깥은 안 밀린다. 표 안쪽(thead·tbody)의 칸 배치는 그대로다. */
table.b09-sheet { display: block; overflow-x: auto; max-width: 100%; }
.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", true],
["duration", "B09_Estimation_Tab_Duration", false],
["supply", "B09_Estimation_Tab_Supply", true],
["base_data", "B09_Estimation_Tab_BaseData", true],
];
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;
/** 품셈 1-2-2 종목별 자리로 반올림한 표시값. 자리를 모르면 `null`. */
quantity_shown: string | null;
quantity_digits: number | 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[];
material_sheet: MaterialSheetDto | null;
};
price_basis: { entries: PriceBasisEntryDto[] };
}
interface MaterialSheetRowDto {
name: string;
spec: string;
unit: string;
total_amount: string;
unit_price_krw: string | null;
amount_krw: string | null;
note: string;
}
interface MaterialSheetDto {
contractor: MaterialSheetRowDto[];
owner: MaterialSheetRowDto[];
unknown: MaterialSheetRowDto[];
contractor_total_krw: string;
owner_total_krw: string;
missing: Array<{ name: string; reason: string }>;
notes: string[];
}
/**
* 수량 표시 — **종목마다 자리가 다르다** (산림품셈 1-2-2의 1).
*
* 체적합계·시멘트·철근은 정수, 돌쌓기·옹벽·떼는 1자리, 철강재는 3자리다. 서버가 줄마다
* `quantity_digits` 를 실어 보내므로 여기서는 그 자리로 찍기만 한다.
* ⚠ 자리를 못 찾은 줄은 `null` 로 오고, 그때만 **종전 2자리**로 찍는다 — 모르는 것을
* 아는 척 자르지 않는다.
*
* ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이
* 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다.
*/
function formatQuantity(value: string | null, digits: number | null = null): string {
if (value === null || value === "") return "";
const parsed = Number(value);
if (!Number.isFinite(parsed)) return value;
const places = digits ?? 2;
return parsed.toLocaleString("ko-KR", {
minimumFractionDigits: places,
maximumFractionDigits: places,
});
}
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 baseData: BaseDataDto | null = null;
let priceSources: PriceSourcesDto | null = null;
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";
// ⚠ 세로와 마찬가지로 **가로도 0 으로 끊어야** 한다(2026-09-08 실측). 안 끊으면 이 칸이
// 안쪽 표의 최소폭(칸이 nowrap)을 그대로 물어 창 620 에서 1,190px 밀려 나갔다.
// 0 이면 아래 .b09-sheet 의 overflow:auto 가 살아나 **표만 제 안에서 가로로 넘어간다.**
body.style.minWidth = "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_shown ?? row.quantity, row.quantity_digits),
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_shown ?? row.quantity, row.quantity_digits)}${row.unit}`,
)
.join(", ");
body.append(note);
}
if (bill.summary.missing.length > 0) {
// ⚠ **머리글을 한 번만 단다.** 종전엔 「못 세운 줄 (16)」 뒤에 갈래별 머리글이
// 또 붙어 **같은 수가 두 번** 떴다. 안내를 더하는 것이 곧 다른 안내를 묻는 것이라
// (2026-09-08 메인 창 지적), 한 화면에 뜨는 줄 수를 늘리지 않는다.
// ⚠ **할 일이 다르므로 갈라 보인다** — 「사용자가 입력하면 풀리는 것」과
// 「우리가 만들어야 하는 것」. 한 목록에 섞으면 사용자가 무엇을 해야 할지 못 읽는다.
// ⚠ **세 갈래로 가른다.** 「여기서 세지 않는 줄」을 할 일 목록에 얹으면
// 사용자가 세우려 들고, 그것이 곧 이중계상이다(㉠~㉦ 규칙).
const notOurs = bill.summary.missing.filter((item) => item.blocked_kind === "not_our_row");
const needsInput = bill.summary.missing.filter(
(item) => item.blocked_kind === "input_missing",
);
const rest = bill.summary.missing.filter(
(item) => item.blocked_kind !== "input_missing" && item.blocked_kind !== "not_our_row",
);
for (const [labelKey, group] of [
["B09_Estimation_Boq_NeedsInput", needsInput],
["B09_Estimation_Boq_NeedsWork", rest],
["B09_Estimation_Boq_NotOurs", notOurs],
] as Array<[keyof typeof ui_locales, typeof bill.summary.missing]>) {
if (group.length === 0) continue;
const head = document.createElement("div");
head.className = "b09-hint";
// 갈래가 하나뿐이면 「금액을 못 세운 줄」이라는 말을 앞에 붙여 뜻이 온전하게 한다.
const prefix =
needsInput.length > 0 && rest.length > 0 ? "" : `${L("B09_Estimation_Boq_Missing")} — `;
head.textContent = `${prefix}${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);
};
/** 자재대 — B08 수량·할증에 단가를 붙인 표. 관급은 **총원가 밖 별도 표기**다. */
const drawMaterialTab = (): void => {
const sheet = bill?.summary.material_sheet ?? null;
if (!sheet) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Mat_Empty");
body.append(empty);
return;
}
// 자재가 아예 없으면 **빈 표 셋을 늘어놓지 않는다** — 「없다」 한 줄이면 된다
// (2026-09-08 화면 전수에서 세 무리가 모두 「(0) — 0」 으로 뜨고 있었다).
if (sheet.contractor.length === 0 && sheet.owner.length === 0 && sheet.unknown.length === 0) {
const none = document.createElement("div");
none.className = "b09-empty";
none.textContent = L("B09_Estimation_Mat_None");
body.append(none);
return;
}
for (const [labelKey, rows, total] of [
["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw],
["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw],
["B09_Estimation_Mat_Unknown", sheet.unknown, null],
] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null]>) {
const head = document.createElement("div");
head.className = "b09-hint";
head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : ` — ${total}`);
body.append(head);
if (rows.length === 0) continue;
const table = document.createElement("table");
table.className = "b09-sheet";
table.innerHTML =
"<thead><tr><th>자재</th><th>규격</th><th>단위</th><th>수량</th>" +
"<th>단가</th><th>금액</th><th>비고</th></tr></thead>";
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
for (const text of [
row.name,
row.spec,
row.unit,
formatQuantity(row.total_amount),
row.unit_price_krw ?? "",
row.amount_krw ?? "",
row.note,
]) {
const td = document.createElement("td");
td.textContent = text;
tr.append(td);
}
tbody.append(tr);
}
table.append(tbody);
body.append(table);
}
for (const note of sheet.notes) {
const line = document.createElement("div");
line.className = "b09-hint";
line.textContent = note.replace(/\*\*/g, "");
body.append(line);
}
};
const drawBody = (): void => {
body.replaceChildren();
if (activeTab === "unit_price") {
drawUnitPriceTab();
return;
}
if (activeTab === "boq") {
drawBoqTab();
return;
}
if (activeTab === "price_basis") {
drawPriceBasisTab();
return;
}
if (activeTab === "supply") {
drawMaterialTab();
return;
}
if (activeTab === "base_data" || activeTab === "machine") {
// 기초자료 네 표 — 없으면 한 번 받아 오고, 받은 뒤 다시 그린다.
if (!baseData) {
const loading = document.createElement("div");
loading.className = "b09-empty";
loading.textContent = L("B09_Estimation_Tab_Pending");
body.append(loading);
if (projectId) {
void fetchBaseData(projectId)
.then((data) => {
baseData = data;
drawBody();
})
.catch(() => {
/* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */
});
}
return;
}
if (activeTab === "machine") {
drawMachineTab(body, baseData);
return;
}
drawBaseDataTab(body, baseData);
// 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고
// 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다.
if (priceSources) {
drawPriceSourcesSections(body, priceSources);
return;
}
drawPriceSourcesPending(body);
if (projectId) {
void fetchPriceSources(projectId)
.then((data) => {
priceSources = data;
drawBody();
})
.catch(() => {
/* 못 받아도 목록표 넷은 그대로 남는다. */
});
}
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);
}