Files
Aislo/B08_Quantity/B08_Quantity_UI_Options.ts
T
eomsangdonandClaude Opus 5.5 6a9506f1fb feat(b08): 좌측 패널 컨테이너 셋(구조물 · 토공 · 수리) + [저장] 줄 · 옵션 저장 길 GET/PUT quantity/options(122-1)
- 구조물 › 배관 1본 길이 · 토공 › 연암 · 경암 % 입력(공용 compact 입력 · 접이 카드)
- 조작 = 캐시 b08-options · [저장] = 프로젝트 층 M02 구조물집계표 변수 · project_settings rock_ratios_pct
- 브라우저 짝 식 파일 B08_Quantity_Options_Calc.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-08 18:43:58 +09:00

202 lines
7.5 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Options.ts
* 좌측 패널(PLAN 122) — 메인 탭과 같은 컨테이너 셋(구조물 · 토공 · 수리집수면적표 · 접이) + [저장] 줄.
*
* 구조물 › 배관 1본 길이(m) — 정본 = 프로젝트 층 M02 구조물집계표 변수 `파형강관_1본_m`
* 토공 › 연암 % · 경암 %(암 전체 기준 · 한쪽 고치면 다른 쪽 = 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";
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;
}
interface SavedOptions {
one_pipe_length_m: number | null;
rock_ratios_pct: { 연암: number; 경암: number };
}
const changes = new EventTarget();
const readDraft = (projectId: string): OptionsDraft =>
readState<OptionsDraft>("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;
}
/** 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 rockRow = document.createElement("div");
rockRow.className = "b08-options__row";
rockRow.append(soft.root, hard.root);
let saved: { one: number | null; soft: number } = { one: 8, soft: 90 };
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 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);
const saveAll = async (): Promise<void> => {
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<SavedOptions>(`/projects/${projectId}/quantity/options`, {
method: "PUT",
body: JSON.stringify(draft),
});
saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_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),
card("earthwork", "B08_Quantity_Tab_Earthwork", rockRow, hint("암 전체 기준 · 두 칸 합 100")),
card("hydrology", "B08_Quantity_Tab_Hydrology", hint("다음 옵션 자리")),
actions,
);
attachCollapsible(panel);
show();
if (projectId)
request<SavedOptions>(`/projects/${projectId}/quantity/options`).then(
(body) => {
saved = { one: body.one_pipe_length_m, soft: body.rock_ratios_pct.연암 };
show();
},
(error: unknown) => showToast(`옵션을 불러오지 못했습니다 — ${String(error)}`, "error"),
);
return panel;
}