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:
@@ -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 {
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user