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,33 +1,142 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Plus, Trash2, Copy } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, Copy, Loader2 } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
|
||||
const codes = [
|
||||
{ code: "ENCOACH-2025-A1B2", type: "Single", used: false, created: "2025-01-10" },
|
||||
{ code: "ENCOACH-2025-C3D4", type: "Single", used: true, created: "2025-01-12" },
|
||||
{ code: "BATCH-2025-E5F6", type: "Batch", used: false, created: "2025-02-01" },
|
||||
{ code: "BATCH-2025-G7H8", type: "Batch", used: false, created: "2025-02-01" },
|
||||
];
|
||||
|
||||
const packages = [
|
||||
{ id: 1, name: "IELTS Starter", price: 99, duration: "1 month", discount: 0 },
|
||||
{ id: 2, name: "IELTS Pro", price: 249, duration: "3 months", discount: 15 },
|
||||
{ id: 3, name: "Corporate Bundle", price: 1999, duration: "12 months", discount: 25 },
|
||||
];
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
platformSettingsService,
|
||||
type GradingConfig,
|
||||
type PlatformPackage,
|
||||
type RegistrationCode,
|
||||
} from "@/services/platformSettings.service";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
// ── Codes ──────────────────────────────────────────────────────────
|
||||
const codesQ = useQuery({
|
||||
queryKey: ["codes"],
|
||||
queryFn: () => platformSettingsService.listCodes(),
|
||||
});
|
||||
const codes: RegistrationCode[] = codesQ.data?.items ?? [];
|
||||
|
||||
const genMut = useMutation({
|
||||
mutationFn: platformSettingsService.generateCodes,
|
||||
onSuccess: (r) => {
|
||||
qc.invalidateQueries({ queryKey: ["codes"] });
|
||||
toast({ title: `Generated ${r.count} code(s)` });
|
||||
},
|
||||
onError: () =>
|
||||
toast({ title: "Failed to generate codes", variant: "destructive" }),
|
||||
});
|
||||
const delCodeMut = useMutation({
|
||||
mutationFn: platformSettingsService.deleteCode,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["codes"] });
|
||||
toast({ title: "Code deleted" });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Packages ───────────────────────────────────────────────────────
|
||||
const pkgQ = useQuery({
|
||||
queryKey: ["packages"],
|
||||
queryFn: () => platformSettingsService.listPackages(),
|
||||
});
|
||||
const packages: PlatformPackage[] = pkgQ.data?.items ?? [];
|
||||
const [pkgOpen, setPkgOpen] = useState(false);
|
||||
const [pkgForm, setPkgForm] = useState<Omit<PlatformPackage, "id">>({
|
||||
name: "",
|
||||
price: 0,
|
||||
duration: "1 month",
|
||||
discount: 0,
|
||||
});
|
||||
const createPkgMut = useMutation({
|
||||
mutationFn: platformSettingsService.createPackage,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["packages"] });
|
||||
setPkgOpen(false);
|
||||
setPkgForm({ name: "", price: 0, duration: "1 month", discount: 0 });
|
||||
toast({ title: "Package created" });
|
||||
},
|
||||
});
|
||||
const delPkgMut = useMutation({
|
||||
mutationFn: platformSettingsService.deletePackage,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["packages"] });
|
||||
toast({ title: "Package deleted" });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Grading config ─────────────────────────────────────────────────
|
||||
const gradingQ = useQuery({
|
||||
queryKey: ["grading-config"],
|
||||
queryFn: () => platformSettingsService.getGrading(),
|
||||
});
|
||||
const [grading, setGrading] = useState<GradingConfig>({
|
||||
min_score: 0,
|
||||
max_score: 9,
|
||||
increment: 0.5,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (gradingQ.data) {
|
||||
setGrading({
|
||||
min_score: gradingQ.data.min_score,
|
||||
max_score: gradingQ.data.max_score,
|
||||
increment: gradingQ.data.increment,
|
||||
});
|
||||
}
|
||||
}, [gradingQ.data]);
|
||||
const saveGradingMut = useMutation({
|
||||
mutationFn: platformSettingsService.setGrading,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["grading-config"] });
|
||||
toast({ title: "Grading configuration saved" });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
err && typeof err === "object" && "message" in err
|
||||
? String((err as { message: unknown }).message)
|
||||
: "Failed to save";
|
||||
toast({ title: msg, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground">Manage codes, packages, discounts, and grading system.</p>
|
||||
<p className="text-muted-foreground">
|
||||
Manage registration codes, packages, and the grading scale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="codes">
|
||||
@@ -37,26 +146,107 @@ export default function SettingsPage() {
|
||||
<TabsTrigger value="grading">Grading System</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* CODES */}
|
||||
<TabsContent value="codes" className="mt-4 space-y-4">
|
||||
<AiTipBanner context="settings-codes" variant="insight" />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
|
||||
<Button size="sm" variant="outline"><Copy className="h-4 w-4 mr-1" /> Generate Batch</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={genMut.isPending}
|
||||
onClick={() =>
|
||||
genMut.mutate({
|
||||
count: 1,
|
||||
code_type: "individual",
|
||||
user_type: "student",
|
||||
max_uses: 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
{genMut.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
)}{" "}
|
||||
Generate Single
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={genMut.isPending}
|
||||
onClick={() =>
|
||||
genMut.mutate({
|
||||
count: 10,
|
||||
code_type: "corporate",
|
||||
user_type: "corporate",
|
||||
max_uses: 50,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" /> Generate Batch (10)
|
||||
</Button>
|
||||
</div>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow><TableHead>Code</TableHead><TableHead>Type</TableHead><TableHead>Used</TableHead><TableHead>Created</TableHead><TableHead className="w-10"></TableHead></TableRow>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{codesQ.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!codesQ.isLoading && codes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center text-muted-foreground py-8"
|
||||
>
|
||||
No codes yet. Click Generate Single to create one.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{codes.map((c) => (
|
||||
<TableRow key={c.code}>
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-mono text-xs">{c.code}</TableCell>
|
||||
<TableCell><Badge variant="outline">{c.type}</Badge></TableCell>
|
||||
<TableCell><Badge variant={c.used ? "secondary" : "default"}>{c.used ? "Used" : "Available"}</Badge></TableCell>
|
||||
<TableCell>{c.created}</TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{c.code_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{c.user_type}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{c.uses}/{c.max_uses}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={c.used ? "secondary" : "default"}>
|
||||
{c.used ? "Used" : "Available"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{c.created ? new Date(c.created).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => delCodeMut.mutate(c.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -65,35 +255,186 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* PACKAGES */}
|
||||
<TabsContent value="packages" className="mt-4 space-y-4">
|
||||
<AiTipBanner context="settings-packages" variant="recommendation" />
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setPkgOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Package
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{pkgQ.isLoading && <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
{packages.map((p) => (
|
||||
<Card key={p.id} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardHeader className="pb-3 flex-row items-start justify-between">
|
||||
<CardTitle className="text-base">{p.name}</CardTitle>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => delPkgMut.mutate(p.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-2xl font-bold">${p.price}</p>
|
||||
<p className="text-sm text-muted-foreground">{p.duration}</p>
|
||||
{p.discount > 0 && <Badge variant="default">{p.discount}% OFF</Badge>}
|
||||
{p.discount > 0 && (
|
||||
<Badge variant="default">{p.discount}% OFF</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={pkgOpen} onOpenChange={setPkgOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Package</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={pkgForm.name}
|
||||
onChange={(e) =>
|
||||
setPkgForm((f) => ({ ...f, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Price (USD)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={pkgForm.price}
|
||||
onChange={(e) =>
|
||||
setPkgForm((f) => ({
|
||||
...f,
|
||||
price: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Discount %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={pkgForm.discount}
|
||||
onChange={(e) =>
|
||||
setPkgForm((f) => ({
|
||||
...f,
|
||||
discount: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Duration</Label>
|
||||
<Select
|
||||
value={pkgForm.duration}
|
||||
onValueChange={(v) =>
|
||||
setPkgForm((f) => ({ ...f, duration: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1 month">1 month</SelectItem>
|
||||
<SelectItem value="3 months">3 months</SelectItem>
|
||||
<SelectItem value="6 months">6 months</SelectItem>
|
||||
<SelectItem value="12 months">12 months</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPkgOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!pkgForm.name || createPkgMut.isPending}
|
||||
onClick={() => createPkgMut.mutate(pkgForm)}
|
||||
>
|
||||
{createPkgMut.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Creating…
|
||||
</>
|
||||
) : (
|
||||
"Create"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
|
||||
{/* GRADING */}
|
||||
<TabsContent value="grading" className="mt-4 space-y-4">
|
||||
<AiTipBanner context="settings-grading" variant="tip" />
|
||||
<Card className="border-0 shadow-sm max-w-lg">
|
||||
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Scoring Scale</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2"><Label>Min Score</Label><Input type="number" defaultValue="0" /></div>
|
||||
<div className="space-y-2"><Label>Max Score</Label><Input type="number" defaultValue="9" /></div>
|
||||
<div className="space-y-2">
|
||||
<Label>Min Score</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={grading.min_score}
|
||||
onChange={(e) =>
|
||||
setGrading((g) => ({
|
||||
...g,
|
||||
min_score: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Max Score</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={grading.max_score}
|
||||
onChange={(e) =>
|
||||
setGrading((g) => ({
|
||||
...g,
|
||||
max_score: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Score Increment</Label><Input type="number" defaultValue="0.5" step="0.5" /></div>
|
||||
<Button>Save Grading Configuration</Button>
|
||||
<div className="space-y-2">
|
||||
<Label>Score Increment</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={grading.increment}
|
||||
onChange={(e) =>
|
||||
setGrading((g) => ({
|
||||
...g,
|
||||
increment: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={saveGradingMut.isPending}
|
||||
onClick={() => saveGradingMut.mutate(grading)}
|
||||
>
|
||||
{saveGradingMut.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
"Save Grading Configuration"
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user