Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions client/src/components/dashboard/primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,125 @@ export function CategoryBars({
);
}

// ────────────────────────────────────────────────────────────────────────────
// Donut / ring chart — proportional distribution with a center total + legend.
// A richer alternative to CategoryBars for status/composition breakdowns. Feed
// it labelled counts and a CSS color per segment; zero-total renders an empty
// ring so the card never looks broken. Colors are passed as CSS values (design
// tokens) so it themes automatically in light + dark.
// ────────────────────────────────────────────────────────────────────────────

export interface DonutSegment {
label: string;
count: number;
/** A CSS color value — e.g. "var(--color-brand-500)". */
color: string;
}

export function DonutChart({
segments,
centerValue,
centerLabel,
size = 148,
thickness = 18,
emptyLabel,
}: {
segments: DonutSegment[];
centerValue: number | string;
centerLabel: string;
size?: number;
thickness?: number;
emptyLabel?: string;
}) {
const total = segments.reduce((sum, s) => sum + s.count, 0);
const shown = segments.filter((s) => s.count > 0);
const r = (size - thickness) / 2;
const circ = 2 * Math.PI * r;
let offset = 0;

return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-4">
<div className="relative shrink-0" style={{ width: size, height: size }}>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="-rotate-90"
role="img"
aria-label={`${centerValue} ${centerLabel}`}
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="var(--color-bg-subtle)"
strokeWidth={thickness}
/>
{total > 0 &&
shown.map((s, i) => {
const len = (s.count / total) * circ;
const el = (
<circle
key={i}
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={s.color}
strokeWidth={thickness}
strokeDasharray={`${len} ${circ - len}`}
strokeDashoffset={-offset}
/>
);
offset += len;
return el;
})}
</svg>
<div className="absolute inset-0 grid place-content-center text-center">
<span className="text-2xl font-bold tabular-nums tracking-tight text-text-primary">
{centerValue}
</span>
<span className="text-[10px] font-medium uppercase tracking-wide text-text-tertiary">
{centerLabel}
</span>
</div>
</div>

{shown.length > 0 ? (
<ul className="min-w-[150px] flex-1 space-y-2">
{shown.map((s) => (
<li key={s.label} className="flex items-center gap-2.5 text-sm">
<span className="size-2.5 shrink-0 rounded-sm" style={{ background: s.color }} aria-hidden />
<span className="flex-1 truncate text-text-secondary">{s.label}</span>
<span className="font-semibold tabular-nums text-text-primary">{s.count}</span>
</li>
))}
</ul>
) : (
<p className="flex-1 text-sm text-text-tertiary">{emptyLabel}</p>
)}
</div>
);
}

// ────────────────────────────────────────────────────────────────────────────
// Status pill — a small tinted chip encoding state in color as well as text.
// ────────────────────────────────────────────────────────────────────────────

export function StatusPill({ label, tone = "neutral" }: { label: string; tone?: StatAccent }) {
return (
<span
className={cn(
"inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-semibold",
ACCENT_BG[tone],
)}
>
{label}
</span>
);
}

// ────────────────────────────────────────────────────────────────────────────
// Hub card (smaller, used for module navigation grids)
// ────────────────────────────────────────────────────────────────────────────
Expand Down
75 changes: 42 additions & 33 deletions client/src/pages/admin/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ import {
QuickActions,
ChartCard,
SmoothAreaChart,
DonutChart,
TimeRangeTabs,
type StatAccent,
type DonutSegment,
} from "@/components/dashboard/primitives";
import { formatRelativeTime } from "@/components/dashboard/utils";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -65,6 +67,22 @@ const AUDIT_ICON: Record<string, { icon: LucideIcon; accent: StatAccent }> = {
ConsultantAvailabilityUpdated: { icon: Clock, accent: "brand" },
};

// Per-status visual encoding for the application-funnel donut: a design-system
// status color per state. Mirrors the Student dashboard's STATUS_META so a
// status reads the same color across the whole product.
const STATUS_META: Record<string, { tone: StatAccent; color: string }> = {
Intending: { tone: "neutral", color: "var(--color-status-planned)" },
Draft: { tone: "neutral", color: "var(--color-status-withdrawn)" },
Applied: { tone: "brand", color: "var(--color-status-applied)" },
Pending: { tone: "warning", color: "var(--color-status-pending)" },
UnderReview: { tone: "warning", color: "var(--color-brand-400)" },
Shortlisted: { tone: "brand", color: "var(--color-status-planned)" },
WaitingResult:{ tone: "warning", color: "var(--color-warning-500)" },
Accepted: { tone: "success", color: "var(--color-status-accepted)" },
Rejected: { tone: "danger", color: "var(--color-status-rejected)" },
Withdrawn: { tone: "neutral", color: "var(--color-status-withdrawn)" },
};

/**
* A real period-over-period delta for a StatCard, or null when there's no
* meaningful baseline (both windows empty) — so the card never shows a
Expand All @@ -84,37 +102,6 @@ function formatCents(cents: number): string {
}).format(cents / 100);
}

interface FunnelBarProps {
points: ApplicationStatusPoint[];
}

function FunnelBars({ points }: FunnelBarProps) {
const { t } = useTranslation(["admin"]);
if (points.length === 0) return null;
const max = Math.max(...points.map((p) => p.count), 1);
const colors = ["bg-brand-500", "bg-success-500", "bg-warning-500", "bg-danger-500", "bg-text-tertiary"];
return (
<div className="space-y-3">
{points.map((p, idx) => (
<div key={p.status} className="flex items-center gap-3">
<span className="w-28 shrink-0 truncate text-xs font-medium text-text-secondary">
{t(`admin:applicationStatus.${p.status}`, { defaultValue: p.status })}
</span>
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-bg-subtle">
<div
className={cn("h-full rounded-full transition-all", colors[idx % colors.length])}
style={{ width: `${(p.count / max) * 100}%` }}
/>
</div>
<span className="w-10 shrink-0 text-end text-xs font-semibold tabular-nums text-text-primary">
{p.count}
</span>
</div>
))}
</div>
);
}

function greetingKey(): "morning" | "afternoon" | "evening" {
const h = new Date().getHours();
if (h < 12) return "morning";
Expand Down Expand Up @@ -154,6 +141,23 @@ export function AdminDashboard() {

const o = overview.data;
const growthValues = useMemo(() => (growth.data ?? []).map((p) => p.count), [growth.data]);

// Real application-status distribution → donut segments. Colors come from the
// shared STATUS_META so every status matches its color elsewhere in the app.
// The center total is the genuine sum of the funnel counts — no fabrication.
const funnelSegments = useMemo<DonutSegment[]>(
() =>
(funnel.data ?? []).map((p) => ({
label: t(`admin:applicationStatus.${p.status}`, { defaultValue: p.status }),
count: p.count,
color: STATUS_META[p.status]?.color ?? "var(--color-text-tertiary)",
})),
[funnel.data, t],
);
const funnelTotal = useMemo(
() => (funnel.data ?? []).reduce((sum, p) => sum + p.count, 0),
[funnel.data],
);
const growthLabels = useMemo(
() =>
(growth.data ?? []).map((p) => {
Expand Down Expand Up @@ -300,9 +304,14 @@ export function AdminDashboard() {
subtitle={t("dashboard:admin.charts.funnelSubtitle")}
>
{funnel.isLoading ? (
<div className="h-32 animate-pulse rounded-lg bg-bg-subtle" />
<div className="h-40 animate-pulse rounded-lg bg-bg-subtle" />
) : (
<FunnelBars points={funnel.data ?? []} />
<DonutChart
segments={funnelSegments}
centerValue={funnelTotal}
centerLabel={t("admin:dashboard.totalApplications")}
emptyLabel={t("dashboard:student.applicationsByStatus.empty")}
/>
)}
</ChartCard>
</div>
Expand Down
Loading
Loading