From 149920f3b5b89dc15985af3c5f0db8cf60a404d1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:05:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B01):=20=EB=AA=A8=EB=8B=AC=20=EB=B0=94?= =?UTF-8?q?=EA=B9=A5=20=ED=81=B4=EB=A6=AD=20=EB=8B=AB=EA=B8=B0=20+=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=EB=A1=9C=EA=B3=A0=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모달 닫기 규칙 일원화 — 바깥 클릭·Esc·[취소] 모두 같은 경로(attachModalDismiss). 고친 게 있으면 공용 showConfirmDialog 로 한 번 묻고, 없으면 바로 닫음. 껍데기 세 곳(Modals·AssetPicker·TempModal) 모두 적용. - 확인창 z-index 토큰 --z-confirm(1050) 신설 — 모달(1000) 뒤에 깔리던 문제 해소. - 프로젝트 로고 칸에 회사 기본 연결 표시 — 프로젝트 값이 비면 회사 로고를 「회사 기본 로고 · <자산명>」으로 보이되 저장값은 계속 null(연결 유지). [기본으로] 로 전용 로고 해제. 남의 회사 프로젝트에는 기본을 내밀지 않음. Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts | 41 ++++++- B01_Dashboard/B01_Dashboard_UI_Common.ts | 111 +++++++++++++++++- B01_Dashboard/B01_Dashboard_UI_Modals.ts | 86 ++++++++++---- B01_Dashboard/B01_Dashboard_UI_TempModal.ts | 10 +- ui_template/ui_template_elements_styles.ts | 2 +- ui_template/ui_template_theme.css | 2 + 6 files changed, 219 insertions(+), 33 deletions(-) diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts index d16b079c..1aadb7eb 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -14,6 +14,7 @@ import { type CompanyAsset, type DashboardUser, } from "./B01_Dashboard_Api_Fetch"; +import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common"; export interface AssetFieldHandle { root: HTMLDivElement; @@ -25,6 +26,13 @@ export interface AssetFieldOptions { owner?: { id: number; name: string } | null; /** 고른 뒤 바로 서버에 반영해야 하는 자리(회사 로고·사용자 서명)에서 쓴다. */ onChange?: (assetId: number | null) => void | Promise; + /** + * 비었을 때 대신 보여 줄 기본값 — 프로젝트 로고를 안 고르면 도면에는 회사 로고가 + * 실린다(`COALESCE(p.logo_asset_id, c.logo_asset_id)`). 그 사실을 화면에도 보인다 + * (2026-09-04 사용자 지시). **저장값은 계속 null** — 회사 로고를 바꾸면 따라가야 하므로 + * 값을 복사해 굳히지 않는다. + */ + fallback?: { asset: CompanyAsset | null; prefix: string; missingNote: string }; } const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const; @@ -60,10 +68,29 @@ export function createAssetField( const name = document.createElement("span"); name.className = "b01-dashboard__asset-name"; + const fallback = options.fallback ?? null; + const reset = createButton({ + label: "기본으로", + variant: "ghost", + onClick: () => { + selected = null; + render(); + void options.onChange?.(null); + }, + }); + const render = (): void => { - preview.hidden = !selected; - if (selected) preview.src = companyAssetFileUrl(selected.id); - name.textContent = selected ? selected.label : "(없음)"; + const shown = selected ?? fallback?.asset ?? null; + preview.hidden = !shown; + if (shown) preview.src = companyAssetFileUrl(shown.id); + if (selected) name.textContent = selected.label; + else if (fallback) { + name.textContent = fallback.asset + ? `${fallback.prefix} · ${fallback.asset.label}` + : fallback.missingNote; + } else name.textContent = "(없음)"; + // 프로젝트 전용 값을 골랐을 때만 기본 연결로 되돌릴 거리가 생긴다. + reset.hidden = !fallback || selected === null; }; render(); @@ -87,6 +114,7 @@ export function createAssetField( ), }); row.append(preview, name, pick); + if (fallback) row.append(reset); root.append(caption, row); return { root, value: () => selected?.id ?? null }; } @@ -209,6 +237,7 @@ function openAssetPickerModal( const grid = document.createElement("div"); grid.className = "b01-dashboard__asset-grid"; + let dismiss: ModalDismissHandle | null = null; const close = (): void => modal.remove(); const choose = (asset: CompanyAsset | null): void => { onPick(asset, list); @@ -308,10 +337,14 @@ function openAssetPickerModal( const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; - actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close })); + actions.append( + createButton({ label: "닫기", variant: "ghost", onClick: () => void dismiss?.tryClose() }), + ); panel.append(heading, grid, addTitle, label.root, file.root); if (pad) panel.append(pad.root); panel.append(mine, add, actions); modal.append(panel); document.body.append(modal); + // 「신규 추가」에 이름·파일을 넣어 두고 바깥을 누르면 한 번 묻는다 (2026-09-04 사용자 지시). + dismiss = attachModalDismiss(modal, panel); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index 90aba864..65b6eccd 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -1,5 +1,10 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; /** @@ -33,3 +38,107 @@ export async function runRequest(action: () => Promise): Promise export function formatDate(value?: string | null): string { return value ? value.slice(0, 10) : "-"; } + +/* ----------------------------------------------------------------------------- + * 모달 바깥 클릭으로 닫기 (2026-09-04 사용자 지시) + * + * 껍데기를 만드는 자리가 세 곳(Modals·AssetPicker·TempModal)이라 규칙을 여기 한 곳에 + * 둔다. 고친 게 있으면 공용 `showConfirmDialog` 로 한 번 묻고, 없으면 바로 닫는다. + * -------------------------------------------------------------------------- */ + +/** 모달 안 입력값을 한 줄로 떠 둔다 — 열 때와 닫을 때를 견주어 변경을 판정한다. */ +export function snapshotModalFields(panel: HTMLElement): string { + const parts: string[] = []; + for (const node of panel.querySelectorAll("input, select, textarea")) { + if (node instanceof HTMLInputElement && node.type === "checkbox") { + parts.push(node.checked ? "1" : "0"); + } else if (node instanceof HTMLInputElement && node.type === "file") { + parts.push( + Array.from(node.files ?? []) + .map((file) => `${file.name}:${file.size}`) + .join(","), + ); + } else { + parts.push((node as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).value); + } + } + return parts.join("\u0001"); +} + +export interface ModalDismissOptions { + /** 입력칸 밖에서 바뀌는 값(고른 로고·고른 파일 목록)을 덧붙인다. */ + extra?: () => string; + /** 실제로 닫는 동작 — 기본은 모달 제거. */ + close?: () => void; +} + +export interface ModalDismissHandle { + isDirty: () => boolean; + /** 변경이 있으면 확인창을 거쳐 닫는다 — [취소] 단추도 이것을 쓴다. */ + tryClose: () => Promise; +} + +export function attachModalDismiss( + modal: HTMLElement, + panel: HTMLElement, + options: ModalDismissOptions = {}, +): ModalDismissHandle { + const extraOf = (): string => options.extra?.() ?? ""; + let baseline = snapshotModalFields(panel); + const extraBaseline = extraOf(); + // 값을 나중에 채우는 칸(비동기 조회)이 있어, 사용자가 아직 손대기 전이면 기준을 다시 뜬다. + let touched = false; + const mark = (): void => { + touched = true; + }; + panel.addEventListener("input", mark, true); + panel.addEventListener("change", mark, true); + + const isDirty = (): boolean => { + if (extraOf() !== extraBaseline) return true; + const now = snapshotModalFields(panel); + if (!touched) { + baseline = now; + return false; + } + return now !== baseline; + }; + + const onKey = (event: KeyboardEvent): void => { + if (!modal.isConnected) { + document.removeEventListener("keydown", onKey, true); + return; + } + if (event.key !== "Escape") return; + // 확인창이 떠 있으면 그쪽이 먼저고, 모달이 여럿이면 맨 위 것만 닫는다. + if (document.querySelector(".ui-confirm")) return; + const opened = document.querySelectorAll(".b01-dashboard__modal"); + if (opened[opened.length - 1] !== modal) return; + event.stopPropagation(); + void tryClose(); + }; + + const close = (): void => { + document.removeEventListener("keydown", onKey, true); + if (options.close) options.close(); + else modal.remove(); + }; + + const tryClose = async (): Promise => { + if (!isDirty()) return close(); + const ok = await showConfirmDialog("변경한 내용이 저장되지 않습니다. 닫을까요?", "닫기"); + if (ok) close(); + }; + + // 패널 안에서 시작한 드래그가 바깥에서 끝나도 닫히지 않게 누른 자리까지 본다. + let downOnOverlay = false; + modal.addEventListener("mousedown", (event) => { + downOnOverlay = event.target === modal; + }); + modal.addEventListener("click", (event) => { + if (event.target === modal && downOnOverlay) void tryClose(); + }); + document.addEventListener("keydown", onKey, true); + + return { isDirty, tryClose }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 9a111b49..a4141316 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -19,6 +19,7 @@ import { addCompanyMember, fetchCompanyMembers, fetchCompanyAssets, + fetchUserCompany, updateCompanyAsset, createCompanyAsset, setCompanyLogo, @@ -27,6 +28,7 @@ import { type Member, } from "./B01_Dashboard_Api_Fetch"; import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; +import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common"; /** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */ const NEW_MEMBER = "__new__"; @@ -35,7 +37,12 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise): void { +function openModal( + title: string, + body: HTMLElement[], + onConfirm: () => Promise, + options: { extra?: () => string } = {}, +): void { const modal = document.createElement("div"); modal.className = "b01-dashboard__modal"; const panel = document.createElement("div"); @@ -45,11 +52,13 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise< heading.textContent = title; const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; + // 닫는 길은 하나로 — [취소]·바깥 클릭·Esc 모두 같은 변경 확인을 거친다. + let dismiss: ModalDismissHandle | null = null; actions.append( createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", - onClick: () => modal.remove(), + onClick: () => void dismiss?.tryClose(), }), createButton({ label: L("Common_Btn_Confirm"), @@ -73,6 +82,7 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise< panel.append(heading, ...body, actions); modal.append(panel); document.body.append(modal); + dismiss = attachModalDismiss(modal, panel, { extra: options.extra }); } export async function openEditProjectModal( @@ -81,10 +91,18 @@ export async function openEditProjectModal( ): Promise { const isUserOnly = user.role === "USER"; // 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정). - const [members, assets] = await Promise.all([ + // 회사 정보는 로고 기본 연결을 보이기 위해 함께 받는다 (2026-09-04 사용자 지시). + const [members, assets, company] = await Promise.all([ fetchCompanyMembers(project.company_id), fetchCompanyAssets(project.company_id), + fetchUserCompany().catch(() => null), ]); + // 남의 회사 프로젝트(시스템관리자)에서는 내 회사 로고를 기본으로 내밀지 않는다. + const sameCompany = company != null && company.id === project.company_id; + const companyLogo = + (sameCompany && + assets.find((asset) => asset.kind === "LOGO" && asset.id === company.logo_asset_id)) || + null; const name = createInputField({ label: L("B01_Dashboard_Table_Project"), @@ -174,6 +192,16 @@ export async function openEditProjectModal( project.logo_asset_id, project.company_id, user, + { + // 프로젝트가 안 고르면 도면에는 회사 로고가 실린다 — 저장값은 계속 비워 둬 연결을 유지한다. + fallback: sameCompany + ? { + asset: companyLogo, + prefix: "회사 기본 로고", + missingNote: "(없음) — 회사 로고 미지정 (회사 정보 화면의 「로고 지정…」)", + } + : undefined, + }, ); if (isUserOnly) { name.input.disabled = true; @@ -212,28 +240,36 @@ export async function openEditProjectModal( ); const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null); - openModal(L("B01_Dashboard_EditProject"), [grid], async () => { - await updateProject(project.id, { - name: name.input.value.trim(), - region: region.input.value.trim() || null, - road_type: roadType.input.value.trim() || null, - project_year: year.input.value ? Number(year.input.value) : null, - estimated_length_m: length.input.value ? Number(length.input.value) : null, - memo: memo.input.value.trim() || null, - status: project.status, - client_org: clientOrg.input.value.trim() || null, - project_number: projectNumber.input.value.trim() || null, - work_amount: workAmount.input.value.trim() || null, - design_date: designDate.input.value || null, - pm_user_id: userId(pm.select), - field_lead_user_id: userId(fieldLead.select), - designer_user_id: userId(designer.select), - logo_asset_id: logo.value(), - // 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다. - signature_asset_id: null, - }); - showToast(L("B01_Dashboard_Saved"), "success"); - }); + // 로고는 입력칸이 아니라 고르기 모달로 바뀌므로 변경 판정에 따로 실어 준다. + const editProjectExtra = (): string => String(logo.value() ?? ""); + + openModal( + L("B01_Dashboard_EditProject"), + [grid], + async () => { + await updateProject(project.id, { + name: name.input.value.trim(), + region: region.input.value.trim() || null, + road_type: roadType.input.value.trim() || null, + project_year: year.input.value ? Number(year.input.value) : null, + estimated_length_m: length.input.value ? Number(length.input.value) : null, + memo: memo.input.value.trim() || null, + status: project.status, + client_org: clientOrg.input.value.trim() || null, + project_number: projectNumber.input.value.trim() || null, + work_amount: workAmount.input.value.trim() || null, + design_date: designDate.input.value || null, + pm_user_id: userId(pm.select), + field_lead_user_id: userId(fieldLead.select), + designer_user_id: userId(designer.select), + logo_asset_id: logo.value(), + // 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다. + signature_asset_id: null, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + }, + { extra: editProjectExtra }, + ); } export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void { diff --git a/B01_Dashboard/B01_Dashboard_UI_TempModal.ts b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts index e5a55b41..a4e7a305 100644 --- a/B01_Dashboard/B01_Dashboard_UI_TempModal.ts +++ b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts @@ -12,7 +12,7 @@ import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { table, text } from "@ui/ui_template_general_blocks"; -import { L } from "./B01_Dashboard_UI_Common"; +import { attachModalDismiss, L, type ModalDismissHandle } from "./B01_Dashboard_UI_Common"; /** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */ export function tempFileType(fileName: string): string { @@ -113,11 +113,13 @@ export function openTempFileModal(options: TempModalOptions): void { const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; + // 닫는 길은 하나로 — [취소]·바깥 클릭·Esc 모두 같은 변경 확인을 거친다 (2026-09-04 사용자 지시). + let dismiss: ModalDismissHandle | null = null; actions.append( createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", - onClick: () => modal.remove(), + onClick: () => void dismiss?.tryClose(), }), createButton({ label: L("Common_Btn_Confirm"), @@ -143,4 +145,8 @@ export function openTempFileModal(options: TempModalOptions): void { panel.append(pickRow, listHost, picker, actions); modal.append(panel); document.body.append(modal); + // 고른 파일은 입력칸이 아니라 목록에 쌓이므로 따로 견준다. + dismiss = attachModalDismiss(modal, panel, { + extra: () => chosen.map((file) => `${file.name}:${file.size}`).join(","), + }); } diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts index 99454304..4e89a9ea 100644 --- a/ui_template/ui_template_elements_styles.ts +++ b/ui_template/ui_template_elements_styles.ts @@ -222,7 +222,7 @@ const BASE_CSS = ` align-items: center; justify-content: center; background-color: rgba(38, 17, 74, 0.35); - z-index: var(--z-overlay); + z-index: var(--z-confirm); } .ui-confirm__panel { min-width: 300px; diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 2a6bfbe7..181d418e 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -197,6 +197,8 @@ --z-dropdown: 200; --z-overlay: 900; --z-modal: 1000; + /* 확인창은 모달 위에 떠야 한다 — 모달 닫기 확인이 모달 뒤에 깔리면 못 누른다. */ + --z-confirm: 1050; --z-toast: 1100; /* --------------------------------------------------------------------------- From cc15a83f6756f00cefce09d98dc6f071ab108c26 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:26:17 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B03~B08):=20=EC=A0=9C=EB=AA=A9=20?= =?UTF-8?q?=EC=A4=84=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20+=20=EA=B3=84=ED=9A=8D=EB=85=B8=EC=84=A0=20CSV=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 워크플로 상태 응답에 project_name 추가. 좌측 제목 패널 같은 행 오른쪽 끝에 프로젝트 이름 표기(길면 말줄임, 전체는 툴팁). 이름표를 오버레이 쪽에 두어 레이아웃을 직접 조립하는 B03 까지 여섯 화면이 한 곳으로 반영됨. - fetchProjectWorkflowState 가 {status, workflow_state} 껍데기를 벗기도록 수정. - 계획노선을 「업로드한 CSV」로 적은 주석을 「계획노선(정본)」으로 정리. 업로드 판정이 .csv 개수만 세어 shapefile 을 막던 것도 .shp 포함으로 맞춤. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_workflow_nav.ts | 2 ++ B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 13 +++++--- B03_FileInput/B03_FileInput_Router.py | 8 +++-- B03_FileInput/B03_FileInput_Service_Chain.py | 10 +++---- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 6 ++-- B05_Profile/B05_Profile_Engine_Sections.py | 2 +- common_util/common_util_drainage_context.py | 2 +- common_util/common_util_drainage_pipes.py | 4 +-- common_util/common_util_workflow_state.py | 19 ++++++++++-- ui_template/ui_template_locale_b1.ts | 4 +-- ui_template/ui_template_overlay.css | 18 ++++++++++++ ui_template/ui_template_overlay.ts | 31 ++++++++++++++++++++ 12 files changed, 96 insertions(+), 23 deletions(-) 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;