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
This commit is contained in:
@@ -1,96 +1,389 @@
|
||||
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 { CheckCircle, BookA, Sparkles } from "lucide-react";
|
||||
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 VocabItem,
|
||||
type CefrLevel,
|
||||
} from "@/services/training.service";
|
||||
|
||||
const vocabItems = [
|
||||
{ word: "Ubiquitous", meaning: "Present, appearing, or found everywhere", level: "C1", completed: true },
|
||||
{ word: "Pragmatic", meaning: "Dealing with things sensibly and realistically", level: "B2", completed: true },
|
||||
{ word: "Eloquent", meaning: "Fluent or persuasive in speaking or writing", level: "C1", completed: false },
|
||||
{ word: "Meticulous", meaning: "Showing great attention to detail", level: "B2", completed: false },
|
||||
{ word: "Ambiguous", meaning: "Open to more than one interpretation", level: "B2", completed: false },
|
||||
{ word: "Coherent", meaning: "Logical and consistent", level: "B1", completed: false },
|
||||
{ word: "Versatile", meaning: "Able to adapt to many different functions", level: "B2", completed: true },
|
||||
{ word: "Concise", meaning: "Giving a lot of information in few words", level: "B1", completed: false },
|
||||
];
|
||||
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
|
||||
export default function VocabularyPage() {
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const completed = vocabItems.filter(v => v.completed).length;
|
||||
const filtered = showCompleted ? vocabItems : vocabItems.filter(v => !v.completed);
|
||||
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({
|
||||
word: "",
|
||||
meaning: "",
|
||||
example_sentence: "",
|
||||
level: "B1" as CefrLevel,
|
||||
part_of_speech: "noun" as VocabItem["part_of_speech"],
|
||||
category: "general",
|
||||
});
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ["training-vocab", levelFilter, search],
|
||||
queryFn: () =>
|
||||
trainingService.listVocab({
|
||||
...(levelFilter !== "all" ? { level: levelFilter } : {}),
|
||||
...(search ? { search } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const items = listQ.data?.items ?? [];
|
||||
const summary = listQ.data?.summary;
|
||||
const filtered = showCompleted ? items : items.filter((v) => !v.completed);
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: trainingService.createVocab,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["training-vocab"] });
|
||||
setCreateOpen(false);
|
||||
setForm({
|
||||
word: "",
|
||||
meaning: "",
|
||||
example_sentence: "",
|
||||
level: "B1",
|
||||
part_of_speech: "noun",
|
||||
category: "general",
|
||||
});
|
||||
toast({ title: "Word added" });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
err && typeof err === "object" && "message" in err
|
||||
? String((err as { message: unknown }).message)
|
||||
: "Failed to add word";
|
||||
toast({ title: msg, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const delMut = useMutation({
|
||||
mutationFn: trainingService.deleteVocab,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["training-vocab"] });
|
||||
toast({ title: "Word deleted" });
|
||||
},
|
||||
});
|
||||
|
||||
const progressMut = useMutation({
|
||||
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
|
||||
trainingService.setVocabProgress(id, { completed }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-vocab"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
|
||||
<p className="text-muted-foreground">Build your vocabulary for IELTS success.</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Build and manage the vocabulary library — track completion by CEFR level.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Word
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AiTipBanner context="vocabulary" variant="recommendation" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Summary */}
|
||||
<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">{completed}/{vocabItems.length} completed</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{summary
|
||||
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={(completed / vocabItems.length) * 100} className="h-3" />
|
||||
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
||||
<Label htmlFor="show" className="text-sm">Show completed</Label>
|
||||
{/* Filters */}
|
||||
<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 word or meaning…"
|
||||
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" checked={showCompleted} onCheckedChange={setShowCompleted} />
|
||||
<Label htmlFor="show" className="text-sm">
|
||||
Show completed
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<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 vocabulary items match.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{filtered.map((v) => (
|
||||
<Card key={v.word} className="border-0 shadow-sm">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{v.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
|
||||
<div>
|
||||
<p className="font-semibold text-sm">{v.word}</p>
|
||||
<p className="text-xs text-muted-foreground">{v.meaning}</p>
|
||||
</div>
|
||||
<Card key={v.id} className="border-0 shadow-sm">
|
||||
<CardContent className="p-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
progressMut.mutate({ id: v.id, completed: !v.completed })
|
||||
}
|
||||
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
|
||||
aria-label={v.completed ? "Mark incomplete" : "Mark complete"}
|
||||
>
|
||||
{v.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">{v.word}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{v.meaning}</p>
|
||||
{v.example_sentence && (
|
||||
<p className="text-xs italic text-muted-foreground mt-0.5 truncate">
|
||||
“{v.example_sentence}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="outline">{v.level}</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{v.part_of_speech}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => delMut.mutate(v.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Badge variant="outline">{v.level}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI panel */}
|
||||
<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>
|
||||
<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>Learn "coherent" + "concise" together — they share academic writing context</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Review C1-level vocabulary for IELTS Task 2</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Focus on collocations with 'make' and 'do'</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold">•</span>Try using "meticulous" in your next writing practice</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-primary font-bold">•</span>
|
||||
Pair <b>coherent</b> + <b>concise</b> — they share academic-writing
|
||||
contexts.
|
||||
</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-primary font-bold">•</span>
|
||||
Review C1 words for IELTS Writing Task 2.
|
||||
</li>
|
||||
<li className="text-sm text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-primary font-bold">•</span>
|
||||
Try using <b>meticulous</b> in your next essay.
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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 Vocabulary Goal</p>
|
||||
<p className="text-sm text-muted-foreground">At 2 words/day, you'll complete this set by March 15. AI recommends adding 10 more domain-specific words from your upcoming exam topics.</p>
|
||||
</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 Vocabulary Goal
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary.remaining > 0
|
||||
? `At 2 words/day, you'll complete the remaining ${summary.remaining} items in about ${Math.ceil(summary.remaining / 2)} days.`
|
||||
: "You've completed every active word — try adding more to keep challenging yourself."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Vocabulary Word</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Word</Label>
|
||||
<Input
|
||||
value={form.word}
|
||||
onChange={(e) => setForm((f) => ({ ...f, word: e.target.value }))}
|
||||
placeholder="e.g. Ubiquitous"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Meaning</Label>
|
||||
<Textarea
|
||||
value={form.meaning}
|
||||
onChange={(e) => setForm((f) => ({ ...f, meaning: e.target.value }))}
|
||||
placeholder="Plain-English definition"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Example sentence (optional)</Label>
|
||||
<Textarea
|
||||
value={form.example_sentence}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, example_sentence: e.target.value }))
|
||||
}
|
||||
placeholder="Use the word in context"
|
||||
/>
|
||||
</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>Part of speech</Label>
|
||||
<Select
|
||||
value={form.part_of_speech}
|
||||
onValueChange={(v) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
part_of_speech: v as VocabItem["part_of_speech"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="noun">noun</SelectItem>
|
||||
<SelectItem value="verb">verb</SelectItem>
|
||||
<SelectItem value="adjective">adjective</SelectItem>
|
||||
<SelectItem value="adverb">adverb</SelectItem>
|
||||
<SelectItem value="phrase">phrase</SelectItem>
|
||||
<SelectItem value="other">other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Input
|
||||
value={form.category}
|
||||
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!form.word.trim() || !form.meaning.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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user