Files
Aislo/B09_Estimation/B09_Estimation_UI_Page.ts
T
eomsangdonandClaude Opus 5 f952ac7ffd fix(git): 병합이 떨군 파일 22개와 되돌아간 파일 35개를 되살림
무슨 일이 있었나
랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into
sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운
커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`·
`CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴.

잃었던 것
- 공용 — `common_util_provenance.py` · `ui_template_provenance.ts`
- B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸
- B09 — 근거 사전 셋
- B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈
- 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등)

어떻게 되살렸나
`611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림.
이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난
옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛
상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 —
`9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑).
위키 여덟은 코덱스 몫이라 손대지 않음.

자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 ·
`B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의
`attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped**
(되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 18:18:57 +09:00

1562 lines
58 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 { attachCollapsible } from "@ui/ui_template_collapsible";
import {
attachProvenance,
createProvenanceToggle,
markProvenanceCell,
type ProvenancePayload,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
import {
drawBaseDataTab,
drawFactorChoices,
drawBasisSheet,
drawDesignDocTab,
drawMachineExpense,
drawMachineTab,
drawPriceSourcesPending,
drawPriceSourcesSections,
fetchBaseData,
fetchBasisSheet,
fetchDesignDocIndex,
fetchMachineExpense,
fetchFactorChoices,
fetchPriceSources,
type BaseDataDto,
type BasisSheetDto,
type DesignDocDto,
type MachineExpenseDto,
type FactorChoicesDto,
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;
/** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */
provenance?: ProvenancePayload;
}
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[];
// 표본이 얇은 노임 — 금액은 서 있고 「조사현장이 적다」는 사실만 알린다.
labor_reliability: Array<{ code: string; name: string; flag: string; why: string }>;
};
rows: UnitPriceRow[];
provenance?: ProvenancePayload;
}
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;
provenance?: ProvenancePayload;
/** 품셈 표에 있는데 아직 안 붙은 줄 — 있으면 이 단가는 **붙은 줄만의 값**이다. */
unattached: string[];
unattached_note: string;
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); }
/* 좌측 패널 상자 — B04~B07 과 같은 꼴(테두리는 공용 .ui-sidebar-section 이 전담).
.ui-sidebar-section 이 붙은 것만 집어 본문 기초자료 표의 같은 클래스는 안 건드린다. */
.b09-panel__group.ui-sidebar-section {
margin: 0;
padding: calc(var(--spacing-8) + var(--spacing-4));
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.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-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); }
/* 표본이 얇은 노임 — 막는 것이 아니라 눈에 띄기만 하면 된다. */
.b09-hint--warn { color: var(--color-warning-text, #8a5a00); }
/* ⚠ 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");
}
/**
* 줄 사유 조각을 줄에 실어 둔다 — 카드가 꺼내 쓴다.
*
* ⚠ 조각마다 **닿는 열**이 함께 온다. 줄에 달렸다고 모든 칸에 띄우면
* 「금액」 카드에 「갈래 근거…」 가 떠서 읽는 사람을 속인다(2026-09-12 B08 실측).
*/
function stashRowNotes(tr: HTMLElement, notes?: Array<{ column: string; text: string }>): void {
if (notes?.length) tr.dataset.provNotes = JSON.stringify(notes);
}
/** 그 칸에 **닿는** 줄 사유만 돌려준다. 열 키가 빈 조각은 줄 전체에 걸리는 사유다. */
function rowNotesFor(cell: HTMLElement, columnKey: string): string[] {
const raw = cell.closest("tr")?.dataset.provNotes;
if (!raw) return [];
try {
return (JSON.parse(raw) as Array<{ column: string; text: string }>)
.filter((note) => note.column === "" || note.column === columnKey)
.map((note) => note.text);
} catch {
return [];
}
}
/** 칸에 열 키·등급을 심는다 — **사전에 없는 열은 아무 일도 안 한다**(빈 카드 방지). */
function mark(cell: HTMLElement, sheet: ProvenanceSheet | undefined, columnKey: string): void {
const column = sheet?.columns[columnKey];
if (column) markProvenanceCell(cell, columnKey, column.tier);
}
function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
const prov = sheet.provenance?.sheets?.cost_sheet;
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;
mark(name, prov, "name");
// ⚠ **같은 열 안에서 줄마다 등급이 갈리는 첫 자리.** 중간줄(간접노무비 따위)은
// `calc` 인데 마지막줄 셋은 계약으로 나가는 `final` 이다. 열 사전은 등급이 하나뿐이라
// 여기서 칸에 덮어 심는다 — 나머지 칸은 생략해 열 등급을 그대로 물려받는다.
const isFinalLine =
line.key === "total_cost" || line.key === "contract_amount" || line.key === "grand_total";
const amountColumn = prov?.columns.amount_krw;
// 등급을 빼면 공용 쪽이 열 등급으로 채워 주지만, **여기서 명시**해 두면 그 채움이
// 없는 판에서도 띠 색이 제대로 붙는다.
if (amountColumn)
markProvenanceCell(amount, "amount_krw", isFinalLine ? "final" : amountColumn.tier);
mark(rate, prov, "rate_percent");
mark(basis, prov, "formula_text");
mark(note, prov, "note");
tr.append(name, amount, rate, basis, note);
tbody.append(tr);
}
table.append(tbody);
wrap.append(table);
attachProvenance(wrap, prov);
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 prov = list.provenance?.sheets?.unit_price_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;
mark(name, prov, "name");
mark(unit, prov, "unit");
tr.append(name, unit);
const moneyKeys = ["material", "labor", "expense", "total"];
[row.material, row.labor, row.expense, row.total].forEach((value, index) => {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
mark(cell, prov, moneyKeys[index]);
tr.append(cell);
});
body.append(tr);
}
table.append(body);
wrap.append(table);
attachProvenance(wrap, prov);
return wrap;
}
/** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */
function buildUnitPriceDetail(
detail: UnitPriceDetailDto,
onDrill: (code: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-sheet b09-up-detail";
const prov = detail.provenance?.sheets?.unit_price_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);
// ⚠ **일부만 선 단가는 반드시 말한다.** 안 말하면 조용히 싼 값이 내역서에 그대로 든다
// (2026-09-09 실측: 일위대가가 선 141 공종 중 70 공종이 이 자리 — 초류종자살포는
// 자재 다섯·장비 셋이 빠진 채 인력 둘만으로 서 있었다).
if (detail.unattached_note) {
const gap = document.createElement("div");
gap.className = "b09-hint";
gap.style.fontWeight = "600";
gap.textContent = detail.unattached_note;
wrap.append(gap);
}
// 행별로 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;
mark(name, prov, "name");
mark(spec, prov, "spec");
mark(source, prov, "source");
mark(unit, prov, "unit");
tr.append(name, spec, source, unit);
const detailKeys = ["quantity", "material", "labor", "expense", "total"];
[row.quantity, row.material, row.labor, row.expense, row.total].forEach((value, index) => {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
mark(cell, prov, detailKeys[index]);
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);
attachProvenance(wrap, prov);
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("section");
group.className = "b09-panel__group ui-collapsible ui-sidebar-section";
const legend = document.createElement("span");
legend.className = "b09-panel__legend ui-collapsible__title";
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("section");
rateGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section";
const rateLegend = document.createElement("span");
rateLegend.className = "b09-panel__legend ui-collapsible__title";
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("section");
quantityGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section";
const quantityLegend = document.createElement("span");
quantityLegend.className = "b09-panel__legend ui-collapsible__title";
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");
// 바닥 고정 액션 줄(공용) — ui_template_overlay 가 이 줄을 스크롤 밖으로 빼낸다.
actions.className = "ui-sidebar-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);
// 그룹 제목 행 클릭 시 접기/펼치기(B04~B07 공통). 액션 줄은 collapsible 이 아니다.
attachCollapsible(root);
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],
["design_doc", "B09_Estimation_Tab_DesignDoc", true],
["basis_sheet", "B09_Estimation_Tab_BasisSheet", 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;
/**
* 줄 사유 **조각** — 어느 사유가 어느 열에 닿는지까지 서버가 갈라 보낸다.
* ⚠ 줄에 달렸다고 모든 칸에 띄우면 「금액」 카드에 「갈래 근거…」 가 떠서 읽는 사람을
* 속인다(2026-09-12 B08 실측). `column` 이 빈 글인 것만 줄 전체에 붙는다.
*/
notes?: Array<{ column: string; text: 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[] };
provenance?: ProvenancePayload;
}
interface MaterialSheetRowDto {
name: string;
spec: string;
unit: string;
total_amount: string;
unit_price_krw: string | null;
amount_krw: string | null;
note: string;
notes?: Array<{ column: string; text: 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 factorChoices: FactorChoicesDto | null = null;
let designDoc: DesignDocDto | null = null;
let basisSheet: BasisSheetDto | null = null;
let machineExpense: MachineExpenseDto | 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;
// 근거 사전이 **한 번이라도** 왔는지 — 개발환경에서만 온다. 안 오면 토글도 안 세운다
// (없는 기능의 단추가 떠 있으면 눌러 보고 「고장났다」고 읽는다).
let hasProvenance = false;
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 noteProvenance = (payload?: ProvenancePayload): void => {
if (!payload || hasProvenance) return;
hasProvenance = true;
drawTabs();
};
/** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */
const openUnitPrice = async (code: string): Promise<void> => {
if (!projectId) return;
try {
unitPriceDetail = await fetchUnitPriceDetail(projectId, code);
noteProvenance(unitPriceDetail.provenance);
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);
}
// ⚠ 표본이 얇은 노임(`*` 조사현장 5개 미만 · `**` 미조사)이 내역에 실렸으면 알린다.
// 금액을 막지 않는다 — 값은 그대로 서고 **무엇이 얇은지**만 말한다
// (지식DB `노임단가_적용 §2-3` 「단가 채택 시 플래그 유지 필요」).
for (const item of unitPriceList.summary.labor_reliability ?? []) {
const line = document.createElement("div");
line.className = "b09-hint b09-hint--warn";
line.textContent = `⚠ ${item.name}(${item.code}) ${item.flag}${item.why}`;
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);
noteProvenance(bill.provenance);
} 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>";
// 열 키는 서버 `BillRow.as_dict()` 낱말과 같아야 사전이 붙는다.
const boqKeys = [
"item_no",
"name",
"spec",
"unit",
"quantity",
"unit_price_krw",
"amount_krw",
"note",
];
const prov = bill.provenance?.sheets?.boq;
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,
];
cells.forEach((text, index) => {
const td = document.createElement("td");
td.textContent = text;
// 머리(그룹)줄은 값이 없다 — 빈 칸에 카드를 띄우면 「설명이 있다」는 거짓이 남는다.
if (!row.is_group) mark(td, prov, boqKeys[index]);
tr.append(td);
});
if (row.is_group) tr.style.fontWeight = "600";
else stashRowNotes(tr, row.notes);
tbody.append(tr);
}
table.append(head, tbody);
body.append(table);
attachProvenance(table, prov, rowNotesFor);
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 prov = bill.provenance?.sheets?.price_basis;
const pbKeys = ["number", "name", "unit", "unit_price_krw"];
const tbody = document.createElement("tbody");
for (const entry of entries) {
const tr = document.createElement("tr");
[
String(entry.number),
`${entry.name} ${entry.spec}`.trim(),
entry.unit,
entry.unit_price_krw,
].forEach((text, index) => {
const td = document.createElement("td");
td.textContent = text;
mark(td, prov, pbKeys[index]);
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);
attachProvenance(list, prov);
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;
}
// ⚠ 「안 갈린 것」은 **못 세운 것이 아니라 세면 안 되는 것**이라 사전을 따로 쓴다
// (`excluded` — 채우면 이중계상, PLAN 8-36 ㉱).
for (const [labelKey, rows, total, sheetName] of [
["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw, "material"],
["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw, "material"],
["B09_Estimation_Mat_Unknown", sheet.unknown, null, "material_unknown"],
] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null, string]>) {
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 matKeys = [
"name",
"spec",
"unit",
"total_amount",
"unit_price_krw",
"amount_krw",
"note",
];
const prov = bill?.provenance?.sheets?.[sheetName];
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
[
row.name,
row.spec,
row.unit,
formatQuantity(row.total_amount),
row.unit_price_krw ?? "",
row.amount_krw ?? "",
row.note,
].forEach((text, index) => {
const td = document.createElement("td");
td.textContent = text;
mark(td, prov, matKeys[index]);
tr.append(td);
});
stashRowNotes(tr, row.notes);
tbody.append(tr);
}
table.append(tbody);
body.append(table);
attachProvenance(table, prov, rowNotesFor);
}
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 === "basis_sheet") {
// 산출기초 — 줄에 달린 근거를 모아 오는 장이라 조립이 끝나야 뜬다.
if (basisSheet) {
drawBasisSheet(body, basisSheet);
return;
}
const waiting = document.createElement("div");
waiting.className = "b09-empty";
waiting.textContent = L("B09_Estimation_Tab_Pending");
body.append(waiting);
if (projectId) {
void fetchBasisSheet(projectId)
.then((data) => {
basisSheet = data;
noteProvenance(data.provenance);
drawBody();
})
.catch(() => {
/* 못 받아도 화면을 비우지 않는다. */
});
}
return;
}
if (activeTab === "design_doc") {
// 설계서 구성표 — 프로젝트 값이 아니라 **우리가 무엇을 내는가**의 표다.
if (designDoc) {
drawDesignDocTab(body, designDoc);
return;
}
const loading = document.createElement("div");
loading.className = "b09-empty";
loading.textContent = L("B09_Estimation_Tab_Pending");
body.append(loading);
if (projectId) {
void fetchDesignDocIndex(projectId)
.then((data) => {
designDoc = data;
drawBody();
})
.catch(() => {
/* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */
});
}
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;
noteProvenance(data.provenance);
drawBody();
})
.catch(() => {
/* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */
});
}
return;
}
if (activeTab === "machine") {
drawMachineTab(body, baseData);
// 계산서는 목록표 **아래**에 붙는다 — 「얼마」를 보고 「왜」로 내려간다.
if (machineExpense) {
drawMachineExpense(body, machineExpense);
return;
}
if (projectId) {
void fetchMachineExpense(projectId)
.then((data) => {
machineExpense = data;
drawBody();
})
.catch(() => {
/* 못 받아도 목록표는 그대로 선다. */
});
}
return;
}
// 산출 조건이 목록표보다 **먼저** 선다 — 값을 낳는 자리가 값보다 아래 있으면
// 사용자가 「바꿀 수 있는 것」을 못 본다.
if (factorChoices && projectId) {
drawFactorChoices(body, factorChoices, projectId, () => {
factorChoices = null;
baseData = null;
priceSources = null;
drawBody();
});
} else if (projectId) {
void fetchFactorChoices(projectId)
.then((data) => {
factorChoices = data;
drawBody();
})
.catch(() => {
/* 못 받아도 아래 표는 그대로 선다. */
});
}
drawBaseDataTab(body, baseData);
// 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고
// 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다.
if (priceSources && projectId) {
drawPriceSourcesSections(body, priceSources, projectId, () => {
// 유가 지역을 바꾸면 **기계 연료비가 다시 서므로** 목록표까지 함께 새로 받는다.
factorChoices = null;
baseData = null;
priceSources = null;
drawBody();
});
return;
}
drawPriceSourcesPending(body);
if (projectId) {
void fetchPriceSources(projectId)
.then((data) => {
priceSources = data;
noteProvenance(data.provenance);
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;
noteProvenance(data.provenance);
drawBody();
})
.catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error"));
}
});
// ⚠ 등급색은 **평소엔 꺼 둔다** — 여덟 색이 늘 켜져 있으면 표가 알록달록해
// 실무 시트와 눈으로 대조를 못 한다(PLAN 8-36 ②). 단추는 탭 줄 끝에 둔다.
if (hasProvenance) bar.append(createProvenanceToggle(root));
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);
noteProvenance(sheet.provenance);
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);
}