/* ============================================================================= * 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, createPanelCard } 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 { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; import { DRAFT, rowIndex, TABS, type Link, type TabContext, type TabId, } from "./B09_Estimation_UI_Tabs"; 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 { isGroupTab, lineHint, pickClass, renderGroupPanel, unpickOnRelease, } 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 = {}; async function request(path: string, init: RequestInit = {}): Promise { 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(".ui-data-table")?.scrollTop ?? 0; show(current); const box = panel.querySelector(".ui-data-table"); if (box && top) box.scrollTop = top; }; /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ const show = (tab: TabId, focus?: string): void => { current = tab; closePopups(); // 303-2 탭 바꿈 · 다시 그림 = 창 모두 닫음 bars.draw(tab); bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1) if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); const ctx: TabContext = { sheets, payload, user, setUser }; const spec = tabTableOf(tab, ctx); if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); if (typeof spec === "string") return void panel.replaceChildren(note(spec)); // 303 행 누름 = 팝업(기본 · 누른 줄 곁 표 조각) · 하이퍼링크(그 탭 그 줄로) const goto = (link: Link | null, anchor?: Element | null): void => { if (!link) return; if (linkMode() === "link") return show(link.tab, link.key); openRowPopup({ link, anchor: anchor ?? null, specOf: (t) => { const made = tabTableOf(t, ctx); return made && typeof made === "object" ? made : null; }, go: (to) => show(to.tab, to.key), }); }; // extra = 줄 class 곁값(181-2 표 보기 호표 구간) const makeTable = (extra?: (index: number) => string | null): DataTable => { const made = createDataTable({ ...tableOptionsOf(spec, tab), fillViewport: true, // 203 표 칸 = 창 끝 − 안내 글 영역(팝업 · 인쇄는 그대로) rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null : spec.rowClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`)); }, }); 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("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); }); // 257-3 칸 우클릭 메뉴 — 누른 칸 자리에 B09 공용 메뉴(줄 메뉴가 없는 표) if (spec.cellMenu) made.root.addEventListener("contextmenu", (event) => { const cell = (event.target as Element).closest("tr[data-i] td"); const column = cell ? spec.columns[cellColumn(cell)] : undefined; const items = cell && column && spec.cellMenu?.(Number(cell.parentElement?.dataset.i), column.key); if (!items?.length) return; event.preventDefault(); openMenuAt(event.clientX, event.clientY, 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; // 203 안내 글 = 줄 수 변수만큼 · 넘치면 스크롤 // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 좌측 하단 스위치로 표(304-1) if (isGroupTab(tab)) { return void panel.replaceChildren( ...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, 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); // 257-2 설계내역서 밖 탭도 이동해 온 줄 = 같은 빨강 상자(그 줄 하나 · 줄 누름 · Esc 로 풂) const range: [number, number] | null = !bill && at >= 0 ? [at, at] : null; const rangeClass = range ? (i: number) => pickClass(range, i) : undefined; const table = makeTable( bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : rangeClass, ); if (range) unpickOnRelease(table.root); const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null; if (level) bars.setTools(level.tools); // 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임 if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`; const foot = hint ?? level?.guide; panel.replaceChildren(table.root, ...(foot ? [foot] : [])); if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; const apply = (next: Row, fromServer: boolean): void => { payload = next; const draft = projectId && !fromServer ? readState("b09-estimation", projectId) : null; user = draft ?? next.user ?? {}; recompute(); show(current); }; // keep = 화면 캐시 그대로(296 [업데이트] — 지운 줄 · 고친 칸을 안 되살림) const run = async ( path: string, method: string, done: string, init?: Row, keep = false, ): Promise => { if (!projectId) return; showLoadingOverlay(); try { const next = await request(`/projects/${projectId}${path}`, { method, ...(init ? { body: JSON.stringify(init) } : {}), }); if (!keep) clearState("b09-estimation", projectId); apply(next, !keep); 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 값으로 새로 굳혔습니다.", { user }, true, ), }); 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 = ""; // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) const section = createPanelCard("데이터 점검", true); // 311 기본 접힘 section.append(refresh, check.list); // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][저장][내역 초기화] const actions = createDock([save, reset], () => isGroupTab(current) && show(current)); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), 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(`/projects/${projectId}/estimation`).then( (next) => apply(next, false), (error: unknown) => panel.replaceChildren(note(`원가 자료를 불러오지 못했습니다 — ${String(error)}`)), ); }