Files
encoach_frontend_new_v2/src/pages/GrammarPage.tsx
Yamen Ahmad 435930a827 feat: institutional + support + training admin sections (backend + frontend)
Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
2026-04-19 03:13:23 +04:00

364 lines
13 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
CheckCircle,
Plus,
Search,
Trash2,
Loader2,
Sparkles,
Circle,
} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
import { trainingService, type CefrLevel } from "@/services/training.service";
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
export default function GrammarPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [showCompleted, setShowCompleted] = useState(true);
const [levelFilter, setLevelFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({
name: "",
description: "",
example: "",
level: "B1" as CefrLevel,
category: "general",
});
const listQ = useQuery({
queryKey: ["training-grammar", levelFilter, search],
queryFn: () =>
trainingService.listGrammar({
...(levelFilter !== "all" ? { level: levelFilter } : {}),
...(search ? { search } : {}),
}),
});
const items = listQ.data?.items ?? [];
const summary = listQ.data?.summary;
const filtered = showCompleted ? items : items.filter((g) => !g.completed);
const createMut = useMutation({
mutationFn: trainingService.createGrammar,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-grammar"] });
setCreateOpen(false);
setForm({
name: "",
description: "",
example: "",
level: "B1",
category: "general",
});
toast({ title: "Rule added" });
},
onError: (err: unknown) => {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message: unknown }).message)
: "Failed to add rule";
toast({ title: msg, variant: "destructive" });
},
});
const delMut = useMutation({
mutationFn: trainingService.deleteGrammar,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-grammar"] });
toast({ title: "Rule deleted" });
},
});
const progressMut = useMutation({
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
trainingService.setGrammarProgress(id, { completed }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-grammar"] }),
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
<p className="text-muted-foreground">
Manage the grammar-rule library and track mastery by CEFR level.
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Add Rule
</Button>
</div>
<AiTipBanner context="grammar" variant="recommendation" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Progress</CardTitle>
<span className="text-sm text-muted-foreground">
{summary
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
: "—"}
</span>
</div>
</CardHeader>
<CardContent>
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
</CardContent>
</Card>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search rules…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVELS.map((l) => (
<SelectItem key={l} value={l}>
{l === "all" ? "All levels" : l}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Switch
id="show-g"
checked={showCompleted}
onCheckedChange={setShowCompleted}
/>
<Label htmlFor="show-g" className="text-sm">
Show completed
</Label>
</div>
</div>
<div className="space-y-2">
{listQ.isLoading && (
<div className="py-8 text-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</div>
)}
{!listQ.isLoading && filtered.length === 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="text-center text-muted-foreground py-8">
No grammar rules match.
</CardContent>
</Card>
)}
{filtered.map((g) => (
<Card key={g.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between gap-3">
<button
onClick={() =>
progressMut.mutate({ id: g.id, completed: !g.completed })
}
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
aria-label={g.completed ? "Mark incomplete" : "Mark complete"}
>
{g.completed ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm">{g.name}</p>
<p className="text-xs text-muted-foreground">{g.description}</p>
{g.example && (
<p className="text-xs italic text-muted-foreground mt-0.5">
e.g. {g.example}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline">{g.level}</Badge>
<Badge variant="secondary" className="text-xs">
{g.category}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => delMut.mutate(g.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Practice passive-voice transformations high exam frequency.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Review modal verbs for IELTS Writing Task 1.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Focus on complex sentences for C1 readiness.
</li>
</ul>
</CardContent>
</Card>
{summary && (
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
<Sparkles className="h-3 w-3" /> AI Study Plan
</p>
<p className="text-sm text-muted-foreground">
{summary.remaining > 0
? `Finish the remaining ${summary.remaining} rule(s) at one per day to stay on track.`
: "You've mastered every active rule in the library."}
</p>
</CardContent>
</Card>
)}
</div>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Grammar Rule</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Rule name</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g. Present Perfect vs Past Simple"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description}
onChange={(e) =>
setForm((f) => ({ ...f, description: e.target.value }))
}
placeholder="When and how this rule applies"
/>
</div>
<div className="space-y-2">
<Label>Example (optional)</Label>
<Textarea
value={form.example}
onChange={(e) => setForm((f) => ({ ...f, example: e.target.value }))}
placeholder="A sample sentence"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>CEFR level</Label>
<Select
value={form.level}
onValueChange={(v) =>
setForm((f) => ({ ...f, level: v as CefrLevel }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
(l) => (
<SelectItem key={l} value={l}>
{l}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) =>
setForm((f) => ({ ...f, category: e.target.value }))
}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={
!form.name.trim() ||
!form.description.trim() ||
createMut.isPending
}
onClick={() => createMut.mutate(form)}
>
{createMut.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding
</>
) : (
"Add"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}