diff --git a/B01_Dashboard/B01_Dashboard_Api_Home.ts b/B01_Dashboard/B01_Dashboard_Api_Home.ts new file mode 100644 index 000000000..11663f1ed --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Api_Home.ts @@ -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; + server: { cpu: number | null; memory: number | null; disk: number | null }; + }; +} + +async function getJson(path: string): Promise { + 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 => getJson("/dashboard/home"); + +/** 카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) */ +export async function fetchProjectSummaries(ids?: string[]): Promise { + const query = ids?.length ? `?ids=${encodeURIComponent(ids.join(","))}` : ""; + return (await getJson<{ projects: ProjectSummary[] }>(`/dashboard/projects/summary${query}`)) + .projects; +} diff --git a/B01_Dashboard/B01_Dashboard_Api_Projects.ts b/B01_Dashboard/B01_Dashboard_Api_Projects.ts index 137dadac0..57389bead 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Projects.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Projects.ts @@ -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 { diff --git a/B01_Dashboard/B01_Dashboard_Router_Home.py b/B01_Dashboard/B01_Dashboard_Router_Home.py index ebeed7c2b..de6e859b9 100644 --- a/B01_Dashboard/B01_Dashboard_Router_Home.py +++ b/B01_Dashboard/B01_Dashboard_Router_Home.py @@ -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 diff --git a/B01_Dashboard/B01_Dashboard_Router_Overview.py b/B01_Dashboard/B01_Dashboard_Router_Overview.py index b738f7eea..563a45c7f 100644 --- a/B01_Dashboard/B01_Dashboard_Router_Overview.py +++ b/B01_Dashboard/B01_Dashboard_Router_Overview.py @@ -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, diff --git a/B01_Dashboard/B01_Dashboard_UI_Home.ts b/B01_Dashboard/B01_Dashboard_UI_Home.ts new file mode 100644 index 000000000..a36ebff4d --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Home.ts @@ -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 = { + 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; + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts b/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts new file mode 100644 index 000000000..ca7502b4a --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts @@ -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) => 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; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Home_Many.ts b/B01_Dashboard/B01_Dashboard_UI_Home_Many.ts new file mode 100644 index 000000000..d5e7b43db --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Home_Many.ts @@ -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): 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] }), + ], + }); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts new file mode 100644 index 000000000..f2c0da38b --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts @@ -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[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 = { + length: "연장", + stations: "측점", + structures: "구조물", +}; +const LINK_LABEL: Record = { + 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; + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectDetail_Map.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectDetail_Map.ts new file mode 100644 index 000000000..ef360aa0b --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectDetail_Map.ts @@ -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( + tag: K, + attrs: Record, +): 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; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts index 82bdf42a3..641fe0ec7 100644 --- a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts @@ -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; } diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts index 3a06dcdda..70d3fde77 100644 --- a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts @@ -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", diff --git a/B01_Dashboard/B01_Dashboard_UI_Shell.ts b/B01_Dashboard/B01_Dashboard_UI_Shell.ts index 41f5aa0d4..69836fcd5 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Shell.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Shell.ts @@ -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 { + 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(".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; diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Home.css b/B01_Dashboard/B01_Dashboard_UI_Style_Home.css new file mode 100644 index 000000000..9a9a8592d --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Home.css @@ -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; + } +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Projects.css b/B01_Dashboard/B01_Dashboard_UI_Style_Projects.css index da6a7e607..63d3e746e 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Projects.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Projects.css @@ -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)); + } +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css b/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css index 3c46cdb83..52ed0b1a3 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css @@ -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; diff --git a/B01_Dashboard/B01_Dashboard_UI_Views.ts b/B01_Dashboard/B01_Dashboard_UI_Views.ts index a728f3ec1..46f4e441d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Views.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Views.ts @@ -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 }, // 회사 관리 diff --git a/B01_Dashboard/logic/B01_Dashboard_Home_Layout.ts b/B01_Dashboard/logic/B01_Dashboard_Home_Layout.ts new file mode 100644 index 000000000..0e920eebb --- /dev/null +++ b/B01_Dashboard/logic/B01_Dashboard_Home_Layout.ts @@ -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( + items: T[], + key: (item: T) => string, +): { label: string; count: number }[] { + const counts = new Map(); + 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; diff --git a/resources/tester/test_546_b01_shell.py b/resources/tester/test_546_b01_shell.py index a11c620e0..9acde4b6d 100644 --- a/resources/tester/test_546_b01_shell.py +++ b/resources/tester/test_546_b01_shell.py @@ -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 # 옛 ☰ 걷음 diff --git a/resources/tester/test_547_2_home.py b/resources/tester/test_547_2_home.py new file mode 100644 index 000000000..816a16085 --- /dev/null +++ b/resources/tester/test_547_2_home.py @@ -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} diff --git a/resources/tester/test_548_project_overview.py b/resources/tester/test_548_project_overview.py new file mode 100644 index 000000000..ecc2ee329 --- /dev/null +++ b/resources/tester/test_548_project_overview.py @@ -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