/* ============================================================================= * B08_Quantity_UI_StructureSummary.ts * **구조물 집계표** 탭 — 측점별 구조물 한 줄 · 종류별 표 · 칸 고치기 (PLAN 2장). * * ⚠ 값을 셈하지 않음 — 서버(`…/quantity/structure-summary`)가 정본을 읽어 세운 줄을 적기만. * ⚠ 칸마다 출처 표시 — 자동(정본) · 사용자(이 표에서 고침) · 라이브러리(양식 기본값) · 빈칸. * ⚠ 이 표에서 고친 값을 B05 가 바꿔 자동으로 돌아간 칸은 **조용히 넘기지 않고** 줄에 알림. * ⚠ 고치기는 데이터 3층 — 칸 조작은 캐시(sessionStorage)에만, [저장] 때 서버가 **정본에** 씀. * 상세 칸만 고침 — 자리·길이·높이·관경 같은 놓기 칸은 시·종점과 한 벌이라 B05 몫(회색). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid"; import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula"; type Source = "auto" | "user" | "library" | "empty"; type CellValue = number | string | null; interface SummaryCell { value: CellValue; source: Source; was?: CellValue; replaced_user_value?: CellValue; } interface SummaryRow { id: string; origin: "structures" | "pipe_points"; chainage_m: number | null; start_m: number | null; end_m: number | null; count: number; length_m: number | null; length_basis: string; memo: string; note?: string; replaced?: string[]; cells: Record; } interface SummaryColumn { key: string; label: string; unit: string; input: string; choices: string[]; editable: boolean; } interface SummaryTable { type_id: string; name: string; columns: SummaryColumn[]; rows: SummaryRow[]; count: number; length_total_m: number | null; length_missing: number; averages: Record; } interface SummaryResponse { revision: number; tables: SummaryTable[]; notes: string[]; message?: string; } interface Edit { id: string; key: string; value: CellValue; } const SOURCE_LABELS: Record = { auto: "자동 — 구조물 놓기(B05)·계곡 시설 정본 값", user: "사용자 — 이 표에서 고친 값(정본에 적힘)", library: "라이브러리 — 정본이 비어 양식 기본값으로 섬", empty: "빈칸 — 정본도 양식도 값 없음", }; const STYLE_ID = "b08-structure-summary-style"; const CSS = ` .b08-sum { display: flex; flex-direction: column; gap: 10px; min-height: 0; } .b08-sum__legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; } .b08-sum__cell--auto { box-shadow: inset 3px 0 0 var(--color-border, #888); } .b08-sum__cell--user { box-shadow: inset 3px 0 0 var(--color-accent, #6c8ebf); } .b08-sum__cell--library { box-shadow: inset 3px 0 0 var(--color-success, #5cb85c); } .b08-sum__cell--empty { color: var(--color-text-muted, #999); } .b08-sum__cell--replaced { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; } .b08-sum td.is-changed { background: color-mix(in srgb, var(--color-accent, #6c8ebf) 18%, transparent); } .b08-sum .b08-grid__table td { white-space: nowrap; } .b08-sum td input, .b08-sum td select { font-size: 12px; max-width: 8rem; } .b08-sum td input[type=number] { width: 5rem; } .b08-sum td button { font-size: 11px; padding: 0 4px; margin-left: 2px; cursor: pointer; } .b08-sum__actions { display: flex; gap: 8px; align-items: center; } `; function injectStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = CSS; document.head.append(style); } /** 칸 글 — 정수는 그대로(뒷길이 45), 소수는 둘째 자리까지. */ function cellText(value: CellValue | undefined): string { if (value === null || value === undefined || value === "") return ""; return typeof value === "number" && !Number.isInteger(value) ? num(value, 2) : String(value); } function station(row: SummaryRow): string { if (row.start_m !== null && row.end_m !== null) { return `${stationLabel(row.start_m)} ~ ${stationLabel(row.end_m)}`; } return row.chainage_m === null ? "" : stationLabel(row.chainage_m); } /** 정본에 지금 적힌 값 — 라이브러리·빈칸은 정본이 빈 것. */ function storedValue(cell: SummaryCell | undefined): CellValue { return cell && (cell.source === "auto" || cell.source === "user") ? cell.value : null; } function same(a: CellValue, b: CellValue): boolean { return String(a ?? "") === String(b ?? ""); } /** 고친 칸 캐시 — [저장] 전까지 이 탭(sessionStorage)에만 둠. 판번호가 다르면 버림. */ class Draft { private readonly storageKey: string; readonly edits = new Map(); onChange: () => void = () => undefined; constructor( projectId: string, readonly revision: number, ) { this.storageKey = `b08-structure-summary-draft:${projectId}`; try { const saved = JSON.parse(sessionStorage.getItem(this.storageKey) ?? "null") as { revision: number; edits: Edit[]; } | null; if (saved?.revision === revision) { for (const edit of saved.edits) this.edits.set(`${edit.id}|${edit.key}`, edit); } } catch { // 캐시를 못 읽으면 빈 초안 — 정본은 그대로. } } get(id: string, key: string): Edit | undefined { return this.edits.get(`${id}|${key}`); } set(edit: Edit, stored: CellValue): void { const slot = `${edit.id}|${edit.key}`; if (same(edit.value, stored)) this.edits.delete(slot); else this.edits.set(slot, edit); this.persist(); } clear(): void { this.edits.clear(); this.persist(); } private persist(): void { try { if (this.edits.size) { const body = { revision: this.revision, edits: [...this.edits.values()] }; sessionStorage.setItem(this.storageKey, JSON.stringify(body)); } else { sessionStorage.removeItem(this.storageKey); } } catch { // 저장소가 막혀도 화면 초안은 살아 있음. } this.onChange(); } } function editor( column: SummaryColumn, row: SummaryRow, cell: SummaryCell | undefined, draft: Draft, td: HTMLTableCellElement, ): HTMLElement[] { const stored = storedValue(cell); const pending = draft.get(row.id, column.key); const current = pending ? pending.value : stored; let control: HTMLInputElement | HTMLSelectElement; if (column.input === "select" && column.choices.length) { const select = document.createElement("select"); select.append(new Option("—", "")); for (const choice of column.choices) select.append(new Option(choice, choice)); select.value = current === null ? "" : String(current); control = select; } else { const input = document.createElement("input"); input.type = column.input === "number" ? "number" : "text"; if (column.input === "number") { input.min = "0"; input.step = "any"; } input.value = current === null ? "" : String(current); control = input; } if (cell?.source === "library") control.title = `비우면 양식 기본값 ${cellText(cell.value)}`; if (control instanceof HTMLInputElement && cell?.source === "library") { control.placeholder = cellText(cell.value); } const mark = (): void => { td.classList.toggle("is-changed", Boolean(draft.get(row.id, column.key))); }; control.addEventListener("change", () => { const raw = control.value.trim(); const value = raw === "" ? null : column.input === "number" ? Number(raw) : raw; draft.set({ id: row.id, key: column.key, value }, stored); mark(); }); mark(); const parts: HTMLElement[] = [control]; if (cell?.source === "user" && cell.was !== undefined) { const undo = el("button", "", "↺"); undo.type = "button"; undo.title = `고치기 전 값(${cellText(cell.was) || "빈칸"})으로 — [저장]하면 「자동」으로 돌아감`; undo.addEventListener("click", () => { control.value = cell.was === null || cell.was === undefined ? "" : String(cell.was); control.dispatchEvent(new Event("change")); }); parts.push(undo); } return parts; } function renderTable(table: SummaryTable, draft: Draft): HTMLElement { const wrap = el("div", "b08-grid"); const length = table.length_total_m === null ? "" : ` · 연장 합 ${num(table.length_total_m, 2)} m`; const missing = table.length_missing ? ` · 연장 빈 줄 ${table.length_missing}` : ""; wrap.append(el("p", "b08-sheet__head", `${table.name} — ${table.count}개소${length}${missing}`)); const scroller = el("div", "b08-grid__scroll"); const grid = el("table", "b08-grid__table"); const head = document.createElement("tr"); head.append(el("th", "", "측점"), el("th", "", "연장(m)")); for (const column of table.columns) { const th = el("th", "", column.label); if (!column.editable) th.title = "놓기 칸 — 시·종점과 한 벌이라 구조물 놓기(B05)에서 고침"; head.append(th); } head.append(el("th", "", "비고")); const thead = document.createElement("thead"); thead.append(head); const tbody = document.createElement("tbody"); for (const row of table.rows) { const tr = document.createElement("tr"); const lengthCell = el("td", "", row.length_m === null ? "" : num(row.length_m, 2)); lengthCell.title = row.length_basis || "연장 없음"; tr.append(el("td", "", station(row)), lengthCell); for (const column of table.columns) { const cell = row.cells[column.key]; const td = el("td", `b08-sum__cell--${cell?.source ?? "empty"}`); if (column.editable) td.append(...editor(column, row, cell, draft, td)); else td.textContent = cellText(cell?.value); td.title = cell ? SOURCE_LABELS[cell.source] : ""; if (cell?.source === "user" && cell.was !== undefined) { td.title += ` · 고치기 전 ${cellText(cell.was) || "빈칸"}`; } if (cell?.replaced_user_value !== undefined) { td.classList.add("b08-sum__cell--replaced"); td.title = `이 표에서 ${cellText(cell.replaced_user_value) || "빈칸"}(으)로 고쳤으나 B05 가 바꿔 자동값으로 돌아감`; } tr.append(td); } const notes = [ row.replaced?.length ? `⚠ B05 가 바꿈: ${row.replaced.join("·")}` : "", row.note ?? "", row.memo, ].filter(Boolean); tr.append(el("td", "", notes.join(" · "))); tbody.append(tr); } const average = document.createElement("tr"); average.append(el("td", "", "평균치수"), el("td", "", "")); for (const column of table.columns) { const value = table.averages[column.key]; average.append(el("td", "", value === undefined ? "" : num(value, 2))); } average.append(el("td", "", "")); tbody.append(average); grid.append(thead, tbody); scroller.append(grid); wrap.append(scroller); return wrap; } /** 탭 본문 — 받는 동안 안내, 오면 종류별 표. [저장] 뒤엔 다시 받음. */ export function renderStructureSummary(projectId: string | null): HTMLElement { injectStyles(); const root = el("div", "b08-sum"); if (!projectId) { root.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것")); return root; } const url = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-summary`; let draft: Draft | null = null; // 저장 안 한 칸이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장). window.addEventListener("beforeunload", (event) => { if (!draft?.edits.size || !root.isConnected) return; event.preventDefault(); event.returnValue = "저장 안 한 칸이 있음"; }); const load = async (): Promise => { root.replaceChildren(el("p", "b08-grid__caption", "구조물 집계표 불러오는 중…")); try { const response = await fetch(url, { credentials: "include" }); const payload = (await response.json().catch(() => ({}))) as SummaryResponse; if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); const active = new Draft(projectId, payload.revision); draft = active; const legend = el("div", "b08-sum__legend"); for (const source of ["auto", "user", "library", "empty"] as Source[]) { legend.append(el("span", `b08-sum__cell--${source}`, ` ${SOURCE_LABELS[source]} `)); } const save = el("button", "b08-spec__save", "저장"); save.type = "button"; const discard = el("button", "b08-quantity__tab", "고친 것 버리기"); discard.type = "button"; const status = el("span", "b08-grid__caption"); active.onChange = () => { const count = active.edits.size; save.disabled = count === 0; discard.disabled = count === 0; status.textContent = count ? `고친 칸 ${count} — [저장]해야 정본에 적힘(구조물도·원단위가 그 값을 씀)` : "상세 칸만 고침 · 자리·길이·높이·관경은 구조물 놓기(B05)에서"; }; active.onChange(); save.addEventListener("click", () => { void (async () => { save.disabled = true; status.textContent = "저장 중…"; try { const result = await fetch(url, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ base_revision: active.revision, edits: [...active.edits.values()], }), }); const body = (await result.json().catch(() => ({}))) as { message?: string }; if (!result.ok) throw new Error(body.message ?? `HTTP ${result.status}`); active.clear(); await load(); } catch (error) { status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`; save.disabled = false; } })(); }); discard.addEventListener("click", () => { active.clear(); void load(); }); const actions = el("div", "b08-sum__actions"); actions.append(save, discard, status); const total = payload.tables.reduce((sum, table) => sum + table.count, 0); root.replaceChildren( el( "p", "b08-sheet__head", `구조물 집계표 · ${payload.tables.length}종 · ${total}개소 (측점별 실치수 — 구조물도가 이 값을 씀)`, ), legend, actions, ...payload.notes.map((note) => el("p", "b08-grid__caption b08-grid__caption--warn", note)), ...(payload.tables.length ? payload.tables.map((table) => renderTable(table, active)) : [el("p", "b08-grid__caption", "놓인 구조물이 없음")]), ); } catch (error) { root.replaceChildren( el( "p", "b08-grid__caption b08-grid__caption--warn", `구조물 집계표를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`, ), ); } }; void load(); return root; }