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; board: BoardColumn;
idle_days: number | null; idle_days: number | null;
stalled: boolean; stalled: boolean;
/** 썸네일 선(줄인 평면 좌표) — 547-1 카드 요약 */ /** 썸네일 선(0~1 칸 · y 아래로) — 547-1 카드 요약 */
thumb?: [number, number][] | null; thumb?: number[][] | null;
metrics?: { length_m?: number; stations?: number; structures?: number };
} }
/** 지도 선 — 위경도 [lat, lon] */ /** 지도 선 — 위경도 [lat, lon] */
@@ -47,6 +48,7 @@ export interface ProjectChange {
at: string; at: string;
who?: string | null; who?: string | null;
action: string; action: string;
stage_no?: number;
} }
export interface ProjectDetail { export interface ProjectDetail {
+159 -1
View File
@@ -1,18 +1,23 @@
"""B01 대시보드 홈 API (PLAN 547) — 프로젝트 카드 요약 · 역할별 홈.""" """B01 대시보드 홈 API (PLAN 547) — 프로젝트 카드 요약 · 역할별 홈."""
from datetime import datetime, timedelta
from typing import Any from typing import Any
import aiomysql import aiomysql
from fastapi import APIRouter, Depends, Query 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 config.config_db import get_db_pool
from .B01_Dashboard_Repository import get_system_resources
from .B01_Dashboard_Summary import project_summaries from .B01_Dashboard_Summary import project_summaries
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"]) router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
SUMMARY_LIMIT = 200 SUMMARY_LIMIT = 200
# 홈 카드 썸네일은 앞 8 개만(9 넘음 꼴은 그림 대신 숫자 · 그래프)
HOME_THUMBS = 8
EXPIRE_SOON_DAYS = 7
async def visible_project_ids(session: dict[str, Any]) -> list[str]: 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"]),) where, args = where + " AND p.company_id = %s", (int(session["company_id"]),)
else: else:
where, args = where + " AND p.user_id = %s", (int(session["user_id"]),) 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() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute( await cursor.execute(
@@ -45,3 +54,152 @@ async def projects_summary(
wanted = dict.fromkeys(pid.strip() for pid in ids.split(",")) wanted = dict.fromkeys(pid.strip() for pid in ids.split(","))
visible = [pid for pid in wanted if pid in allowed] visible = [pid for pid in wanted if pid in allowed]
return {"status": "success", "projects": await project_summaries(visible[:SUMMARY_LIMIT])} 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 config.config_db import get_db_pool
from .B01_Dashboard_Repository import _project_rows from .B01_Dashboard_Repository import _project_rows
from .B01_Dashboard_Summary import project_summaries
router = APIRouter(prefix="/api/dashboard/overview", tags=["B01_Dashboard"]) 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()) rows = await _project_rows(cursor, await cursor.fetchall())
if scope == "mine": if scope == "mine":
rows = [r for r in rows if is_mine(r, int(session["user_id"]))] 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: 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]: 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 pyproj import Transformer
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj 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 return [], None
def _numbers(root: Path | None, length_m: float | None, stations: int) -> list[dict[str, Any]]: def _numbers(metrics: dict[str, Any]) -> list[dict[str, Any]]:
"""핵심 숫자 — 있는 것만(연장 · 측점 · 구조물).""" """핵심 숫자 — 카드 요약 값 가운데 있는 것만(연장 · 측점 · 구조물)."""
from B05_Profile.B05_Profile_Structures_Repository import load_structures keys = (
("length_m", "length", "m"),
out: list[dict[str, Any]] = [] ("stations", "stations", None),
if length_m: ("structures", "structures", None),
out.append({"key": "length", "value": round(float(length_m), 1), "unit": "m"}) )
if stations: return [
out.append({"key": "stations", "value": stations}) {"key": key, "value": metrics[name], **({"unit": unit} if unit else {})}
if root is not None: for name, key, unit in keys
count = len(load_structures(str(root))[1]) if metrics.get(name)
if count: ]
out.append({"key": "structures", "value": count})
return out
def project_outputs(kind: str, stages: list[dict[str, Any]]) -> list[dict[str, str]]: 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: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
raw = await _visible_project(cursor, session, project_id) raw = await _visible_project(cursor, session, project_id)
row = _decorate((await _project_rows(cursor, [raw]))[0]) 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) links = await _links(cursor, row)
changes = await _changes(cursor, project_id, row["workflow_state"]["stages"]) changes = await _changes(cursor, project_id, row["workflow_state"]["stages"])
try: try:
root = Path(resolve_stored_project_path(raw["storage_path"])) root = Path(resolve_stored_project_path(raw["storage_path"]))
except (ValueError, TypeError, AttributeError): except (ValueError, TypeError, AttributeError):
root = None 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 = { people = {
"pm": row["manager_name"] if row.get("pm_user_id") else None, "pm": row["manager_name"] if row.get("pm_user_id") else None,
"field_lead": row.pop("field_lead_name", None), "field_lead": row.pop("field_lead_name", None),
@@ -317,7 +320,7 @@ async def overview_project_detail(
"project": row, "project": row,
"people": people, "people": people,
"route": {"points": line} if line else None, "route": {"points": line} if line else None,
"numbers": _numbers(root, length, stations), "numbers": _numbers(summary.get("metrics", {})),
"links": links, "links": links,
"outputs": project_outputs(row["project_kind"], row["workflow_state"]["stages"]), "outputs": project_outputs(row["project_kind"], row["workflow_state"]["stages"]),
"changes": changes, "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); if (route) void goToWorkflowStage(project.id, route);
} }
/** 썸네일 선 — 줄인 좌표(아무 평면 좌표) 를 칸에 맞춰 SVG 한 줄 · 없으면 빈 판 */ /** 썸네일 선 — 카드 요약(547-1) 0~1 칸 좌표(y 아래로) · 비율 그대로 가운데 · 없으면 빈 판 */
export function thumbSvg(points: [number, number][] | null | undefined, height = 56): SVGElement { export function thumbSvg(points: number[][] | null | undefined): SVGElement {
const ns = "http://www.w3.org/2000/svg"; const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "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"); svg.classList.add("b01-proj-thumb");
if (!points || points.length < 2) return svg; 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"); 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); svg.append(path);
return svg; return svg;
} }
@@ -3,7 +3,11 @@
* 프로젝트 목록 보기 셋(PLAN 548-1) — 카드 · 표(줄 끝 ⋯) · 칸반(칸 = 서버 `board` · 완료 칸 접힘). * 프로젝트 목록 보기 셋(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 { el } from "@ui/ui_template_elements_base";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import type { OverviewProject } from "./B01_Dashboard_Api_Projects"; 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)); 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[] = []; const items: MapContextMenuItem[] = [];
if (canEditProject(me, project)) { if (canEditProject(me, project)) {
items.push(["수정", () => void openEditProjectModal(me, project)]); items.push(["수정", () => void openEditProjectModal(me, project)]);
@@ -116,9 +133,7 @@ export function projectRows(
if (!items.length) more.hidden = true; if (!items.length) more.hidden = true;
more.addEventListener("click", (event) => { more.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
const box = wrap.getBoundingClientRect(); openMenuAt(menu, wrap, more, items);
const at = more.getBoundingClientRect();
menu.open(at.right - box.left - 120, at.bottom - box.top, items);
}); });
const row = el("tr", { const row = el("tr", {
className: "b01-proj-row", 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 { currentLanguageIndex, setLanguage } from "@ui/ui_template_locale";
import { readTouchMode, setTouchMode, type TouchMode } from "../A00_Common/device_context"; import { readTouchMode, setTouchMode, type TouchMode } from "../A00_Common/device_context";
import { currentQuery, navigateTo, type MountedPage } from "../A00_Common/router"; 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 { roleLabel } from "./B01_Dashboard_UI_Common";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick"; import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat"; import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat";
import { ADMIN_COMPANY_KEY, pickAdminCompany } from "./B01_Dashboard_UI_Legacy"; 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 { roleOf, viewFor, VIEWS, type ViewCtx } from "./B01_Dashboard_UI_Views";
import "./B01_Dashboard_UI_Style_Shell.css"; import "./B01_Dashboard_UI_Style_Shell.css";
@@ -116,14 +123,19 @@ function buildUserMenu(ctx: ViewCtx, roleText: string): HTMLElement {
menu.hidden = !open; menu.hidden = !open;
trigger.setAttribute("aria-expanded", String(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( menu.append(
who, who,
menuItem("userCircle", "내 정보", () => ctx.go("me")), menuItem("userCircle", "내 정보", () => ctx.go("me")),
menuItem(dark ? "sun" : "moon", dark ? "밝은 화면" : "어두운 화면", () => { theme,
toggleTheme();
setOpen(false);
}),
menuItem("world", currentLanguageIndex === 0 ? "English" : "한국어", () => { menuItem("world", currentLanguageIndex === 0 ? "English" : "한국어", () => {
const next = currentLanguageIndex === 0 ? "en" : "ko"; const next = currentLanguageIndex === 0 ? "en" : "ko";
setLanguage(next); setLanguage(next);
@@ -183,6 +195,39 @@ function buildCompany(
return b; 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( export async function mountDashboardShell(
root: HTMLElement, root: HTMLElement,
me: DashboardUser, me: DashboardUser,
@@ -218,8 +263,12 @@ export async function mountDashboardShell(
spacer.className = "b01-shell__spacer"; spacer.className = "b01-shell__spacer";
const help = createHelpButton(); const help = createHelpButton();
help.classList.add("b01-shell__help-btn"); 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( header.append(
brand, brand,
buildCompany(ctx, companies, (id) => { buildCompany(ctx, companies, (id) => {
@@ -252,6 +301,7 @@ export async function mountDashboardShell(
const mine = ++seq; const mine = ++seq;
const view = viewFor(currentQuery().get("view"), role); const view = viewFor(currentQuery().get("view"), role);
nav.setActive(view.group === "hidden" && view.key === "project" ? "projects" : view.key); nav.setActive(view.group === "hidden" && view.key === "project" ? "projects" : view.key);
void refreshBadges(ctx, nav, bell);
const host = document.createElement("div"); const host = document.createElement("div");
host.className = "b01-shell__page"; host.className = "b01-shell__page";
host.dataset.view = view.key; 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 { .b01-pd {
--pj-accent: var(--b01-accent, var(--color-primary, #1f7a4d)); --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-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-surface: var(--b01-card, var(--color-surface, #fff));
--pj-sunken: var(--b01-surface-sunken, var(--color-surface-sunken, #eef0f3)); --pj-sunken: var(--b01-bg, var(--color-surface-sunken, #eef0f3));
--pj-border: var(--b01-border, var(--color-border, #e6e8ec)); --pj-border: var(--b01-line, var(--color-border, #e6e8ec));
--pj-text: var(--b01-text, var(--color-text, #1f2328)); --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-radius: var(--b01-radius, var(--radius-cards, 12px));
--pj-gap: var(--b01-gap, var(--spacing-16, 14px)); --pj-gap: var(--spacing-16, 14px);
--pj-road: var(--b01-kind-road, var(--pj-accent)); --pj-road: var(--pj-accent);
--pj-plan: var(--b01-kind-plan, #2f5fbf); --pj-plan: #2f5fbf;
--pj-stab: var(--b01-kind-stab, #c26a0f); --pj-stab: #c26a0f;
--pj-warn: var(--b01-warn, #d97706); --pj-warn: #d97706;
--pj-danger: var(--b01-danger, var(--color-danger, #e5484d)); --pj-danger: var(--b01-alert, var(--color-danger, #e5484d));
color: var(--pj-text); color: var(--pj-text);
font-size: var(--text-body-sm, 13.5px); font-size: var(--text-body-sm, 13.5px);
} }
@@ -349,3 +349,180 @@
grid-template-columns: 1fr 1fr; 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-border: var(--b01-line);
--color-primary: var(--b01-accent); --color-primary: var(--b01-accent);
--color-primary-text: var(--b01-accent-text); --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; min-height: 100dvh;
display: grid; display: grid;
+6 -3
View File
@@ -10,6 +10,7 @@
import type { IconName } from "@ui/ui_template_icons"; import type { IconName } from "@ui/ui_template_icons";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; 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 { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy";
import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav"; import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav";
import { mountAssigned } from "./B01_Dashboard_UI_View_Assigned"; 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 { mountCompanyDefaults } from "./B01_Dashboard_UI_View_CompanyDefaults";
import { mountPeople } from "./B01_Dashboard_UI_View_People"; import { mountPeople } from "./B01_Dashboard_UI_View_People";
import { mountTemp } from "./B01_Dashboard_UI_View_Temp"; 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 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */ /** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN"; export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN";
@@ -51,9 +54,9 @@ const legacy =
// prettier-ignore // prettier-ignore
export const VIEWS: View[] = [ export const VIEWS: View[] = [
// 작업 // 작업
{ key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: legacy("projects") }, { key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: mountHome },
{ key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: legacy("projects") }, { key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: mountProjectList },
{ key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: legacy("projects") }, { key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: mountProjectDetail },
{ key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: mountAssigned }, { 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 }, { 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: def _run_views(body: str) -> object:
raw = _text(B01 / "B01_Dashboard_UI_Views.ts") text = _text(B01 / "B01_Dashboard_UI_Views.ts")
src = re.sub(r"^import[\s\S]*?;\n", "", raw, flags=re.M) # import 한 값(type 아닌 이름) = 빈 함수 — 페이지 줄이 새 mount 를 끌어와도 그대로 돎
# 값으로 가져오는 이름(mount 함수들)은 빈 함수로 — 타입 가져오기는 건너뜀 stubs = [
names = [ name.split(" as ")[-1].strip()
n.split(" as ")[-1].strip() for group in re.findall(r"^import \{([^}]*)\}", text, flags=re.M)
for m in re.finditer(r"^import\s+\{([^}]*)\}\s+from[^;]*;", raw, flags=re.M) for name in group.split(",")
for n in m.group(1).split(",") if name.strip() and not name.strip().startswith("type ")
if n.strip() and not n.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( result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", ["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script], "--input-type=module-typescript", "-e", script],
@@ -107,3 +107,16 @@ def test_셸_한_벌():
assert ':root[data-theme="dark"] .b01-shell {' in css assert ':root[data-theme="dark"] .b01-shell {' in css
icons = _text(ROOT / "ui_template" / "ui_template_icons.ts") icons = _text(ROOT / "ui_template" / "ui_template_icons.ts")
assert "MIT License" in icons and "Paweł Kuna" in icons 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