- 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
219 lines
8.1 KiB
TypeScript
219 lines
8.1 KiB
TypeScript
import { useCallback, useState } from "react";
|
|
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, AlertTriangle } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { useBulkCreate, useSendCredentials, useValidateCSV } from "@/hooks/queries/useEntityOnboarding";
|
|
import type { BulkCreateResult, CSVValidationReport, EntityStudent } from "@/types";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "sonner";
|
|
|
|
export default function BulkStudentUpload() {
|
|
const [step, setStep] = useState<1 | 2 | 3>(1);
|
|
const [report, setReport] = useState<CSVValidationReport | null>(null);
|
|
const [created, setCreated] = useState<BulkCreateResult | null>(null);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
|
|
const validateMut = useValidateCSV();
|
|
const bulkMut = useBulkCreate();
|
|
const sendMut = useSendCredentials();
|
|
|
|
const onFile = useCallback(
|
|
(file: File | null) => {
|
|
if (!file || !file.name.toLowerCase().endsWith(".csv")) {
|
|
toast.error("Please upload a CSV file.");
|
|
return;
|
|
}
|
|
validateMut.mutate(file, {
|
|
onSuccess: (data) => {
|
|
setReport(data);
|
|
setStep(2);
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Validation failed"),
|
|
});
|
|
},
|
|
[validateMut],
|
|
);
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
const f = e.dataTransfer.files[0];
|
|
onFile(f ?? null);
|
|
};
|
|
|
|
const summary = report
|
|
? `${report.valid_count} valid, ${report.error_count} error${report.error_count === 1 ? "" : "s"}, ${report.warning_count} warning${report.warning_count === 1 ? "" : "s"}`
|
|
: "";
|
|
|
|
const confirmCreate = () => {
|
|
bulkMut.mutate(
|
|
{ validate_session_id: report?.validate_session_id },
|
|
{
|
|
onSuccess: (data) => {
|
|
setCreated(data);
|
|
setStep(3);
|
|
toast.success("Accounts created.");
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Bulk create failed"),
|
|
},
|
|
);
|
|
};
|
|
|
|
const sendEmails = () => {
|
|
const ids = created?.accounts.map((a) => a.id) ?? [];
|
|
if (!ids.length) return;
|
|
sendMut.mutate(ids, {
|
|
onSuccess: () => toast.success("Credential emails queued."),
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Send failed"),
|
|
});
|
|
};
|
|
|
|
const statusIcon = (s: string) => {
|
|
if (s === "valid") return <CheckCircle2 className="h-4 w-4 text-green-600" />;
|
|
if (s === "error") return <AlertCircle className="h-4 w-4 text-destructive" />;
|
|
return <AlertTriangle className="h-4 w-4 text-amber-500" />;
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 p-6 max-w-5xl mx-auto">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Bulk student upload</h1>
|
|
<p className="text-muted-foreground text-sm mt-1">CSV import for your entity</p>
|
|
</div>
|
|
|
|
{step === 1 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Step 1 — Upload CSV</CardTitle>
|
|
<CardDescription>Drag and drop a file or click to browse. CSV only.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div
|
|
className={cn(
|
|
"border-2 border-dashed rounded-lg p-12 text-center transition-colors",
|
|
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25",
|
|
)}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
setDragOver(true);
|
|
}}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
>
|
|
<Upload className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
|
|
<p className="text-sm text-muted-foreground mb-4">Drop your CSV here</p>
|
|
<label className="inline-flex cursor-pointer">
|
|
<input
|
|
type="file"
|
|
accept=".csv,text/csv"
|
|
className="hidden"
|
|
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
|
|
/>
|
|
<Button type="button" variant="secondary">
|
|
Browse files
|
|
</Button>
|
|
</label>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<FileSpreadsheet className="h-4 w-4" />
|
|
<a href="/templates/students.csv" className="text-primary underline-offset-4 hover:underline" download>
|
|
Download Template
|
|
</a>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{step === 2 && report && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Step 2 — Validation report</CardTitle>
|
|
<CardDescription>Review each row before creating accounts.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="rounded-md border bg-muted/40 px-4 py-3 text-sm font-medium">{summary}</div>
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-16">Row#</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Issue</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{report.rows.map((r) => (
|
|
<TableRow key={r.row_number}>
|
|
<TableCell>{r.row_number}</TableCell>
|
|
<TableCell>{r.student_name}</TableCell>
|
|
<TableCell>{r.email}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
{statusIcon(r.status)}
|
|
<span className="capitalize">{r.status}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">{r.issue ?? "—"}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setStep(1);
|
|
setReport(null);
|
|
}}
|
|
>
|
|
Fix & Re-upload
|
|
</Button>
|
|
<Button onClick={confirmCreate} disabled={bulkMut.isPending}>
|
|
Confirm & Create Accounts
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{step === 3 && created && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Step 3 — Accounts created</CardTitle>
|
|
<CardDescription>{created.created_count} new accounts are ready.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{created.accounts.map((a: EntityStudent) => (
|
|
<TableRow key={a.id}>
|
|
<TableCell>{a.name}</TableCell>
|
|
<TableCell>{a.email}</TableCell>
|
|
<TableCell>{a.account_status}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<Button onClick={sendEmails} disabled={sendMut.isPending}>
|
|
Send Credential Emails
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|