Files
Aislo/B09_Estimation/B09_Estimation_UI_Shell.ts
T
eomsangdonandClaude Opus 5.5 f87831533c feat(b09): 공기 초안 — 메인 탭 공기 · 공기집계 · 공기산출 (draft 폴더 · 252)
설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:51:32 +09:00

376 lines
15 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,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { cellColumn, createDataTable, type DataTable } from "@ui/ui_template_data_table";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
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 { createTabBars } from "./B09_Estimation_UI_TabBar";
import { createCheckPanel } from "./B09_Estimation_UI_Check";
import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor";
import {
DRAFT,
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 { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
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";
import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
// 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음)
import.meta.glob("./draft/*_UI.ts", { eager: true });
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const LATER: Record<string, string> = {};
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
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) = 서버 단독 — 받은 그대로 두는 칸(+ sg_hand = 손 줄 로직 → 산근 키 · 166-2) */
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: Row = {
...Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []])),
sg_hand: server.sg_hand ?? {},
};
// 대가(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 books = { daega: labor.daega, sg_list: kept.sg_list, sg_hand: kept.sg_hand };
const base = computeEstimation(
payload.inputs,
user,
(owner: string) => daegaPrice(labor, owner),
books,
);
const { gaps: daegaGaps, ...daega } = labor;
const out: Row = {
...base,
...kept,
...daega,
gaps: [...base.gaps, ...(server.sg_gaps || []), ...daegaGaps],
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 요율(사용자 ?? 자동 166-1) · 손입력
total: computeTotal(base.bill?.sums, user.estimate, payload.rate_table),
};
for (const extend of DRAFT.extend) extend(payload, user, out);
return out;
}
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 = TABS[0][0];
// 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar`
const bars = createTabBars((tab) => show(tab));
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(bars.top, bars.sub, panel);
const recompute = (): void => {
if (!payload) return;
sheets = computeSheets(payload, user);
check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지
};
const setUser = (next: Row): void => {
user = next;
if (projectId) writeState("b09-estimation", user, projectId);
recompute();
// 고친 뒤 다시 그려도 보던 자리 지킴(166-2 내역서 수량 · 손 줄)
const top = panel.querySelector<HTMLElement>(".ui-data-table")?.scrollTop ?? 0;
show(current);
const box = panel.querySelector<HTMLElement>(".ui-data-table");
if (box && top) box.scrollTop = top;
};
/** 탭 하나 그림 · focus = 이동해 온 줄 키 */
const show = (tab: TabId, focus?: string): void => {
current = tab;
bars.draw(tab);
bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1)
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
const build = BUILDERS[tab] ?? DRAFT.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);
};
// extra = 줄 class 곁값(181-2 표 보기 호표 구간)
const makeTable = (extra?: (index: number) => string | null): DataTable => {
const made = 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,
// 243-1 고칠 수 있는 칸 = 모든 표 더블클릭 · Enter · F2 (설계내역서 키 = 레벨 키 몫)
editOnDouble: spec.editOnDouble ?? true,
keyEdit: !(spec as BillTable).menu,
onEdit: spec.onEdit,
rowClass: extra
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
: spec.rowClass,
cellClass: spec.cellClass,
level: spec.level,
mergeSame: spec.mergeSame,
onCellClick: (i, key) => {
if (spec.onCell) spec.onCell(i, key);
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
},
});
made.root.dataset.b09Table = tab;
// 166-2 줄 우클릭 메뉴(설계내역서) — 공용 표는 그대로 · 표 root 에 붙임
const menu = (spec as BillTable).menu;
if (menu) attachRowMenu(made.root, menu);
// 183-3 칸 고르기 메뉴 — 누른 칸 아래에 B09 공용 메뉴
if (spec.choices)
made.root.addEventListener("click", (event) => {
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
const column = cell ? spec.columns[cellColumn(cell)] : undefined;
const items =
cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key);
if (!cell || !items?.length) return;
const box = cell.getBoundingClientRect();
openMenuAt(box.left, box.bottom, items);
});
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 공용 호버 정보(185-3)
if (spec.tip)
attachHoverInfo(
made.root,
(cell) => {
const column = spec.columns[cellColumn(cell as HTMLTableCellElement)];
return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key);
},
"tr[data-i] td",
);
return made;
};
const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄
// 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표
if (isGroupTab(tab)) {
const redraw = (): void => show(tab);
return void panel.replaceChildren(
...renderGroupPanel({
tab,
spec,
hint,
focus,
goto,
makeTable,
redraw,
setTools: bars.setTools,
}),
);
}
// 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력
// 239-1 · 242-1 이동해 온 줄 = 그 줄 + 자손 빨강 외곽선(is-pick) · 고른 줄 상자와 한때 하나(오면 고름 풂)
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
if (bill) jumpTo(bill.rows, at);
const table = makeTable(
bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined,
);
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
if (level) bars.setTools(level.tools);
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, !bill)); // 설계내역서 = 스크롤만(표시 = 빨강)
};
const apply = (next: Row, fromServer: boolean): void => {
payload = next;
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
user = draft ?? next.user ?? {};
recompute();
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 check = createCheckPanel(refresh, (link) => show(link.tab, link.key));
// 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역
const reset = createButton({
label: "내역 초기화",
variant: "ghost",
onClick: () =>
void showConfirmDialog(
"설계내역서에서 고친 칸 · 더한 줄 · 지운 줄 · 차례를 모두 지우고 기본 로직 내역으로 되돌립니다. 바로 저장됩니다.",
"초기화",
).then((ok) => {
if (!ok) return;
const { bill: _bill, ...rest } = user;
void run("/estimation", "PUT", "설계내역서를 초기값으로 되돌렸습니다.", { user: rest });
}),
});
reset.dataset.b09BillReset = "";
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, refresh, check.list);
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
actions.append(save, reset);
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)}`)),
);
}