From a08dd821125c29ff98b5e33aa3aec29aacc6238d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 19:39:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(529-1):=20D01=20=EC=98=88=EC=A0=95?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EB=93=B1=EB=A1=9D=20=C2=B7=20E01=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=EC=84=B1=ED=8F=89=EA=B0=80=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- A00_Common/group_workflow.ts | 42 ++++- D01_PlanRegister/D01_PlanRegister_UI_Page.ts | 152 ++++++++++++++++++ .../draft/D01_PlanRegister_Route.ts | 15 ++ E01_StabRegister/E01_StabRegister_UI_Page.ts | 99 ++++++++++++ .../draft/E01_StabRegister_Route.ts | 15 ++ ui_template/ui_template_locale_de.ts | 32 ++++ 6 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 D01_PlanRegister/D01_PlanRegister_UI_Page.ts create mode 100644 D01_PlanRegister/draft/D01_PlanRegister_Route.ts create mode 100644 E01_StabRegister/E01_StabRegister_UI_Page.ts create mode 100644 E01_StabRegister/draft/E01_StabRegister_Route.ts diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts index 4ece4cdaa..a056c69cf 100644 --- a/A00_Common/group_workflow.ts +++ b/A00_Common/group_workflow.ts @@ -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 { + 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(); + } +} diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts new file mode 100644 index 000000000..53d026d3a --- /dev/null +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -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 = { 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); +} diff --git a/D01_PlanRegister/draft/D01_PlanRegister_Route.ts b/D01_PlanRegister/draft/D01_PlanRegister_Route.ts new file mode 100644 index 000000000..47ed265cc --- /dev/null +++ b/D01_PlanRegister/draft/D01_PlanRegister_Route.ts @@ -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, +}; diff --git a/E01_StabRegister/E01_StabRegister_UI_Page.ts b/E01_StabRegister/E01_StabRegister_UI_Page.ts new file mode 100644 index 000000000..6425818f2 --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_UI_Page.ts @@ -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); +} diff --git a/E01_StabRegister/draft/E01_StabRegister_Route.ts b/E01_StabRegister/draft/E01_StabRegister_Route.ts new file mode 100644 index 000000000..e0f866b09 --- /dev/null +++ b/E01_StabRegister/draft/E01_StabRegister_Route.ts @@ -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, +}; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index d71934712..93d459f31 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -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;