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:
170
src/pages/admin/LevelMappingConfig.tsx
Normal file
170
src/pages/admin/LevelMappingConfig.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user