440 lines
20 KiB
TypeScript
440 lines
20 KiB
TypeScript
"use client"
|
|
|
|
import { fetchWithAuth } from "@/lib/apiClient";
|
|
import { useEffect, useState } from "react"
|
|
import { useParams, useRouter } from "next/navigation"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/ui/badge"
|
|
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> = {
|
|
"AI Coach": "bg-[var(--cyan-ai-10)] text-[var(--cyan-ai)] border-[var(--cyan-ai-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> = { "AI 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 [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("")
|
|
const [trialDays, setTrialDays] = useState(15)
|
|
const [grantingTrial, setGrantingTrial] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetchWithAuth(`/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)
|
|
})
|
|
.catch(e => setError(e.message))
|
|
.finally(() => setLoading(false))
|
|
}, [studentId])
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
try {
|
|
const res = await fetchWithAuth(`/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 fetchWithAuth(`/api/admin/students/${studentId}`).then(r => r.json())
|
|
setData(fresh)
|
|
setEditing(false)
|
|
} catch (e: any) {
|
|
alert(e.message)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
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">
|
|
{/* 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-[var(--cyan-ai)] text-background hover:bg-[var(--cyan-ai-80)]">
|
|
{saving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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-[var(--cyan-ai-20)] to-[var(--cyan-ai-10)] border border-[var(--cyan-ai-20)] flex items-center justify-center text-3xl font-black text-[var(--cyan-ai)]">
|
|
{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="AI 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="JEE_Main">JEE</option>
|
|
<option value="NEET">NEET</option>
|
|
<option value="JEE_NEET">JEE + NEET</option>
|
|
</select>
|
|
) : (
|
|
<Badge variant="outline" className="border-border">
|
|
{data.target_exam === "JEE_Main" ? "JEE" : 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-[var(--cyan-ai)]", 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 text-base">
|
|
<Activity size={16} className="text-[var(--cyan-ai)]" /> Recent Activity
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{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-[var(--cyan-ai)] text-sm">{a.score}/{a.total_questions}</div>
|
|
<div className="text-xs text-muted-foreground">{a.accuracy.toFixed(1)}% acc</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border shadow-sm">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<BookOpen size={16} className="text-red-500" /> Weak Topics
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{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>
|
|
|
|
{/* 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-[var(--cyan-ai)]" /> 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>
|
|
</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>
|
|
)}
|
|
{/* Trial Control Card */}
|
|
<Card className="border-amber-500/30 bg-amber-500/5 shadow-sm">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base text-amber-500">
|
|
<Flame size={16} className="text-amber-500" /> Trial Access Control
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Current Trial End</label>
|
|
<p className="text-sm font-semibold">
|
|
{data.trial_end ? (
|
|
<span className={new Date(data.trial_end) > new Date() ? "text-green-500" : "text-red-500"}>
|
|
{formatDate(data.trial_end)} ({new Date(data.trial_end) > new Date() ? "Active" : "Expired"})
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">No trial set</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold text-muted-foreground uppercase tracking-wider">Grant Trial (days)</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
max={365}
|
|
value={trialDays}
|
|
onChange={e => setTrialDays(Number(e.target.value))}
|
|
className="w-24 bg-background border border-border rounded-lg px-3 py-2 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
<Button
|
|
onClick={async () => {
|
|
setGrantingTrial(true)
|
|
try {
|
|
const res = await fetchWithAuth(`/api/admin/students/${studentId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ trial_days: trialDays }),
|
|
})
|
|
if (!res.ok) throw new Error("Failed")
|
|
const fresh = await fetchWithAuth(`/api/admin/students/${studentId}`).then(r => r.json())
|
|
setData(fresh)
|
|
alert(`Trial ${trialDays > 0 ? `granted for ${trialDays} days` : 'removed'} successfully!`)
|
|
} catch { alert("Failed to update trial") }
|
|
finally { setGrantingTrial(false) }
|
|
}}
|
|
disabled={grantingTrial}
|
|
className="bg-amber-500 text-black hover:bg-amber-400 font-bold gap-2"
|
|
>
|
|
{grantingTrial ? <RefreshCw size={14} className="animate-spin" /> : <CheckCircle2 size={14} />}
|
|
{trialDays > 0 ? `Grant ${trialDays}d Trial` : "Remove Trial"}
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">Set 0 to remove trial. Trial starts from now.</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|