Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
/* =============================================================================
|
||||
* group_workflow.ts
|
||||
* D 예정노선 · E 안정성평가 단계 묶음(요구 345 · PLAN 529-1) — B05~B09 틀(`createWorkflowLayout`)을
|
||||
* 단계 목록 · 아이콘 · 주소 인자로 그대로 씀. 빈 단계 = 틀 · 좌측 패널([?] [<] [>]) · 단계 막대만.
|
||||
* 페이지 길 = 각 폴더 `draft/<폴더>_Route.ts` · 본문이 생기면 그 폴더 페이지로 갈아 끼움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { PLAN_STEP_ROUTES, STAB_STEP_ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { navigateTo, type PageRenderer } from "./router";
|
||||
|
||||
interface GroupFlow {
|
||||
routes: readonly RoutePath[];
|
||||
labels: readonly LocaleKey[];
|
||||
icons: readonly string[];
|
||||
}
|
||||
|
||||
const FLOWS: readonly GroupFlow[] = [
|
||||
{
|
||||
routes: PLAN_STEP_ROUTES,
|
||||
labels: ["DE_Step_PlanBase", "DE_Step_PlanAnalysis", "DE_Step_PlanConfirm"],
|
||||
icons: ["📍", "🧭", "✅"],
|
||||
},
|
||||
{
|
||||
routes: STAB_STEP_ROUTES,
|
||||
labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"],
|
||||
icons: ["🪨", "📐", "🧊", "📄"],
|
||||
},
|
||||
];
|
||||
|
||||
/** 단계 틀을 세움 — mainContent · leftPanel 이 없으면 빈 단계 안내 · [?] 줄만 */
|
||||
export function mountGroupStep(
|
||||
root: HTMLElement,
|
||||
route: RoutePath,
|
||||
parts: { mainContent?: HTMLElement; leftPanel?: HTMLElement } = {},
|
||||
): void {
|
||||
const flow = FLOWS.find((f) => f.routes.includes(route));
|
||||
if (!flow) throw new Error(`단계 묶음 밖 주소: ${route}`);
|
||||
const active = flow.routes.indexOf(route);
|
||||
const layout = createWorkflowLayout({
|
||||
title: t(flow.labels[active]),
|
||||
steps: flow.labels.map((key) => t(key)),
|
||||
icons: flow.icons,
|
||||
activeStep: active,
|
||||
routes: flow.routes,
|
||||
leftPanel: parts.leftPanel,
|
||||
mainContent:
|
||||
parts.mainContent ?? el("p", { className: "app-placeholder", text: t("DE_Step_Empty") }),
|
||||
onStepClick: (_index, next) => navigateTo(next as RoutePath),
|
||||
});
|
||||
root.append(layout.root);
|
||||
}
|
||||
|
||||
/** 빈 단계 페이지(길 선언의 load 가 부름) */
|
||||
export const groupStepPage =
|
||||
(route: RoutePath): PageRenderer =>
|
||||
(root) =>
|
||||
mountGroupStep(root, route);
|
||||
@@ -15,6 +15,7 @@ from common_util.common_util_auth import (
|
||||
hash_device_token,
|
||||
hash_password,
|
||||
hash_user_agent,
|
||||
is_forest_service,
|
||||
set_device_token_cookie,
|
||||
set_session_cookie,
|
||||
verify_password,
|
||||
@@ -173,6 +174,7 @@ async def current_session(session: dict = Depends(verify_session)):
|
||||
"role": session["role"],
|
||||
"company_id": session["company_id"],
|
||||
"is_master": session["is_master"],
|
||||
"is_forest_service": is_forest_service(session),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from common_util.common_util_auth import (
|
||||
hash_device_token,
|
||||
hash_password,
|
||||
hash_user_agent,
|
||||
is_forest_email,
|
||||
set_device_token_cookie,
|
||||
verify_password,
|
||||
)
|
||||
@@ -84,17 +85,20 @@ async def verify_registration(payload: RegisterVerifyRequest, request: Request):
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="인증 코드가 유효하지 않습니다.")
|
||||
await consume_otp(otp["id"])
|
||||
await complete_registration(user["id"], bool(user["is_master"]))
|
||||
# 메일 *.go.kr = 회사 「산림청」 고정 — 화면이 보낸 회사 값은 받지 않음(요구 347-8).
|
||||
account_status = await complete_registration(
|
||||
user["id"], bool(user["is_master"]), forest=is_forest_email(user["email"])
|
||||
)
|
||||
# 가입 인증한 브라우저를 신뢰 등록하여 첫 로그인 시 OTP를 생략한다.
|
||||
agent = request.headers.get("user-agent", "unknown")[:1000]
|
||||
device_token = generate_device_token()
|
||||
await trust_device(user["id"], hash_device_token(device_token), hash_user_agent(agent))
|
||||
# 회원가입 시점에는 회사가 없으므로(NO_COMPANY) 마스터 알림은 발송하지 않는다.
|
||||
# 회사 생성/연결은 로그인 후 B01_Dashboard에서 진행한다.
|
||||
# 회사 생성/연결은 로그인 후 B01_Dashboard에서 진행한다(산림청 메일은 위에서 붙음).
|
||||
response = JSONResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"account_status": "NO_COMPANY",
|
||||
"account_status": account_status,
|
||||
}
|
||||
)
|
||||
set_device_token_cookie(response, device_token)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { requestRegistration, verifyRegistration } from "./A07_Register_Api_Fetc
|
||||
import "./A07_Register_UI_Style.css";
|
||||
|
||||
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
|
||||
const FOREST_COMPANY_NAME = "산림청";
|
||||
|
||||
interface AgreementField {
|
||||
root: HTMLElement;
|
||||
@@ -80,6 +81,14 @@ export function renderA07Register(root: HTMLElement): void {
|
||||
type: "text",
|
||||
});
|
||||
const email = createInputField({ label: L("A07_Register_Field_Email"), type: "email" });
|
||||
// 메일 *.go.kr = 회사 「산림청」 고정(요구 347-8) — 서버도 같은 규칙으로 붙임.
|
||||
const company = createInputField({ label: L("A07_Register_Field_Company"), type: "text" });
|
||||
company.input.value = FOREST_COMPANY_NAME;
|
||||
company.input.readOnly = true;
|
||||
company.root.hidden = true;
|
||||
email.input.addEventListener("input", () => {
|
||||
company.root.hidden = !email.input.value.trim().toLowerCase().endsWith(".go.kr");
|
||||
});
|
||||
const phone = createInputField({ label: L("A07_Register_Field_Phone"), type: "tel" });
|
||||
const password = createInputField({ label: L("A07_Register_Field_Password"), type: "password" });
|
||||
const passwordConfirm = createInputField({
|
||||
@@ -132,6 +141,7 @@ export function renderA07Register(root: HTMLElement): void {
|
||||
name.root,
|
||||
position.root,
|
||||
email.root,
|
||||
company.root,
|
||||
phone.root,
|
||||
password.root,
|
||||
passwordConfirm.root,
|
||||
|
||||
@@ -193,7 +193,7 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, company_id, name, region, road_type, project_year,
|
||||
"""SELECT id, company_id, name, region, road_type, project_kind, project_year,
|
||||
estimated_length_m, route_start_m, route_end_m,
|
||||
memo, status, updated_at, created_at,
|
||||
client_org, project_number, work_amount, design_date,
|
||||
@@ -210,7 +210,8 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
|
||||
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_kind,
|
||||
p.project_year,
|
||||
p.estimated_length_m, p.route_start_m, p.route_end_m,
|
||||
p.memo, p.status, p.updated_at, p.created_at,
|
||||
p.client_org, p.project_number, p.work_amount, p.design_date,
|
||||
@@ -229,7 +230,8 @@ async def list_all_projects() -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
|
||||
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_kind,
|
||||
p.project_year,
|
||||
p.estimated_length_m, p.route_start_m, p.route_end_m,
|
||||
p.memo, p.status, p.updated_at, p.created_at,
|
||||
p.client_org, p.project_number, p.work_amount, p.design_date,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_design_speed
|
||||
from B05_Profile.B05_Profile_PageSettings import grade_class_of
|
||||
from common_util.common_util_auth import is_forest_service, require_company
|
||||
from common_util.common_util_project_kind import create_kind_project
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
router = APIRouter(prefix="/api/d01", tags=["D01_PlanRegister"])
|
||||
dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D01_PlanRegister"])
|
||||
|
||||
|
||||
class CreatePlanRequest(BaseModel):
|
||||
project_name: str = Field(..., min_length=1, max_length=255)
|
||||
project_year: int = Field(..., ge=2000, le=2100)
|
||||
project_year_end: int | None = Field(default=None, ge=2000, le=2100)
|
||||
region: str = Field(..., min_length=1, max_length=100)
|
||||
road_type: str = Field(..., pattern="^(main|fire|work)$")
|
||||
design_speed_kph: int | None = None
|
||||
start_chainage_m: float | None = Field(default=None, ge=0)
|
||||
end_chainage_m: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
@router.post("/project")
|
||||
async def post_plan_project(
|
||||
request: Request,
|
||||
payload: CreatePlanRequest,
|
||||
session: dict[str, Any] = Depends(require_company),
|
||||
) -> dict[str, str]:
|
||||
"""예정노선 프로젝트 생성 — 산림청(시스템관리자 · *.go.kr)만."""
|
||||
if not is_forest_service(session):
|
||||
raise HTTPException(status_code=403, detail="예정노선은 산림청만 만들 수 있습니다.")
|
||||
if session.get("company_id") is None:
|
||||
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
|
||||
year_end = payload.project_year_end or payload.project_year
|
||||
if year_end < payload.project_year:
|
||||
raise HTTPException(status_code=400, detail="사업연도 끝이 시작보다 앞입니다.")
|
||||
start_m, end_m = payload.start_chainage_m, payload.end_chainage_m
|
||||
if start_m is not None and end_m is not None and start_m >= end_m:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다."
|
||||
)
|
||||
speed = resolve_design_speed(grade_class_of(payload.road_type, {}), payload.design_speed_kph)
|
||||
project_id = await create_kind_project(
|
||||
request=request,
|
||||
user_id=int(session["user_id"]),
|
||||
company_id=int(session["company_id"]),
|
||||
kind="PLAN",
|
||||
name=payload.project_name.strip(),
|
||||
region=payload.region.strip(),
|
||||
road_type=payload.road_type,
|
||||
project_year=payload.project_year,
|
||||
project_year_end=year_end,
|
||||
design_speed_kph=speed,
|
||||
route_start_m=start_m,
|
||||
route_end_m=end_m,
|
||||
)
|
||||
return {"project_id": project_id}
|
||||
|
||||
|
||||
@dashboard_router.get("/plan-routes/assigned")
|
||||
async def assigned_plan_routes(
|
||||
session: dict[str, Any] = Depends(require_company),
|
||||
) -> dict[str, Any]:
|
||||
"""우리 회사에 지정된 예정노선 연도 — 보기 · 연결 상태."""
|
||||
async with (
|
||||
get_db_pool().acquire() as connection,
|
||||
connection.cursor(aiomysql.DictCursor) as cursor,
|
||||
):
|
||||
await cursor.execute(
|
||||
"""SELECT a.project_id, p.name AS project_name, a.year, a.linked_project_id
|
||||
FROM plan_route_assignees a
|
||||
JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL
|
||||
WHERE a.company_id = %s
|
||||
ORDER BY p.name, a.year""",
|
||||
(session.get("company_id"),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
# ponytail: update_available 자리만 — 지문 비교는 531-3.
|
||||
routes = [{**r, "update_available": False} for r in rows]
|
||||
return {"status": "success", "routes": routes}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* =============================================================================
|
||||
* D02_PlanBase_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.D02_PLAN_BASE,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () =>
|
||||
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D02_PLAN_BASE),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.D03_PLAN_ANALYSIS,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () =>
|
||||
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D03_PLAN_ANALYSIS),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/* =============================================================================
|
||||
* D04_PlanConfirm_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.D04_PLAN_CONFIRM,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () =>
|
||||
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D04_PLAN_CONFIRM),
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"""E01 안정성평가 생성 API (요구 345 · 347 · 요구서 4-1).
|
||||
|
||||
대상 = 같은 회사 임도(ROAD) 중 B06 횡단 단계(stage 3) 완료. 자료 복사는 528-4 — 지금은 행만.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common_util.common_util_auth import require_company
|
||||
from common_util.common_util_project_kind import create_kind_project
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"])
|
||||
|
||||
_SOURCE_SQL = """
|
||||
SELECT p.id AS project_id, p.name, p.region, p.road_type, p.project_year,
|
||||
DATE_FORMAT(p.updated_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS updated_at,
|
||||
s.state = 'COMPLETE' AS section_complete
|
||||
FROM projects p
|
||||
LEFT JOIN project_workflow_stages s ON s.project_id = p.id AND s.stage_no = 3
|
||||
WHERE p.company_id = %s AND p.project_kind = 'ROAD' AND p.deleted_at IS NULL
|
||||
"""
|
||||
|
||||
|
||||
class CreateStabRequest(BaseModel):
|
||||
project_name: str | None = Field(default=None, max_length=255)
|
||||
source_project_id: str = Field(..., min_length=36, max_length=36)
|
||||
|
||||
|
||||
async def _sources(company_id: Any, project_id: str | None = None) -> list[dict[str, Any]]:
|
||||
sql, args = _SOURCE_SQL, [company_id]
|
||||
if project_id:
|
||||
sql, args = sql + " AND p.id = %s", [company_id, project_id]
|
||||
async with (
|
||||
get_db_pool().acquire() as connection,
|
||||
connection.cursor(aiomysql.DictCursor) as cursor,
|
||||
):
|
||||
await cursor.execute(sql + " ORDER BY p.updated_at DESC", tuple(args))
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
@router.get("/source-candidates")
|
||||
async def source_candidates(
|
||||
session: dict[str, Any] = Depends(require_company),
|
||||
) -> dict[str, Any]:
|
||||
"""B06 까지 끝난 같은 회사 임도 프로젝트."""
|
||||
rows = await _sources(session.get("company_id"))
|
||||
projects = [
|
||||
{k: v for k, v in r.items() if k != "section_complete"}
|
||||
for r in rows
|
||||
if r["section_complete"]
|
||||
]
|
||||
return {"status": "success", "projects": projects}
|
||||
|
||||
|
||||
@router.post("/project")
|
||||
async def post_stab_project(
|
||||
request: Request,
|
||||
payload: CreateStabRequest,
|
||||
session: dict[str, Any] = Depends(require_company),
|
||||
) -> dict[str, str]:
|
||||
"""안정성평가 프로젝트 생성 — 원본 B06 미완료면 400."""
|
||||
company_id = session.get("company_id")
|
||||
rows = await _sources(company_id, payload.source_project_id) if company_id else []
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="같은 회사 임도 프로젝트가 아닙니다.")
|
||||
source = rows[0]
|
||||
if not source["section_complete"]:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="원본 임도의 횡단 설계(B06)가 끝나지 않았습니다."
|
||||
)
|
||||
name = (payload.project_name or "").strip() or f"{source['name']} 안정성평가"
|
||||
project_id = await create_kind_project(
|
||||
request=request,
|
||||
user_id=int(session["user_id"]),
|
||||
company_id=int(company_id),
|
||||
kind="STABILITY",
|
||||
name=name,
|
||||
region=source["region"],
|
||||
road_type=source["road_type"],
|
||||
project_year=source["project_year"],
|
||||
source_project_id=source["project_id"],
|
||||
)
|
||||
return {"project_id": project_id}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* =============================================================================
|
||||
* E02_StabGround_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.E02_STAB_GROUND,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () =>
|
||||
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E02_STAB_GROUND),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/* =============================================================================
|
||||
* E03_StabSection_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.E03_STAB_SECTION,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () =>
|
||||
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E03_STAB_SECTION),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/* =============================================================================
|
||||
* E04_Stab3D_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.E04_STAB_3D,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () =>
|
||||
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E04_STAB_3D),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/* =============================================================================
|
||||
* E05_StabReport_Route.ts
|
||||
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: ROUTES.E05_STAB_REPORT,
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () =>
|
||||
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.E05_STAB_REPORT),
|
||||
};
|
||||
@@ -148,6 +148,16 @@ async def get_optional_session(request: Request) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_forest_email(email: str | None) -> bool:
|
||||
"""메일이 *.go.kr(대소문자 무시) — 가입 때 회사 「산림청」 고정."""
|
||||
return bool(email) and email.strip().lower().endswith(".go.kr")
|
||||
|
||||
|
||||
def is_forest_service(session: dict[str, Any]) -> bool:
|
||||
"""산림청 = 시스템관리자 또는 메일 *.go.kr — 예정노선 생성 · 고침 권한."""
|
||||
return session.get("role") == "SYSTEM_ADMIN" or is_forest_email(session.get("email"))
|
||||
|
||||
|
||||
async def require_master(session: dict[str, Any] = Depends(verify_session)) -> dict[str, Any]:
|
||||
if not session["is_master"] and session["role"] != "SYSTEM_ADMIN":
|
||||
raise HTTPException(status_code=403, detail="마스터 권한이 필요합니다.")
|
||||
@@ -196,6 +206,19 @@ async def require_project_access(
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row and row[0] != session["company_id"]:
|
||||
# 예정노선을 지정받은 회사 = 보기만(요구 345 · GET 만 통과).
|
||||
await cursor.execute(
|
||||
"SELECT 1 FROM plan_route_assignees"
|
||||
" WHERE project_id = %s AND company_id = %s LIMIT 1",
|
||||
(str(project_id), session["company_id"]),
|
||||
)
|
||||
shared = await cursor.fetchone()
|
||||
if shared:
|
||||
if request.method in ("GET", "HEAD"):
|
||||
return session
|
||||
raise HTTPException(status_code=403, detail="보기만 허용된 예정노선입니다.")
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if row[0] != session["company_id"]:
|
||||
|
||||
@@ -148,15 +148,32 @@ async def consume_otp(otp_id: int) -> None:
|
||||
await connection.commit()
|
||||
|
||||
|
||||
async def complete_registration(user_id: int, is_master: bool) -> None:
|
||||
async def complete_registration(user_id: int, is_master: bool, forest: bool = False) -> str:
|
||||
"""메일 인증 끝 — 계정 상태를 돌려줌. forest(*.go.kr) = 회사 「산림청」 고정 · ACTIVE."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(user_id,),
|
||||
)
|
||||
company = None
|
||||
if forest:
|
||||
await cursor.execute(
|
||||
"SELECT id FROM companies WHERE name = '산림청' AND deleted_at IS NULL"
|
||||
)
|
||||
company = await cursor.fetchone()
|
||||
if company:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET status = 'ACTIVE', company_id = %s, role = 'USER',
|
||||
last_email_verified_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(company[0], user_id),
|
||||
)
|
||||
else:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET status = 'NO_COMPANY',
|
||||
last_email_verified_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(user_id,),
|
||||
)
|
||||
await connection.commit()
|
||||
return "ACTIVE" if company else "NO_COMPANY"
|
||||
|
||||
|
||||
async def company_master_email(company_id: int) -> str | None:
|
||||
|
||||
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException
|
||||
router = APIRouter(prefix="/api/help", tags=["help"])
|
||||
|
||||
HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help"
|
||||
PAGE_RE = re.compile(r"^B0[2-9]$")
|
||||
PAGE_RE = re.compile(r"^(B0[2-9]|D0[1-4]|E0[1-5])$")
|
||||
|
||||
|
||||
def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""종류별 프로젝트 행 만들기 — D 예정노선 · E 안정성평가(요구 345).
|
||||
|
||||
임도설계(ROAD)는 B02 그대로. 저장소 경로 · 초기 폴더는 B02 함수를 그대로 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import (
|
||||
_build_project_storage,
|
||||
_initialize_project_storage,
|
||||
)
|
||||
from common_util.common_util_audit import record_audit
|
||||
from common_util.common_util_storage import get_project_stage_path
|
||||
from common_util.common_util_workflow_state import initialize_project_stages
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
KIND_STAGE_DIRS = {
|
||||
"PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"),
|
||||
"STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"),
|
||||
}
|
||||
|
||||
|
||||
async def create_kind_project(
|
||||
*,
|
||||
request: Any | None,
|
||||
user_id: int,
|
||||
company_id: int,
|
||||
kind: str,
|
||||
name: str,
|
||||
region: str | None,
|
||||
road_type: str | None,
|
||||
project_year: int | None,
|
||||
project_year_end: int | None = None,
|
||||
design_speed_kph: int | None = None,
|
||||
route_start_m: float | None = None,
|
||||
route_end_m: float | None = None,
|
||||
source_project_id: str | None = None,
|
||||
) -> str:
|
||||
"""projects 행 + 참여자 + 종류별 단계 + 저장소 폴더 → 새 project_id."""
|
||||
project_id = str(uuid4())
|
||||
storage_path, project_root = _build_project_storage(company_id, user_id, project_id)
|
||||
now = datetime.utcnow()
|
||||
|
||||
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
|
||||
try:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO projects (
|
||||
id, user_id, company_id, name, region, road_type, project_kind,
|
||||
project_year, project_year_end, design_speed_kph,
|
||||
route_start_m, route_end_m, status, crs_epsg, storage_path,
|
||||
source_project_id, source_copied_at, created_at, updated_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s,
|
||||
%s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
user_id,
|
||||
company_id,
|
||||
name,
|
||||
region,
|
||||
road_type,
|
||||
kind,
|
||||
project_year,
|
||||
project_year_end,
|
||||
design_speed_kph,
|
||||
route_start_m,
|
||||
route_end_m,
|
||||
storage_path,
|
||||
source_project_id,
|
||||
now if source_project_id else None,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await cursor.execute(
|
||||
"INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)",
|
||||
(project_id, user_id),
|
||||
)
|
||||
await initialize_project_stages(cursor, project_id, kind)
|
||||
await record_audit(
|
||||
cursor,
|
||||
actor_id=user_id,
|
||||
action="PROJECT_CREATE",
|
||||
resource_type="project",
|
||||
resource_ref=project_id,
|
||||
request=request,
|
||||
)
|
||||
_initialize_project_storage(project_root, project_id)
|
||||
for stage_dir in KIND_STAGE_DIRS[kind]:
|
||||
get_project_stage_path(str(project_root), stage_dir)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return project_id
|
||||
@@ -20,9 +20,19 @@ STAGE_KEYS = [
|
||||
]
|
||||
|
||||
|
||||
async def initialize_project_stages(cursor: aiomysql.DictCursor, project_id: str) -> None:
|
||||
"""프로젝트 생성 시 7개의 단계를 NOT_STARTED 상태로 시드한다."""
|
||||
for stage_no, stage_key in enumerate(STAGE_KEYS):
|
||||
# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05.
|
||||
STAGE_KEYS_BY_KIND = {
|
||||
"ROAD": STAGE_KEYS,
|
||||
"PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"],
|
||||
"STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT"],
|
||||
}
|
||||
|
||||
|
||||
async def initialize_project_stages(
|
||||
cursor: aiomysql.DictCursor, project_id: str, kind: str = "ROAD"
|
||||
) -> None:
|
||||
"""프로젝트 생성 시 종류별 단계를 NOT_STARTED 상태로 시드한다."""
|
||||
for stage_no, stage_key in enumerate(STAGE_KEYS_BY_KIND[kind]):
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO project_workflow_stages (
|
||||
|
||||
@@ -120,8 +120,33 @@ export const ROUTES = {
|
||||
M01_MASTER_DATA: "m01-master-data",
|
||||
// 마스터 템플릿 — 표 양식 · 도면 양식(시스템 · 회사 · 개인 · 프로젝트 층). 화면은 로그인만 확인.
|
||||
M02_MASTER_TEMPLATE: "m02-master-template",
|
||||
// D 예정노선 · E 안정성평가(요구 345) — 길 선언은 페이지 폴더 `draft/*_Route.ts`
|
||||
D01_PLAN_REGISTER: "d01-plan-register",
|
||||
D02_PLAN_BASE: "d02-plan-base",
|
||||
D03_PLAN_ANALYSIS: "d03-plan-analysis",
|
||||
D04_PLAN_CONFIRM: "d04-plan-confirm",
|
||||
E01_STAB_REGISTER: "e01-stab-register",
|
||||
E02_STAB_GROUND: "e02-stab-ground",
|
||||
E03_STAB_SECTION: "e03-stab-section",
|
||||
E04_STAB_3D: "e04-stab-3d",
|
||||
E05_STAB_REPORT: "e05-stab-report",
|
||||
} as const;
|
||||
|
||||
/** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */
|
||||
export const PLAN_STEP_ROUTES = [
|
||||
ROUTES.D02_PLAN_BASE,
|
||||
ROUTES.D03_PLAN_ANALYSIS,
|
||||
ROUTES.D04_PLAN_CONFIRM,
|
||||
] as const;
|
||||
|
||||
/** E 안정성평가 단계 주소(E02 → E05 · 생성 E01 은 단계 밖) */
|
||||
export const STAB_STEP_ROUTES = [
|
||||
ROUTES.E02_STAB_GROUND,
|
||||
ROUTES.E03_STAB_SECTION,
|
||||
ROUTES.E04_STAB_3D,
|
||||
ROUTES.E05_STAB_REPORT,
|
||||
] as const;
|
||||
|
||||
export type RouteKey = keyof typeof ROUTES;
|
||||
export type RoutePath = (typeof ROUTES)[RouteKey];
|
||||
|
||||
|
||||
@@ -211,6 +211,15 @@ PROJECT_STORAGE_STAGE_DIRS = frozenset(
|
||||
"B07_DesignDetail",
|
||||
"B08_Quantity",
|
||||
"B09_Estimation",
|
||||
"D01_PlanRegister",
|
||||
"D02_PlanBase",
|
||||
"D03_PlanAnalysis",
|
||||
"D04_PlanConfirm",
|
||||
"E01_StabRegister",
|
||||
"E02_StabGround",
|
||||
"E03_StabSection",
|
||||
"E04_Stab3D",
|
||||
"E05_StabReport",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- 020_project_groups.sql
|
||||
-- 신규 프로젝트 그룹 — 종류 · 예정노선 연도 구간 · 지정 업체 · 산림청 회사 (요구 345 · 347-8)
|
||||
--
|
||||
-- 추가만 함 — 기존 행 = ROAD(임도설계) · 기존 칸 · 동작 그대로.
|
||||
-- 예정노선 시작 · 종료 누가거리는 기존 route_start_m · route_end_m 을 씀.
|
||||
|
||||
USE aislo_db;
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS project_kind VARCHAR(20) NOT NULL DEFAULT 'ROAD'
|
||||
COMMENT 'ROAD 임도설계 · PLAN 예정노선 · EROSION 사방설계 · STABILITY 안정성평가' AFTER road_type,
|
||||
ADD COLUMN IF NOT EXISTS project_year_end INT NULL
|
||||
COMMENT '사업연도 범위 끝(예정노선) — 시작은 project_year' AFTER project_year,
|
||||
ADD COLUMN IF NOT EXISTS design_speed_kph INT NULL
|
||||
COMMENT '설계속도(km/h · 예정노선 등록값)' AFTER project_year_end,
|
||||
ADD COLUMN IF NOT EXISTS source_project_id CHAR(36) NULL
|
||||
COMMENT '원본 프로젝트(E = 임도 · 임도 = 예정노선)',
|
||||
ADD COLUMN IF NOT EXISTS source_copied_at DATETIME NULL COMMENT '원본 복사 시각',
|
||||
ADD COLUMN IF NOT EXISTS source_fingerprint CHAR(40) NULL COMMENT '복사 때 원본 지문(sha1)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plan_route_years (
|
||||
project_id CHAR(36) NOT NULL COMMENT 'projects.id (PLAN)',
|
||||
year INT NOT NULL,
|
||||
start_x DOUBLE NULL,
|
||||
start_y DOUBLE NULL,
|
||||
end_x DOUBLE NULL,
|
||||
end_y DOUBLE NULL,
|
||||
epsg INT NULL,
|
||||
route_json JSON NULL COMMENT '분석 결과 노선',
|
||||
fingerprint CHAR(40) NULL COMMENT '노선 내용 지문(sha1)',
|
||||
confirmed_at DATETIME NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (project_id, year),
|
||||
CONSTRAINT fk_plan_route_years_project FOREIGN KEY (project_id)
|
||||
REFERENCES projects (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예정노선 연도 구간';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plan_route_assignees (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id CHAR(36) NOT NULL COMMENT 'projects.id (PLAN)',
|
||||
year INT NOT NULL,
|
||||
company_id INT NULL COMMENT '등록 회사 — NULL 이면 수기 업체',
|
||||
company_name VARCHAR(255) NULL,
|
||||
email VARCHAR(255) NULL,
|
||||
notified_at DATETIME NULL,
|
||||
linked_project_id CHAR(36) NULL COMMENT '업체가 연결한 임도 프로젝트',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_plan_route_assignees_project (project_id, year),
|
||||
KEY idx_plan_route_assignees_company (company_id),
|
||||
CONSTRAINT fk_plan_route_assignees_project FOREIGN KEY (project_id)
|
||||
REFERENCES projects (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_plan_route_assignees_company FOREIGN KEY (company_id)
|
||||
REFERENCES companies (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_plan_route_assignees_linked FOREIGN KEY (linked_project_id)
|
||||
REFERENCES projects (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예정노선 연도별 지정 업체';
|
||||
|
||||
-- 산림청 회사 — 메일 *.go.kr 가입자가 자동으로 붙음.
|
||||
INSERT IGNORE INTO companies (name, company_code, status) VALUES ('산림청', 'FOREST', 'ACTIVE');
|
||||
@@ -72,6 +72,9 @@ from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_route
|
||||
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
|
||||
from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_router
|
||||
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
||||
from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_dashboard_router
|
||||
from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router
|
||||
from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router
|
||||
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
|
||||
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
|
||||
@@ -450,6 +453,10 @@ app.include_router(a09_security_router)
|
||||
app.include_router(b01_dashboard_router)
|
||||
app.include_router(b02_proj_register_router)
|
||||
protected = [Depends(verify_session)]
|
||||
# 신규 그룹 등록(요구 345) — 권한은 길 안에서(산림청 · 회사).
|
||||
app.include_router(d01_plan_register_router, dependencies=protected)
|
||||
app.include_router(d01_dashboard_router, dependencies=protected)
|
||||
app.include_router(e01_stab_register_router, dependencies=protected)
|
||||
# 프로젝트 경로가 붙는 라우터는 그 프로젝트가 내 회사 것인지도 함께 본다
|
||||
# (2026-09-01 — 지금까지 id만 알면 남의 회사 자료에 닿았다).
|
||||
protected_with_company = [
|
||||
|
||||
@@ -115,7 +115,12 @@ const toasts = [];
|
||||
let role = "SYSTEM_ADMIN";
|
||||
let help;
|
||||
const STUBS = {
|
||||
"@config/config_frontend": { ROUTES, API_BASE_URL: "" },
|
||||
"@config/config_frontend": {
|
||||
ROUTES,
|
||||
API_BASE_URL: "",
|
||||
PLAN_STEP_ROUTES: ["d02-plan-base", "d03-plan-analysis", "d04-plan-confirm"],
|
||||
STAB_STEP_ROUTES: ["e02-stab-ground", "e03-stab-section", "e04-stab-3d", "e05-stab-report"],
|
||||
},
|
||||
"./ui_template_locale": { t: (key) => key },
|
||||
"./ui_template_modal": { openModal: () => undefined },
|
||||
"./ui_template_elements": {
|
||||
@@ -190,6 +195,13 @@ const ALL = [
|
||||
"b09-print",
|
||||
"m01-master-data",
|
||||
"m02-master-template",
|
||||
// 529-1 D · E 묶음 — 묶음 안에서만 [<] [>] · 등록(D01 · E01) = [?] 만
|
||||
"d01-plan-register",
|
||||
"d02-plan-base",
|
||||
"d04-plan-confirm",
|
||||
"e01-stab-register",
|
||||
"e02-stab-ground",
|
||||
"e05-stab-report",
|
||||
];
|
||||
const rows = {};
|
||||
for (const route of ALL) rows[route] = { helpRow: rowOf(route, true), plain: rowOf(route, false) };
|
||||
|
||||
@@ -32,6 +32,19 @@ def results():
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_529_1_D_E_묶음_안에서만_앞뒤(results):
|
||||
rows = results["rows"]
|
||||
for route in ("d01-plan-register", "e01-stab-register"):
|
||||
assert rows[route]["plain"]["labels"] == ["?", "저장"], route
|
||||
for route in ("d02-plan-base", "d04-plan-confirm", "e02-stab-ground", "e05-stab-report"):
|
||||
assert rows[route]["plain"]["labels"] == ["?", "<", ">", "저장"], route
|
||||
# 묶음 첫 단계 [<] · 끝 단계 [>] 흐림(B 묶음과 따로)
|
||||
assert rows["d02-plan-base"]["plain"]["disabled"][:3] == [False, True, False]
|
||||
assert rows["d04-plan-confirm"]["plain"]["disabled"][:3] == [False, False, True]
|
||||
assert rows["e02-stab-ground"]["plain"]["disabled"][:3] == [False, True, False]
|
||||
assert rows["e05-stab-report"]["plain"]["disabled"][:3] == [False, False, True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", ["helpRow", "plain"])
|
||||
def test_다섯_주소만_물음_앞뒤_차례(results, shape):
|
||||
for route in FIVE:
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""528-1 · 3 · 5 신규 프로젝트 그룹 공통(요구 345 · 347-8).
|
||||
|
||||
- 산림청 판정 — *.go.kr(대소문자 · 하위 도메인) · 시스템관리자 · 아닌 메일
|
||||
- D01 생성 — 일반 사용자 403 · 산림청 = PLAN · 설계속도 기본 · 연도 끝 기본
|
||||
- E01 생성 — 원본 B06 미완료 400 · 남의 것 404 · 완료 = STABILITY + 원본 칸
|
||||
- 공유 판정 — 지정 회사 GET 200 · PUT 403 · 미지정 403 · 같은 회사 그대로
|
||||
- 가입 — go.kr = 회사 「산림청」 · ACTIVE · 화면이 보낸 회사 값 무시 · 다른 메일 = NO_COMPANY
|
||||
- 종류별 단계 시드 · storage 허용 목록
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from A07_Register import A07_Register_Router as reg_router
|
||||
from A07_Register.A07_Register_Schema import RegisterRequest
|
||||
from common_util import common_util_auth as auth
|
||||
from common_util import common_util_auth_repository as auth_repo
|
||||
from common_util.common_util_workflow_state import initialize_project_stages
|
||||
from config.config_system import PROJECT_STORAGE_STAGE_DIRS
|
||||
from D01_PlanRegister import D01_PlanRegister_Router as d01
|
||||
from E01_StabRegister import E01_StabRegister_Router as e01
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
"""SQL 조각 → 돌려줄 한 줄. 실행한 SQL · 인자를 쌓음."""
|
||||
|
||||
def __init__(self, answers, log):
|
||||
self.answers, self.log, self.last = answers, log, None
|
||||
|
||||
async def execute(self, sql, args=None):
|
||||
self.log.append((" ".join(sql.split()), args))
|
||||
self.last = next((v for k, v in self.answers.items() if k in sql), None)
|
||||
|
||||
async def fetchone(self):
|
||||
return self.last
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self, answers=None):
|
||||
self.answers, self.log = answers or {}, []
|
||||
|
||||
def acquire(self):
|
||||
return self
|
||||
|
||||
def cursor(self, *a):
|
||||
return FakeCursor(self.answers, self.log)
|
||||
|
||||
async def commit(self):
|
||||
self.log.append(("commit", None))
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
class Req:
|
||||
def __init__(self, method="GET", project_id="p1"):
|
||||
self.method, self.path_params = method, {"project_id": project_id}
|
||||
|
||||
|
||||
USER = {"user_id": 9, "role": "USER", "company_id": 1, "email": "a@gmail.com"}
|
||||
FOREST = {"user_id": 8, "role": "USER", "company_id": 4, "email": "Kim@Forest.GO.KR"}
|
||||
ADMIN = {"user_id": 1, "role": "SYSTEM_ADMIN", "company_id": 1, "email": "x@aislo.kr"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email,expected",
|
||||
[
|
||||
("a@forest.go.kr", True),
|
||||
("A@KFS.GO.KR", True),
|
||||
("a@nifos.sub.forest.go.kr", True),
|
||||
("a@gmail.com", False),
|
||||
("a@notgo.kr", False),
|
||||
("a@go.kr.evil.com", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_forest_email(email, expected):
|
||||
assert auth.is_forest_email(email) is expected
|
||||
|
||||
|
||||
def test_forest_service():
|
||||
assert auth.is_forest_service(FOREST)
|
||||
assert auth.is_forest_service(ADMIN)
|
||||
assert not auth.is_forest_service(USER)
|
||||
|
||||
|
||||
def _plan(**kw):
|
||||
body = {"project_name": "예정", "project_year": 2026, "region": "홍천", "road_type": "fire"}
|
||||
return d01.CreatePlanRequest(**{**body, **kw})
|
||||
|
||||
|
||||
def test_d01_plan_forbidden_for_user():
|
||||
with pytest.raises(HTTPException) as e:
|
||||
asyncio.run(d01.post_plan_project(None, _plan(), USER))
|
||||
assert e.value.status_code == 403
|
||||
|
||||
|
||||
def test_d01_plan_created(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_create(**kw):
|
||||
seen.update(kw)
|
||||
return "new-id"
|
||||
|
||||
monkeypatch.setattr(d01, "create_kind_project", fake_create)
|
||||
out = asyncio.run(d01.post_plan_project(None, _plan(start_chainage_m=0), FOREST))
|
||||
assert out == {"project_id": "new-id"}
|
||||
assert seen["kind"] == "PLAN" and seen["company_id"] == 4
|
||||
assert seen["design_speed_kph"] == 20 and seen["project_year_end"] == 2026
|
||||
asyncio.run(d01.post_plan_project(None, _plan(design_speed_kph=40), ADMIN))
|
||||
assert seen["design_speed_kph"] == 40
|
||||
asyncio.run(d01.post_plan_project(None, _plan(road_type="work", design_speed_kph=40), ADMIN))
|
||||
assert seen["design_speed_kph"] == 20 # 작업임도 = 20 만
|
||||
with pytest.raises(HTTPException) as e:
|
||||
asyncio.run(d01.post_plan_project(None, _plan(project_year_end=2025), FOREST))
|
||||
assert e.value.status_code == 400
|
||||
|
||||
|
||||
def _e01(monkeypatch, rows):
|
||||
seen = {}
|
||||
|
||||
async def fake_sources(company_id, project_id=None):
|
||||
return rows
|
||||
|
||||
async def fake_create(**kw):
|
||||
seen.update(kw)
|
||||
return "stab-id"
|
||||
|
||||
monkeypatch.setattr(e01, "_sources", fake_sources)
|
||||
monkeypatch.setattr(e01, "create_kind_project", fake_create)
|
||||
payload = e01.CreateStabRequest(source_project_id="s" * 36)
|
||||
return seen, lambda: asyncio.run(e01.post_stab_project(None, payload, USER))
|
||||
|
||||
|
||||
def test_e01_requires_b06(monkeypatch):
|
||||
src = {"project_id": "s" * 36, "name": "임도", "region": "r", "road_type": "fire"}
|
||||
_, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 0}])
|
||||
with pytest.raises(HTTPException) as e:
|
||||
call()
|
||||
assert e.value.status_code == 400
|
||||
_, call = _e01(monkeypatch, [])
|
||||
with pytest.raises(HTTPException) as e:
|
||||
call()
|
||||
assert e.value.status_code == 404
|
||||
seen, call = _e01(monkeypatch, [{**src, "project_year": 2026, "section_complete": 1}])
|
||||
assert call() == {"project_id": "stab-id"}
|
||||
assert seen["kind"] == "STABILITY" and seen["source_project_id"] == "s" * 36
|
||||
assert seen["name"] == "임도 안정성평가"
|
||||
|
||||
|
||||
def _access(monkeypatch, method, assigned, owner_company=2):
|
||||
pool = FakePool(
|
||||
{
|
||||
"FROM projects": (owner_company,),
|
||||
"plan_route_assignees": (1,) if assigned else None,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(auth, "get_db_pool", lambda: pool)
|
||||
return asyncio.run(auth.require_project_access(Req(method), USER))
|
||||
|
||||
|
||||
def test_shared_get_only(monkeypatch):
|
||||
assert _access(monkeypatch, "GET", True) is USER
|
||||
for method in ("PUT", "POST", "DELETE"):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
_access(monkeypatch, method, True)
|
||||
assert e.value.status_code == 403
|
||||
with pytest.raises(HTTPException) as e:
|
||||
_access(monkeypatch, "GET", False)
|
||||
assert e.value.status_code == 403
|
||||
assert _access(monkeypatch, "PUT", False, owner_company=1) is USER
|
||||
|
||||
|
||||
def test_register_forest_company(monkeypatch):
|
||||
pool = FakePool({"FROM companies": (4,)})
|
||||
monkeypatch.setattr(auth_repo, "get_db_pool", lambda: pool)
|
||||
assert asyncio.run(auth_repo.complete_registration(5, False, forest=True)) == "ACTIVE"
|
||||
update = [a for s, a in pool.log if s.startswith("UPDATE users")]
|
||||
assert update == [(4, 5)]
|
||||
pool = FakePool({"FROM companies": (4,)})
|
||||
monkeypatch.setattr(auth_repo, "get_db_pool", lambda: pool)
|
||||
assert asyncio.run(auth_repo.complete_registration(5, False)) == "NO_COMPANY"
|
||||
assert not any("companies" in s for s, _ in pool.log)
|
||||
|
||||
|
||||
def test_register_verify_forces_forest(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_user(email):
|
||||
return {"id": 5, "email": email, "status": "PENDING_EMAIL", "is_master": 0}
|
||||
|
||||
async def fake_otp(user_id, kind):
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
return {"id": 1, "expires_at": datetime.utcnow() + timedelta(minutes=5), "otp_hash": "h"}
|
||||
|
||||
async def fake_complete(user_id, is_master, forest=False):
|
||||
seen["forest"] = forest
|
||||
return "ACTIVE" if forest else "NO_COMPANY"
|
||||
|
||||
async def noop(*a, **k):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(reg_router, "get_user_by_email", fake_user)
|
||||
monkeypatch.setattr(reg_router, "get_active_otp", fake_otp)
|
||||
monkeypatch.setattr(reg_router, "verify_password", lambda a, b: True)
|
||||
monkeypatch.setattr(reg_router, "consume_otp", noop)
|
||||
monkeypatch.setattr(reg_router, "trust_device", noop)
|
||||
monkeypatch.setattr(reg_router, "complete_registration", fake_complete)
|
||||
|
||||
class R:
|
||||
headers = {}
|
||||
|
||||
body = reg_router.RegisterVerifyRequest(email="Lee@KFS.go.kr", otp_code="123456")
|
||||
resp = asyncio.run(reg_router.verify_registration(body, R()))
|
||||
assert seen["forest"] is True and b'"ACTIVE"' in resp.body
|
||||
body = reg_router.RegisterVerifyRequest(email="lee@gmail.com", otp_code="123456")
|
||||
resp = asyncio.run(reg_router.verify_registration(body, R()))
|
||||
assert seen["forest"] is False and b'"NO_COMPANY"' in resp.body
|
||||
|
||||
|
||||
def test_register_ignores_sent_company():
|
||||
data = RegisterRequest(
|
||||
email="a@kfs.go.kr",
|
||||
password="12345678",
|
||||
password_confirm="12345678",
|
||||
name="이",
|
||||
terms_version="1.0",
|
||||
terms_agreed=True,
|
||||
privacy_agreed=True,
|
||||
company_name="다른회사",
|
||||
).model_dump()
|
||||
assert "company_name" not in data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,count", [("ROAD", 7), ("PLAN", 3), ("STABILITY", 4)])
|
||||
def test_stage_seed_by_kind(kind, count):
|
||||
log = []
|
||||
asyncio.run(initialize_project_stages(FakeCursor({}, log), "p1", kind))
|
||||
assert len(log) == count
|
||||
if kind == "PLAN":
|
||||
assert [a[2] for _, a in log] == ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"]
|
||||
|
||||
|
||||
def test_storage_dirs_allowed():
|
||||
for name in ("D02_PlanBase", "D04_PlanConfirm", "E02_StabGround", "E05_StabReport"):
|
||||
assert name in PROJECT_STORAGE_STAGE_DIRS
|
||||
@@ -28,11 +28,15 @@ def test_title_order_and_live_edit():
|
||||
assert "고친 글" in read_help("B05", tmp_path)[1]["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05"])
|
||||
@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05", "D05", "E06", "C01"])
|
||||
def test_bad_page_refused(page):
|
||||
with pytest.raises(HTTPException):
|
||||
read_help(page)
|
||||
|
||||
|
||||
def test_529_1_D_E_pages_open_empty(tmp_path):
|
||||
assert read_help("D02", tmp_path) == [] and read_help("E05", tmp_path) == []
|
||||
|
||||
|
||||
def test_root_is_repo_resources():
|
||||
assert HELP_ROOT.parts[-2:] == ("resources", "help")
|
||||
|
||||
+1
-1
@@ -34,6 +34,6 @@
|
||||
"@kninnug/constrainautor": ["config/node_modules/@kninnug/constrainautor"],
|
||||
}
|
||||
},
|
||||
"include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "Z0*", "ui_template", "config/**/*", "common_util"],
|
||||
"include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "D0*", "E0*", "Z0*", "ui_template", "config/**/*", "common_util"],
|
||||
"exclude": ["0_old", "node_modules", "venv", "dist"]
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막
|
||||
* (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서).
|
||||
* 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞.
|
||||
* [<] [>](요구 280-2) = [?] 바로 뒤 · B05~B09 다섯 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 →
|
||||
* [<] [>](요구 280-2) = [?] 바로 뒤 · 단계 묶음(B05~B09 · D02~D04 · E02~E05 · 529-1) 주소만 · 누르면 `ui-step-nav` 를 올려 보냄 →
|
||||
* 진행 단계 틀(`createWorkflowLayout`)이 이웃 단계 단추를 누름(저장 · 막힘은 각 페이지 `onStepClick`).
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_help.css";
|
||||
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
||||
import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend";
|
||||
import { createButton, el } from "./ui_template_elements";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
|
||||
@@ -23,18 +23,23 @@ interface HelpItem {
|
||||
const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" };
|
||||
const STEP_TEXT = { prev: "<", next: ">", prevTip: "이전 단계", nextTip: "다음 단계" };
|
||||
|
||||
/** [<] [>] 를 붙이는 주소 — 목록으로 거름(b09-print · B03 · B04 · M01 · M02 뺌) */
|
||||
const STEP_NAV_ROUTES = [
|
||||
ROUTES.B05_PROFILE,
|
||||
ROUTES.B06_SECTION,
|
||||
ROUTES.B07_DESIGN_DETAIL,
|
||||
ROUTES.B08_QUANTITY,
|
||||
ROUTES.B09_ESTIMATION,
|
||||
] as const;
|
||||
/** [<] [>] 를 붙이는 주소 묶음 — 묶음 안에서만 오감(b09-print · B03 · B04 · M01 · M02 · D01 · E01 뺌) */
|
||||
const STEP_NAV_GROUPS: ReadonlyArray<readonly string[]> = [
|
||||
[
|
||||
ROUTES.B05_PROFILE,
|
||||
ROUTES.B06_SECTION,
|
||||
ROUTES.B07_DESIGN_DETAIL,
|
||||
ROUTES.B08_QUANTITY,
|
||||
ROUTES.B09_ESTIMATION,
|
||||
],
|
||||
PLAN_STEP_ROUTES,
|
||||
STAB_STEP_ROUTES,
|
||||
];
|
||||
|
||||
/** 설명서 폴더 이름 = 주소 머리(#/b05-… → B05 · #/d02-… → D02) */
|
||||
function currentPage(): string {
|
||||
const n = /^#\/b0(\d)/i.exec(location.hash)?.[1];
|
||||
return n ? `B0${n}` : "";
|
||||
const head = /^#\/([bde]0\d)/i.exec(location.hash)?.[1];
|
||||
return head ? head.toUpperCase() : "";
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
@@ -186,24 +191,25 @@ function createStepNavButton(step: -1 | 1): HTMLButtonElement {
|
||||
return button;
|
||||
}
|
||||
|
||||
/** 지금 주소의 [<] [>] 차례 — 목록 밖이면 -1 */
|
||||
function stepNavIndex(): number {
|
||||
/** 지금 주소의 묶음 · [<] [>] 차례 — 목록 밖이면 차례 -1 */
|
||||
function stepNavIndex(): { index: number; last: number } {
|
||||
const route = location.hash.replace(/^#\/?/, "").split("?")[0];
|
||||
return (STEP_NAV_ROUTES as readonly string[]).indexOf(route);
|
||||
const group = STEP_NAV_GROUPS.find((routes) => routes.includes(route)) ?? [];
|
||||
return { index: group.indexOf(route), last: group.length - 1 };
|
||||
}
|
||||
|
||||
/** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */
|
||||
export function placeHelpButton(actions: HTMLElement): void {
|
||||
if (actions.querySelector(".ui-help-btn")) return;
|
||||
const row = actions.querySelector<HTMLElement>("[data-help-row]") ?? actions;
|
||||
if (stepNavIndex() < 0) return void row.prepend(createHelpButton());
|
||||
if (stepNavIndex().index < 0) return void row.prepend(createHelpButton());
|
||||
const prev = createStepNavButton(-1);
|
||||
const next = createStepNavButton(1);
|
||||
// 첫 단계(B05) [<] · 마지막 단계(B09) [>] 흐림 — B05 ↔ B06 은 페이지를 다시 안 세우므로 주소마다 다시 맞춤
|
||||
const sync = (): void => {
|
||||
const index = stepNavIndex();
|
||||
const { index, last } = stepNavIndex();
|
||||
prev.disabled = index <= 0;
|
||||
next.disabled = index === STEP_NAV_ROUTES.length - 1;
|
||||
next.disabled = index === last;
|
||||
};
|
||||
const onHash = (): void => {
|
||||
if (prev.isConnected) sync();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* - ui_template_locale_b1.ts : B 그룹 전반부 (B01~B04)
|
||||
* - ui_template_locale_b2.ts : B 그룹 후반부 (B05~B07)
|
||||
* - ui_template_locale_b5.ts : B 그룹 끝 (B08~B11)
|
||||
* - ui_template_locale_de.ts : D 예정노선 · E 안정성평가 · 대시보드 [+] 고르기
|
||||
*
|
||||
* 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다.
|
||||
* ========================================================================== */
|
||||
@@ -28,6 +29,7 @@ import { ui_locales_b3 } from "./ui_template_locale_b3";
|
||||
import { ui_locales_b5 } from "./ui_template_locale_b5";
|
||||
import { ui_locales_m1 } from "./ui_template_locale_m1";
|
||||
import { ui_locales_m2 } from "./ui_template_locale_m2";
|
||||
import { ui_locales_de } from "./ui_template_locale_de";
|
||||
|
||||
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
|
||||
export const LANGUAGES = ["ko", "en"] as const;
|
||||
@@ -65,6 +67,7 @@ export const ui_locales = {
|
||||
...ui_locales_b5,
|
||||
...ui_locales_m1,
|
||||
...ui_locales_m2,
|
||||
...ui_locales_de,
|
||||
} as const;
|
||||
|
||||
export type LocaleKey = keyof typeof ui_locales;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_de.ts
|
||||
* 다국어 사전 — D 예정노선 · E 안정성평가 그룹(요구 345) · 대시보드 [+] 고르기
|
||||
*
|
||||
* 신규 문구는 해당 섹션 최하단에 추가.
|
||||
* 이 파일은 사전 데이터만 담는다. 합성·언어 전환·t() 헬퍼는 ui_template_locale.ts 참조.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LocaleEntry } from "./ui_template_locale_common";
|
||||
|
||||
export const ui_locales_de = {
|
||||
/* --- 단계 막대 · 빈 단계 --- */
|
||||
DE_Step_PlanBase: ["기준 지정", "Base Points"],
|
||||
DE_Step_PlanAnalysis: ["노선 분석", "Route Analysis"],
|
||||
DE_Step_PlanConfirm: ["확정 · 내보내기", "Confirm · Export"],
|
||||
DE_Step_StabGround: ["지반 조건", "Ground"],
|
||||
DE_Step_StabSection: ["2D 해석", "2D Analysis"],
|
||||
DE_Step_Stab3D: ["3D 해석", "3D Analysis"],
|
||||
DE_Step_StabReport: ["보고서", "Report"],
|
||||
DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."],
|
||||
} as const satisfies Record<string, LocaleEntry>;
|
||||
@@ -33,8 +33,8 @@ export const LOUPE_HOLD_MS = 400;
|
||||
export const LOUPE_REFRESH_MS = 500;
|
||||
/** 화면 끝과 원 사이 여백(px) — 테두리가 안 잘리게 */
|
||||
export const LOUPE_EDGE = 4;
|
||||
/** 붙일 주소 — B01~B09 · M01 · M02(A 영역 · 로그인 · B10 · B11 은 뺌) */
|
||||
export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/;
|
||||
/** 붙일 주소 — B01~B09 · M01 · M02 · D01~D04 · E01~E05(A 영역 · 로그인 · B10 · B11 은 뺌) */
|
||||
export const LOUPE_ROUTE = /^(b0[1-9]|m0[12]|d0[1-4]|e0[1-5])-/;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** CAD 영역 — cad_host 가 끼운 상자와 iframe */
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* 드롭다운 초점 빼기 — 마우스로 고른 뒤 초점이 남으면 휠 · ↑↓ 가 값을 또 바꿈(요구 280-6).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 막을 주소 — B · M 영역(`b01-…` · `m02-…`) · A 영역은 그대로 */
|
||||
export const NATIVE_MENU_ROUTE = /^[bm]\d/;
|
||||
/** 막을 주소 — B · M · D · E 영역(`b01-…` · `m02-…` · `d02-…`) · A 영역은 그대로 */
|
||||
export const NATIVE_MENU_ROUTE = /^[bmde]\d/;
|
||||
|
||||
/** `win` 의 기본 메뉴를 막음 — `isOn()` 이 거짓이면(예: A 영역 주소) 그대로 둠 */
|
||||
export function blockNativeMenu(win: Window, isOn: () => boolean): void {
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface WorkflowLayoutOptions {
|
||||
/** 막힌(NOT_STARTED) 단계라도 켤 칸 — 수 하나 또는 목록(B05 · B06 = 2 · 3 · 4 · 496-1) */
|
||||
currentStage?: number | readonly number[];
|
||||
routes?: readonly string[];
|
||||
/** 단계 아이콘 — 없으면 B 일곱 단계 아이콘(D · E 그룹은 제 목록 · 529-1) */
|
||||
icons?: readonly string[];
|
||||
onStepClick?: (stepIndex: number, route: string) => void;
|
||||
/** 좌측 패널이 열리고 닫힐 때(처음 앉힘 포함) — 닫힌 동안 바뀐 안쪽을 열 때 맞춤(499-1). */
|
||||
onOptionsOpenChange?: (isOpen: boolean) => void;
|
||||
@@ -212,7 +214,7 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
|
||||
currentStage: options.currentStage,
|
||||
routes: options.routes,
|
||||
orientation: "vertical",
|
||||
icons: WORKFLOW_STEP_ICONS,
|
||||
icons: options.icons ?? WORKFLOW_STEP_ICONS,
|
||||
onStepClick: options.onStepClick,
|
||||
homeButton: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user