diff --git a/B01_Dashboard/B01_Dashboard_Api_Projects.ts b/B01_Dashboard/B01_Dashboard_Api_Projects.ts new file mode 100644 index 000000000..137dadac0 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Api_Projects.ts @@ -0,0 +1,82 @@ +/* ============================================================================= + * B01_Dashboard_Api_Projects.ts + * 프로젝트 목록 · 상세 API(요구 369~374 · PLAN 548) — 서버 `B01_Dashboard_Router_Overview.py`. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { ProjectItem } from "./B01_Dashboard_Api_Fetch"; + +/** 칸반 칸 — 서버 `board_column` 과 같은 열쇠 */ +export type BoardColumn = "start" | "design" | "review" | "confirm" | "done"; + +export interface OverviewProject extends ProjectItem { + user_id?: number | null; + project_year_end?: number | null; + design_speed_kph?: number | null; + created_at?: string | null; + manager_name?: string | null; + source_project_id?: string | null; + stage_total: number; + board: BoardColumn; + idle_days: number | null; + stalled: boolean; + /** 썸네일 선(줄인 평면 좌표) — 547-1 카드 요약 */ + thumb?: [number, number][] | null; +} + +/** 지도 선 — 위경도 [lat, lon] */ +export interface RouteLine { + points: [number, number][]; +} + +export interface ProjectLink { + kind: "source" | "derived" | "plan"; + project_id: string; + name: string; + project_kind?: string | null; + note?: string | null; +} + +export interface ProjectOutput { + key: string; + label: string; + url: string; +} + +export interface ProjectChange { + at: string; + who?: string | null; + action: string; +} + +export interface ProjectDetail { + project: OverviewProject; + people: { pm?: string | null; field_lead?: string | null; designer?: string | null }; + route: RouteLine | null; + numbers: { key: string; value: number; unit?: string }[]; + links: ProjectLink[]; + outputs: ProjectOutput[]; + changes: ProjectChange[]; +} + +async function getJson(path: string): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.detail || `요청 실패: ${response.status}`); + return data as T; +} + +export async function fetchOverviewProjects( + scope: "mine" | "company", + companyId?: number | null, +): Promise { + const company = companyId ? `&company_id=${companyId}` : ""; + const data = await getJson<{ projects: OverviewProject[] }>( + `/dashboard/overview/projects?scope=${scope}${company}`, + ); + return data.projects ?? []; +} + +export function fetchProjectDetail(projectId: string): Promise { + return getJson(`/dashboard/overview/projects/${encodeURIComponent(projectId)}`); +} diff --git a/B01_Dashboard/B01_Dashboard_Router_Overview.py b/B01_Dashboard/B01_Dashboard_Router_Overview.py new file mode 100644 index 000000000..b738f7eea --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Router_Overview.py @@ -0,0 +1,324 @@ +"""B01 대시보드 프로젝트 목록 · 상세 API (요구 369~374 · PLAN 548). + +목록 = 카드 · 표 · 칸반 한 벌(칸반 칸은 단계 상태로 저절로 · `board_column`). +상세 = 머리 · 단계 · 지도 선 · 핵심 숫자 · 기본 정보 · 연결 · 산출물 · 최근 변경(있는 것만). +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException, Query + +from common_util.common_util_auth import verify_session +from config.config_db import get_db_pool + +from .B01_Dashboard_Repository import _project_rows + +router = APIRouter(prefix="/api/dashboard/overview", tags=["B01_Dashboard"]) + +#: 종류별 단계 수 — 화면 단계 막대(`group_workflow.ts` · `WORKFLOW_STEP_ROUTES`)와 같음 +STAGE_TOTAL = {"ROAD": 7, "PLAN": 3, "STABILITY": 6} +#: 손대지 않은 날이 이만큼 넘으면 「오래 멈춤」 +STALE_DAYS = 14 + +_COLUMNS = """p.id, p.user_id, p.company_id, p.name, p.region, p.road_type, p.project_kind, + p.project_year, p.project_year_end, p.design_speed_kph, + p.estimated_length_m, p.route_start_m, p.route_end_m, + p.memo, p.status, p.updated_at, p.created_at, p.crs_epsg, + p.client_org, p.project_number, p.work_amount, p.design_date, + p.pm_user_id, p.field_lead_user_id, p.designer_user_id, + p.logo_asset_id, p.signature_asset_id, p.source_project_id, + u.name AS owner_name, COALESCE(pm.name, u.name) AS manager_name""" +_FROM = """FROM projects p LEFT JOIN users u ON u.id = p.user_id + LEFT JOIN users pm ON pm.id = p.pm_user_id""" + + +def board_column(kind: str | None, stages: list[dict[str, Any]]) -> str: + """칸반 칸 — start 시작 전 · design 설계 중 · review 검토 · confirm 확정 대기 · done 완료. + + review = 앞 단계가 바뀌어 다시 볼 단계(STALE) · 실패(FAILED)가 있음. + confirm = 앞 단계를 다 마치고 마지막 단계만 남음. + """ + total = STAGE_TOTAL.get(kind or "ROAD", 0) + states = {int(s["stage_no"]): s["state"] for s in stages} + if total and states.get(total - 1) == "COMPLETE": + return "done" + if all(state == "NOT_STARTED" for state in states.values()): + return "start" + if any(state in ("STALE", "FAILED") for state in states.values()): + return "review" + done_head = 0 + while states.get(done_head) == "COMPLETE": + done_head += 1 + if total and done_head >= total - 1: + return "confirm" + return "design" + + +def idle_days(updated_at: datetime | None, now: datetime | None = None) -> int | None: + """마지막 수정 뒤 지난 날 수.""" + if updated_at is None: + return None + return ((now or datetime.now()) - updated_at).days + + +def is_mine(row: dict[str, Any], user_id: int) -> bool: + """내 것 = 만든 사람 · 참여자 · 책임 · 현장 · 설계 담당.""" + people = (row.get("user_id"), row.get("pm_user_id"), row.get("field_lead_user_id")) + return user_id in (*people, row.get("designer_user_id")) or user_id in row.get( + "member_user_ids", [] + ) + + +def _decorate(row: dict[str, Any]) -> dict[str, Any]: + stages = row.get("workflow_state", {}).get("stages", []) + kind = row.get("project_kind") or "ROAD" + days = idle_days(row.get("updated_at")) + return { + **row, + "project_kind": kind, + "stage_total": STAGE_TOTAL.get(kind, 0), + "board": board_column(kind, stages), + "idle_days": days, + "stalled": days is not None and days > STALE_DAYS, + } + + +def _scope_where(session: dict[str, Any], company_id: int | None) -> tuple[str, tuple[Any, ...]]: + """볼 범위 — 시스템 관리자 = 고른 회사(없음 = 전체) · 회사 사람 = 자기 회사 · 그 밖 = 내 것.""" + if session["role"] == "SYSTEM_ADMIN": + return ("p.company_id = %s", (company_id,)) if company_id else ("1 = 1", ()) + own = session.get("company_id") + if company_id is not None and int(company_id) != int(own or 0): + raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.") + if own: + return "p.company_id = %s", (int(own),) + return "p.user_id = %s", (int(session["user_id"]),) + + +@router.get("/projects") +async def overview_projects( + scope: str = Query("company", pattern="^(mine|company)$"), + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(verify_session), +): + where, args = _scope_where(session, company_id) + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + f"""SELECT {_COLUMNS} {_FROM} + WHERE {where} AND p.deleted_at IS NULL + ORDER BY p.updated_at DESC, p.created_at DESC""", + args, + ) + 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]} + + +async def _visible_project(cursor: Any, session: dict[str, Any], project_id: str) -> dict: + await cursor.execute( + f"""SELECT {_COLUMNS}, p.storage_path, + fl.name AS field_lead_name, ds.name AS designer_name {_FROM} + LEFT JOIN users fl ON fl.id = p.field_lead_user_id + LEFT JOIN users ds ON ds.id = p.designer_user_id + WHERE p.id = %s AND p.deleted_at IS NULL""", + (project_id,), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if session["role"] != "SYSTEM_ADMIN": + own = session.get("company_id") + allowed = ( + int(row["company_id"] or 0) == int(own) + if own + else int(row["user_id"]) == int(session["user_id"]) + ) + if not allowed: + raise HTTPException(status_code=403, detail="이 프로젝트를 볼 권한이 없습니다.") + return row + + +def _route_lonlat(root: Path, limit: int = 300) -> tuple[list[list[float]], float | None]: + """설계 노선(작업본 → 초기값 → 예상노선) [lat, lon] · 연장 — 못 읽으면 ([], None).""" + from pyproj import Transformer + + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from common_util.common_util_route_geometry import ( + expected_route_csv_path, + planned_route_initial_path, + planned_route_working_path, + read_planned_route_csv, + ) + + for path in ( + planned_route_working_path(root), + planned_route_initial_path(root), + expected_route_csv_path(root), + ): + route = read_planned_route_csv(path) if path.is_file() else None + if route is None or len(route.vertices) < 2: + continue + picked = route.vertices[:: max(1, len(route.vertices) // limit)] + if picked[-1] is not route.vertices[-1]: + picked.append(route.vertices[-1]) + try: + crs = route.crs_input or project_epsg_from_prj(root) + to_wgs = Transformer.from_crs(crs, "EPSG:4326", always_xy=True) + lonlat = [to_wgs.transform(v.x, v.y) for v in picked] + except Exception: # 좌표계 못 읽음 = 지도 없음 · 연장은 그대로 + return [], route.vertices[-1].chainage_m + points = [[round(lat, 7), round(lon, 7)] for lon, lat in lonlat] + return points, route.vertices[-1].chainage_m + 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 project_outputs(kind: str, stages: list[dict[str, Any]]) -> list[dict[str, str]]: + """산출물 — 인쇄 · 내려받기 화면(b09-print)에서 지금 받을 수 있는 것만(임도설계). + + 도면 = 횡단(3) 마침 · 수량 = 수량(5) 마침 · 내역 = 원가(6) 들어감. + """ + if kind != "ROAD": + return [] + state = {int(s["stage_no"]): s["state"] for s in stages} + out = [] + if state.get(3) == "COMPLETE": + out.append({"key": "drawings", "label": "도면 PDF"}) + if state.get(5) == "COMPLETE": + out.append({"key": "quantity", "label": "수량 xlsx"}) + if state.get(6, "NOT_STARTED") != "NOT_STARTED": + out.append({"key": "cost", "label": "내역 xlsx"}) + return out + + +def _link(kind: str, row: dict[str, Any], note: str | None = None) -> dict[str, Any]: + return { + "kind": kind, + "project_id": row["id"], + "name": row["name"], + "project_kind": row.get("project_kind") or "PLAN", + "note": note, + } + + +async def _links(cursor: Any, row: dict[str, Any]) -> list[dict[str, Any]]: + """연결된 것 — 원본 · 이 프로젝트에서 나온 것 · 연결한 예정노선(원본과 같으면 한 번만).""" + links: list[dict[str, Any]] = [] + source = row.get("source_project_id") + if source: + await cursor.execute( + "SELECT id, name, project_kind FROM projects WHERE id = %s AND deleted_at IS NULL", + (source,), + ) + links += [_link("source", r) for r in await cursor.fetchall()] + await cursor.execute( + """SELECT id, name, project_kind FROM projects + WHERE source_project_id = %s AND deleted_at IS NULL ORDER BY created_at""", + (row["id"],), + ) + links += [_link("derived", r) for r in await cursor.fetchall()] + await cursor.execute( + """SELECT p.id, p.name, a.year FROM plan_route_assignees a + JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL + WHERE a.linked_project_id = %s ORDER BY a.year""", + (row["id"],), + ) + for r in await cursor.fetchall(): + if r["id"] == source: + links[0]["note"] = f"{r['year']} 년" + else: + links.append(_link("plan", r, f"{r['year']} 년")) + return links + + +#: 최근 변경 — 감사 기록 중 사람 글로 보일 것 +_AUDIT_ACTIONS = ("PROJECT_CREATE", "PROJECT_UPDATE") + + +async def _changes(cursor: Any, project_id: str, stages: list[dict[str, Any]]) -> list[dict]: + """최근 변경 — 감사 기록(만듦 · 정보 고침) + 단계 마친 때 · 새것부터 8 개.""" + await cursor.execute( + """SELECT l.action, DATE_FORMAT(l.timestamp, '%%Y-%%m-%%d %%H:%%i:%%s') AS at, + u.name AS who + FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id + WHERE l.resource_type = 'project' AND l.resource_ref = %s + AND l.action IN (%s, %s) + ORDER BY l.timestamp DESC LIMIT 8""", + (project_id, *_AUDIT_ACTIONS), + ) + items: list[dict[str, Any]] = [dict(r) for r in await cursor.fetchall()] + items += [ + { + "at": s["completed_at"].replace("T", " "), + "who": None, + "action": "STAGE_COMPLETE", + "stage_no": s["stage_no"], + } + for s in stages + if s.get("state") == "COMPLETE" and s.get("completed_at") + ] + return sorted(items, key=lambda item: item["at"], reverse=True)[:8] + + +@router.get("/projects/{project_id}") +async def overview_project_detail( + project_id: str, session: dict[str, Any] = Depends(verify_session) +): + from common_util.common_util_storage import resolve_stored_project_path + + pool = get_db_pool() + 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) + people = { + "pm": row["manager_name"] if row.get("pm_user_id") else None, + "field_lead": row.pop("field_lead_name", None), + "designer": row.pop("designer_name", None), + } + row.pop("storage_path", None) + return { + "status": "success", + "project": row, + "people": people, + "route": {"points": line} if line else None, + "numbers": _numbers(root, length, stations), + "links": links, + "outputs": project_outputs(row["project_kind"], row["workflow_state"]["stages"]), + "changes": changes, + } diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectList.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList.ts new file mode 100644 index 000000000..a7e4bf638 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList.ts @@ -0,0 +1,231 @@ +/* ============================================================================= + * B01_Dashboard_UI_ProjectList.ts + * 대시보드 「프로젝트」 세부 페이지(요구 369~374 · PLAN 548-1) — 툴바(찾기 · 종류 칩 · 담당 · 보기 · + * 카드/표/칸반 · [+ 새 프로젝트]) + 고른 보기. 고른 보기는 계정 취향(`aislo:pref:`)으로 기억. + * 칸반은 9 개 넘을 때만 · 칸은 서버가 단계 상태로 나눔(끌기 없음). + * ========================================================================== */ + +import "./B01_Dashboard_UI_Style_Projects.css"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { el } from "@ui/ui_template_elements_base"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { fetchOverviewProjects, type OverviewProject } from "./B01_Dashboard_Api_Projects"; +import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject"; +import { KINDS } from "./B01_Dashboard_UI_ProjectList_Parts"; +import { cardGrid, kanbanBoard, projectRows } from "./B01_Dashboard_UI_ProjectList_Views"; + +/** 등록부 ctx(546) 가운데 이 페이지가 쓰는 것 */ +export interface ProjectViewCtx { + me: DashboardUser; + companyId: number | null; + go: (view: string, params?: Record) => void; +} + +type ViewMode = "card" | "table" | "kanban"; +type Scope = "mine" | "company"; + +/** 칸반이 열리는 수 — 넘으면 [칸반] 단추 */ +export const KANBAN_MIN = 10; +const PREF_VIEW = "aislo:pref:b01.projects.view"; +const PREF_SCOPE = "aislo:pref:b01.projects.scope"; + +interface ListState { + scope: Scope; + view: ViewMode; + kind: string; + manager: string; + query: string; + projects: OverviewProject[]; +} + +/** 거르기 — 종류 · 담당 · 찾기(이름 · 지역) */ +export function filterProjects( + projects: OverviewProject[], + kind: string, + manager: string, + query: string, +): OverviewProject[] { + const q = query.trim().toLowerCase(); + return projects.filter( + (p) => + (!kind || (p.project_kind ?? "ROAD") === kind) && + (!manager || p.manager_name === manager) && + (!q || `${p.name} ${p.region ?? ""}`.toLowerCase().includes(q)), + ); +} + +function segment( + items: { key: T; label: string }[], + active: T, + onPick: (key: T) => void, +): HTMLElement { + const seg = el("span", { className: "b01-proj-seg", attrs: { role: "group" } }); + for (const item of items) { + const button = el("button", { + className: item.key === active ? "is-on" : "", + text: item.label, + attrs: { type: "button", "data-key": item.key }, + }); + button.addEventListener("click", () => onPick(item.key)); + seg.append(button); + } + return seg; +} + +function chip(label: string, on: boolean, onClick?: () => void, soon = false): HTMLElement { + const button = el("button", { + className: `b01-proj-chip${on ? " is-on" : ""}${soon ? " is-soon" : ""}`, + text: label, + attrs: { type: "button" }, + }); + button.addEventListener("click", () => (soon ? showToast(label) : onClick?.())); + return button; +} + +/** 「프로젝트」 페이지 — 등록부 mount */ +export function mountProjectList(host: HTMLElement, ctx: ProjectViewCtx): () => void { + let alive = true; + const state: ListState = { + scope: readByKey(PREF_SCOPE) === "mine" ? "mine" : "company", + view: (readByKey(PREF_VIEW) as ViewMode | null) ?? "card", + kind: "", + manager: "", + query: "", + projects: [], + }; + const root = el("div", { className: "b01-proj" }); + const head = el("div", { className: "b01-proj-head" }); + const tools = el("div", { className: "b01-proj-tools" }); + const body = el("div", { className: "b01-proj-body" }); + root.append(head, tools, body); + host.replaceChildren(root); + + const search = el("input", { + className: "b01-proj-search", + attrs: { type: "search", placeholder: "이름 · 지역 찾기" }, + }); + search.addEventListener("input", () => { + state.query = search.value; + paintBody(); + }); + + const open = (project: OverviewProject): void => ctx.go("project", { id: project.id }); + + function paintHead(): void { + const kanbanOk = state.projects.length >= KANBAN_MIN; + const views: { key: ViewMode; label: string }[] = [ + { key: "card", label: "카드" }, + { key: "table", label: "표" }, + ...(kanbanOk ? [{ key: "kanban" as ViewMode, label: "칸반" }] : []), + ]; + head.replaceChildren( + el("h2", { text: "프로젝트" }), + el("span", { className: "b01-proj-count", text: String(state.projects.length) }), + el("span", { className: "b01-proj-sp" }), + segment(views, shownView(), (key) => { + state.view = key; + writeByKey(PREF_VIEW, key); + paintHead(); + paintBody(); + }), + createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }), + ); + } + + /** 기억한 보기가 칸반인데 9 개 이하 = 카드 */ + const shownView = (): ViewMode => + state.view === "kanban" && state.projects.length < KANBAN_MIN ? "card" : state.view; + + function paintTools(): void { + const count = (kind: string): number => + state.projects.filter((p) => (p.project_kind ?? "ROAD") === kind).length; + const chips = [ + chip(`전체 ${state.projects.length}`, !state.kind, () => pickKind("")), + ...KINDS.map((k) => + "soon" in k + ? chip(`${k.label()} · 준비 중`, false, undefined, true) + : chip(`${k.label()} ${count(k.key)}`, state.kind === k.key, () => pickKind(k.key)), + ), + ]; + const managers = [...new Set(state.projects.map((p) => p.manager_name).filter(Boolean))]; + const select = el("select", { className: "b01-proj-select", attrs: { "aria-label": "담당" } }); + select.append(new Option("담당 : 전체", ""), ...managers.map((m) => new Option(m!, m!))); + select.value = state.manager; + select.addEventListener("change", () => { + state.manager = select.value; + paintBody(); + }); + const scope = segment( + [ + { key: "mine" as Scope, label: "내 것" }, + { key: "company" as Scope, label: "회사 전체" }, + ], + state.scope, + (key) => { + state.scope = key; + writeByKey(PREF_SCOPE, key); + void load(); + }, + ); + tools.replaceChildren( + search, + ...chips, + el("span", { className: "b01-proj-sp" }), + select, + scope, + ); + } + + function pickKind(kind: string): void { + state.kind = kind; + paintTools(); + paintBody(); + } + + function paintBody(): void { + const shown = filterProjects(state.projects, state.kind, state.manager, state.query); + if (!shown.length) { + body.replaceChildren( + el("p", { + className: "b01-proj-empty", + text: state.projects.length + ? "조건에 맞는 프로젝트가 없습니다." + : "아직 프로젝트가 없습니다. [+ 새 프로젝트]로 시작합니다.", + }), + ); + return; + } + const view = shownView(); + body.replaceChildren( + view === "table" + ? projectRows(shown, ctx.me, open) + : view === "kanban" + ? kanbanBoard(shown, open) + : cardGrid(shown, open), + ); + } + + async function load(): Promise { + body.replaceChildren(el("p", { className: "b01-proj-empty", text: "불러오는 중…" })); + try { + const projects = await fetchOverviewProjects(state.scope, ctx.companyId); + if (!alive) return; + state.projects = projects; + } catch (error) { + if (!alive) return; + state.projects = []; + showToast(error instanceof Error ? error.message : String(error), "error"); + } + paintHead(); + paintTools(); + paintBody(); + } + + paintHead(); + paintTools(); + void load(); + return () => { + alive = false; + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts new file mode 100644 index 000000000..82bdf42a3 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * B01_Dashboard_UI_ProjectList_Parts.ts + * 프로젝트 목록 · 상세 공용 조각(PLAN 548) — 종류 배지 · 단계 글 · 진행 막대 · 썸네일 선 · 칸반 칸 이름. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements_base"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { groupFlowOf } from "../A00_Common/group_workflow"; +import type { RoutePath } from "@config/config_frontend"; +import type { BoardColumn, OverviewProject } from "./B01_Dashboard_Api_Projects"; +import { L } from "./B01_Dashboard_UI_Common"; + +/** 종류 — 칩 · 배지 차례 · 사방설계 = 준비 중 */ +export const KINDS = [ + { key: "ROAD", label: () => L("DE_New_Road") }, + { key: "PLAN", label: () => L("DE_New_Plan") }, + { key: "STABILITY", label: () => L("DE_New_Stability") }, + { key: "EROSION", label: () => L("DE_New_Erosion"), soon: true }, +] as const; + +export const kindLabel = (kind?: string | null): string => + (KINDS.find((k) => k.key === (kind ?? "ROAD")) ?? KINDS[0]).label(); + +export function kindBadge(kind?: string | null): HTMLElement { + const key = (kind ?? "ROAD").toLowerCase(); + return el("span", { className: `b01-proj-badge b01-proj-badge--${key}`, text: kindLabel(kind) }); +} + +export const BOARD_COLUMNS: { key: BoardColumn; label: string }[] = [ + { key: "start", label: "시작 전" }, + { key: "design", label: "설계 중" }, + { key: "review", label: "검토" }, + { key: "confirm", label: "확정 대기" }, + { key: "done", label: "완료" }, +]; + +export const boardLabel = (key: BoardColumn): string => + BOARD_COLUMNS.find((c) => c.key === key)?.label ?? key; + +/** 종류별 단계 이름 · 주소 */ +export function stageFlow(kind?: string | null): { + steps: string[]; + routes: readonly RoutePath[]; +} { + const group = groupFlowOf(kind); + return group + ? { steps: group.steps, routes: group.routes } + : { steps: workflowSteps(), routes: WORKFLOW_STEP_ROUTES }; +} + +/** 지금 단계 번호(0 부터) · 마친 단계 수 */ +export function stageProgress(project: OverviewProject): { current: number; done: number } { + const stages = project.workflow_state?.stages ?? []; + return { + current: project.workflow_state?.current_stage ?? 0, + done: stages.filter((s) => s.state === "COMPLETE").length, + }; +} + +/** 「횡단설계 4/7」 — 시작 전 = 0 · 완료 = 전체 */ +export function stageText(project: OverviewProject): string { + const { steps } = stageFlow(project.project_kind); + const total = project.stage_total || steps.length; + if (!total) return L("DE_New_Soon"); + const { current } = stageProgress(project); + if (project.board === "done") return `완료 ${total}/${total}`; + const n = project.board === "start" ? 0 : current + 1; + return `${steps[current] ?? ""} ${n}/${total}`; +} + +export function progressBar(project: OverviewProject): HTMLElement { + const total = project.stage_total || 1; + const bar = el("span", { className: "b01-proj-bar" }); + const fill = el("i"); + fill.style.width = `${Math.round((stageProgress(project).done / total) * 100)}%`; + bar.append(fill); + return bar; +} + +/** 단계 점 — 마침 · 지금 · 남음 */ +export function stageDots(project: OverviewProject): HTMLElement { + const total = project.stage_total; + const { current } = stageProgress(project); + const states = new Map((project.workflow_state?.stages ?? []).map((s) => [s.stage_no, s.state])); + const row = el("span", { className: "b01-proj-dots" }); + for (let i = 0; i < total; i += 1) { + const state = states.get(i); + const cls = + state === "COMPLETE" + ? "is-done" + : state === "STALE" || state === "FAILED" + ? "is-stale" + : i === current && project.board !== "start" + ? "is-cur" + : ""; + row.append(el("i", { className: cls })); + } + return row; +} + +/** 단계 화면으로 — 종류별 주소 */ +export function openStage(project: OverviewProject, index: number): void { + const { routes } = stageFlow(project.project_kind); + const route = routes[Math.max(0, Math.min(index, routes.length - 1))]; + if (route) void goToWorkflowStage(project.id, route); +} + +/** 썸네일 선 — 줄인 좌표(아무 평면 좌표) 를 칸에 맞춰 SVG 한 줄 · 없으면 빈 판 */ +export function thumbSvg(points: [number, number][] | null | undefined, height = 56): SVGElement { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("viewBox", `0 0 200 ${height}`); + 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); + svg.append(path); + return svg; +} + +/** 「오늘」 · 「어제」 · 「n 일 전」 · 그 밖 날짜 */ +export function ago(project: OverviewProject): string { + const days = project.idle_days; + if (days === null || days === undefined) return "-"; + if (days <= 0) return "오늘"; + if (days === 1) return "어제"; + if (days < 7) return `${days} 일 전`; + return (project.updated_at ?? "").slice(0, 10); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts new file mode 100644 index 000000000..3a06dcdda --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts @@ -0,0 +1,177 @@ +/* ============================================================================= + * B01_Dashboard_UI_ProjectList_Views.ts + * 프로젝트 목록 보기 셋(PLAN 548-1) — 카드 · 표(줄 끝 ⋯) · 칸반(칸 = 서버 `board` · 완료 칸 접힘). + * ========================================================================== */ + +import { createMapContextMenu, 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"; +import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper"; +import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals"; +import { + ago, + BOARD_COLUMNS, + boardLabel, + kindBadge, + progressBar, + stageDots, + stageText, + thumbSvg, +} from "./B01_Dashboard_UI_ProjectList_Parts"; + +type Open = (project: OverviewProject) => void; + +/** 누름 = 상세 · Enter 도 같음 */ +function clickable(node: T, onOpen: () => void): T { + node.tabIndex = 0; + node.addEventListener("click", onOpen); + node.addEventListener("keydown", (event) => { + if (event.key === "Enter") onOpen(); + }); + return node; +} + +function stalledMark(project: OverviewProject): HTMLElement[] { + return project.stalled + ? [ + el("span", { + className: "b01-proj-stalled", + text: `${project.idle_days} 일 그대로`, + attrs: { title: "14 일 넘게 고친 것이 없음" }, + }), + ] + : []; +} + +function card(project: OverviewProject, onOpen: Open, compact = false): HTMLElement { + const box = el("div", { + className: `b01-proj-card b01-proj-card--${(project.project_kind ?? "ROAD").toLowerCase()}`, + attrs: { "data-project": project.id }, + }); + const info = el("div", { + className: "b01-proj-card__body", + children: [ + el("b", { className: "b01-proj-card__name", text: project.name }), + el("div", { + className: "b01-proj-card__line", + children: [ + kindBadge(project.project_kind), + el("span", { className: "b01-proj-mini", text: stageText(project) }), + ], + }), + ...(compact ? [progressBar(project)] : [stageDots(project)]), + el("div", { + className: "b01-proj-card__meta", + children: [ + el("span", { text: project.manager_name ?? "-" }), + ...stalledMark(project), + el("span", { text: ago(project) }), + ], + }), + ], + }); + box.append(thumbSvg(project.thumb, compact ? 44 : 64), info); + return clickable(box, () => onOpen(project)); +} + +export function cardGrid(projects: OverviewProject[], onOpen: Open): HTMLElement { + return el("div", { + className: "b01-proj-cards", + children: projects.map((p) => card(p, onOpen)), + }); +} + +/** 줄 끝 ⋯ — 지금 있는 동작만(수정 · 삭제) */ +function rowMenuItems(project: OverviewProject, me: DashboardUser): MapContextMenuItem[] { + const items: MapContextMenuItem[] = []; + if (canEditProject(me, project)) { + items.push(["수정", () => void openEditProjectModal(me, project)]); + } + if (canDeleteProject(me)) { + items.push("-", ["삭제", () => openDeleteProjectModal(me, project)]); + } + return items; +} + +export function projectRows( + projects: OverviewProject[], + me: DashboardUser, + onOpen: Open, +): HTMLElement { + const wrap = el("div", { className: "b01-proj-table" }); + const menu = createMapContextMenu("b01-proj-menu"); + wrap.append(menu.element); + const head = ["이름", "종류", "지역", "진행", "상태", "담당", "수정일", ""]; + const table = el("table"); + const thead = el("tr", { children: head.map((h) => el("th", { text: h })) }); + table.append(thead); + for (const project of projects) { + const more = el("button", { + className: "b01-proj-more", + text: "⋯", + attrs: { type: "button", "aria-label": "더 보기" }, + }); + const items = rowMenuItems(project, me); + 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); + }); + const row = el("tr", { + className: "b01-proj-row", + attrs: { "data-project": project.id }, + children: [ + el("td", { children: [el("b", { text: project.name }), ...stalledMark(project)] }), + el("td", { children: [kindBadge(project.project_kind)] }), + el("td", { text: project.region ?? "-" }), + el("td", { + className: "b01-proj-prog", + children: [progressBar(project), el("span", { text: stageText(project) })], + }), + el("td", { text: boardLabel(project.board) }), + el("td", { text: project.manager_name ?? "-" }), + el("td", { text: (project.updated_at ?? "").slice(0, 10) || "-" }), + el("td", { children: [more] }), + ], + }); + table.append(clickable(row, () => onOpen(project))); + } + wrap.append(table); + return wrap; +} + +/** 칸반 — 칸 차례 고정 · 완료 칸은 접혀 시작(머리 누름 = 펼침) */ +export function kanbanBoard(projects: OverviewProject[], onOpen: Open): HTMLElement { + const board = el("div", { className: "b01-proj-kanban" }); + for (const column of BOARD_COLUMNS) { + const items = projects.filter((p) => p.board === column.key); + const col = el("section", { + className: "b01-proj-col", + attrs: { "data-board": column.key }, + }); + const title = el("h4", { + children: [ + el("span", { text: column.label }), + el("span", { className: "b01-proj-mini", text: String(items.length) }), + ], + }); + const list = el("div", { + className: "b01-proj-col__list", + children: items.map((p) => card(p, onOpen, true)), + }); + if (column.key === "done") { + col.classList.add("is-folded"); + title.append(el("span", { className: "b01-proj-fold", text: "펼치기" })); + title.addEventListener("click", () => { + const folded = col.classList.toggle("is-folded"); + title.lastElementChild!.textContent = folded ? "펼치기" : "접기"; + }); + } + col.append(title, list); + board.append(col); + } + return board; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Projects.css b/B01_Dashboard/B01_Dashboard_UI_Style_Projects.css new file mode 100644 index 000000000..da6a7e607 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Projects.css @@ -0,0 +1,351 @@ +/* ============================================================================= + * B01_Dashboard_UI_Style_Projects.css + * 프로젝트 목록 · 상세(PLAN 548) — 색 · 간격은 대시보드 셸 변수(--b01-*) · 없으면 공용 테마 값. + * ========================================================================== */ + +.b01-proj, +.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-text: var(--b01-text, var(--color-text, #1f2328)); + --pj-muted: var(--b01-text-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)); + color: var(--pj-text); + font-size: var(--text-body-sm, 13.5px); +} + +/* ── 머리 · 툴바 ─────────────────────────────────────────────────────────── */ +.b01-proj-head, +.b01-pd-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.b01-proj-head h2, +.b01-pd-head h2 { + margin: 0; + font-size: 19px; +} +.b01-proj-count { + color: var(--pj-muted); +} +.b01-proj-sp { + flex: 1; +} +.b01-proj-tools { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 12px; +} +.b01-proj-search { + width: 220px; + padding: 5px 10px; + border: 1px solid var(--pj-border); + border-radius: 7px; + background: var(--pj-surface); + color: var(--pj-text); + font: inherit; +} +.b01-proj-select { + padding: 3px 8px; + border: 1px solid var(--pj-border); + border-radius: 14px; + background: var(--pj-surface); + color: var(--pj-text); + font: inherit; +} +.b01-proj-chip { + border: 1px solid var(--pj-border); + border-radius: 14px; + padding: 2px 10px; + background: var(--pj-surface); + color: var(--pj-text); + font: inherit; + cursor: pointer; +} +.b01-proj-chip.is-on { + border-color: var(--pj-accent); + color: var(--pj-accent); + background: var(--pj-accent-soft); +} +.b01-proj-chip.is-soon { + opacity: 0.5; + border-style: dashed; +} +.b01-proj-seg { + display: inline-flex; + border: 1px solid var(--pj-border); + border-radius: 8px; + overflow: hidden; +} +.b01-proj-seg button { + border: 0; + padding: 5px 11px; + background: var(--pj-surface); + color: var(--pj-text); + font: inherit; + cursor: pointer; +} +.b01-proj-seg button.is-on { + background: var(--pj-accent); + color: #fff; +} +.b01-proj-empty { + color: var(--pj-muted); + padding: 24px 0; + text-align: center; +} +.b01-proj-mini { + color: var(--pj-muted); + font-size: 12px; +} + +/* ── 배지 · 막대 · 점 ────────────────────────────────────────────────────── */ +.b01-proj-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + border-radius: 5px; + padding: 0 6px; + margin-right: 6px; + color: var(--pj-road); + background: color-mix(in srgb, var(--pj-road) 13%, transparent); +} +.b01-proj-badge--plan { + color: var(--pj-plan); + background: color-mix(in srgb, var(--pj-plan) 13%, transparent); +} +.b01-proj-badge--stability { + color: var(--pj-stab); + background: color-mix(in srgb, var(--pj-stab) 13%, transparent); +} +.b01-proj-badge--erosion { + color: var(--pj-muted); + background: var(--pj-sunken); +} +.b01-proj-bar { + display: inline-block; + width: 90px; + height: 6px; + border-radius: 4px; + background: var(--pj-sunken); + overflow: hidden; + vertical-align: middle; +} +.b01-proj-bar i { + display: block; + height: 100%; + background: var(--pj-accent); +} +.b01-proj-dots { + display: flex; + gap: 4px; + margin: 6px 0 2px; +} +.b01-proj-dots i { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--pj-sunken); + border: 1px solid var(--pj-border); +} +.b01-proj-dots i.is-done { + background: var(--pj-accent); + border-color: var(--pj-accent); +} +.b01-proj-dots i.is-cur { + border: 2px solid var(--pj-accent); + background: var(--pj-surface); +} +.b01-proj-dots i.is-stale { + background: var(--pj-warn); + border-color: var(--pj-warn); +} +.b01-proj-stalled { + color: var(--pj-warn); + font-size: 11.5px; + margin-left: 6px; +} + +/* ── 카드 ────────────────────────────────────────────────────────────────── */ +.b01-proj-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: var(--pj-gap); +} +.b01-proj-card { + background: var(--pj-surface); + border: 1px solid var(--pj-border); + border-radius: var(--pj-radius); + overflow: hidden; + cursor: pointer; + transition: + box-shadow 0.15s, + border-color 0.15s; +} +.b01-proj-card:hover, +.b01-proj-card:focus-visible { + border-color: var(--pj-accent); + box-shadow: 0 2px 10px rgb(16 24 40 / 8%); + outline: none; +} +.b01-proj-thumb { + display: block; + width: 100%; + height: 64px; + background: color-mix(in srgb, var(--pj-road) 9%, var(--pj-surface)); +} +.b01-proj-col .b01-proj-thumb { + height: 44px; +} +.b01-proj-card--plan .b01-proj-thumb { + background: color-mix(in srgb, var(--pj-plan) 9%, var(--pj-surface)); +} +.b01-proj-card--stability .b01-proj-thumb { + background: color-mix(in srgb, var(--pj-stab) 9%, var(--pj-surface)); +} +.b01-proj-thumb path { + fill: none; + stroke: #c0392b; + stroke-width: 2.5; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} +.b01-proj-card--plan .b01-proj-thumb path { + stroke: var(--pj-plan); +} +.b01-proj-card--stability .b01-proj-thumb path { + stroke: var(--pj-stab); +} +.b01-proj-card__body { + padding: 8px 10px 9px; +} +.b01-proj-card__name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.b01-proj-card__line { + margin-top: 2px; +} +.b01-proj-card .b01-proj-bar { + width: 100%; + margin-top: 5px; +} +.b01-proj-card__meta { + display: flex; + justify-content: space-between; + gap: 6px; + color: var(--pj-muted); + font-size: 11.5px; + margin-top: 5px; +} + +/* ── 표 ──────────────────────────────────────────────────────────────────── */ +.b01-proj-table { + position: relative; + background: var(--pj-surface); + border: 1px solid var(--pj-border); + border-radius: var(--pj-radius); + overflow: visible; +} +.b01-proj-table table { + width: 100%; + border-collapse: collapse; +} +.b01-proj-table th, +.b01-proj-table td { + padding: 9px 12px; + text-align: left; + border-bottom: 1px solid var(--pj-border); +} +.b01-proj-table th { + color: var(--pj-muted); + font-weight: 600; + font-size: 12px; + background: var(--pj-sunken); +} +.b01-proj-row { + cursor: pointer; +} +.b01-proj-row:hover td, +.b01-proj-row:focus-visible td { + background: var(--pj-accent-soft); +} +.b01-proj-row:focus-visible { + outline: none; +} +.b01-proj-prog span:last-child { + margin-left: 8px; +} +.b01-proj-more { + border: 0; + background: none; + color: var(--pj-muted); + font-size: 16px; + cursor: pointer; + padding: 0 6px; +} +/* 삭제 = 끝 줄(수정 뒤 구분선 다음) */ +.b01-proj-menu__context-menu .ui-ctx-menu__item:last-child:not(:first-child) { + color: var(--pj-danger); +} + +/* ── 칸반 ────────────────────────────────────────────────────────────────── */ +.b01-proj-kanban { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(0, 0.8fr); + gap: 12px; + align-items: start; +} +.b01-proj-col { + background: var(--pj-sunken); + border-radius: var(--pj-radius); + padding: 10px; +} +.b01-proj-col h4 { + display: flex; + gap: 6px; + align-items: center; + margin: 0 0 8px; + font-size: 13px; +} +.b01-proj-col__list { + display: grid; + gap: 8px; +} +.b01-proj-col[data-board="done"] h4 { + cursor: pointer; +} +.b01-proj-col[data-board="done"] .b01-proj-card { + opacity: 0.75; +} +.b01-proj-col.is-folded .b01-proj-col__list { + display: none; +} +.b01-proj-fold { + margin-left: auto; + color: var(--pj-muted); + font-size: 11.5px; + font-weight: 400; +} + +@media (max-width: 900px) { + .b01-proj-kanban { + grid-template-columns: 1fr 1fr; + } +} diff --git a/main.py b/main.py index f39967346..b70813046 100644 --- a/main.py +++ b/main.py @@ -28,6 +28,7 @@ from A07_Register.A07_Register_Router import router as a07_register_router from A08_Support.A08_Support_Router import router as a08_support_router from A09_Security.A09_Security_Router import router as a09_security_router from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router +from B01_Dashboard.B01_Dashboard_Router_Overview import router as b01_overview_router from B01_Dashboard.B01_Dashboard_Router_Home import router as b01_home_router from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router from B03_FileInput.B03_FileInput_Repository import ProjectNotFoundError @@ -465,6 +466,7 @@ app.include_router(a07_register_router) app.include_router(a08_support_router) app.include_router(a09_security_router) app.include_router(b01_dashboard_router) +app.include_router(b01_overview_router) app.include_router(b01_home_router) app.include_router(b02_proj_register_router) protected = [Depends(verify_session)]