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_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_ProjectDetail.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts new file mode 100644 index 000000000..0ec697d4e --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts @@ -0,0 +1,299 @@ +/* ============================================================================= + * 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 { 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(); + const box = bar.getBoundingClientRect(); + const at = more.getBoundingClientRect(); + menu.open(at.right - box.left - 120, at.bottom - box.top, 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..570a02ce0 100644 --- a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts @@ -71,7 +71,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)); } @@ -83,7 +83,7 @@ export function cardGrid(projects: OverviewProject[], onOpen: Open): HTMLElement } /** 줄 끝 ⋯ — 지금 있는 동작만(수정 · 삭제) */ -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)]); 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/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