Compare commits

...

8 Commits

Author SHA1 Message Date
“kirukib”
e35defe48a ui-fixes 2026-02-27 19:03:47 +03:00
“kirukib”
c7447f68ad Merge branch 'production' into front 2026-02-27 17:20:40 +03:00
“kirukib”
d0e694bc07 t 2026-02-27 17:08:11 +03:00
66c5adf6c2 Merge branch 'production' of https://gitea.yaltopia.com/Yimaru/Yimaru-Admin into production 2026-02-27 01:09:52 -08:00
6f9323de27 version display added 2026-02-27 01:04:05 -08:00
Kerod-Fresenbet-Gebremedhin2660
8b405e015c Empty commit to trigger CI/CD - 2 2026-02-24 19:44:53 +03:00
Kerod-Fresenbet-Gebremedhin2660
9c6b5eef6d Empty commit to trigger CI/CD - 2 2026-02-24 19:15:55 +03:00
Kerod-Fresenbet-Gebremedhin2660
d02aff35fa Empty commit to trigger CI/CD - 1 2026-02-24 19:11:34 +03:00
10 changed files with 594 additions and 391 deletions

2
.env
View File

@ -1,2 +1,2 @@
VITE_API_BASE_URL= https://api.yimaru.yaltopia.com/
VITE_API_BASE_URL= https://api.yimaru.yaltopia.com/api/v1
VITE_GOOGLE_CLIENT_ID=google_client_id

2
src/globals.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare const __BUILD_HASH__: string
declare const __BUILD_TIME__: string

View File

@ -27,7 +27,7 @@ import {
} from "recharts"
import { StatCard } from "../components/dashboard/StatCard"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
// import { cn } from "../lib/utils"
import { cn } from "../lib/utils"
import { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api"
import { useEffect, useState } from "react"
@ -44,6 +44,7 @@ export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("")
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
useEffect(() => {
const fetchUser = async () => {
@ -115,69 +116,109 @@ export function DashboardPage() {
<div className="flex items-center justify-center py-20 text-destructive">Failed to load dashboard data.</div>
) : (
<>
{/* Stat Cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={Users}
label="Total Users"
value={dashboard.users.total_users.toLocaleString()}
deltaLabel={`+${dashboard.users.new_month} this month`}
deltaPositive={dashboard.users.new_month > 0}
/>
<StatCard
icon={BadgeCheck}
label="Active Subscribers"
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
deltaPositive={dashboard.subscriptions.new_month > 0}
/>
<StatCard
icon={DollarSign}
label="Total Revenue (ETB)"
value={dashboard.payments.total_revenue.toLocaleString()}
deltaLabel={`${dashboard.payments.total_payments} payments`}
deltaPositive={dashboard.payments.total_revenue > 0}
/>
<StatCard
icon={TicketCheck}
label="Issues"
value={`${dashboard.issues.resolved_issues}/${dashboard.issues.total_issues}`}
deltaLabel={`${(dashboard.issues.resolution_rate * 100).toFixed(1)}% resolved`}
deltaPositive={dashboard.issues.resolution_rate > 0.5}
/>
{/* Stat tabs */}
<div className="mb-3 border-b border-grayScale-200">
<div className="-mb-px flex gap-6">
<button
type="button"
onClick={() => setActiveStatTab("primary")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeStatTab === "primary"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700",
)}
>
Overview
{activeStatTab === "primary" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
<button
type="button"
onClick={() => setActiveStatTab("secondary")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeStatTab === "secondary"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700",
)}
>
More metrics
{activeStatTab === "secondary" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
</div>
</div>
{/* Stat Cards */}
{activeStatTab === "primary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={Users}
label="Total Users"
value={dashboard.users.total_users.toLocaleString()}
deltaLabel={`+${dashboard.users.new_month} this month`}
deltaPositive={dashboard.users.new_month > 0}
/>
<StatCard
icon={BadgeCheck}
label="Active Subscribers"
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
deltaPositive={dashboard.subscriptions.new_month > 0}
/>
<StatCard
icon={DollarSign}
label="Total Revenue (ETB)"
value={dashboard.payments.total_revenue.toLocaleString()}
deltaLabel={`${dashboard.payments.total_payments} payments`}
deltaPositive={dashboard.payments.total_revenue > 0}
/>
<StatCard
icon={TicketCheck}
label="Issues"
value={`${dashboard.issues.resolved_issues}/${dashboard.issues.total_issues}`}
deltaLabel={`${(dashboard.issues.resolution_rate * 100).toFixed(1)}% resolved`}
deltaPositive={dashboard.issues.resolution_rate > 0.5}
/>
</div>
)}
{/* Secondary Stats */}
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={BookOpen}
label="Courses"
value={dashboard.courses.total_courses.toLocaleString()}
deltaLabel={`${dashboard.courses.total_sub_courses} sub-courses, ${dashboard.courses.total_videos} videos`}
deltaPositive
/>
<StatCard
icon={HelpCircle}
label="Questions"
value={dashboard.content.total_questions.toLocaleString()}
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
deltaPositive
/>
<StatCard
icon={Bell}
label="Notifications"
value={dashboard.notifications.total_sent.toLocaleString()}
deltaLabel={`${dashboard.notifications.unread_count} unread`}
deltaPositive={dashboard.notifications.unread_count === 0}
/>
<StatCard
icon={UsersRound}
label="Team Members"
value={dashboard.team.total_members.toLocaleString()}
deltaLabel={`${dashboard.team.by_role.length} roles`}
deltaPositive
/>
</div>
{activeStatTab === "secondary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={BookOpen}
label="Courses"
value={dashboard.courses.total_courses.toLocaleString()}
deltaLabel={`${dashboard.courses.total_sub_courses} sub-courses, ${dashboard.courses.total_videos} videos`}
deltaPositive
/>
<StatCard
icon={HelpCircle}
label="Questions"
value={dashboard.content.total_questions.toLocaleString()}
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
deltaPositive
/>
<StatCard
icon={Bell}
label="Notifications"
value={dashboard.notifications.total_sent.toLocaleString()}
deltaLabel={`${dashboard.notifications.unread_count} unread`}
deltaPositive={dashboard.notifications.unread_count === 0}
/>
<StatCard
icon={UsersRound}
label="Team Members"
value={dashboard.team.total_members.toLocaleString()}
deltaLabel={`${dashboard.team.by_role.length} roles`}
deltaPositive
/>
</div>
)}
{/* User Registrations Chart */}
<div className="mt-5 grid gap-4">

View File

@ -44,7 +44,7 @@ function formatDateTime(dateStr: string | null | undefined): string {
function LoadingSkeleton() {
return (
<div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-10 sm:px-6">
<div className="mx-auto w-full max-w-6xl space-y-8 px-4 py-10 sm:px-6">
<div className="animate-pulse space-y-8">
{/* Hero skeleton */}
<div className="overflow-hidden rounded-2xl border border-grayScale-100">
@ -93,15 +93,15 @@ function InfoRow({
extra?: React.ReactNode;
}) {
return (
<div className="group flex items-center justify-between rounded-lg px-3 py-3 transition-colors hover:bg-grayScale-100/60">
<div className="group flex flex-col gap-1 rounded-lg px-3 py-3 transition-colors hover:bg-grayScale-100/60 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 text-sm text-grayScale-400">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400 transition-colors group-hover:bg-brand-100 group-hover:text-brand-500">
<Icon className="h-4 w-4" />
</div>
<span className="font-medium">{label}</span>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
<span className="text-right">{value || "—"}</span>
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600 sm:justify-end min-w-0">
<span className="truncate text-right sm:text-left">{value || "—"}</span>
{extra}
</div>
</div>
@ -121,13 +121,13 @@ function VerifiedIcon({ verified }: { verified: boolean }) {
}
function ProgressRing({ percent }: { percent: number }) {
const radius = 18;
const radius = 14;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percent / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center">
<svg className="h-11 w-11 -rotate-90" viewBox="0 0 44 44">
<svg className="h-8 w-8 -rotate-90" viewBox="0 0 44 44">
<circle
cx="22"
cy="22"
@ -150,7 +150,7 @@ function ProgressRing({ percent }: { percent: number }) {
className="text-brand-500 transition-all duration-700"
/>
</svg>
<span className="absolute text-[10px] font-bold text-brand-600">{percent}%</span>
<span className="absolute text-[9px] font-bold text-brand-600">{percent}%</span>
</div>
);
}
@ -179,7 +179,7 @@ export function ProfilePage() {
if (error || !profile) {
return (
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
<div className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6">
<Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-5 p-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
@ -203,227 +203,310 @@ export function ProfilePage() {
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
const completionPct = profile.profile_completion_percentage ?? 0;
const sectionCardIcons: Record<string, { icon: typeof User; color: string }> = {
personal: { icon: User, color: "from-brand-500 to-brand-600" },
contact: { icon: Mail, color: "from-brand-400 to-brand-500" },
account: { icon: Shield, color: "from-brand-600 to-brand-500" },
};
return (
<div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-8 sm:px-6">
{/* Hero Card */}
<Card className="overflow-hidden border-0 shadow-lg">
{/* Banner gradient */}
<div className="relative h-36 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 sm:h-40">
{/* Decorative pattern overlay */}
<div className="absolute inset-0 opacity-10">
<div
className="h-full w-full"
style={{
backgroundImage:
"radial-gradient(circle at 25% 50%, white 1px, transparent 1px), radial-gradient(circle at 75% 50%, white 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6">
{/* Page header (no tabs) */}
<div className="mb-5">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">My Info</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-grayScale-800">Profile</h1>
</div>
{/* Main profile layout card */}
<div className="rounded-2xl border border-grayScale-100 bg-white shadow-sm">
{/* Header strip */}
<div className="border-b border-grayScale-100 px-6 py-4 sm:px-8">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">Overview</p>
<p className="mt-1 text-sm text-grayScale-500">
Personal, job and account details for this team member.
</p>
</div>
</div>
{/* Bottom fade */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white/20 to-transparent" />
</div>
<CardContent className="-mt-16 px-6 pb-8 pt-0 sm:px-10">
<div className="flex flex-col items-center text-center">
{/* Avatar */}
<Avatar className="h-28 w-28 ring-4 ring-white shadow-lg">
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
<AvatarFallback className="bg-gradient-to-br from-brand-100 to-brand-200 text-2xl font-bold text-brand-600">
{initials}
</AvatarFallback>
</Avatar>
{/* Name */}
<h1 className="mt-4 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl">
{fullName}
</h1>
{/* Role badge */}
<Badge
className={cn(
"mt-2.5 px-3 py-1",
profile.role === "ADMIN"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200"
)}
>
<Shield className="h-3 w-3 mr-1.5" />
{profile.role}
</Badge>
{/* Status pills */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-2.5">
{/* Active status */}
<div
className={cn(
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
profile.status === "ACTIVE"
? "border-mint-300 bg-mint-100/60 text-mint-500"
: "border-destructive/20 bg-destructive/10 text-destructive"
)}
>
<span
className={cn(
"h-2 w-2 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500 animate-pulse" : "bg-destructive"
)}
/>
{profile.status}
<div className="px-6 py-6 sm:px-8 sm:py-7">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1.8fr)_minmax(0,1.2fr)]">
{/* Left column: About & details */}
<div className="space-y-6">
{/* Identity */}
<div className="flex flex-col gap-4 sm:flex-row">
<Avatar className="h-16 w-16 sm:h-18 sm:w-18">
<AvatarImage src={profile.profile_picture_url || undefined} alt={fullName} />
<AvatarFallback className="bg-grayScale-100 text-base font-semibold text-grayScale-600">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight text-grayScale-800">{fullName}</h2>
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
#{profile.id}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge
className={cn(
"px-2.5 py-0.5 text-xs font-semibold",
profile.role === "ADMIN"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200"
)}
>
<Shield className="mr-1 h-3 w-3" />
{profile.role}
</Badge>
<span className="inline-flex items-center gap-1.5 rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
<Calendar className="h-3 w-3" />
Joined {formatDate(profile.created_at)}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
profile.status === "ACTIVE"
? "bg-mint-50 text-mint-600"
: "bg-destructive/10 text-destructive"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
)}
/>
{profile.status}
</span>
<div className="inline-flex items-center gap-2 rounded-full border border-brand-100 bg-brand-50/60 px-2.5 py-0.5 text-xs font-semibold text-brand-600">
<ProgressRing percent={completionPct} />
<span>Profile complete</span>
</div>
</div>
</div>
</div>
{/* Email verification */}
<div
className={cn(
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
profile.email_verified
? "border-mint-300 bg-mint-100/60 text-mint-500"
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
)}
>
{profile.email_verified ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
Email {profile.email_verified ? "Verified" : "Unverified"}
{/* About / Contact */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
About
</h3>
<div className="space-y-1.5 rounded-xl border border-grayScale-100 bg-grayScale-50/60 px-3 py-3">
<InfoRow icon={Phone} label="Phone" value={profile.phone_number} extra={<VerifiedIcon verified={profile.phone_verified} />} />
<InfoRow icon={Mail} label="Email" value={profile.email} extra={<VerifiedIcon verified={profile.email_verified} />} />
<InfoRow
icon={MapPin}
label="Location"
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
/>
</div>
</div>
{/* Phone verification */}
<div
className={cn(
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors",
profile.phone_verified
? "border-mint-300 bg-mint-100/60 text-mint-500"
: "border-grayScale-200 bg-grayScale-100/60 text-grayScale-400"
)}
>
{profile.phone_verified ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
Phone {profile.phone_verified ? "Verified" : "Unverified"}
{/* Employee details */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Employee details
</h3>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:text-sm text-grayScale-500">
<div>
<dt className="text-grayScale-400">Date of birth</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{formatDate(profile.birth_day)}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Age</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.age ? `${profile.age} years` : "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Gender</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.gender || "Not specified"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Age group</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.age_group || "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Occupation</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.occupation || "—"}
</dd>
</div>
<div>
<dt className="text-grayScale-400">Preferred language</dt>
<dd className="mt-0.5 font-medium text-grayScale-700">
{profile.preferred_language || "—"}
</dd>
</div>
</dl>
</div>
</div>
{/* Middle column: Job information */}
<div className="space-y-6">
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Job information
</h3>
<div className="overflow-x-auto rounded-xl border border-grayScale-100">
<table className="w-full min-w-[600px] border-collapse text-sm">
<thead className="bg-grayScale-50 text-xs font-medium uppercase tracking-[0.12em] text-grayScale-400">
<tr>
<th className="px-4 py-2 text-left">Title</th>
<th className="px-4 py-2 text-left">Team</th>
<th className="px-4 py-2 text-left">Division</th>
<th className="px-4 py-2 text-left">Manager</th>
<th className="px-4 py-2 text-left">Hire date</th>
<th className="px-4 py-2 text-left">Location</th>
</tr>
</thead>
<tbody className="divide-y divide-grayScale-100 text-grayScale-700">
<tr>
<td className="px-4 py-3">{profile.occupation || profile.role}</td>
<td className="px-4 py-3">{profile.role}</td>
<td className="px-4 py-3">{profile.preferred_language || "—"}</td>
<td className="px-4 py-3"></td>
<td className="px-4 py-3">{formatDate(profile.created_at)}</td>
<td className="px-4 py-3">
{[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Profile completion ring */}
<div className="flex items-center gap-2 rounded-full border border-brand-200 bg-brand-100/30 px-3 py-1 text-xs font-semibold text-brand-600">
<ProgressRing percent={completionPct} />
<span>Profile Complete</span>
{/* Learning & goals */}
<div className="grid gap-4 md:grid-cols-2">
<Card className="shadow-none border-grayScale-100">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-grayScale-700">
Learning goal
</CardTitle>
</CardHeader>
<CardContent className="pt-1">
<p className="text-sm text-grayScale-500">
{profile.learning_goal || "No learning goal specified."}
</p>
</CardContent>
</Card>
<Card className="shadow-none border-grayScale-100">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-grayScale-700">
Language goal
</CardTitle>
</CardHeader>
<CardContent className="pt-1">
<p className="text-sm text-grayScale-500">
{profile.language_goal || "No language goal specified."}
</p>
</CardContent>
</Card>
</div>
</div>
{/* Right column: Activity & account summary */}
<div className="space-y-6">
{/* Activity */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Activity
</h3>
<Card className="shadow-none border-grayScale-100">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-brand-600">
<Clock className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-700">
Last login
</p>
<p className="text-xs text-grayScale-400">
{formatDateTime(profile.last_login)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-grayScale-50 text-grayScale-500">
<User className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-700">
Account created
</p>
<p className="text-xs text-grayScale-400">
{formatDateTime(profile.created_at)}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Account summary */}
<div>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Account
</h3>
<Card className="shadow-none border-grayScale-100">
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Role</span>
<span className="font-medium text-grayScale-700">{profile.role}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Status</span>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs font-semibold",
profile.status === "ACTIVE"
? "text-mint-600"
: "text-destructive"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
)}
/>
{profile.status}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Email</span>
<span className="flex items-center gap-1 text-grayScale-700">
<span className="truncate max-w-[140px] text-right text-xs sm:text-sm">
{profile.email}
</span>
<VerifiedIcon verified={profile.email_verified} />
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Phone</span>
<span className="flex items-center gap-1 text-grayScale-700">
<span className="truncate max-w-[120px] text-right text-xs sm:text-sm">
{profile.phone_number || "—"}
</span>
<VerifiedIcon verified={profile.phone_verified} />
</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Info Cards */}
<div className="grid gap-6 md:grid-cols-3">
{/* Personal Information */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
<User className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Personal Information
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow icon={User} label="Full Name" value={fullName} />
<InfoRow icon={User} label="Gender" value={profile.gender || "Not specified"} />
<InfoRow icon={Calendar} label="Birthday" value={formatDate(profile.birth_day)} />
<InfoRow icon={User} label="Age Group" value={profile.age_group || "—"} />
<InfoRow icon={Briefcase} label="Occupation" value={profile.occupation || "—"} />
</CardContent>
</Card>
{/* Contact & Location */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
<Mail className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Contact & Location
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow
icon={Mail}
label="Email"
value={profile.email}
extra={<VerifiedIcon verified={profile.email_verified} />}
/>
<InfoRow
icon={Phone}
label="Phone"
value={profile.phone_number}
extra={<VerifiedIcon verified={profile.phone_verified} />}
/>
<InfoRow icon={Globe} label="Country" value={profile.country || "—"} />
<InfoRow icon={MapPin} label="Region" value={profile.region || "—"} />
</CardContent>
</Card>
{/* Account Details */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg">
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
<Shield className="h-4 w-4" />
</div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
Account Details
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-0.5 px-3 pb-4">
<InfoRow icon={Shield} label="Role" value={profile.role} />
<InfoRow
icon={Languages}
label="Language"
value={profile.preferred_language || "—"}
/>
<InfoRow
icon={Clock}
label="Last Login"
value={formatDateTime(profile.last_login)}
/>
<InfoRow
icon={Calendar}
label="Member Since"
value={formatDate(profile.created_at)}
/>
<InfoRow
icon={CheckCircle2}
label="Status"
value={profile.status}
extra={
<span
className={cn(
"h-2.5 w-2.5 rounded-full ring-2",
profile.status === "ACTIVE"
? "bg-mint-500 ring-mint-100"
: "bg-destructive ring-destructive/20"
)}
/>
}
/>
</CardContent>
</Card>
</div>
</div>
</div>
);

View File

@ -282,6 +282,7 @@ export function AnalyticsPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
const fetchData = async () => {
setLoading(true)
@ -384,107 +385,166 @@ export function AnalyticsPage() {
</div>
</div>
{/* Summary Tabs */}
<div className="mb-4 border-b border-grayScale-200">
<div className="-mb-px flex gap-6">
<button
onClick={() => setActiveSummaryTab("key")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeSummaryTab === "key" ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700",
)}
>
Key Metrics
{activeSummaryTab === "key" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
<button
onClick={() => setActiveSummaryTab("content")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeSummaryTab === "content" ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700",
)}
>
Content &amp; Platform
{activeSummaryTab === "content" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
<button
onClick={() => setActiveSummaryTab("operations")}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeSummaryTab === "operations" ? "text-brand-600" : "text-grayScale-400 hover:text-grayScale-700",
)}
>
Operations
{activeSummaryTab === "operations" && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
</div>
</div>
<div className="space-y-4">
{/* ─── Key Metrics ─── */}
<Section title="Key Metrics" icon={TrendingUp} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={Users}
label="Total Users"
value={formatNumber(users.total_users)}
sub={`+${users.new_today} today · +${users.new_week} this week · +${users.new_month} this month`}
trend={users.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={BadgeCheck}
label="Active Subscriptions"
value={formatNumber(subscriptions.active_subscriptions)}
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={DollarSign}
label="Total Revenue"
value={`ETB ${formatNumber(payments.total_revenue)}`}
sub={`${payments.successful_payments}/${payments.total_payments} successful · Avg ETB ${payments.avg_transaction_value.toLocaleString()}`}
trend={payments.total_revenue > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={TicketCheck}
label="Issue Resolution"
value={`${(issues.resolution_rate * 100).toFixed(1)}%`}
sub={`${issues.resolved_issues} resolved of ${issues.total_issues} total`}
trend={issues.resolution_rate >= 0.5 ? "up" : "down"}
/>
</div>
</Section>
{activeSummaryTab === "key" && (
<>
{/* ─── Key Metrics ─── */}
<Section title="Key Metrics" icon={TrendingUp} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={Users}
label="Total Users"
value={formatNumber(users.total_users)}
sub={`+${users.new_today} today · +${users.new_week} this week · +${users.new_month} this month`}
trend={users.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={BadgeCheck}
label="Active Subscriptions"
value={formatNumber(subscriptions.active_subscriptions)}
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={DollarSign}
label="Total Revenue"
value={`ETB ${formatNumber(payments.total_revenue)}`}
sub={`${payments.successful_payments}/${payments.total_payments} successful · Avg ETB ${payments.avg_transaction_value.toLocaleString()}`}
trend={payments.total_revenue > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={TicketCheck}
label="Issue Resolution"
value={`${(issues.resolution_rate * 100).toFixed(1)}%`}
sub={`${issues.resolved_issues} resolved of ${issues.total_issues} total`}
trend={issues.resolution_rate >= 0.5 ? "up" : "down"}
/>
</div>
</Section>
</>
)}
{/* ─── Content & Platform ─── */}
<Section title="Content & Platform" icon={BookOpen} count={courses.total_courses + content.total_questions} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={FolderOpen}
label="Categories"
value={courses.total_categories.toLocaleString()}
sub={`${courses.total_courses} courses`}
trend="neutral"
/>
<KpiCard
{activeSummaryTab === "content" && (
<>
{/* ─── Content & Platform ─── */}
<Section
title="Content & Platform"
icon={BookOpen}
label="Sub-Courses"
value={courses.total_sub_courses.toLocaleString()}
sub={`across ${courses.total_courses} courses`}
trend="neutral"
/>
<KpiCard
icon={Video}
label="Videos"
value={courses.total_videos.toLocaleString()}
trend="neutral"
/>
<KpiCard
icon={HelpCircle}
label="Questions"
value={content.total_questions.toLocaleString()}
sub={`${content.total_question_sets} question sets`}
trend="neutral"
/>
</div>
</Section>
count={courses.total_courses + content.total_questions}
defaultOpen
>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={FolderOpen}
label="Categories"
value={courses.total_categories.toLocaleString()}
sub={`${courses.total_courses} courses`}
trend="neutral"
/>
<KpiCard
icon={BookOpen}
label="Sub-Courses"
value={courses.total_sub_courses.toLocaleString()}
sub={`across ${courses.total_courses} courses`}
trend="neutral"
/>
<KpiCard
icon={Video}
label="Videos"
value={courses.total_videos.toLocaleString()}
trend="neutral"
/>
<KpiCard
icon={HelpCircle}
label="Questions"
value={content.total_questions.toLocaleString()}
sub={`${content.total_question_sets} question sets`}
trend="neutral"
/>
</div>
</Section>
</>
)}
{/* ─── Operations ─── */}
<Section title="Operations" icon={Bell} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={Bell}
label="Notifications Sent"
value={formatNumber(notifications.total_sent)}
sub={`${notifications.read_count} read · ${notifications.unread_count} unread`}
trend={notifications.unread_count === 0 ? "up" : "neutral"}
/>
<KpiCard
icon={UsersRound}
label="Team Members"
value={team.total_members.toLocaleString()}
sub={`${team.by_role.length} roles`}
trend="neutral"
/>
<KpiCard
icon={CreditCard}
label="Payments"
value={payments.total_payments.toLocaleString()}
sub={`${payments.successful_payments} successful`}
trend={payments.successful_payments > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={Layers}
label="Question Sets"
value={content.total_question_sets.toLocaleString()}
sub={content.question_sets_by_type.map((q) => `${q.count} ${q.label.toLowerCase()}`).join(" · ")}
trend="neutral"
/>
</div>
</Section>
{activeSummaryTab === "operations" && (
<>
{/* ─── Operations ─── */}
<Section title="Operations" icon={Bell} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={Bell}
label="Notifications Sent"
value={formatNumber(notifications.total_sent)}
sub={`${notifications.read_count} read · ${notifications.unread_count} unread`}
trend={notifications.unread_count === 0 ? "up" : "neutral"}
/>
<KpiCard
icon={UsersRound}
label="Team Members"
value={team.total_members.toLocaleString()}
sub={`${team.by_role.length} roles`}
trend="neutral"
/>
<KpiCard
icon={CreditCard}
label="Payments"
value={payments.total_payments.toLocaleString()}
sub={`${payments.successful_payments} successful`}
trend={payments.successful_payments > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={Layers}
label="Question Sets"
value={content.total_question_sets.toLocaleString()}
sub={content.question_sets_by_type.map((q) => `${q.count} ${q.label.toLowerCase()}`).join(" · ")}
trend="neutral"
/>
</div>
</Section>
</>
)}
{/* ─── User Analytics ─── */}
<Section title="User Analytics" icon={Users} count={users.total_users} defaultOpen>

View File

@ -216,7 +216,7 @@ export function LoginPage() {
<div className="h-9 w-9 rotate-45 rounded-lg bg-white/90" />
</div>
<h2 className="mb-4 text-3xl font-bold tracking-tight text-white">
Yimaru Academy
Yimaru Academy Test Mode
</h2>
<p className="text-base leading-relaxed text-white/70">
Manage your academy, track student progress, and streamline
@ -401,11 +401,15 @@ export function LoginPage() {
</form>
{/* Footer */}
<p className="mt-10 text-center text-xs text-grayScale-400">
© {new Date().getFullYear()} Yimaru Academy · All rights reserved
</p>
<div className="mt-10 text-center text-xs text-grayScale-400">
<p>© {new Date().getFullYear()} Yimaru Academy · All rights reserved</p>
<p className="mt-1 font-mono text-[10px] text-grayScale-300">
v{__BUILD_HASH__} · {new Date(__BUILD_TIME__).toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -512,7 +512,7 @@ export function IssuesPage() {
<TableRow key={issue.id} className="group">
<TableCell>
<div className="flex items-start gap-3 max-w-[300px]">
<div className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-50 group-hover:text-brand-500 transition-colors">
<div className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-500 group-hover:text-white transition-colors">
<TypeIcon className="h-4 w-4" />
</div>
<div className="min-w-0">

View File

@ -404,7 +404,7 @@ export function UserLogPage() {
<TableRow key={log.id} className="group">
<TableCell>
<div className="flex items-center gap-2.5">
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-50 group-hover:text-brand-500 transition-colors">
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-500 group-hover:text-white transition-colors">
<ActionIcon className="h-4 w-4" />
</div>
<span

View File

@ -29,32 +29,32 @@ export function UserManagementDashboard() {
<Users className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">Total Users</p>
<p className="text-2xl font-bold text-grayScale-600">1,248</p>
<p className="text-sm font-medium text-white/80">Total Users</p>
<p className="text-2xl font-bold text-white">1,248</p>
</div>
</CardContent>
</Card>
<Card className="border-none bg-mint-50 shadow-sm">
<Card className="border-none bg-brand-50 shadow-sm">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-mint-100 text-mint-600">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<UserCheck className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">Active Users</p>
<p className="text-2xl font-bold text-grayScale-600">1,180</p>
<p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-white">1,180</p>
</div>
</CardContent>
</Card>
<Card className="border-none bg-gold-50 shadow-sm sm:col-span-2 lg:col-span-1">
<Card className="border-none bg-brand-50 shadow-sm sm:col-span-2 lg:col-span-1">
<CardContent className="flex items-center gap-4 p-5">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-gold-100 text-gold-600">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<TrendingUp className="h-6 w-6" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">New This Month</p>
<p className="text-2xl font-bold text-grayScale-600">64</p>
<p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-white">64</p>
</div>
</CardContent>
</Card>

View File

@ -1,8 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { execSync } from 'child_process'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
define: {
__BUILD_HASH__: JSON.stringify(
(() => {
try {
return execSync('git rev-parse --short HEAD').toString().trim()
} catch {
return 'unknown'
}
})()
),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
})