Files
Aislo/B08_Quantity/B08_Quantity_UI_Options.ts
T
eomsangdonandClaude Opus 5.5 14c3f01f11 fix(B08): 임의 · 실무 값 빈칸 — 관경 · 련 미입력 · 1본 프로젝트 값만 · 거창 계수 비율 미정 · 수심비 빈칸(143-2)
- 집계표 — 관경 · 련 빈칸 = 「미입력」 열(or 1000 · or 1 지움) · 관경 빈칸 흙막이 빈 높이 미입력 · 1본 = 프로젝트 층 변수만(시스템 층 폴백 지움)
- 토공집계표 — 성토면다짐 · 초류종자살포 · 지장목제거 = 빈칸 + 「비율 미정」 · 구조물 쪽 늘 빈 줄 8 뺌(나머지 7 유지)
- 수리 박스 — 수심비 처음 값 빈칸 · 빈칸이면 수심 · 유출량 멈춤 · 칸 옆 알림
- 좌측 패널 1본 읽기 전 빈칸 · 시험 test_69 · 98 · 122 · 73_11 고침 + 새 test_143_2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-09 14:56:57 +09:00

257 lines
9.5 KiB
TypeScript

/* =============================================================================
* 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<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;
}
/** 지금 할증 % — 캐시 칸 → 저장본. */
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<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.연암,
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<SavedOptions>(`/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;
}