feat(B03~B08): 제목 줄 프로젝트 이름 + 계획노선 CSV 문구 정리

- 워크플로 상태 응답에 project_name 추가. 좌측 제목 패널 같은 행 오른쪽 끝에
  프로젝트 이름 표기(길면 말줄임, 전체는 툴팁). 이름표를 오버레이 쪽에 두어
  레이아웃을 직접 조립하는 B03 까지 여섯 화면이 한 곳으로 반영됨.
- fetchProjectWorkflowState 가 {status, workflow_state} 껍데기를 벗기도록 수정.
- 계획노선을 「업로드한 CSV」로 적은 주석을 「계획노선(정본)」으로 정리.
  업로드 판정이 .csv 개수만 세어 shapefile 을 막던 것도 .shp 포함으로 맞춤.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:26:17 +09:00
co-authored by Claude Opus 5
parent 149920f3b5
commit cc15a83f67
12 changed files with 96 additions and 23 deletions
+2
View File
@@ -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[];
}
+9 -4
View File
@@ -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<ResourceData> {
return request<ResourceData>(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`);
}
export function fetchProjectWorkflowState(projectId: string): Promise<WorkflowState> {
return request(`/projects/${projectId}/workflow-state`, {
method: "GET",
}) as Promise<WorkflowState>;
export async function fetchProjectWorkflowState(projectId: string): Promise<WorkflowState> {
// 응답은 `{status, workflow_state}` 껍데기로 온다 — 벗겨서 상태만 넘긴다.
const data = await request<{ workflow_state?: WorkflowState } & WorkflowState>(
`/projects/${projectId}/workflow-state`,
{ method: "GET" },
);
return data.workflow_state ?? data;
}
+5 -3
View File
@@ -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}
+5 -5
View File
@@ -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
+3 -3
View File
@@ -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<PlannedRoute
}
/* ── 배수유역 분석 (B04_PreProcess_Router_Watershed.py) ────────────────────
* 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
* 관리자 확인용. 계획 노선(B03 정본) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고
* 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다.
* ------------------------------------------------------------------------ */
+1 -1
View File
@@ -113,7 +113,7 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
물린 자리와 측점 자리가 어긋난다.
관 지점 파일에는 저장 당시 노선 지문이 함께 있다. 지문이 달라도 저장분에 좌표가 있으면
**이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 CSV)과 여기 계획선은
**이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 정본)과 여기 계획선은
같은 자리를 지나면서 연장이 다르다(실측 350.11m vs 354.83m). 그래서 지문은 거의 항상
달랐고 관이 통째로 빠졌다(2026-08-30 사용자 지적). 좌표가 없는 구 저장분만 버린다.
"""
+1 -1
View File
@@ -3,7 +3,7 @@
두 화면이 같은 관 목록과 같은 세부유역을 보여 주려면 **입력이 한 글자도 달라선 안 된다**
(2026-08-01 사용자 지시). 그래서 노선·종단 Z·좌표계를 여기 한 곳에서 만들어 양쪽에 넘긴다.
노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 업로드한 원청 계획노선 CSV**다. B04 격자
노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 받은 원청 계획노선(정본)**다. B04 격자
해석이 그 노선으로 도로 셀을 구웠으므로, 다른 노선의 누가거리를 쓰면 도로 셀과 관 위치가
어긋난다. 종단 Z만 상황에 따라 갈아 끼운다 → [[common_util_route_profile]].
"""
+2 -2
View File
@@ -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)
+17 -2
View File
@@ -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,
}
+2 -2
View File
@@ -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: [
+18
View File
@@ -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);
+31
View File
@@ -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;