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([]); 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) => { 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
Invalid entity.
; } if (isLoading) { return
Loading mapping…
; } return (

Level mapping

Entity #{entityId}

CEFR ↔ internal levels Editable grid with overlap validation
CEFR min score CEFR max score Internal level name CEFR equivalent {rows.map((row, i) => ( updateRow(i, { cefr_min_score: Number(e.target.value) })} /> updateRow(i, { cefr_max_score: Number(e.target.value) })} /> updateRow(i, { internal_level_name: e.target.value })} /> updateRow(i, { cefr_equivalent: e.target.value })} /> ))}
); }