Files
encoach_frontend_new_v2/src/pages/admin/LevelMappingConfig.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

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