- `OutputPlace.UNIT_PRICE_ROW` 추가 — **0.1원 미만 버림**(품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」). 계산은 전정밀, **표 그리는 자리에서만** 자름(단수는 출력 위치에 붙음). - **행 합계 = 자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 섬. 전정밀 합을 따로 자르면 성분과 합계가 어긋나 보임. 표 전체 합계도 **행별로 자른 값을 더함** (`단수처리_규칙.md` §2 「행별 처리(합계 후 아님)」). - ⚠ **행별 절사로 생기는 끝자리 차이를 숨기지 않음** — 화면에 「행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계: …」를 띄움. 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 듦. **화면 실측(5174)** — 일위대가 탭 → 목록 64건 → 「제근」 본표 → `▸ 굴착기(무한궤도) 0.2 | 기계경비(105) | hr | 0.8 | 8,936.5 | 28,329.7 | 11,098.5 | 48,364.7` → 기계 줄 클릭 → **시간당 사용료 본표로 파고듦**(손료 13,873.1 · 경유 11,170.6 · 건설기계운전사 35,412.1 = 60,455.9). 합계 98,292.9 / 자르기 전 98,293.2 표시. 산출 요약 2줄(자재 미확보로 구조물 계열 안 섬)도 탭 상단에 뜸. ⚠ 검증 함정 기록 — 해시 라우트에 쿼리를 붙일 때 `#/b09-estimation?v=2` 는 라우트가 안 잡힘. **`?v=2#/b09-estimation` 처럼 해시 앞에** 붙여야 함. 자체검증 — `pytest tmp/tests/ -q` 116 passed · ruff 통과 · tsc(B09) 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
765 lines
27 KiB
TypeScript
765 lines
27 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 { 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;
|
|
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;
|
|
}
|
|
|
|
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: "",
|
|
};
|
|
|
|
/** 총계 성격의 줄 — 표에서 굵게 띄운다. */
|
|
const TOTAL_KEYS = new Set([
|
|
"material_cost",
|
|
"labor_cost",
|
|
"expense",
|
|
"net_construction_cost",
|
|
"total_cost",
|
|
"contract_amount",
|
|
"grand_total",
|
|
]);
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 스타일 — 공통 토큰만 사용 (frontend.md §1 하드코딩 금지)
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
const STYLE_ID = "b09-estimation-styles";
|
|
|
|
function injectStyles(): void {
|
|
if (document.getElementById(STYLE_ID)) return;
|
|
const style = document.createElement("style");
|
|
style.id = STYLE_ID;
|
|
style.textContent = `
|
|
.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); }
|
|
.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); }
|
|
.b09-panel__legend {
|
|
font-size: var(--font-size-xs, 12px); letter-spacing: .06em;
|
|
color: var(--color-text-secondary); text-transform: uppercase;
|
|
}
|
|
.b09-panel__readonly {
|
|
font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary);
|
|
display: flex; justify-content: space-between; gap: var(--space-sm, 8px);
|
|
border-bottom: 1px solid var(--color-border); padding: 2px 0;
|
|
}
|
|
.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); }
|
|
.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); }
|
|
|
|
.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; }
|
|
.b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; }
|
|
.b09-tab {
|
|
font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer;
|
|
border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary);
|
|
}
|
|
.b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); }
|
|
.b09-tab:disabled { cursor: not-allowed; opacity: .55; }
|
|
|
|
.b09-sheet { overflow: auto; min-height: 0; flex: 1; }
|
|
.b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); }
|
|
.b09-sheet th, .b09-sheet td {
|
|
border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right;
|
|
white-space: nowrap; font-variant-numeric: tabular-nums;
|
|
}
|
|
.b09-sheet th { text-align: center; color: var(--color-text-secondary); font-weight: 600; }
|
|
.b09-sheet td.b09-left, .b09-sheet th.b09-left { text-align: left; white-space: normal; }
|
|
.b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); }
|
|
.b09-sheet tr.is-adopted td { background: var(--color-surface); }
|
|
.b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; }
|
|
.b09-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"],
|
|
]);
|
|
|
|
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", false],
|
|
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
|
|
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
|
|
["machine", "B09_Estimation_Tab_Machine", false],
|
|
["duration", "B09_Estimation_Tab_Duration", false],
|
|
["supply", "B09_Estimation_Tab_Supply", false],
|
|
["base_data", "B09_Estimation_Tab_BaseData", false],
|
|
];
|
|
|
|
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement {
|
|
const bar = document.createElement("div");
|
|
bar.className = "b09-tabs";
|
|
for (const [key, labelKey, enabled] of TAB_KEYS) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "b09-tab";
|
|
button.dataset.tab = key;
|
|
button.textContent = L(labelKey);
|
|
button.disabled = !enabled;
|
|
if (!enabled) button.title = L("B09_Estimation_Tab_Pending");
|
|
if (key === active) button.classList.add("is-active");
|
|
button.addEventListener("click", () => onSelect(key));
|
|
bar.append(button);
|
|
}
|
|
return bar;
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* API
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
function 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();
|
|
}
|
|
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;
|
|
}
|
|
|
|
async function confirmEstimationStage(projectId: string): Promise<void> {
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`,
|
|
{ method: "POST", credentials: "include" },
|
|
);
|
|
if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`);
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 페이지 진입점
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
|
injectStyles();
|
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
|
const form: CostFormState = { ...INITIAL_FORM };
|
|
let activeTab = "cost_sheet";
|
|
let sheet: CostSheetDto | null = null;
|
|
let unitPriceList: UnitPriceListDto | null = null;
|
|
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
|
let selectedUnitPrice: string | null = null;
|
|
|
|
const main = document.createElement("div");
|
|
main.className = "b09-main";
|
|
const body = document.createElement("div");
|
|
body.style.flex = "1";
|
|
body.style.minHeight = "0";
|
|
body.style.display = "flex";
|
|
body.style.flexDirection = "column";
|
|
|
|
/** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */
|
|
const openUnitPrice = async (code: string): Promise<void> => {
|
|
if (!projectId) return;
|
|
try {
|
|
unitPriceDetail = await fetchUnitPriceDetail(projectId, code);
|
|
selectedUnitPrice = code;
|
|
drawBody();
|
|
} catch {
|
|
showToast(L("B09_Estimation_UP_Load_Failed"), "error");
|
|
}
|
|
};
|
|
|
|
const drawUnitPriceTab = (): void => {
|
|
if (!unitPriceList) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "b09-empty";
|
|
empty.textContent = L("B09_Estimation_Tab_Pending");
|
|
body.append(empty);
|
|
return;
|
|
}
|
|
// 산출 요약을 **화면에도** 낸다 — 무엇이 안 선 상태인지 사용자가 알아야 한다.
|
|
for (const note of unitPriceList.summary.notes) {
|
|
const line = document.createElement("div");
|
|
line.className = "b09-hint";
|
|
line.textContent = note;
|
|
body.append(line);
|
|
}
|
|
body.append(
|
|
buildUnitPriceList(unitPriceList, selectedUnitPrice, (code) => {
|
|
void openUnitPrice(code);
|
|
}),
|
|
);
|
|
if (unitPriceDetail) {
|
|
body.append(
|
|
buildUnitPriceDetail(unitPriceDetail, (code) => {
|
|
void openUnitPrice(code);
|
|
}),
|
|
);
|
|
} else {
|
|
const hint = document.createElement("div");
|
|
hint.className = "b09-empty";
|
|
hint.textContent = L("B09_Estimation_UP_Pick");
|
|
body.append(hint);
|
|
}
|
|
};
|
|
|
|
const drawBody = (): void => {
|
|
body.replaceChildren();
|
|
if (activeTab === "unit_price") {
|
|
drawUnitPriceTab();
|
|
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;
|
|
}
|
|
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);
|
|
}
|