feat(v3): restructure project + add complete frontend
- 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
This commit is contained in:
218
frontend/src/pages/admin/BulkStudentUpload.tsx
Normal file
218
frontend/src/pages/admin/BulkStudentUpload.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user