Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_4

This commit is contained in:
2026-10-26 18:06:53 +09:00
7 changed files with 1313 additions and 0 deletions
@@ -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<T>(path: string): Promise<T> {
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<OverviewProject[]> {
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<ProjectDetail> {
return getJson<ProjectDetail>(`/dashboard/overview/projects/${encodeURIComponent(projectId)}`);
}
@@ -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,
}
@@ -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<string, string>) => 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<T extends string>(
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<void> {
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;
};
}
@@ -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);
}
@@ -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<T extends HTMLElement>(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;
}
@@ -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;
}
}
+2
View File
@@ -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)]