Round 15: Timetable, Resource Library, Advanced Analytics, Accessibility

This commit is contained in:
Mustafa Cetin committed 2026-07-29 11:16:28 +00:00
1 parent 0a7cfae311
commit a94f900ae0
15 files changed
+4580 -1079

No files matched your search

File diff suppressed because it is too large. Load diff
+8
View File
@@ -55,6 +55,8 @@ import {
BookCheck,
Megaphone,
Briefcase,
Clock as ClockIcon,
Library as LibraryIcon,
} from 'lucide-react';
import {
Sidebar,
@@ -145,6 +147,8 @@ import NotebooksView from './notebooks-view';
import DrawingView from './drawing-view';
import HomeworkView from './homework-view';
import PortfolioView from './portfolio-view';
import TimetableView from './timetable-view';
import ResourceLibraryView from './resource-library-view';
type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string };
type NavSection = { id: string; labelKey: string; items: NavItem[] };
@@ -181,6 +185,8 @@ const navSections: NavSection[] = [
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.notebooks' },
{ key: 'drawing', icon: Palette, labelKey: 'nav.drawing' },
{ key: 'portfolio', icon: Briefcase, labelKey: 'nav.portfolio' },
{ key: 'timetable', icon: ClockIcon, labelKey: 'nav.timetable' },
{ key: 'resources', icon: LibraryIcon, labelKey: 'nav.resources' },
],
},
{
@@ -348,6 +354,8 @@ function renderView(view: ViewName) {
case 'drawing': return <DrawingView />;
case 'homework': return <HomeworkView />;
case 'portfolio': return <PortfolioView />;
case 'timetable': return <TimetableView />;
case 'resources': return <ResourceLibraryView />;
default: return <DashboardView />;
}
}
+811
View File
@@ -0,0 +1,811 @@
'use client';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Library,
Plus,
Search,
FileText,
ClipboardList,
Presentation,
Video,
Link2,
Image,
Headphones,
Download,
Eye,
Star,
Trash2,
Edit3,
X,
Globe,
Lock,
Filter,
ChevronDown,
Tag,
BookOpen,
} from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
// ── Types ──────────────────────────────────────────────────────────────
interface ResourceItem {
id: string;
schoolId: string;
authorId: string;
title: string;
description: string | null;
resourceType: string;
url: string | null;
content: string | null;
subjectId: string | null;
classGroupId: string | null;
gradeLevel: number | null;
tags: string | null; // JSON array
isPublic: boolean;
downloadCount: number;
createdAt: string;
author: { id: string; firstName: string; lastName: string };
subject: { id: string; name: string } | null;
classGroup: { id: string; name: string; gradeLevel: number } | null;
}
interface SubjectOption {
id: string;
name: string;
}
interface ClassOption {
id: string;
name: string;
gradeLevel: number;
}
type ResourceType = 'document' | 'worksheet' | 'presentation' | 'video_link' | 'image' | 'link' | 'audio';
const RESOURCE_TYPES: ResourceType[] = ['document', 'worksheet', 'presentation', 'video_link', 'image', 'link', 'audio'];
function getTypeIcon(type: string) {
switch (type) {
case 'document': return FileText;
case 'worksheet': return ClipboardList;
case 'presentation': return Presentation;
case 'video_link': return Video;
case 'image': return Image;
case 'link': return Link2;
case 'audio': return Headphones;
default: return FileText;
}
}
function getTypeColor(type: string): string {
switch (type) {
case 'document': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300';
case 'worksheet': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
case 'presentation': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300';
case 'video_link': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300';
case 'image': return 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300';
case 'link': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300';
case 'audio': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/40 dark:text-gray-300';
}
}
function getTypeLabel(type: string): string {
const keyMap: Record<string, string> = {
document: 'resources.document',
worksheet: 'resources.worksheet',
presentation: 'resources.presentation',
video_link: 'resources.video',
image: 'resources.image',
link: 'resources.link',
audio: 'resources.audio',
};
return t(keyMap[type] ?? 'resources.type');
}
// ── Resource Create/Edit Dialog ────────────────────────────────────────
function ResourceDialog({
open,
onClose,
resource,
subjects,
classes,
schoolId,
onSave,
}: {
open: boolean;
onClose: () => void;
resource: ResourceItem | null;
subjects: SubjectOption[];
classes: ClassOption[];
schoolId: string;
onSave: (data: Record<string, unknown>) => void;
}) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [resourceType, setResourceType] = useState<ResourceType>('document');
const [url, setUrl] = useState('');
const [content, setContent] = useState('');
const [subjectId, setSubjectId] = useState<string>('none');
const [classGroupId, setClassGroupId] = useState<string>('none');
const [gradeLevel, setGradeLevel] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (resource) {
setTitle(resource.title);
setDescription(resource.description ?? '');
setResourceType(resource.resourceType as ResourceType);
setUrl(resource.url ?? '');
setContent(resource.content ?? '');
setSubjectId(resource.subjectId ?? 'none');
setClassGroupId(resource.classGroupId ?? 'none');
setGradeLevel(resource.gradeLevel?.toString() ?? '');
try {
const parsedTags = resource.tags ? JSON.parse(resource.tags) : [];
setTagsInput(Array.isArray(parsedTags) ? parsedTags.join(', ') : '');
} catch {
setTagsInput('');
}
setIsPublic(resource.isPublic);
} else {
setTitle('');
setDescription('');
setResourceType('document');
setUrl('');
setContent('');
setSubjectId('none');
setClassGroupId('none');
setGradeLevel('');
setTagsInput('');
setIsPublic(true);
}
}, [resource, open]);
const handleSave = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const tags = tagsInput
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const data: Record<string, unknown> = {
schoolId,
title: title.trim(),
description: description.trim() || null,
resourceType,
url: url.trim() || null,
content: content.trim() || null,
subjectId: subjectId === 'none' ? null : subjectId,
classGroupId: classGroupId === 'none' ? null : classGroupId,
gradeLevel: gradeLevel ? parseInt(gradeLevel, 10) : null,
tags: tags.length > 0 ? tags : null,
isPublic,
};
if (resource) {
data.id = resource.id;
}
onSave(data);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{resource ? t('resources.edit') : t('resources.create')}
</DialogTitle>
<DialogDescription>
{resource ? t('resources.edit') : t('resources.create')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="res-title">{t('resources.title_field')}</Label>
<Input id="res-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title..." className="min-h-[44px]" />
</div>
<div className="space-y-2">
<Label htmlFor="res-type">{t('resources.type')}</Label>
<Select value={resourceType} onValueChange={(v) => setResourceType(v as ResourceType)}>
<SelectTrigger id="res-type" className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RESOURCE_TYPES.map((rt) => (
<SelectItem key={rt} value={rt}>{getTypeLabel(rt)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="res-desc">{t('resources.description_field')}</Label>
<Textarea id="res-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description..." rows={3} />
</div>
<div className="space-y-2">
<Label htmlFor="res-url">{t('resources.url_field')}</Label>
<Input id="res-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="min-h-[44px]" />
</div>
{(resourceType === 'document' || resourceType === 'worksheet') && (
<div className="space-y-2">
<Label htmlFor="res-content">{t('resources.content_field')}</Label>
<Textarea id="res-content" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Text content..." rows={4} />
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="res-subject">{t('resources.subject_field')}</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger id="res-subject" className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('resources.all_subjects')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="res-class">{t('resources.class_field')}</Label>
<Select value={classGroupId} onValueChange={setClassGroupId}>
<SelectTrigger id="res-class" className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">—</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="res-grade">{t('resources.grade_field')}</Label>
<Input id="res-grade" type="number" min={1} max={13} value={gradeLevel} onChange={(e) => setGradeLevel(e.target.value)} className="min-h-[44px]" />
</div>
<div className="space-y-2">
<Label htmlFor="res-tags">{t('resources.tags_field')}</Label>
<Input id="res-tags" value={tagsInput} onChange={(e) => setTagsInput(e.target.value)} placeholder="tag1, tag2..." className="min-h-[44px]" />
</div>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="res-public" className="flex items-center gap-2 min-h-[44px]">
{isPublic ? <Globe className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
{isPublic ? t('resources.public') : t('resources.private')}
</Label>
<Switch id="res-public" checked={isPublic} onCheckedChange={setIsPublic} />
</div>
</div>
<div className="flex items-center gap-2 justify-end">
<Button variant="outline" size="sm" onClick={onClose} className="min-h-[44px]">
{t('action.cancel')}
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !title.trim()} className="min-h-[44px]">
{saving ? '...' : t('action.save')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ── Resource Preview Dialog ────────────────────────────────────────────
function PreviewDialog({
open,
onClose,
resource,
}: {
open: boolean;
onClose: () => void;
resource: ResourceItem | null;
}) {
if (!resource) return null;
const typeIcon = getTypeIcon(resource.resourceType);
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{React.createElement(typeIcon, { className: 'h-5 w-5' })}
{resource.title}
</DialogTitle>
<DialogDescription>
{resource.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getTypeColor(resource.resourceType)}>
{getTypeLabel(resource.resourceType)}
</Badge>
{resource.subject && (
<Badge variant="outline">{resource.subject.name}</Badge>
)}
{resource.isPublic ? (
<Badge variant="outline" className="gap-1"><Globe className="h-3 w-3" /> {t('resources.public')}</Badge>
) : (
<Badge variant="outline" className="gap-1"><Lock className="h-3 w-3" /> {t('resources.private')}</Badge>
)}
</div>
{resource.content && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 text-sm whitespace-pre-wrap max-h-64 overflow-y-auto">
{resource.content}
</div>
)}
{resource.url && (
<div className="space-y-2">
<Label>{t('resources.url_field')}</Label>
<a
href={resource.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-emerald-600 dark:text-emerald-400 hover:underline break-all"
>
{resource.url}
</a>
</div>
)}
{resource.tags && (() => {
try {
const parsed = JSON.parse(resource.tags);
if (Array.isArray(parsed) && parsed.length > 0) {
return (
<div className="flex items-center gap-2 flex-wrap">
<Tag className="h-3 w-3 text-muted-foreground" />
{parsed.map((tag: string, i: number) => (
<Badge key={i} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
);
}
} catch { /* ignore */ }
return null;
})()}
<div className="text-xs text-muted-foreground flex items-center gap-4">
<span className="flex items-center gap-1">
<Download className="h-3 w-3" /> {resource.downloadCount} {t('resources.downloads')}
</span>
<span>
{resource.author.firstName} {resource.author.lastName}
</span>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ── Main View ──────────────────────────────────────────────────────────
export default function ResourceLibraryView() {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const [resources, setResources] = useState<ResourceItem[]>([]);
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
const [classes, setClasses] = useState<ClassOption[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [filterSubject, setFilterSubject] = useState<string>('all');
const [dialogOpen, setDialogOpen] = useState(false);
const [editResource, setEditResource] = useState<ResourceItem | null>(null);
const [previewResource, setPreviewResource] = useState<ResourceItem | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const schoolId = currentUser?.schoolId ?? '';
// Load favorites from localStorage
useEffect(() => {
try {
const stored = localStorage.getItem('ct_resource_favorites');
if (stored) setFavorites(new Set(JSON.parse(stored)));
} catch { /* ignore */ }
}, []);
// Load subjects
useEffect(() => {
if (!schoolId) return;
apiGet<SubjectOption[]>(`/api/subjects?schoolId=${schoolId}`)
.then((data) => setSubjects(data))
.catch(() => {});
}, [schoolId]);
// Load classes
useEffect(() => {
if (!schoolId) return;
apiGet<ClassOption[]>(`/api/classes?schoolId=${schoolId}`)
.then((data) => setClasses(data))
.catch(() => {});
}, [schoolId]);
// Load resources
const loadResources = useCallback(async () => {
if (!schoolId) {
setResources([]);
setLoading(false);
return;
}
setLoading(true);
try {
const params = new URLSearchParams({ schoolId });
if (filterType && filterType !== 'all') params.set('resourceType', filterType);
if (filterSubject && filterSubject !== 'all') params.set('subjectId', filterSubject);
if (searchQuery) params.set('search', searchQuery);
const data = await apiGet<ResourceItem[]>(`/api/resources?${params.toString()}`);
setResources(data);
} catch {
setResources([]);
}
setLoading(false);
}, [schoolId, filterType, filterSubject, searchQuery]);
useEffect(() => {
loadResources();
}, [loadResources]);
// Handlers
const handleSaveResource = async (data: Record<string, unknown>) => {
try {
if (data.id) {
await apiPut(`/api/resources/${data.id}`, data);
} else {
await apiPost('/api/resources', data);
}
await loadResources();
setDialogOpen(false);
setEditResource(null);
} catch (err) {
console.error('Error saving resource:', err);
}
};
const handleDeleteResource = async (id: string) => {
try {
await apiDelete(`/api/resources/${id}`);
await loadResources();
} catch (err) {
console.error('Error deleting resource:', err);
}
};
const handleDownload = async (resource: ResourceItem) => {
try {
await apiPut(`/api/resources/${resource.id}`, { incrementDownload: true });
if (resource.url) {
window.open(resource.url, '_blank');
}
await loadResources();
} catch (err) {
console.error('Error downloading resource:', err);
}
};
const toggleFavorite = (id: string) => {
const newFavs = new Set(favorites);
if (newFavs.has(id)) {
newFavs.delete(id);
} else {
newFavs.add(id);
}
setFavorites(newFavs);
try {
localStorage.setItem('ct_resource_favorites', JSON.stringify([...newFavs]));
} catch { /* ignore */ }
};
const togglePublic = async (resource: ResourceItem) => {
try {
await apiPut(`/api/resources/${resource.id}`, { isPublic: !resource.isPublic });
await loadResources();
} catch (err) {
console.error('Error toggling public:', err);
}
};
// Sort: favorites first, then by date
const sortedResources = useMemo(() => {
return [...resources].sort((a, b) => {
const aFav = favorites.has(a.id) ? 1 : 0;
const bFav = favorites.has(b.id) ? 1 : 0;
if (bFav !== aFav) return bFav - aFav;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}, [resources, favorites]);
// ── Render ────────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Library className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
<h1 className="text-2xl font-bold">{t('resources.library')}</h1>
</div>
<Button
className="min-h-[44px]"
onClick={() => {
setEditResource(null);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t('resources.create')}
</Button>
</div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9 min-h-[44px]"
placeholder={t('resources.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-[160px] min-h-[44px]">
<SelectValue placeholder={t('resources.filter_type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('resources.all_types')}</SelectItem>
{RESOURCE_TYPES.map((rt) => (
<SelectItem key={rt} value={rt}>{getTypeLabel(rt)}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterSubject} onValueChange={setFilterSubject}>
<SelectTrigger className="w-[160px] min-h-[44px]">
<SelectValue placeholder={t('resources.filter_subject')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('resources.all_subjects')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Resource Grid */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : sortedResources.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Library className="h-8 w-8 mx-auto mb-2 opacity-30" />
{t('resources.no_resources')}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<AnimatePresence>
{sortedResources.map((resource) => {
const TypeIcon = getTypeIcon(resource.resourceType);
const isFav = favorites.has(resource.id);
return (
<motion.div
key={resource.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<Card className="h-full flex flex-col hover:shadow-md transition-shadow">
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className={`p-1.5 rounded-md ${getTypeColor(resource.resourceType)}`}>
<TypeIcon className="h-4 w-4" />
</div>
<CardTitle className="text-sm font-semibold truncate" title={resource.title}>
{resource.title}
</CardTitle>
</div>
<button
className="shrink-0 p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px] flex items-center justify-center"
onClick={() => toggleFavorite(resource.id)}
aria-label={t('resources.favorite')}
>
<Star className={`h-4 w-4 ${isFav ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground'}`} />
</button>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
{resource.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{resource.description}
</p>
)}
<div className="flex items-center gap-2 flex-wrap">
<Badge className={`text-xs ${getTypeColor(resource.resourceType)}`}>
{getTypeLabel(resource.resourceType)}
</Badge>
{resource.subject && (
<Badge variant="outline" className="text-xs">{resource.subject.name}</Badge>
)}
</div>
{resource.tags && (() => {
try {
const parsed = JSON.parse(resource.tags);
if (Array.isArray(parsed) && parsed.length > 0) {
return (
<div className="flex items-center gap-1 mt-2 flex-wrap">
{parsed.slice(0, 3).map((tag: string, i: number) => (
<Badge key={i} variant="secondary" className="text-xs">{tag}</Badge>
))}
{parsed.length > 3 && (
<span className="text-xs text-muted-foreground">+{parsed.length - 3}</span>
)}
</div>
);
}
} catch { /* ignore */ }
return null;
})()}
</CardContent>
<CardFooter className="p-4 pt-0 flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Download className="h-3 w-3" />
{resource.downloadCount}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => { setPreviewResource(resource); setPreviewOpen(true); }}
title={t('resources.preview')}
>
<Eye className="h-4 w-4" />
</Button>
{resource.url && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => handleDownload(resource)}
title={t('resources.download')}
>
<Download className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => togglePublic(resource)}
title={t('resources.toggle_public')}
>
{resource.isPublic ? <Globe className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Button>
{currentUser?.id === resource.authorId && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px]"
onClick={() => { setEditResource(resource); setDialogOpen(true); }}
title={t('action.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 min-h-[44px] min-w-[44px] text-destructive"
onClick={() => handleDeleteResource(resource.id)}
title={t('action.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</CardFooter>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
{/* Resource Create/Edit Dialog */}
<ResourceDialog
open={dialogOpen}
onClose={() => { setDialogOpen(false); setEditResource(null); }}
resource={editResource}
subjects={subjects}
classes={classes}
schoolId={schoolId}
onSave={handleSaveResource}
/>
{/* Preview Dialog */}
<PreviewDialog
open={previewOpen}
onClose={() => { setPreviewOpen(false); setPreviewResource(null); }}
resource={previewResource}
/>
</div>
);
}
+668
View File
@@ -0,0 +1,668 @@
'use client';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Clock,
Printer,
Plus,
Trash2,
GripVertical,
Edit3,
X,
ChevronDown,
ChevronUp,
Coffee,
BookOpen,
} from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
// ── Subject Color Palette ──────────────────────────────────────────────
const SUBJECT_COLORS: Record<string, string> = {
'Deutsch': 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-900/40 dark:text-rose-200 dark:border-rose-800',
'Mathematik': 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/40 dark:text-blue-200 dark:border-blue-800',
'Englisch': 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-900/40 dark:text-violet-200 dark:border-violet-800',
'Sachunterricht': 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-900/40 dark:text-emerald-200 dark:border-emerald-800',
'Musik': 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-900/40 dark:text-orange-200 dark:border-orange-800',
'Sport': 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-900/40 dark:text-teal-200 dark:border-teal-800',
'Religion': 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-800',
'Kunst': 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-300 dark:bg-fuchsia-900/40 dark:text-fuchsia-200 dark:border-fuchsia-800',
'German': 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-900/40 dark:text-rose-200 dark:border-rose-800',
'Mathematics': 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/40 dark:text-blue-200 dark:border-blue-800',
'English': 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-900/40 dark:text-violet-200 dark:border-violet-800',
'Science': 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-900/40 dark:text-emerald-200 dark:border-emerald-800',
'Music': 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-900/40 dark:text-orange-200 dark:border-orange-800',
'Physical Education': 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-900/40 dark:text-teal-200 dark:border-teal-800',
'Religion/Ethics': 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-800',
'Art': 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-300 dark:bg-fuchsia-900/40 dark:text-fuchsia-200 dark:border-fuchsia-800',
};
const FALLBACK_COLOR = 'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-900/40 dark:text-gray-200 dark:border-gray-800';
const BREAK_COLOR = 'bg-gray-50 text-gray-500 border-gray-200 dark:bg-gray-800/30 dark:text-gray-400 dark:border-gray-700';
function getSubjectColor(subjectName: string | null): string {
if (!subjectName) return FALLBACK_COLOR;
return SUBJECT_COLORS[subjectName] ?? FALLBACK_COLOR;
}
// ── Types ──────────────────────────────────────────────────────────────
interface TimetableSlot {
id: string;
schoolId: string;
classGroupId: string;
dayOfWeek: number;
period: number;
subjectId: string | null;
teacherId: string | null;
roomId: string | null;
startTime: string;
endTime: string;
isBreak: boolean;
classGroup: { id: string; name: string; gradeLevel: number };
subject: { id: string; name: string } | null;
teacher: { id: string; firstName: string; lastName: string } | null;
}
interface SubjectOption {
id: string;
name: string;
}
interface TeacherOption {
id: string;
firstName: string;
lastName: string;
}
interface ClassOption {
id: string;
name: string;
gradeLevel: number;
}
const DAYS = [0, 1, 2, 3, 4]; // Mon-Fri
const PERIODS = [1, 2, 3, 4, 5, 6, 7, 8];
const DEFAULT_TIMES: Record<number, { start: string; end: string }> = {
1: { start: '08:00', end: '08:45' },
2: { start: '08:45', end: '09:30' },
3: { start: '09:30', end: '10:15' },
4: { start: '10:15', end: '11:00' },
5: { start: '11:00', end: '11:45' },
6: { start: '11:45', end: '12:30' },
7: { start: '13:00', end: '13:45' },
8: { start: '13:45', end: '14:30' },
};
function getDayLabel(dayOfWeek: number): string {
const keys = ['timetable.monday', 'timetable.tuesday', 'timetable.wednesday', 'timetable.thursday', 'timetable.friday'];
return t(keys[dayOfWeek] ?? 'timetable.day');
}
// ── Slot Dialog ────────────────────────────────────────────────────────
function SlotDialog({
open,
onClose,
slot,
subjects,
teachers,
schoolId,
classGroupId,
dayOfWeek,
period,
onSave,
onDelete,
}: {
open: boolean;
onClose: () => void;
slot: TimetableSlot | null;
subjects: SubjectOption[];
teachers: TeacherOption[];
schoolId: string;
classGroupId: string;
dayOfWeek: number;
period: number;
onSave: (data: Record<string, unknown>) => void;
onDelete: (id: string) => void;
}) {
const [subjectId, setSubjectId] = useState<string>('none');
const [teacherId, setTeacherId] = useState<string>('none');
const [roomId, setRoomId] = useState('');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [isBreak, setIsBreak] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (slot) {
setSubjectId(slot.subjectId ?? 'none');
setTeacherId(slot.teacherId ?? 'none');
setRoomId(slot.roomId ?? '');
setStartTime(slot.startTime);
setEndTime(slot.endTime);
setIsBreak(slot.isBreak);
} else {
setSubjectId('none');
setTeacherId('none');
setRoomId('');
const defaultTime = DEFAULT_TIMES[period];
setStartTime(defaultTime?.start ?? '08:00');
setEndTime(defaultTime?.end ?? '08:45');
setIsBreak(false);
}
}, [slot, period, open]);
const handleSave = async () => {
setSaving(true);
try {
const data: Record<string, unknown> = {
schoolId,
classGroupId,
dayOfWeek,
period,
subjectId: subjectId === 'none' ? null : subjectId,
teacherId: teacherId === 'none' ? null : teacherId,
roomId: roomId || null,
startTime,
endTime,
isBreak,
};
if (slot) {
data.id = slot.id;
}
onSave(data);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{slot ? t('timetable.edit_slot') : t('timetable.add_slot')}
</DialogTitle>
<DialogDescription>
{getDayLabel(dayOfWeek)} — {t('timetable.period_num', { n: period })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-3">
<Label htmlFor="slot-break" className="flex items-center gap-2 min-h-[44px]">
<Coffee className="h-4 w-4" />
{t('timetable.break')}
</Label>
<Switch id="slot-break" checked={isBreak} onCheckedChange={setIsBreak} />
</div>
{!isBreak && (
<>
<div className="space-y-2">
<Label htmlFor="slot-subject">{t('timetable.subject')}</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger id="slot-subject" className="min-h-[44px]">
<SelectValue placeholder={t('timetable.no_subject')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('timetable.no_subject')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="slot-teacher">{t('timetable.teacher')}</Label>
<Select value={teacherId} onValueChange={setTeacherId}>
<SelectTrigger id="slot-teacher" className="min-h-[44px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">—</SelectItem>
{teachers.map((tch) => (
<SelectItem key={tch.id} value={tch.id}>
{tch.firstName} {tch.lastName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="slot-room">{t('timetable.room')}</Label>
<Input id="slot-room" value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder="Room..." className="min-h-[44px]" />
</div>
</>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="slot-start">{t('timetable.start_time')}</Label>
<Input id="slot-start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="min-h-[44px]" />
</div>
<div className="space-y-2">
<Label htmlFor="slot-end">{t('timetable.end_time')}</Label>
<Input id="slot-end" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="min-h-[44px]" />
</div>
</div>
</div>
<div className="flex items-center gap-2 justify-end">
{slot && (
<Button
variant="destructive"
size="sm"
className="min-h-[44px]"
onClick={() => { onDelete(slot.id); onClose(); }}
>
<Trash2 className="h-4 w-4 mr-1" />
{t('timetable.delete_slot')}
</Button>
)}
<Button variant="outline" size="sm" onClick={onClose} className="min-h-[44px]">
{t('action.cancel')}
</Button>
<Button size="sm" onClick={handleSave} disabled={saving} className="min-h-[44px]">
{saving ? '...' : t('action.save')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ── Main View ──────────────────────────────────────────────────────────
export default function TimetableView() {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const [slots, setSlots] = useState<TimetableSlot[]>([]);
const [classes, setClasses] = useState<ClassOption[]>([]);
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
const [teachers, setTeachers] = useState<TeacherOption[]>([]);
const [selectedClassId, setSelectedClassId] = useState<string>('');
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editSlot, setEditSlot] = useState<TimetableSlot | null>(null);
const [dialogDay, setDialogDay] = useState(0);
const [dialogPeriod, setDialogPeriod] = useState(1);
const [dragSlot, setDragSlot] = useState<string | null>(null);
const [dragOverCell, setDragOverCell] = useState<{ day: number; period: number } | null>(null);
const schoolId = currentUser?.schoolId ?? '';
// Load classes
useEffect(() => {
if (!schoolId) return;
apiGet<ClassOption[]>(`/api/classes?schoolId=${schoolId}`)
.then((data) => {
setClasses(data);
if (data.length > 0 && !selectedClassId) {
setSelectedClassId(data[0].id);
}
})
.catch(() => {});
}, [schoolId]);
// Load subjects
useEffect(() => {
if (!schoolId) return;
apiGet<SubjectOption[]>(`/api/subjects?schoolId=${schoolId}`)
.then((data) => setSubjects(data))
.catch(() => {});
}, [schoolId]);
// Load teachers
useEffect(() => {
if (!schoolId) return;
apiGet<{ id: string; firstName: string; lastName: string; role: string }[]>(`/api/users?schoolId=${schoolId}`)
.then((data) => {
const teacherList = data.filter((u) => u.role === 'TEACHER' || u.role === 'SCHOOL_ADMIN');
setTeachers(teacherList);
})
.catch(() => {});
}, [schoolId]);
// Load timetable slots
const loadSlots = useCallback(async () => {
if (!schoolId || !selectedClassId) {
setSlots([]);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await apiGet<TimetableSlot[]>(`/api/timetable?schoolId=${schoolId}&classGroupId=${selectedClassId}`);
setSlots(data);
} catch {
setSlots([]);
}
setLoading(false);
}, [schoolId, selectedClassId]);
useEffect(() => {
loadSlots();
}, [loadSlots]);
// Build grid lookup
const gridMap = useMemo(() => {
const map: Record<string, TimetableSlot> = {};
for (const slot of slots) {
map[`${slot.dayOfWeek}-${slot.period}`] = slot;
}
return map;
}, [slots]);
// Handlers
const handleSaveSlot = async (data: Record<string, unknown>) => {
try {
if (data.id) {
// Update existing
await apiPut<TimetableSlot>(`/api/timetable/${data.id}`, {
subjectId: data.subjectId,
teacherId: data.teacherId,
roomId: data.roomId,
startTime: data.startTime,
endTime: data.endTime,
isBreak: data.isBreak,
});
} else {
// Create new
await apiPost<TimetableSlot>('/api/timetable', data);
}
await loadSlots();
setDialogOpen(false);
setEditSlot(null);
} catch (err) {
console.error('Error saving slot:', err);
}
};
const handleDeleteSlot = async (id: string) => {
try {
await apiDelete(`/api/timetable/${id}`);
await loadSlots();
} catch (err) {
console.error('Error deleting slot:', err);
}
};
const handleDragStart = (slotId: string) => {
setDragSlot(slotId);
};
const handleDragEnd = () => {
setDragSlot(null);
setDragOverCell(null);
};
const handleDragOver = (day: number, period: number) => {
setDragOverCell({ day, period });
};
const handleDrop = async (targetDay: number, targetPeriod: number) => {
if (!dragSlot) return;
try {
await apiPut(`/api/timetable/${dragSlot}`, {
dayOfWeek: targetDay,
period: targetPeriod,
});
await loadSlots();
} catch (err) {
console.error('Error moving slot:', err);
}
setDragSlot(null);
setDragOverCell(null);
};
const handlePrint = () => {
window.print();
};
// ── Render ────────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Clock className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
<h1 className="text-2xl font-bold">{t('timetable.title')}</h1>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Select value={selectedClassId} onValueChange={setSelectedClassId}>
<SelectTrigger className="w-[180px] min-h-[44px]">
<SelectValue placeholder={t('timetable.select_class')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name} (Grade {c.gradeLevel})</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="min-h-[44px]"
onClick={handlePrint}
>
<Printer className="h-4 w-4 mr-1" />
{t('timetable.print')}
</Button>
</div>
</div>
{/* Timetable Grid */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : !selectedClassId ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{t('timetable.select_class')}
</CardContent>
</Card>
) : (
<Card className="print:shadow-none print:border-0">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[640px]">
{/* Header row: Day names */}
<thead>
<tr className="bg-emerald-50 dark:bg-emerald-950/30">
<th className="p-2 text-sm font-semibold text-left w-[80px] border border-gray-200 dark:border-gray-700">
{t('timetable.period')}
</th>
<th className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700">
{t('timetable.start_time')} / {t('timetable.end_time')}
</th>
{DAYS.map((day) => (
<th key={day} className="p-2 text-sm font-semibold text-center border border-gray-200 dark:border-gray-700 min-w-[120px]">
{getDayLabel(day)}
</th>
))}
</tr>
</thead>
<tbody>
{PERIODS.map((period) => {
const defaultTime = DEFAULT_TIMES[period];
const slotInRow = slots.find((s) => s.period === period);
const timeDisplay = slotInRow
? `${slotInRow.startTime}–${slotInRow.endTime}`
: `${defaultTime?.start ?? ''}–${defaultTime?.end ?? ''}`;
// Check if this is a break row (common convention: period 3 is break)
const isBreakRow = PERIODS.indexOf(period) === 2; // between period 3 and 4
return (
<React.Fragment key={period}>
<tr className="hover:bg-gray-50/50 dark:hover:bg-gray-800/20">
<td className="p-2 text-sm font-medium text-center border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/30">
{period}
</td>
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
{timeDisplay}
</td>
{DAYS.map((day) => {
const cellSlot = gridMap[`${day}-${period}`];
const isDragOver = dragOverCell?.day === day && dragOverCell?.period === period;
const isDragTarget = dragSlot && !cellSlot;
return (
<td
key={`${day}-${period}`}
className={`p-1 border border-gray-200 dark:border-gray-700 transition-colors ${
isDragOver && isDragTarget ? 'bg-emerald-50 dark:bg-emerald-950/40' : ''
} ${cellSlot?.isBreak ? BREAK_COLOR.split(' ')[0] : ''}`}
onDragOver={(e) => { e.preventDefault(); handleDragOver(day, period); }}
onDragLeave={() => setDragOverCell(null)}
onDrop={() => handleDrop(day, period)}
>
{cellSlot ? (
<motion.div
layout
draggable
onDragStart={() => handleDragStart(cellSlot.id)}
onDragEnd={handleDragEnd}
className={`rounded-md p-2 cursor-pointer min-h-[44px] flex flex-col gap-0.5 border ${cellSlot.isBreak ? BREAK_COLOR : getSubjectColor(cellSlot.subject?.name)}`}
onClick={() => {
setEditSlot(cellSlot);
setDialogDay(day);
setDialogPeriod(period);
setDialogOpen(true);
}}
>
<div className="flex items-center justify-between">
<span className="font-medium text-xs truncate">
{cellSlot.isBreak
? t('timetable.break_period')
: cellSlot.subject?.name ?? t('timetable.no_subject')}
</span>
<GripVertical className="h-3 w-3 opacity-40 shrink-0" />
</div>
{!cellSlot.isBreak && cellSlot.teacher && (
<span className="text-xs opacity-70 truncate">
{cellSlot.teacher.firstName} {cellSlot.teacher.lastName}
</span>
)}
{cellSlot.roomId && (
<span className="text-xs opacity-60 truncate">
R{cellSlot.roomId}
</span>
)}
</motion.div>
) : (
<button
className="w-full h-full min-h-[44px] flex items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => {
setEditSlot(null);
setDialogDay(day);
setDialogPeriod(period);
setDialogOpen(true);
}}
aria-label={`${t('timetable.add_slot')} - ${getDayLabel(day)} Period ${period}`}
>
<Plus className="h-4 w-4 text-muted-foreground opacity-30" />
</button>
)}
</td>
);
})}
</tr>
{/* Break row between period 3 and 4 */}
{isBreakRow && (
<tr className="bg-gray-100 dark:bg-gray-800/20">
<td className="p-2 text-xs text-center border border-gray-200 dark:border-gray-700">
<Coffee className="h-3 w-3 mx-auto text-gray-400" />
</td>
<td className="p-2 text-xs text-muted-foreground text-center border border-gray-200 dark:border-gray-700">
10:15–10:30
</td>
{DAYS.map((day) => (
<td
key={`break-${day}`}
className="p-1 border border-gray-200 dark:border-gray-700"
>
<div className={`rounded-md p-2 min-h-[28px] flex items-center justify-center border ${BREAK_COLOR}`}>
<span className="text-xs font-medium">{t('timetable.break_period')}</span>
</div>
</td>
))}
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
{slots.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
<BookOpen className="h-8 w-8 mx-auto mb-2 opacity-30" />
{t('timetable.no_slots')}
</div>
)}
</CardContent>
</Card>
)}
{/* Drag hint */}
{slots.length > 0 && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<GripVertical className="h-3 w-3" />
{t('timetable.drag_hint')}
</p>
)}
{/* Slot Dialog */}
<SlotDialog
open={dialogOpen}
onClose={() => { setDialogOpen(false); setEditSlot(null); }}
slot={editSlot}
subjects={subjects}
teachers={teachers}
schoolId={schoolId}
classGroupId={selectedClassId}
dayOfWeek={dialogDay}
period={dialogPeriod}
onSave={handleSaveSlot}
onDelete={handleDeleteSlot}
/>
</div>
);
}