Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts
T
eomsangdonandClaude Opus 5.5 3e1186861a feat(B07): 계획평면도 종류 공통 편집 · 대피소 자리 측점 NO + 거리 · 공용 체크박스 (67-1~4)
- 종류 고르기(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 · 새 종류) → 든 조각 체크 → [업데이트] · 새 종류는 [추가]
- 대피소 · 사토장 자리 — B05 구조물 폼의 측점 NO + 거리 두 칸 · 체크박스 · [업데이트]
- ui_template_checkbox.ts · .css 신설 — 테마 토큰 색(밝음 · 어두움)
- 「더하기」 → 「추가」 · 「자리 더하기」 → 「자리 추가」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-06 23:09:17 +09:00

212 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B07_DesignDetail_UI_PlanKinds.ts
* 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
* 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장).
*
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
* 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸.
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import { showToast } from "@ui/ui_template_elements";
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
import {
addPlanKind,
deletePlanKind,
fetchPlanKinds,
savePlanMarks,
updatePlanKind,
type DesignDrawingItem,
type PlanKindsState,
type PlanMark,
} from "./B07_DesignDetail_Api_Fetch";
/** 종단이 없어 측점 간격을 못 받았을 때 — 서버 `infer_station_interval` 기본값과 같음. */
const DEFAULT_STATION_INTERVAL_M = 20;
export interface PlanKindsControls {
projectId: string;
/** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */
onChanged: () => void;
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
if (text) node.textContent = text;
return node;
}
/** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */
async function run(action: () => Promise<unknown>, controls: PlanKindsControls): Promise<void> {
try {
await action();
controls.onChanged();
} catch (error) {
showToast(error instanceof Error ? error.message : "계획평면도를 고치지 못했습니다.", "error");
}
}
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */
function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
const form = el("div", "b07-plan-tools__block");
form.append(el("strong", "", "종류"));
const pick = el("select", "b07-plan-tools__kind");
pick.append(
...state.kinds.map((kind) => new Option(kind.name, kind.id)),
new Option("새 종류", ""),
);
const name = el("input", "b07-plan-tools__name");
name.type = "text";
name.placeholder = "종류 이름";
name.maxLength = 40;
const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id }));
const pieceList = el("div", "b07-plan-tools__pieces");
pieceList.append(...boxes.map((box) => box.root));
const action = el("button", "b07-plan-tools__button");
action.type = "button";
const show = (): void => {
const kind = state.kinds.find((item) => item.id === pick.value);
name.value = kind?.name ?? "";
boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false));
action.textContent = kind ? "업데이트" : "추가";
};
pick.addEventListener("change", show);
show();
action.addEventListener("click", () => {
const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value);
const kindId = pick.value;
const label = name.value.trim();
void run(
() =>
kindId
? updatePlanKind(controls.projectId, kindId, label, pieces)
: addPlanKind(controls.projectId, label, pieces),
controls,
);
});
form.append(pick, name, pieceList, action);
return form;
}
function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
const form = el("div", "b07-plan-tools__block");
form.append(el("strong", "", "대피소 · 사토장 자리"));
form.append(
el(
"p",
"b07-plan-tools__hint",
"구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(NO + 거리)만 적으세요.",
),
);
const interval = state.station_interval_m || DEFAULT_STATION_INTERVAL_M;
const rows = el("div", "b07-plan-tools__marks");
const readers = new Map<HTMLElement, () => PlanMark | null>();
const addRow = (mark: PlanMark): void => {
const row = el("div", "b07-plan-tools__mark");
const station = stationFields("측점", () => interval);
station.write(mark.chainage_m, interval);
const refuge = createCheckbox("대피소", { checked: mark.refuge });
const spoil = createCheckbox("사토장", { checked: mark.spoil });
const remove = el("button", "b07-plan-tools__remove", "×");
remove.type = "button";
remove.title = "이 자리 빼기";
remove.addEventListener("click", () => {
readers.delete(row);
row.remove();
});
row.append(station.wrap, refuge.root, spoil.root, remove);
readers.set(row, () => {
const chainage = station.read(interval, true);
return chainage === null
? null
: { chainage_m: chainage, refuge: refuge.input.checked, spoil: spoil.input.checked };
});
rows.append(row);
};
state.marks.forEach(addRow);
const more = el("button", "b07-plan-tools__button", "자리 추가");
more.type = "button";
more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false }));
const save = el("button", "b07-plan-tools__button", "업데이트");
save.type = "button";
save.addEventListener("click", () => {
const marks = [...readers.values()].map((read) => read());
if (marks.some((mark) => mark === null)) {
showToast("측점 NO + 거리를 바르게 적어 주세요.", "error");
return;
}
void run(() => savePlanMarks(controls.projectId, marks as PlanMark[]), controls);
});
const actions = el("div", "b07-plan-tools__actions");
actions.append(more, save);
form.append(rows, actions);
return form;
}
/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */
export function buildPlanSection(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
controls?: PlanKindsControls,
): HTMLElement {
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
section.dataset.kind = "plan";
section.append(el("h3", "ui-collapsible__title", `계획평면도 ${items.length}`));
const kinds = new Map<string, DesignDrawingItem[]>();
for (const item of items) {
const key = item.plan_kind ?? item.id;
kinds.set(key, [...(kinds.get(key) ?? []), item]);
}
for (const [kindId, sheets] of kinds) {
const name = sheets[0].plan_kind_name ?? sheets[0].label;
const block = el("div", "b07-plan-kind");
block.dataset.planKind = kindId;
// 한 장이면 [도면 단추][×] 한 줄 · 여러 장이면 [이름][×] 아래 장 단추.
const head = el("div", "b07-plan-kind__head");
const buttons = sheets.map((drawing, index) =>
makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name),
);
head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]);
if (controls) {
const remove = el("button", "b07-plan-tools__remove", "×");
remove.type = "button";
remove.title = `「${name}」 종류 지우기`;
remove.addEventListener("click", () => {
if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`))
return;
void run(() => deletePlanKind(controls.projectId, kindId), controls);
});
head.append(remove);
}
block.append(head, ...(sheets.length > 1 ? buttons : []));
section.append(block);
}
if (controls) {
// 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
const tools = el("details", "b07-plan-tools");
tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리"));
tools.addEventListener("toggle", () => {
if (!tools.open || tools.dataset.loaded) return;
tools.dataset.loaded = "true";
void fetchPlanKinds(controls.projectId)
.then((state) => tools.append(kindForm(state, controls), marksForm(state, controls)))
.catch((error: unknown) => {
delete tools.dataset.loaded;
showToast(
error instanceof Error ? error.message : "종류 목록을 받지 못했습니다.",
"error",
);
});
});
section.append(tools);
}
return section;
}