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;
/* ---------------------------------------------------------------------------