249 lines
8.2 KiB
TypeScript
249 lines
8.2 KiB
TypeScript
import Link from 'next/link';
|
||
|
||
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
|
||
|
||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||
import { Badge } from '@kit/ui/badge';
|
||
import { Button } from '@kit/ui/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||
|
||
import { createCourseManagementApi } from '@kit/course-management/api';
|
||
|
||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||
|
||
interface PageProps {
|
||
params: Promise<{ account: string }>;
|
||
}
|
||
|
||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||
|
||
const MONTH_NAMES = [
|
||
'Januar',
|
||
'Februar',
|
||
'März',
|
||
'April',
|
||
'Mai',
|
||
'Juni',
|
||
'Juli',
|
||
'August',
|
||
'September',
|
||
'Oktober',
|
||
'November',
|
||
'Dezember',
|
||
];
|
||
|
||
function getDaysInMonth(year: number, month: number): number {
|
||
return new Date(year, month + 1, 0).getDate();
|
||
}
|
||
|
||
function getFirstWeekday(year: number, month: number): number {
|
||
const day = new Date(year, month, 1).getDay();
|
||
return day === 0 ? 6 : day - 1;
|
||
}
|
||
|
||
export default async function CourseCalendarPage({ params }: PageProps) {
|
||
const { account } = await params;
|
||
const client = getSupabaseServerClient();
|
||
|
||
const { data: acct } = await client
|
||
.from('accounts')
|
||
.select('id')
|
||
.eq('slug', account)
|
||
.single();
|
||
|
||
if (!acct) return <div>Konto nicht gefunden</div>;
|
||
|
||
const api = createCourseManagementApi(client);
|
||
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
|
||
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = now.getMonth();
|
||
const daysInMonth = getDaysInMonth(year, month);
|
||
const firstWeekday = getFirstWeekday(year, month);
|
||
|
||
// Build set of days that have running courses
|
||
const courseDates = new Set<number>();
|
||
|
||
for (const course of courses.data) {
|
||
const c = course as Record<string, unknown>;
|
||
if (c.status === 'cancelled') continue;
|
||
const startDate = c.start_date ? new Date(String(c.start_date)) : null;
|
||
const endDate = c.end_date ? new Date(String(c.end_date)) : null;
|
||
|
||
if (!startDate) continue;
|
||
|
||
const courseStart = startDate;
|
||
const courseEnd = endDate ?? startDate;
|
||
|
||
for (let d = 1; d <= daysInMonth; d++) {
|
||
const dayDate = new Date(year, month, d);
|
||
if (dayDate >= courseStart && dayDate <= courseEnd) {
|
||
courseDates.add(d);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build calendar grid
|
||
const cells: Array<{ day: number | null; hasCourse: boolean; isToday: boolean }> = [];
|
||
|
||
for (let i = 0; i < firstWeekday; i++) {
|
||
cells.push({ day: null, hasCourse: false, isToday: false });
|
||
}
|
||
|
||
for (let d = 1; d <= daysInMonth; d++) {
|
||
cells.push({
|
||
day: d,
|
||
hasCourse: courseDates.has(d),
|
||
isToday: d === now.getDate() && month === now.getMonth() && year === now.getFullYear(),
|
||
});
|
||
}
|
||
|
||
while (cells.length % 7 !== 0) {
|
||
cells.push({ day: null, hasCourse: false, isToday: false });
|
||
}
|
||
|
||
const activeCourses = courses.data.filter(
|
||
(c: Record<string, unknown>) =>
|
||
c.status === 'open' || c.status === 'running',
|
||
);
|
||
|
||
return (
|
||
<CmsPageShell account={account} title="Kurskalender">
|
||
<div className="flex w-full flex-col gap-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<Link href={`/home/${account}/courses`}>
|
||
<Button variant="ghost" size="icon">
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Button>
|
||
</Link>
|
||
<div>
|
||
<h1 className="text-2xl font-bold">Kurskalender</h1>
|
||
<p className="text-muted-foreground">
|
||
Kurstermine im Überblick
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Month Calendar */}
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<Button variant="ghost" size="icon" disabled>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<CardTitle>
|
||
{MONTH_NAMES[month]} {year}
|
||
</CardTitle>
|
||
<Button variant="ghost" size="icon" disabled>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{/* Weekday Header */}
|
||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||
{WEEKDAYS.map((day) => (
|
||
<div
|
||
key={day}
|
||
className="text-center text-xs font-medium text-muted-foreground py-2"
|
||
>
|
||
{day}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Calendar Grid */}
|
||
<div className="grid grid-cols-7 gap-1">
|
||
{cells.map((cell, idx) => (
|
||
<div
|
||
key={idx}
|
||
className={`relative flex h-12 items-center justify-center rounded-md text-sm transition-colors ${
|
||
cell.day === null
|
||
? 'bg-transparent'
|
||
: cell.hasCourse
|
||
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 font-semibold'
|
||
: 'bg-muted/30 hover:bg-muted/50'
|
||
} ${cell.isToday ? 'ring-2 ring-primary ring-offset-1' : ''}`}
|
||
>
|
||
{cell.day !== null && (
|
||
<>
|
||
<span>{cell.day}</span>
|
||
{cell.hasCourse && (
|
||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" />
|
||
Kurstag
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="inline-block h-3 w-3 rounded-sm bg-muted/30" />
|
||
Frei
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="inline-block h-3 w-3 rounded-sm ring-2 ring-primary" />
|
||
Heute
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Active Courses this Month */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Aktive Kurse ({activeCourses.length})</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{activeCourses.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">
|
||
Keine aktiven Kurse in diesem Monat.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{activeCourses.map((course: Record<string, unknown>) => (
|
||
<div
|
||
key={String(course.id)}
|
||
className="flex items-center justify-between rounded-md border p-3"
|
||
>
|
||
<div>
|
||
<Link
|
||
href={`/home/${account}/courses/${String(course.id)}`}
|
||
className="font-medium hover:underline"
|
||
>
|
||
{String(course.name)}
|
||
</Link>
|
||
<p className="text-xs text-muted-foreground">
|
||
{course.start_date
|
||
? new Date(String(course.start_date)).toLocaleDateString('de-DE')
|
||
: '—'}{' '}
|
||
–{' '}
|
||
{course.end_date
|
||
? new Date(String(course.end_date)).toLocaleDateString('de-DE')
|
||
: '—'}
|
||
</p>
|
||
</div>
|
||
<Badge variant={String(course.status) === 'running' ? 'info' : 'default'}>
|
||
{String(course.status) === 'running' ? 'Laufend' : 'Offen'}
|
||
</Badge>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</CmsPageShell>
|
||
);
|
||
}
|