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

445 lines
16 KiB
TypeScript

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 { Badge } from "@/components/ui/badge";
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";
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 registration codes, packages, and the grading scale.
</p>
</div>
<Tabs defaultValue="codes">
<TabsList>
<TabsTrigger value="codes">Codes</TabsTrigger>
<TabsTrigger value="packages">Packages & Discounts</TabsTrigger>
<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"
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>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.id}>
<TableCell className="font-mono text-xs">{c.code}</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>
</Table>
</CardContent>
</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 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>
)}
</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>
<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"
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"
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>
</Tabs>
</div>
);
}