- `B09_Estimation_Router.py` 신설 — 무상태 계산 엔드포인트 3종. · `POST …/estimation/cost` 원가계산서 한 장(줄마다 비목·금액·요율·산출근거) · `GET …/estimation/items` 비목 정의 목록 · `POST …/estimation/confirm` stage 6 완료 전이 요율 구간을 못 고르면 500 이 아니라 **422 + 사유**로 알림 (기본값으로 안 때움). `target_contract_amount_krw` 를 주면 필요한 이윤 조정액을 **보여만 줌**(★법대로 8-10). - `B09_Estimation_UI_Page.ts` — 셸에서 실제 화면으로. 3단 레이아웃, 좌측 입력 4군 (공사조건·요율 판 읽기전용·관급자재·이윤 조정) + 우측 탭 8장(원가계산서 활성). 원가계산서 표는 **네 칸 + 비고**이고 **안전관리비 A·B 두 줄이 나란히**, 채택 줄은 강조·미채택 줄은 취소선. - `main.py` — B09 라우터 import·등록 **자기 줄만** 추가. - `ui_template_locale_b2.ts` — B09 키 추가, `B09_Estimation_Title` 을 「설계도서」→**「원가계산」**으로 변경 (PLAN 9-1 사용자 승인). - 자체검증: `tsc --noEmit` 통과 · 백엔드 재시작 후 `openapi.json` 에 3개 경로 등록 확인 · `/api/health` 200. 화면 조작 검증은 다음 단계(브라우저 창이 닫혀 재기동 필요). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
482 lines
17 KiB
TypeScript
482 lines
17 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 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-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;
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 좌측 패널
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
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", false],
|
|
["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 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;
|
|
|
|
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 drawBody = (): void => {
|
|
body.replaceChildren();
|
|
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();
|
|
});
|
|
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);
|
|
}
|