Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_1

This commit is contained in:
2026-09-04 18:43:58 +09:00
18 changed files with 315 additions and 56 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;
}
+37 -4
View File
@@ -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<void>;
/**
* 비었을 때 대신 보여 줄 기본값 — 프로젝트 로고를 안 고르면 도면에는 회사 로고가
* 실린다(`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);
}
+110 -1
View File
@@ -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<unknown>): Promise<void>
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<void>;
}
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<void> => {
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 };
}
+61 -25
View File
@@ -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>): void {
function openModal(
title: string,
body: HTMLElement[],
onConfirm: () => Promise<void>,
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<void> {
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 {
+8 -2
View File
@@ -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(","),
});
}
+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,
}
+1 -1
View File
@@ -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;
+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;
+2
View File
@@ -197,6 +197,8 @@
--z-dropdown: 200;
--z-overlay: 900;
--z-modal: 1000;
/* 확인창은 모달 위에 떠야 한다 — 모달 닫기 확인이 모달 뒤에 깔리면 못 누른다. */
--z-confirm: 1050;
--z-toast: 1100;
/* ---------------------------------------------------------------------------