feat(B07): 계획평면도 한 묶음 · 종류 더하기 · 지우기 · 대피소 자리 화면 (PLAN 60-1 · 60-3 · 60-4)

- 좌측 목록 계획평면도 컨테이너 하나 · 종류별 단추 + × 지우기(기본 넷 포함)
- 종류 더하기 = 이름 + 정보 조각 고르기 · 대피소 · 사토장 자리 = 측점 + 대피소/사토장(한 자리 둘 다)
- 고친 뒤 목록 · 도면을 서버에서 다시 받아 화면 다시 세움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-06 17:59:30 +09:00
co-authored by Claude Opus 5.5
parent 138e80c79f
commit 11337a1595
5 changed files with 346 additions and 5 deletions
@@ -19,6 +19,9 @@ export interface DesignDrawingItem {
label: string;
chainage_m: number | null;
confirmed: boolean;
/** 계획평면도만 — 종류 id · 이름(PLAN 60장). */
plan_kind?: string | null;
plan_kind_name?: string | null;
}
export interface CadDrawing {
@@ -170,6 +173,51 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P
);
}
/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */
export interface PlanKind {
id: string;
name: string;
pieces: string[];
}
export interface PlanMark {
chainage_m: number;
refuge: boolean;
spoil: boolean;
}
export interface PlanKindsState {
pieces: { id: string; name: string }[];
kinds: PlanKind[];
marks: PlanMark[];
}
export function fetchPlanKinds(projectId: string): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-kinds`);
}
export function addPlanKind(
projectId: string,
name: string,
pieces: string[],
): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-kinds`, {
method: "POST",
body: JSON.stringify({ name, pieces }),
});
}
export function deletePlanKind(projectId: string, kindId: string): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, {
method: "DELETE",
});
}
export function savePlanMarks(projectId: string, marks: PlanMark[]): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-marks`, {
method: "PUT",
body: JSON.stringify({ marks }),
});
}
/** 도각 편집 화면이 싣는 도각 한 장 (실치수 1:1, 잠금 없는 도면층). */
export interface FrameTemplateResponse {
status: string;
+17 -1
View File
@@ -420,7 +420,23 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass);
const drawingPanel = buildDrawingSidePanel(
drawings,
selectDrawing,
drawingError,
devBypass,
projectId
? {
projectId,
// 종류 · 대피소 자리를 고쳤다 — 목록 · 도면을 서버에서 다시 받아 화면을 다시 세운다.
onChanged: () => {
if (!mayDiscardEdits()) return;
cad.destroy();
void renderB07DesignDetail(root);
},
}
: undefined,
);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
+10 -4
View File
@@ -9,6 +9,7 @@
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
import { buildPlanSection, type PlanKindsControls } from "./B07_DesignDetail_UI_PlanKinds";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -28,10 +29,8 @@ export const DRAWING_GROUPS: readonly {
idPrefix?: string;
}[] = [
{ label: "표지", kind: "cover" },
{ label: "계획평면도(지형)", idPrefix: "plan_terrain" },
{ label: "계획평면도(노선배치도)", idPrefix: "plan_route" },
{ label: "계획평면도(배치도)", idPrefix: "plan_layout" },
{ label: "계획평면도(라이다)", idPrefix: "plan_lidar" },
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)를 한 묶음으로 — PLAN 60장.
{ label: "계획평면도", kind: "plan" },
{ label: "종단면도", kind: "longitudinal" },
{ label: "표준 횡단면도", kind: "cross_standard" },
{ label: "횡단면도", kind: "cross" },
@@ -46,6 +45,7 @@ export function buildDrawingSidePanel(
onSelect: (drawing: DesignDrawingItem) => void,
errorMessage?: string,
devBypass = false,
plan?: PlanKindsControls,
): HTMLDivElement {
const panel = document.createElement("div");
panel.className = "b07-drawing-list";
@@ -89,6 +89,12 @@ export function buildDrawingSidePanel(
};
for (const group of DRAWING_GROUPS) {
if (group.kind === "plan") {
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
const plans = drawings.filter((item) => item.kind === "plan");
panel.append(buildPlanSection(plans, drawingButton, plan));
continue;
}
const items = group.kind
? drawings.filter((item) => item.kind === group.kind)
: group.idPrefix
@@ -0,0 +1,196 @@
/* =============================================================================
* B07_DesignDetail_UI_PlanKinds.ts
* 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
* 사용자 추가)별 도면 단추, 종류 지우기 · 더하기, 대피소 · 사토장 자리 (PLAN 60장).
*
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
addPlanKind,
deletePlanKind,
fetchPlanKinds,
savePlanMarks,
type DesignDrawingItem,
type PlanKindsState,
type PlanMark,
} from "./B07_DesignDetail_Api_Fetch";
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 addKindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
const form = el("div", "b07-plan-tools__block");
form.append(el("strong", "", "종류 더하기"));
const name = el("input", "b07-plan-tools__name");
name.type = "text";
name.placeholder = "종류 이름";
name.maxLength = 40;
const boxes = state.pieces.map((piece) => {
const label = el("label", "b07-plan-tools__piece");
const box = el("input", "");
box.type = "checkbox";
box.value = piece.id;
label.append(box, document.createTextNode(piece.name));
return { label, box };
});
const pieceList = el("div", "b07-plan-tools__pieces");
pieceList.append(...boxes.map((item) => item.label));
const add = el("button", "b07-plan-tools__button", "더하기");
add.type = "button";
add.addEventListener("click", () => {
const pieces = boxes.filter((item) => item.box.checked).map((item) => item.box.value);
void run(() => addPlanKind(controls.projectId, name.value.trim(), pieces), controls);
});
form.append(name, pieceList, add);
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",
"구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(m)만 적으세요.",
),
);
const rows = el("div", "b07-plan-tools__marks");
const readers = new Map<HTMLElement, () => PlanMark>();
const addRow = (mark: PlanMark): void => {
const row = el("div", "b07-plan-tools__mark");
const chainage = el("input", "b07-plan-tools__chainage");
chainage.type = "number";
chainage.min = "0";
chainage.step = "0.1";
chainage.value = String(mark.chainage_m);
const flag = (text: string, checked: boolean): [HTMLLabelElement, HTMLInputElement] => {
const label = el("label", "b07-plan-tools__piece");
const box = el("input", "");
box.type = "checkbox";
box.checked = checked;
label.append(box, document.createTextNode(text));
return [label, box];
};
const [refugeLabel, refuge] = flag("대피소", mark.refuge);
const [spoilLabel, spoil] = flag("사토장", 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(chainage, refugeLabel, spoilLabel, remove);
readers.set(row, () => ({
chainage_m: Number(chainage.value),
refuge: refuge.checked,
spoil: spoil.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());
void run(() => savePlanMarks(controls.projectId, marks), 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(addKindForm(state, controls), marksForm(state, controls)))
.catch((error: unknown) => {
delete tools.dataset.loaded;
showToast(
error instanceof Error ? error.message : "종류 목록을 받지 못했습니다.",
"error",
);
});
});
section.append(tools);
}
return section;
}
@@ -234,3 +234,78 @@
}
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 더하기 · 대피소 자리(PLAN 60장) */
.b07-plan-kind {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.b07-plan-kind__head,
.b07-plan-tools__mark,
.b07-plan-tools__actions {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.b07-plan-kind__name {
flex: 1;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b07-plan-tools {
margin-top: var(--spacing-4);
font-size: var(--text-body-sm);
}
.b07-plan-tools summary {
color: var(--color-text-muted);
cursor: pointer;
}
.b07-plan-tools__block {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-8);
}
.b07-plan-tools__pieces {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}
.b07-plan-tools__piece {
display: flex;
align-items: center;
gap: var(--spacing-4);
white-space: nowrap;
}
.b07-plan-tools__hint {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b07-plan-tools__chainage {
width: 72px;
min-width: 0;
}
.b07-plan-tools__button,
.b07-plan-tools__remove {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
}
.b07-plan-tools__remove {
padding: 0 var(--spacing-8);
}