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:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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초 안팎이 걸리므로 여기서 한 번만 돌린다.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
|
||||
@@ -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 사용자 지적). 좌표가 없는 구 저장분만 버린다.
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
두 화면이 같은 관 목록과 같은 세부유역을 보여 주려면 **입력이 한 글자도 달라선 안 된다**
|
||||
(2026-08-01 사용자 지시). 그래서 노선·종단 Z·좌표계를 여기 한 곳에서 만들어 양쪽에 넘긴다.
|
||||
|
||||
노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 업로드한 원청 계획노선 CSV**다. B04 격자
|
||||
노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 받은 원청 계획노선(정본)**이다. B04 격자
|
||||
해석이 그 노선으로 도로 셀을 구웠으므로, 다른 노선의 누가거리를 쓰면 도로 셀과 관 위치가
|
||||
어긋난다. 종단 Z만 상황에 따라 갈아 끼운다 → [[common_util_route_profile]].
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user