/* ============================================================================= * B08_Quantity_UI_Options.ts * 좌측 패널(PLAN 122) — 메인 탭과 같은 컨테이너 셋(구조물 · 토공 · 수리집수면적표 · 접이) + [저장] 줄. * * 구조물 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m` * 구조물 › 할증 모래 · 자갈 · 시멘트 %(PLAN 124) — 총괄자재수량집계표 그 세 줄 · 0~100 * 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 100 − 값 · 0~100) * 수리집수면적표 — 빈 자리(다음 옵션) * 조작 = 캐시 `b08-options` 즉시 + 열린 표가 같은 식으로 바로 다시 셈(`…_Options_Calc`) · * [저장] = PUT …/quantity/options → 캐시 비움 · 열린 탭을 서버 정본으로 다시 그림 · 자동 저장 없음. * ========================================================================== */ import { createButton, createInputField, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { clampPct } from "./B08_Quantity_Options_Calc"; import { PANEL_SURCHARGE, type Cells } from "./B08_Quantity_Material_Calc"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** 조작 중 값 — 없는 칸은 저장본 그대로 · 1본 null = 빈칸. */ export interface OptionsDraft { one_pipe_length_m?: number | null; soft_rock_pct?: number; material_surcharge_pct?: Cells; } interface SavedOptions { one_pipe_length_m: number | null; rock_ratios_pct: { 연암: number; 경암: number }; material_surcharge_pct: Cells; } /** 할증 칸 — [키, 이름] · 키 = 총괄 줄 */ const SURCHARGE_FIELDS: Array<[string, string]> = [ ["sand", "모래"], ["gravel", "자갈"], ["cement", "시멘트"], ]; const changes = new EventTarget(); const readDraft = (projectId: string): OptionsDraft => readState("b08-options", projectId) ?? {}; /** 지금 1본 길이 — 캐시 → 저장본. */ export function liveOnePipe(projectId: string, saved: number | null): number | null { const draft = readDraft(projectId); return draft.one_pipe_length_m !== undefined ? draft.one_pipe_length_m : saved; } /** 지금 연암 % — 캐시 → 저장본. */ export function liveSoftRock(projectId: string, saved: number): number { return readDraft(projectId).soft_rock_pct ?? saved; } /** 지금 할증 % — 캐시 칸 → 저장본. */ export function liveSurcharge(projectId: string, saved: Cells): Cells { return { ...saved, ...readDraft(projectId).material_surcharge_pct }; } /** host 가 화면에 붙어 있는 동안 옵션이 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌. */ export function onOptionsChange(host: HTMLElement, fn: () => void): void { const listener = (): void => { if (host.isConnected) fn(); else changes.removeEventListener("change", listener); }; changes.addEventListener("change", listener); } const pctText = (value: number): string => String(Math.round(value * 1e6) / 1e6); function card(id: string, title: keyof typeof ui_locales, ...body: HTMLElement[]): HTMLElement { const section = document.createElement("section"); section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; section.dataset.b08Options = id; const heading = document.createElement("h3"); heading.className = "ui-collapsible__title"; heading.textContent = L(title); section.append(heading, ...body); return section; } function hint(text: string): HTMLElement { const p = document.createElement("p"); p.className = "b08-options__hint"; p.textContent = text; return p; } /** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림. */ export function createOptionsPanel(projectId: string | null, onSaved: () => void): HTMLElement { const one = createInputField({ label: "배관 1본 길이 (m)", type: "number", compact: true, min: 0, }); const soft = createInputField({ label: "연암 (%)", type: "number", compact: true, min: 0, max: 100, }); const hard = createInputField({ label: "경암 (%)", type: "number", compact: true, min: 0, max: 100, }); one.input.dataset.b08Option = "one_pipe_length_m"; soft.input.dataset.b08Option = "soft_rock_pct"; hard.input.dataset.b08Option = "hard_rock_pct"; const surcharge = SURCHARGE_FIELDS.map(([key, name]) => { const field = createInputField({ label: `${name} (%)`, type: "number", compact: true, min: 0, max: 100, }); field.input.dataset.b08Option = `surcharge_${key}`; return [key, field.input, field.root] as const; }); const surchargeRow = document.createElement("div"); surchargeRow.className = "b08-options__row b08-options__row--3"; surchargeRow.append(...surcharge.map(([, , root]) => root)); const surchargeBlock = [surchargeRow, hint("할증 — 총괄 자재 표 세 줄 · 기본 품셈 1-3-1")]; const rockRow = document.createElement("div"); rockRow.className = "b08-options__row"; rockRow.append(soft.root, hard.root); // 1본 = 프로젝트 값만(읽기 전 빈칸 · 143-2) let saved: { one: number | null; soft: number; surcharge: Cells } = { one: null, soft: 90, surcharge: PANEL_SURCHARGE, }; const show = (): void => { const draft = projectId ? readDraft(projectId) : {}; const oneM = draft.one_pipe_length_m !== undefined ? draft.one_pipe_length_m : saved.one; const softPct = draft.soft_rock_pct ?? saved.soft; one.input.value = oneM == null ? "" : String(oneM); soft.input.value = pctText(softPct); hard.input.value = pctText(100 - softPct); const pct = { ...saved.surcharge, ...draft.material_surcharge_pct }; for (const [key, input] of surcharge) input.value = pctText(pct[key] ?? 0); }; const put = (patch: OptionsDraft): void => { if (!projectId) return; writeState("b08-options", { ...readDraft(projectId), ...patch }, projectId); changes.dispatchEvent(new Event("change")); }; one.input.addEventListener("input", () => { const value = Number(one.input.value); put({ one_pipe_length_m: one.input.value.trim() && value > 0 ? value : null }); }); // 한쪽을 고치면 다른 쪽 = 100 − 값 · 칸을 떠나면 자른 값으로 다시 씀 const pair = (from: HTMLInputElement, other: HTMLInputElement, isSoft: boolean): void => { from.addEventListener("input", () => { const value = from.value.trim() ? clampPct(Number(from.value)) : null; if (value === null) return; other.value = pctText(100 - value); put({ soft_rock_pct: isSoft ? value : 100 - value }); }); from.addEventListener("change", show); }; pair(soft.input, hard.input, true); pair(hard.input, soft.input, false); for (const [key, input] of surcharge) { input.addEventListener("input", () => { const value = input.value.trim() ? clampPct(Number(input.value)) : null; if (value === null || !projectId) return; const pct = readDraft(projectId).material_surcharge_pct; put({ material_surcharge_pct: { ...pct, [key]: value } }); }); input.addEventListener("change", show); } const saveAll = async (): Promise => { if (!projectId) return; const draft = readDraft(projectId); if (draft.one_pipe_length_m === null) { showToast("배관 1본 길이를 넣으세요 — 0 보다 큰 값.", "error"); return; } showLoadingOverlay(); try { const body = await request(`/projects/${projectId}/quantity/options`, { method: "PUT", body: JSON.stringify(draft), }); saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암, surcharge: body.material_surcharge_pct, }; clearState("b08-options", projectId); show(); showToast("수량 산출 옵션을 저장했습니다.", "success"); onSaved(); } catch (error) { showToast(`저장하지 못했습니다 — ${String(error)}`, "error"); } finally { hideLoadingOverlay(); } }; const save = createButton({ label: L("B08_Quantity_Btn_Save"), variant: "filled", onClick: () => void saveAll(), }); save.dataset.b08OptionsSave = ""; const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; actions.append(save); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel b08-options"; panel.append( card("structure", "B08_Quantity_Tab_Structure", one.root, ...surchargeBlock), card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")), card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")), actions, ); attachCollapsible(panel); show(); if (projectId) request(`/projects/${projectId}/quantity/options`).then( (body) => { saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암, surcharge: body.material_surcharge_pct, }; show(); }, (error: unknown) => showToast(`옵션을 불러오지 못했습니다 — ${String(error)}`, "error"), ); return panel; }