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
236 lines
7.4 KiB
TypeScript
236 lines
7.4 KiB
TypeScript
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
|
|
import { API_BASE_URL } from "@/lib/api-client";
|
|
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
|
|
|
|
interface MaterialViewerProps {
|
|
material: ChapterMaterial | null;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onDownload?: (materialId: number, name: string) => void;
|
|
}
|
|
|
|
const typeLabels: Record<MaterialType, string> = {
|
|
pdf: "PDF Document",
|
|
document: "Document",
|
|
video: "Video",
|
|
audio: "Audio",
|
|
image: "Image",
|
|
link: "External Link",
|
|
article: "Article",
|
|
};
|
|
|
|
const typeIcons: Record<MaterialType, React.ReactNode> = {
|
|
pdf: <FileText className="h-4 w-4" />,
|
|
document: <FileText className="h-4 w-4" />,
|
|
video: <Video className="h-4 w-4" />,
|
|
audio: <Music className="h-4 w-4" />,
|
|
image: <Image className="h-4 w-4" />,
|
|
link: <Link2 className="h-4 w-4" />,
|
|
article: <FileText className="h-4 w-4" />,
|
|
};
|
|
|
|
function getContentUrl(materialId: number): string {
|
|
const token = localStorage.getItem("encoach_token") ?? "";
|
|
return `${API_BASE_URL}/materials/${materialId}/content?token=${token}`;
|
|
}
|
|
|
|
function isYouTubeUrl(url: string): boolean {
|
|
return /(?:youtube\.com\/(?:watch|embed)|youtu\.be\/)/.test(url);
|
|
}
|
|
|
|
function getYouTubeEmbedUrl(url: string): string {
|
|
const match = url.match(
|
|
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/
|
|
);
|
|
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
|
}
|
|
|
|
function PdfViewer({ material }: { material: ChapterMaterial }) {
|
|
if (material.file_url) {
|
|
return (
|
|
<iframe
|
|
src={getContentUrl(material.id)}
|
|
className="w-full h-full min-h-[600px] rounded-lg border"
|
|
title={material.name}
|
|
/>
|
|
);
|
|
}
|
|
if (material.url) {
|
|
return (
|
|
<iframe
|
|
src={material.url}
|
|
className="w-full h-full min-h-[600px] rounded-lg border"
|
|
title={material.name}
|
|
/>
|
|
);
|
|
}
|
|
return <EmptyState message="No PDF file available." />;
|
|
}
|
|
|
|
function VideoViewer({ material }: { material: ChapterMaterial }) {
|
|
if (material.url && isYouTubeUrl(material.url)) {
|
|
return (
|
|
<iframe
|
|
src={getYouTubeEmbedUrl(material.url)}
|
|
className="w-full aspect-video rounded-lg border"
|
|
title={material.name}
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
allowFullScreen
|
|
/>
|
|
);
|
|
}
|
|
if (material.url) {
|
|
return (
|
|
<video
|
|
src={material.url}
|
|
controls
|
|
className="w-full rounded-lg border"
|
|
title={material.name}
|
|
>
|
|
Your browser does not support the video tag.
|
|
</video>
|
|
);
|
|
}
|
|
if (material.file_url) {
|
|
return (
|
|
<video
|
|
src={getContentUrl(material.id)}
|
|
controls
|
|
className="w-full rounded-lg border"
|
|
title={material.name}
|
|
>
|
|
Your browser does not support the video tag.
|
|
</video>
|
|
);
|
|
}
|
|
return <EmptyState message="No video source available." />;
|
|
}
|
|
|
|
function AudioViewer({ material }: { material: ChapterMaterial }) {
|
|
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
|
|
if (!src) return <EmptyState message="No audio source available." />;
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<audio src={src} controls className="w-full max-w-lg">
|
|
Your browser does not support the audio element.
|
|
</audio>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ImageViewer({ material }: { material: ChapterMaterial }) {
|
|
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
|
|
if (!src) return <EmptyState message="No image available." />;
|
|
return (
|
|
<div className="flex items-center justify-center">
|
|
<img
|
|
src={src}
|
|
alt={material.name}
|
|
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ArticleViewer({ material }: { material: ChapterMaterial }) {
|
|
if (!material.description) return <EmptyState message="No article content available." />;
|
|
return (
|
|
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
|
|
<div className="whitespace-pre-wrap">{material.description}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LinkViewer({ material }: { material: ChapterMaterial }) {
|
|
if (!material.url) return <EmptyState message="No URL available." />;
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2 p-4 rounded-lg border bg-muted/50">
|
|
<Link2 className="h-5 w-5 text-primary shrink-0" />
|
|
<a
|
|
href={material.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary underline break-all flex-1"
|
|
>
|
|
{material.url}
|
|
</a>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={material.url} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="h-4 w-4 mr-1" /> Open
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
<iframe
|
|
src={material.url}
|
|
className="w-full min-h-[500px] rounded-lg border"
|
|
title={material.name}
|
|
sandbox="allow-scripts allow-same-origin"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ message }: { message: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
<FileText className="h-12 w-12 mb-3 opacity-40" />
|
|
<p>{message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const viewers: Record<MaterialType, React.FC<{ material: ChapterMaterial }>> = {
|
|
pdf: PdfViewer,
|
|
document: PdfViewer,
|
|
video: VideoViewer,
|
|
audio: AudioViewer,
|
|
image: ImageViewer,
|
|
link: LinkViewer,
|
|
article: ArticleViewer,
|
|
};
|
|
|
|
export default function MaterialViewer({ material, open, onOpenChange, onDownload }: MaterialViewerProps) {
|
|
if (!material) return null;
|
|
|
|
const Viewer = viewers[material.type] ?? ArticleViewer;
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent side="right" className="w-full sm:max-w-2xl lg:max-w-4xl overflow-y-auto">
|
|
<SheetHeader className="space-y-3 pb-4 border-b">
|
|
<div className="flex items-center gap-2">
|
|
{typeIcons[material.type]}
|
|
<Badge variant="outline">{typeLabels[material.type]}</Badge>
|
|
</div>
|
|
<SheetTitle className="text-lg">{material.name}</SheetTitle>
|
|
{material.description && material.type !== "article" && (
|
|
<p className="text-sm text-muted-foreground">{material.description}</p>
|
|
)}
|
|
<div className="flex gap-2">
|
|
{material.allow_download && material.file_url && onDownload && (
|
|
<Button variant="outline" size="sm" onClick={() => onDownload(material.id, material.name)}>
|
|
<Download className="h-4 w-4 mr-1" /> Download
|
|
</Button>
|
|
)}
|
|
{material.url && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={material.url} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="h-4 w-4 mr-1" /> Open Link
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</SheetHeader>
|
|
|
|
<div className="mt-6">
|
|
<Viewer material={material} />
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|