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
This commit is contained in:
@@ -188,6 +188,8 @@ export interface PlanKindsState {
|
|||||||
pieces: { id: string; name: string }[];
|
pieces: { id: string; name: string }[];
|
||||||
kinds: PlanKind[];
|
kinds: PlanKind[];
|
||||||
marks: PlanMark[];
|
marks: PlanMark[];
|
||||||
|
/** 종단 측점 간격(m) — 측점 NO + 거리 입력용(GET 만 · 종단이 없으면 null). */
|
||||||
|
station_interval_m?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchPlanKinds(projectId: string): Promise<PlanKindsState> {
|
export function fetchPlanKinds(projectId: string): Promise<PlanKindsState> {
|
||||||
@@ -205,6 +207,18 @@ export function addPlanKind(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updatePlanKind(
|
||||||
|
projectId: string,
|
||||||
|
kindId: string,
|
||||||
|
name: string,
|
||||||
|
pieces: string[],
|
||||||
|
): Promise<PlanKindsState> {
|
||||||
|
return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ name, pieces }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function deletePlanKind(projectId: string, kindId: string): Promise<PlanKindsState> {
|
export function deletePlanKind(projectId: string, kindId: string): Promise<PlanKindsState> {
|
||||||
return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, {
|
return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* B07_DesignDetail_UI_PlanKinds.ts
|
* B07_DesignDetail_UI_PlanKinds.ts
|
||||||
* 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
|
* 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
|
||||||
* 사용자 추가)별 도면 단추, 종류 지우기 · 더하기, 대피소 · 사토장 자리 (PLAN 60장).
|
* 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장).
|
||||||
*
|
*
|
||||||
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
|
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
|
||||||
|
* 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||||
import { showToast } from "@ui/ui_template_elements";
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
|
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||||
import {
|
import {
|
||||||
addPlanKind,
|
addPlanKind,
|
||||||
deletePlanKind,
|
deletePlanKind,
|
||||||
fetchPlanKinds,
|
fetchPlanKinds,
|
||||||
savePlanMarks,
|
savePlanMarks,
|
||||||
|
updatePlanKind,
|
||||||
type DesignDrawingItem,
|
type DesignDrawingItem,
|
||||||
type PlanKindsState,
|
type PlanKindsState,
|
||||||
type PlanMark,
|
type PlanMark,
|
||||||
} from "./B07_DesignDetail_Api_Fetch";
|
} from "./B07_DesignDetail_Api_Fetch";
|
||||||
|
|
||||||
|
/** 종단이 없어 측점 간격을 못 받았을 때 — 서버 `infer_station_interval` 기본값과 같음. */
|
||||||
|
const DEFAULT_STATION_INTERVAL_M = 20;
|
||||||
|
|
||||||
export interface PlanKindsControls {
|
export interface PlanKindsControls {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
/** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */
|
/** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */
|
||||||
@@ -44,30 +51,45 @@ async function run(action: () => Promise<unknown>, controls: PlanKindsControls):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addKindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
|
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */
|
||||||
|
function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
|
||||||
const form = el("div", "b07-plan-tools__block");
|
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");
|
const name = el("input", "b07-plan-tools__name");
|
||||||
name.type = "text";
|
name.type = "text";
|
||||||
name.placeholder = "종류 이름";
|
name.placeholder = "종류 이름";
|
||||||
name.maxLength = 40;
|
name.maxLength = 40;
|
||||||
const boxes = state.pieces.map((piece) => {
|
const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id }));
|
||||||
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");
|
const pieceList = el("div", "b07-plan-tools__pieces");
|
||||||
pieceList.append(...boxes.map((item) => item.label));
|
pieceList.append(...boxes.map((box) => box.root));
|
||||||
const add = el("button", "b07-plan-tools__button", "더하기");
|
const action = el("button", "b07-plan-tools__button");
|
||||||
add.type = "button";
|
action.type = "button";
|
||||||
add.addEventListener("click", () => {
|
const show = (): void => {
|
||||||
const pieces = boxes.filter((item) => item.box.checked).map((item) => item.box.value);
|
const kind = state.kinds.find((item) => item.id === pick.value);
|
||||||
void run(() => addPlanKind(controls.projectId, name.value.trim(), pieces), controls);
|
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;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,28 +100,18 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem
|
|||||||
el(
|
el(
|
||||||
"p",
|
"p",
|
||||||
"b07-plan-tools__hint",
|
"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 rows = el("div", "b07-plan-tools__marks");
|
||||||
const readers = new Map<HTMLElement, () => PlanMark>();
|
const readers = new Map<HTMLElement, () => PlanMark | null>();
|
||||||
const addRow = (mark: PlanMark): void => {
|
const addRow = (mark: PlanMark): void => {
|
||||||
const row = el("div", "b07-plan-tools__mark");
|
const row = el("div", "b07-plan-tools__mark");
|
||||||
const chainage = el("input", "b07-plan-tools__chainage");
|
const station = stationFields("측점", () => interval);
|
||||||
chainage.type = "number";
|
station.write(mark.chainage_m, interval);
|
||||||
chainage.min = "0";
|
const refuge = createCheckbox("대피소", { checked: mark.refuge });
|
||||||
chainage.step = "0.1";
|
const spoil = createCheckbox("사토장", { checked: mark.spoil });
|
||||||
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", "×");
|
const remove = el("button", "b07-plan-tools__remove", "×");
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.title = "이 자리 빼기";
|
remove.title = "이 자리 빼기";
|
||||||
@@ -107,23 +119,28 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem
|
|||||||
readers.delete(row);
|
readers.delete(row);
|
||||||
row.remove();
|
row.remove();
|
||||||
});
|
});
|
||||||
row.append(chainage, refugeLabel, spoilLabel, remove);
|
row.append(station.wrap, refuge.root, spoil.root, remove);
|
||||||
readers.set(row, () => ({
|
readers.set(row, () => {
|
||||||
chainage_m: Number(chainage.value),
|
const chainage = station.read(interval, true);
|
||||||
refuge: refuge.checked,
|
return chainage === null
|
||||||
spoil: spoil.checked,
|
? null
|
||||||
}));
|
: { chainage_m: chainage, refuge: refuge.input.checked, spoil: spoil.input.checked };
|
||||||
|
});
|
||||||
rows.append(row);
|
rows.append(row);
|
||||||
};
|
};
|
||||||
state.marks.forEach(addRow);
|
state.marks.forEach(addRow);
|
||||||
const more = el("button", "b07-plan-tools__button", "자리 더하기");
|
const more = el("button", "b07-plan-tools__button", "자리 추가");
|
||||||
more.type = "button";
|
more.type = "button";
|
||||||
more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false }));
|
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.type = "button";
|
||||||
save.addEventListener("click", () => {
|
save.addEventListener("click", () => {
|
||||||
const marks = [...readers.values()].map((read) => read());
|
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");
|
const actions = el("div", "b07-plan-tools__actions");
|
||||||
actions.append(more, save);
|
actions.append(more, save);
|
||||||
@@ -131,7 +148,7 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem
|
|||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 더하기 · 대피소 자리. */
|
/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */
|
||||||
export function buildPlanSection(
|
export function buildPlanSection(
|
||||||
items: DesignDrawingItem[],
|
items: DesignDrawingItem[],
|
||||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||||
@@ -161,9 +178,7 @@ export function buildPlanSection(
|
|||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.title = `「${name}」 종류 지우기`;
|
remove.title = `「${name}」 종류 지우기`;
|
||||||
remove.addEventListener("click", () => {
|
remove.addEventListener("click", () => {
|
||||||
if (
|
if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`))
|
||||||
!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 종류 더하기로 새로 만듭니다.`)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
void run(() => deletePlanKind(controls.projectId, kindId), controls);
|
void run(() => deletePlanKind(controls.projectId, kindId), controls);
|
||||||
});
|
});
|
||||||
@@ -174,14 +189,14 @@ export function buildPlanSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (controls) {
|
if (controls) {
|
||||||
// 더하기 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
|
// 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
|
||||||
const tools = el("details", "b07-plan-tools");
|
const tools = el("details", "b07-plan-tools");
|
||||||
tools.append(el("summary", "", "종류 더하기 · 대피소 자리"));
|
tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리"));
|
||||||
tools.addEventListener("toggle", () => {
|
tools.addEventListener("toggle", () => {
|
||||||
if (!tools.open || tools.dataset.loaded) return;
|
if (!tools.open || tools.dataset.loaded) return;
|
||||||
tools.dataset.loaded = "true";
|
tools.dataset.loaded = "true";
|
||||||
void fetchPlanKinds(controls.projectId)
|
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) => {
|
.catch((error: unknown) => {
|
||||||
delete tools.dataset.loaded;
|
delete tools.dataset.loaded;
|
||||||
showToast(
|
showToast(
|
||||||
|
|||||||
@@ -235,7 +235,7 @@
|
|||||||
|
|
||||||
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
|
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
|
||||||
|
|
||||||
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 더하기 · 대피소 자리(PLAN 60장) */
|
/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 종류 추가 · 업데이트 · 대피소 자리(PLAN 60 · 67장) */
|
||||||
.b07-plan-kind {
|
.b07-plan-kind {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -278,11 +278,9 @@
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.b07-plan-tools__piece {
|
.b07-plan-tools__kind,
|
||||||
display: flex;
|
.b07-plan-tools__name {
|
||||||
align-items: center;
|
min-width: 0;
|
||||||
gap: var(--spacing-4);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.b07-plan-tools__hint {
|
.b07-plan-tools__hint {
|
||||||
@@ -291,9 +289,30 @@
|
|||||||
font-size: var(--text-caption);
|
font-size: var(--text-caption);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b07-plan-tools__chainage {
|
/* 측점 NO + 거리 두 칸(B05 stationFields) — 한 줄: [측점][NO][+m] */
|
||||||
width: 72px;
|
.b07-plan-tools__mark .b05-route__field {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-4);
|
||||||
min-width: 0;
|
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,
|
.b07-plan-tools__button,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user