표를 냈는데 화면에 없으면 낸 것이 아니라 붙임. 목록표 넷 아래에 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>
1287 lines
46 KiB
TypeScript
1287 lines
46 KiB
TypeScript
/* =============================================================================
|
||
* 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);
|
||
}
|