Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
256 lines
9.5 KiB
TypeScript
256 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);
|
|
|
|
let saved: { one: number | null; soft: number; surcharge: Cells } = {
|
|
one: 8,
|
|
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;
|
|
}
|