145-1 · 145-3 · 145-4 · 145-5 받음 · 충돌 = B09 이음 자리 넷 — Router · Shell 짝 셈 한 벌(바탕 → 산근 → 대가 → 내역 → 총괄) · used_keys 는 사본 셈 길(참조사본 포함) · 144 · 145-1 시험 단언 이음에 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Shell.ts
|
|
* 로그인 후 09: 6차 워크플로우 (원가계산) — 탭 화면 (PLAN 144-1)
|
|
*
|
|
* 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄
|
|
* 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) +
|
|
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices
|
|
* 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음
|
|
* ========================================================================== */
|
|
|
|
import "@ui/ui_template_general_layout.css";
|
|
import "./B09_Estimation_UI_Page.css";
|
|
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
|
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
|
import {
|
|
createButton,
|
|
hideLoadingOverlay,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
|
import { createDataTable } from "@ui/ui_template_data_table";
|
|
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
|
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
|
import { computeBase, type Row } from "./B09_Estimation_Calc_Base";
|
|
import { computeEstimation } from "./B09_Estimation_Calc_Bill";
|
|
import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor";
|
|
import { computeTotal } from "./B09_Estimation_Calc_Total";
|
|
import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor";
|
|
import {
|
|
buildCheck,
|
|
rowIndex,
|
|
TABS,
|
|
type Link,
|
|
type TabBuild,
|
|
type TabContext,
|
|
type TabId,
|
|
} from "./B09_Estimation_UI_Tabs";
|
|
import {
|
|
buildBasis,
|
|
buildCompare,
|
|
buildExpense,
|
|
buildLabor,
|
|
buildMachine,
|
|
buildMachineBlocks,
|
|
buildMaterial,
|
|
} from "./B09_Estimation_UI_Tabs_Base";
|
|
import { buildBill } from "./B09_Estimation_UI_Tabs_Bill";
|
|
import {
|
|
buildSumExpense,
|
|
buildSumLabor,
|
|
buildSumMachine,
|
|
buildSumMaterial,
|
|
} from "./B09_Estimation_UI_Tabs_Summary";
|
|
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
|
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
const LATER: Record<string, string> = {};
|
|
|
|
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
|
check: buildCheck,
|
|
dg_list: buildDaegaList,
|
|
dg: buildDaegaBlocks,
|
|
bill: buildBill,
|
|
basis: buildBasis,
|
|
machine: buildMachine,
|
|
machine_blocks: buildMachineBlocks,
|
|
material: buildMaterial,
|
|
labor: buildLabor,
|
|
expense: buildExpense,
|
|
compare: buildCompare,
|
|
cost: buildCost,
|
|
total: buildTotal,
|
|
sum_material: buildSumMaterial,
|
|
sum_labor: buildSumLabor,
|
|
sum_expense: buildSumExpense,
|
|
sum_machine: buildSumMachine,
|
|
sg_list: buildSgList,
|
|
sg: buildSg,
|
|
};
|
|
|
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
...init,
|
|
});
|
|
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
|
|
if (!response.ok) {
|
|
const detail = body?.detail;
|
|
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
|
}
|
|
return body as T;
|
|
}
|
|
|
|
function note(textValue: string): HTMLElement {
|
|
const p = document.createElement("p");
|
|
p.className = "ui-general-block__empty";
|
|
p.textContent = textValue;
|
|
return p;
|
|
}
|
|
|
|
/** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸 */
|
|
const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;
|
|
|
|
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) · 산근은 서버 답 그대로(+ 「연결 없음」) */
|
|
export function computeSheets(payload: Row, user: Row): Row {
|
|
const server: Row = payload.sheets || {};
|
|
const kept = Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []]));
|
|
// 대가(145-2) — 산근 단가는 서버 답 목록 그대로 · 내역(145-3)은 대가 단가로 블록마다
|
|
const sg = Object.fromEntries((kept.sg_list as Row[]).map((r) => [r.key, r]));
|
|
const labor = computeLabor(
|
|
payload.inputs.daega,
|
|
{ ...computeBase(payload.inputs, user), gaps: [] },
|
|
sg,
|
|
);
|
|
const base = computeEstimation(payload.inputs, user, (owner: string) => daegaPrice(labor, owner));
|
|
const { gaps: daegaGaps, ...daega } = labor;
|
|
return {
|
|
...base,
|
|
...kept,
|
|
...daega,
|
|
gaps: [...base.gaps, ...(server.sg_gaps || []), ...daegaGaps],
|
|
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
|
total: computeTotal(base.bill?.sums, user.estimate),
|
|
};
|
|
}
|
|
|
|
export function renderB09Estimation(root: HTMLElement): void {
|
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
|
let payload: Row | null = null;
|
|
let user: Row = {};
|
|
let sheets: Row = {};
|
|
let current: TabId = "check";
|
|
|
|
const tabBar = document.createElement("div");
|
|
tabBar.className = "b09-estimation__tabs";
|
|
tabBar.setAttribute("role", "tablist");
|
|
const panel = document.createElement("div");
|
|
panel.className = "b09-estimation__panel";
|
|
panel.setAttribute("role", "tabpanel");
|
|
const body = document.createElement("div");
|
|
body.className = "b09-estimation";
|
|
body.append(tabBar, panel);
|
|
|
|
const made = document.createElement("p");
|
|
made.className = "b09-estimation__hint";
|
|
|
|
const recompute = (): void => {
|
|
if (payload) sheets = computeSheets(payload, user);
|
|
};
|
|
|
|
const setUser = (next: Row): void => {
|
|
user = next;
|
|
if (projectId) writeState("b09-estimation", user, projectId);
|
|
recompute();
|
|
show(current);
|
|
};
|
|
|
|
/** 탭 하나 그림 · focus = 이동해 온 줄 키 */
|
|
const show = (tab: TabId, focus?: string): void => {
|
|
current = tab;
|
|
tabBar.replaceChildren(
|
|
...TABS.map(([id, label]) => {
|
|
const button = createButton({
|
|
label,
|
|
variant: id === tab ? "filled" : "ghost",
|
|
size: "sm",
|
|
onClick: () => id !== current && show(id),
|
|
});
|
|
button.setAttribute("role", "tab");
|
|
button.setAttribute("aria-selected", String(id === tab));
|
|
button.dataset.b09Tab = id;
|
|
return button;
|
|
}),
|
|
);
|
|
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
|
|
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
|
|
const build = BUILDERS[tab];
|
|
if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움"));
|
|
const ctx: TabContext = { sheets, payload, user, setUser };
|
|
const spec = build(ctx);
|
|
if (typeof spec === "string") return void panel.replaceChildren(note(spec));
|
|
const goto = (link: Link | null): void => {
|
|
if (link) show(link.tab, link.key);
|
|
};
|
|
const table = createDataTable({
|
|
columns: spec.columns,
|
|
rows: spec.rows,
|
|
total: spec.total ?? null,
|
|
frozen: spec.frozen ?? 0,
|
|
empty: "",
|
|
caption: TABS.find(([id]) => id === tab)?.[1],
|
|
editable: spec.editable,
|
|
onEdit: spec.onEdit,
|
|
onCellClick: (i, key) => {
|
|
if (spec.onCell) spec.onCell(i, key);
|
|
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
|
|
},
|
|
});
|
|
table.root.dataset.b09Table = tab;
|
|
panel.replaceChildren(...(spec.hint ? [note(spec.hint)] : []), table.root);
|
|
if (focus !== undefined) {
|
|
const index = rowIndex(spec.rows, focus);
|
|
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
|
|
}
|
|
};
|
|
|
|
const apply = (next: Row, fromServer: boolean): void => {
|
|
payload = next;
|
|
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
|
|
user = draft ?? next.user ?? {};
|
|
recompute();
|
|
made.textContent = next.book?.made
|
|
? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로`
|
|
: "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘";
|
|
show(current);
|
|
};
|
|
|
|
const run = async (path: string, method: string, done: string, init?: Row): Promise<void> => {
|
|
if (!projectId) return;
|
|
showLoadingOverlay();
|
|
try {
|
|
const next = await request<Row>(`/projects/${projectId}${path}`, {
|
|
method,
|
|
...(init ? { body: JSON.stringify(init) } : {}),
|
|
});
|
|
clearState("b09-estimation", projectId);
|
|
apply(next, true);
|
|
showToast(done, "success");
|
|
} catch (error) {
|
|
showToast(`하지 못했습니다 — ${String(error)}`, "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
};
|
|
|
|
const save = createButton({
|
|
label: "저장",
|
|
variant: "filled",
|
|
onClick: () => void run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }),
|
|
});
|
|
save.dataset.b09Save = "";
|
|
const refresh = createButton({
|
|
label: "단가 갱신",
|
|
variant: "ghost",
|
|
onClick: () =>
|
|
void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."),
|
|
});
|
|
refresh.dataset.b09Refresh = "";
|
|
const section = document.createElement("section");
|
|
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
|
const heading = document.createElement("h3");
|
|
heading.className = "ui-collapsible__title";
|
|
heading.textContent = "원가 단가";
|
|
section.append(heading, made, refresh);
|
|
const actions = document.createElement("div");
|
|
actions.className = "ui-sidebar-actions";
|
|
actions.append(save);
|
|
const left = document.createElement("div");
|
|
left.className = "ui-sidebar-panel b09-options";
|
|
left.append(section, actions);
|
|
attachCollapsible(left);
|
|
|
|
const layout = createWorkflowLayout({
|
|
title: L("B09_Estimation_Title"),
|
|
steps: workflowSteps(),
|
|
activeStep: 6,
|
|
mainContent: body,
|
|
leftPanel: left,
|
|
routes: WORKFLOW_STEP_ROUTES,
|
|
onStepClick: (stepIndex: number) => {
|
|
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
|
},
|
|
});
|
|
root.append(layout.root);
|
|
show(current);
|
|
|
|
if (projectId)
|
|
request<Row>(`/projects/${projectId}/estimation`).then(
|
|
(next) => apply(next, false),
|
|
(error: unknown) =>
|
|
panel.replaceChildren(note(`원가 자료를 불러오지 못했습니다 — ${String(error)}`)),
|
|
);
|
|
}
|