diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index 8f58d4bb..bbd26970 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -14,6 +14,8 @@ import { navigateTo } from "./router"; export interface WorkflowState { project_id: string; + /** 좌측 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). */ + project_name?: string | null; current_stage: number; stages: WorkflowStage[]; } diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 6aec62b9..a8b00529 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -29,6 +29,8 @@ export interface WorkflowStageState { export interface WorkflowState { project_id: string; + /** 좌측 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). */ + project_name?: string | null; current_stage: number; stages: WorkflowStageState[]; } @@ -412,8 +414,11 @@ export async function fetchSystemResources(days = 30): Promise { return request(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`); } -export function fetchProjectWorkflowState(projectId: string): Promise { - return request(`/projects/${projectId}/workflow-state`, { - method: "GET", - }) as Promise; +export async function fetchProjectWorkflowState(projectId: string): Promise { + // 응답은 `{status, workflow_state}` 껍데기로 온다 — 벗겨서 상태만 넘긴다. + const data = await request<{ workflow_state?: WorkflowState } & WorkflowState>( + `/projects/${projectId}/workflow-state`, + { method: "GET" }, + ); + return data.workflow_state ?? data; } diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index e573962f..6747cb5b 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -164,13 +164,15 @@ async def upload_project_files( "message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.", }, ) - csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames) - if csv_count != 1: + # 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다 + # (2026-09-04 사용자 지시: 사용자는 CSV 를 쓰지 않음. CSV 는 내부 정본 한 벌뿐). + route_count = sum(Path(filename).suffix.lower() in {".csv", ".shp"} for filename in filenames) + if route_count != 1: return JSONResponse( status_code=400, content={ "status": "error", - "message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.", + "message": "계획노선 파일(shapefile 의 .shp 또는 .csv)을 정확히 1개 포함해야 합니다.", }, ) request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames} diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 55e24128..d23842b4 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -1,7 +1,7 @@ """B03 업로드 이후 자동 설계 체인 — WF1 확정 다음을 잇는다. WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어도 서버가 이어서 -① B05 기본 경로 계산·확정(계획노선 CSV 기반) ② B06 기본 횡단 설계 확정까지 +① B05 기본 경로 계산·확정(계획노선 정본 기반) ② B06 기본 횡단 설계 확정까지 기본값으로 진행해 영구저장소에 남긴다(2026-08-04 사용자 확정). 이후 사용자가 대시보드에서 B05/B06에 들어오면 저장본을 바로 로딩해 검토·수정만 하면 된다. @@ -164,7 +164,7 @@ async def run_auto_design_chain( ) return None - # 2) 계획노선 CSV → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다. + # 2) 계획노선 정본 → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다. project_root = Path(resolve_stored_project_path(stored_path)) # 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면 # 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장). @@ -179,8 +179,8 @@ async def run_auto_design_chain( points = _planned_route_points_in_project_crs(project_root, defaults) if not points: - logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) - mark_design_failed(project_root, "계획노선 CSV가 없어 초기 노선을 세울 수 없습니다.") + logger.warning("자동 설계 체인 중단(계획노선 없음): project_id=%s", project_id) + mark_design_failed(project_root, "계획노선이 없어 초기 노선을 세울 수 없습니다.") return None # 3) B05 경로 계산 @@ -300,7 +300,7 @@ async def run_redesign_chain( 새 경로에 이월하고, 표준단면 설정(data.options)도 함께 넘긴다. 나머지 미지정 측점은 확정 시 기본값으로 채워진다. - 경로가 아예 없으면 신규 자동 체인(계획노선 CSV 기본값)으로 되돌아간다. + 경로가 아예 없으면 신규 자동 체인(계획노선 정본 기본값)으로 되돌아간다. """ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Repository import get_latest_route diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index df279155..cec90c88 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -137,7 +137,7 @@ export interface SurfaceConfirmedResponse { z_min: number; z_max: number; } | null; - /** 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */ + /** 계획노선(B03 정본)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */ route_bounds: { x_min: number; x_max: number; y_min: number; y_max: number } | null; } @@ -308,7 +308,7 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise }); } -/** 계획노선(B03 업로드 CSV)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */ +/** 계획노선(B03 정본)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */ export interface PlannedRouteResponse { status: string; points: Array<{ x: number; y: number }>; @@ -322,7 +322,7 @@ export async function fetchPlannedRoute(projectId: string): Promise list[P 물린 자리와 측점 자리가 어긋난다. 관 지점 파일에는 저장 당시 노선 지문이 함께 있다. 지문이 달라도 저장분에 좌표가 있으면 - **이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 CSV)과 여기 계획선은 + **이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 정본)과 여기 계획선은 같은 자리를 지나면서 연장이 다르다(실측 350.11m vs 354.83m). 그래서 지문은 거의 항상 달랐고 관이 통째로 빠졌다(2026-08-30 사용자 지적). 좌표가 없는 구 저장분만 버린다. """ diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index 2d023038..e3bbb60a 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -3,7 +3,7 @@ 두 화면이 같은 관 목록과 같은 세부유역을 보여 주려면 **입력이 한 글자도 달라선 안 된다** (2026-08-01 사용자 지시). 그래서 노선·종단 Z·좌표계를 여기 한 곳에서 만들어 양쪽에 넘긴다. -노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 업로드한 원청 계획노선 CSV**다. B04 격자 +노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 받은 원청 계획노선(정본)**이다. B04 격자 해석이 그 노선으로 도로 셀을 구웠으므로, 다른 노선의 누가거리를 쓰면 도로 셀과 관 위치가 어긋난다. 종단 Z만 상황에 따라 갈아 끼운다 → [[common_util_route_profile]]. """ diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 27ce35f8..07a7edb7 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -9,7 +9,7 @@ 종단 Z가 달라지지만 관이 놓인 자리는 그대로여야 하고, 그때는 세부유역만 다시 나누면 된다. 좌표를 같이 남기는 이유(2026-08-30 사용자 지적 — "결국 노선 위에 위치해야 한다"): 관 자리를 -정한 선(계획노선 CSV)과 화면에 그려지는 선(B05 최적 경로)은 **같은 자리를 지나면서 연장이 +정한 선(계획노선 정본)과 화면에 그려지는 선(B05 최적 경로)은 **같은 자리를 지나면서 연장이 다르다**(실측 350.11m vs 354.83m). 누가거리만 남기면 읽는 쪽이 쥔 선에 따라 같은 값이 3~4m 미끄러져 관이 선 옆에 떨어진 것처럼 보인다. 좌표를 남겨 두면 어느 선으로 읽든 그 좌표를 투영해 **항상 선 위에** 앉힐 수 있다. @@ -416,7 +416,7 @@ def save_detail_basins(stored_path: str, features: list[dict[str, Any]], crs: st """세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다). `crs`는 좌표를 WGS84로 바꿀 때 쓴 **사업지 좌표계**다. 되읽는 쪽(B07 유역도)이 같은 - 좌표계로 되돌려야 하는데, 예전에는 이 값을 안 남겨 노선 CSV의 EPSG 라벨로 되돌렸다 + 좌표계로 되돌려야 하는데, 예전에는 이 값을 안 남겨 노선 정본의 EPSG 라벨로 되돌렸다 (2026-09-01: 라벨과 실좌표계가 갈린 프로젝트에서 유역이 딴 자리로 갔다). """ path = detail_basins_path(stored_path) diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index a1d4cacd..502c6267 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -168,7 +168,16 @@ async def fail_stage( async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Dict[str, Any]: - """프로젝트의 모든 단계 상태를 조회하여 요약 및 배열로 반환한다.""" + """프로젝트의 모든 단계 상태를 조회하여 요약 및 배열로 반환한다. + + 프로젝트 이름도 함께 싣는다 (2026-09-04 사용자 지시) — B03~B08 좌측 제목 줄 오른쪽에 + 이름을 붙이는데, 화면이 들고 있는 것은 프로젝트 id 뿐이라 여기서 내려 준다. 새로고침· + 주소 직접 입력으로 들어와도 같은 값이 따라온다. + """ + await cursor.execute("SELECT name FROM projects WHERE id = %s", (project_id,)) + name_row = await cursor.fetchone() + project_name = name_row["name"] if name_row else None + await cursor.execute( """ SELECT stage_no, stage_key, state, progress_percent, params, message, @@ -183,7 +192,12 @@ async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Di rows = await cursor.fetchall() if not rows: - return {"project_id": project_id, "current_stage": 0, "stages": []} + return { + "project_id": project_id, + "project_name": project_name, + "current_stage": 0, + "stages": [], + } stages_list = [] for r in rows: @@ -221,6 +235,7 @@ async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Di return { "project_id": project_id, + "project_name": project_name, "current_stage": current_stage, "stages": stages_list, } diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index e73e12d1..af121860 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -364,8 +364,8 @@ export const ui_locales_b1 = { "A file for this slot is already selected.", ], B03_File_Error_RequiredSlots: [ - "필수 카드를 모두 채우세요 — 계획노선(CSV 또는 shapefile 한 벌), LAS/LAZ, 지형 PRJ·TFW.", - "Fill every required card: the planned route (a CSV or a full shapefile set), " + + "필수 카드를 모두 채우세요 — 계획노선(shapefile 한 벌 또는 CSV), LAS/LAZ, 지형 PRJ·TFW.", + "Fill every required card: the planned route (a full shapefile set or a CSV), " + "LAS/LAZ, and the terrain PRJ and TFW.", ], B03_File_Error_SlotType: [ diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index e4372eb4..c0e2062c 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -93,6 +93,24 @@ cursor: pointer; } +/* 제목 줄 오른쪽 프로젝트 이름 (2026-09-04 사용자 지시) — 길면 말줄임, 전체는 툴팁. + 줄어드는 쪽은 이름만 — 페이지 제목은 짧은 고정 문구라 밀리면 안 된다. */ +.ui-workflow-overlay__panel--title .ui-workflow-overlay__title { + flex: 0 0 auto; +} + +.ui-workflow-overlay__project { + flex: 0 1 auto; + max-width: 60%; + min-width: 0; + overflow: hidden; + color: var(--color-text-muted, var(--color-text)); + font-size: var(--text-body-sm); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + .ui-workflow-overlay__toggle { flex: 0 0 auto; width: var(--spacing-32); diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index d2736098..607c6093 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -1,6 +1,8 @@ import "./ui_template_overlay.css"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { t } from "./ui_template_locale"; import { makePanelDraggable } from "./ui_template_overlay_drag"; +import { fetchProjectWorkflowState } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; @@ -110,12 +112,38 @@ function splitSidebarActions(body: HTMLElement): void { } } +/** + * 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). + * + * 화면이 들고 있는 것은 프로젝트 id 뿐이라 워크플로 상태 응답에서 이름을 받아 온다 — + * 새로고침·주소 직접 입력으로 들어와도 따라온다. 이름이 없으면 칸을 비워 둔다. + * 여기 한 곳에 두어 제목 패널을 쓰는 화면(B03~B08)이 모두 같은 값을 보인다. + */ +function createProjectNameTag(): HTMLElement { + const tag = document.createElement("span"); + tag.className = "ui-workflow-overlay__project"; + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) return tag; + void fetchProjectWorkflowState(projectId) + .then((state) => { + const name = state.project_name ?? ""; + tag.textContent = name; + // 이름이 길면 말줄임으로 자르고 전체는 툴팁으로 본다. + if (name) tag.title = name; + }) + .catch(() => { + /* 조회 실패는 제목만 보이면 된다 — 빈 칸으로 둔다. */ + }); + return tag; +} + function createPanel( variant: "title" | "progress", titleText: string, body: HTMLElement, storageKey: string, onOpenChange?: (isOpen: boolean) => void, + titleAside?: HTMLElement, ): { root: HTMLElement; setOpen: (isOpen: boolean) => void } { const root = document.createElement("aside"); root.className = `ui-workflow-overlay__panel ui-workflow-overlay__panel--${variant}`; @@ -137,6 +165,8 @@ function createPanel( if (isSidebar) { header.append(title); + // 제목은 왼쪽, 프로젝트 이름은 같은 행 오른쪽 끝 (2026-09-04 사용자 지시). + if (titleAside) header.append(titleAside); root.append(header, toggle); } else { header.append(title, toggle); @@ -210,6 +240,7 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo titleBody, TITLE_OVERLAY_STATE_KEY, options.onOptionsOpenChange, + createProjectNameTag(), ); root.append(titlePanel.root); setTitleOpen = titlePanel.setOpen;