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
314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { useFacilities, useCreateFacility, useUpdateFacility, useDeleteFacility, useAssets, useCreateAsset, useDeleteAsset } from "@/hooks/queries";
|
|
import { Search, Plus, Trash2, Edit, Building } from "lucide-react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
export default function AdminFacilities() {
|
|
const [search, setSearch] = useState("");
|
|
const [section, setSection] = useState<"facilities" | "assets">("facilities");
|
|
const [facOpen, setFacOpen] = useState(false);
|
|
const [facEditOpen, setFacEditOpen] = useState(false);
|
|
const [facEditId, setFacEditId] = useState<number | null>(null);
|
|
const [assetOpen, setAssetOpen] = useState(false);
|
|
const [facForm, setFacForm] = useState({ name: "", code: "" });
|
|
const [assetForm, setAssetForm] = useState({ name: "", code: "" });
|
|
const { toast } = useToast();
|
|
|
|
const facQ = useFacilities();
|
|
const assetQ = useAssets();
|
|
const createFac = useCreateFacility();
|
|
const updateFac = useUpdateFacility();
|
|
const delFac = useDeleteFacility();
|
|
const createAsset = useCreateAsset();
|
|
const delAsset = useDeleteAsset();
|
|
|
|
const loading = section === "facilities" ? facQ.isLoading : assetQ.isLoading;
|
|
const facilities = facQ.data?.data ?? facQ.data?.items ?? [];
|
|
const assets = assetQ.data?.data ?? assetQ.data?.items ?? [];
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const q = search.toLowerCase();
|
|
const filteredFac = facilities.filter(
|
|
(f) => f.name?.toLowerCase().includes(q) || f.code?.toLowerCase().includes(q),
|
|
);
|
|
const filteredAssets = assets.filter(
|
|
(a) =>
|
|
a.name?.toLowerCase().includes(q) ||
|
|
a.code?.toLowerCase().includes(q) ||
|
|
a.product_name?.toLowerCase().includes(q),
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
<Building className="h-7 w-7" />
|
|
Facilities
|
|
</h1>
|
|
<p className="text-muted-foreground">Facilities and fixed assets.</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => (section === "facilities" ? setFacOpen(true) : setAssetOpen(true))}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{section === "facilities" ? "New facility" : "New asset"}
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant={section === "facilities" ? "default" : "outline"}
|
|
onClick={() => setSection("facilities")}
|
|
>
|
|
Facilities
|
|
</Button>
|
|
<Button variant={section === "assets" ? "default" : "outline"} onClick={() => setSection("assets")}>
|
|
Assets
|
|
</Button>
|
|
</div>
|
|
<div className="relative 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..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
{section === "facilities" ? (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>#</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Code</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredFac.map((f, i) => (
|
|
<TableRow key={f.id}>
|
|
<TableCell>{i + 1}</TableCell>
|
|
<TableCell className="font-medium">{f.name}</TableCell>
|
|
<TableCell>{f.code}</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setFacEditId(f.id);
|
|
setFacForm({ name: f.name || "", code: f.code || "" });
|
|
setFacEditOpen(true);
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-destructive"
|
|
onClick={() => {
|
|
if (!window.confirm("Delete this facility?")) return;
|
|
delFac.mutate(f.id, {
|
|
onSuccess: () => toast({ title: "Deleted" }),
|
|
onError: (err: Error) =>
|
|
toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
});
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>#</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Code</TableHead>
|
|
<TableHead>Product</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredAssets.map((a, i) => (
|
|
<TableRow key={a.id}>
|
|
<TableCell>{i + 1}</TableCell>
|
|
<TableCell className="font-medium">{a.name}</TableCell>
|
|
<TableCell>{a.code}</TableCell>
|
|
<TableCell>{a.product_name}</TableCell>
|
|
<TableCell>
|
|
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (!window.confirm("Delete this asset?")) return; delAsset.mutate(a.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Dialog open={facOpen} onOpenChange={setFacOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Create facility</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input value={facForm.name} onChange={(e) => setFacForm((f) => ({ ...f, name: e.target.value }))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Code</Label>
|
|
<Input value={facForm.code} onChange={(e) => setFacForm((f) => ({ ...f, code: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setFacOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
disabled={createFac.isPending || !facForm.name.trim()}
|
|
onClick={() =>
|
|
createFac.mutate(
|
|
{ name: facForm.name.trim(), code: facForm.code || undefined },
|
|
{
|
|
onSuccess: () => {
|
|
setFacOpen(false);
|
|
setFacForm({ name: "", code: "" });
|
|
toast({ title: "Created successfully" });
|
|
},
|
|
onError: (err: Error) =>
|
|
toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
},
|
|
)
|
|
}
|
|
>
|
|
Create
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={facEditOpen} onOpenChange={setFacEditOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Edit facility</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input value={facForm.name} onChange={(e) => setFacForm((f) => ({ ...f, name: e.target.value }))} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Code</Label>
|
|
<Input value={facForm.code} onChange={(e) => setFacForm((f) => ({ ...f, code: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setFacEditOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
disabled={updateFac.isPending || !facForm.name.trim() || !facEditId}
|
|
onClick={() => {
|
|
if (!facEditId) return;
|
|
updateFac.mutate(
|
|
{ id: facEditId, data: { name: facForm.name.trim(), code: facForm.code || undefined } },
|
|
{
|
|
onSuccess: () => {
|
|
setFacEditOpen(false);
|
|
setFacEditId(null);
|
|
setFacForm({ name: "", code: "" });
|
|
toast({ title: "Updated successfully" });
|
|
},
|
|
onError: (err: Error) =>
|
|
toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
},
|
|
);
|
|
}}
|
|
>
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={assetOpen} onOpenChange={setAssetOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Create asset</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
value={assetForm.name}
|
|
onChange={(e) => setAssetForm((f) => ({ ...f, name: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Code</Label>
|
|
<Input
|
|
value={assetForm.code}
|
|
onChange={(e) => setAssetForm((f) => ({ ...f, code: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAssetOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
disabled={createAsset.isPending || !assetForm.name.trim()}
|
|
onClick={() =>
|
|
createAsset.mutate(
|
|
{ name: assetForm.name.trim(), code: assetForm.code || undefined },
|
|
{
|
|
onSuccess: () => {
|
|
setAssetOpen(false);
|
|
setAssetForm({ name: "", code: "" });
|
|
toast({ title: "Created successfully" });
|
|
},
|
|
onError: (err: Error) =>
|
|
toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
},
|
|
)
|
|
}
|
|
>
|
|
Create
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|