From 3e1186861a58790360e8773b763c225a74a3c76f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:09:17 +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=EC=A2=85=EB=A5=98=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=C2=B7=20=EB=8C=80=ED=94=BC=EC=86=8C=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EC=B8=A1=EC=A0=90=20NO=20+=20=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EA=B3=B5=EC=9A=A9=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20(67-1~4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종류 고르기(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 · 새 종류) → 든 조각 체크 → [업데이트] · 새 종류는 [추가] - 대피소 · 사토장 자리 — B05 구조물 폼의 측점 NO + 거리 두 칸 · 체크박스 · [업데이트] - ui_template_checkbox.ts · .css 신설 — 테마 토큰 색(밝음 · 어두움) - 「더하기」 → 「추가」 · 「자리 더하기」 → 「자리 추가」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Api_Fetch.ts | 14 +++ .../B07_DesignDetail_UI_PlanKinds.ts | 117 ++++++++++-------- .../B07_DesignDetail_UI_Style.css | 35 ++++-- ui_template/ui_template_checkbox.css | 34 +++++ ui_template/ui_template_checkbox.ts | 32 +++++ 5 files changed, 173 insertions(+), 59 deletions(-) create mode 100644 ui_template/ui_template_checkbox.css create mode 100644 ui_template/ui_template_checkbox.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index c7e856856..9f712e219 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -188,6 +188,8 @@ export interface PlanKindsState { pieces: { id: string; name: string }[]; kinds: PlanKind[]; marks: PlanMark[]; + /** 종단 측점 간격(m) — 측점 NO + 거리 입력용(GET 만 · 종단이 없으면 null). */ + station_interval_m?: number | null; } export function fetchPlanKinds(projectId: string): Promise { @@ -205,6 +207,18 @@ export function addPlanKind( }); } +export function updatePlanKind( + projectId: string, + kindId: string, + name: string, + pieces: string[], +): Promise { + return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { + method: "PUT", + body: JSON.stringify({ name, pieces }), + }); +} + export function deletePlanKind(projectId: string, kindId: string): Promise { return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { method: "DELETE", diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 65a634851..7ec37a5f6 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -1,22 +1,29 @@ /* ============================================================================= * B07_DesignDetail_UI_PlanKinds.ts * 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · - * 사용자 추가)별 도면 단추, 종류 지우기 · 더하기, 대피소 · 사토장 자리 (PLAN 60장). + * 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (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; /** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */ @@ -44,30 +51,45 @@ async function run(action: () => Promise, controls: PlanKindsControls): } } -function addKindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { +/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */ +function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); - form.append(el("strong", "", "종류 더하기")); + 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) => { - 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 boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id })); 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); + 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(name, pieceList, add); + form.append(pick, name, pieceList, action); return form; } @@ -78,28 +100,18 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem el( "p", "b07-plan-tools__hint", - "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(m)만 적으세요.", + "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(NO + 거리)만 적으세요.", ), ); + const interval = state.station_interval_m || DEFAULT_STATION_INTERVAL_M; const rows = el("div", "b07-plan-tools__marks"); - const readers = new Map PlanMark>(); + const readers = new Map PlanMark | null>(); 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 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 = "이 자리 빼기"; @@ -107,23 +119,28 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem 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, - })); + 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", "자리 더하기"); + 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", "자리 저장"); + 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); + 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); @@ -131,7 +148,7 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem return form; } -/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 더하기 · 대피소 자리. */ +/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */ export function buildPlanSection( items: DesignDrawingItem[], makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, @@ -161,9 +178,7 @@ export function buildPlanSection( remove.type = "button"; remove.title = `「${name}」 종류 지우기`; remove.addEventListener("click", () => { - if ( - !window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 종류 더하기로 새로 만듭니다.`) - ) + if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`)) return; void run(() => deletePlanKind(controls.projectId, kindId), controls); }); @@ -174,14 +189,14 @@ export function buildPlanSection( } if (controls) { - // 더하기 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. + // 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. const tools = el("details", "b07-plan-tools"); - tools.append(el("summary", "", "종류 더하기 · 대피소 자리")); + 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))) + .then((state) => tools.append(kindForm(state, controls), marksForm(state, controls))) .catch((error: unknown) => { delete tools.dataset.loaded; showToast( diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index d278b1b24..d098787a4 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -235,7 +235,7 @@ /* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */ -/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 더하기 · 대피소 자리(PLAN 60장) */ +/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 종류 추가 · 업데이트 · 대피소 자리(PLAN 60 · 67장) */ .b07-plan-kind { display: flex; flex-direction: column; @@ -278,11 +278,9 @@ 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__kind, +.b07-plan-tools__name { + min-width: 0; } .b07-plan-tools__hint { @@ -291,9 +289,30 @@ font-size: var(--text-caption); } -.b07-plan-tools__chainage { - width: 72px; +/* 측점 NO + 거리 두 칸(B05 stationFields) — 한 줄: [측점][NO][+m] */ +.b07-plan-tools__mark .b05-route__field { + display: flex; + flex: 1; + align-items: center; + gap: var(--spacing-4); min-width: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.b07-plan-tools__mark .b05-structure__station-pair { + display: flex; + gap: var(--spacing-4); + min-width: 0; +} + +.b07-plan-tools__mark .b05-structure__station-pair input { + width: 52px; + min-width: 0; +} + +.b07-plan-tools__mark input.is-invalid { + border-color: var(--color-danger); } .b07-plan-tools__button, diff --git a/ui_template/ui_template_checkbox.css b/ui_template/ui_template_checkbox.css new file mode 100644 index 000000000..c896db75b --- /dev/null +++ b/ui_template/ui_template_checkbox.css @@ -0,0 +1,34 @@ +/* 공용 체크박스(ui_template_checkbox.ts) — 글 한 줄 · 색은 테마 토큰 */ +.ui-checkbox { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + min-width: 0; + color: var(--color-text-body); + white-space: nowrap; + cursor: pointer; +} + +.ui-checkbox__input { + flex: 0 0 auto; + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--color-primary); + cursor: pointer; +} + +.ui-checkbox__input:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +.ui-checkbox__text { + overflow: hidden; + text-overflow: ellipsis; +} + +.ui-checkbox:has(.ui-checkbox__input:disabled) { + color: var(--color-text-muted); + cursor: default; +} diff --git a/ui_template/ui_template_checkbox.ts b/ui_template/ui_template_checkbox.ts new file mode 100644 index 000000000..0f8845a31 --- /dev/null +++ b/ui_template/ui_template_checkbox.ts @@ -0,0 +1,32 @@ +/* ============================================================================= + * ui_template_checkbox.ts + * 공용 체크박스 — [□ 글] 한 줄 · 색은 테마 토큰(밝음 · 어두움 같이). 줄 세우기는 부르는 쪽 몫. + * + * const box = createCheckbox("등고선", { checked: true, value: "contour" }); + * panel.append(box.root); // 읽기 = box.input.checked · box.input.value + * ========================================================================== */ + +import "./ui_template_checkbox.css"; + +export interface Checkbox { + root: HTMLLabelElement; + input: HTMLInputElement; +} + +export function createCheckbox( + text: string, + options: { checked?: boolean; value?: string } = {}, +): Checkbox { + const root = document.createElement("label"); + root.className = "ui-checkbox"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.className = "ui-checkbox__input"; + input.checked = options.checked ?? false; + if (options.value !== undefined) input.value = options.value; + const caption = document.createElement("span"); + caption.className = "ui-checkbox__text"; + caption.textContent = text; + root.append(input, caption); + return { root, input }; +}