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