From 11337a15956c722574d9fc2108a136fa66aac351 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 17:59:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=ED=95=9C=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EB=8D=94=ED=95=98=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EB=8C=80=ED=94=BC?= =?UTF-8?q?=EC=86=8C=20=EC=9E=90=EB=A6=AC=20=ED=99=94=EB=A9=B4=20(PLAN=206?= =?UTF-8?q?0-1=20=C2=B7=2060-3=20=C2=B7=2060-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 목록 계획평면도 컨테이너 하나 · 종류별 단추 + × 지우기(기본 넷 포함) - 종류 더하기 = 이름 + 정보 조각 고르기 · 대피소 · 사토장 자리 = 측점 + 대피소/사토장(한 자리 둘 다) - 고친 뒤 목록 · 도면을 서버에서 다시 받아 화면 다시 세움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Api_Fetch.ts | 48 +++++ B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 18 +- .../B07_DesignDetail_UI_Panels.ts | 14 +- .../B07_DesignDetail_UI_PlanKinds.ts | 196 ++++++++++++++++++ .../B07_DesignDetail_UI_Style.css | 75 +++++++ 5 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 51c9d19f6..c7e856856 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -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 { + return requestJson(`/projects/${projectId}/plan-kinds`); +} + +export function addPlanKind( + projectId: string, + name: string, + pieces: string[], +): Promise { + return requestJson(`/projects/${projectId}/plan-kinds`, { + method: "POST", + body: JSON.stringify({ name, pieces }), + }); +} + +export function deletePlanKind(projectId: string, kindId: string): Promise { + return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { + method: "DELETE", + }); +} + +export function savePlanMarks(projectId: string, marks: PlanMark[]): Promise { + return requestJson(`/projects/${projectId}/plan-marks`, { + method: "PUT", + body: JSON.stringify({ marks }), + }); +} + /** 도각 편집 화면이 싣는 도각 한 장 (실치수 1:1, 잠금 없는 도면층). */ export interface FrameTemplateResponse { status: string; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 5cd7b9bca..8625e4f5f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -420,7 +420,23 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { 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로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index a3fde8268..59bfc6f72 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -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 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts new file mode 100644 index 000000000..65a634851 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -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( + 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, controls: PlanKindsControls): Promise { + 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 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(); + 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; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 3af80ed70..d278b1b24 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -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); +}