+
{ratings.averageRating.toFixed(1)}
@@ -235,7 +365,7 @@ export function ScholarshipProviderDashboard() {
body={t('dashboard:company.reviews.emptyBody')}
/>
) : (
-
+
{(ratings.recentReviews ?? []).slice(0, 4).map((review) => (
)}
-
-
-
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 */}
-
-
+
-
- {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)}
+
+
+
+
+
+ );
+ })}
+
+ )}