Files
full_encoach_platform/frontend/src/pages/admin/BulkStudentUpload.tsx
Yamen Ahmad f1c4953a63 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
2026-04-10 17:26:42 +04:00

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>
);
}