feat: setup admin JSON generation and pdf viewer for free plan DPPs
This commit is contained in:
@@ -1,7 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState({
|
||||
maintenance_mode: false,
|
||||
allow_new_registrations: true,
|
||||
enable_free_trial: true,
|
||||
trial_duration_days: 5,
|
||||
trial_tier: "Al_Coach"
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/settings")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if(data && !data.detail) {
|
||||
setSettings(prev => ({ ...prev, ...data }));
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load settings:", err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveSettings = async (section: string, overrides: Partial<typeof settings> = {}) => {
|
||||
try {
|
||||
const payload = { ...settings, ...overrides };
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
alert(`${section} saved successfully!`);
|
||||
setSettings(payload);
|
||||
} else {
|
||||
alert(`Failed to save ${section}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error saving settings.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (key: keyof typeof settings, sectionName: string) => {
|
||||
const newValue = !settings[key];
|
||||
setSettings(prev => ({ ...prev, [key]: newValue }));
|
||||
saveSettings(sectionName, { [key]: newValue });
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 animate-pulse text-muted-foreground font-bold">Loading System Settings...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-4xl">
|
||||
<div>
|
||||
@@ -24,7 +78,49 @@ export default function SettingsPage() {
|
||||
<label className="text-sm font-bold">OpenAI API Key (Fallback)</label>
|
||||
<input type="password" defaultValue="sk-........................" className="w-full bg-background border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#00F0FF]" />
|
||||
</div>
|
||||
<Button className="bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 font-bold">Save Configuration</Button>
|
||||
<Button onClick={() => alert("Configuration Saved via Admin Panel")} className="bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 font-bold">Save Configuration</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border bg-secondary/10">
|
||||
<CardHeader>
|
||||
<CardTitle>Trial Management</CardTitle>
|
||||
<CardDescription>Configure the trial length and tier access for new signups.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-background border border-border rounded-xl">
|
||||
<div>
|
||||
<div className="font-bold">Enable Free Trial</div>
|
||||
<div className="text-sm text-muted-foreground">Offer a trial to newly registered users.</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleToggle('enable_free_trial', 'Free Trial')}
|
||||
className={`h-6 w-11 rounded-full relative cursor-pointer transition-colors ${settings.enable_free_trial ? 'bg-primary border border-primary shadow-[0_0_10px_var(--color-primary)]' : 'bg-secondary border border-border'}`}>
|
||||
<div className={`absolute top-1 size-4 rounded-full transition-all ${settings.enable_free_trial ? 'right-1 bg-primary-foreground' : 'left-1 bg-muted-foreground'}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold">Trial Duration (Days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.trial_duration_days}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, trial_duration_days: parseInt(e.target.value) || 0 }))}
|
||||
className="w-full bg-background border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#00F0FF]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold">Trial Access Tier</label>
|
||||
<select
|
||||
value={settings.trial_tier}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, trial_tier: e.target.value }))}
|
||||
className="w-full bg-background border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#00F0FF]">
|
||||
<option value="Pro">Pro Tier</option>
|
||||
<option value="Al_Coach">AI Coach (All Features)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => saveSettings("Trial Settings")} className="bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90 font-bold">Update Trial Settings</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -39,8 +135,10 @@ export default function SettingsPage() {
|
||||
<div className="font-bold">Maintenance Mode</div>
|
||||
<div className="text-sm text-muted-foreground">Disable all student access and show maintenance screen.</div>
|
||||
</div>
|
||||
<div className="h-6 w-11 rounded-full bg-secondary border border-border relative cursor-pointer">
|
||||
<div className="absolute left-1 top-1 size-4 bg-muted-foreground rounded-full"></div>
|
||||
<div
|
||||
onClick={() => handleToggle('maintenance_mode', 'Maintenance Mode')}
|
||||
className={`h-6 w-11 rounded-full relative cursor-pointer transition-colors ${settings.maintenance_mode ? 'bg-red-500 border border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.8)]' : 'bg-secondary border border-border'}`}>
|
||||
<div className={`absolute top-1 size-4 rounded-full transition-all ${settings.maintenance_mode ? 'right-1 bg-white' : 'left-1 bg-muted-foreground'}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-background border border-border rounded-xl">
|
||||
@@ -48,8 +146,10 @@ export default function SettingsPage() {
|
||||
<div className="font-bold">Allow New Registrations</div>
|
||||
<div className="text-sm text-muted-foreground">Open the `/signup` route for new users.</div>
|
||||
</div>
|
||||
<div className="h-6 w-11 rounded-full bg-primary border border-primary relative cursor-pointer shadow-[0_0_10px_var(--color-primary)]">
|
||||
<div className="absolute right-1 top-1 size-4 bg-primary-foreground rounded-full"></div>
|
||||
<div
|
||||
onClick={() => handleToggle('allow_new_registrations', 'Registration Setting')}
|
||||
className={`h-6 w-11 rounded-full relative cursor-pointer transition-colors ${settings.allow_new_registrations ? 'bg-primary border border-primary shadow-[0_0_10px_var(--color-primary)]' : 'bg-secondary border border-border'}`}>
|
||||
<div className={`absolute top-1 size-4 rounded-full transition-all ${settings.allow_new_registrations ? 'right-1 bg-primary-foreground' : 'left-1 bg-muted-foreground'}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -60,7 +160,7 @@ export default function SettingsPage() {
|
||||
<CardTitle className="text-red-500">Danger Zone</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="bg-red-500 text-white hover:bg-red-600 font-bold">Purge Generated Cache</Button>
|
||||
<Button onClick={() => alert("Cache Purged.")} className="bg-red-500 text-white hover:bg-red-600 font-bold">Purge Generated Cache</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -2,201 +2,373 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ArrowLeft, Loader2, User, Mail, Target, Award, Calendar, Activity, BookOpen } from "lucide-react"
|
||||
import {
|
||||
ArrowLeft, Loader2, Mail, Phone, Target, Award, Calendar, Activity,
|
||||
BookOpen, Flame, TrendingUp, CheckCircle2, AlertCircle, RefreshCw,
|
||||
Edit3, Save, X
|
||||
} from "lucide-react"
|
||||
|
||||
const TIER_STYLE: Record<string, string> = {
|
||||
"Al_Coach": "bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20",
|
||||
"Pro": "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
|
||||
"Free": "bg-secondary text-foreground border-border",
|
||||
}
|
||||
const TIER_LABEL: Record<string, string> = { "Al_Coach": "AI Coach", "Pro": "Pro", "Free": "Free" }
|
||||
|
||||
function formatDate(iso?: string | null) {
|
||||
if (!iso) return "—"
|
||||
try { return new Date(iso).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }) }
|
||||
catch { return iso }
|
||||
}
|
||||
|
||||
function formatAmount(paise: number) {
|
||||
return `₹${(paise / 100).toLocaleString("en-IN")}`
|
||||
}
|
||||
|
||||
export default function StudentKundliPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const studentId = params.studentId as string
|
||||
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [userData, setUserData] = useState<any>(null)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
// editable fields
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editTier, setEditTier] = useState("")
|
||||
const [editStreak, setEditStreak] = useState(0)
|
||||
const [editExam, setEditExam] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
// Simulated fetch of student's complete "Kundli"
|
||||
setTimeout(() => {
|
||||
setUserData({
|
||||
id: studentId,
|
||||
name: "Rahul Sharma",
|
||||
email: "rahul@example.com",
|
||||
target: "NEET",
|
||||
tier: "AI Pro",
|
||||
joined: "Oct 12, 2025",
|
||||
stats: {
|
||||
total_tests: 42,
|
||||
average_score: "78%",
|
||||
streak: 45,
|
||||
total_questions_solved: 3450,
|
||||
ai_coach_interactions: 124
|
||||
},
|
||||
recent_tests: [
|
||||
{ id: 1, name: "Full Syllabus NEET Mock #4", score: "620/720", date: "2 days ago", percentile: "98.5%" },
|
||||
{ id: 2, name: "Physics: Kinematics DPP", score: "45/50", date: "4 days ago", percentile: "95.2%" },
|
||||
{ id: 3, name: "Biology: Human Reproduction", score: "88/100", date: "1 week ago", percentile: "99.1%" }
|
||||
],
|
||||
payments: [
|
||||
{ id: "PAY_1", amount: "₹1599", date: "Oct 12, 2025", plan: "AI Pro (Yearly)", status: "Success" }
|
||||
],
|
||||
weak_topics: ["Rotational Dynamics", "Organic Chemistry", "Genetics"]
|
||||
fetch(`/api/admin/students/${studentId}`)
|
||||
.then(r => { if (!r.ok) throw new Error("Not found"); return r.json() })
|
||||
.then(d => {
|
||||
setData(d)
|
||||
setEditName(d.name)
|
||||
setEditTier(d.tier)
|
||||
setEditStreak(d.streak_count)
|
||||
setEditExam(d.target_exam)
|
||||
})
|
||||
setLoading(false)
|
||||
}, 800)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [studentId])
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-muted-foreground w-8 h-8" /></div>
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/students/${studentId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editName,
|
||||
tier: editTier,
|
||||
streak_count: editStreak,
|
||||
target_exam: editExam,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to save")
|
||||
// Refresh data
|
||||
const fresh = await fetch(`/api/admin/students/${studentId}`).then(r => r.json())
|
||||
setData(fresh)
|
||||
setEditing(false)
|
||||
} catch (e: any) {
|
||||
alert(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!userData) return <div>User not found</div>
|
||||
if (loading) return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="animate-spin text-muted-foreground w-8 h-8" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (error || !data) return (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<AlertCircle className="mx-auto mb-3 text-red-500" size={32} />
|
||||
<p>Student not found or failed to load.</p>
|
||||
<Button variant="ghost" onClick={() => router.back()} className="mt-4">← Back</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-6xl mx-auto animate-in fade-in duration-500">
|
||||
<Button variant="ghost" onClick={() => router.back()} className="text-muted-foreground hover:text-foreground mb-4">
|
||||
<ArrowLeft size={16} className="mr-2" /> Back to Students
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* User Identity Card */}
|
||||
<Card className="flex-1 border-border shadow-sm">
|
||||
<CardHeader className="pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-secondary flex items-center justify-center text-2xl font-bold">
|
||||
{userData.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{userData.name}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2 mt-1">
|
||||
<Mail size={14} /> {userData.email}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 grid grid-cols-2 gap-y-4 gap-x-8">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2 mb-1"><Target size={14}/> Target Exam</div>
|
||||
<div className="font-medium">{userData.target}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2 mb-1"><Award size={14}/> Plan Tier</div>
|
||||
<Badge className="bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20">{userData.tier}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2 mb-1"><Calendar size={14}/> Joined</div>
|
||||
<div className="font-medium">{userData.joined}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2 mb-1"><User size={14}/> User ID</div>
|
||||
<div className="font-mono text-xs">{userData.id}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-2 gap-4 flex-1">
|
||||
<Card className="bg-secondary/20 border-border">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-3xl font-black text-[#00F0FF]">{userData.stats.total_tests}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Tests Attempted</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-secondary/20 border-border">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-3xl font-black text-orange-500">{userData.stats.streak}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Day Streak</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-secondary/20 border-border">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-3xl font-black">{userData.stats.average_score}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Avg. Accuracy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-secondary/20 border-border">
|
||||
<CardContent className="p-4 flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="text-3xl font-black">{userData.stats.total_questions_solved}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">Questions Solved</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => router.back()} className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft size={16} className="mr-2" /> Back to Students
|
||||
</Button>
|
||||
{!editing ? (
|
||||
<Button variant="outline" onClick={() => setEditing(true)} className="gap-2">
|
||||
<Edit3 size={14} /> Edit Profile
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => setEditing(false)} className="gap-2">
|
||||
<X size={14} /> Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2 bg-[#00F0FF] text-background hover:bg-[#00F0FF]/90">
|
||||
{saving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity & Weak Topics */}
|
||||
{/* Profile Header Card */}
|
||||
<Card className="border-border shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||
{/* Avatar */}
|
||||
<div className="relative shrink-0">
|
||||
{data.profile_image_url ? (
|
||||
<img src={data.profile_image_url} alt={data.name} className="w-20 h-20 rounded-2xl object-cover" />
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-[#00F0FF]/20 to-[#00F0FF]/5 border border-[#00F0FF]/20 flex items-center justify-center text-3xl font-black text-[#00F0FF]">
|
||||
{data.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
{data.name === "Neha Verma" && (
|
||||
<span className="absolute -top-2 -right-2 text-[9px] font-black px-1.5 py-0.5 rounded bg-yellow-500 text-background">DEMO</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + meta */}
|
||||
<div className="flex-1">
|
||||
{editing ? (
|
||||
<input
|
||||
value={editName}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
className="text-2xl font-bold bg-secondary border border-border rounded-lg px-3 py-1.5 w-full max-w-sm focus:outline-none focus:ring-1 focus:ring-primary mb-3"
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-2xl font-bold mb-1">{data.name}</h2>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{editing ? (
|
||||
<select
|
||||
value={editTier}
|
||||
onChange={e => setEditTier(e.target.value)}
|
||||
className="text-sm bg-secondary border border-border rounded-lg px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="Free">Free</option>
|
||||
<option value="Pro">Pro</option>
|
||||
<option value="Al_Coach">AI Coach</option>
|
||||
</select>
|
||||
) : (
|
||||
<Badge className={TIER_STYLE[data.tier] || TIER_STYLE["Free"]} variant="outline">
|
||||
{TIER_LABEL[data.tier] || data.tier}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<select
|
||||
value={editExam}
|
||||
onChange={e => setEditExam(e.target.value)}
|
||||
className="text-sm bg-secondary border border-border rounded-lg px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="NEET">NEET</option>
|
||||
<option value="JEE_Main">JEE Main</option>
|
||||
<option value="JEE_Advanced">JEE Advanced</option>
|
||||
</select>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-border">{data.target_exam.replace("_", " ")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-3 text-sm text-muted-foreground">
|
||||
{data.email && <span className="flex items-center gap-1.5"><Mail size={13}/>{data.email}</span>}
|
||||
{data.phone && <span className="flex items-center gap-1.5"><Phone size={13}/>{data.phone}</span>}
|
||||
{data.school && <span className="flex items-center gap-1.5"><BookOpen size={13}/>{data.school}</span>}
|
||||
<span className="flex items-center gap-1.5"><Calendar size={13}/> Joined {formatDate(data.created_at)}</span>
|
||||
{data.subscription_end && (
|
||||
<span className="flex items-center gap-1.5 text-green-500 font-medium">
|
||||
<CheckCircle2 size={13}/> Active till {formatDate(data.subscription_end)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Tests Attempted", value: data.stats.total_tests, color: "text-[#00F0FF]", icon: <Activity size={16}/> },
|
||||
{ label: "Avg Accuracy", value: data.stats.avg_accuracy ? `${data.stats.avg_accuracy}%` : "—", color: "text-green-500", icon: <TrendingUp size={16}/> },
|
||||
{ label: "AI Sessions", value: data.stats.mastered_mistakes, color: "text-purple-400", icon: <Award size={16}/> },
|
||||
{
|
||||
label: "Current Streak",
|
||||
value: editing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editStreak}
|
||||
onChange={e => setEditStreak(Number(e.target.value))}
|
||||
className="text-2xl font-black w-20 bg-secondary border border-border rounded px-2 py-0.5 focus:outline-none text-orange-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Flame size={20} className="fill-orange-500 text-orange-500" />
|
||||
{data.streak_count}
|
||||
</span>
|
||||
),
|
||||
color: "text-orange-500",
|
||||
icon: <Flame size={16}/>
|
||||
},
|
||||
].map((stat, i) => (
|
||||
<Card key={i} className="border-border bg-card shadow-sm">
|
||||
<CardContent className="p-5 flex flex-col items-center text-center">
|
||||
<div className={`mb-2 ${stat.color}`}>{stat.icon}</div>
|
||||
<div className={`text-2xl font-black mb-1 ${stat.color}`}>{stat.value}</div>
|
||||
<div className="text-xs text-muted-foreground font-medium">{stat.label}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Attempts + Weak Topics */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2 border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Activity size={18} className="text-[#00F0FF]"/> Recent Tests</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Activity size={16} className="text-[#00F0FF]" /> Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{userData.recent_tests.map((test: any) => (
|
||||
<div key={test.id} className="flex items-center justify-between p-3 rounded-lg border border-border bg-secondary/10">
|
||||
<div>
|
||||
<div className="font-bold">{test.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{test.date}</div>
|
||||
{data.recent_attempts.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-6">No tests attempted yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.recent_attempts.map((a: any) => (
|
||||
<div key={a.id} className="flex items-center justify-between p-3 rounded-lg border border-border bg-secondary/10">
|
||||
<div>
|
||||
<div className="font-bold text-sm">{a.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{formatDate(a.submitted_at)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono font-bold text-[#00F0FF] text-sm">{a.score}/{a.total_questions}</div>
|
||||
<div className="text-xs text-muted-foreground">{a.accuracy.toFixed(1)}% acc</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono font-bold text-[#00F0FF]">{test.score}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{test.percentile} %ile</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><BookOpen size={18} className="text-red-500"/> AI Identified Weaknesses</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BookOpen size={16} className="text-red-500" /> Weak Topics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{userData.weak_topics.map((topic: string, i: number) => (
|
||||
<li key={i} className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="font-medium text-sm">{topic}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data.weak_topics.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-6">No weak topics identified yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{data.weak_topics.map((topic: string, i: number) => (
|
||||
<li key={i} className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
|
||||
<span className="font-medium text-sm">{topic}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Payment History */}
|
||||
<Card className="border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Payment History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-secondary/30">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold">Transaction ID</th>
|
||||
<th className="px-4 py-3 font-semibold">Plan</th>
|
||||
<th className="px-4 py-3 font-semibold">Date</th>
|
||||
<th className="px-4 py-3 font-semibold">Amount</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{userData.payments.map((p: any) => (
|
||||
<tr key={p.id}>
|
||||
<td className="px-4 py-3 font-mono text-xs">{p.id}</td>
|
||||
<td className="px-4 py-3 font-medium">{p.plan}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{p.date}</td>
|
||||
<td className="px-4 py-3 font-bold">{p.amount}</td>
|
||||
<td className="px-4 py-3"><Badge className="bg-green-500/20 text-green-500 border-green-500/30">{p.status}</Badge></td>
|
||||
{/* Subscriptions */}
|
||||
{data.subscriptions.length > 0 && (
|
||||
<Card className="border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Award size={16} className="text-[#00F0FF]" /> Subscription History
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-secondary/30 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold">Plan</th>
|
||||
<th className="px-4 py-3 font-semibold">Billing</th>
|
||||
<th className="px-4 py-3 font-semibold">Start</th>
|
||||
<th className="px-4 py-3 font-semibold">End</th>
|
||||
<th className="px-4 py-3 font-semibold">Amount</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.subscriptions.map((s: any) => (
|
||||
<tr key={s.id}>
|
||||
<td className="px-4 py-3 font-medium">{TIER_LABEL[s.tier] || s.tier}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{s.billing_cycle}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(s.start_date)}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(s.end_date)}</td>
|
||||
<td className="px-4 py-3 font-bold">{formatAmount(s.amount)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge className={s.status === "Active" ? "bg-green-500/20 text-green-500 border-green-500/30" : "bg-secondary text-muted-foreground border-border"}>
|
||||
{s.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transactions */}
|
||||
{data.transactions.length > 0 && (
|
||||
<Card className="border-border shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Payment History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-secondary/30 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold">ID</th>
|
||||
<th className="px-4 py-3 font-semibold">Plan</th>
|
||||
<th className="px-4 py-3 font-semibold">Date</th>
|
||||
<th className="px-4 py-3 font-semibold">Amount</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.transactions.map((t: any) => (
|
||||
<tr key={t.id}>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{t.id.slice(0, 12)}...</td>
|
||||
<td className="px-4 py-3 font-medium">{TIER_LABEL[t.tier] || t.tier}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(t.created_at)}</td>
|
||||
<td className="px-4 py-3 font-bold">₹{t.amount.toLocaleString("en-IN")}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge className={t.status === "Success" ? "bg-green-500/20 text-green-500 border-green-500/30" : "bg-red-500/20 text-red-500 border-red-500/30"}>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,94 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Search, Download, MoreVertical } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Search, Download, Flame, RefreshCw } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
const TIER_STYLE: Record<string, string> = {
|
||||
"Al_Coach": "bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20 font-bold",
|
||||
"Pro": "bg-yellow-500/10 text-yellow-500 border-yellow-500/20 font-bold",
|
||||
"Free": "bg-secondary text-foreground border-border",
|
||||
}
|
||||
|
||||
const TIER_LABEL: Record<string, string> = {
|
||||
"Al_Coach": "AI Coach",
|
||||
"Pro": "Pro",
|
||||
"Free": "Free",
|
||||
}
|
||||
|
||||
export default function StudentsPage() {
|
||||
const router = useRouter()
|
||||
const students = [
|
||||
{ id: "STU-001", name: "Rahul Sharma", email: "rahul@example.com", tier: "AI Pro", target: "NEET", joined: "Oct 12, 2025", tests: 42, streak: 45, lastLogin: "Today, 08:30 AM" },
|
||||
{ id: "STU-002", name: "Priya Patel", email: "priya@example.com", tier: "Basic", target: "JEE Main", joined: "Nov 05, 2025", tests: 14, streak: 2, lastLogin: "Yesterday, 14:15 PM" },
|
||||
{ id: "STU-003", name: "Amit Kumar", email: "amit@example.com", tier: "AI Pro", target: "JEE Adv", joined: "Dec 01, 2025", tests: 89, streak: 0, lastLogin: "3 Days Ago" },
|
||||
{ id: "STU-004", name: "Sneha Reddy", email: "sneha@example.com", tier: "AI Pro", target: "NEET", joined: "Dec 15, 2025", tests: 34, streak: 12, lastLogin: "Today, 11:45 AM" },
|
||||
{ id: "STU-005", name: "Vikram Singh", email: "vikram@example.com", tier: "Basic", target: "NEET", joined: "Jan 10, 2026", tests: 5, streak: 0, lastLogin: "1 Week Ago" },
|
||||
]
|
||||
const [students, setStudents] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/students")
|
||||
.then(r => r.json())
|
||||
.then(data => setStudents(data))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const filtered = students.filter(s =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.phone.includes(search)
|
||||
)
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
try { return new Date(iso).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }) }
|
||||
catch { return iso }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Student Directory</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage users, view engagement, and handle subscriptions.</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{loading ? "Loading..." : `${students.length} students enrolled`}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="border-border hover:bg-secondary"><Download size={16} className="mr-2"/> Export CSV</Button>
|
||||
<Button variant="outline" className="border-border hover:bg-secondary">
|
||||
<Download size={16} className="mr-2" /> Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/10 border border-border rounded-xl overflow-hidden backdrop-blur-sm">
|
||||
<div className="p-4 border-b border-border flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, email, or ID..."
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search by name, email, or phone..."
|
||||
className="w-full bg-background border border-border rounded-lg pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-semibold">Student</th>
|
||||
<th className="px-6 py-4 font-semibold">Plan Tier</th>
|
||||
<th className="px-6 py-4 font-semibold">Target Exam</th>
|
||||
<th className="px-6 py-4 font-semibold">Tests Taken</th>
|
||||
<th className="px-6 py-4 font-semibold text-center">Streak</th>
|
||||
<th className="px-6 py-4 font-semibold">Last Login</th>
|
||||
<th className="px-6 py-4 font-semibold text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{students.map((s) => (
|
||||
<tr
|
||||
key={s.id}
|
||||
className="hover:bg-secondary/20 transition-colors cursor-pointer"
|
||||
onClick={() => router.push(`/admin/students/${s.id}`)}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-bold">{s.name}</div>
|
||||
<div className="text-muted-foreground text-xs">{s.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge className={
|
||||
s.tier === "AI Pro" ? "bg-[#00F0FF]/10 text-[#00F0FF] border-[#00F0FF]/20 font-bold" : "bg-secondary text-foreground border-border"
|
||||
} variant="outline">
|
||||
{s.tier}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-medium">{s.target}</td>
|
||||
<td className="px-6 py-4 font-mono">{s.tests}</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<span className={`font-bold ${s.streak > 0 ? 'text-orange-500' : 'text-muted-foreground'}`}>{s.streak}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-muted-foreground">{s.lastLogin}</td>
|
||||
<td className="px-6 py-4 text-right flex items-center justify-end gap-2">
|
||||
{s.streak === 0 && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-[10px] border-red-500/50 text-red-500 hover:bg-red-500/10" onClick={() => window.alert(`Alert sent to ${s.name} regarding broken streak.`)}>
|
||||
Send Alert
|
||||
</Button>
|
||||
)}
|
||||
<button className="p-1.5 hover:text-foreground hover:bg-secondary rounded"><MoreVertical size={16} /></button>
|
||||
</td>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground gap-3">
|
||||
<RefreshCw size={18} className="animate-spin" /> Loading students...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-semibold">Student</th>
|
||||
<th className="px-6 py-4 font-semibold">Plan</th>
|
||||
<th className="px-6 py-4 font-semibold">Target</th>
|
||||
<th className="px-6 py-4 font-semibold">Tests</th>
|
||||
<th className="px-6 py-4 font-semibold">Accuracy</th>
|
||||
<th className="px-6 py-4 font-semibold text-center">Streak</th>
|
||||
<th className="px-6 py-4 font-semibold">Last Active</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((s) => (
|
||||
<tr
|
||||
key={s.id}
|
||||
className="hover:bg-secondary/20 transition-colors cursor-pointer"
|
||||
onClick={() => router.push(`/admin/students/${s.id}`)}
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{s.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold flex items-center gap-2">
|
||||
{s.name}
|
||||
{s.name === "Neha Verma" && (
|
||||
<span className="text-[9px] font-black px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">DEMO ⭐</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{s.email || s.phone}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge className={TIER_STYLE[s.tier] || TIER_STYLE["Free"]} variant="outline">
|
||||
{TIER_LABEL[s.tier] || s.tier}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-medium">{s.target_exam.replace("_", " ")}</td>
|
||||
<td className="px-6 py-4 font-mono">{s.total_tests}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`font-bold ${s.avg_accuracy >= 80 ? "text-green-500" : s.avg_accuracy >= 60 ? "text-yellow-500" : "text-muted-foreground"}`}>
|
||||
{s.avg_accuracy > 0 ? `${s.avg_accuracy}%` : "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<span className={`font-bold flex items-center justify-center gap-1 ${s.streak_count > 0 ? "text-orange-500" : "text-muted-foreground"}`}>
|
||||
{s.streak_count > 0 && <Flame size={12} className="fill-orange-500" />}
|
||||
{s.streak_count}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-muted-foreground text-xs">{formatDate(s.last_active)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-border text-sm text-muted-foreground text-center">
|
||||
Showing top 5 active students out of 12,400 total.
|
||||
Showing {filtered.length} of {students.length} students
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user