- 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
171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { queryKeys } from "@/hooks/queries/keys";
|
|
import { levelMappingService } from "@/services/level-mapping.service";
|
|
import type { LevelMappingRow } from "@/types";
|
|
import { toast } from "sonner";
|
|
|
|
function validateRows(rows: LevelMappingRow[]): string | null {
|
|
const sorted = [...rows].sort((x, y) => x.cefr_min_score - y.cefr_min_score);
|
|
for (const r of sorted) {
|
|
if (r.cefr_min_score > r.cefr_max_score) return "Each row must have min ≤ max.";
|
|
}
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
if (sorted[i].cefr_min_score <= sorted[i - 1].cefr_max_score) {
|
|
return "CEFR score ranges must not overlap.";
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export default function LevelMappingConfig() {
|
|
const { entityId: eid } = useParams<{ entityId: string }>();
|
|
const entityId = Number(eid);
|
|
const qc = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: queryKeys.levelMapping.get(entityId),
|
|
queryFn: () => levelMappingService.get(entityId),
|
|
enabled: Number.isFinite(entityId),
|
|
});
|
|
|
|
const [rows, setRows] = useState<LevelMappingRow[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (data?.mappings?.length) setRows(data.mappings.map((r) => ({ ...r })));
|
|
}, [data]);
|
|
|
|
const dirty = useMemo(() => JSON.stringify(rows) !== JSON.stringify(data?.mappings ?? []), [rows, data]);
|
|
|
|
const saveMut = useMutation({
|
|
mutationFn: () => levelMappingService.update(entityId, rows),
|
|
onSuccess: () => {
|
|
toast.success("Saved.");
|
|
qc.invalidateQueries({ queryKey: queryKeys.levelMapping.get(entityId) });
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Save failed"),
|
|
});
|
|
|
|
const resetMut = useMutation({
|
|
mutationFn: () => levelMappingService.resetToDefault(entityId),
|
|
onSuccess: () => {
|
|
toast.success("Reset.");
|
|
qc.invalidateQueries({ queryKey: queryKeys.levelMapping.get(entityId) });
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : "Reset failed"),
|
|
});
|
|
|
|
const updateRow = (index: number, patch: Partial<LevelMappingRow>) => {
|
|
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
|
|
};
|
|
|
|
const addRow = () => {
|
|
setRows((prev) => [
|
|
...prev,
|
|
{
|
|
cefr_min_score: 0,
|
|
cefr_max_score: 0,
|
|
internal_level_name: "New level",
|
|
cefr_equivalent: "B1",
|
|
},
|
|
]);
|
|
};
|
|
|
|
const save = () => {
|
|
const err = validateRows(rows);
|
|
if (err) {
|
|
toast.error(err);
|
|
return;
|
|
}
|
|
saveMut.mutate();
|
|
};
|
|
|
|
if (!Number.isFinite(entityId)) {
|
|
return <div className="p-6 text-destructive">Invalid entity.</div>;
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <div className="p-6 text-muted-foreground">Loading mapping…</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-6 max-w-5xl mx-auto">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Level mapping</h1>
|
|
<p className="text-muted-foreground text-sm mt-1">Entity #{entityId}</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle>CEFR ↔ internal levels</CardTitle>
|
|
<CardDescription>Editable grid with overlap validation</CardDescription>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={addRow}>
|
|
Add Row
|
|
</Button>
|
|
<Button variant="outline" onClick={() => resetMut.mutate()} disabled={resetMut.isPending}>
|
|
Reset to Default
|
|
</Button>
|
|
<Button onClick={save} disabled={!dirty || saveMut.isPending}>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>CEFR min score</TableHead>
|
|
<TableHead>CEFR max score</TableHead>
|
|
<TableHead>Internal level name</TableHead>
|
|
<TableHead>CEFR equivalent</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rows.map((row, i) => (
|
|
<TableRow key={row.id ?? i}>
|
|
<TableCell>
|
|
<Input
|
|
type="number"
|
|
value={row.cefr_min_score}
|
|
onChange={(e) => updateRow(i, { cefr_min_score: Number(e.target.value) })}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Input
|
|
type="number"
|
|
value={row.cefr_max_score}
|
|
onChange={(e) => updateRow(i, { cefr_max_score: Number(e.target.value) })}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Input
|
|
value={row.internal_level_name}
|
|
onChange={(e) => updateRow(i, { internal_level_name: e.target.value })}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Input
|
|
value={row.cefr_equivalent}
|
|
onChange={(e) => updateRow(i, { cefr_equivalent: e.target.value })}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|