Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4

# Conflicts:
#	B01_Dashboard/B01_Dashboard_UI_Views.ts
#	resources/tester/test_546_b01_shell.py
This commit is contained in:
2026-10-26 18:27:08 +09:00
20 changed files with 2452 additions and 85 deletions
+74
View File
@@ -0,0 +1,74 @@
/* =============================================================================
* B01_Dashboard_Api_Home.ts
* 대시보드 홈 API(PLAN 547) — GET /dashboard/home · /dashboard/projects/summary
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 프로젝트 카드 요약(547-1 · `B01_Dashboard_Summary.py`) — 값이 없으면 칸이 빠짐 */
export interface ProjectSummary {
id: string;
name: string;
kind: string;
region?: string | null;
company_id?: number | null;
stage_index: number;
stage_total: number;
stage_name: string;
done: boolean;
updated_at?: string | null;
stale_days?: number | null;
owner_user_id?: number | null;
owner_names: string[];
/** 노선 중심선 0~1(y 아래로) · 없으면 null */
thumb: [number, number][] | null;
metrics: { length_m?: number; stations?: number; structures?: number };
}
export interface PlanTodo {
project_id: string;
project_name: string;
year: number;
linked_project_id: string | null;
}
export interface HomeData {
role: "SYSTEM_ADMIN" | "FOREST" | "ADMIN" | "USER";
/** 진행 중(완료 아님) · 최근 수정 순 · 썸네일은 앞 8 개만 */
projects: ProjectSummary[];
/** 홈 범위 프로젝트 전체(완료 포함) — 0 = 처음 */
project_total: number;
todos: {
new_plans: PlanTodo[];
updated_plans: PlanTodo[];
join_requests?: number;
expiring_temp: { batch_id: string; name: string; expires_at: string; days_left: number }[];
temp_count: number;
};
company?: { id: number; name: string | null; member_count: number | null };
forest?: {
plan_status: { draft: number; analyzed: number; confirmed: number };
assign_waiting: number;
};
system?: {
companies: number;
users: number;
company_names: Record<string, string>;
server: { cpu: number | null; memory: number | null; disk: number | null };
};
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
if (!response.ok) throw new Error(`홈 자료를 불러오지 못했습니다 (${response.status})`);
return (await response.json()) as T;
}
export const fetchHome = (): Promise<HomeData> => getJson<HomeData>("/dashboard/home");
/** 카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) */
export async function fetchProjectSummaries(ids?: string[]): Promise<ProjectSummary[]> {
const query = ids?.length ? `?ids=${encodeURIComponent(ids.join(","))}` : "";
return (await getJson<{ projects: ProjectSummary[] }>(`/dashboard/projects/summary${query}`))
.projects;
}
+4 -2
View File
@@ -20,8 +20,9 @@ export interface OverviewProject extends ProjectItem {
board: BoardColumn;
idle_days: number | null;
stalled: boolean;
/** 썸네일 선(줄인 평면 좌표) — 547-1 카드 요약 */
thumb?: [number, number][] | null;
/** 썸네일 선(0~1 칸 · y 아래로) — 547-1 카드 요약 */
thumb?: number[][] | null;
metrics?: { length_m?: number; stations?: number; structures?: number };
}
/** 지도 선 — 위경도 [lat, lon] */
@@ -47,6 +48,7 @@ export interface ProjectChange {
at: string;
who?: string | null;
action: string;
stage_no?: number;
}
export interface ProjectDetail {
+159 -1
View File
@@ -1,18 +1,23 @@
"""B01 대시보드 홈 API (PLAN 547) — 프로젝트 카드 요약 · 역할별 홈."""
from datetime import datetime, timedelta
from typing import Any
import aiomysql
from fastapi import APIRouter, Depends, Query
from common_util.common_util_auth import verify_session
from common_util.common_util_auth import is_forest_email, verify_session
from config.config_db import get_db_pool
from .B01_Dashboard_Repository import get_system_resources
from .B01_Dashboard_Summary import project_summaries
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
SUMMARY_LIMIT = 200
# 홈 카드 썸네일은 앞 8 개만(9 넘음 꼴은 그림 대신 숫자 · 그래프)
HOME_THUMBS = 8
EXPIRE_SOON_DAYS = 7
async def visible_project_ids(session: dict[str, Any]) -> list[str]:
@@ -23,6 +28,10 @@ async def visible_project_ids(session: dict[str, Any]) -> list[str]:
where, args = where + " AND p.company_id = %s", (int(session["company_id"]),)
else:
where, args = where + " AND p.user_id = %s", (int(session["user_id"]),)
return await _ids(where, args)
async def _ids(where: str, args: tuple) -> list[str]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
@@ -45,3 +54,152 @@ async def projects_summary(
wanted = dict.fromkeys(pid.strip() for pid in ids.split(","))
visible = [pid for pid in wanted if pid in allowed]
return {"status": "success", "projects": await project_summaries(visible[:SUMMARY_LIMIT])}
def home_role(session: dict[str, Any]) -> str:
"""홈 역할 — 셸 등록부 `roleOf` 와 같은 차례(시스템 · 회사 관리자 · 산림청 *.go.kr · 그 밖)."""
if session["role"] in ("SYSTEM_ADMIN", "ADMIN"):
return session["role"]
return "FOREST" if is_forest_email(session.get("email")) else "USER"
async def home_project_ids(session: dict[str, Any], role: str) -> list[str]:
"""홈 프로젝트 — 시스템 관리자 = 전부 · 회사 관리자 = 회사 · 그 밖 = 내가 만든 · 참여한 것."""
if role in ("SYSTEM_ADMIN", "ADMIN") or not session.get("company_id"):
return await visible_project_ids(session)
me = int(session["user_id"])
return await _ids(
"p.deleted_at IS NULL AND p.company_id = %s AND (p.user_id = %s OR EXISTS "
"(SELECT 1 FROM project_members m WHERE m.project_id = p.id AND m.user_id = %s))",
(int(session["company_id"]), me, me),
)
async def _one(cursor: Any, sql: str, args: tuple = ()) -> dict[str, Any]:
await cursor.execute(sql, args)
return await cursor.fetchone() or {}
async def _todos(cursor: Any, session: dict[str, Any], role: str) -> dict[str, Any]:
"""처리할 일 · 보관함 — 받은 예정노선(새로 · 업데이트) · 가입 요청 · 곧 만료 보관 파일."""
from D01_PlanRegister.D01_PlanRegister_Router import assigned_plan_routes
company_id = session.get("company_id")
plans = (await assigned_plan_routes(session))["routes"] if company_id else []
pick = ("project_id", "project_name", "year", "linked_project_id")
todos: dict[str, Any] = {
"new_plans": [
{k: p[k] for k in pick}
for p in plans
if not p["linked_project_id"] and p["confirmed_at"]
],
"updated_plans": [{k: p[k] for k in pick} for p in plans if p["update_available"]],
}
if role == "SYSTEM_ADMIN" or (role == "ADMIN" and company_id):
where = "" if role == "SYSTEM_ADMIN" else " AND company_id = %s"
row = await _one(
cursor,
f"SELECT COUNT(*) AS n FROM join_requests WHERE status = 'PENDING'{where}",
() if role == "SYSTEM_ADMIN" else (int(company_id),),
)
todos["join_requests"] = int(row.get("n") or 0)
await cursor.execute(
"""SELECT id, name, status, expires_at FROM temp_upload_batches
WHERE user_id = %s AND status <> 'linked'
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY expires_at IS NULL, expires_at""",
(int(session["user_id"]),),
)
batches = list(await cursor.fetchall())
soon = datetime.now() + timedelta(days=EXPIRE_SOON_DAYS)
todos["expiring_temp"] = [
{
"batch_id": str(b["id"]),
"name": b["name"],
"expires_at": b["expires_at"].isoformat(),
"days_left": max(0, (b["expires_at"] - datetime.now()).days),
}
for b in batches
if b["expires_at"] and b["expires_at"] <= soon
]
todos["temp_count"] = len(batches)
return todos
async def _company(cursor: Any, company_id: int) -> dict[str, Any]:
row = await _one(
cursor,
"""SELECT c.name, (SELECT COUNT(*) FROM users u
WHERE u.company_id = c.id AND u.deleted_at IS NULL) AS member_count
FROM companies c WHERE c.id = %s""",
(company_id,),
)
return {"id": company_id, "name": row.get("name"), "member_count": row.get("member_count")}
async def _forest(cursor: Any, company_id: int) -> dict[str, Any]:
"""예정노선 연도 상태별 수(분석 전 · 분석됨 · 확정) · 업체 지정 대기(확정 · 업체 없음)."""
await cursor.execute(
"""SELECT y.route_json IS NOT NULL AS analyzed, y.confirmed_at IS NOT NULL AS confirmed,
EXISTS (SELECT 1 FROM plan_route_assignees a
WHERE a.project_id = y.project_id AND a.year = y.year) AS assigned
FROM plan_route_years y
JOIN projects p ON p.id = y.project_id AND p.deleted_at IS NULL
WHERE p.company_id = %s""",
(company_id,),
)
status = {"draft": 0, "analyzed": 0, "confirmed": 0}
waiting = 0
for r in await cursor.fetchall():
key = "confirmed" if r["confirmed"] else "analyzed" if r["analyzed"] else "draft"
status[key] += 1
waiting += bool(r["confirmed"] and not r["assigned"])
return {"plan_status": status, "assign_waiting": waiting}
async def _system(cursor: Any) -> dict[str, Any]:
row = await _one(
cursor,
"""SELECT (SELECT COUNT(*) FROM companies WHERE deleted_at IS NULL) AS companies,
(SELECT COUNT(*) FROM users WHERE deleted_at IS NULL) AS users""",
)
await cursor.execute("SELECT id, name FROM companies WHERE deleted_at IS NULL")
names = {int(r["id"]): r["name"] for r in await cursor.fetchall()}
current = (await get_system_resources(days=1))["current"]
server = {k: current.get(f"{k}_usage_percent") for k in ("cpu", "memory", "disk")}
return {
"companies": int(row.get("companies") or 0),
"users": int(row.get("users") or 0),
"company_names": names,
"server": server,
}
@router.get("/home")
async def dashboard_home(session: dict[str, Any] = Depends(verify_session)):
"""역할별 홈 — 진행 중 프로젝트 카드(최근 수정 순) · 처리할 일 · 회사 · 산림청 · 시스템 요약.
단계 분포 · 팀원별 · 종류별 · 회사별 수는 화면이 `projects` 로 셈(같은 목록 한 벌).
"""
role = home_role(session)
ids = await home_project_ids(session, role)
cards = [s for s in await project_summaries(ids[:SUMMARY_LIMIT]) if not s["done"]]
for card in cards[HOME_THUMBS:]:
card["thumb"] = None
company_id = session.get("company_id")
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
body: dict[str, Any] = {
"status": "success",
"role": role,
"projects": cards,
"project_total": len(ids),
"todos": await _todos(cursor, session, role),
}
if role == "ADMIN" and company_id:
body["company"] = await _company(cursor, int(company_id))
if role == "FOREST" and company_id:
body["forest"] = await _forest(cursor, int(company_id))
if role == "SYSTEM_ADMIN":
body["system"] = await _system(cursor)
return body
+28 -25
View File
@@ -17,6 +17,7 @@ from common_util.common_util_auth import verify_session
from config.config_db import get_db_pool
from .B01_Dashboard_Repository import _project_rows
from .B01_Dashboard_Summary import project_summaries
router = APIRouter(prefix="/api/dashboard/overview", tags=["B01_Dashboard"])
@@ -118,7 +119,17 @@ async def overview_projects(
rows = await _project_rows(cursor, await cursor.fetchall())
if scope == "mine":
rows = [r for r in rows if is_mine(r, int(session["user_id"]))]
return {"status": "success", "projects": [_decorate(r) for r in rows]}
summaries = {s["id"]: s for s in await project_summaries([str(r["id"]) for r in rows])}
return {
"status": "success",
"projects": [_with_summary(_decorate(r), summaries.get(str(r["id"]))) for r in rows],
}
def _with_summary(row: dict[str, Any], summary: dict[str, Any] | None) -> dict[str, Any]:
"""카드 요약(547-1) 의 썸네일 선 · 핵심 숫자를 붙임."""
summary = summary or {}
return {**row, "thumb": summary.get("thumb"), "metrics": summary.get("metrics", {})}
async def _visible_project(cursor: Any, session: dict[str, Any], project_id: str) -> dict:
@@ -146,7 +157,7 @@ async def _visible_project(cursor: Any, session: dict[str, Any], project_id: str
def _route_lonlat(root: Path, limit: int = 300) -> tuple[list[list[float]], float | None]:
"""설계 노선(작업본 → 초기값 → 예상노선) [lat, lon] · 연장 — 못 읽으면 ([], None)."""
"""지도 선 = 설계 노선(작업본 → 초기값 → 예상노선) [lat, lon] · 연장 — 못 읽으면 ([], None)."""
from pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
@@ -179,20 +190,18 @@ def _route_lonlat(root: Path, limit: int = 300) -> tuple[list[list[float]], floa
return [], None
def _numbers(root: Path | None, length_m: float | None, stations: int) -> list[dict[str, Any]]:
"""핵심 숫자 — 있는 것만(연장 · 측점 · 구조물)."""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
out: list[dict[str, Any]] = []
if length_m:
out.append({"key": "length", "value": round(float(length_m), 1), "unit": "m"})
if stations:
out.append({"key": "stations", "value": stations})
if root is not None:
count = len(load_structures(str(root))[1])
if count:
out.append({"key": "structures", "value": count})
return out
def _numbers(metrics: dict[str, Any]) -> list[dict[str, Any]]:
"""핵심 숫자 — 카드 요약 값 가운데 있는 것만(연장 · 측점 · 구조물)."""
keys = (
("length_m", "length", "m"),
("stations", "stations", None),
("structures", "structures", None),
)
return [
{"key": key, "value": metrics[name], **({"unit": unit} if unit else {})}
for name, key, unit in keys
if metrics.get(name)
]
def project_outputs(kind: str, stages: list[dict[str, Any]]) -> list[dict[str, str]]:
@@ -292,20 +301,14 @@ async def overview_project_detail(
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
raw = await _visible_project(cursor, session, project_id)
row = _decorate((await _project_rows(cursor, [raw]))[0])
await cursor.execute(
"""SELECT COUNT(*) AS n FROM cross_sections WHERE route_id = (
SELECT id FROM routes WHERE project_id = %s
ORDER BY computed_at DESC, id DESC LIMIT 1)""",
(project_id,),
)
stations = int((await cursor.fetchone())["n"])
links = await _links(cursor, row)
changes = await _changes(cursor, project_id, row["workflow_state"]["stages"])
try:
root = Path(resolve_stored_project_path(raw["storage_path"]))
except (ValueError, TypeError, AttributeError):
root = None
line, length = _route_lonlat(root) if root else ([], None)
line = _route_lonlat(root)[0] if root else []
summary = (await project_summaries([project_id]) or [{}])[0]
people = {
"pm": row["manager_name"] if row.get("pm_user_id") else None,
"field_lead": row.pop("field_lead_name", None),
@@ -317,7 +320,7 @@ async def overview_project_detail(
"project": row,
"people": people,
"route": {"points": line} if line else None,
"numbers": _numbers(root, length, stations),
"numbers": _numbers(summary.get("metrics", {})),
"links": links,
"outputs": project_outputs(row["project_kind"], row["workflow_state"]["stages"]),
"changes": changes,
+258
View File
@@ -0,0 +1,258 @@
/* =============================================================================
* B01_Dashboard_UI_Home.ts
* 대시보드 홈(PLAN 547-2 · 요구 369~374) — 진행 중 프로젝트 수에 따라 꼴이 저절로 바뀜
* 1 = 큰 카드 · 2~3 = 중간 카드 · 4~8 = 한 줄 목록 + 팀원별 · 단계 분포 · 9 넘음 = 요약 · 그래프
* 오른쪽 = 처리할 일 · 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음이면 세 걸음
* 꼴 고정(자동 · 작게 · 크게) = localStorage 기억
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { fetchHome, type HomeData, type ProjectSummary } from "./B01_Dashboard_Api_Home";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import { bigCard, midCard, rowCard, type Go } from "./B01_Dashboard_UI_Home_Cards";
import { manyView, memberPane, pane, stagePane } from "./B01_Dashboard_UI_Home_Many";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { homeLayout, type HomeFixed, type HomeLayout } from "./logic/B01_Dashboard_Home_Layout";
import "./B01_Dashboard_UI_Style_Home.css";
const FIXED_KEY = "aislo.b01.homeLayout";
const LAYOUT_TEXT: Record<HomeLayout, string> = {
empty: "처음 시작",
one: "진행 중 1 개 → 큰 카드",
few: "진행 중 2~3 개 → 중간 카드",
list: "진행 중 4~8 개 → 한 줄 목록",
many: "진행 중 9 개 넘음 → 요약 · 그래프",
};
function readFixed(): HomeFixed {
const saved = localStorage.getItem(FIXED_KEY);
return saved === "small" || saved === "large" ? saved : "auto";
}
function fixedSwitch(current: HomeFixed, onPick: (next: HomeFixed) => void): HTMLElement {
const seg = el("div", {
className: "b01-home__seg",
attrs: { role: "group", "aria-label": "꼴" },
});
for (const [value, label] of [
["auto", "자동"],
["small", "작게"],
["large", "크게"],
] as const) {
const b = el("button", {
className: value === current ? "on" : "",
text: label,
attrs: { type: "button", "aria-pressed": String(value === current) },
});
b.addEventListener("click", () => onPick(value));
seg.append(b);
}
return seg;
}
function todoRow(dot: string, text: string, action: string, onClick: () => void): HTMLElement {
return el("div", {
className: "b01-home__todo",
children: [
el("span", { className: `b01-home__dot b01-home__dot--${dot}` }),
el("span", { text }),
createButton({ label: action, variant: "ghost", size: "sm", onClick }),
],
});
}
/** 처리할 일 — 없으면 「처리할 일 없음」 */
function todoPane(data: HomeData, go: Go): HTMLElement {
const t = data.todos;
const rows: HTMLElement[] = [];
for (const p of t.new_plans)
rows.push(
todoRow("plan", `새 예정노선 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
);
for (const p of t.updated_plans)
rows.push(
todoRow("warn", `업데이트 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
);
if (t.join_requests)
rows.push(todoRow("alert", `가입 요청 ${t.join_requests} 건`, "처리", () => go("people")));
for (const b of t.expiring_temp)
rows.push(
todoRow("warn", `보관 파일 ${b.name} · ${b.days_left} 일 남음`, "열기", () => go("temp")),
);
if (!rows.length) rows.push(el("div", { className: "b01-home__muted", text: "처리할 일 없음" }));
return pane("처리할 일", ...rows);
}
function sidePanes(data: HomeData, go: Go): HTMLElement[] {
const panes = [todoPane(data, go)];
panes.push(
pane(
"임시 보관함",
el("div", { className: "b01-home__muted", text: `보관 묶음 ${data.todos.temp_count} 개` }),
createButton({ label: "보관함 ›", variant: "ghost", size: "sm", onClick: () => go("temp") }),
),
);
if (data.company) {
const c = data.company;
panes.push(
pane(
c.name ?? "회사",
el("div", {
className: "b01-home__muted",
text: `팀원 ${c.member_count ?? 0} 명 · 진행 중 ${data.projects.length} 개`,
}),
createButton({
label: "사람 ›",
variant: "ghost",
size: "sm",
onClick: () => go("people"),
}),
),
);
}
if (data.forest) {
const f = data.forest;
panes.push(
pane(
"예정노선",
el("div", {
className: "b01-home__muted",
text: `분석 전 ${f.plan_status.draft} · 분석됨 ${f.plan_status.analyzed} · 확정 ${f.plan_status.confirmed}`,
}),
el("div", {
className: f.assign_waiting ? "b01-home__stale" : "b01-home__muted",
text: `업체 지정 대기 ${f.assign_waiting}`,
}),
),
);
}
return panes;
}
/** 처음 — 세 걸음(내 정보 · 파일 · 첫 프로젝트) */
function firstSteps(ctx: ViewCtx, data: HomeData): HTMLElement {
const me = ctx.me;
const steps: [boolean, string, string, () => void][] = [
[
Boolean(me.position || me.department || me.phone),
"내 정보 채우기",
"내 정보 ›",
() => ctx.go("me"),
],
[data.todos.temp_count > 0, "측량 파일 올려 두기", "보관함 ›", () => ctx.go("temp")],
[false, "첫 프로젝트 만들기", "+ 새 프로젝트", () => void openNewProjectPicker()],
];
return pane(
"처음 오셨네요 — 세 걸음",
...steps.map(([done, text, action, onClick], i) =>
el("div", {
className: `b01-home__step${done ? " b01-home__step--done" : ""}`,
children: [
el("span", { className: "b01-home__step-no", text: done ? "✓" : String(i + 1) }),
el("span", { text }),
createButton({ label: action, variant: done ? "ghost" : "filled", size: "sm", onClick }),
],
}),
),
);
}
function mainArea(layout: HomeLayout, ctx: ViewCtx, data: HomeData): HTMLElement {
const cards: ProjectSummary[] = data.projects;
const go = ctx.go;
if (layout === "empty")
return data.project_total
? pane(
"진행 중인 프로젝트 없음",
el("div", { className: "b01-home__muted", text: "모두 끝났습니다." }),
)
: firstSteps(ctx, data);
if (layout === "one") return bigCard(cards[0], go);
if (layout === "few") {
const mids = el("div", {
className: "b01-home__mids",
children: cards.slice(0, 8).map((c, i) => midCard(c, go, i === 0)),
});
if (cards.length <= 8) return mids;
const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") });
return el("div", { className: "b01-home__many", children: [mids, all] });
}
if (layout === "list")
return el("div", {
className: "b01-home__split b01-home__split--list",
children: [
el("div", {
children: [
el("div", {
className: "b01-home__muted",
text: `진행 중 ${cards.length} · 최근 작업 순`,
}),
el("div", { className: "b01-home__rows", children: cards.map((c) => rowCard(c, go)) }),
],
}),
el("div", { children: [memberPane(cards), stagePane(cards)] }),
],
});
return manyView(data, go);
}
export function mountHome(host: HTMLElement, ctx: ViewCtx): () => void {
let alive = true;
let data: HomeData | null = null;
const root = el("div", { className: "b01-home" });
host.append(root);
const draw = (): void => {
if (!data) return;
const fixed = readFixed();
const layout = homeLayout(data.projects.length, fixed);
root.dataset.layout = layout;
const head = el("div", {
className: "b01-home__head",
children: [
el("h2", { text: "홈" }),
el("span", {
className: "b01-home__muted",
text:
fixed === "auto" || layout === "empty"
? LAYOUT_TEXT[layout]
: `진행 중 ${data.projects.length} 개 · ${fixed === "small" ? "작게" : "크게"} 고정`,
}),
el("span", { className: "b01-home__push" }),
fixedSwitch(fixed, (next) => {
if (next === "auto") localStorage.removeItem(FIXED_KEY);
else localStorage.setItem(FIXED_KEY, next);
draw();
}),
createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }),
],
});
const body = el("div", {
className: "b01-home__body",
children: [
el("div", { className: "b01-home__main", children: [mainArea(layout, ctx, data)] }),
el("aside", { className: "b01-home__side", children: sidePanes(data, ctx.go) }),
],
});
root.replaceChildren(head, body);
};
root.append(el("div", { className: "b01-home__muted", text: "불러오는 중…" }));
fetchHome()
.then((loaded) => {
if (!alive) return;
data = loaded;
draw();
})
.catch((error: unknown) => {
if (!alive) return;
root.replaceChildren(
el("div", { className: "b01-home__muted", text: "홈을 불러오지 못했습니다." }),
);
showToast(error instanceof Error ? error.message : String(error), "error");
});
return () => {
alive = false;
};
}
@@ -0,0 +1,219 @@
/* =============================================================================
* B01_Dashboard_UI_Home_Cards.ts
* 홈 카드 부품(PLAN 547-2) — 썸네일 선 · 단계 점 · 큰 카드(1) · 중간 카드(2~3) · 한 줄(4~8)
* [이어서 하기] = 그 단계 화면 · [자세히] · 줄 누름 = go("project", { id })
* ========================================================================== */
import type { RoutePath } from "@config/config_frontend";
import { createButton, el } from "@ui/ui_template_elements";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { groupFlowOf } from "../A00_Common/group_workflow";
import type { ProjectSummary } from "./B01_Dashboard_Api_Home";
import { L } from "./B01_Dashboard_UI_Common";
import { isStale } from "./logic/B01_Dashboard_Home_Layout";
export type Go = (view: string, params?: Record<string, string>) => void;
const SVG = "http://www.w3.org/2000/svg";
const KIND_LABEL = {
ROAD: "DE_New_Road",
PLAN: "DE_New_Plan",
STABILITY: "DE_New_Stability",
EROSION: "DE_New_Erosion",
} as const;
export const kindLabel = (kind: string): string =>
L(KIND_LABEL[kind as keyof typeof KIND_LABEL] ?? "DE_New_Road");
export const kindClass = (kind: string): string =>
`b01-home__kind--${(kind in KIND_LABEL ? kind : "ROAD").toLowerCase()}`;
/** 노선 중심선 썸네일 — 선이 없으면 빈 바탕 */
export function thumbSvg(card: ProjectSummary, className: string): SVGSVGElement {
const svg = document.createElementNS(SVG, "svg");
svg.setAttribute("class", `b01-home__thumb ${kindClass(card.kind)} ${className}`);
svg.setAttribute("viewBox", "-0.08 -0.08 1.16 1.16");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.setAttribute("aria-hidden", "true");
if (card.thumb) {
const line = document.createElementNS(SVG, "polyline");
line.setAttribute("points", card.thumb.map(([x, y]) => `${x},${y}`).join(" "));
line.setAttribute("vector-effect", "non-scaling-stroke");
svg.append(line);
}
return svg;
}
/** 단계 점 — 지난 단계 · 지금 단계 · 남은 단계 */
export function stagePips(card: ProjectSummary): HTMLElement {
const pips = el("div", { className: `b01-home__pips ${kindClass(card.kind)}` });
for (let i = 0; i < card.stage_total; i += 1) {
const state = i < card.stage_index ? "ok" : i === card.stage_index ? "cur" : "";
pips.append(el("i", { className: state }));
}
return pips;
}
export const stageText = (card: ProjectSummary): string =>
`${card.stage_index + 1} / ${card.stage_total} · ${card.stage_name}`;
/** 「n 일째 그대로」 · 오늘 · n 일 전 */
export function whenText(card: ProjectSummary): string {
const days = card.stale_days ?? null;
if (days === null) return "";
if (isStale(card)) return `${days} 일째 그대로`;
return days === 0 ? "오늘" : `${days} 일 전`;
}
export function continueStage(card: ProjectSummary): void {
const routes: readonly RoutePath[] = groupFlowOf(card.kind)?.routes ?? WORKFLOW_STEP_ROUTES;
void goToWorkflowStage(card.id, routes[Math.min(card.stage_index, routes.length - 1)]);
}
function kindBadge(card: ProjectSummary): HTMLElement {
return el("span", {
className: `b01-home__badge ${kindClass(card.kind)}`,
text: kindLabel(card.kind),
});
}
function title(card: ProjectSummary): HTMLElement {
return el("div", {
className: "b01-home__title",
children: [el("b", { text: card.name }), kindBadge(card)],
});
}
function nextBox(card: ProjectSummary): HTMLElement {
return el("div", { className: "b01-home__next", text: `다음: ${card.stage_name}` });
}
function whenLine(card: ProjectSummary): HTMLElement {
const line = el("div", { className: "b01-home__muted" });
const owner = card.owner_names[0];
if (owner) line.append(`${owner} · `);
line.append(
el("span", { className: isStale(card) ? "b01-home__stale" : "", text: whenText(card) }),
);
return line;
}
const METRIC_ROWS: [keyof ProjectSummary["metrics"], string, (v: number) => string][] = [
["length_m", "연장", (v) => `${v.toLocaleString()} m`],
["stations", "측점", (v) => `${v}`],
["structures", "구조물", (v) => `${v}`],
];
/** 핵심 숫자 — 있는 값만 */
function metricStrip(card: ProjectSummary): HTMLElement | null {
const cells = METRIC_ROWS.filter(([key]) => card.metrics[key] !== undefined).map(
([key, label, fmt]) =>
el("div", {
children: [
el("b", { text: fmt(card.metrics[key] as number) }),
el("span", { text: label }),
],
}),
);
return cells.length ? el("div", { className: "b01-home__metrics", children: cells }) : null;
}
const continueButton = (card: ProjectSummary, label = "이어서 하기 ›"): HTMLButtonElement =>
createButton({ label, size: "sm", onClick: () => continueStage(card) });
const detailButton = (card: ProjectSummary, go: Go): HTMLButtonElement =>
createButton({
label: "자세히",
variant: "ghost",
size: "sm",
onClick: () => go("project", { id: card.id }),
});
/** 진행 중 1 — 큰 카드(썸네일 · 단계 점 · 다음 할 일 · 핵심 숫자) */
export function bigCard(card: ProjectSummary, go: Go): HTMLElement {
const info = el("div", {
className: "b01-home__big-info",
children: [
el("div", { className: "b01-home__muted", text: "지금 하는 프로젝트" }),
title(card),
el("b", { className: "b01-home__stage", text: stageText(card) }),
stagePips(card),
nextBox(card),
whenLine(card),
el("div", {
className: "b01-home__actions",
children: [continueButton(card), detailButton(card, go)],
}),
],
});
const top = el("div", {
className: "b01-home__big-top",
children: [thumbSvg(card, "b01-home__thumb--big") as unknown as HTMLElement, info],
});
const metrics = metricStrip(card);
return el("div", {
className: "b01-home__pane b01-home__big",
children: metrics ? [top, metrics] : [top],
});
}
/** 진행 중 2~3 — 중간 카드(맨 앞 = 최근 작업) */
export function midCard(card: ProjectSummary, go: Go, first: boolean): HTMLElement {
const node = el("div", {
className: `b01-home__mid${first ? " b01-home__mid--first" : ""}`,
children: [
thumbSvg(card, "b01-home__thumb--mid") as unknown as HTMLElement,
el("div", {
className: "b01-home__mid-body",
children: [
whenLine(card),
title(card),
el("div", { className: "b01-home__muted", text: stageText(card) }),
stagePips(card),
nextBox(card),
el("div", {
className: "b01-home__actions",
children: [
continueButton(card, first ? "이어서 하기 ›" : "이어서 ›"),
detailButton(card, go),
],
}),
],
}),
],
});
return node;
}
/** 진행 중 4~8 — 한 줄(누름 = 상세 · 14 일 넘게 그대로면 노랑) */
export function rowCard(card: ProjectSummary, go: Go): HTMLElement {
const open = createButton({ label: "열기 ›", variant: "ghost", size: "sm" });
open.addEventListener("click", (event) => {
event.stopPropagation();
continueStage(card);
});
const row = el("div", {
className: `b01-home__row${isStale(card) ? " b01-home__row--stale" : ""}`,
attrs: { role: "button", tabindex: "0" },
children: [
thumbSvg(card, "b01-home__thumb--row") as unknown as HTMLElement,
el("div", { className: "b01-home__row-name", children: [title(card), whenLine(card)] }),
el("div", {
className: "b01-home__row-stage",
children: [
stagePips(card),
el("div", {
className: "b01-home__muted",
text: `${card.stage_name} ${card.stage_index + 1}/${card.stage_total}`,
}),
],
}),
open,
],
});
row.addEventListener("click", () => go("project", { id: card.id }));
row.addEventListener("keydown", (event) => {
if (event.key === "Enter") go("project", { id: card.id });
});
return row;
}
+207
View File
@@ -0,0 +1,207 @@
/* =============================================================================
* B01_Dashboard_UI_Home_Many.ts
* 홈 4 넘음 부품(PLAN 547-2) — 팀원별 · 단계 분포(4~8) · 요약 숫자 · 막대 · 원 그래프(9 넘음)
* 그래프 = 인라인 SVG(새 라이브러리 없음) · 색 = 셸 · 테마 변수(CSS 클래스)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { HomeData, ProjectSummary } from "./B01_Dashboard_Api_Home";
import { kindClass, kindLabel, type Go } from "./B01_Dashboard_UI_Home_Cards";
import {
countBy,
donutArcs,
isStale,
stageBuckets,
topWithRest,
} from "./logic/B01_Dashboard_Home_Layout";
const SVG = "http://www.w3.org/2000/svg";
function svgNode(tag: string, attrs: Record<string, string | number>): SVGElement {
const node = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
return node;
}
export function pane(titleText: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement {
const node = el("div", {
className: "b01-home__pane",
children: [el("h3", { className: "b01-home__pane-title", text: titleText })],
});
node.append(...children);
return node;
}
const ownerOf = (card: ProjectSummary): string => card.owner_names[0] ?? "담당 없음";
/** 팀원별 진행 중 개수 */
export function memberPane(cards: ProjectSummary[]): HTMLElement {
const rows = countBy(cards, ownerOf).map(({ label, count }) =>
el("div", {
className: "b01-home__member",
children: [
el("span", { className: "b01-home__avatar", text: label.slice(0, 1) }),
el("span", { text: label }),
el("span", { className: "b01-home__muted b01-home__push", text: `${count} 개` }),
],
}),
);
return pane("팀원별", ...rows);
}
/** 단계 분포 — 시작 전 · 설계 중 · 확정 대기 · 쌓은 막대 */
export function stagePane(cards: ProjectSummary[]): HTMLElement {
const b = stageBuckets(cards);
const total = Math.max(1, cards.length);
const parts: [number, string, string][] = [
[b.start, "start", "시작 전"],
[b.working, "working", "설계 중"],
[b.last, "last", "확정 대기"],
];
const bar = el("div", { className: "b01-home__stack" });
for (const [n, cls, label] of parts) {
if (!n) continue;
const seg = el("i", {
className: `b01-home__stack--${cls}`,
attrs: { title: `${label} ${n}` },
});
seg.style.width = `${(n / total) * 100}%`;
bar.append(seg);
}
const text = parts.map(([n, , label]) => `${label} ${n}`).join(" · ");
const stale = cards.filter(isStale).length;
const legend = el("div", { className: "b01-home__muted", text });
const children: HTMLElement[] = [bar, legend];
if (stale)
children.push(
el("div", { className: "b01-home__stale", text: `14 일 넘게 그대로 ${stale} 개` }),
);
return pane("단계 분포", ...children);
}
function kpi(label: string, value: string, note?: string, warn = false): HTMLElement {
return el("div", {
className: "b01-home__kpi",
children: [
el("div", { className: "b01-home__muted", text: label }),
el("b", { className: warn ? "b01-home__alert" : "", text: value }),
...(note ? [el("span", { className: "b01-home__muted", text: note })] : []),
],
});
}
/** 가로 막대 — 회사별(시스템 관리자) · 담당별(그 밖) · 앞 4 + 그 밖 */
function barChart(rows: { label: string; count: number; rest?: number }[]): SVGElement {
const shown = topWithRest(rows, 5);
const max = Math.max(1, ...shown.map((r) => r.count));
const rowH = 22;
const svg = svgNode("svg", {
class: "b01-home__bars",
viewBox: `0 0 320 ${shown.length * rowH}`,
width: "100%",
preserveAspectRatio: "xMinYMin meet",
role: "img",
});
shown.forEach((r, i) => {
const y = i * rowH;
const label = r.rest ? `그 밖 ${r.rest} 곳` : r.label;
const width = (r.count / max) * 180;
const text = svgNode("text", { x: 0, y: y + 14 });
text.textContent = label.length > 9 ? `${label.slice(0, 9)}…` : label;
const count = svgNode("text", { x: 96 + width, y: y + 14 });
count.textContent = String(r.count);
svg.append(
text,
svgNode("rect", { x: 90, y: y + 4, width, height: 12, rx: 3, class: r.rest ? "rest" : "" }),
count,
);
});
return svg;
}
/** 원 그래프 — 종류별 · 가운데 = 합 */
function kindDonut(cards: ProjectSummary[]): HTMLElement {
const kinds = countBy(cards, (c) => c.kind);
const arcs = donutArcs(kinds.map((k) => k.count));
const svg = svgNode("svg", {
class: "b01-home__donut",
viewBox: "0 0 42 42",
width: 96,
height: 96,
});
kinds.forEach((k, i) => {
svg.append(
svgNode("circle", {
cx: 21,
cy: 21,
r: 15.9155,
class: kindClass(k.label),
"stroke-dasharray": arcs[i].dash,
"stroke-dashoffset": arcs[i].offset,
}),
);
});
const sum = svgNode("text", { x: 21, y: 24, "text-anchor": "middle" });
sum.textContent = String(cards.length);
svg.append(sum);
const legend = el("div", {
className: "b01-home__legend",
children: kinds.map((k) =>
el("span", {
children: [
el("i", { className: `b01-home__swatch ${kindClass(k.label)}` }),
`${kindLabel(k.label)} ${k.count}`,
],
}),
),
});
return el("div", {
className: "b01-home__donut-row",
children: [svg as unknown as HTMLElement, legend],
});
}
/** 9 넘음 — 요약 숫자 · 회사별(담당별) 막대 · 종류 원 · 단계 분포 · [모든 프로젝트 ›] */
export function manyView(data: HomeData, go: Go): HTMLElement {
const cards = data.projects;
const sys = data.system;
const pending = data.todos.join_requests ?? 0;
const kpis = el("div", { className: "b01-home__kpis" });
if (sys) kpis.append(kpi("회사", String(sys.companies)), kpi("사용자", String(sys.users)));
kpis.append(kpi("진행 중 프로젝트", String(cards.length), `전체 ${data.project_total}`));
if (data.todos.join_requests !== undefined)
kpis.append(
kpi("처리 대기", String(pending), pending ? `가입 ${pending}` : undefined, pending > 0),
);
if (sys) {
const s = sys.server;
const disk = s.disk ?? 0;
kpis.append(
kpi(
"서버",
`디스크 ${Math.round(disk)}%`,
`CPU ${Math.round(s.cpu ?? 0)} · 메모리 ${Math.round(s.memory ?? 0)}`,
disk >= 85,
),
);
}
const rows = sys
? countBy(cards, (c) => sys.company_names[String(c.company_id)] ?? "회사 없음")
: countBy(cards, (c) => c.owner_names[0] ?? "담당 없음");
const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") });
return el("div", {
className: "b01-home__many",
children: [
kpis,
el("div", {
className: "b01-home__split",
children: [
pane(sys ? "회사별 진행 중" : "담당별 진행 중", barChart(rows)),
pane("종류", kindDonut(cards)),
],
}),
stagePane(cards),
el("div", { className: "b01-home__actions", children: [all] }),
],
});
}
@@ -0,0 +1,297 @@
/* =============================================================================
* B01_Dashboard_UI_ProjectDetail.ts
* 프로젝트 상세(요구 369~374 · PLAN 548-2) — 주소 `view=project&id=`.
* 머리(이름 · 종류 · 지역 · 설계속도 · [⋯] · [작업 열기 — 지금 단계 ›]) · 단계 진행(누름 = 그 단계 화면) ·
* 지도(노선 · 시점 · 종점) + 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경 — 값이 있는 칸만.
* ========================================================================== */
import "./B01_Dashboard_UI_Style_Projects.css";
import type { RoutePath } from "@config/config_frontend";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { createButton } from "@ui/ui_template_elements";
import { el } from "@ui/ui_template_elements_base";
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
import { currentQuery } from "../A00_Common/router";
import {
fetchProjectDetail,
type ProjectChange,
type ProjectDetail,
type ProjectLink,
} from "./B01_Dashboard_Api_Projects";
import { L } from "./B01_Dashboard_UI_Common";
import { routeMap } from "./B01_Dashboard_UI_ProjectDetail_Map";
import type { ProjectViewCtx } from "./B01_Dashboard_UI_ProjectList";
import {
kindBadge,
openStage,
stageFlow,
stageProgress,
} from "./B01_Dashboard_UI_ProjectList_Parts";
import { openMenuAt, rowMenuItems } from "./B01_Dashboard_UI_ProjectList_Views";
/** 인쇄 · 내려받기 화면(B09 초안 길) — 도면 PDF · 수량 · 내역 xlsx 를 받는 곳 */
const PRINT_ROUTE = "b09-print" as RoutePath;
const ROAD_TYPE: Record<string, Parameters<typeof L>[0]> = {
main: "B02_Proj_RoadType_Main",
fire: "B02_Proj_RoadType_Fire",
work: "B02_Proj_RoadType_Work",
};
const roadType = (value?: string | null): string | null =>
value ? (ROAD_TYPE[value] ? L(ROAD_TYPE[value]) : value) : null;
const NUMBER_LABEL: Record<string, string> = {
length: "연장",
stations: "측점",
structures: "구조물",
};
const LINK_LABEL: Record<ProjectLink["kind"], string> = {
source: "원본",
derived: "이어 만든 것",
plan: "받은 예정노선",
};
function pane(title: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement {
const box = el("section", { className: "b01-pd-pane", children: [el("h3", { text: title })] });
box.append(...children);
return box;
}
function steps(detail: ProjectDetail): HTMLElement {
const project = detail.project;
const { steps: names } = stageFlow(project.project_kind);
const { current } = stageProgress(project);
const states = new Map((project.workflow_state?.stages ?? []).map((s) => [s.stage_no, s.state]));
const row = el("div", { className: "b01-pd-steps" });
row.style.setProperty("--n", String(names.length));
names.forEach((name, index) => {
const state = states.get(index);
const done = state === "COMPLETE";
const cls = done
? "is-done"
: state === "STALE" || state === "FAILED"
? "is-stale"
: index === current && project.board !== "start"
? "is-cur"
: "";
const step = el("button", {
className: `b01-pd-step ${cls}`,
attrs: { type: "button", title: `${name} 화면 열기` },
children: [el("span", { text: done ? "✓" : String(index + 1) }), el("b", { text: name })],
});
step.addEventListener("click", () => openStage(project, index));
row.append(step);
});
return row;
}
function numbers(detail: ProjectDetail): HTMLElement | null {
if (!detail.numbers.length) return null;
return el("div", {
className: "b01-pd-kpis",
children: detail.numbers.map((n) =>
el("div", {
className: "b01-pd-kpi",
children: [
el("b", { text: `${n.value.toLocaleString("ko-KR")}${n.unit ? ` ${n.unit}` : ""}` }),
el("span", { text: NUMBER_LABEL[n.key] ?? n.key }),
],
}),
),
});
}
function infoPane(detail: ProjectDetail): HTMLElement {
const p = detail.project;
const year = p.project_year
? `${p.project_year}${p.project_year_end && p.project_year_end !== p.project_year ? ` ~ ${p.project_year_end}` : ""}`
: null;
const people = [
...new Set([p.manager_name, detail.people.field_lead, detail.people.designer].filter(Boolean)),
].join(" · ");
const rows: [string, string | null | undefined][] = [
["사업 연도", year],
["지역", p.region],
["임도 종류", roadType(p.road_type)],
["설계속도", p.design_speed_kph ? `${p.design_speed_kph} km/h` : null],
["시행청", p.client_org],
["사업 번호", p.project_number],
["담당", people],
["만든 날", (p.created_at ?? "").slice(0, 10)],
];
const kv = el("div", { className: "b01-pd-kv" });
for (const [label, value] of rows) {
if (value) kv.append(el("span", { text: label }), el("b", { text: value }));
}
return pane("기본 정보", kv);
}
function linkPane(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement | null {
if (!detail.links.length) return null;
return pane(
"연결된 것",
...detail.links.map((link) =>
el("div", {
className: "b01-pd-link",
children: [
kindBadge(link.project_kind),
el("span", { text: `${LINK_LABEL[link.kind]} · ${link.name}` }),
...(link.note ? [el("span", { className: "b01-proj-mini", text: link.note })] : []),
el("span", { className: "b01-proj-sp" }),
createButton({
label: "보기",
variant: "ghost",
onClick: () => ctx.go("project", { id: link.project_id }),
}),
],
}),
),
);
}
function outputPane(detail: ProjectDetail): HTMLElement | null {
if (!detail.outputs.length) return null;
return pane(
"산출물 내려받기",
...detail.outputs.map((out) =>
el("div", {
className: "b01-pd-link",
attrs: { "data-output": out.key },
children: [
el("span", { text: out.label }),
el("span", { className: "b01-proj-sp" }),
createButton({
label: "받기 화면",
variant: "ghost",
onClick: () => void goToWorkflowStage(detail.project.id, PRINT_ROUTE),
}),
],
}),
),
);
}
function changeText(change: ProjectChange, detail: ProjectDetail): string {
const what =
change.action === "STAGE_COMPLETE"
? `${stageFlow(detail.project.project_kind).steps[change.stage_no ?? 0] ?? ""} 마침`
: change.action === "PROJECT_CREATE"
? "만듦"
: "정보 고침";
return [change.at.slice(0, 16), change.who, what].filter(Boolean).join(" · ");
}
function changePane(detail: ProjectDetail): HTMLElement | null {
if (!detail.changes.length) return null;
return pane(
"최근 변경",
el("div", {
className: "b01-pd-timeline",
children: detail.changes.map((c) => el("div", { text: changeText(c, detail) })),
}),
);
}
function head(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement {
const p = detail.project;
const { steps: names } = stageFlow(p.project_kind);
const { current } = stageProgress(p);
const sub = [
p.region,
roadType(p.road_type),
p.design_speed_kph && `설계속도 ${p.design_speed_kph} km/h`,
]
.filter(Boolean)
.join(" · ");
const bar = el("div", { className: "b01-pd-head" });
const menu = createMapContextMenu("b01-proj-menu");
const items = rowMenuItems(p, ctx.me);
const more = el("button", {
className: "b01-proj-more",
text: "⋯",
attrs: { type: "button", "aria-label": "더 보기" },
});
more.hidden = !items.length;
more.addEventListener("click", (event) => {
event.stopPropagation();
openMenuAt(menu, bar, more, items);
});
const work = createButton({
label: names.length ? `작업 열기 — ${names[current] ?? names[0]} ›` : "작업 열기 ›",
onClick: () => openStage(p, current),
});
work.dataset.openWork = "";
bar.append(
el("h2", { text: p.name }),
kindBadge(p.project_kind),
el("span", { className: "b01-proj-mini", text: sub }),
el("span", { className: "b01-proj-sp" }),
more,
work,
menu.element,
);
return bar;
}
function render(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement {
const left: HTMLElement[] = [];
const kpis = numbers(detail);
if (detail.route) {
left.push(pane("노선 미리보기", routeMap(detail.route.points), ...(kpis ? [kpis] : [])));
} else if (kpis) {
left.push(pane("핵심 숫자", kpis));
}
const right = [
infoPane(detail),
linkPane(detail, ctx),
outputPane(detail),
changePane(detail),
].filter((x): x is HTMLElement => x !== null);
const crumb = el("button", {
className: "b01-pd-crumb",
text: "프로젝트 ›",
attrs: { type: "button" },
});
crumb.addEventListener("click", () => ctx.go("projects"));
return el("div", {
className: "b01-pd",
children: [
crumb,
head(detail, ctx),
pane("단계 진행 · 누르면 그 단계로", steps(detail)),
el("div", {
className: `b01-pd-grid${left.length ? "" : " is-single"}`,
children: [
el("div", { className: "b01-pd-col", children: left }),
el("div", { className: "b01-pd-col", children: right }),
],
}),
],
});
}
/** 「프로젝트 상세」 — 등록부 mount · id = 주소 `id` */
export function mountProjectDetail(host: HTMLElement, ctx: ProjectViewCtx): () => void {
let alive = true;
const id = currentQuery().get("id") ?? "";
host.replaceChildren(el("p", { className: "b01-proj-empty", text: "불러오는 중…" }));
void fetchProjectDetail(id).then(
(detail) => alive && host.replaceChildren(render(detail, ctx)),
(error) => {
if (!alive) return;
const back = createButton({ label: "프로젝트 목록", onClick: () => ctx.go("projects") });
host.replaceChildren(
el("div", {
className: "b01-proj-empty",
children: [
el("p", { text: error instanceof Error ? error.message : String(error) }),
back,
],
}),
);
},
);
return () => {
alive = false;
};
}
@@ -0,0 +1,95 @@
/* =============================================================================
* B01_Dashboard_UI_ProjectDetail_Map.ts
* 상세 지도 미리보기(PLAN 548-2) — 주소 지도(`UI_MapPreview`)와 같은 배경 타일 길(서버가 VWorld 를 받아 넘김)
* 위에 노선 선 · 시점 · 종점. 라이브러리 없이 SVG 한 장(타일 = image · 선 = path) — 칸 너비에 맞춰 줄어듦.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
const W = 640;
const H = 260;
const TILE = 256;
const PAD = 28;
const MAX_ZOOM = 17;
const MIN_ZOOM = 6;
const NS = "http://www.w3.org/2000/svg";
/** 위경도 → 그 배율의 전역 픽셀(웹 메르카토르) */
function toPixel(lat: number, lon: number, zoom: number): [number, number] {
const size = TILE * 2 ** zoom;
const rad = (lat * Math.PI) / 180;
return [
((lon + 180) / 360) * size,
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * size,
];
}
/** 선이 판(여백 뺌)에 다 드는 가장 큰 배율 */
export function fitZoom(points: [number, number][]): number {
for (let zoom = MAX_ZOOM; zoom > MIN_ZOOM; zoom -= 1) {
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
const xs = px.map((p) => p[0]);
const ys = px.map((p) => p[1]);
if (
Math.max(...xs) - Math.min(...xs) <= W - 2 * PAD &&
Math.max(...ys) - Math.min(...ys) <= H - 2 * PAD
) {
return zoom;
}
}
return MIN_ZOOM;
}
function node<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
): SVGElementTagNameMap[K] {
const element = document.createElementNS(NS, tag);
for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, String(value));
return element;
}
/** 노선 지도 — points = [lat, lon] 두 점 넘게 */
export function routeMap(points: [number, number][]): SVGSVGElement {
const zoom = fitZoom(points);
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
const xs = px.map((p) => p[0]);
const ys = px.map((p) => p[1]);
// 판 왼쪽 위 = 선 가운데 - 판 반
const left = (Math.min(...xs) + Math.max(...xs)) / 2 - W / 2;
const top = (Math.min(...ys) + Math.max(...ys)) / 2 - H / 2;
const svg = node("svg", { viewBox: `0 0 ${W} ${H}`, class: "b01-pd-map" });
const limit = 2 ** zoom;
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + W) / TILE); tx += 1) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + H) / TILE); ty += 1) {
if (tx < 0 || ty < 0 || tx >= limit || ty >= limit) continue;
svg.append(
node("image", {
href: `${API_BASE_URL}/dashboard/map/tile/${zoom}/${tx}/${ty}`,
x: tx * TILE - left,
y: ty * TILE - top,
width: TILE,
height: TILE,
}),
);
}
}
const local = px.map(([x, y]) => [x - left, y - top] as const);
svg.append(
node("path", {
class: "b01-pd-map__line",
d: local.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" "),
}),
);
const ends: [readonly [number, number], string][] = [
[local[0], "시점"],
[local[local.length - 1], "종점"],
];
for (const [[x, y], label] of ends) {
svg.append(node("circle", { class: "b01-pd-map__end", cx: x, cy: y, r: 5 }));
const text = node("text", { class: "b01-pd-map__label", x: x + 8, y: y - 6 });
text.textContent = label;
svg.append(text);
}
return svg;
}
@@ -106,31 +106,15 @@ export function openStage(project: OverviewProject, index: number): void {
if (route) void goToWorkflowStage(project.id, route);
}
/** 썸네일 선 — 줄인 좌표(아무 평면 좌표) 를 칸에 맞춰 SVG 한 줄 · 없으면 빈 판 */
export function thumbSvg(points: [number, number][] | null | undefined, height = 56): SVGElement {
/** 썸네일 선 — 카드 요약(547-1) 0~1 칸 좌표(y 아래로) · 비율 그대로 가운데 · 없으면 빈 판 */
export function thumbSvg(points: number[][] | null | undefined): SVGElement {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", `0 0 200 ${height}`);
svg.setAttribute("viewBox", "-0.1 -0.1 1.2 1.2");
svg.classList.add("b01-proj-thumb");
if (!points || points.length < 2) return svg;
const xs = points.map((p) => p[0]);
const ys = points.map((p) => p[1]);
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const span = Math.max(x1 - x0, y1 - y0) || 1;
const pad = 8;
const sx = (200 - 2 * pad) / span;
const sy = (height - 2 * pad) / span;
const scale = Math.min(sx, sy);
const ox = (200 - (x1 - x0) * scale) / 2;
const oy = (height - (y1 - y0) * scale) / 2;
const d = points
.map(
([x, y], i) =>
`${i ? "L" : "M"}${(ox + (x - x0) * scale).toFixed(1)} ${(oy + (y1 - y) * scale).toFixed(1)}`,
)
.join(" ");
const path = document.createElementNS(ns, "path");
path.setAttribute("d", d);
path.setAttribute("d", points.map(([x, y], i) => `${i ? "L" : "M"}${x} ${y}`).join(" "));
svg.append(path);
return svg;
}
@@ -3,7 +3,11 @@
* 프로젝트 목록 보기 셋(PLAN 548-1) — 카드 · 표(줄 끝 ⋯) · 칸반(칸 = 서버 `board` · 완료 칸 접힘).
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import {
createMapContextMenu,
type MapContextMenu,
type MapContextMenuItem,
} from "@ui/ui_template_context_menu";
import { el } from "@ui/ui_template_elements_base";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import type { OverviewProject } from "./B01_Dashboard_Api_Projects";
@@ -71,7 +75,7 @@ function card(project: OverviewProject, onOpen: Open, compact = false): HTMLElem
}),
],
});
box.append(thumbSvg(project.thumb, compact ? 44 : 64), info);
box.append(thumbSvg(project.thumb), info);
return clickable(box, () => onOpen(project));
}
@@ -82,8 +86,21 @@ export function cardGrid(projects: OverviewProject[], onOpen: Open): HTMLElement
});
}
/** ⋯ 메뉴 — 오른쪽 끝을 단추 오른쪽에 맞춤(host = position 기준 · 메뉴 element 를 품은 것) */
export function openMenuAt(
menu: MapContextMenu,
host: HTMLElement,
button: HTMLElement,
items: MapContextMenuItem[],
): void {
const box = host.getBoundingClientRect();
const at = button.getBoundingClientRect();
menu.open(at.right - box.left, at.bottom - box.top, items);
menu.element.style.left = `${at.right - box.left - menu.element.offsetWidth}px`;
}
/** 줄 끝 ⋯ — 지금 있는 동작만(수정 · 삭제) */
function rowMenuItems(project: OverviewProject, me: DashboardUser): MapContextMenuItem[] {
export function rowMenuItems(project: OverviewProject, me: DashboardUser): MapContextMenuItem[] {
const items: MapContextMenuItem[] = [];
if (canEditProject(me, project)) {
items.push(["수정", () => void openEditProjectModal(me, project)]);
@@ -116,9 +133,7 @@ export function projectRows(
if (!items.length) more.hidden = true;
more.addEventListener("click", (event) => {
event.stopPropagation();
const box = wrap.getBoundingClientRect();
const at = more.getBoundingClientRect();
menu.open(at.right - box.left - 120, at.bottom - box.top, items);
openMenuAt(menu, wrap, more, items);
});
const row = el("tr", {
className: "b01-proj-row",
+59 -9
View File
@@ -12,12 +12,19 @@ import { icon, type IconName } from "@ui/ui_template_icons";
import { currentLanguageIndex, setLanguage } from "@ui/ui_template_locale";
import { readTouchMode, setTouchMode, type TouchMode } from "../A00_Common/device_context";
import { currentQuery, navigateTo, type MountedPage } from "../A00_Common/router";
import { fetchAllCompanies, type CompanyInfo, type DashboardUser } from "./B01_Dashboard_Api_Fetch";
import {
fetchAllCompanies,
fetchCompanyJoinRequests,
type CompanyInfo,
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { fetchTempBatches } from "./B01_Dashboard_Api_Temp";
import { fetchAssignedPlans } from "./B01_Dashboard_UI_AssignedPlans";
import { roleLabel } from "./B01_Dashboard_UI_Common";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat";
import { ADMIN_COMPANY_KEY, pickAdminCompany } from "./B01_Dashboard_UI_Legacy";
import { buildNav, viewHref } from "./B01_Dashboard_UI_Nav";
import { buildNav, viewHref, type DashboardNav } from "./B01_Dashboard_UI_Nav";
import { roleOf, viewFor, VIEWS, type ViewCtx } from "./B01_Dashboard_UI_Views";
import "./B01_Dashboard_UI_Style_Shell.css";
@@ -116,14 +123,19 @@ function buildUserMenu(ctx: ViewCtx, roleText: string): HTMLElement {
menu.hidden = !open;
trigger.setAttribute("aria-expanded", String(open));
};
const dark = document.documentElement.getAttribute("data-theme") === "dark";
// 테마 줄 = 지금과 반대 화면 이름 · 바꾼 뒤 다시 씀
const themeItem = (): HTMLButtonElement => {
const dark = document.documentElement.getAttribute("data-theme") === "dark";
return menuItem(dark ? "sun" : "moon", dark ? "밝은 화면" : "어두운 화면", () => {
toggleTheme();
theme.replaceWith((theme = themeItem()));
});
};
let theme = themeItem();
menu.append(
who,
menuItem("userCircle", "내 정보", () => ctx.go("me")),
menuItem(dark ? "sun" : "moon", dark ? "밝은 화면" : "어두운 화면", () => {
toggleTheme();
setOpen(false);
}),
theme,
menuItem("world", currentLanguageIndex === 0 ? "English" : "한국어", () => {
const next = currentLanguageIndex === 0 ? "en" : "ko";
setLanguage(next);
@@ -183,6 +195,39 @@ function buildCompany(
return b;
}
/** 보관함 만료 임박 = 7 일 안 */
const EXPIRE_SOON_MS = 7 * 24 * 3600 * 1000;
/** 메뉴 배지 · 알림 수 — 가입 요청(회사 관리자 · 시스템 관리자) · 받은 예정노선 미연결 · 업데이트 ·
* 보관함 묶음 수(만료 임박은 처리할 일) · 처리할 일 = 빨강 배지 합 · 실패한 것은 0 */
async function refreshBadges(ctx: ViewCtx, nav: DashboardNav, bell: HTMLElement): Promise<void> {
const admin = ctx.role === "ADMIN" || ctx.role === "SYSTEM_ADMIN";
const [joins, plans, temp] = await Promise.all([
admin
? fetchCompanyJoinRequests(ctx.role === "SYSTEM_ADMIN" ? ctx.companyId : undefined).catch(
() => [],
)
: [],
ctx.role === "FOREST" ? [] : fetchAssignedPlans().catch(() => []),
fetchTempBatches()
.then((r) => r.batches)
.catch(() => []),
]);
const newPlans = plans.filter((p) => !p.linked_project_id || p.update_available).length;
const now = Date.now();
const expiring = temp.filter(
(b) => b.expires_at && Date.parse(b.expires_at) - now < EXPIRE_SOON_MS,
).length;
nav.setBadge("people", joins.length, true);
nav.setBadge("assigned", newPlans, true);
nav.setBadge("temp", temp.length);
const todo = joins.length + newPlans + expiring;
const badge = bell.querySelector<HTMLElement>(".b01-shell__badge")!;
badge.hidden = todo <= 0;
badge.textContent = String(todo);
bell.title = bell.ariaLabel = `처리할 일 ${todo} 건`;
}
export async function mountDashboardShell(
root: HTMLElement,
me: DashboardUser,
@@ -218,8 +263,12 @@ export async function mountDashboardShell(
spacer.className = "b01-shell__spacer";
const help = createHelpButton();
help.classList.add("b01-shell__help-btn");
const bell = iconButton("bell", "알림 (준비 중)");
bell.classList.add("b01-shell__bell");
// 알림 = 처리할 일 수 · 누르면 홈(처리할 일 목록 자리)
const bell = iconButton("bell", "처리할 일", () => ctx.go("home"));
const bellBadge = document.createElement("span");
bellBadge.className = "b01-shell__badge is-alert";
bellBadge.hidden = true;
bell.append(bellBadge);
header.append(
brand,
buildCompany(ctx, companies, (id) => {
@@ -252,6 +301,7 @@ export async function mountDashboardShell(
const mine = ++seq;
const view = viewFor(currentQuery().get("view"), role);
nav.setActive(view.group === "hidden" && view.key === "project" ? "projects" : view.key);
void refreshBadges(ctx, nav, bell);
const host = document.createElement("div");
host.className = "b01-shell__page";
host.dataset.view = view.key;
@@ -0,0 +1,500 @@
/* 대시보드 홈(PLAN 547-2) — 색 · 간격 = 셸 변수(--b01-*) · 종류 색 = 테마 차트 변수 */
.b01-home {
--b01-home-road: var(--b01-accent);
--b01-home-plan: var(--color-chart-0);
--b01-home-stab: var(--color-chart-2);
--b01-home-warn: var(--color-warning);
display: flex;
flex-direction: column;
gap: var(--spacing-16);
color: var(--b01-text);
}
.b01-home__head {
display: flex;
align-items: center;
gap: var(--spacing-12);
flex-wrap: wrap;
}
.b01-home__head h2 {
margin: 0;
font-size: 19px;
}
.b01-home__push {
margin-left: auto;
}
.b01-home__muted {
color: var(--b01-muted);
font-size: 12px;
}
.b01-home__stale {
color: var(--b01-home-warn);
font-size: 12px;
font-weight: 600;
}
.b01-home__alert {
color: var(--b01-alert);
}
.b01-home__seg {
display: inline-flex;
border: 1px solid var(--b01-line);
border-radius: 8px;
overflow: hidden;
}
.b01-home__seg button {
border: 0;
background: var(--b01-card);
color: var(--b01-text);
padding: var(--spacing-4) var(--spacing-12);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.b01-home__seg button.on {
background: var(--b01-accent);
color: var(--b01-accent-text);
}
.b01-home__body {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
gap: var(--spacing-16);
align-items: start;
}
.b01-home__main,
.b01-home__side {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.b01-home__pane {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
padding: var(--spacing-12) var(--spacing-16);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b01-home__pane > .ui-btn {
align-self: flex-start;
}
.b01-home__pane-title {
margin: 0;
font-size: 13px;
}
.b01-home__actions {
display: flex;
gap: var(--spacing-8);
flex-wrap: wrap;
}
/* 종류 배지 · 썸네일 · 단계 점 */
.b01-home__kind--road {
--kind: var(--b01-home-road);
}
.b01-home__kind--plan {
--kind: var(--b01-home-plan);
}
.b01-home__kind--stability {
--kind: var(--b01-home-stab);
}
.b01-home__kind--erosion {
--kind: var(--b01-muted);
}
.b01-home__badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
border-radius: 5px;
padding: 0 6px;
white-space: nowrap;
color: var(--kind);
border: 1px solid var(--kind);
}
.b01-home__title {
display: flex;
align-items: center;
gap: var(--spacing-8);
min-width: 0;
}
.b01-home__title b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-home__thumb {
display: block;
width: 100%;
background: var(--b01-accent-soft);
}
.b01-home__thumb polyline {
fill: none;
stroke: var(--kind);
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
}
.b01-home__thumb--big {
height: 210px;
}
.b01-home__thumb--mid {
height: 100px;
}
.b01-home__thumb--row {
width: 80px;
height: 44px;
border-radius: 6px;
}
.b01-home__thumb--row polyline {
stroke-width: 2;
}
.b01-home__pips {
display: flex;
gap: 4px;
}
.b01-home__pips i {
flex: 1;
height: 7px;
border-radius: 4px;
background: var(--b01-line);
box-sizing: border-box;
}
.b01-home__pips i.ok {
background: var(--kind);
}
.b01-home__pips i.cur {
background: var(--b01-card);
border: 2px solid var(--kind);
}
.b01-home__next {
border: 1px solid var(--b01-home-warn);
border-radius: 8px;
padding: var(--spacing-4) var(--spacing-8);
font-size: 12.5px;
}
/* 1 — 큰 카드 */
.b01-home__big {
padding: 0;
overflow: hidden;
gap: 0;
}
.b01-home__big-top {
display: grid;
grid-template-columns: 1.1fr 1fr;
}
.b01-home__big-info {
padding: var(--spacing-12) var(--spacing-16);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b01-home__big-info .b01-home__title b {
font-size: 17px;
}
.b01-home__metrics {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
border-top: 1px solid var(--b01-line);
}
.b01-home__metrics div {
padding: var(--spacing-8) var(--spacing-12);
border-left: 1px solid var(--b01-line);
}
.b01-home__metrics div:first-child {
border-left: 0;
}
.b01-home__metrics b {
display: block;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.b01-home__metrics span {
color: var(--b01-muted);
font-size: 11.5px;
}
/* 2~3 — 중간 카드 */
.b01-home__mids {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--spacing-12);
}
.b01-home__mid {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
overflow: hidden;
}
.b01-home__mid--first {
border-color: var(--b01-accent);
}
.b01-home__mid-body {
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
/* 4~8 — 한 줄 목록 */
.b01-home__split {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: var(--spacing-12);
align-items: start;
}
.b01-home__split--list {
grid-template-columns: minmax(0, 1fr) 220px;
}
.b01-home__split--list > div {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.b01-home__rows {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
overflow: hidden;
}
.b01-home__row {
display: grid;
grid-template-columns: 80px minmax(0, 1.2fr) minmax(0, 1fr) auto;
gap: var(--spacing-12);
align-items: center;
padding: var(--spacing-8) var(--spacing-12);
border-top: 1px solid var(--b01-line);
cursor: pointer;
}
.b01-home__row:first-child {
border-top: 0;
}
.b01-home__row:hover,
.b01-home__row:focus-visible {
background: var(--b01-hover);
outline: none;
}
.b01-home__row--stale {
box-shadow: inset 3px 0 0 var(--b01-home-warn);
}
.b01-home__member {
display: flex;
align-items: center;
gap: var(--spacing-8);
font-size: 12.5px;
}
.b01-home__avatar {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 700;
background: var(--b01-accent-soft);
color: var(--b01-accent);
}
.b01-home__stack {
display: flex;
height: 10px;
border-radius: 6px;
overflow: hidden;
background: var(--b01-line);
}
.b01-home__stack i {
display: block;
height: 100%;
}
.b01-home__stack--start {
background: var(--b01-accent-soft);
}
.b01-home__stack--working {
background: var(--b01-accent);
}
.b01-home__stack--last {
background: var(--b01-home-warn);
}
/* 9 넘음 — 요약 숫자 · 그래프 */
.b01-home__many {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
.b01-home__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: var(--spacing-12);
}
.b01-home__kpi {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
padding: var(--spacing-8) var(--spacing-12);
}
.b01-home__kpi b {
display: block;
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.b01-home__bars text {
font-size: 10px;
fill: var(--b01-text);
}
.b01-home__bars rect {
fill: var(--b01-accent);
}
.b01-home__bars rect.rest {
fill: var(--b01-accent-soft);
}
.b01-home__donut-row {
display: flex;
align-items: center;
gap: var(--spacing-16);
}
.b01-home__donut circle {
fill: none;
stroke: var(--kind);
stroke-width: 6;
}
.b01-home__donut text {
font-size: 8px;
font-weight: 700;
fill: var(--b01-text);
}
.b01-home__legend {
display: grid;
gap: var(--spacing-4);
font-size: 12px;
}
.b01-home__legend span {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.b01-home__swatch {
width: 9px;
height: 9px;
border-radius: 3px;
background: var(--kind);
}
/* 오른쪽 — 처리할 일 · 처음 세 걸음 */
.b01-home__todo,
.b01-home__step {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--spacing-8);
align-items: center;
font-size: 12.5px;
}
.b01-home__dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.b01-home__dot--plan {
background: var(--b01-home-plan);
}
.b01-home__dot--warn {
background: var(--b01-home-warn);
}
.b01-home__dot--alert {
background: var(--b01-alert);
}
.b01-home__step-no {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
border: 2px solid var(--b01-accent);
color: var(--b01-accent);
}
.b01-home__step--done .b01-home__step-no {
background: var(--b01-accent);
color: var(--b01-accent-text);
}
@media (max-width: 900px) {
.b01-home__body,
.b01-home__split,
.b01-home__split--list,
.b01-home__big-top {
grid-template-columns: 1fr;
}
.b01-home__row {
grid-template-columns: 60px minmax(0, 1fr) auto;
}
.b01-home__row-stage {
display: none;
}
}
+187 -10
View File
@@ -7,18 +7,18 @@
.b01-pd {
--pj-accent: var(--b01-accent, var(--color-primary, #1f7a4d));
--pj-accent-soft: var(--b01-accent-soft, color-mix(in srgb, var(--pj-accent) 12%, transparent));
--pj-surface: var(--b01-surface, var(--color-surface, #fff));
--pj-sunken: var(--b01-surface-sunken, var(--color-surface-sunken, #eef0f3));
--pj-border: var(--b01-border, var(--color-border, #e6e8ec));
--pj-surface: var(--b01-card, var(--color-surface, #fff));
--pj-sunken: var(--b01-bg, var(--color-surface-sunken, #eef0f3));
--pj-border: var(--b01-line, var(--color-border, #e6e8ec));
--pj-text: var(--b01-text, var(--color-text, #1f2328));
--pj-muted: var(--b01-text-muted, var(--color-text-muted, #6b7280));
--pj-muted: var(--b01-muted, var(--color-text-muted, #6b7280));
--pj-radius: var(--b01-radius, var(--radius-cards, 12px));
--pj-gap: var(--b01-gap, var(--spacing-16, 14px));
--pj-road: var(--b01-kind-road, var(--pj-accent));
--pj-plan: var(--b01-kind-plan, #2f5fbf);
--pj-stab: var(--b01-kind-stab, #c26a0f);
--pj-warn: var(--b01-warn, #d97706);
--pj-danger: var(--b01-danger, var(--color-danger, #e5484d));
--pj-gap: var(--spacing-16, 14px);
--pj-road: var(--pj-accent);
--pj-plan: #2f5fbf;
--pj-stab: #c26a0f;
--pj-warn: #d97706;
--pj-danger: var(--b01-alert, var(--color-danger, #e5484d));
color: var(--pj-text);
font-size: var(--text-body-sm, 13.5px);
}
@@ -349,3 +349,180 @@
grid-template-columns: 1fr 1fr;
}
}
/* ── 상세 ────────────────────────────────────────────────────────────────── */
.b01-pd-crumb {
border: 0;
background: none;
padding: 0;
color: var(--pj-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
margin-bottom: 4px;
}
.b01-pd-head {
position: relative;
}
.b01-pd-pane {
background: var(--pj-surface);
border: 1px solid var(--pj-border);
border-radius: var(--pj-radius);
padding: 14px 16px;
margin-bottom: var(--pj-gap);
}
.b01-pd-pane h3 {
margin: 0 0 10px;
font-size: 14px;
}
.b01-pd-steps {
display: grid;
grid-template-columns: repeat(var(--n, 7), minmax(0, 1fr));
gap: 6px;
}
.b01-pd-step {
border: 1px solid var(--pj-border);
border-radius: 9px;
padding: 7px 4px;
background: var(--pj-surface);
color: var(--pj-text);
font: inherit;
font-size: 11.5px;
text-align: center;
cursor: pointer;
}
.b01-pd-step:hover {
border-color: var(--pj-accent);
}
.b01-pd-step span {
display: grid;
place-items: center;
width: 20px;
height: 20px;
margin: 0 auto 4px;
border-radius: 50%;
background: var(--pj-sunken);
color: var(--pj-muted);
font-size: 11px;
font-weight: 700;
}
.b01-pd-step b {
font-weight: 500;
}
.b01-pd-step.is-done span {
background: var(--pj-accent);
color: #fff;
}
.b01-pd-step.is-cur {
border-color: var(--pj-accent);
background: var(--pj-accent-soft);
}
.b01-pd-step.is-cur span {
background: var(--pj-surface);
color: var(--pj-accent);
border: 2px solid var(--pj-accent);
}
.b01-pd-step.is-stale span {
background: #d97706;
color: #fff;
}
.b01-pd-grid {
display: grid;
grid-template-columns: 1.55fr 1fr;
gap: var(--pj-gap);
align-items: start;
}
.b01-pd-grid.is-single {
grid-template-columns: 1fr;
}
.b01-pd-grid.is-single > .b01-pd-col:first-child {
display: none;
}
.b01-pd-map {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
background: var(--pj-sunken);
}
.b01-pd-map__line {
fill: none;
stroke: #c0392b;
stroke-width: 3.5;
stroke-linejoin: round;
}
.b01-pd-map__end {
fill: #fff;
stroke: #c0392b;
stroke-width: 2;
}
.b01-pd-map__label {
font-size: 12px;
fill: #1f2328;
paint-order: stroke;
stroke: #fff;
stroke-width: 3px;
}
.b01-pd-kpis {
display: flex;
flex-wrap: wrap;
gap: 22px;
margin-top: 10px;
}
.b01-pd-kpi b {
display: block;
font-size: 22px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.b01-pd-kpi span {
color: var(--pj-muted);
font-size: 12px;
}
.b01-pd-kv {
display: grid;
grid-template-columns: 96px 1fr;
gap: 6px 10px;
}
.b01-pd-kv span {
color: var(--pj-muted);
}
.b01-pd-link {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-top: 1px solid var(--pj-border);
}
.b01-pd-link:first-of-type {
border-top: 0;
}
.b01-pd-timeline {
border-left: 2px solid var(--pj-border);
margin-left: 4px;
padding-left: 12px;
}
.b01-pd-timeline div {
position: relative;
padding: 4px 0;
font-size: 12.5px;
}
.b01-pd-timeline div::before {
content: "";
position: absolute;
left: -17px;
top: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--pj-accent);
}
@media (max-width: 900px) {
.b01-pd-grid {
grid-template-columns: 1fr;
}
.b01-pd-steps {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@@ -22,6 +22,10 @@
--color-border: var(--b01-line);
--color-primary: var(--b01-accent);
--color-primary-text: var(--b01-accent-text);
--color-text: var(--b01-text);
--color-text-body: var(--b01-text);
--color-text-secondary: var(--b01-muted);
--color-text-muted: var(--b01-muted);
min-height: 100dvh;
display: grid;
+6 -3
View File
@@ -10,6 +10,7 @@
import type { IconName } from "@ui/ui_template_icons";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import { mountHome } from "./B01_Dashboard_UI_Home";
import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy";
import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav";
import { mountAssigned } from "./B01_Dashboard_UI_View_Assigned";
@@ -17,6 +18,8 @@ import { mountCompany } from "./B01_Dashboard_UI_View_Company";
import { mountCompanyDefaults } from "./B01_Dashboard_UI_View_CompanyDefaults";
import { mountPeople } from "./B01_Dashboard_UI_View_People";
import { mountTemp } from "./B01_Dashboard_UI_View_Temp";
import { mountProjectDetail } from "./B01_Dashboard_UI_ProjectDetail";
import { mountProjectList } from "./B01_Dashboard_UI_ProjectList";
/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN";
@@ -51,9 +54,9 @@ const legacy =
// prettier-ignore
export const VIEWS: View[] = [
// 작업
{ key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: legacy("projects") },
{ key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: legacy("projects") },
{ key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: legacy("projects") },
{ key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: mountHome },
{ key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: mountProjectList },
{ key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: mountProjectDetail },
{ key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: mountAssigned },
{ key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: mountTemp },
// 회사 관리
@@ -0,0 +1,80 @@
/* =============================================================================
* logic/B01_Dashboard_Home_Layout.ts
* 홈 꼴 고르기 · 묶어 세기(PLAN 547-2) — 순수 함수 · 시험 `test_547_2_home_layout.py`
* 진행 중 1 = one · 2~3 = few · 4~8 = list · 9 넘음 = many · 0 = empty
* 고정 small = list(9 넘음 = many) · large = one(1) / few(2 넘음)
* ========================================================================== */
export type HomeLayout = "empty" | "one" | "few" | "list" | "many";
export type HomeFixed = "auto" | "small" | "large";
export const STALE_DAYS = 14;
export const MANY_FROM = 9;
export function homeLayout(count: number, fixed: HomeFixed = "auto"): HomeLayout {
if (count <= 0) return "empty";
if (fixed === "large") return count === 1 ? "one" : "few";
if (fixed === "small") return count >= MANY_FROM ? "many" : "list";
if (count === 1) return "one";
if (count <= 3) return "few";
return count < MANY_FROM ? "list" : "many";
}
interface StageLike {
stage_index: number;
stage_total: number;
stale_days?: number | null;
}
export const isStale = (card: StageLike): boolean => (card.stale_days ?? 0) >= STALE_DAYS;
/** 단계 분포 — 시작 전(첫 단계) · 설계 중 · 확정 대기(마지막 단계) */
export function stageBuckets(cards: StageLike[]): { start: number; working: number; last: number } {
const out = { start: 0, working: 0, last: 0 };
for (const c of cards) {
if (c.stage_index <= 0) out.start += 1;
else if (c.stage_index >= c.stage_total - 1) out.last += 1;
else out.working += 1;
}
return out;
}
/** 묶어 세기 — 많은 차례 · 같으면 이름 차례 */
export function countBy<T>(
items: T[],
key: (item: T) => string,
): { label: string; count: number }[] {
const counts = new Map<string, number>();
for (const item of items) counts.set(key(item), (counts.get(key(item)) ?? 0) + 1);
return [...counts]
.map(([label, count]) => ({ label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}
/** 앞 limit 개 + 나머지 하나로 묶음(「그 밖 N 곳」) */
export function topWithRest(
rows: { label: string; count: number }[],
limit: number,
): { label: string; count: number; rest?: number }[] {
if (rows.length <= limit) return rows;
const rest = rows.slice(limit - 1);
return [
...rows.slice(0, limit - 1),
{ label: "", count: rest.reduce((sum, r) => sum + r.count, 0), rest: rest.length },
];
}
/** 원 그래프 조각 — 둘레 100 기준 stroke-dasharray · stroke-dashoffset(12 시 시작 · 시계 방향) */
export function donutArcs(values: number[]): { dash: string; offset: number }[] {
const total = values.reduce((sum, v) => sum + v, 0);
if (total <= 0) return [];
let start = 0;
return values.map((v) => {
const len = (v / total) * 100;
const arc = { dash: `${round2(len)} ${round2(100 - len)}`, offset: round2(25 - start) };
start += len;
return arc;
});
}
const round2 = (v: number): number => Math.round(v * 100) / 100;
+22 -9
View File
@@ -24,16 +24,16 @@ def _text(path: Path) -> str:
def _run_views(body: str) -> object:
raw = _text(B01 / "B01_Dashboard_UI_Views.ts")
src = re.sub(r"^import[\s\S]*?;\n", "", raw, flags=re.M)
# 값으로 가져오는 이름(mount 함수들)은 빈 함수로 — 타입 가져오기는 건너뜀
names = [
n.split(" as ")[-1].strip()
for m in re.finditer(r"^import\s+\{([^}]*)\}\s+from[^;]*;", raw, flags=re.M)
for n in m.group(1).split(",")
if n.strip() and not n.strip().startswith("type ")
text = _text(B01 / "B01_Dashboard_UI_Views.ts")
# import 한 값(type 아닌 이름) = 빈 함수 — 페이지 줄이 새 mount 를 끌어와도 그대로 돎
stubs = [
name.split(" as ")[-1].strip()
for group in re.findall(r"^import \{([^}]*)\}", text, flags=re.M)
for name in group.split(",")
if name.strip() and not name.strip().startswith("type ")
]
script = "".join(f"const {n} = () => {{}}; " for n in names) + "\n" + src + "\n" + body
src = re.sub(r"^import[\s\S]*?;\n", "", text, flags=re.M)
script = "".join(f"const {n} = () => {{}};\n" for n in stubs) + src + "\n" + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
@@ -107,3 +107,16 @@ def test_셸_한_벌():
assert ':root[data-theme="dark"] .b01-shell {' in css
icons = _text(ROOT / "ui_template" / "ui_template_icons.ts")
assert "MIT License" in icons and "Paweł Kuna" in icons
def test_메뉴_배지_알림():
shell = _text(B01 / "B01_Dashboard_UI_Shell.ts")
badges = shell.split("async function refreshBadges")[1].split("\n}\n")[0]
assert 'nav.setBadge("people", joins.length, true);' in badges # 가입 요청 = 빨강
assert "!p.linked_project_id || p.update_available" in badges # 받은 예정노선 새로 · 업데이트
assert 'nav.setBadge("temp", temp.length);' in badges
assert "const todo = joins.length + newPlans + expiring;" in badges # 알림 = 처리할 일 합
assert "void refreshBadges(ctx, nav, bell);" in shell # 페이지 바뀔 때마다 다시 셈
css = _text(B01 / "B01_Dashboard_UI_Style_Shell.css")
assert "@media (max-width: 1024px)" in css # 작은 화면 = 아이콘 레일
assert "html:has(.b01-shell) .app-header" in css # 옛 ☰ 걷음
+152
View File
@@ -0,0 +1,152 @@
"""547-2 대시보드 홈 — 개수별 꼴 고르기(TS logic) · 역할 가르기 · 역할별 홈 API 칸."""
import asyncio
import json
import re
import subprocess
from pathlib import Path
import pytest
import B01_Dashboard.B01_Dashboard_Router_Home as home
ROOT = Path(__file__).resolve().parents[2]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
LOGIC = ROOT / "B01_Dashboard" / "logic" / "B01_Dashboard_Home_Layout.ts"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as m from "./B01_Dashboard_Home_Layout.js";
const counts = [0, 1, 2, 3, 4, 8, 9, 40];
const out = {
auto: counts.map((n) => m.homeLayout(n)),
small: counts.map((n) => m.homeLayout(n, "small")),
large: counts.map((n) => m.homeLayout(n, "large")),
buckets: m.stageBuckets([
{ stage_index: 0, stage_total: 7 }, { stage_index: 3, stage_total: 7 },
{ stage_index: 6, stage_total: 7 }, { stage_index: 2, stage_total: 3 },
]),
stale: [m.isStale({ stage_index: 1, stage_total: 7, stale_days: 13 }),
m.isStale({ stage_index: 1, stage_total: 7, stale_days: 14 })],
count: m.countBy(["나", "가", "나"], (x) => x),
rest: m.topWithRest([5, 4, 3, 2, 1, 1].map((c, i) => ({ label: "c" + i, count: c })), 5),
donut: m.donutArcs([1, 1, 2]),
};
writeFileSync(process.argv[2], JSON.stringify(out));
"""
@pytest.fixture(scope="module")
def ts(tmp_path_factory):
if not TSC.is_file():
pytest.skip("프론트엔드 의존성 미설치")
tmp = tmp_path_factory.mktemp("home")
out = tmp / "js"
args = ["--outDir", str(out), "--module", "esnext", "--target", "es2022", "--ignoreConfig"]
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(LOGIC), *args], cwd=str(ROOT), check=True, capture_output=True
)
for emitted in out.glob("*.js"):
text = emitted.read_text(encoding="utf-8")
emitted.write_text(re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', text), encoding="utf-8")
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
dst = tmp / "out.json"
subprocess.run(["node", str(out / "runner.mjs"), str(dst)], check=True, capture_output=True) # noqa: S603, S607
return json.loads(dst.read_text(encoding="utf-8"))
def test_layout_by_count(ts):
# 개수 0 1 2 3 4 8 9 40
assert ts["auto"] == ["empty", "one", "few", "few", "list", "list", "many", "many"]
assert ts["small"] == ["empty", "list", "list", "list", "list", "list", "many", "many"]
assert ts["large"] == ["empty", "one", "few", "few", "few", "few", "few", "few"]
def test_counts_and_charts(ts):
assert ts["buckets"] == {"start": 1, "working": 1, "last": 2}
assert ts["stale"] == [False, True]
assert ts["count"] == [{"label": "나", "count": 2}, {"label": "가", "count": 1}]
assert ts["rest"][-1] == {"label": "", "count": 2, "rest": 2} and len(ts["rest"]) == 5
assert [a["dash"] for a in ts["donut"]] == ["25 75", "25 75", "50 50"]
assert [a["offset"] for a in ts["donut"]] == [25, 0, -25]
def test_home_role_order_matches_shell():
s = {"email": "a@forest.go.kr"}
assert home.home_role({**s, "role": "SYSTEM_ADMIN"}) == "SYSTEM_ADMIN"
assert home.home_role({**s, "role": "ADMIN"}) == "ADMIN"
assert home.home_role({**s, "role": "USER"}) == "FOREST"
assert home.home_role({"email": "a@b.com", "role": "USER"}) == "USER"
class _Cursor:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql, args=()):
self.sql = sql
async def fetchone(self):
return {"n": 2, "name": "회사", "member_count": 3, "companies": 4, "users": 9}
async def fetchall(self):
if "temp_upload_batches" in self.sql or "plan_route_years" in self.sql:
return []
return [{"id": 1, "name": "회사"}]
class _Pool:
def acquire(self):
class _Conn:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
def cursor(self, *_):
return _Cursor()
return _Conn()
@pytest.mark.parametrize(
("session", "keys", "join"),
[
({"role": "SYSTEM_ADMIN", "user_id": 1, "company_id": 1}, {"system"}, True),
({"role": "ADMIN", "user_id": 1, "company_id": 1}, {"company"}, True),
({"role": "USER", "user_id": 1, "company_id": 4, "email": "x@a.go.kr"}, {"forest"}, False),
({"role": "USER", "user_id": 1, "company_id": 1, "email": "x@a.com"}, set(), False),
],
)
def test_home_blocks_by_role(monkeypatch, session, keys, join):
async def fake_ids(_session, _role):
return ["a", "b"]
async def fake_summaries(ids):
return [
{"id": i, "done": i == "b", "thumb": [[0, 0], [1, 1]]} for i in ids
] # b = 완료 → 빠짐
async def fake_plans(_session):
return {"routes": []}
async def fake_resources(days):
return {"current": {"cpu_usage_percent": 1, "memory_usage_percent": 2}}
import D01_PlanRegister.D01_PlanRegister_Router as d01
monkeypatch.setattr(home, "home_project_ids", fake_ids)
monkeypatch.setattr(home, "project_summaries", fake_summaries)
monkeypatch.setattr(home, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(home, "get_system_resources", fake_resources)
monkeypatch.setattr(d01, "assigned_plan_routes", fake_plans)
body = asyncio.run(home.dashboard_home(session=session))
assert {"system", "company", "forest"} & body.keys() == keys
assert [p["id"] for p in body["projects"]] == ["a"] and body["project_total"] == 2
assert ("join_requests" in body["todos"]) is join
if "system" in body:
assert body["system"]["server"] == {"cpu": 1, "memory": 2, "disk": None}
@@ -0,0 +1,76 @@
"""548 프로젝트 목록 · 상세 — 칸반 나눔 규칙 · 산출물 · 내 것 · 단계 수 짝(서버 · 화면 · 요약)."""
import re
from datetime import datetime, timedelta
from pathlib import Path
from B01_Dashboard.B01_Dashboard_Router_Overview import (
STAGE_TOTAL,
board_column,
idle_days,
is_mine,
project_outputs,
)
from B01_Dashboard.B01_Dashboard_Summary import STAGE_NAMES
ROOT = Path(__file__).resolve().parents[2]
def stages(*states: str) -> list[dict]:
return [{"stage_no": i, "state": s} for i, s in enumerate(states)]
N, P, C, S, F = "NOT_STARTED", "IN_PROGRESS", "COMPLETE", "STALE", "FAILED"
def test_board_column_road():
assert board_column("ROAD", stages(N, N, N, N, N, N, N)) == "start"
assert board_column("ROAD", []) == "start"
assert board_column("ROAD", stages(P, N, N, N, N, N, N)) == "design"
assert board_column("ROAD", stages(C, C, C, P, N, N, N)) == "design"
assert board_column("ROAD", stages(C, C, P, S, N, N, N)) == "review"
assert board_column("ROAD", stages(C, F, N, N, N, N, N)) == "review"
assert board_column("ROAD", stages(C, C, C, C, C, C, P)) == "confirm"
assert board_column("ROAD", stages(C, C, C, C, C, C, N)) == "confirm"
assert board_column("ROAD", stages(C, C, C, C, C, C, C)) == "done"
# 마지막만 마침이어도 완료(앞이 STALE 이어도)
assert board_column("ROAD", stages(C, S, C, C, C, C, C)) == "done"
def test_board_column_other_kinds():
assert board_column("PLAN", stages(C, C, P)) == "confirm"
assert board_column("PLAN", stages(C, C, C)) == "done"
assert board_column("STABILITY", stages(C, P, N, N, N, N)) == "design"
assert board_column("STABILITY", stages(N, N, N, N, N)) == "start"
# 모르는 종류(사방) = 단계 없음 → 시작 전 · 설계 중
assert board_column("EROSION", []) == "start"
def test_outputs_only_when_ready():
assert project_outputs("PLAN", stages(C, C, C)) == []
assert project_outputs("ROAD", stages(C, C, C, P, N, N, N)) == []
keys = lambda st: [o["key"] for o in project_outputs("ROAD", st)] # noqa: E731
assert keys(stages(C, C, C, C, P, N, N)) == ["drawings"]
assert keys(stages(C, C, C, C, C, C, P)) == ["drawings", "quantity", "cost"]
assert keys(stages(C, C, C, C, C, S, S)) == ["drawings", "cost"]
def test_is_mine_and_idle():
row = {"user_id": 1, "pm_user_id": 2, "field_lead_user_id": None, "designer_user_id": 3}
assert all(is_mine({**row, "member_user_ids": []}, uid) for uid in (1, 2, 3))
assert is_mine({**row, "member_user_ids": [9]}, 9)
assert not is_mine({**row, "member_user_ids": [9]}, 8)
now = datetime(2026, 10, 26, 12)
assert idle_days(now - timedelta(days=15, hours=1), now) == 15
assert idle_days(None, now) is None
def test_stage_total_pairs():
"""단계 수 = 화면 단계 막대(group_workflow · workflowSteps) = 카드 요약(547-1)."""
group = (ROOT / "A00_Common/group_workflow.ts").read_text(encoding="utf-8")
labels = re.findall(r"labels:\s*\[(.*?)\]", group, re.S)
plan, stab = (len(re.findall(r'"DE_Step_\w+"', block)) for block in labels)
scaffold = (ROOT / "A00_Common/b_page_scaffold.ts").read_text(encoding="utf-8")
road = re.search(r"function workflowSteps\(\)[^{]*\{\s*return \[(.*?)\]", scaffold, re.S).group(1)
assert STAGE_TOTAL == {"ROAD": road.count("L("), "PLAN": plan, "STABILITY": stab}
assert {k: len(v) for k, v in STAGE_NAMES.items()} == STAGE_TOTAL