Files
myeasycms-v2/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx
T. Zehetbauer c6d564836f
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 17m4s
Workflow / ⚫️ Test (push) Has been skipped
fix: add missing newlines at the end of JSON files; clean up formatting in page components
2026-04-02 11:02:58 +02:00

293 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Link from 'next/link';
import { ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate } from '@kit/shared/dates';
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 { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
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,
searchParams,
}: PageProps) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
const t = await getTranslations('courses');
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
const now = new Date();
const monthParam = search.month as string | undefined;
let year: number;
let month: number;
if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) {
const [y, m] = monthParam.split('-').map(Number);
year = y!;
month = m! - 1;
} else {
year = now.getFullYear();
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 courseItem = course as Record<string, unknown>;
if (courseItem.status === 'cancelled') continue;
const startDate = courseItem.start_date
? new Date(String(courseItem.start_date))
: null;
const endDate = courseItem.end_date
? new Date(String(courseItem.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(
(courseItem: Record<string, unknown>) =>
courseItem.status === 'open' || courseItem.status === 'running',
);
// Use translation arrays for weekdays and months
const WEEKDAYS = t.raw('calendar.weekdays') as string[];
const MONTH_NAMES = t.raw('calendar.months') as string[];
return (
<CmsPageShell account={account} title={t('pages.calendarTitle')}>
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
asChild
aria-label={t('calendar.backToCourses')}
>
<Link href={`/home/${account}/courses`}>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<p className="text-muted-foreground">{t('calendar.overview')}</p>
</div>
</div>
{/* Month Calendar */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="icon"
asChild
aria-label={t('calendar.previousMonth')}
>
<Link
href={`/home/${account}/courses/calendar?month=${
month === 0
? `${year - 1}-12`
: `${year}-${String(month).padStart(2, '0')}`
}`}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<CardTitle>
{MONTH_NAMES[month]} {year}
</CardTitle>
<Button
variant="ghost"
size="icon"
asChild
aria-label={t('calendar.nextMonth')}
>
<Link
href={`/home/${account}/courses/calendar?month=${
month === 11
? `${year + 1}-01`
: `${year}-${String(month + 2).padStart(2, '0')}`
}`}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{/* Weekday Header */}
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((day) => (
<div
key={day}
className="text-muted-foreground py-2 text-center text-xs font-medium"
>
{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 font-semibold text-emerald-700 dark:text-emerald-400'
: 'bg-muted/30 hover:bg-muted/50'
} ${cell.isToday ? 'ring-primary ring-2 ring-offset-1' : ''}`}
>
{cell.day !== null && (
<>
<span>{cell.day}</span>
{cell.hasCourse && (
<span className="absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-emerald-500" />
)}
</>
)}
</div>
))}
</div>
{/* Legend */}
<div className="text-muted-foreground mt-4 flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-emerald-500/15" />
{t('calendar.courseDay')}
</div>
<div className="flex items-center gap-1.5">
<span className="bg-muted/30 inline-block h-3 w-3 rounded-sm" />
{t('calendar.free')}
</div>
<div className="flex items-center gap-1.5">
<span className="ring-primary inline-block h-3 w-3 rounded-sm ring-2" />
{t('calendar.today')}
</div>
</div>
</CardContent>
</Card>
{/* Active Courses this Month */}
<Card>
<CardHeader>
<CardTitle>
{t('calendar.activeCourses', { count: activeCourses.length })}
</CardTitle>
</CardHeader>
<CardContent>
{activeCourses.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('calendar.noActiveCourses')}
</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-muted-foreground text-xs">
{formatDate(course.start_date as string)} {' '}
{formatDate(course.end_date as string)}
</p>
</div>
<Badge
variant={
String(course.status) === 'running' ? 'info' : 'default'
}
>
{String(course.status) === 'running'
? t('status.running')
: t('status.open')}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}