Files
encoach_frontend_v4/src/pages/admin/MarksheetManager.tsx
Yamen Ahmad 11a7265460 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

313 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, FileSpreadsheet, CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useToast } from "@/hooks/use-toast";
import type { ResultTemplate, Marksheet, MarksheetLine, GradeConfig } from "@/types/institutional-exam";
const marksheetStateBadge: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
validated: "default",
cancelled: "destructive",
};
export default function MarksheetManager() {
const { toast } = useToast();
const qc = useQueryClient();
const [activeTab, setActiveTab] = useState<"templates" | "marksheets">("templates");
const [showCreateTemplate, setShowCreateTemplate] = useState(false);
const [selectedMarksheetId, setSelectedMarksheetId] = useState<number | null>(null);
const { data: templatesData, isLoading: loadingTemplates } = useQuery({
queryKey: ["result-templates", "list"],
queryFn: () => institutionalExamService.listResultTemplates(),
});
const templates = templatesData?.items ?? [];
const { data: marksheetsData, isLoading: loadingMarksheets } = useQuery({
queryKey: ["marksheets", "list"],
queryFn: () => institutionalExamService.listMarksheets(),
enabled: activeTab === "marksheets",
});
const marksheets = marksheetsData?.items ?? [];
const { data: selectedMarksheet } = useQuery({
queryKey: ["marksheets", selectedMarksheetId],
queryFn: () => institutionalExamService.getMarksheet(selectedMarksheetId!),
enabled: !!selectedMarksheetId,
});
const { data: sessionsData } = useQuery({
queryKey: ["inst-exam-sessions", "list"],
queryFn: () => institutionalExamService.listSessions(),
});
const sessions = sessionsData?.items ?? [];
const { data: gradeConfigsData } = useQuery({
queryKey: ["grade-configs", "list"],
queryFn: () => institutionalExamService.listGradeConfigs(),
});
const gradeConfigs = gradeConfigsData?.items ?? [];
const [templateForm, setTemplateForm] = useState({
name: "",
exam_session_id: 0,
grade_ids: [] as number[],
});
const createTemplateMutation = useMutation({
mutationFn: () => institutionalExamService.createResultTemplate(templateForm as Parameters<typeof institutionalExamService.createResultTemplate>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["result-templates"] });
toast({ title: "Template created" });
setShowCreateTemplate(false);
setTemplateForm({ name: "", exam_session_id: 0, grade_ids: [] });
},
onError: () => toast({ title: "Error", description: "Failed to create template", variant: "destructive" }),
});
const generateMutation = useMutation({
mutationFn: (templateId: number) => institutionalExamService.generateMarksheets(templateId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["marksheets"] });
qc.invalidateQueries({ queryKey: ["result-templates"] });
toast({ title: "Marksheets generated" });
},
onError: () => toast({ title: "Error", description: "Generation failed", variant: "destructive" }),
});
const validateMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.validateMarksheet(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["marksheets"] });
toast({ title: "Marksheet validated" });
},
onError: () => toast({ title: "Error", description: "Validation failed", variant: "destructive" }),
});
const isLoading = activeTab === "templates" ? loadingTemplates : loadingMarksheets;
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Marksheet Manager</h1>
<p className="text-muted-foreground">Manage result templates and marksheets.</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant={activeTab === "templates" ? "default" : "outline"} size="sm" onClick={() => setActiveTab("templates")}>
Result Templates
</Button>
<Button variant={activeTab === "marksheets" ? "default" : "outline"} size="sm" onClick={() => setActiveTab("marksheets")}>
Marksheets
</Button>
</div>
{activeTab === "templates" && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Result Templates</CardTitle>
<Dialog open={showCreateTemplate} onOpenChange={setShowCreateTemplate}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Create Template</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Result Template</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mid-Term Results" value={templateForm.name} onChange={e => setTemplateForm({ ...templateForm, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Exam Session</Label>
<Select value={templateForm.exam_session_id ? templateForm.exam_session_id.toString() : ""} onValueChange={v => setTemplateForm({ ...templateForm, exam_session_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select session" /></SelectTrigger>
<SelectContent>
{sessions.map((s: { id: number; name: string }) => (
<SelectItem key={s.id} value={s.id.toString()}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Grade Configuration</Label>
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-lg p-2">
{gradeConfigs.map((gc: GradeConfig) => (
<label key={gc.id} className="flex items-center gap-2 p-1 hover:bg-accent/50 rounded cursor-pointer">
<input
type="checkbox"
checked={templateForm.grade_ids.includes(gc.id)}
onChange={e => {
setTemplateForm(prev => ({
...prev,
grade_ids: e.target.checked
? [...prev.grade_ids, gc.id]
: prev.grade_ids.filter(id => id !== gc.id),
}));
}}
className="rounded"
/>
<span className="text-sm">{gc.result} ({gc.min_per}%{gc.max_per}%)</span>
</label>
))}
{gradeConfigs.length === 0 && <p className="text-xs text-muted-foreground p-1">No grade configs available.</p>}
</div>
</div>
<Button className="w-full" onClick={() => createTemplateMutation.mutate()} disabled={createTemplateMutation.isPending || !templateForm.name || !templateForm.exam_session_id}>
{createTemplateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Template
</Button>
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Exam Session</TableHead>
<TableHead>Evaluation</TableHead>
<TableHead>Result Date</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((tpl: ResultTemplate) => (
<TableRow key={tpl.id}>
<TableCell className="font-medium">{tpl.name}</TableCell>
<TableCell>{tpl.exam_session_name}</TableCell>
<TableCell><Badge variant="outline" className="capitalize">{tpl.evaluation_type}</Badge></TableCell>
<TableCell className="text-muted-foreground">{tpl.result_date}</TableCell>
<TableCell>
<Badge variant={tpl.state === "result_generated" ? "default" : "outline"} className="capitalize">
{tpl.state.replace("_", " ")}
</Badge>
</TableCell>
<TableCell className="text-right">
{tpl.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => generateMutation.mutate(tpl.id)} disabled={generateMutation.isPending}>
<FileSpreadsheet className="mr-1 h-3 w-3" /> Generate Marksheets
</Button>
)}
</TableCell>
</TableRow>
))}
{templates.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No result templates found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{activeTab === "marksheets" && (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Exam Session</TableHead>
<TableHead>Generated</TableHead>
<TableHead>Pass</TableHead>
<TableHead>Fail</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{marksheets.map((ms: Marksheet) => (
<TableRow
key={ms.id}
className="cursor-pointer hover:bg-accent/50"
onClick={() => setSelectedMarksheetId(selectedMarksheetId === ms.id ? null : ms.id)}
>
<TableCell className="font-medium">{ms.name}</TableCell>
<TableCell>{ms.exam_session_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{ms.generated_date} by {ms.generated_by_name}</TableCell>
<TableCell><Badge variant="default">{ms.total_pass}</Badge></TableCell>
<TableCell><Badge variant="destructive">{ms.total_failed}</Badge></TableCell>
<TableCell>
<Badge variant={marksheetStateBadge[ms.state] ?? "outline"} className="capitalize">{ms.state}</Badge>
</TableCell>
<TableCell className="text-right" onClick={e => e.stopPropagation()}>
{ms.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => validateMutation.mutate(ms.id)} disabled={validateMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Validate
</Button>
)}
</TableCell>
</TableRow>
))}
{marksheets.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No marksheets found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{selectedMarksheet && (
<Card>
<CardHeader>
<CardTitle>Results {selectedMarksheet.name}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Total Marks</TableHead>
<TableHead>Percentage</TableHead>
<TableHead>Grade</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(selectedMarksheet.lines ?? []).map((line: MarksheetLine) => (
<TableRow key={line.id}>
<TableCell className="font-medium">{line.student_name}</TableCell>
<TableCell>{line.total_marks}</TableCell>
<TableCell>{line.percentage.toFixed(1)}%</TableCell>
<TableCell>{line.grade ?? "—"}</TableCell>
<TableCell>
<Badge variant={line.status === "pass" ? "default" : "destructive"} className="capitalize">{line.status}</Badge>
</TableCell>
</TableRow>
))}
{(selectedMarksheet.lines ?? []).length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No results in this marksheet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}