diff --git a/A00_Common/group_workflow.ts b/A00_Common/group_workflow.ts new file mode 100644 index 000000000..4ece4cdaa --- /dev/null +++ b/A00_Common/group_workflow.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * group_workflow.ts + * D 예정노선 · E 안정성평가 단계 묶음(요구 345 · PLAN 529-1) — B05~B09 틀(`createWorkflowLayout`)을 + * 단계 목록 · 아이콘 · 주소 인자로 그대로 씀. 빈 단계 = 틀 · 좌측 패널([?] [<] [>]) · 단계 막대만. + * 페이지 길 = 각 폴더 `draft/<폴더>_Route.ts` · 본문이 생기면 그 폴더 페이지로 갈아 끼움. + * ========================================================================== */ + +import { PLAN_STEP_ROUTES, STAB_STEP_ROUTES, type RoutePath } from "@config/config_frontend"; +import { el } 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"; + +interface GroupFlow { + routes: readonly RoutePath[]; + labels: readonly LocaleKey[]; + icons: readonly string[]; +} + +const FLOWS: readonly GroupFlow[] = [ + { + routes: PLAN_STEP_ROUTES, + labels: ["DE_Step_PlanBase", "DE_Step_PlanAnalysis", "DE_Step_PlanConfirm"], + icons: ["📍", "🧭", "✅"], + }, + { + routes: STAB_STEP_ROUTES, + labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"], + icons: ["🪨", "📐", "🧊", "📄"], + }, +]; + +/** 단계 틀을 세움 — mainContent · leftPanel 이 없으면 빈 단계 안내 · [?] 줄만 */ +export function mountGroupStep( + root: HTMLElement, + route: RoutePath, + parts: { mainContent?: HTMLElement; leftPanel?: HTMLElement } = {}, +): void { + const flow = FLOWS.find((f) => f.routes.includes(route)); + if (!flow) throw new Error(`단계 묶음 밖 주소: ${route}`); + const active = flow.routes.indexOf(route); + const layout = createWorkflowLayout({ + title: t(flow.labels[active]), + steps: flow.labels.map((key) => t(key)), + icons: flow.icons, + activeStep: active, + routes: flow.routes, + leftPanel: parts.leftPanel, + mainContent: + parts.mainContent ?? el("p", { className: "app-placeholder", text: t("DE_Step_Empty") }), + onStepClick: (_index, next) => navigateTo(next as RoutePath), + }); + root.append(layout.root); +} + +/** 빈 단계 페이지(길 선언의 load 가 부름) */ +export const groupStepPage = + (route: RoutePath): PageRenderer => + (root) => + mountGroupStep(root, route); diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index 4b0088898..116c3b5bc 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -15,6 +15,8 @@ export interface SessionUser { role?: string; company_id?: number | null; is_master?: boolean; + /** 산림청 = 시스템관리자 · *.go.kr 메일(요구 345) — 예정노선 만들기 */ + is_forest_service?: boolean; } async function post(path: string, body?: object): Promise { diff --git a/B01_Dashboard/B01_Dashboard_UI_NewProject.css b/B01_Dashboard/B01_Dashboard_UI_NewProject.css new file mode 100644 index 000000000..51bbf2aab --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_NewProject.css @@ -0,0 +1,48 @@ +/* 대시보드 [+] 고르기 모달(528-2) — 카드 넷 한 줄 · 그림 선 = 글자 색(밝음 · 어두움) */ +.b01-new-project__grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--spacing-12); +} + +.b01-new-project__card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-16) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + cursor: pointer; +} + +.b01-new-project__card:hover:not(.is-dim, .is-soon) { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.b01-new-project__card.is-dim, +.b01-new-project__card.is-soon { + opacity: 0.45; + cursor: not-allowed; +} + +.b01-new-project__icon svg { + display: block; + width: 56px; + height: 56px; +} + +.b01-new-project__badge { + position: absolute; + top: var(--spacing-4); + right: var(--spacing-4); + padding: 0 var(--spacing-4); + border-radius: var(--radius-sm); + background: var(--color-border); + font-size: var(--text-caption); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_NewProject.ts b/B01_Dashboard/B01_Dashboard_UI_NewProject.ts new file mode 100644 index 000000000..0c188793e --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_NewProject.ts @@ -0,0 +1,89 @@ +/* ============================================================================= + * B01_Dashboard_UI_NewProject.ts + * 대시보드 [+] 고르기 모달(요구 345 · PLAN 528-2) — 카드 넷(그림 + 제목) = 예정노선 · 임도설계 · + * 사방설계 · 안정성평가. 그림 = SVG 직접(선 색 = 글자 색 → 밝음 · 어두움 그대로). + * 예정노선 = 산림청(세션 is_forest_service)만 · 아니면 흐림 + 까닭 툴팁(누름 = 같은 까닭 알림) + * 사방설계 = 「준비 중」 · 임도설계 = B02 · 예정노선 = D01 · 안정성평가 = E01 + * ========================================================================== */ + +import "./B01_Dashboard_UI_NewProject.css"; +import { ROUTES, type RoutePath } from "@config/config_frontend"; +import { el, showToast } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { navigateTo } from "../A00_Common/router"; + +/** 48 칸 그림 — 선 · 면은 currentColor */ +const ICONS = { + plan: ``, + road: ``, + erosion: ``, + stability: ``, +} as const; + +interface CardSpec { + icon: keyof typeof ICONS; + title: LocaleKey; + route?: RoutePath; + /** 못 고르는 까닭(툴팁 · 누름 알림) — 없으면 고를 수 있음 */ + reason?: LocaleKey; + soon?: boolean; +} + +function svgIcon(kind: keyof typeof ICONS): HTMLElement { + const box = el("span", { className: "b01-new-project__icon", attrs: { "aria-hidden": "true" } }); + box.innerHTML = `${ICONS[kind]}`; + return box; +} + +function card(spec: CardSpec, close: () => void): HTMLButtonElement { + const button = el("button", { + className: "b01-new-project__card", + attrs: { type: "button" }, + children: [svgIcon(spec.icon), el("span", { text: t(spec.title) })], + }); + button.dataset.newProject = spec.icon; + if (spec.soon) { + button.classList.add("is-soon"); + button.append(el("span", { className: "b01-new-project__badge", text: t("DE_New_Soon") })); + } + if (spec.reason) { + button.classList.add("is-dim"); + button.title = t(spec.reason); + } + button.addEventListener("click", () => { + if (spec.reason || spec.soon) return void showToast(t(spec.reason ?? "DE_New_Soon")); + close(); + navigateTo(spec.route!); + }); + return button; +} + +/** [+] — 세션의 산림청 판정으로 예정노선 카드 켬 · 흐림 */ +export async function openNewProjectPicker(): Promise { + const forest = Boolean((await fetchSessionUser())?.is_forest_service); + const specs: CardSpec[] = [ + { + icon: "plan", + title: "DE_New_Plan", + route: ROUTES.D01_PLAN_REGISTER, + reason: forest ? undefined : "DE_New_PlanForestOnly", + }, + { icon: "road", title: "DE_New_Road", route: ROUTES.B02_PROJ_REGISTER }, + { icon: "erosion", title: "DE_New_Erosion", soon: true }, + { icon: "stability", title: "DE_New_Stability", route: ROUTES.E01_STAB_REGISTER }, + ]; + openModal({ + title: t("DE_New_Title"), + closeLabel: t("Common_Btn_Close"), + dialogClass: "b01-new-project", + mount: (body, close) => + body.append( + el("div", { + className: "b01-new-project__grid", + children: specs.map((spec) => card(spec, close)), + }), + ), + }); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index cdd21d765..ce3d5bdee 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -44,6 +44,7 @@ import { import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals"; import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile"; import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick"; +import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject"; import { projectTable } from "./B01_Dashboard_UI_Projects"; import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload"; @@ -260,7 +261,7 @@ function buildPage(state: DashboardState): HTMLElement { buildSystemSettingsSection(state), withCompanyPill( section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ - createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), + createButton({ label: "+", onClick: () => void openNewProjectPicker() }), ]), state, ), @@ -294,7 +295,7 @@ function buildPage(state: DashboardState): HTMLElement { grid.append( buildSystemSettingsSection(state), section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [ - createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), + createButton({ label: "+", onClick: () => void openNewProjectPicker() }), ]), buildTempUploadSection(), section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [ @@ -307,7 +308,7 @@ function buildPage(state: DashboardState): HTMLElement { grid.append( buildSystemSettingsSection(state), section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [ - createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), + createButton({ label: "+", onClick: () => void openNewProjectPicker() }), ]), // 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록 // 바로 아래에 둔다(2026-08-08 사용자 지시). diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index da687e23b..4e5c7fe82 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -22,8 +22,6 @@ 373 화면이 구간 `legs[].path` 로 캐시 → [저장]) GET /api/projects/{id}/quantity/milestone/reverse?lon=&lat= 지도에서 고른 소재지(244-13) → VWorld 주소 한 줄(서버 단독 · 저장 안 함 · 못 찾음 = null) - GET /api/projects/{id}/quantity/milestone/map-key 카카오 지도 JavaScript 키(244-9 · - 공개용 · .env KAKAO_JS_KEY · 없으면 null) 정본 = 프로젝트 storage `B08_Quantity/milestone/`(`milestone.json` + `<경로 id>.<확장자>` 사용자 그림 · `auto/<경로 id>.png` 자동 그림 · 폴더가 목록 · B09 견적서 파일 길 꼴) · 보이는 그림 = 사용자 @@ -676,12 +674,3 @@ async def milestone_directions(project_id: UUID, body: Directions) -> dict[str, """[길찾기] 한 구간 — 단추 누를 때만(조작 중 자동 호출 없음) · 길 선은 점 줄여 돌려줌(373).""" got = await asyncio.to_thread(directions_km, body.origin, body.destination) return {**got, "path": thin_path(got["path"])} if got.get("path") else got - - -KAKAO_JS = "KAKAO_JS_KEY" - - -@router.get(PATH + "/map-key") -async def milestone_map_key(project_id: UUID) -> dict[str, Any]: - """카카오 지도 JavaScript 키(공개용 · 도메인 등록으로 막힘) — 없으면 null = 화면 안내 한 줄.""" - return {"key": os.environ.get(KAKAO_JS, "").strip() or None} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 7ba848e6b..747844737 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -188,35 +188,6 @@ pointer-events: none; } -/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 · 높이 어디서나 한 값(150) */ -.b08-milestone__map { - width: 100%; - height: 480px; - border: 1px solid var(--color-border); -} - -.b08-milestone__map-label { - padding: 2px 6px; - border: 1px solid #3c3c3c; - background: #fff; - color: #141414; - font-size: 12px; - white-space: nowrap; -} - -/* 262-2 고정 시점 표지 — 둥근 붉은 알약(고르는 표지 = 기본 핀 + 흰 이름표와 구별) */ -.b08-milestone__map-start { - padding: 3px 10px; - border: 2px solid #fff; - border-radius: 999px; - background: #e60046; - color: #fff; - font-size: 12px; - font-weight: 700; - white-space: nowrap; - pointer-events: none; -} - /* 등록 · 연결선 모달(244-13) — 더 큰 창 · 2열 칸 */ .ui-modal.b08-milestone__modal { width: min(960px, 94vw); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index 2d873d60a..b306bf393 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -23,7 +23,7 @@ import { type SegKind, } from "./B08_Quantity_Milestone_Calc"; import { ownerOf, segmentEnds, setLeg } from "./B08_Quantity_Milestone_Layout"; -import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { editMap, type MapPin } from "@ui/ui_template_kakao_map"; import { fmtKm, toastKm } from "./B08_Quantity_UI_Milestone_Modal"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -224,7 +224,7 @@ export function openLegInfo(projectId: string, from: string): void { drawMap(); }; // 끝점 끌어 옮김 = 그 소재지 고침(주소 다시) + 다시 길찾기 - const map = editMap(projectId, { + const map = editMap({ hint: "경로 = 카카오 길찾기 · 거래처 · 기준점 표지를 끌어 옮기면 다시 길찾기", onMove: (key, lon, lat) => { apply(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index e665d5969..bba005a19 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -35,7 +35,7 @@ import { } from "./B08_Quantity_Milestone_Calc"; import { setLeg } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; -import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { editMap, type MapPin } from "@ui/ui_template_kakao_map"; import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { capturePlace, @@ -305,7 +305,7 @@ export function openSiteInfo(projectId: string): void { }); save.dataset.b08MilestoneSite = "save"; actions.append(save); - const map = editMap(projectId, { hint: "", onMove: () => undefined }); + const map = editMap({ hint: "", onMove: () => undefined }); const pins = (s: { start_lonlat: [number, number] | null; mid_lonlat: [number, number] | null; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts index f4f2af5be..42abd90a1 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts @@ -3,7 +3,7 @@ * 거리이정표 등록 모달 「소재지」 칸(PLAN 244-13 · 사용자 147번 5) — 주소 찾기 · 지도에서 고르기만(직접 입력 없음). * * 주소 찾기 = B01 회사 주소와 같은 서버 후보 검색(VWorld 대리 · `searchAddress`) → 후보 고름 = 소재지 · 표지 - * 지도 = 다음(카카오) 지도 JS(`…_KakaoMap` editMap) — 지도 누름 = 그 자리 소재지 · 표지 끌어 옮김 = 다시 고름 · + * 지도 = 다음(카카오) 지도 JS(`ui_template_kakao_map` editMap) — 지도 누름 = 그 자리 소재지 · 표지 끌어 옮김 = 다시 고름 · * 고른 좌표 → 서버 GET …/milestone/reverse(VWorld 주소 한 줄 · 못 찾음 = 위도, 경도 글) * 값 = 주소 글 · 경도 · 위도(캐시 · [저장] 때 서버) * ========================================================================== */ @@ -11,7 +11,7 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { request } from "./B08_Quantity_UI_Hydro"; -import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { editMap } from "@ui/ui_template_kakao_map"; import { liveMilestone, startPoint } from "./B08_Quantity_UI_Milestone_State"; export interface Place { @@ -54,7 +54,7 @@ export function placePicker(projectId: string, initial: Place, name: () => strin where.dataset.b08MilestonePlace = "value"; // 262-2 시점 = 거리이정표가 쓰는 시점(끌어 옮긴 덮개 · 없으면 노선 시점) const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); - const map = editMap(projectId, { + const map = editMap({ hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김", onClick: (lon, lat) => void moveTo(lon, lat), onMove: (_key, lon, lat) => void moveTo(lon, lat), diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index 40526113d..8e628dee3 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -20,7 +20,7 @@ import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { chainOf, ownerOf, reachesSite } from "./B08_Quantity_Milestone_Layout"; import { DEST_TEXT } from "./B08_Quantity_Milestone_Table"; -import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; +import { editMap, type MapPin } from "@ui/ui_template_kakao_map"; import { openPlaceModal } from "./B08_Quantity_UI_Milestone_Modal"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -185,7 +185,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView { putMilestone(projectId, fresh); await refind(touchingSegments(fresh, key)); }; - const map = editMap(projectId, { + const map = editMap({ hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김", onClick: (lon, lat) => void moveTo(routeId, lon, lat), onMove: (key, lon, lat) => void moveTo(key, lon, lat), diff --git a/D02_PlanBase/draft/D02_PlanBase_Route.ts b/D02_PlanBase/draft/D02_PlanBase_Route.ts new file mode 100644 index 000000000..1db951dd7 --- /dev/null +++ b/D02_PlanBase/draft/D02_PlanBase_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * D02_PlanBase_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D02_PLAN_BASE, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D02_PLAN_BASE), +}; diff --git a/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts b/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts new file mode 100644 index 000000000..00fe6d874 --- /dev/null +++ b/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * D03_PlanAnalysis_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D03_PLAN_ANALYSIS, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D03_PLAN_ANALYSIS), +}; diff --git a/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts b/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts new file mode 100644 index 000000000..c59fad866 --- /dev/null +++ b/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * D04_PlanConfirm_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D04_PLAN_CONFIRM, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D04_PLAN_CONFIRM), +}; diff --git a/E02_StabGround/draft/E02_StabGround_Route.ts b/E02_StabGround/draft/E02_StabGround_Route.ts new file mode 100644 index 000000000..13e8f23bb --- /dev/null +++ b/E02_StabGround/draft/E02_StabGround_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * E02_StabGround_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E02_STAB_GROUND, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E02_STAB_GROUND), +}; diff --git a/E03_StabSection/draft/E03_StabSection_Route.ts b/E03_StabSection/draft/E03_StabSection_Route.ts new file mode 100644 index 000000000..4b3805d51 --- /dev/null +++ b/E03_StabSection/draft/E03_StabSection_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * E03_StabSection_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E03_STAB_SECTION, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E03_STAB_SECTION), +}; diff --git a/E04_Stab3D/draft/E04_Stab3D_Route.ts b/E04_Stab3D/draft/E04_Stab3D_Route.ts new file mode 100644 index 000000000..0f7aa9118 --- /dev/null +++ b/E04_Stab3D/draft/E04_Stab3D_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * E04_Stab3D_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E04_STAB_3D, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E04_STAB_3D), +}; diff --git a/E05_StabReport/draft/E05_StabReport_Route.ts b/E05_StabReport/draft/E05_StabReport_Route.ts new file mode 100644 index 000000000..62a5a5bb7 --- /dev/null +++ b/E05_StabReport/draft/E05_StabReport_Route.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * E05_StabReport_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E05_STAB_REPORT, + workflow: true, + project: true, + footerless: true, + load: async () => + (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E05_STAB_REPORT), +}; diff --git a/common_util/common_util_help_router.py b/common_util/common_util_help_router.py index 1f9c4be92..f08a44ba8 100644 --- a/common_util/common_util_help_router.py +++ b/common_util/common_util_help_router.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException router = APIRouter(prefix="/api/help", tags=["help"]) HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help" -PAGE_RE = re.compile(r"^B0[2-9]$") +PAGE_RE = re.compile(r"^(B0[2-9]|D0[1-4]|E0[1-5])$") def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]: diff --git a/common_util/common_util_map_router.py b/common_util/common_util_map_router.py new file mode 100644 index 000000000..96c51ea66 --- /dev/null +++ b/common_util/common_util_map_router.py @@ -0,0 +1,21 @@ +"""카카오 지도 JavaScript 키 공용 길(529-2) — B08 거리이정표 · D 예정노선 지도가 씀. + +GET /api/map/kakao-key {key} — 공개용(도메인 등록으로 막힘) · .env KAKAO_JS_KEY · 없으면 null +""" + +from __future__ import annotations + +import os +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/api/map", tags=["map"]) + +KAKAO_JS = "KAKAO_JS_KEY" + + +@router.get("/kakao-key") +async def kakao_map_key() -> dict[str, Any]: + """없으면 null = 화면 안내 한 줄.""" + return {"key": os.environ.get(KAKAO_JS, "").strip() or None} diff --git a/config/config_frontend.ts b/config/config_frontend.ts index 01ddc0a75..9d295ec1c 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -120,8 +120,33 @@ export const ROUTES = { M01_MASTER_DATA: "m01-master-data", // 마스터 템플릿 — 표 양식 · 도면 양식(시스템 · 회사 · 개인 · 프로젝트 층). 화면은 로그인만 확인. M02_MASTER_TEMPLATE: "m02-master-template", + // D 예정노선 · E 안정성평가(요구 345) — 길 선언은 페이지 폴더 `draft/*_Route.ts` + D01_PLAN_REGISTER: "d01-plan-register", + D02_PLAN_BASE: "d02-plan-base", + D03_PLAN_ANALYSIS: "d03-plan-analysis", + D04_PLAN_CONFIRM: "d04-plan-confirm", + E01_STAB_REGISTER: "e01-stab-register", + E02_STAB_GROUND: "e02-stab-ground", + E03_STAB_SECTION: "e03-stab-section", + E04_STAB_3D: "e04-stab-3d", + E05_STAB_REPORT: "e05-stab-report", } as const; +/** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */ +export const PLAN_STEP_ROUTES = [ + ROUTES.D02_PLAN_BASE, + ROUTES.D03_PLAN_ANALYSIS, + ROUTES.D04_PLAN_CONFIRM, +] as const; + +/** E 안정성평가 단계 주소(E02 → E05 · 생성 E01 은 단계 밖) */ +export const STAB_STEP_ROUTES = [ + ROUTES.E02_STAB_GROUND, + ROUTES.E03_STAB_SECTION, + ROUTES.E04_STAB_3D, + ROUTES.E05_STAB_REPORT, +] as const; + export type RouteKey = keyof typeof ROUTES; export type RoutePath = (typeof ROUTES)[RouteKey]; diff --git a/main.py b/main.py index 40d04b8b5..b10ab78e9 100644 --- a/main.py +++ b/main.py @@ -95,6 +95,7 @@ from common_util.common_util_auth import ( # 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). from common_util.common_util_dev_unlock_router import router as dev_unlock_router from common_util.common_util_help_router import router as help_router +from common_util.common_util_map_router import router as map_router from common_util.common_util_resource_monitor import sample_resources_loop from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop from config.config_db import close_db_pool, get_db_pool, init_db_pool @@ -519,6 +520,7 @@ app.include_router(m02_project_items_router, dependencies=protected) app.include_router(dev_unlock_router, dependencies=protected_with_company) # [?] 설명서 글(54-2) — 로그인만 · 파일을 부를 때마다 읽음. app.include_router(help_router, dependencies=protected) +app.include_router(map_router, dependencies=protected) # ───────────────────────────────────────────────────────────────────────── diff --git a/resources/tester/helper_280_2_step_nav.cjs b/resources/tester/helper_280_2_step_nav.cjs index 3e62d8c86..92ed1250a 100644 --- a/resources/tester/helper_280_2_step_nav.cjs +++ b/resources/tester/helper_280_2_step_nav.cjs @@ -115,7 +115,12 @@ const toasts = []; let role = "SYSTEM_ADMIN"; let help; const STUBS = { - "@config/config_frontend": { ROUTES, API_BASE_URL: "" }, + "@config/config_frontend": { + ROUTES, + API_BASE_URL: "", + PLAN_STEP_ROUTES: ["d02-plan-base", "d03-plan-analysis", "d04-plan-confirm"], + STAB_STEP_ROUTES: ["e02-stab-ground", "e03-stab-section", "e04-stab-3d", "e05-stab-report"], + }, "./ui_template_locale": { t: (key) => key }, "./ui_template_modal": { openModal: () => undefined }, "./ui_template_elements": { @@ -190,6 +195,13 @@ const ALL = [ "b09-print", "m01-master-data", "m02-master-template", + // 529-1 D · E 묶음 — 묶음 안에서만 [<] [>] · 등록(D01 · E01) = [?] 만 + "d01-plan-register", + "d02-plan-base", + "d04-plan-confirm", + "e01-stab-register", + "e02-stab-ground", + "e05-stab-report", ]; const rows = {}; for (const route of ALL) rows[route] = { helpRow: rowOf(route, true), plain: rowOf(route, false) }; diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index c4bbfbc45..2488e14ab 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -13,7 +13,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" -KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts" +KMAP = "ui_template/ui_template_kakao_map.ts" ORIGIN = "http://100.72.32.27:5173" @@ -25,7 +25,8 @@ STUBS = f""" const el = (tag: string, o: any = {{}}) => ({{ tag, hidden: false, textContent: o.text ?? "", className: o.className, dataset: {{}}, children: o.children ?? [] }}); const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); -const request = async () => ({{ key: "fake-js-key" }}); +const API_BASE_URL = ""; +(globalThis as any).fetch = async () => ({{ ok: true, json: async () => ({{ key: "fake-js-key" }}) }}); const injected: string[] = []; (globalThis as any).window = globalThis; (globalThis as any).location = {{ origin: {json.dumps(ORIGIN)} }}; @@ -50,7 +51,7 @@ def _view(setup): + IMPORTS.sub("", _read(KMAP)) + """ // 156 경로 탭 지도 칸 없앰 — 같은 SDK 싣기 · 안내 길 = 연결선 모달 지도 칸(editMap) -const view = editMap("p1", { onMove: () => {}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }); +const view = editMap({ onMove: () => {}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }); view.set([{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }], routePaths()); await new Promise((r) => setTimeout(r, 30)); const [hint, box] = view.root.children; diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index b6a019bc9..f54a8f1d1 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -30,7 +30,7 @@ IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) B08 = "B08_Quantity/B08_Quantity_" CALC = B08 + "Milestone_Calc.ts" LAYOUT = B08 + "Milestone_Layout.ts" -KMAP = B08 + "UI_Milestone_KakaoMap.ts" +KMAP = "ui_template/ui_template_kakao_map.ts" STATE = B08 + "UI_Milestone_State.ts" BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" @@ -158,7 +158,8 @@ const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.t const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); (globalThis as any).window = globalThis; (globalThis as any).document = { createElement: () => ({}), head: { append: () => undefined } }; -const request = async () => ({ key: "fake-js-key" }); +const API_BASE_URL = ""; +(globalThis as any).fetch = async () => ({ ok: true, json: async () => ({ key: "fake-js-key" }) }); const LOG: any = { markers: [], handlers: [], lines: 0 }; class LatLng { lat: number; lng: number; constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; } @@ -183,7 +184,7 @@ def test_고르는_지도_누름_끌기(): [KMAP], """ const moved: any[] = []; const clicked: any[] = []; -const m = editMap("p1", { hint: "h", onMove: (k, lon, lat) => moved.push([k, lon, lat]), +const m = editMap({ hint: "h", onMove: (k, lon, lat) => moved.push([k, lon, lat]), onClick: (lon, lat) => clicked.push([lon, lat]), center: () => [127.83, 35.64] }); m.set([], []); await new Promise((r) => setTimeout(r, 20)); @@ -292,7 +293,7 @@ def test_화면_등록_모달_연결선_모달(): assert "소재지" in modal and "거래처 위치" not in modal for text in (modal, place): assert 'label: "직접 입력"' not in text and "buildAddressField" not in text - assert "editMap(projectId" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place + assert "editMap({" in place and "onClick: (lon, lat) => void moveTo(lon, lat)" in place assert "/quantity/milestone/reverse?lon=" in place # ② 상자 누름 = 같은 등록 모달 · [기준점 +] 없음 assert "base: (id) => openPlaceModal(projectId, { add: false, id })," in diagram diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index 31cdf6dd5..77ed4ccc2 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -151,7 +151,7 @@ def test_화면_모달_팬줌_그림탭(): assert 'from "../B01_Dashboard/B01_Dashboard_Api_Fetch"' in place assert "searchAddress(text)" in place # ② 시점 모달 — 지도 = 공용 카카오 지도(255-1 · 주소 줄 지움) - assert "/quantity/milestone/site-address" in modal and "editMap(projectId" in modal + assert "/quantity/milestone/site-address" in modal and "editMap({" in modal # ⑤ 거래처 상자 = 같은 등록 모달(고침 · 삭제 · 244-13) · 구간 km · 도로 구분 = 연결선 모달 for text in ('"수정"', 'label: "삭제"', "rowValues()", "putMilestone(projectId, fresh)"): assert text in add diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index fe54091ec..7a8cefdc8 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -22,11 +22,12 @@ if str(ROOT) not in sys.path: from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 from B08_Quantity.B08_Quantity_Milestone_Calc import migrate_milestone # noqa: E402 +from common_util import common_util_map_router as map_router # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) BASE = "/api/projects/00000000-0000-0000-0000-000000000001/quantity/milestone" LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" -KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts" +KMAP = "ui_template/ui_template_kakao_map.ts" GEOCHANG = {"lon": 127.9095, "lat": 35.6866} BYEONGGOK = {"lon": 127.85, "lat": 35.66} BODY = { @@ -108,14 +109,14 @@ def test_좌표_저장_안_함(tmp_path): def test_JS_키_길(monkeypatch): app = FastAPI() - app.include_router(ms.router) + app.include_router(map_router.router) # 529-2 공용 길로 옮김 client = TestClient(app) - monkeypatch.delenv(ms.KAKAO_JS, raising=False) - assert client.get(BASE + "/map-key").json() == {"key": None} - monkeypatch.setenv(ms.KAKAO_JS, " ") - assert client.get(BASE + "/map-key").json() == {"key": None} - monkeypatch.setenv(ms.KAKAO_JS, "fake-js-key") - assert client.get(BASE + "/map-key").json() == {"key": "fake-js-key"} + monkeypatch.delenv(map_router.KAKAO_JS, raising=False) + assert client.get("/api/map/kakao-key").json() == {"key": None} + monkeypatch.setenv(map_router.KAKAO_JS, " ") + assert client.get("/api/map/kakao-key").json() == {"key": None} + monkeypatch.setenv(map_router.KAKAO_JS, "fake-js-key") + assert client.get("/api/map/kakao-key").json() == {"key": "fake-js-key"} def test_표지_셋(): @@ -164,10 +165,11 @@ def _map(key, paths, extra=""): const KEY = {json.dumps(key)}; const PATHS = {json.dumps(paths)}; const asked: string[] = []; -const request = async (url: string) => (asked.push(url), {{ key: KEY }}); +const API_BASE_URL = ""; +(globalThis as any).fetch = async (url: string) => (asked.push(url), {{ ok: true, json: async () => ({{ key: KEY }}) }}); {extra} // 156 경로 탭 지도 칸 없앰 — 같은 SDK 싣기 · 안내 길 = 연결선 모달 지도 칸(editMap) -const view = editMap("p1", {{ onMove: () => {{}}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }}); +const view = editMap({{ onMove: () => {{}}, hint: "길 선 = 카카오 길찾기 · 경로 그림은 서버가 자동으로 만듦" }}); view.set([{{ key: "r1", name: "거창군청", lon: 127.9, lat: 35.68, movable: true }}, {{ key: "base", name: "병곡리", lon: 127.85, lat: 35.66, movable: true }}, {{ key: "start", name: "시점", lon: 127.83, lat: 35.64, movable: false }}], PATHS); @@ -181,7 +183,7 @@ console.log(JSON.stringify({{ asked, injected, hint: hint.textContent, boxHidden def test_키_없음_안내_한_줄(): got = _map(None, []) - assert got["asked"] == ["/projects/p1/quantity/milestone/map-key"] + assert got["asked"] == ["/map/kakao-key"] assert got["injected"] == [] # SDK 안 부름 assert got["hint"] == ( "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요" diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index eca1c4cc6..6eb744146 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -73,11 +73,12 @@ def test_폭_전체_지도_높이_한_값(): block = css[css.index(".b08-milestone {") :] assert "width: 100%;" in block[: block.index("}")] assert ".b08-milestone > .ui-data-table .ui-data-table__table {\n width: 100%;" in css - assert css.count("height: 480px;") == 1 and ".b08-milestone__map {" in css + kcss = _read("ui_template/ui_template_kakao_map.css") # 529-2 공용 지도 칸 + assert kcss.count("height: 480px;") == 1 and ".ui-kakao-map__box {" in kcss def test_153_지도_보기_유지_상자_색_끌어_옮김(): - kmap = _read("B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts") + kmap = _read("ui_template/ui_template_kakao_map.ts") assert ( "let fitted = false;" in kmap and "map.panTo(" in kmap ) # 처음 한 번만 맞춤 · 그 뒤 줌 그대로 @@ -170,9 +171,7 @@ def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화(): route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert 'label: "그림 변경"' in route and 'label: "초기화"' in route assert "그림 지우기" not in route and "자동 그림으로 되돌리기" not in route.split("*/", 1)[1] - assert ( - "map.root.hidden = user;" in route and "editMap(projectId" in route - ) # 그림 없으면 길찾기 지도 + assert "map.root.hidden = user;" in route and "editMap({" in route # 그림 없으면 길찾기 지도 assert ".b08-milestone__route-head {" in css and "justify-content: space-between;" in css state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "export const hasUserImage" in state and "canRevertImage" not in state diff --git a/resources/tester/test_255_1_b08_milestone_site_modal.py b/resources/tester/test_255_1_b08_milestone_site_modal.py index 51ce9d5b0..29efd249e 100644 --- a/resources/tester/test_255_1_b08_milestone_site_modal.py +++ b/resources/tester/test_255_1_b08_milestone_site_modal.py @@ -46,6 +46,6 @@ def test_시점_모달_km_카카오_지도(): # 설명 줄 · 등록 주소 · 브이월드 주소 줄 · B01 타일 지도 없음 for gone in ("시점 = 노선 첫 점", "프로젝트 등록 주소", "브이월드 주소", "buildAddressMap"): assert gone not in modal - assert "editMap(projectId" in site + assert "editMap({" in site assert '["start", "시점", s.start_lonlat]' in site and '["mid", "중점", s.mid_lonlat]' in site assert 'dialogClass: "b08-milestone__modal"' in site # 다른 모달과 같은 창 · 지도 높이 diff --git a/resources/tester/test_280_2_step_nav.py b/resources/tester/test_280_2_step_nav.py index 7693b2b47..20a92d14c 100644 --- a/resources/tester/test_280_2_step_nav.py +++ b/resources/tester/test_280_2_step_nav.py @@ -32,6 +32,19 @@ def results(): return json.loads(proc.stdout) +def test_529_1_D_E_묶음_안에서만_앞뒤(results): + rows = results["rows"] + for route in ("d01-plan-register", "e01-stab-register"): + assert rows[route]["plain"]["labels"] == ["?", "저장"], route + for route in ("d02-plan-base", "d04-plan-confirm", "e02-stab-ground", "e05-stab-report"): + assert rows[route]["plain"]["labels"] == ["?", "<", ">", "저장"], route + # 묶음 첫 단계 [<] · 끝 단계 [>] 흐림(B 묶음과 따로) + assert rows["d02-plan-base"]["plain"]["disabled"][:3] == [False, True, False] + assert rows["d04-plan-confirm"]["plain"]["disabled"][:3] == [False, False, True] + assert rows["e02-stab-ground"]["plain"]["disabled"][:3] == [False, True, False] + assert rows["e05-stab-report"]["plain"]["disabled"][:3] == [False, False, True] + + @pytest.mark.parametrize("shape", ["helpRow", "plain"]) def test_다섯_주소만_물음_앞뒤_차례(results, shape): for route in FIVE: diff --git a/resources/tester/test_54_2_help_files.py b/resources/tester/test_54_2_help_files.py index 480fda24a..1bc48b728 100644 --- a/resources/tester/test_54_2_help_files.py +++ b/resources/tester/test_54_2_help_files.py @@ -28,11 +28,15 @@ def test_title_order_and_live_edit(): assert "고친 글" in read_help("B05", tmp_path)[1]["text"] -@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05"]) +@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05", "D05", "E06", "C01"]) def test_bad_page_refused(page): with pytest.raises(HTTPException): read_help(page) +def test_529_1_D_E_pages_open_empty(tmp_path): + assert read_help("D02", tmp_path) == [] and read_help("E05", tmp_path) == [] + + def test_root_is_repo_resources(): assert HELP_ROOT.parts[-2:] == ("resources", "help") diff --git a/tsconfig.json b/tsconfig.json index 30bf838b6..c2c101de7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,6 @@ "@kninnug/constrainautor": ["config/node_modules/@kninnug/constrainautor"], } }, - "include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "Z0*", "ui_template", "config/**/*", "common_util"], + "include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "D0*", "E0*", "Z0*", "ui_template", "config/**/*", "common_util"], "exclude": ["0_old", "node_modules", "venv", "dist"] } diff --git a/ui_template/ui_template_help.ts b/ui_template/ui_template_help.ts index c8d7f13ec..002d0cb36 100644 --- a/ui_template/ui_template_help.ts +++ b/ui_template/ui_template_help.ts @@ -5,12 +5,12 @@ * 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막 * (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서). * 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞. - * [<] [>](요구 280-2) = [?] 바로 뒤 · B05~B09 다섯 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 → + * [<] [>](요구 280-2) = [?] 바로 뒤 · 단계 묶음(B05~B09 · D02~D04 · E02~E05 · 529-1) 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 → * 진행 단계 틀(`createWorkflowLayout`)이 이웃 단계 단추를 누름(저장 · 막힘은 각 페이지 `onStepClick`). * ========================================================================== */ import "./ui_template_help.css"; -import { API_BASE_URL, ROUTES } from "@config/config_frontend"; +import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend"; import { createButton, el } from "./ui_template_elements"; import { openModal } from "./ui_template_modal"; @@ -23,18 +23,23 @@ interface HelpItem { const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" }; const STEP_TEXT = { prev: "<", next: ">", prevTip: "이전 단계", nextTip: "다음 단계" }; -/** [<] [>] 를 붙이는 주소 — 목록으로 거름(b09-print · B03 · B04 · M01 · M02 뺌) */ -const STEP_NAV_ROUTES = [ - ROUTES.B05_PROFILE, - ROUTES.B06_SECTION, - ROUTES.B07_DESIGN_DETAIL, - ROUTES.B08_QUANTITY, - ROUTES.B09_ESTIMATION, -] as const; +/** [<] [>] 를 붙이는 주소 묶음 — 묶음 안에서만 오감(b09-print · B03 · B04 · M01 · M02 · D01 · E01 뺌) */ +const STEP_NAV_GROUPS: ReadonlyArray = [ + [ + ROUTES.B05_PROFILE, + ROUTES.B06_SECTION, + ROUTES.B07_DESIGN_DETAIL, + ROUTES.B08_QUANTITY, + ROUTES.B09_ESTIMATION, + ], + PLAN_STEP_ROUTES, + STAB_STEP_ROUTES, +]; +/** 설명서 폴더 이름 = 주소 머리(#/b05-… → B05 · #/d02-… → D02) */ function currentPage(): string { - const n = /^#\/b0(\d)/i.exec(location.hash)?.[1]; - return n ? `B0${n}` : ""; + const head = /^#\/([bde]0\d)/i.exec(location.hash)?.[1]; + return head ? head.toUpperCase() : ""; } function escapeHtml(text: string): string { @@ -186,24 +191,25 @@ function createStepNavButton(step: -1 | 1): HTMLButtonElement { return button; } -/** 지금 주소의 [<] [>] 차례 — 목록 밖이면 -1 */ -function stepNavIndex(): number { +/** 지금 주소의 묶음 · [<] [>] 차례 — 목록 밖이면 차례 -1 */ +function stepNavIndex(): { index: number; last: number } { const route = location.hash.replace(/^#\/?/, "").split("?")[0]; - return (STEP_NAV_ROUTES as readonly string[]).indexOf(route); + const group = STEP_NAV_GROUPS.find((routes) => routes.includes(route)) ?? []; + return { index: group.indexOf(route), last: group.length - 1 }; } /** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */ export function placeHelpButton(actions: HTMLElement): void { if (actions.querySelector(".ui-help-btn")) return; const row = actions.querySelector("[data-help-row]") ?? actions; - if (stepNavIndex() < 0) return void row.prepend(createHelpButton()); + if (stepNavIndex().index < 0) return void row.prepend(createHelpButton()); const prev = createStepNavButton(-1); const next = createStepNavButton(1); // 첫 단계(B05) [<] · 마지막 단계(B09) [>] 흐림 — B05 ↔ B06 은 페이지를 다시 안 세우므로 주소마다 다시 맞춤 const sync = (): void => { - const index = stepNavIndex(); + const { index, last } = stepNavIndex(); prev.disabled = index <= 0; - next.disabled = index === STEP_NAV_ROUTES.length - 1; + next.disabled = index === last; }; const onHash = (): void => { if (prev.isConnected) sync(); diff --git a/ui_template/ui_template_kakao_map.css b/ui_template/ui_template_kakao_map.css new file mode 100644 index 000000000..5a6ccad0e --- /dev/null +++ b/ui_template/ui_template_kakao_map.css @@ -0,0 +1,34 @@ +/* 카카오 지도 칸 공용(529-2) — 지도 · 안내 · 표지 이름 · 높이 어디서나 한 값(150) */ +.ui-kakao-map__box { + width: 100%; + height: 480px; + border: 1px solid var(--color-border); +} + +.ui-kakao-map__hint { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.ui-kakao-map__label { + padding: 2px 6px; + border: 1px solid #3c3c3c; + background: #fff; + color: #141414; + font-size: 12px; + white-space: nowrap; +} + +/* 262-2 고정 시점 표지 — 둥근 붉은 알약(고르는 표지 = 기본 핀 + 흰 이름표와 구별) */ +.ui-kakao-map__start { + padding: 3px 10px; + border: 2px solid #fff; + border-radius: 999px; + background: #e60046; + color: #fff; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + pointer-events: none; +} diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/ui_template/ui_template_kakao_map.ts similarity index 81% rename from B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts rename to ui_template/ui_template_kakao_map.ts index a6c2af12f..3c10ed1a6 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/ui_template/ui_template_kakao_map.ts @@ -1,17 +1,16 @@ /* ============================================================================= - * B08_Quantity_UI_Milestone_KakaoMap.ts - * 거리이정표 카카오 지도 칸(PLAN 244-9 · 사용자 138번) — 등록 모달(소재지 고르기) · 연결선 모달(길 선 · 표지 끌기) - * 한 벌(`editMap`) · 경로 하위 탭 지도 칸은 없앰(156 · 탭 = 그림만). + * ui_template_kakao_map.ts + * 카카오 지도 칸 공용(PLAN 244-9 · 529-2) — 고르는 지도 한 벌(`editMap`) · B08 거리이정표 · D 예정노선이 씀. * - * SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET …/milestone/map-key + * SDK = dapi.kakao.com/v2/maps/sdk.js(autoload=false) · 키 = 서버 GET /api/map/kakao-key * (.env JS 키 · 공개용 · 도메인 등록으로 막힘) · 키 없음 · 못 뜸 = 안내 한 줄 + 사진 올리기 - * 길 선 = 마지막 [길찾기] 응답 경로 좌표(메모리만 · 저장 안 함) · 처음 한 번만 맞춤(153 · 그 뒤 보기 그대로) - * 경로 그림 = 서버 자동 캡처(244-11 · `…_State` autoRouteImage) — 브라우저 캡처 없음(접속 주소 무관) + * 길 선 = 부르는 쪽이 준 [위도, 경도] 목록 · 처음 한 번만 맞춤(153 · 그 뒤 보기 그대로) * (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; -import { request } from "./B08_Quantity_UI_Hydro"; +import "./ui_template_kakao_map.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { el } from "./ui_template_elements"; export const NO_MAP = "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요"; @@ -58,11 +57,12 @@ interface KakaoMaps { let sdk: Promise | null = null; /** SDK 한 번 싣기 — 키 없음 · 못 받음 = NO_MAP 으로 거절(다음 부름 때 다시) */ -export function loadKakaoMaps(projectId: string): Promise { +export function loadKakaoMaps(): Promise { sdk ??= (async () => { - const { key } = await request<{ key: string | null }>( - `/projects/${projectId}/quantity/milestone/map-key`, - ); + const response = await fetch(`${API_BASE_URL}/map/kakao-key`, { credentials: "include" }); + const { key } = response.ok + ? ((await response.json()) as { key: string | null }) + : { key: null }; if (!key) throw new Error(NO_MAP); const w = window as unknown as { kakao?: { maps: KakaoMaps } }; if (!w.kakao?.maps) { @@ -109,24 +109,21 @@ export interface EditMap { } /** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */ -export function editMap( - projectId: string, - opts: { - onMove: (key: string, lon: number, lat: number) => void; - onClick?: (lon: number, lat: number) => void; - center?: () => [number, number] | null; - hint: string; - }, -): EditMap { - const box = el("div", { className: "b08-milestone__map" }); - const hint = el("p", { className: "b08-options__hint", text: opts.hint }); - hint.dataset.b08MilestoneMapHint = ""; +export function editMap(opts: { + onMove: (key: string, lon: number, lat: number) => void; + onClick?: (lon: number, lat: number) => void; + center?: () => [number, number] | null; + hint: string; +}): EditMap { + const box = el("div", { className: "ui-kakao-map__box" }); + const hint = el("p", { className: "ui-kakao-map__hint", text: opts.hint }); + hint.dataset.kakaoMapHint = ""; let map: KMap | null = null; let drawn: KOverlay[] = []; let fitted = false; // 153 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로 const set = (pins: MapPin[], lines: Array>): void => { - loadKakaoMaps(projectId).then( + loadKakaoMaps().then( (maps) => requestAnimationFrame(() => { const moving = pins.filter((p) => !p.fixed); @@ -164,7 +161,7 @@ export function editMap( new maps.CustomOverlay({ position: at, content: el("span", { - className: "b08-milestone__map-start", + className: "ui-kakao-map__start", text: p.name, }), yAnchor: 0.5, @@ -180,7 +177,7 @@ export function editMap( marker, new maps.CustomOverlay({ position: at, - content: el("span", { className: "b08-milestone__map-label", text: p.name }), + content: el("span", { className: "ui-kakao-map__label", text: p.name }), yAnchor: 2.6, }), ]; @@ -211,7 +208,7 @@ export function editMap( }; return { - root: el("div", { className: "b08-milestone__map-wrap", children: [hint, box] }), + root: el("div", { className: "ui-kakao-map", children: [hint, box] }), set, }; } diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 51b1be1e4..042d85609 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -14,6 +14,7 @@ * - ui_template_locale_b1.ts : B 그룹 전반부 (B01~B04) * - ui_template_locale_b2.ts : B 그룹 후반부 (B05~B07) * - ui_template_locale_b5.ts : B 그룹 끝 (B08~B11) + * - ui_template_locale_de.ts : D 예정노선 · E 안정성평가 · 대시보드 [+] 고르기 * * 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다. * ========================================================================== */ @@ -28,6 +29,7 @@ import { ui_locales_b3 } from "./ui_template_locale_b3"; import { ui_locales_b5 } from "./ui_template_locale_b5"; import { ui_locales_m1 } from "./ui_template_locale_m1"; import { ui_locales_m2 } from "./ui_template_locale_m2"; +import { ui_locales_de } from "./ui_template_locale_de"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -65,6 +67,7 @@ export const ui_locales = { ...ui_locales_b5, ...ui_locales_m1, ...ui_locales_m2, + ...ui_locales_de, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts new file mode 100644 index 000000000..d71934712 --- /dev/null +++ b/ui_template/ui_template_locale_de.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * ui_template_locale_de.ts + * 다국어 사전 — D 예정노선 · E 안정성평가 그룹(요구 345) · 대시보드 [+] 고르기 + * + * 신규 문구는 해당 섹션 최하단에 추가. + * 이 파일은 사전 데이터만 담는다. 합성·언어 전환·t() 헬퍼는 ui_template_locale.ts 참조. + * ========================================================================== */ + +import type { LocaleEntry } from "./ui_template_locale_common"; + +export const ui_locales_de = { + /* --- 단계 막대 · 빈 단계 --- */ + DE_Step_PlanBase: ["기준 지정", "Base Points"], + DE_Step_PlanAnalysis: ["노선 분석", "Route Analysis"], + DE_Step_PlanConfirm: ["확정 · 내보내기", "Confirm · Export"], + DE_Step_StabGround: ["지반 조건", "Ground"], + DE_Step_StabSection: ["2D 해석", "2D Analysis"], + DE_Step_Stab3D: ["3D 해석", "3D Analysis"], + DE_Step_StabReport: ["보고서", "Report"], + DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."], + + /* --- 대시보드 [+] 고르기(528-2) --- */ + DE_New_Title: ["새 프로젝트", "New Project"], + DE_New_Plan: ["예정노선", "Planned Route"], + DE_New_Road: ["임도설계", "Forest Road Design"], + DE_New_Erosion: ["사방설계", "Erosion Control"], + DE_New_Stability: ["안정성평가", "Stability Assessment"], + DE_New_Soon: ["준비 중", "Coming soon"], + DE_New_PlanForestOnly: [ + "예정노선은 산림청(시스템 관리자 · go.kr 메일)만 만들 수 있습니다.", + "Only the Korea Forest Service (system admin · go.kr mail) can create planned routes.", + ], +} as const satisfies Record; diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index a7768fb54..dc083b541 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -33,8 +33,8 @@ export const LOUPE_HOLD_MS = 400; export const LOUPE_REFRESH_MS = 500; /** 화면 끝과 원 사이 여백(px) — 테두리가 안 잘리게 */ export const LOUPE_EDGE = 4; -/** 붙일 주소 — B01~B09 · M01 · M02(A 영역 · 로그인 · B10 · B11 은 뺌) */ -export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/; +/** 붙일 주소 — B01~B09 · M01 · M02 · D01~D04 · E01~E05(A 영역 · 로그인 · B10 · B11 은 뺌) */ +export const LOUPE_ROUTE = /^(b0[1-9]|m0[12]|d0[1-4]|e0[1-5])-/; const SVG_NS = "http://www.w3.org/2000/svg"; /** CAD 영역 — cad_host 가 끼운 상자와 iframe */ diff --git a/ui_template/ui_template_native_menu.ts b/ui_template/ui_template_native_menu.ts index d5da108d9..993c81901 100644 --- a/ui_template/ui_template_native_menu.ts +++ b/ui_template/ui_template_native_menu.ts @@ -7,8 +7,8 @@ * 드롭다운 초점 빼기 — 마우스로 고른 뒤 초점이 남으면 휠 · ↑↓ 가 값을 또 바꿈(요구 280-6). * ========================================================================== */ -/** 막을 주소 — B · M 영역(`b01-…` · `m02-…`) · A 영역은 그대로 */ -export const NATIVE_MENU_ROUTE = /^[bm]\d/; +/** 막을 주소 — B · M · D · E 영역(`b01-…` · `m02-…` · `d02-…`) · A 영역은 그대로 */ +export const NATIVE_MENU_ROUTE = /^[bmde]\d/; /** `win` 의 기본 메뉴를 막음 — `isOn()` 이 거짓이면(예: A 영역 주소) 그대로 둠 */ export function blockNativeMenu(win: Window, isOn: () => boolean): void { diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 07ff4ba68..8d87be6b7 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -30,6 +30,8 @@ export interface WorkflowLayoutOptions { /** 막힌(NOT_STARTED) 단계라도 켤 칸 — 수 하나 또는 목록(B05 · B06 = 2 · 3 · 4 · 496-1) */ currentStage?: number | readonly number[]; routes?: readonly string[]; + /** 단계 아이콘 — 없으면 B 일곱 단계 아이콘(D · E 그룹은 제 목록 · 529-1) */ + icons?: readonly string[]; onStepClick?: (stepIndex: number, route: string) => void; /** 좌측 패널이 열리고 닫힐 때(처음 앉힘 포함) — 닫힌 동안 바뀐 안쪽을 열 때 맞춤(499-1). */ onOptionsOpenChange?: (isOpen: boolean) => void; @@ -212,7 +214,7 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa currentStage: options.currentStage, routes: options.routes, orientation: "vertical", - icons: WORKFLOW_STEP_ICONS, + icons: options.icons ?? WORKFLOW_STEP_ICONS, onStepClick: options.onStepClick, homeButton: true, });