feat(529-1): D01 예정노선 등록 · E01 안정성평가 만들기 화면
- D01 칸: 사업연도 시작 · 끝 · 사업지역 · 종류 「노선 계획」 고정 · 임도 종류(B02 값) · 설계속도(종류별 고를 값 · 기본 최저 = resolve_design_speed) · 프로젝트명(B02 이름 조합기 · 연도 자리 「시작~끝」) · 노선 시작 · 종료 누가거리 · 도면 칸 없음 - E01: 대상 임도(source-candidates = B06 완료만) · 이름(손대기 전 「원본 이름 안정성평가」) - [등록] = POST /api/d01/project · /api/e01/project → 현재 프로젝트 · 첫 단계(D02 · E02) · 공용 createGroupProject - 설계속도 선택지 = B05 SPEED_CHOICES(파이썬 거울 시험 있음) · 모양 = B02 등록 CSS 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,14 @@
|
||||
* 페이지 길 = 각 폴더 `draft/<폴더>_Route.ts` · 본문이 생기면 그 폴더 페이지로 갈아 끼움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { PLAN_STEP_ROUTES, STAB_STEP_ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
API_BASE_URL,
|
||||
CURRENT_PROJECT_ID_KEY,
|
||||
PLAN_STEP_ROUTES,
|
||||
STAB_STEP_ROUTES,
|
||||
type RoutePath,
|
||||
} from "@config/config_frontend";
|
||||
import { el, hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { navigateTo, type PageRenderer } from "./router";
|
||||
@@ -58,3 +64,35 @@ export const groupStepPage =
|
||||
(route: RoutePath): PageRenderer =>
|
||||
(root) =>
|
||||
mountGroupStep(root, route);
|
||||
|
||||
/** 등록 화면 [등록](D01 · E01) — POST → 현재 프로젝트로 기억 → 첫 단계로 · 실패 = 서버 까닭 알림 */
|
||||
export async function createGroupProject(
|
||||
path: string,
|
||||
body: object,
|
||||
firstStep: RoutePath,
|
||||
): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as {
|
||||
project_id?: string;
|
||||
detail?: unknown;
|
||||
};
|
||||
if (!response.ok || !data.project_id) {
|
||||
const detail = typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
localStorage.setItem(CURRENT_PROJECT_ID_KEY, data.project_id);
|
||||
showToast(t("DE_Reg_Success"), "success");
|
||||
navigateTo(firstStep);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/* =============================================================================
|
||||
* D01_PlanRegister_UI_Page.ts
|
||||
* D01 예정노선 등록(요구서 345 3-1 · PLAN 529-1) — B02 등록 화면 꼴 · 도면 칸 없음.
|
||||
* 칸 = 사업연도 시작 · 끝 · 사업지역 · 종류 「노선 계획」(고정) · 임도 종류(B02 값) · 설계속도
|
||||
* (종류별 고를 수 있는 값 · 기본 = 가장 낮은 값 = B05 resolve_design_speed) · 프로젝트명(B02 이름
|
||||
* 조합기 · 연도 자리 = 「시작~끝」) · 노선 시작 · 종료 누가거리.
|
||||
* [등록] = POST /api/d01/project → 현재 프로젝트 · D02 로.
|
||||
* ========================================================================== */
|
||||
|
||||
import "../B02_ProjRegister/B02_ProjRegister_UI_Style.css";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createHelpButton } from "@ui/ui_template_help";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { createGroupProject } from "../A00_Common/group_workflow";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
|
||||
import { SPEED_CHOICES } from "../B05_Profile/logic/B05_Profile_Grade_Criteria";
|
||||
|
||||
/** B02 임도 종류 → 설계속도 기준 등급(B05 grade_class_of 와 같음) */
|
||||
const GRADE_CLASS: Record<string, string> = { main: "trunk", fire: "fire", work: "work" };
|
||||
|
||||
const numberOrNull = (text: string): number | null =>
|
||||
text.trim() === "" ? null : Number.parseFloat(text);
|
||||
|
||||
export function renderD01PlanRegister(root: HTMLElement): void {
|
||||
const year = new Date().getFullYear();
|
||||
const yearStart = createInputField({
|
||||
label: t("DE_Reg_YearStart"),
|
||||
type: "number",
|
||||
value: String(year),
|
||||
min: 2000,
|
||||
max: 2100,
|
||||
});
|
||||
const yearEnd = createInputField({
|
||||
label: t("DE_Reg_YearEnd"),
|
||||
type: "number",
|
||||
value: String(year),
|
||||
min: 2000,
|
||||
max: 2100,
|
||||
});
|
||||
const region = createInputField({
|
||||
label: t("B02_Proj_Field_Region"),
|
||||
placeholder: t("B02_Proj_Field_Region_Placeholder"),
|
||||
required: true,
|
||||
});
|
||||
const kind = createInputField({ label: t("DE_Reg_Kind"), value: t("DE_Reg_KindPlan") });
|
||||
kind.input.readOnly = true;
|
||||
const roadType = createSelectField({
|
||||
label: t("B02_Proj_Field_RoadType"),
|
||||
options: [
|
||||
{ value: "main", text: t("B02_Proj_RoadType_Main") },
|
||||
{ value: "fire", text: t("B02_Proj_RoadType_Fire") },
|
||||
{ value: "work", text: t("B02_Proj_RoadType_Work") },
|
||||
],
|
||||
});
|
||||
const speed = createSelectField({ label: t("DE_Reg_DesignSpeed"), options: [] });
|
||||
const name = createInputField({ label: t("DE_Reg_Name"), required: true });
|
||||
const startM = createInputField({ label: t("DE_Reg_StartM"), type: "number", min: 0 });
|
||||
const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 });
|
||||
|
||||
// 설계속도 = 종류별 고를 수 있는 값 · 기본 = 가장 낮은 값(20)
|
||||
const syncSpeeds = (): void => {
|
||||
const choices = SPEED_CHOICES[GRADE_CLASS[roadType.select.value]] ?? [20];
|
||||
speed.setOptions(
|
||||
choices.map((kph) => ({ value: String(kph), text: `${kph} km/h` })),
|
||||
String(Math.min(...choices)),
|
||||
);
|
||||
};
|
||||
|
||||
// 이름 = B02 조합기 그대로(손대기 전 = 자동 · 고친 뒤 = 조각만 갈아 끼움)
|
||||
const composer = createNameComposer();
|
||||
const years = (): string => {
|
||||
const [a, b] = [yearStart.input.value.trim(), yearEnd.input.value.trim()];
|
||||
return b && b !== a ? `${a}~${b}` : a;
|
||||
};
|
||||
const syncName = (): void => {
|
||||
name.input.value = composer.update({
|
||||
year: years(),
|
||||
region: region.input.value,
|
||||
type: roadType.select.selectedOptions[0]?.textContent ?? "",
|
||||
});
|
||||
};
|
||||
name.input.addEventListener("input", () => composer.edit(name.input.value));
|
||||
for (const field of [yearStart, yearEnd, region]) field.input.addEventListener("input", syncName);
|
||||
roadType.select.addEventListener("change", () => (syncSpeeds(), syncName()));
|
||||
syncSpeeds();
|
||||
syncName();
|
||||
|
||||
const submit = (): void => {
|
||||
const fields = [yearStart, yearEnd, region, name, startM, endM];
|
||||
fields.forEach((field) => field.setError());
|
||||
const y0 = Number.parseInt(yearStart.input.value, 10);
|
||||
const y1 = Number.parseInt(yearEnd.input.value, 10);
|
||||
const [s, e] = [numberOrNull(startM.input.value), numberOrNull(endM.input.value)];
|
||||
const errors: Array<[(typeof fields)[number], string | false]> = [
|
||||
[yearStart, !(y0 >= 2000 && y0 <= 2100) && t("Common_Validation_NumberRange")],
|
||||
[yearEnd, !(y1 >= y0 && y1 <= 2100) && t("DE_Reg_YearOrder")],
|
||||
[region, !region.input.value.trim() && t("B02_Proj_Error_Required")],
|
||||
[name, !name.input.value.trim() && t("B02_Proj_Error_Required")],
|
||||
[startM, s !== null && !(s >= 0) && t("Common_Validation_NumberRange")],
|
||||
[endM, e !== null && s !== null && !(e > s) && t("DE_Reg_ChainOrder")],
|
||||
];
|
||||
const bad = errors.filter(([, message]) => message);
|
||||
bad.forEach(([field, message]) => field.setError(message as string));
|
||||
if (bad.length) return;
|
||||
void createGroupProject(
|
||||
"/d01/project",
|
||||
{
|
||||
project_name: name.input.value.trim(),
|
||||
project_year: y0,
|
||||
project_year_end: y1,
|
||||
region: region.input.value.trim(),
|
||||
road_type: roadType.select.value,
|
||||
design_speed_kph: Number(speed.select.value),
|
||||
start_chainage_m: s,
|
||||
end_chainage_m: e,
|
||||
},
|
||||
ROUTES.D02_PLAN_BASE,
|
||||
);
|
||||
};
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b02-proj__grid";
|
||||
grid.append(
|
||||
...[yearStart, yearEnd, region, kind, roadType, speed, name, startM, endM].map((f) => f.root),
|
||||
);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-general-block__actions b02-proj__actions";
|
||||
actions.append(
|
||||
createHelpButton(),
|
||||
createButton({
|
||||
label: t("Common_Btn_Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.B01_ACCOUNT),
|
||||
}),
|
||||
createButton({ label: t("DE_Reg_Submit"), variant: "filled", onClick: submit }),
|
||||
);
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b02-proj",
|
||||
title: t("DE_Reg_PlanTitle"),
|
||||
subtitle: t("DE_Reg_PlanSubtitle"),
|
||||
content: createCard({ body: [grid, actions], raised: true }),
|
||||
});
|
||||
root.append(layout.root);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* =============================================================================
|
||||
* D01_PlanRegister_Route.ts
|
||||
* 등록 화면 길 선언(PLAN 529-1 · 528-2) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 필요(B02 와 같음) · 꼬리말 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.D01_PLAN_REGISTER,
|
||||
workflow: true,
|
||||
footerless: true,
|
||||
load: async () => (await import("../D01_PlanRegister_UI_Page")).renderD01PlanRegister,
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* E01_StabRegister_UI_Page.ts
|
||||
* E01 안정성평가 생성(요구서 345 4-1 · PLAN 528-2) — 대상 임도 고르기(GET /api/e01/source-candidates
|
||||
* = 같은 회사 · B06 완료만) · 이름(손대기 전 = 「원본 이름 안정성평가」).
|
||||
* [등록] = POST /api/e01/project → 현재 프로젝트 · E02 로.
|
||||
* ========================================================================== */
|
||||
|
||||
import "../B02_ProjRegister/B02_ProjRegister_UI_Style.css";
|
||||
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createHelpButton } from "@ui/ui_template_help";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { createGroupProject } from "../A00_Common/group_workflow";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
|
||||
interface SourceProject {
|
||||
project_id: string;
|
||||
name: string;
|
||||
region?: string | null;
|
||||
project_year?: number | null;
|
||||
}
|
||||
|
||||
export function renderE01StabRegister(root: HTMLElement): void {
|
||||
const source = createSelectField({ label: t("DE_Reg_Source"), options: [] });
|
||||
const name = createInputField({ label: t("DE_Reg_Name") });
|
||||
const note = el("p", { className: "ui-general-block__empty" });
|
||||
let sources: SourceProject[] = [];
|
||||
let typed = false;
|
||||
const autoName = (): string => {
|
||||
const picked = sources.find((p) => p.project_id === source.select.value);
|
||||
return picked ? `${picked.name} ${t("DE_New_Stability")}` : "";
|
||||
};
|
||||
name.input.addEventListener("input", () => (typed = name.input.value.trim() !== ""));
|
||||
source.select.addEventListener("change", () => {
|
||||
if (!typed) name.input.value = autoName();
|
||||
});
|
||||
|
||||
const submitButton = createButton({
|
||||
label: t("DE_Reg_Submit"),
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
source.setError();
|
||||
if (!source.select.value) return void source.setError(t("B02_Proj_Error_Required"));
|
||||
void createGroupProject(
|
||||
"/e01/project",
|
||||
{ project_name: name.input.value.trim() || null, source_project_id: source.select.value },
|
||||
ROUTES.E02_STAB_GROUND,
|
||||
);
|
||||
},
|
||||
});
|
||||
submitButton.disabled = true;
|
||||
|
||||
void (async () => {
|
||||
const response = await fetch(`${API_BASE_URL}/e01/source-candidates`, {
|
||||
credentials: "include",
|
||||
});
|
||||
sources = response.ok
|
||||
? ((await response.json()) as { projects: SourceProject[] }).projects
|
||||
: [];
|
||||
source.setOptions(
|
||||
sources.map((p) => ({
|
||||
value: p.project_id,
|
||||
text: [p.name, p.region, p.project_year].filter(Boolean).join(" · "),
|
||||
})),
|
||||
);
|
||||
note.textContent = sources.length ? "" : t("DE_Reg_SourceEmpty");
|
||||
submitButton.disabled = !sources.length;
|
||||
if (!typed) name.input.value = autoName();
|
||||
})();
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b02-proj__grid";
|
||||
grid.append(source.root, name.root);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-general-block__actions b02-proj__actions";
|
||||
actions.append(
|
||||
createHelpButton(),
|
||||
createButton({
|
||||
label: t("Common_Btn_Cancel"),
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.B01_ACCOUNT),
|
||||
}),
|
||||
submitButton,
|
||||
);
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b02-proj",
|
||||
title: t("DE_Reg_StabTitle"),
|
||||
subtitle: t("DE_Reg_StabSubtitle"),
|
||||
content: createCard({ body: [grid, note, actions], raised: true }),
|
||||
});
|
||||
root.append(layout.root);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* =============================================================================
|
||||
* E01_StabRegister_Route.ts
|
||||
* 등록 화면 길 선언(PLAN 529-1 · 528-2) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 필요(B02 와 같음) · 꼬리말 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.E01_STAB_REGISTER,
|
||||
workflow: true,
|
||||
footerless: true,
|
||||
load: async () => (await import("../E01_StabRegister_UI_Page")).renderE01StabRegister,
|
||||
};
|
||||
@@ -30,4 +30,36 @@ export const ui_locales_de = {
|
||||
"예정노선은 산림청(시스템 관리자 · go.kr 메일)만 만들 수 있습니다.",
|
||||
"Only the Korea Forest Service (system admin · go.kr mail) can create planned routes.",
|
||||
],
|
||||
|
||||
/* --- 등록 화면 D01 · E01(529-1 · 528-2) --- */
|
||||
DE_Reg_PlanTitle: ["예정노선 등록", "Register Planned Route"],
|
||||
DE_Reg_PlanSubtitle: [
|
||||
"여러 해에 걸친 예정노선의 기본 정보를 넣습니다.",
|
||||
"Enter the basics of a multi-year planned route.",
|
||||
],
|
||||
DE_Reg_StabTitle: ["안정성평가 만들기", "New Stability Assessment"],
|
||||
DE_Reg_StabSubtitle: [
|
||||
"횡단 설계까지 끝난 임도 프로젝트를 골라 그 시점 자료로 평가합니다.",
|
||||
"Pick a forest road project with finished cross-sections; its data is copied as of now.",
|
||||
],
|
||||
DE_Reg_YearStart: ["사업 연도 시작", "Start year"],
|
||||
DE_Reg_YearEnd: ["사업 연도 끝", "End year"],
|
||||
DE_Reg_YearOrder: [
|
||||
"끝 연도는 시작 연도와 같거나 뒤여야 합니다.",
|
||||
"End year must not precede start year.",
|
||||
],
|
||||
DE_Reg_Kind: ["종류", "Kind"],
|
||||
DE_Reg_KindPlan: ["노선 계획", "Route plan"],
|
||||
DE_Reg_DesignSpeed: ["설계속도", "Design speed"],
|
||||
DE_Reg_Name: ["프로젝트명", "Project name"],
|
||||
DE_Reg_StartM: ["노선 시작 누가거리 (m)", "Route start chainage (m)"],
|
||||
DE_Reg_EndM: ["노선 종료 누가거리 (m)", "Route end chainage (m)"],
|
||||
DE_Reg_ChainOrder: ["종료 누가거리는 시작보다 커야 합니다.", "End chainage must exceed start."],
|
||||
DE_Reg_Source: ["대상 임도 프로젝트", "Source forest road project"],
|
||||
DE_Reg_SourceEmpty: [
|
||||
"횡단 설계(B06)까지 끝난 임도 프로젝트가 없습니다.",
|
||||
"No forest road project has finished cross-sections (B06).",
|
||||
],
|
||||
DE_Reg_Submit: ["등록", "Register"],
|
||||
DE_Reg_Success: ["프로젝트를 만들었습니다.", "Project created."],
|
||||
} as const satisfies Record<string, LocaleEntry>;
|
||||
|
||||
Reference in New Issue
Block a user