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 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" } from "recharts"
import { StatCard } from "../components/dashboard/StatCard" import { StatCard } from "../components/dashboard/StatCard"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" 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 { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api" import { getDashboard } from "../api/analytics.api"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
@ -44,6 +44,7 @@ export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("") const [userFirstName, setUserFirstName] = useState<string>("")
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
@ -115,8 +116,45 @@ export function DashboardPage() {
<div className="flex items-center justify-center py-20 text-destructive">Failed to load dashboard data.</div> <div className="flex items-center justify-center py-20 text-destructive">Failed to load dashboard data.</div>
) : ( ) : (
<> <>
{/* 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 */} {/* Stat Cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> {activeStatTab === "primary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
icon={Users} icon={Users}
label="Total Users" label="Total Users"
@ -146,9 +184,11 @@ export function DashboardPage() {
deltaPositive={dashboard.issues.resolution_rate > 0.5} deltaPositive={dashboard.issues.resolution_rate > 0.5}
/> />
</div> </div>
)}
{/* Secondary Stats */} {/* Secondary Stats */}
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> {activeStatTab === "secondary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
icon={BookOpen} icon={BookOpen}
label="Courses" label="Courses"
@ -178,6 +218,7 @@ export function DashboardPage() {
deltaPositive deltaPositive
/> />
</div> </div>
)}
{/* User Registrations Chart */} {/* User Registrations Chart */}
<div className="mt-5 grid gap-4"> <div className="mt-5 grid gap-4">

View File

@ -44,7 +44,7 @@ function formatDateTime(dateStr: string | null | undefined): string {
function LoadingSkeleton() { function LoadingSkeleton() {
return ( 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"> <div className="animate-pulse space-y-8">
{/* Hero skeleton */} {/* Hero skeleton */}
<div className="overflow-hidden rounded-2xl border border-grayScale-100"> <div className="overflow-hidden rounded-2xl border border-grayScale-100">
@ -93,15 +93,15 @@ function InfoRow({
extra?: React.ReactNode; extra?: React.ReactNode;
}) { }) {
return ( 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 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"> <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" /> <Icon className="h-4 w-4" />
</div> </div>
<span className="font-medium">{label}</span> <span className="font-medium">{label}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm font-medium text-grayScale-600"> <div className="flex items-center gap-2 text-sm font-medium text-grayScale-600 sm:justify-end min-w-0">
<span className="text-right">{value || "—"}</span> <span className="truncate text-right sm:text-left">{value || "—"}</span>
{extra} {extra}
</div> </div>
</div> </div>
@ -121,13 +121,13 @@ function VerifiedIcon({ verified }: { verified: boolean }) {
} }
function ProgressRing({ percent }: { percent: number }) { function ProgressRing({ percent }: { percent: number }) {
const radius = 18; const radius = 14;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const offset = circumference - (percent / 100) * circumference; const offset = circumference - (percent / 100) * circumference;
return ( return (
<div className="relative inline-flex items-center justify-center"> <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 <circle
cx="22" cx="22"
cy="22" cy="22"
@ -150,7 +150,7 @@ function ProgressRing({ percent }: { percent: number }) {
className="text-brand-500 transition-all duration-700" className="text-brand-500 transition-all duration-700"
/> />
</svg> </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> </div>
); );
} }
@ -179,7 +179,7 @@ export function ProfilePage() {
if (error || !profile) { if (error || !profile) {
return ( 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"> <Card className="border-dashed">
<CardContent className="flex flex-col items-center gap-5 p-12"> <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"> <div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
@ -203,228 +203,311 @@ export function ProfilePage() {
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase(); const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
const completionPct = profile.profile_completion_percentage ?? 0; 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 ( return (
<div className="mx-auto w-full max-w-5xl space-y-8 px-4 py-8 sm:px-6"> <div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6">
{/* Hero Card */} {/* Page header (no tabs) */}
<Card className="overflow-hidden border-0 shadow-lg"> <div className="mb-5">
{/* Banner gradient */} <p className="text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">My Info</p>
<div className="relative h-36 bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 sm:h-40"> <h1 className="mt-1 text-2xl font-semibold tracking-tight text-grayScale-800">Profile</h1>
{/* 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>
{/* Bottom fade */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white/20 to-transparent" />
</div> </div>
<CardContent className="-mt-16 px-6 pb-8 pt-0 sm:px-10"> {/* Main profile layout card */}
<div className="flex flex-col items-center text-center"> <div className="rounded-2xl border border-grayScale-100 bg-white shadow-sm">
{/* Avatar */} {/* Header strip */}
<Avatar className="h-28 w-28 ring-4 ring-white shadow-lg"> <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>
</div>
<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} /> <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"> <AvatarFallback className="bg-grayScale-100 text-base font-semibold text-grayScale-600">
{initials} {initials}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0">
{/* Name */} <div className="flex flex-wrap items-center gap-2">
<h1 className="mt-4 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl"> <h2 className="text-lg font-semibold tracking-tight text-grayScale-800">{fullName}</h2>
{fullName} <span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
</h1> #{profile.id}
</span>
{/* Role badge */} </div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge <Badge
className={cn( className={cn(
"mt-2.5 px-3 py-1", "px-2.5 py-0.5 text-xs font-semibold",
profile.role === "ADMIN" profile.role === "ADMIN"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20" ? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-100 text-grayScale-600 border border-grayScale-200" : "bg-grayScale-50 text-grayScale-600 border border-grayScale-200"
)} )}
> >
<Shield className="h-3 w-3 mr-1.5" /> <Shield className="mr-1 h-3 w-3" />
{profile.role} {profile.role}
</Badge> </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">
{/* Status pills */} <Calendar className="h-3 w-3" />
<div className="mt-6 flex flex-wrap items-center justify-center gap-2.5"> Joined {formatDate(profile.created_at)}
{/* Active status */} </span>
<div </div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span
className={cn( className={cn(
"flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors", "inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
profile.status === "ACTIVE" profile.status === "ACTIVE"
? "border-mint-300 bg-mint-100/60 text-mint-500" ? "bg-mint-50 text-mint-600"
: "border-destructive/20 bg-destructive/10 text-destructive" : "bg-destructive/10 text-destructive"
)} )}
> >
<span <span
className={cn( className={cn(
"h-2 w-2 rounded-full", "h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500 animate-pulse" : "bg-destructive" profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive"
)} )}
/> />
{profile.status} {profile.status}
</div> </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">
{/* 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"}
</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"}
</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} /> <ProgressRing percent={completionPct} />
<span>Profile Complete</span> <span>Profile complete</span>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* 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>
{/* 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>
{/* 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> </CardContent>
</Card> </Card>
{/* Info Cards */} <Card className="shadow-none border-grayScale-100">
<div className="grid gap-6 md:grid-cols-3"> <CardHeader className="pb-2">
{/* Personal Information */} <CardTitle className="text-sm font-semibold text-grayScale-700">
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg"> Language goal
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" /> </CardTitle>
<CardHeader className="pb-3"> </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 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"> <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" /> <User className="h-4 w-4" />
</div> </div>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600"> <div>
Personal Information <p className="text-sm font-medium text-grayScale-700">
</CardTitle> Account created
</p>
<p className="text-xs text-grayScale-400">
{formatDateTime(profile.created_at)}
</p>
</div>
</div> </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> </CardContent>
</Card> </Card>
</div>
{/* Contact & Location */} {/* Account summary */}
<Card className="group overflow-hidden border border-grayScale-100 transition-all duration-200 hover:shadow-lg"> <div>
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" /> <h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
<CardHeader className="pb-3"> Account
<div className="flex items-center gap-3"> </h3>
<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"> <Card className="shadow-none border-grayScale-100">
<Mail className="h-4 w-4" /> <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>
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600"> <div className="flex items-center justify-between text-sm">
Contact & Location <span className="text-grayScale-400">Status</span>
</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 <span
className={cn( className={cn(
"h-2.5 w-2.5 rounded-full ring-2", "inline-flex items-center gap-1.5 text-xs font-semibold",
profile.status === "ACTIVE" profile.status === "ACTIVE"
? "bg-mint-500 ring-mint-100" ? "text-mint-600"
: "bg-destructive ring-destructive/20" : "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> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</div>
</div>
</div>
</div>
); );
} }

View File

@ -282,6 +282,7 @@ export function AnalyticsPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
const fetchData = async () => { const fetchData = async () => {
setLoading(true) setLoading(true)
@ -384,7 +385,51 @@ export function AnalyticsPage() {
</div> </div>
</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"> <div className="space-y-4">
{activeSummaryTab === "key" && (
<>
{/* ─── Key Metrics ─── */} {/* ─── Key Metrics ─── */}
<Section title="Key Metrics" icon={TrendingUp} defaultOpen> <Section title="Key Metrics" icon={TrendingUp} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@ -418,9 +463,18 @@ export function AnalyticsPage() {
/> />
</div> </div>
</Section> </Section>
</>
)}
{activeSummaryTab === "content" && (
<>
{/* ─── Content & Platform ─── */} {/* ─── Content & Platform ─── */}
<Section title="Content & Platform" icon={BookOpen} count={courses.total_courses + content.total_questions} defaultOpen> <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"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard <KpiCard
icon={FolderOpen} icon={FolderOpen}
@ -451,7 +505,11 @@ export function AnalyticsPage() {
/> />
</div> </div>
</Section> </Section>
</>
)}
{activeSummaryTab === "operations" && (
<>
{/* ─── Operations ─── */} {/* ─── Operations ─── */}
<Section title="Operations" icon={Bell} defaultOpen> <Section title="Operations" icon={Bell} defaultOpen>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@ -485,6 +543,8 @@ export function AnalyticsPage() {
/> />
</div> </div>
</Section> </Section>
</>
)}
{/* ─── User Analytics ─── */} {/* ─── User Analytics ─── */}
<Section title="User Analytics" icon={Users} count={users.total_users} defaultOpen> <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 className="h-9 w-9 rotate-45 rounded-lg bg-white/90" />
</div> </div>
<h2 className="mb-4 text-3xl font-bold tracking-tight text-white"> <h2 className="mb-4 text-3xl font-bold tracking-tight text-white">
Yimaru Academy Yimaru Academy Test Mode
</h2> </h2>
<p className="text-base leading-relaxed text-white/70"> <p className="text-base leading-relaxed text-white/70">
Manage your academy, track student progress, and streamline Manage your academy, track student progress, and streamline
@ -401,11 +401,15 @@ export function LoginPage() {
</form> </form>
{/* Footer */} {/* Footer */}
<p className="mt-10 text-center text-xs text-grayScale-400"> <div className="mt-10 text-center text-xs text-grayScale-400">
© {new Date().getFullYear()} Yimaru Academy · All rights reserved <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> </p>
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@ -512,7 +512,7 @@ export function IssuesPage() {
<TableRow key={issue.id} className="group"> <TableRow key={issue.id} className="group">
<TableCell> <TableCell>
<div className="flex items-start gap-3 max-w-[300px]"> <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" /> <TypeIcon className="h-4 w-4" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">

View File

@ -404,7 +404,7 @@ export function UserLogPage() {
<TableRow key={log.id} className="group"> <TableRow key={log.id} className="group">
<TableCell> <TableCell>
<div className="flex items-center gap-2.5"> <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" /> <ActionIcon className="h-4 w-4" />
</div> </div>
<span <span

View File

@ -29,32 +29,32 @@ export function UserManagementDashboard() {
<Users className="h-6 w-6" /> <Users className="h-6 w-6" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">Total Users</p> <p className="text-sm font-medium text-white/80">Total Users</p>
<p className="text-2xl font-bold text-grayScale-600">1,248</p> <p className="text-2xl font-bold text-white">1,248</p>
</div> </div>
</CardContent> </CardContent>
</Card> </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"> <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" /> <UserCheck className="h-6 w-6" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">Active Users</p> <p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-grayScale-600">1,180</p> <p className="text-2xl font-bold text-white">1,180</p>
</div> </div>
</CardContent> </CardContent>
</Card> </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"> <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" /> <TrendingUp className="h-6 w-6" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-grayScale-400">New This Month</p> <p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-grayScale-600">64</p> <p className="text-2xl font-bold text-white">64</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,8 +1,21 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { execSync } from 'child_process'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], 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()),
},
}) })