/* ============================================================================= * B09_Estimation_UI_Tab_RateTable.ts * 제비율 요율표 탭 — STmate 「원가계산기준」(wM_KAN_RATE)을 본뜸 (PLAN 12장 · 랩탑 메인). * * - 좌측 = 도구줄 자리: `수정기준선택 ☞` [기본제비율] · `수 정` · `취 소` · `저 장`. * - 본문 = 첫 탭 「☞ 공사원가계산 제잡비율」 묶음 차례 · 아래 탭 넷(첫 탭만 켜짐). * - 요율 덮어쓰기 = **이 프로젝트만** · 칸마다 사유 필수 · 「기본값 ↔ 고친 값」 갈라 보임 · ↺ 되돌리기. * - ⚠ 값은 서버가 냄 — 화면은 칸 주소와 새 값·사유만 돌려줌. 마스터 요율표는 안 건드림. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, showToast } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } interface RateAddress { variable: string; table: string | null; match: Record; } interface RateCell { value: number | null; default: number | null; overridden: boolean; address: RateAddress; } interface RateSection { title: string; formula: string; columns: string[]; rows: (string | number | RateCell)[][]; } interface Override extends RateAddress { rate_percent: string; reason: string; entered_at?: string; } interface RateTableDto { status: string; message?: string; rate_version: { dataset_id: string; effective_date: string; sha256: string }; sections: RateSection[]; overrides: Override[]; override_error: string; } /** 고치는 중인 칸 — 저장 전 캐시(지침 5장). */ interface DraftEntry { address: RateAddress; value: string; reason: string; label: string; default: number | null; } /** DFM 아래 탭 — 화면에 보이는 묶음 제목 그대로. */ const RATE_TABS = [ "☞ 공사원가계산 제잡비율", "표준시장단가 제잡비율", "☞ 행정자치부 적용 제잡비율", "☞ 실적공사비 적용시 제경비율 조정계수", ]; const STYLE_ID = "b09-rate-table-styles"; function injectStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` .b09rt { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } .b09rt__meta { font-size: 12px; color: var(--color-text-secondary); } .b09rt__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } .b09rt__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } .b09rt__section { border: 1px solid var(--color-border); padding: 6px 8px; } .b09rt__title { font-weight: 600; font-size: 13px; } .b09rt__formula { font-size: 12px; color: var(--color-text-secondary); margin-left: 6px; font-weight: normal; } .b09rt__table { border-collapse: collapse; font-size: 12px; margin-top: 4px; } .b09rt__table th, .b09rt__table td { border: 1px solid var(--color-border); padding: 2px 8px; } .b09rt__table td { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .b09rt__table td.b09rt__text { text-align: left; } .b09rt__table td.is-changed { outline: 2px solid var(--color-accent, #6c8ebf); outline-offset: -2px; } .b09rt__default { font-size: 11px; color: var(--color-text-secondary); margin-left: 4px; } .b09rt__table input { width: 5em; text-align: right; } .b09rt__tabs { display: flex; gap: 2px; border-top: 1px solid var(--color-border); padding-top: 4px; } .b09rt__tab { font-size: 12px; padding: 2px 10px; border: 1px solid var(--color-border); border-top: none; background: none; } .b09rt__tab[aria-selected="true"] { font-weight: 600; } .b09rt__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } .b09rt__change { display: flex; flex-direction: column; gap: 2px; border-top: 1px dotted var(--color-border); padding-top: 4px; } .b09rt__change input { width: 100%; } .b09rt__change input.is-empty { outline: 2px solid var(--color-danger, #d9534f); } .b09rt__buttons { display: flex; gap: 6px; flex-wrap: wrap; } `; document.head.append(style); } function el( tag: K, className = "", text = "", ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; } function addressKey(address: RateAddress): string { const match = Object.keys(address.match) .sort() .map((k) => `${k}=${address.match[k]}`) .join("&"); return `${address.variable}|${address.table ?? ""}|${match}`; } function isCell(value: unknown): value is RateCell { return typeof value === "object" && value !== null && "address" in value; } async function fetchRates(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`, { credentials: "include" }, ); const body = (await response.json()) as RateTableDto; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); return body; } async function saveOverrides(projectId: string, overrides: Override[]): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ overrides }), }, ); const body = (await response.json()) as { message?: string }; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); } /** 프로젝트별 탭 상태 — 탭을 다시 골라도 [저장] 전 고친 칸이 남음. */ const states = new Map }>(); function stateOf(projectId: string, data: RateTableDto) { let state = states.get(projectId); if (!state) { state = { editing: false, draft: new Map() }; states.set(projectId, state); } if (!state.editing) { // 편집 중이 아니면 저장본으로 다시 채움 — 저장본이 곧 정본. state.draft = new Map( data.overrides.map((o) => [ addressKey(o), { address: { variable: o.variable, table: o.table, match: o.match }, value: o.rate_percent, reason: o.reason, label: "", default: null, }, ]), ); } return state; } function render(ctx: B09TabContext): void { injectStyles(); if (!ctx.projectId) { ctx.body.append(el("div", "b09rt__meta", "프로젝트를 고르세요")); return; } const projectId = ctx.projectId; const load = (): void => { ctx.body.replaceChildren(el("div", "b09rt__meta", "요율표 받는 중…")); fetchRates(projectId) .then((data) => { const state = stateOf(projectId, data); const redraw = (): void => { ctx.body.replaceChildren(); ctx.panel.replaceChildren(); drawBody(ctx, data, state, redraw); drawPanel(ctx, state, redraw, load); }; redraw(); }) .catch((error: unknown) => { ctx.body.replaceChildren( el( "div", "b09rt__meta", `요율표를 받지 못함 — ${error instanceof Error ? error.message : ""}`, ), ); }); }; load(); } function drawPanel( ctx: B09TabContext, state: { editing: boolean; draft: Map }, redraw: () => void, reload: () => void, ): void { const box = el("div", "b09rt__panel"); const pick = el("label", "b09rt__panel"); pick.append(el("span", "", "수정기준선택 ☞")); const select = el("select"); for (const [i, name] of ["기본제비율", "표준시장", "행자부기준"].entries()) { const option = el("option", "", i ? `${name} (준비 중)` : name); option.disabled = i > 0; select.append(option); } pick.append(select); box.append(pick); const buttons = el("div", "b09rt__buttons"); if (!state.editing) { buttons.append( createButton({ label: "수 정", onClick: () => { state.editing = true; redraw(); }, }), ); } else { buttons.append( createButton({ label: "취 소", variant: "ghost", onClick: () => { state.editing = false; reload(); }, }), createButton({ label: "저 장", onClick: async () => { const entries = [...state.draft.values()]; if (entries.some((entry) => !entry.reason.trim())) { showToast("고친 요율마다 사유를 적어야 저장됨", "error"); redraw(); return; } try { await saveOverrides( ctx.projectId as string, entries.map((entry) => ({ ...entry.address, rate_percent: entry.value, reason: entry.reason.trim(), })), ); state.editing = false; showToast("요율 덮어쓰기 저장 — 이 프로젝트만", "success"); reload(); } catch (error) { showToast(error instanceof Error ? error.message : "저장 못 함", "error"); } }, }), ); } box.append(buttons); box.append( el( "span", "b09rt__meta", "고친 요율은 이 프로젝트에만 걸림 — 마스터 요율표(조달청 판)는 그대로 · 칸마다 사유 필수", ), ); // 고친 칸 목록 — 사유 칸(편집 중에만 고침). const changes = [...state.draft.values()]; box.append(el("div", "b09rt__title", `고친 요율 ${changes.length}건`)); for (const entry of changes) { const row = el("div", "b09rt__change"); row.append( el( "span", "", `${entry.label || entry.address.variable} — ${entry.default ?? "?"} → ${entry.value}`, ), ); if (state.editing) { const reason = el("input"); reason.placeholder = "사유(필수) — 예: 발주처 지침 ○○호"; reason.value = entry.reason; reason.classList.toggle("is-empty", !entry.reason.trim()); reason.addEventListener("input", () => { entry.reason = reason.value; reason.classList.toggle("is-empty", !reason.value.trim()); }); row.append(reason); } else { row.append(el("span", "b09rt__meta", `사유: ${entry.reason}`)); } box.append(row); } box.append( createButton({ label: "원가계산서", variant: "ghost", onClick: () => ctx.open("cost_sheet") }), ); ctx.panel.append(box); } function drawBody( ctx: B09TabContext, data: RateTableDto, state: { editing: boolean; draft: Map }, redraw: () => void, ): void { const wrap = el("div", "b09rt"); wrap.append( el( "div", "b09rt__meta", `원가계산기준 — 요율 판 ${data.rate_version.effective_date} (${data.rate_version.dataset_id})` + (state.editing ? " · 수정 중" : ""), ), ); if (data.override_error) { wrap.append( el("div", "b09rt__warn", `⚠ 저장된 덮어쓰기가 지금 판과 안 맞음 — ${data.override_error}`), ); } const scroll = el("div", "b09rt__scroll"); for (const section of data.sections) { const box = el("div", "b09rt__section"); const title = el("div", "b09rt__title", section.title); if (section.formula) title.append(el("span", "b09rt__formula", section.formula)); box.append(title); const table = el("table", "b09rt__table"); const head = el("tr"); for (const column of section.columns) head.append(el("th", "", column)); table.append(head); for (const row of section.rows) { const tr = el("tr"); const rowLabel = row.filter((c) => typeof c === "string" && c).join(" "); row.forEach((cell, index) => { if (!isCell(cell)) { tr.append(el("td", typeof cell === "number" ? "" : "b09rt__text", String(cell))); return; } const key = addressKey(cell.address); const entry = state.draft.get(key); if (entry) { entry.label = `${section.title} · ${rowLabel} · ${section.columns[index]}`; entry.default = cell.default; } tr.append(rateCell(cell, entry, state, section, rowLabel, index, redraw)); }); table.append(tr); } box.append(table); scroll.append(box); } wrap.append(scroll); const tabs = el("div", "b09rt__tabs"); RATE_TABS.forEach((name, i) => { const tab = el("button", "b09rt__tab", name); tab.setAttribute("aria-selected", String(i === 0)); tab.disabled = i > 0; if (i > 0) tab.title = "준비 중"; tabs.append(tab); }); wrap.append(tabs); ctx.body.append(wrap); } /** 요율 칸 — 보기: 값(+기본값) · 편집: 입력 + ↺. 기본값과 같아지면 덮어쓰기에서 빠짐. */ function rateCell( cell: RateCell, entry: DraftEntry | undefined, state: { editing: boolean; draft: Map }, section: RateSection, rowLabel: string, index: number, redraw: () => void, ): HTMLTableCellElement { const td = el("td"); const key = addressKey(cell.address); const shown = entry ? entry.value : String(cell.value ?? ""); const changed = Boolean(entry) && Number(shown) !== cell.default; td.classList.toggle("is-changed", changed); if (entry?.reason) td.title = `기본 ${cell.default} · 사유: ${entry.reason}`; if (!state.editing) { td.append(document.createTextNode(shown)); if (changed) td.append(el("span", "b09rt__default", `(기본 ${cell.default})`)); return td; } const input = el("input"); input.type = "number"; input.step = "0.001"; input.min = "0"; input.value = shown; input.addEventListener("change", () => { if (input.value === "" || Number(input.value) === cell.default) { state.draft.delete(key); } else { state.draft.set(key, { address: cell.address, value: input.value, reason: entry?.reason ?? "", label: `${section.title} · ${rowLabel} · ${section.columns[index]}`, default: cell.default, }); } redraw(); }); td.append(input); if (entry) { const undo = el("button", "", "↺"); undo.type = "button"; undo.title = `기본값(${cell.default})으로 — [저 장]하면 덮어쓰기에서 빠짐`; undo.addEventListener("click", () => { state.draft.delete(key); redraw(); }); td.append(undo); } return td; } export const rateTableTab: B09Tab = { key: "rate_table", label: () => L("B09_Estimation_Tab_RateTable"), render, };