- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
207 lines
8.1 KiB
TypeScript
207 lines
8.1 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
useCredentialStatuses,
|
|
useResendAllPendingCredentials,
|
|
useResendCredential,
|
|
} from "@/hooks/queries/useEntityOnboarding";
|
|
import type { CredentialStatus } from "@/types";
|
|
import { toast } from "sonner";
|
|
|
|
export default function CredentialDashboard() {
|
|
const [accountFilter, setAccountFilter] = useState<string>("all");
|
|
const [emailSentFilter, setEmailSentFilter] = useState<string>("all");
|
|
const [placementFilter, setPlacementFilter] = useState<string>("all");
|
|
const [dateFrom, setDateFrom] = useState("");
|
|
const [dateTo, setDateTo] = useState("");
|
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
|
|
const filters = useMemo(
|
|
() => ({
|
|
account_status: accountFilter === "all" ? undefined : accountFilter,
|
|
email_sent:
|
|
emailSentFilter === "all" ? undefined : emailSentFilter === "yes" ? true : emailSentFilter === "no" ? false : undefined,
|
|
placement_status: placementFilter === "all" ? undefined : placementFilter,
|
|
date_from: dateFrom || undefined,
|
|
date_to: dateTo || undefined,
|
|
}),
|
|
[accountFilter, emailSentFilter, placementFilter, dateFrom, dateTo],
|
|
);
|
|
|
|
const { data, isLoading } = useCredentialStatuses(filters);
|
|
const resendOne = useResendCredential();
|
|
const resendAll = useResendAllPendingCredentials();
|
|
|
|
const rows: CredentialStatus[] = data?.data ?? [];
|
|
|
|
const toggle = (id: number) => {
|
|
setSelected((prev) => {
|
|
const n = new Set(prev);
|
|
if (n.has(id)) n.delete(id);
|
|
else n.add(id);
|
|
return n;
|
|
});
|
|
};
|
|
|
|
const toggleAll = (checked: boolean) => {
|
|
if (!checked) setSelected(new Set());
|
|
else setSelected(new Set(rows.map((r) => r.student_id)));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 p-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Credential dashboard</h1>
|
|
<p className="text-muted-foreground text-sm mt-1">Track onboarding emails and placement</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Filters</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<div className="space-y-2">
|
|
<Label>Account status</Label>
|
|
<Select value={accountFilter} onValueChange={setAccountFilter}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="All" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
<SelectItem value="pending_first_login">Pending first login</SelectItem>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="password_reset_required">Password reset required</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Email sent</Label>
|
|
<Select value={emailSentFilter} onValueChange={setEmailSentFilter}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="All" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
<SelectItem value="yes">Yes</SelectItem>
|
|
<SelectItem value="no">No</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Placement status</Label>
|
|
<Select value={placementFilter} onValueChange={setPlacementFilter}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="All" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
<SelectItem value="not_started">Not started</SelectItem>
|
|
<SelectItem value="in_progress">In progress</SelectItem>
|
|
<SelectItem value="completed">Completed</SelectItem>
|
|
<SelectItem value="bypassed">Bypassed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2 md:col-span-2 lg:col-span-1">
|
|
<Label>Date range</Label>
|
|
<div className="flex gap-2">
|
|
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
|
|
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() =>
|
|
resendAll.mutate(undefined, {
|
|
onSuccess: () => toast.success("Bulk resend started."),
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
|
|
})
|
|
}
|
|
disabled={resendAll.isPending}
|
|
>
|
|
Resend All Pending
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-12 text-muted-foreground">Loading…</div>
|
|
) : (
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12">
|
|
<Checkbox
|
|
checked={rows.length > 0 && selected.size === rows.length}
|
|
onCheckedChange={(c) => toggleAll(!!c)}
|
|
/>
|
|
</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Account Status</TableHead>
|
|
<TableHead>Email Sent</TableHead>
|
|
<TableHead>First Login</TableHead>
|
|
<TableHead>Placement Status</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rows.map((r) => (
|
|
<TableRow key={r.student_id}>
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={selected.has(r.student_id)}
|
|
onCheckedChange={() => toggle(r.student_id)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="font-medium">{r.name}</TableCell>
|
|
<TableCell>{r.email}</TableCell>
|
|
<TableCell>{r.account_status}</TableCell>
|
|
<TableCell>{r.email_sent ? "Yes" : "No"}</TableCell>
|
|
<TableCell>{r.first_login_at ? new Date(r.first_login_at).toLocaleString() : "—"}</TableCell>
|
|
<TableCell>{r.placement_status}</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() =>
|
|
resendOne.mutate(r.student_id, {
|
|
onSuccess: () => toast.success("Email resent."),
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Failed"),
|
|
})
|
|
}
|
|
disabled={resendOne.isPending}
|
|
>
|
|
Resend Email
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|