feat(B01): 대시보드 홈 — 진행 중 수에 따라 꼴이 저절로(1 큰 카드 · 2~3 중간 · 4~8 한 줄 · 9 넘음 요약) (PLAN 547-2)

- 등록부 home 줄 = mountHome · 썸네일 선 · 단계 점 · 「다음: 지금 단계」 · [이어서 하기] = 그 단계 화면 · [자세히] = 상세
- 4~8 = 팀원별 · 단계 분포 · 14 일 넘게 그대로 표시 · 9 넘음 = 요약 숫자 · 회사별 막대 · 종류 원(인라인 SVG)
- 오른쪽 = 처리할 일 · 임시 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음 = 세 걸음 · 꼴 고정(자동 · 작게 · 크게) 기억
- 홈 역할 차례 = 셸 roleOf 와 같게(회사 관리자가 산림청 앞)
- test_546 등록부 시험 — import 한 mount 이름을 빈 함수로 저절로(페이지 줄 갈아 끼워도 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 18:20:21 +09:00
co-authored by Claude Opus 5.5
parent 3274d4b107
commit 729f4c75b8
10 changed files with 1500 additions and 16 deletions
+74
View File
@@ -0,0 +1,74 @@
/* =============================================================================
* B01_Dashboard_Api_Home.ts
* 대시보드 홈 API(PLAN 547) — GET /dashboard/home · /dashboard/projects/summary
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 프로젝트 카드 요약(547-1 · `B01_Dashboard_Summary.py`) — 값이 없으면 칸이 빠짐 */
export interface ProjectSummary {
id: string;
name: string;
kind: string;
region?: string | null;
company_id?: number | null;
stage_index: number;
stage_total: number;
stage_name: string;
done: boolean;
updated_at?: string | null;
stale_days?: number | null;
owner_user_id?: number | null;
owner_names: string[];
/** 노선 중심선 0~1(y 아래로) · 없으면 null */
thumb: [number, number][] | null;
metrics: { length_m?: number; stations?: number; structures?: number };
}
export interface PlanTodo {
project_id: string;
project_name: string;
year: number;
linked_project_id: string | null;
}
export interface HomeData {
role: "SYSTEM_ADMIN" | "FOREST" | "ADMIN" | "USER";
/** 진행 중(완료 아님) · 최근 수정 순 · 썸네일은 앞 8 개만 */
projects: ProjectSummary[];
/** 홈 범위 프로젝트 전체(완료 포함) — 0 = 처음 */
project_total: number;
todos: {
new_plans: PlanTodo[];
updated_plans: PlanTodo[];
join_requests?: number;
expiring_temp: { batch_id: string; name: string; expires_at: string; days_left: number }[];
temp_count: number;
};
company?: { id: number; name: string | null; member_count: number | null };
forest?: {
plan_status: { draft: number; analyzed: number; confirmed: number };
assign_waiting: number;
};
system?: {
companies: number;
users: number;
company_names: Record<string, string>;
server: { cpu: number | null; memory: number | null; disk: number | null };
};
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
if (!response.ok) throw new Error(`홈 자료를 불러오지 못했습니다 (${response.status})`);
return (await response.json()) as T;
}
export const fetchHome = (): Promise<HomeData> => getJson<HomeData>("/dashboard/home");
/** 카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) */
export async function fetchProjectSummaries(ids?: string[]): Promise<ProjectSummary[]> {
const query = ids?.length ? `?ids=${encodeURIComponent(ids.join(","))}` : "";
return (await getJson<{ projects: ProjectSummary[] }>(`/dashboard/projects/summary${query}`))
.projects;
}
+4 -8
View File
@@ -57,14 +57,10 @@ async def projects_summary(
def home_role(session: dict[str, Any]) -> str:
"""홈 꼴 역할 — SYSTEM_ADMIN · FOREST(*.go.kr) · ADMIN(회사 관리자 · 마스터) · USER."""
if session["role"] == "SYSTEM_ADMIN":
return "SYSTEM_ADMIN"
if is_forest_email(session.get("email")):
return "FOREST"
if session["role"] == "ADMIN" or session.get("is_master"):
return "ADMIN"
return "USER"
"""홈 역할 — 셸 등록부 `roleOf` 와 같은 차례(시스템 · 회사 관리자 · 산림청 *.go.kr · 그 밖)."""
if session["role"] in ("SYSTEM_ADMIN", "ADMIN"):
return session["role"]
return "FOREST" if is_forest_email(session.get("email")) else "USER"
async def home_project_ids(session: dict[str, Any], role: str) -> list[str]:
+252
View File
@@ -0,0 +1,252 @@
/* =============================================================================
* B01_Dashboard_UI_Home.ts
* 대시보드 홈(PLAN 547-2 · 요구 369~374) — 진행 중 프로젝트 수에 따라 꼴이 저절로 바뀜
* 1 = 큰 카드 · 2~3 = 중간 카드 · 4~8 = 한 줄 목록 + 팀원별 · 단계 분포 · 9 넘음 = 요약 · 그래프
* 오른쪽 = 처리할 일 · 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음이면 세 걸음
* 꼴 고정(자동 · 작게 · 크게) = localStorage 기억
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { fetchHome, type HomeData, type ProjectSummary } from "./B01_Dashboard_Api_Home";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import { bigCard, midCard, rowCard, type Go } from "./B01_Dashboard_UI_Home_Cards";
import { manyView, memberPane, pane, stagePane } from "./B01_Dashboard_UI_Home_Many";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { homeLayout, type HomeFixed, type HomeLayout } from "./logic/B01_Dashboard_Home_Layout";
import "./B01_Dashboard_UI_Style_Home.css";
const FIXED_KEY = "aislo.b01.homeLayout";
const LAYOUT_TEXT: Record<HomeLayout, string> = {
empty: "처음 시작",
one: "진행 중 1 개 → 큰 카드",
few: "진행 중 2~3 개 → 중간 카드",
list: "진행 중 4~8 개 → 한 줄 목록",
many: "진행 중 9 개 넘음 → 요약 · 그래프",
};
function readFixed(): HomeFixed {
const saved = localStorage.getItem(FIXED_KEY);
return saved === "small" || saved === "large" ? saved : "auto";
}
function fixedSwitch(current: HomeFixed, onPick: (next: HomeFixed) => void): HTMLElement {
const seg = el("div", {
className: "b01-home__seg",
attrs: { role: "group", "aria-label": "꼴" },
});
for (const [value, label] of [
["auto", "자동"],
["small", "작게"],
["large", "크게"],
] as const) {
const b = el("button", {
className: value === current ? "on" : "",
text: label,
attrs: { type: "button", "aria-pressed": String(value === current) },
});
b.addEventListener("click", () => onPick(value));
seg.append(b);
}
return seg;
}
function todoRow(dot: string, text: string, action: string, onClick: () => void): HTMLElement {
return el("div", {
className: "b01-home__todo",
children: [
el("span", { className: `b01-home__dot b01-home__dot--${dot}` }),
el("span", { text }),
createButton({ label: action, variant: "ghost", size: "sm", onClick }),
],
});
}
/** 처리할 일 — 없으면 「처리할 일 없음」 */
function todoPane(data: HomeData, go: Go): HTMLElement {
const t = data.todos;
const rows: HTMLElement[] = [];
for (const p of t.new_plans)
rows.push(
todoRow("plan", `새 예정노선 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
);
for (const p of t.updated_plans)
rows.push(
todoRow("warn", `업데이트 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
);
if (t.join_requests)
rows.push(todoRow("alert", `가입 요청 ${t.join_requests} 건`, "처리", () => go("people")));
for (const b of t.expiring_temp)
rows.push(
todoRow("warn", `보관 파일 ${b.name} · ${b.days_left} 일 남음`, "열기", () => go("temp")),
);
if (!rows.length) rows.push(el("div", { className: "b01-home__muted", text: "처리할 일 없음" }));
return pane("처리할 일", ...rows);
}
function sidePanes(data: HomeData, go: Go): HTMLElement[] {
const panes = [todoPane(data, go)];
panes.push(
pane(
"임시 보관함",
el("div", { className: "b01-home__muted", text: `보관 묶음 ${data.todos.temp_count} 개` }),
createButton({ label: "보관함 ›", variant: "ghost", size: "sm", onClick: () => go("temp") }),
),
);
if (data.company) {
const c = data.company;
panes.push(
pane(
c.name ?? "회사",
el("div", {
className: "b01-home__muted",
text: `팀원 ${c.member_count ?? 0} 명 · 진행 중 ${data.projects.length} 개`,
}),
createButton({
label: "사람 ›",
variant: "ghost",
size: "sm",
onClick: () => go("people"),
}),
),
);
}
if (data.forest) {
const f = data.forest;
panes.push(
pane(
"예정노선",
el("div", {
className: "b01-home__muted",
text: `분석 전 ${f.plan_status.draft} · 분석됨 ${f.plan_status.analyzed} · 확정 ${f.plan_status.confirmed}`,
}),
el("div", {
className: f.assign_waiting ? "b01-home__stale" : "b01-home__muted",
text: `업체 지정 대기 ${f.assign_waiting}`,
}),
),
);
}
return panes;
}
/** 처음 — 세 걸음(내 정보 · 파일 · 첫 프로젝트) */
function firstSteps(ctx: ViewCtx, data: HomeData): HTMLElement {
const me = ctx.me;
const steps: [boolean, string, string, () => void][] = [
[
Boolean(me.position || me.department || me.phone),
"내 정보 채우기",
"내 정보 ›",
() => ctx.go("me"),
],
[data.todos.temp_count > 0, "측량 파일 올려 두기", "보관함 ›", () => ctx.go("temp")],
[false, "첫 프로젝트 만들기", "+ 새 프로젝트", () => void openNewProjectPicker()],
];
return pane(
"처음 오셨네요 — 세 걸음",
...steps.map(([done, text, action, onClick], i) =>
el("div", {
className: `b01-home__step${done ? " b01-home__step--done" : ""}`,
children: [
el("span", { className: "b01-home__step-no", text: done ? "✓" : String(i + 1) }),
el("span", { text }),
createButton({ label: action, variant: done ? "ghost" : "filled", size: "sm", onClick }),
],
}),
),
);
}
function mainArea(layout: HomeLayout, ctx: ViewCtx, data: HomeData): HTMLElement {
const cards: ProjectSummary[] = data.projects;
const go = ctx.go;
if (layout === "empty")
return data.project_total
? pane(
"진행 중인 프로젝트 없음",
el("div", { className: "b01-home__muted", text: "모두 끝났습니다." }),
)
: firstSteps(ctx, data);
if (layout === "one") return bigCard(cards[0], go);
if (layout === "few") {
const mids = el("div", {
className: "b01-home__mids",
children: cards.slice(0, 8).map((c, i) => midCard(c, go, i === 0)),
});
if (cards.length <= 8) return mids;
const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") });
return el("div", { className: "b01-home__many", children: [mids, all] });
}
if (layout === "list")
return el("div", {
className: "b01-home__split b01-home__split--list",
children: [
el("div", {
children: [
el("div", {
className: "b01-home__muted",
text: `진행 중 ${cards.length} · 최근 작업 순`,
}),
el("div", { className: "b01-home__rows", children: cards.map((c) => rowCard(c, go)) }),
],
}),
el("div", { children: [memberPane(cards), stagePane(cards)] }),
],
});
return manyView(data, go);
}
export function mountHome(host: HTMLElement, ctx: ViewCtx): () => void {
let alive = true;
let data: HomeData | null = null;
const root = el("div", { className: "b01-home" });
host.append(root);
const draw = (): void => {
if (!data) return;
const fixed = readFixed();
const layout = homeLayout(data.projects.length, fixed);
root.dataset.layout = layout;
const head = el("div", {
className: "b01-home__head",
children: [
el("h2", { text: "홈" }),
el("span", { className: "b01-home__muted", text: LAYOUT_TEXT[layout] }),
el("span", { className: "b01-home__push" }),
fixedSwitch(fixed, (next) => {
if (next === "auto") localStorage.removeItem(FIXED_KEY);
else localStorage.setItem(FIXED_KEY, next);
draw();
}),
createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }),
],
});
const body = el("div", {
className: "b01-home__body",
children: [
el("div", { className: "b01-home__main", children: [mainArea(layout, ctx, data)] }),
el("aside", { className: "b01-home__side", children: sidePanes(data, ctx.go) }),
],
});
root.replaceChildren(head, body);
};
root.append(el("div", { className: "b01-home__muted", text: "불러오는 중…" }));
fetchHome()
.then((loaded) => {
if (!alive) return;
data = loaded;
draw();
})
.catch((error: unknown) => {
if (!alive) return;
root.replaceChildren(
el("div", { className: "b01-home__muted", text: "홈을 불러오지 못했습니다." }),
);
showToast(error instanceof Error ? error.message : String(error), "error");
});
return () => {
alive = false;
};
}
@@ -0,0 +1,219 @@
/* =============================================================================
* B01_Dashboard_UI_Home_Cards.ts
* 홈 카드 부품(PLAN 547-2) — 썸네일 선 · 단계 점 · 큰 카드(1) · 중간 카드(2~3) · 한 줄(4~8)
* [이어서 하기] = 그 단계 화면 · [자세히] · 줄 누름 = go("project", { id })
* ========================================================================== */
import type { RoutePath } from "@config/config_frontend";
import { createButton, el } from "@ui/ui_template_elements";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { groupFlowOf } from "../A00_Common/group_workflow";
import type { ProjectSummary } from "./B01_Dashboard_Api_Home";
import { L } from "./B01_Dashboard_UI_Common";
import { isStale } from "./logic/B01_Dashboard_Home_Layout";
export type Go = (view: string, params?: Record<string, string>) => void;
const SVG = "http://www.w3.org/2000/svg";
const KIND_LABEL = {
ROAD: "DE_New_Road",
PLAN: "DE_New_Plan",
STABILITY: "DE_New_Stability",
EROSION: "DE_New_Erosion",
} as const;
export const kindLabel = (kind: string): string =>
L(KIND_LABEL[kind as keyof typeof KIND_LABEL] ?? "DE_New_Road");
export const kindClass = (kind: string): string =>
`b01-home__kind--${(kind in KIND_LABEL ? kind : "ROAD").toLowerCase()}`;
/** 노선 중심선 썸네일 — 선이 없으면 빈 바탕 */
export function thumbSvg(card: ProjectSummary, className: string): SVGSVGElement {
const svg = document.createElementNS(SVG, "svg");
svg.setAttribute("class", `b01-home__thumb ${kindClass(card.kind)} ${className}`);
svg.setAttribute("viewBox", "-0.08 -0.08 1.16 1.16");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.setAttribute("aria-hidden", "true");
if (card.thumb) {
const line = document.createElementNS(SVG, "polyline");
line.setAttribute("points", card.thumb.map(([x, y]) => `${x},${y}`).join(" "));
line.setAttribute("vector-effect", "non-scaling-stroke");
svg.append(line);
}
return svg;
}
/** 단계 점 — 지난 단계 · 지금 단계 · 남은 단계 */
export function stagePips(card: ProjectSummary): HTMLElement {
const pips = el("div", { className: `b01-home__pips ${kindClass(card.kind)}` });
for (let i = 0; i < card.stage_total; i += 1) {
const state = i < card.stage_index ? "ok" : i === card.stage_index ? "cur" : "";
pips.append(el("i", { className: state }));
}
return pips;
}
export const stageText = (card: ProjectSummary): string =>
`${card.stage_index + 1} / ${card.stage_total} · ${card.stage_name}`;
/** 「n 일째 그대로」 · 오늘 · n 일 전 */
export function whenText(card: ProjectSummary): string {
const days = card.stale_days ?? null;
if (days === null) return "";
if (isStale(card)) return `${days} 일째 그대로`;
return days === 0 ? "오늘" : `${days} 일 전`;
}
export function continueStage(card: ProjectSummary): void {
const routes: readonly RoutePath[] = groupFlowOf(card.kind)?.routes ?? WORKFLOW_STEP_ROUTES;
void goToWorkflowStage(card.id, routes[Math.min(card.stage_index, routes.length - 1)]);
}
function kindBadge(card: ProjectSummary): HTMLElement {
return el("span", {
className: `b01-home__badge ${kindClass(card.kind)}`,
text: kindLabel(card.kind),
});
}
function title(card: ProjectSummary): HTMLElement {
return el("div", {
className: "b01-home__title",
children: [el("b", { text: card.name }), kindBadge(card)],
});
}
function nextBox(card: ProjectSummary): HTMLElement {
return el("div", { className: "b01-home__next", text: `다음: ${card.stage_name}` });
}
function whenLine(card: ProjectSummary): HTMLElement {
const line = el("div", { className: "b01-home__muted" });
const owner = card.owner_names[0];
if (owner) line.append(`${owner} · `);
line.append(
el("span", { className: isStale(card) ? "b01-home__stale" : "", text: whenText(card) }),
);
return line;
}
const METRIC_ROWS: [keyof ProjectSummary["metrics"], string, (v: number) => string][] = [
["length_m", "연장", (v) => `${v.toLocaleString()} m`],
["stations", "측점", (v) => `${v}`],
["structures", "구조물", (v) => `${v}`],
];
/** 핵심 숫자 — 있는 값만 */
function metricStrip(card: ProjectSummary): HTMLElement | null {
const cells = METRIC_ROWS.filter(([key]) => card.metrics[key] !== undefined).map(
([key, label, fmt]) =>
el("div", {
children: [
el("b", { text: fmt(card.metrics[key] as number) }),
el("span", { text: label }),
],
}),
);
return cells.length ? el("div", { className: "b01-home__metrics", children: cells }) : null;
}
const continueButton = (card: ProjectSummary, label = "이어서 하기 ›"): HTMLButtonElement =>
createButton({ label, size: "sm", onClick: () => continueStage(card) });
const detailButton = (card: ProjectSummary, go: Go): HTMLButtonElement =>
createButton({
label: "자세히",
variant: "ghost",
size: "sm",
onClick: () => go("project", { id: card.id }),
});
/** 진행 중 1 — 큰 카드(썸네일 · 단계 점 · 다음 할 일 · 핵심 숫자) */
export function bigCard(card: ProjectSummary, go: Go): HTMLElement {
const info = el("div", {
className: "b01-home__big-info",
children: [
el("div", { className: "b01-home__muted", text: "지금 하는 프로젝트" }),
title(card),
el("b", { className: "b01-home__stage", text: stageText(card) }),
stagePips(card),
nextBox(card),
whenLine(card),
el("div", {
className: "b01-home__actions",
children: [continueButton(card), detailButton(card, go)],
}),
],
});
const top = el("div", {
className: "b01-home__big-top",
children: [thumbSvg(card, "b01-home__thumb--big") as unknown as HTMLElement, info],
});
const metrics = metricStrip(card);
return el("div", {
className: "b01-home__pane b01-home__big",
children: metrics ? [top, metrics] : [top],
});
}
/** 진행 중 2~3 — 중간 카드(맨 앞 = 최근 작업) */
export function midCard(card: ProjectSummary, go: Go, first: boolean): HTMLElement {
const node = el("div", {
className: `b01-home__mid${first ? " b01-home__mid--first" : ""}`,
children: [
thumbSvg(card, "b01-home__thumb--mid") as unknown as HTMLElement,
el("div", {
className: "b01-home__mid-body",
children: [
whenLine(card),
title(card),
el("div", { className: "b01-home__muted", text: stageText(card) }),
stagePips(card),
nextBox(card),
el("div", {
className: "b01-home__actions",
children: [
continueButton(card, first ? "이어서 하기 ›" : "이어서 ›"),
detailButton(card, go),
],
}),
],
}),
],
});
return node;
}
/** 진행 중 4~8 — 한 줄(누름 = 상세 · 14 일 넘게 그대로면 노랑) */
export function rowCard(card: ProjectSummary, go: Go): HTMLElement {
const open = createButton({ label: "열기 ›", variant: "ghost", size: "sm" });
open.addEventListener("click", (event) => {
event.stopPropagation();
continueStage(card);
});
const row = el("div", {
className: `b01-home__row${isStale(card) ? " b01-home__row--stale" : ""}`,
attrs: { role: "button", tabindex: "0" },
children: [
thumbSvg(card, "b01-home__thumb--row") as unknown as HTMLElement,
el("div", { className: "b01-home__row-name", children: [title(card), whenLine(card)] }),
el("div", {
className: "b01-home__row-stage",
children: [
stagePips(card),
el("div", {
className: "b01-home__muted",
text: `${card.stage_name} ${card.stage_index + 1}/${card.stage_total}`,
}),
],
}),
open,
],
});
row.addEventListener("click", () => go("project", { id: card.id }));
row.addEventListener("keydown", (event) => {
if (event.key === "Enter") go("project", { id: card.id });
});
return row;
}
+207
View File
@@ -0,0 +1,207 @@
/* =============================================================================
* B01_Dashboard_UI_Home_Many.ts
* 홈 4 넘음 부품(PLAN 547-2) — 팀원별 · 단계 분포(4~8) · 요약 숫자 · 막대 · 원 그래프(9 넘음)
* 그래프 = 인라인 SVG(새 라이브러리 없음) · 색 = 셸 · 테마 변수(CSS 클래스)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { HomeData, ProjectSummary } from "./B01_Dashboard_Api_Home";
import { kindClass, kindLabel, type Go } from "./B01_Dashboard_UI_Home_Cards";
import {
countBy,
donutArcs,
isStale,
stageBuckets,
topWithRest,
} from "./logic/B01_Dashboard_Home_Layout";
const SVG = "http://www.w3.org/2000/svg";
function svgNode(tag: string, attrs: Record<string, string | number>): SVGElement {
const node = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
return node;
}
export function pane(titleText: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement {
const node = el("div", {
className: "b01-home__pane",
children: [el("h3", { className: "b01-home__pane-title", text: titleText })],
});
node.append(...children);
return node;
}
const ownerOf = (card: ProjectSummary): string => card.owner_names[0] ?? "담당 없음";
/** 팀원별 진행 중 개수 */
export function memberPane(cards: ProjectSummary[]): HTMLElement {
const rows = countBy(cards, ownerOf).map(({ label, count }) =>
el("div", {
className: "b01-home__member",
children: [
el("span", { className: "b01-home__avatar", text: label.slice(0, 1) }),
el("span", { text: label }),
el("span", { className: "b01-home__muted b01-home__push", text: `${count} 개` }),
],
}),
);
return pane("팀원별", ...rows);
}
/** 단계 분포 — 시작 전 · 설계 중 · 확정 대기 · 쌓은 막대 */
export function stagePane(cards: ProjectSummary[]): HTMLElement {
const b = stageBuckets(cards);
const total = Math.max(1, cards.length);
const parts: [number, string, string][] = [
[b.start, "start", "시작 전"],
[b.working, "working", "설계 중"],
[b.last, "last", "확정 대기"],
];
const bar = el("div", { className: "b01-home__stack" });
for (const [n, cls, label] of parts) {
if (!n) continue;
const seg = el("i", {
className: `b01-home__stack--${cls}`,
attrs: { title: `${label} ${n}` },
});
seg.style.width = `${(n / total) * 100}%`;
bar.append(seg);
}
const text = parts.map(([n, , label]) => `${label} ${n}`).join(" · ");
const stale = cards.filter(isStale).length;
const legend = el("div", { className: "b01-home__muted", text });
const children: HTMLElement[] = [bar, legend];
if (stale)
children.push(
el("div", { className: "b01-home__stale", text: `14 일 넘게 그대로 ${stale} 개` }),
);
return pane("단계 분포", ...children);
}
function kpi(label: string, value: string, note?: string, warn = false): HTMLElement {
return el("div", {
className: "b01-home__kpi",
children: [
el("div", { className: "b01-home__muted", text: label }),
el("b", { className: warn ? "b01-home__alert" : "", text: value }),
...(note ? [el("span", { className: "b01-home__muted", text: note })] : []),
],
});
}
/** 가로 막대 — 회사별(시스템 관리자) · 담당별(그 밖) · 앞 4 + 그 밖 */
function barChart(rows: { label: string; count: number; rest?: number }[]): SVGElement {
const shown = topWithRest(rows, 5);
const max = Math.max(1, ...shown.map((r) => r.count));
const rowH = 22;
const svg = svgNode("svg", {
class: "b01-home__bars",
viewBox: `0 0 320 ${shown.length * rowH}`,
width: "100%",
preserveAspectRatio: "xMinYMin meet",
role: "img",
});
shown.forEach((r, i) => {
const y = i * rowH;
const label = r.rest ? `그 밖 ${r.rest} 곳` : r.label;
const width = (r.count / max) * 180;
const text = svgNode("text", { x: 0, y: y + 14 });
text.textContent = label.length > 9 ? `${label.slice(0, 9)}…` : label;
const count = svgNode("text", { x: 96 + width, y: y + 14 });
count.textContent = String(r.count);
svg.append(
text,
svgNode("rect", { x: 90, y: y + 4, width, height: 12, rx: 3, class: r.rest ? "rest" : "" }),
count,
);
});
return svg;
}
/** 원 그래프 — 종류별 · 가운데 = 합 */
function kindDonut(cards: ProjectSummary[]): HTMLElement {
const kinds = countBy(cards, (c) => c.kind);
const arcs = donutArcs(kinds.map((k) => k.count));
const svg = svgNode("svg", {
class: "b01-home__donut",
viewBox: "0 0 42 42",
width: 96,
height: 96,
});
kinds.forEach((k, i) => {
svg.append(
svgNode("circle", {
cx: 21,
cy: 21,
r: 15.9155,
class: kindClass(k.label),
"stroke-dasharray": arcs[i].dash,
"stroke-dashoffset": arcs[i].offset,
}),
);
});
const sum = svgNode("text", { x: 21, y: 24, "text-anchor": "middle" });
sum.textContent = String(cards.length);
svg.append(sum);
const legend = el("div", {
className: "b01-home__legend",
children: kinds.map((k) =>
el("span", {
children: [
el("i", { className: `b01-home__swatch ${kindClass(k.label)}` }),
`${kindLabel(k.label)} ${k.count}`,
],
}),
),
});
return el("div", {
className: "b01-home__donut-row",
children: [svg as unknown as HTMLElement, legend],
});
}
/** 9 넘음 — 요약 숫자 · 회사별(담당별) 막대 · 종류 원 · 단계 분포 · [모든 프로젝트 ›] */
export function manyView(data: HomeData, go: Go): HTMLElement {
const cards = data.projects;
const sys = data.system;
const pending = data.todos.join_requests ?? 0;
const kpis = el("div", { className: "b01-home__kpis" });
if (sys) kpis.append(kpi("회사", String(sys.companies)), kpi("사용자", String(sys.users)));
kpis.append(kpi("진행 중 프로젝트", String(cards.length), `전체 ${data.project_total}`));
if (data.todos.join_requests !== undefined)
kpis.append(
kpi("처리 대기", String(pending), pending ? `가입 ${pending}` : undefined, pending > 0),
);
if (sys) {
const s = sys.server;
const disk = s.disk ?? 0;
kpis.append(
kpi(
"서버",
`디스크 ${Math.round(disk)}%`,
`CPU ${Math.round(s.cpu ?? 0)} · 메모리 ${Math.round(s.memory ?? 0)}`,
disk >= 85,
),
);
}
const rows = sys
? countBy(cards, (c) => sys.company_names[String(c.company_id)] ?? "회사 없음")
: countBy(cards, (c) => c.owner_names[0] ?? "담당 없음");
const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") });
return el("div", {
className: "b01-home__many",
children: [
kpis,
el("div", {
className: "b01-home__split",
children: [
pane(sys ? "회사별 진행 중" : "담당별 진행 중", barChart(rows)),
pane("종류", kindDonut(cards)),
],
}),
stagePane(cards),
el("div", { className: "b01-home__actions", children: [all] }),
],
});
}
@@ -0,0 +1,500 @@
/* 대시보드 홈(PLAN 547-2) — 색 · 간격 = 셸 변수(--b01-*) · 종류 색 = 테마 차트 변수 */
.b01-home {
--b01-home-road: var(--b01-accent);
--b01-home-plan: var(--color-chart-0);
--b01-home-stab: var(--color-chart-2);
--b01-home-warn: var(--color-warning);
display: flex;
flex-direction: column;
gap: var(--spacing-16);
color: var(--b01-text);
}
.b01-home__head {
display: flex;
align-items: center;
gap: var(--spacing-12);
flex-wrap: wrap;
}
.b01-home__head h2 {
margin: 0;
font-size: 19px;
}
.b01-home__push {
margin-left: auto;
}
.b01-home__muted {
color: var(--b01-muted);
font-size: 12px;
}
.b01-home__stale {
color: var(--b01-home-warn);
font-size: 12px;
font-weight: 600;
}
.b01-home__alert {
color: var(--b01-alert);
}
.b01-home__seg {
display: inline-flex;
border: 1px solid var(--b01-line);
border-radius: 8px;
overflow: hidden;
}
.b01-home__seg button {
border: 0;
background: var(--b01-card);
color: var(--b01-text);
padding: var(--spacing-4) var(--spacing-12);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.b01-home__seg button.on {
background: var(--b01-accent);
color: var(--b01-accent-text);
}
.b01-home__body {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
gap: var(--spacing-16);
align-items: start;
}
.b01-home__main,
.b01-home__side {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.b01-home__pane {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
padding: var(--spacing-12) var(--spacing-16);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b01-home__pane > .ui-btn {
align-self: flex-start;
}
.b01-home__pane-title {
margin: 0;
font-size: 13px;
}
.b01-home__actions {
display: flex;
gap: var(--spacing-8);
flex-wrap: wrap;
}
/* 종류 배지 · 썸네일 · 단계 점 */
.b01-home__kind--road {
--kind: var(--b01-home-road);
}
.b01-home__kind--plan {
--kind: var(--b01-home-plan);
}
.b01-home__kind--stability {
--kind: var(--b01-home-stab);
}
.b01-home__kind--erosion {
--kind: var(--b01-muted);
}
.b01-home__badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
border-radius: 5px;
padding: 0 6px;
white-space: nowrap;
color: var(--kind);
border: 1px solid var(--kind);
}
.b01-home__title {
display: flex;
align-items: center;
gap: var(--spacing-8);
min-width: 0;
}
.b01-home__title b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-home__thumb {
display: block;
width: 100%;
background: var(--b01-accent-soft);
}
.b01-home__thumb polyline {
fill: none;
stroke: var(--kind);
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
}
.b01-home__thumb--big {
height: 210px;
}
.b01-home__thumb--mid {
height: 100px;
}
.b01-home__thumb--row {
width: 80px;
height: 44px;
border-radius: 6px;
}
.b01-home__thumb--row polyline {
stroke-width: 2;
}
.b01-home__pips {
display: flex;
gap: 4px;
}
.b01-home__pips i {
flex: 1;
height: 7px;
border-radius: 4px;
background: var(--b01-line);
box-sizing: border-box;
}
.b01-home__pips i.ok {
background: var(--kind);
}
.b01-home__pips i.cur {
background: var(--b01-card);
border: 2px solid var(--kind);
}
.b01-home__next {
border: 1px solid var(--b01-home-warn);
border-radius: 8px;
padding: var(--spacing-4) var(--spacing-8);
font-size: 12.5px;
}
/* 1 — 큰 카드 */
.b01-home__big {
padding: 0;
overflow: hidden;
gap: 0;
}
.b01-home__big-top {
display: grid;
grid-template-columns: 1.1fr 1fr;
}
.b01-home__big-info {
padding: var(--spacing-12) var(--spacing-16);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b01-home__big-info .b01-home__title b {
font-size: 17px;
}
.b01-home__metrics {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
border-top: 1px solid var(--b01-line);
}
.b01-home__metrics div {
padding: var(--spacing-8) var(--spacing-12);
border-left: 1px solid var(--b01-line);
}
.b01-home__metrics div:first-child {
border-left: 0;
}
.b01-home__metrics b {
display: block;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.b01-home__metrics span {
color: var(--b01-muted);
font-size: 11.5px;
}
/* 2~3 — 중간 카드 */
.b01-home__mids {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--spacing-12);
}
.b01-home__mid {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
overflow: hidden;
}
.b01-home__mid--first {
border-color: var(--b01-accent);
}
.b01-home__mid-body {
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
/* 4~8 — 한 줄 목록 */
.b01-home__split {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: var(--spacing-12);
align-items: start;
}
.b01-home__split--list {
grid-template-columns: minmax(0, 1fr) 220px;
}
.b01-home__split--list > div {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.b01-home__rows {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
overflow: hidden;
}
.b01-home__row {
display: grid;
grid-template-columns: 80px minmax(0, 1.2fr) minmax(0, 1fr) auto;
gap: var(--spacing-12);
align-items: center;
padding: var(--spacing-8) var(--spacing-12);
border-top: 1px solid var(--b01-line);
cursor: pointer;
}
.b01-home__row:first-child {
border-top: 0;
}
.b01-home__row:hover,
.b01-home__row:focus-visible {
background: var(--b01-hover);
outline: none;
}
.b01-home__row--stale {
box-shadow: inset 3px 0 0 var(--b01-home-warn);
}
.b01-home__member {
display: flex;
align-items: center;
gap: var(--spacing-8);
font-size: 12.5px;
}
.b01-home__avatar {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 700;
background: var(--b01-accent-soft);
color: var(--b01-accent);
}
.b01-home__stack {
display: flex;
height: 10px;
border-radius: 6px;
overflow: hidden;
background: var(--b01-line);
}
.b01-home__stack i {
display: block;
height: 100%;
}
.b01-home__stack--start {
background: var(--b01-accent-soft);
}
.b01-home__stack--working {
background: var(--b01-accent);
}
.b01-home__stack--last {
background: var(--b01-home-warn);
}
/* 9 넘음 — 요약 숫자 · 그래프 */
.b01-home__many {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
.b01-home__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: var(--spacing-12);
}
.b01-home__kpi {
background: var(--b01-card);
border: 1px solid var(--b01-line);
border-radius: var(--b01-radius);
padding: var(--spacing-8) var(--spacing-12);
}
.b01-home__kpi b {
display: block;
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.b01-home__bars text {
font-size: 10px;
fill: var(--b01-text);
}
.b01-home__bars rect {
fill: var(--b01-accent);
}
.b01-home__bars rect.rest {
fill: var(--b01-accent-soft);
}
.b01-home__donut-row {
display: flex;
align-items: center;
gap: var(--spacing-16);
}
.b01-home__donut circle {
fill: none;
stroke: var(--kind);
stroke-width: 6;
}
.b01-home__donut text {
font-size: 8px;
font-weight: 700;
fill: var(--b01-text);
}
.b01-home__legend {
display: grid;
gap: var(--spacing-4);
font-size: 12px;
}
.b01-home__legend span {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.b01-home__swatch {
width: 9px;
height: 9px;
border-radius: 3px;
background: var(--kind);
}
/* 오른쪽 — 처리할 일 · 처음 세 걸음 */
.b01-home__todo,
.b01-home__step {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--spacing-8);
align-items: center;
font-size: 12.5px;
}
.b01-home__dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.b01-home__dot--plan {
background: var(--b01-home-plan);
}
.b01-home__dot--warn {
background: var(--b01-home-warn);
}
.b01-home__dot--alert {
background: var(--b01-alert);
}
.b01-home__step-no {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
border: 2px solid var(--b01-accent);
color: var(--b01-accent);
}
.b01-home__step--done .b01-home__step-no {
background: var(--b01-accent);
color: var(--b01-accent-text);
}
@media (max-width: 900px) {
.b01-home__body,
.b01-home__split,
.b01-home__split--list,
.b01-home__big-top {
grid-template-columns: 1fr;
}
.b01-home__row {
grid-template-columns: 60px minmax(0, 1fr) auto;
}
.b01-home__row-stage {
display: none;
}
}
+2 -1
View File
@@ -10,6 +10,7 @@
import type { IconName } from "@ui/ui_template_icons";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import { mountHome } from "./B01_Dashboard_UI_Home";
import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy";
import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav";
@@ -46,7 +47,7 @@ const legacy =
// prettier-ignore
export const VIEWS: View[] = [
// 작업
{ key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: legacy("projects") },
{ key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: mountHome },
{ key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: legacy("projects") },
{ key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: legacy("projects") },
{ key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: legacy("assigned") },
@@ -0,0 +1,80 @@
/* =============================================================================
* logic/B01_Dashboard_Home_Layout.ts
* 홈 꼴 고르기 · 묶어 세기(PLAN 547-2) — 순수 함수 · 시험 `test_547_2_home_layout.py`
* 진행 중 1 = one · 2~3 = few · 4~8 = list · 9 넘음 = many · 0 = empty
* 고정 small = list(9 넘음 = many) · large = one(1) / few(2 넘음)
* ========================================================================== */
export type HomeLayout = "empty" | "one" | "few" | "list" | "many";
export type HomeFixed = "auto" | "small" | "large";
export const STALE_DAYS = 14;
export const MANY_FROM = 9;
export function homeLayout(count: number, fixed: HomeFixed = "auto"): HomeLayout {
if (count <= 0) return "empty";
if (fixed === "large") return count === 1 ? "one" : "few";
if (fixed === "small") return count >= MANY_FROM ? "many" : "list";
if (count === 1) return "one";
if (count <= 3) return "few";
return count < MANY_FROM ? "list" : "many";
}
interface StageLike {
stage_index: number;
stage_total: number;
stale_days?: number | null;
}
export const isStale = (card: StageLike): boolean => (card.stale_days ?? 0) >= STALE_DAYS;
/** 단계 분포 — 시작 전(첫 단계) · 설계 중 · 확정 대기(마지막 단계) */
export function stageBuckets(cards: StageLike[]): { start: number; working: number; last: number } {
const out = { start: 0, working: 0, last: 0 };
for (const c of cards) {
if (c.stage_index <= 0) out.start += 1;
else if (c.stage_index >= c.stage_total - 1) out.last += 1;
else out.working += 1;
}
return out;
}
/** 묶어 세기 — 많은 차례 · 같으면 이름 차례 */
export function countBy<T>(
items: T[],
key: (item: T) => string,
): { label: string; count: number }[] {
const counts = new Map<string, number>();
for (const item of items) counts.set(key(item), (counts.get(key(item)) ?? 0) + 1);
return [...counts]
.map(([label, count]) => ({ label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}
/** 앞 limit 개 + 나머지 하나로 묶음(「그 밖 N 곳」) */
export function topWithRest(
rows: { label: string; count: number }[],
limit: number,
): { label: string; count: number; rest?: number }[] {
if (rows.length <= limit) return rows;
const rest = rows.slice(limit - 1);
return [
...rows.slice(0, limit - 1),
{ label: "", count: rest.reduce((sum, r) => sum + r.count, 0), rest: rest.length },
];
}
/** 원 그래프 조각 — 둘레 100 기준 stroke-dasharray · stroke-dashoffset(12 시 시작 · 시계 방향) */
export function donutArcs(values: number[]): { dash: string; offset: number }[] {
const total = values.reduce((sum, v) => sum + v, 0);
if (total <= 0) return [];
let start = 0;
return values.map((v) => {
const len = (v / total) * 100;
const arc = { dash: `${round2(len)} ${round2(100 - len)}`, offset: round2(25 - start) };
start += len;
return arc;
});
}
const round2 = (v: number): number => Math.round(v * 100) / 100;
+10 -7
View File
@@ -24,13 +24,16 @@ def _text(path: Path) -> str:
def _run_views(body: str) -> object:
src = re.sub(r"^import[\s\S]*?;\n", "", _text(B01 / "B01_Dashboard_UI_Views.ts"), flags=re.M)
script = (
"const mountLegacy = () => {}; const mountGuide = () => {}; const mountChat = () => {};\n"
+ src
+ "\n"
+ body
)
text = _text(B01 / "B01_Dashboard_UI_Views.ts")
# import 한 값(type 아닌 이름) = 빈 함수 — 페이지 줄이 새 mount 를 끌어와도 그대로 돎
stubs = [
name.split(" as ")[-1].strip()
for group in re.findall(r"^import \{([^}]*)\}", text, flags=re.M)
for name in group.split(",")
if name.strip() and not name.strip().startswith("type ")
]
src = re.sub(r"^import[\s\S]*?;\n", "", text, flags=re.M)
script = "".join(f"const {n} = () => {{}};\n" for n in stubs) + src + "\n" + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
+152
View File
@@ -0,0 +1,152 @@
"""547-2 대시보드 홈 — 개수별 꼴 고르기(TS logic) · 역할 가르기 · 역할별 홈 API 칸."""
import asyncio
import json
import re
import subprocess
from pathlib import Path
import pytest
import B01_Dashboard.B01_Dashboard_Router_Home as home
ROOT = Path(__file__).resolve().parents[2]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
LOGIC = ROOT / "B01_Dashboard" / "logic" / "B01_Dashboard_Home_Layout.ts"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as m from "./B01_Dashboard_Home_Layout.js";
const counts = [0, 1, 2, 3, 4, 8, 9, 40];
const out = {
auto: counts.map((n) => m.homeLayout(n)),
small: counts.map((n) => m.homeLayout(n, "small")),
large: counts.map((n) => m.homeLayout(n, "large")),
buckets: m.stageBuckets([
{ stage_index: 0, stage_total: 7 }, { stage_index: 3, stage_total: 7 },
{ stage_index: 6, stage_total: 7 }, { stage_index: 2, stage_total: 3 },
]),
stale: [m.isStale({ stage_index: 1, stage_total: 7, stale_days: 13 }),
m.isStale({ stage_index: 1, stage_total: 7, stale_days: 14 })],
count: m.countBy(["나", "가", "나"], (x) => x),
rest: m.topWithRest([5, 4, 3, 2, 1, 1].map((c, i) => ({ label: "c" + i, count: c })), 5),
donut: m.donutArcs([1, 1, 2]),
};
writeFileSync(process.argv[2], JSON.stringify(out));
"""
@pytest.fixture(scope="module")
def ts(tmp_path_factory):
if not TSC.is_file():
pytest.skip("프론트엔드 의존성 미설치")
tmp = tmp_path_factory.mktemp("home")
out = tmp / "js"
args = ["--outDir", str(out), "--module", "esnext", "--target", "es2022", "--ignoreConfig"]
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(LOGIC), *args], cwd=str(ROOT), check=True, capture_output=True
)
for emitted in out.glob("*.js"):
text = emitted.read_text(encoding="utf-8")
emitted.write_text(re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', text), encoding="utf-8")
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
dst = tmp / "out.json"
subprocess.run(["node", str(out / "runner.mjs"), str(dst)], check=True, capture_output=True) # noqa: S603, S607
return json.loads(dst.read_text(encoding="utf-8"))
def test_layout_by_count(ts):
# 개수 0 1 2 3 4 8 9 40
assert ts["auto"] == ["empty", "one", "few", "few", "list", "list", "many", "many"]
assert ts["small"] == ["empty", "list", "list", "list", "list", "list", "many", "many"]
assert ts["large"] == ["empty", "one", "few", "few", "few", "few", "few", "few"]
def test_counts_and_charts(ts):
assert ts["buckets"] == {"start": 1, "working": 1, "last": 2}
assert ts["stale"] == [False, True]
assert ts["count"] == [{"label": "나", "count": 2}, {"label": "가", "count": 1}]
assert ts["rest"][-1] == {"label": "", "count": 2, "rest": 2} and len(ts["rest"]) == 5
assert [a["dash"] for a in ts["donut"]] == ["25 75", "25 75", "50 50"]
assert [a["offset"] for a in ts["donut"]] == [25, 0, -25]
def test_home_role_order_matches_shell():
s = {"email": "a@forest.go.kr"}
assert home.home_role({**s, "role": "SYSTEM_ADMIN"}) == "SYSTEM_ADMIN"
assert home.home_role({**s, "role": "ADMIN"}) == "ADMIN"
assert home.home_role({**s, "role": "USER"}) == "FOREST"
assert home.home_role({"email": "a@b.com", "role": "USER"}) == "USER"
class _Cursor:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql, args=()):
self.sql = sql
async def fetchone(self):
return {"n": 2, "name": "회사", "member_count": 3, "companies": 4, "users": 9}
async def fetchall(self):
if "temp_upload_batches" in self.sql or "plan_route_years" in self.sql:
return []
return [{"id": 1, "name": "회사"}]
class _Pool:
def acquire(self):
class _Conn:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
def cursor(self, *_):
return _Cursor()
return _Conn()
@pytest.mark.parametrize(
("session", "keys", "join"),
[
({"role": "SYSTEM_ADMIN", "user_id": 1, "company_id": 1}, {"system"}, True),
({"role": "ADMIN", "user_id": 1, "company_id": 1}, {"company"}, True),
({"role": "USER", "user_id": 1, "company_id": 4, "email": "x@a.go.kr"}, {"forest"}, False),
({"role": "USER", "user_id": 1, "company_id": 1, "email": "x@a.com"}, set(), False),
],
)
def test_home_blocks_by_role(monkeypatch, session, keys, join):
async def fake_ids(_session, _role):
return ["a", "b"]
async def fake_summaries(ids):
return [
{"id": i, "done": i == "b", "thumb": [[0, 0], [1, 1]]} for i in ids
] # b = 완료 → 빠짐
async def fake_plans(_session):
return {"routes": []}
async def fake_resources(days):
return {"current": {"cpu_usage_percent": 1, "memory_usage_percent": 2}}
import D01_PlanRegister.D01_PlanRegister_Router as d01
monkeypatch.setattr(home, "home_project_ids", fake_ids)
monkeypatch.setattr(home, "project_summaries", fake_summaries)
monkeypatch.setattr(home, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(home, "get_system_resources", fake_resources)
monkeypatch.setattr(d01, "assigned_plan_routes", fake_plans)
body = asyncio.run(home.dashboard_home(session=session))
assert {"system", "company", "forest"} & body.keys() == keys
assert [p["id"] for p in body["projects"]] == ["a"] and body["project_total"] == 2
assert ("join_requests" in body["todos"]) is join
if "system" in body:
assert body["system"]["server"] == {"cpu": 1, "memory": 2, "disk": None}