diff --git a/client/src/components/dashboard/primitives.tsx b/client/src/components/dashboard/primitives.tsx index e6b6c029..f35b1d42 100644 --- a/client/src/components/dashboard/primitives.tsx +++ b/client/src/components/dashboard/primitives.tsx @@ -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 ( +
+
+ + + {total > 0 && + shown.map((s, i) => { + const len = (s.count / total) * circ; + const el = ( + + ); + offset += len; + return el; + })} + +
+ + {centerValue} + + + {centerLabel} + +
+
+ + {shown.length > 0 ? ( + + ) : ( +

{emptyLabel}

+ )} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// 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 ( + + {label} + + ); +} + // ──────────────────────────────────────────────────────────────────────────── // Hub card (smaller, used for module navigation grids) // ──────────────────────────────────────────────────────────────────────────── diff --git a/client/src/pages/admin/AdminDashboard.tsx b/client/src/pages/admin/AdminDashboard.tsx index 8cc61d28..fd815c09 100644 --- a/client/src/pages/admin/AdminDashboard.tsx +++ b/client/src/pages/admin/AdminDashboard.tsx @@ -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"; @@ -65,6 +67,22 @@ const AUDIT_ICON: Record = { 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 = { + 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 @@ -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 ( -
- {points.map((p, idx) => ( -
- - {t(`admin:applicationStatus.${p.status}`, { defaultValue: p.status })} - -
-
-
- - {p.count} - -
- ))} -
- ); -} - function greetingKey(): "morning" | "afternoon" | "evening" { const h = new Date().getHours(); if (h < 12) return "morning"; @@ -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( + () => + (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) => { @@ -300,9 +304,14 @@ export function AdminDashboard() { subtitle={t("dashboard:admin.charts.funnelSubtitle")} > {funnel.isLoading ? ( -
+
) : ( - + )}
diff --git a/client/src/pages/company/Dashboard.tsx b/client/src/pages/company/Dashboard.tsx index 6baec8b6..d9ed87a9 100644 --- a/client/src/pages/company/Dashboard.tsx +++ b/client/src/pages/company/Dashboard.tsx @@ -10,7 +10,6 @@ import { BarChart2, Plus, ArrowRight, - FileText, Users, Clock, } from 'lucide-react'; @@ -26,8 +25,10 @@ import { StatCard, QuickActions, ChartCard, - CategoryBars, - type CategoryBar, + DonutChart, + StatusPill, + type DonutSegment, + type StatAccent, } from '@/components/dashboard/primitives'; import { formatRelativeTime } from '@/components/dashboard/utils'; @@ -47,11 +48,28 @@ export interface ScholarshipProviderRatingsSummaryDto { recentReviews: ScholarshipProviderReviewRow[]; } -// Status buckets shown in the "Applications by status" breakdown, in order. +// Status buckets shown in the "Applications by status" donut, in order. // Shortlisted MUST be included — a shortlisted application is a real, non-terminal // state; omitting it dropped those rows from the chart and the pending count. const COMPANY_STATUS_ORDER = ['Applied', 'Pending', 'UnderReview', 'WaitingResult', 'Shortlisted', 'Accepted', 'Rejected']; +// Per-status visual encoding: a pill tone (background chip) + a donut color +// (design-system status token). Mirrors StudentDashboard's STATUS_META so a given +// status reads the same everywhere (donut slice color == row pill tone). Draft / +// Withdrawn are here purely so recent-row pills resolve a tone if a row lands in +// one of those states — the donut only iterates COMPANY_STATUS_ORDER. +const STATUS_META: Record = { + 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)' }, + Draft: { tone: 'neutral', color: 'var(--color-status-withdrawn)' }, +}; + function greetingKey(): 'morning' | 'afternoon' | 'evening' { const h = new Date().getHours(); if (h < 12) return 'morning'; @@ -77,7 +95,7 @@ export function ScholarshipProviderDashboard() { staleTime: 60_000, }); - const { data: applicationsPage } = useQuery({ + const { data: applicationsPage, isLoading: appsLoading } = useQuery({ queryKey: ['applications', 'company', 'all'], queryFn: () => applicationsApi.getScholarshipProviderApplications(undefined, 1, 50), staleTime: 60_000, @@ -110,13 +128,31 @@ export function ScholarshipProviderDashboard() { (byStatus['Shortlisted'] ?? 0); // Real status distribution across ALL applications (no fabricated numbers — - // straight from the server aggregate). Ordered + zero buckets dropped. - const statusBreakdown = useMemo(() => { - return COMPANY_STATUS_ORDER.map((s) => ({ - label: t(`applications:scholarshipProviderReview.status.${s}`, { defaultValue: s }), - count: byStatus[s] ?? 0, - })).filter((x) => x.count > 0); - }, [byStatus, t]); + // straight from the server aggregate). Ordered, colored to match the pills, + // zero buckets dropped. Feeds the DonutChart. + const donutSegments = useMemo( + () => + COMPANY_STATUS_ORDER.map((s) => ({ + label: t(`applications:scholarshipProviderReview.status.${s}`, { defaultValue: s }), + count: byStatus[s] ?? 0, + color: STATUS_META[s]?.color ?? 'var(--color-text-tertiary)', + })).filter((x) => x.count > 0), + [byStatus, t], + ); + + // Newest-first slice of the recent applicant rows, surfaced as a real list with + // status pills (avatar initial + student + scholarship + relative time). + const recentApps = useMemo( + () => + [...apps] + .sort((a, b) => { + const ta = a.submittedAt ? new Date(a.submittedAt).getTime() : 0; + const tb = b.submittedAt ? new Date(b.submittedAt).getTime() : 0; + return tb - ta; + }) + .slice(0, 6), + [apps], + ); if (ratingsLoading) { return ( @@ -195,19 +231,113 @@ export function ScholarshipProviderDashboard() {
+ {/* Left column: application pipeline donut + live applicant list */}
-
-
+
+

+ {t('applications:scholarshipProviderReview.title', { defaultValue: 'Review Applications' })} +

+ + {t('dashboard:activity.viewAll')} + +
+ + {appsLoading ? ( +
    + {[0, 1, 2, 3].map((i) => ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ))} +
+ ) : recentApps.length === 0 ? ( +
+

{t('dashboard:activity.emptyTitle')}

+

{t('dashboard:activity.emptyBody')}

+
+ ) : ( +
    + {recentApps.map((app) => { + const meta = STATUS_META[app.status] ?? { tone: 'neutral' as StatAccent }; + const initial = (app.studentName || '?').trim().charAt(0).toUpperCase(); + return ( +
  • + + + {initial} + +
    +

    + {app.studentName} +

    +

    + {app.scholarshipTitle} + {app.submittedAt && ( + <> + {' · '} + {formatRelativeTime(app.submittedAt, i18n.language)} + + )} +

    +
    + + +
  • + ); + })} +
+ )} +
+
+ + {/* Right column: quick actions + student ratings */} +
- -
diff --git a/client/src/pages/consultant/ConsultantDashboard.tsx b/client/src/pages/consultant/ConsultantDashboard.tsx index 26035875..a16f7217 100644 --- a/client/src/pages/consultant/ConsultantDashboard.tsx +++ b/client/src/pages/consultant/ConsultantDashboard.tsx @@ -17,16 +17,17 @@ import { usePaymentsEnabled } from "@/hooks/usePlatformStatus"; import { bookingsApi } from "@/services/api/bookings"; import { notificationsApi } from "@/services/api/notifications"; import { queryKeys } from "@/lib/queryClient"; +import { formatDateTime } from "@/lib/bookingFormat"; import { WelcomeBanner, StatCard, - HubCard, ActivityFeed, QuickActions, ChartCard, - CategoryBars, + DonutChart, + StatusPill, type ActivityItem, - type CategoryBar, + type DonutSegment, type StatAccent, } from "@/components/dashboard/primitives"; import { formatRelativeTime } from "@/components/dashboard/utils"; @@ -49,8 +50,8 @@ function greetingKey(): "morning" | "afternoon" | "evening" { // Booking buckets shown in the "Bookings by status" breakdown, in order. The // no-show statuses (reported + both confirmed sides) are merged into a single -// "NoShow" row at render time. Rejected (consultant declined) and Expired (no -// response in time) are real terminal outcomes and must be shown, not dropped. +// "NoShow" segment at render time. Rejected (consultant declined) and Expired +// (no response in time) are real terminal outcomes and must be shown. const CONSULTANT_BOOKING_STATUSES = [ "Requested", "Confirmed", @@ -60,6 +61,20 @@ const CONSULTANT_BOOKING_STATUSES = [ "Expired", ]; +// Per-status visual encoding: a pill tone (background chip) and a donut color +// (a design-system status token). Keeps the distribution donut and the row +// pills in sync so a status reads the same everywhere. "NoShow" is the merged +// bucket for the three server no-show statuses. +const STATUS_META: Record = { + Requested: { tone: "warning", color: "var(--color-brand-400)" }, + Confirmed: { tone: "brand", color: "var(--color-status-applied)" }, + Completed: { tone: "success", color: "var(--color-status-accepted)" }, + Cancelled: { tone: "neutral", color: "var(--color-status-withdrawn)" }, + Rejected: { tone: "danger", color: "var(--color-status-rejected)" }, + Expired: { tone: "neutral", color: "var(--color-text-tertiary)" }, + NoShow: { tone: "warning", color: "var(--color-status-pending)" }, +}; + export function ConsultantDashboard() { const { t, i18n } = useTranslation(["dashboard"]); const paymentsEnabled = usePaymentsEnabled(); @@ -121,14 +136,38 @@ export function ConsultantDashboard() { (bookingCounts.NoShowStudent ?? 0) + (bookingCounts.NoShowConsultant ?? 0) + (bookingCounts.NoShowReported ?? 0); - const bookingBreakdown: CategoryBar[] = [ + // Donut segments encode each status by its design-system color; zero-count + // segments are dropped so the ring and legend only show real states. + const bookingSegments: DonutSegment[] = [ ...CONSULTANT_BOOKING_STATUSES.map((s) => ({ label: t(`dashboard:consultant.bookingsByStatus.statuses.${s}`), count: bookingCounts[s] ?? 0, + color: STATUS_META[s]?.color ?? "var(--color-text-tertiary)", })), - { label: t("dashboard:consultant.bookingsByStatus.statuses.NoShow"), count: noShowCount }, + { + label: t("dashboard:consultant.bookingsByStatus.statuses.NoShow"), + count: noShowCount, + color: STATUS_META.NoShow.color, + }, ].filter((x) => x.count > 0); + // The next confirmed sessions, soonest first — a real, actionable list that + // replaces the redundant nav-tile grid (those links live in Quick actions). + const nowMs = now.getTime(); + const upcomingList = useMemo( + () => + bookings + .filter( + (b) => b.status === "Confirmed" && new Date(b.scheduledStartAt).getTime() > nowMs, + ) + .sort( + (a, b) => + new Date(a.scheduledStartAt).getTime() - new Date(b.scheduledStartAt).getTime(), + ) + .slice(0, 6), + [bookings, nowMs], + ); + const activities = useMemo(() => { if (!notifPage) return []; const isAr = i18n.language === "ar"; @@ -152,15 +191,6 @@ export function ConsultantDashboard() { }); }, [notifPage, i18n.language, paymentsEnabled]); - const CARDS: Array<{ icon: LucideIcon; to: string; titleKey: string; accent: StatAccent }> = [ - { icon: Clock, to: "/consultant/availability", titleKey: "availability", accent: "brand" }, - { icon: CalendarCheck, to: "/consultant/bookings", titleKey: "bookings", accent: "success" }, - // Earnings hub card is money-related — only surface it when payments are on. - ...(paymentsEnabled - ? [{ icon: Wallet, to: "/consultant/earnings", titleKey: "earnings", accent: "warning" as StatAccent }] - : []), - ]; - return (
+ {/* Left column: booking distribution + upcoming-sessions list */}
- -
-
-

- {t("dashboard:consultant.cards.bookings.title")} +
+
+

+ {t("dashboard:consultant.stats.upcoming")}

+ + {t("dashboard:activity.viewAll")} +
-
- {CARDS.map(({ icon, to, titleKey, accent }, idx) => ( - - ))} -
+ + {upcomingList.length === 0 ? ( +
+

+ {t("dashboard:consultant.upcomingSessions.empty", { + defaultValue: "No upcoming sessions scheduled.", + })} +

+
+ ) : ( +
    + {upcomingList.map((b) => { + const meta = STATUS_META.Confirmed; + const initial = (b.studentName || "?").trim().charAt(0).toUpperCase(); + return ( +
  • + + + {initial} + +
    +

    + {b.studentName} +

    +

    + {formatDateTime(b.scheduledStartAt, i18n.language)} +

    +
    + + +
  • + ); + })} +
+ )}

diff --git a/client/src/pages/student/StudentDashboard.tsx b/client/src/pages/student/StudentDashboard.tsx index 89eaae02..6d75116f 100644 --- a/client/src/pages/student/StudentDashboard.tsx +++ b/client/src/pages/student/StudentDashboard.tsx @@ -27,13 +27,13 @@ import { useBookmarksQuery } from "@/hooks/useScholarshipsQuery"; import { WelcomeBanner, StatCard, - HubCard, ActivityFeed, QuickActions, ChartCard, - CategoryBars, + DonutChart, + StatusPill, type ActivityItem, - type CategoryBar, + type DonutSegment, type StatAccent, } from "@/components/dashboard/primitives"; import { formatRelativeTime } from "@/components/dashboard/utils"; @@ -72,6 +72,24 @@ const STUDENT_APP_STATUSES = [ "Withdrawn", ]; +// Per-status visual encoding: a pill tone (background chip) and a donut color +// (a design-system status token). Keeps the pipeline chart and the row pills in +// sync so a status reads the same everywhere. +const STATUS_META: Record = { + 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)" }, +}; + +const TERMINAL_STATUSES = new Set(["Accepted", "Rejected", "Withdrawn"]); + export function StudentDashboard() { const { t, i18n } = useTranslation(["dashboard", "notifications", "applications"]); const firstName = useAuthStore((s) => s.user?.firstName ?? ""); @@ -106,9 +124,11 @@ export function StudentDashboard() { staleTime: 30_000, }); - const activeApps = applications.filter( - (a) => a.status !== "Accepted" && a.status !== "Rejected" && a.status !== "Withdrawn", - ).length; + const activeApplications = useMemo( + () => applications.filter((a) => !TERMINAL_STATUSES.has(a.status)), + [applications], + ); + const activeApps = activeApplications.length; const upcomingBookings = bookings.filter( (b) => b.status === "Confirmed" && new Date(b.scheduledStartAt) > new Date(), @@ -119,11 +139,22 @@ export function StudentDashboard() { acc[a.status] = (acc[a.status] ?? 0) + 1; return acc; }, {}); - const appBreakdown: CategoryBar[] = STUDENT_APP_STATUSES.map((s) => ({ + const donutSegments: DonutSegment[] = STUDENT_APP_STATUSES.map((s) => ({ label: t(`applications:kanban.columns.${s}`, { defaultValue: s }), count: appCounts[s] ?? 0, + color: STATUS_META[s]?.color ?? "var(--color-text-tertiary)", })).filter((x) => x.count > 0); + // The most-recently-updated active applications, surfaced as a real list with + // status pills — replaces a redundant nav-tile grid with actionable content. + const recentActive = useMemo( + () => + [...activeApplications] + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + .slice(0, 6), + [activeApplications], + ); + const activities = useMemo(() => { if (!notifPage) return []; const isAr = i18n.language === "ar"; @@ -140,18 +171,6 @@ export function StudentDashboard() { }); }, [notifPage, i18n.language]); - const CARDS: Array<{ icon: LucideIcon; to: string; titleKey: string; descKey: string }> = [ - { icon: GraduationCap, to: "/student/scholarships", titleKey: "scholarships", descKey: "scholarships" }, - { icon: FileText, to: "/student/applications", titleKey: "applications", descKey: "applications" }, - { icon: Bookmark, to: "/student/bookmarks", titleKey: "bookmarks", descKey: "bookmarks" }, - { icon: Users, to: "/student/consultants", titleKey: "consultants", descKey: "consultants" }, - { icon: CalendarCheck, to: "/student/bookings", titleKey: "bookings", descKey: "bookings" }, - { icon: MessageSquare, to: "/student/community", titleKey: "community", descKey: "community" }, - { icon: MessageCircle, to: "/student/messages", titleKey: "messages", descKey: "messages" }, - { icon: Sparkles, to: "/student/ai", titleKey: "ai", descKey: "ai" }, - { icon: BookOpen, to: "/student/resources", titleKey: "resources", descKey: "resources" }, - ]; - return (
- {/* Left column: nav hub cards */} + {/* Left column: application pipeline + live application list */}
- -
+
-
-

- {t("dashboard:student.cards.scholarships.title")} -

-
+

+ {t("dashboard:student.cards.applications.title")} +

+ + {t("dashboard:activity.viewAll")} +
-
- {CARDS.map(({ icon, to, titleKey, descKey }, idx) => ( - - ))} -
+ + {recentActive.length === 0 ? ( +
+

+ {t("dashboard:student.applicationsByStatus.empty")} +

+
+ ) : ( +
    + {recentActive.map((a) => { + const meta = STATUS_META[a.status] ?? { tone: "neutral" as StatAccent }; + const initial = (a.scholarshipTitle || "?").trim().charAt(0).toUpperCase(); + return ( +
  • + + + {initial} + +
    +

    + {a.scholarshipTitle} +

    +

    + {a.scholarshipProviderName ?? + t(`applications:mode.${a.mode}`, { defaultValue: a.mode })} + {" · "} + {formatRelativeTime(a.updatedAt, i18n.language)} +

    +
    + + +
  • + ); + })} +
+ )}