feat(B01): 프로젝트 상세 화면 · 카드 요약 연결 · 지킴 시험 (PLAN 548-2)

- 상세 화면(view=project) — 머리 · [⋯] · [작업 열기 — 지금 단계 ›] · 단계 진행(누름 = 그 단계) ·
  노선 지도(배경 타일 + 선 · 시점 · 종점) · 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경(있는 칸만)
- 목록 · 상세 숫자 · 썸네일 = 547-1 카드 요약(project_summaries) 한 벌
- 색 = 셸 변수(--b01-*) 이름에 맞춤
- 시험 test_548_project_overview — 칸반 나눔 · 산출물 · 내 것 · 단계 수 짝

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 18:08:18 +09:00
co-authored by Claude Opus 5.5
parent ffa5d250e8
commit 070791854c
8 changed files with 695 additions and 59 deletions
+4 -2
View File
@@ -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 {
+28 -25
View File
@@ -17,6 +17,7 @@ from common_util.common_util_auth import verify_session
from config.config_db import get_db_pool
from .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,
@@ -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<string, Parameters<typeof L>[0]> = {
main: "B02_Proj_RoadType_Main",
fire: "B02_Proj_RoadType_Fire",
work: "B02_Proj_RoadType_Work",
};
const roadType = (value?: string | null): string | null =>
value ? (ROAD_TYPE[value] ? L(ROAD_TYPE[value]) : value) : null;
const NUMBER_LABEL: Record<string, string> = {
length: "연장",
stations: "측점",
structures: "구조물",
};
const LINK_LABEL: Record<ProjectLink["kind"], string> = {
source: "원본",
derived: "이어 만든 것",
plan: "받은 예정노선",
};
function pane(title: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement {
const box = el("section", { className: "b01-pd-pane", children: [el("h3", { text: title })] });
box.append(...children);
return box;
}
function steps(detail: ProjectDetail): HTMLElement {
const project = detail.project;
const { steps: names } = stageFlow(project.project_kind);
const { current } = stageProgress(project);
const states = new Map((project.workflow_state?.stages ?? []).map((s) => [s.stage_no, s.state]));
const row = el("div", { className: "b01-pd-steps" });
row.style.setProperty("--n", String(names.length));
names.forEach((name, index) => {
const state = states.get(index);
const done = state === "COMPLETE";
const cls = done
? "is-done"
: state === "STALE" || state === "FAILED"
? "is-stale"
: index === current && project.board !== "start"
? "is-cur"
: "";
const step = el("button", {
className: `b01-pd-step ${cls}`,
attrs: { type: "button", title: `${name} 화면 열기` },
children: [el("span", { text: done ? "✓" : String(index + 1) }), el("b", { text: name })],
});
step.addEventListener("click", () => openStage(project, index));
row.append(step);
});
return row;
}
function numbers(detail: ProjectDetail): HTMLElement | null {
if (!detail.numbers.length) return null;
return el("div", {
className: "b01-pd-kpis",
children: detail.numbers.map((n) =>
el("div", {
className: "b01-pd-kpi",
children: [
el("b", { text: `${n.value.toLocaleString("ko-KR")}${n.unit ? ` ${n.unit}` : ""}` }),
el("span", { text: NUMBER_LABEL[n.key] ?? n.key }),
],
}),
),
});
}
function infoPane(detail: ProjectDetail): HTMLElement {
const p = detail.project;
const year = p.project_year
? `${p.project_year}${p.project_year_end && p.project_year_end !== p.project_year ? ` ~ ${p.project_year_end}` : ""}`
: null;
const people = [
...new Set([p.manager_name, detail.people.field_lead, detail.people.designer].filter(Boolean)),
].join(" · ");
const rows: [string, string | null | undefined][] = [
["사업 연도", year],
["지역", p.region],
["임도 종류", roadType(p.road_type)],
["설계속도", p.design_speed_kph ? `${p.design_speed_kph} km/h` : null],
["시행청", p.client_org],
["사업 번호", p.project_number],
["담당", people],
["만든 날", (p.created_at ?? "").slice(0, 10)],
];
const kv = el("div", { className: "b01-pd-kv" });
for (const [label, value] of rows) {
if (value) kv.append(el("span", { text: label }), el("b", { text: value }));
}
return pane("기본 정보", kv);
}
function linkPane(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement | null {
if (!detail.links.length) return null;
return pane(
"연결된 것",
...detail.links.map((link) =>
el("div", {
className: "b01-pd-link",
children: [
kindBadge(link.project_kind),
el("span", { text: `${LINK_LABEL[link.kind]} · ${link.name}` }),
...(link.note ? [el("span", { className: "b01-proj-mini", text: link.note })] : []),
el("span", { className: "b01-proj-sp" }),
createButton({
label: "보기",
variant: "ghost",
onClick: () => ctx.go("project", { id: link.project_id }),
}),
],
}),
),
);
}
function outputPane(detail: ProjectDetail): HTMLElement | null {
if (!detail.outputs.length) return null;
return pane(
"산출물 내려받기",
...detail.outputs.map((out) =>
el("div", {
className: "b01-pd-link",
attrs: { "data-output": out.key },
children: [
el("span", { text: out.label }),
el("span", { className: "b01-proj-sp" }),
createButton({
label: "받기 화면",
variant: "ghost",
onClick: () => void goToWorkflowStage(detail.project.id, PRINT_ROUTE),
}),
],
}),
),
);
}
function changeText(change: ProjectChange, detail: ProjectDetail): string {
const what =
change.action === "STAGE_COMPLETE"
? `${stageFlow(detail.project.project_kind).steps[change.stage_no ?? 0] ?? ""} 마침`
: change.action === "PROJECT_CREATE"
? "만듦"
: "정보 고침";
return [change.at.slice(0, 16), change.who, what].filter(Boolean).join(" · ");
}
function changePane(detail: ProjectDetail): HTMLElement | null {
if (!detail.changes.length) return null;
return pane(
"최근 변경",
el("div", {
className: "b01-pd-timeline",
children: detail.changes.map((c) => el("div", { text: changeText(c, detail) })),
}),
);
}
function head(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement {
const p = detail.project;
const { steps: names } = stageFlow(p.project_kind);
const { current } = stageProgress(p);
const sub = [
p.region,
roadType(p.road_type),
p.design_speed_kph && `설계속도 ${p.design_speed_kph} km/h`,
]
.filter(Boolean)
.join(" · ");
const bar = el("div", { className: "b01-pd-head" });
const menu = createMapContextMenu("b01-proj-menu");
const items = rowMenuItems(p, ctx.me);
const more = el("button", {
className: "b01-proj-more",
text: "⋯",
attrs: { type: "button", "aria-label": "더 보기" },
});
more.hidden = !items.length;
more.addEventListener("click", (event) => {
event.stopPropagation();
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;
};
}
@@ -0,0 +1,95 @@
/* =============================================================================
* B01_Dashboard_UI_ProjectDetail_Map.ts
* 상세 지도 미리보기(PLAN 548-2) — 주소 지도(`UI_MapPreview`)와 같은 배경 타일 길(서버가 VWorld 를 받아 넘김)
* 위에 노선 선 · 시점 · 종점. 라이브러리 없이 SVG 한 장(타일 = image · 선 = path) — 칸 너비에 맞춰 줄어듦.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
const W = 640;
const H = 260;
const TILE = 256;
const PAD = 28;
const MAX_ZOOM = 17;
const MIN_ZOOM = 6;
const NS = "http://www.w3.org/2000/svg";
/** 위경도 → 그 배율의 전역 픽셀(웹 메르카토르) */
function toPixel(lat: number, lon: number, zoom: number): [number, number] {
const size = TILE * 2 ** zoom;
const rad = (lat * Math.PI) / 180;
return [
((lon + 180) / 360) * size,
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * size,
];
}
/** 선이 판(여백 뺌)에 다 드는 가장 큰 배율 */
export function fitZoom(points: [number, number][]): number {
for (let zoom = MAX_ZOOM; zoom > MIN_ZOOM; zoom -= 1) {
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
const xs = px.map((p) => p[0]);
const ys = px.map((p) => p[1]);
if (
Math.max(...xs) - Math.min(...xs) <= W - 2 * PAD &&
Math.max(...ys) - Math.min(...ys) <= H - 2 * PAD
) {
return zoom;
}
}
return MIN_ZOOM;
}
function node<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
): SVGElementTagNameMap[K] {
const element = document.createElementNS(NS, tag);
for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, String(value));
return element;
}
/** 노선 지도 — points = [lat, lon] 두 점 넘게 */
export function routeMap(points: [number, number][]): SVGSVGElement {
const zoom = fitZoom(points);
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
const xs = px.map((p) => p[0]);
const ys = px.map((p) => p[1]);
// 판 왼쪽 위 = 선 가운데 - 판 반
const left = (Math.min(...xs) + Math.max(...xs)) / 2 - W / 2;
const top = (Math.min(...ys) + Math.max(...ys)) / 2 - H / 2;
const svg = node("svg", { viewBox: `0 0 ${W} ${H}`, class: "b01-pd-map" });
const limit = 2 ** zoom;
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + W) / TILE); tx += 1) {
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + H) / TILE); ty += 1) {
if (tx < 0 || ty < 0 || tx >= limit || ty >= limit) continue;
svg.append(
node("image", {
href: `${API_BASE_URL}/dashboard/map/tile/${zoom}/${tx}/${ty}`,
x: tx * TILE - left,
y: ty * TILE - top,
width: TILE,
height: TILE,
}),
);
}
}
const local = px.map(([x, y]) => [x - left, y - top] as const);
svg.append(
node("path", {
class: "b01-pd-map__line",
d: local.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" "),
}),
);
const ends: [readonly [number, number], string][] = [
[local[0], "시점"],
[local[local.length - 1], "종점"],
];
for (const [[x, y], label] of ends) {
svg.append(node("circle", { class: "b01-pd-map__end", cx: x, cy: y, r: 5 }));
const text = node("text", { class: "b01-pd-map__label", x: x + 8, y: y - 6 });
text.textContent = label;
svg.append(text);
}
return svg;
}
@@ -106,31 +106,15 @@ export function openStage(project: OverviewProject, index: number): void {
if (route) void goToWorkflowStage(project.id, route);
}
/** 썸네일 선 — 줄인 좌표(아무 평면 좌표) 를 칸에 맞춰 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;
}
@@ -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)]);
+187 -10
View File
@@ -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));
}
}
@@ -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