feat: complete CMS v2 with Docker, Fischerei, Meetings, Verband modules + UX audit fixes
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m26s
Workflow / ⚫️ Test (push) Has been skipped

Major changes:
- Docker Compose: full Supabase stack (11 services) equivalent to supabase CLI
- Fischerei module: 16 DB tables, waters/species/stocking/catch books/competitions
- Sitzungsprotokolle module: meeting protocols, agenda items, task tracking
- Verbandsverwaltung module: federation management, member clubs, contacts, fees
- Per-account module activation via Modules page toggle
- Site Builder: live CMS data in Puck blocks (courses, events, membership registration)
- Public registration APIs: course signup, event registration, membership application
- Document generation: PDF member cards, Excel reports, HTML labels
- Landing page: real Com.BISS content (no filler text)
- UX audit fixes: AccountNotFound component, shared status badges, confirm dialog,
  pagination, duplicate heading removal, emoji→badge replacement, a11y fixes
- QA: healthcheck fix, API auth fix, enum mismatch fix, password required attribute
This commit is contained in:
Zaid Marzguioui
2026-03-31 16:35:46 +02:00
parent 16648c92eb
commit ebd0fd4638
176 changed files with 17133 additions and 981 deletions

View File

@@ -0,0 +1,228 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { Switch } from '@kit/ui/switch';
import { CreateMeetingProtocolSchema } from '../schema/meetings.schema';
import { createProtocol } from '../server/actions/meetings-actions';
interface CreateProtocolFormProps {
accountId: string;
account: string;
}
export function CreateProtocolForm({
accountId,
account,
}: CreateProtocolFormProps) {
const router = useRouter();
const form = useForm({
resolver: zodResolver(CreateMeetingProtocolSchema),
defaultValues: {
accountId,
title: '',
meetingDate: new Date().toISOString().split('T')[0]!,
meetingType: 'vorstand' as const,
location: '',
attendees: '',
remarks: '',
isPublished: false,
},
});
const { execute, isPending } = useAction(createProtocol, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Protokoll erfolgreich erstellt');
router.push(`/home/${account}/meetings/protocols`);
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Speichern des Protokolls');
},
});
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-6"
>
{/* Card 1: Grunddaten */}
<Card>
<CardHeader>
<CardTitle>Sitzungsdaten</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Titel *</FormLabel>
<FormControl>
<Input placeholder="z.B. Vorstandssitzung März 2026" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="meetingDate"
render={({ field }) => (
<FormItem>
<FormLabel>Sitzungsdatum *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meetingType"
render={({ field }) => (
<FormItem>
<FormLabel>Sitzungsart *</FormLabel>
<FormControl>
<select
{...field}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="vorstand">Vorstandssitzung</option>
<option value="mitglieder">Mitgliederversammlung</option>
<option value="ausschuss">Ausschusssitzung</option>
<option value="sonstige">Sonstige</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Ort</FormLabel>
<FormControl>
<Input placeholder="z.B. Vereinsheim" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 2: Teilnehmer & Anmerkungen */}
<Card>
<CardHeader>
<CardTitle>Teilnehmer & Anmerkungen</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="attendees"
render={({ field }) => (
<FormItem>
<FormLabel>Teilnehmer</FormLabel>
<FormControl>
<textarea
{...field}
placeholder="Namen der Teilnehmer (kommagetrennt oder zeilenweise)"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="remarks"
render={({ field }) => (
<FormItem>
<FormLabel>Anmerkungen</FormLabel>
<FormControl>
<textarea
{...field}
placeholder="Optionale Anmerkungen zum Protokoll"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Card 3: Veröffentlichung */}
<Card>
<CardHeader>
<CardTitle>Veröffentlichung</CardTitle>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="isPublished"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Protokoll veröffentlichen</FormLabel>
<FormDescription>
Veröffentlichte Protokolle sind für alle Mitglieder sichtbar.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird gespeichert...' : 'Protokoll erstellen'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,6 @@
export { MeetingsTabNavigation } from './meetings-tab-navigation';
export { MeetingsDashboard } from './meetings-dashboard';
export { ProtocolsDataTable } from './protocols-data-table';
export { CreateProtocolForm } from './create-protocol-form';
export { ProtocolItemsList } from './protocol-items-list';
export { OpenTasksView } from './open-tasks-view';

View File

@@ -0,0 +1,210 @@
'use client';
import Link from 'next/link';
import {
FileText,
Calendar,
ListChecks,
AlertTriangle,
} from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { MEETING_TYPE_LABELS } from '../lib/meetings-constants';
interface DashboardStats {
totalProtocols: number;
thisYearProtocols: number;
openTasks: number;
overdueTasks: number;
}
interface RecentProtocol {
id: string;
title: string;
meeting_date: string;
meeting_type: string;
is_published: boolean;
}
interface OverdueTask {
id: string;
title: string;
responsible_person: string | null;
due_date: string | null;
status: string;
meeting_protocols: {
id: string;
title: string;
};
}
interface MeetingsDashboardProps {
stats: DashboardStats;
recentProtocols: RecentProtocol[];
overdueTasks: OverdueTask[];
account: string;
}
export function MeetingsDashboard({
stats,
recentProtocols,
overdueTasks,
account,
}: MeetingsDashboardProps) {
return (
<div className="flex w-full flex-col gap-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Sitzungsprotokolle Übersicht</h1>
<p className="text-muted-foreground">
Protokolle, Tagesordnungspunkte und Aufgaben verwalten
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Link href={`/home/${account}/meetings/protocols`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Protokolle gesamt</p>
<p className="text-2xl font-bold">{stats.totalProtocols}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<FileText className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
<Link href={`/home/${account}/meetings/protocols`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Protokolle dieses Jahr</p>
<p className="text-2xl font-bold">{stats.thisYearProtocols}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<Calendar className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
<Link href={`/home/${account}/meetings/tasks`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Offene Aufgaben</p>
<p className="text-2xl font-bold">{stats.openTasks}</p>
</div>
<div className="rounded-full bg-primary/10 p-3 text-primary">
<ListChecks className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
<Link href={`/home/${account}/meetings/tasks`}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Überfällige Aufgaben</p>
<p className="text-2xl font-bold">{stats.overdueTasks}</p>
</div>
<div className="rounded-full bg-destructive/10 p-3 text-destructive">
<AlertTriangle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</Link>
</div>
{/* Recent + Overdue Sections */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Letzte Protokolle</CardTitle>
</CardHeader>
<CardContent>
{recentProtocols.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Protokolle vorhanden.
</p>
) : (
<div className="space-y-3">
{recentProtocols.map((protocol) => (
<Link
key={protocol.id}
href={`/home/${account}/meetings/protocols/${protocol.id}`}
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{protocol.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(protocol.meeting_date).toLocaleDateString('de-DE')}
{' · '}
{MEETING_TYPE_LABELS[protocol.meeting_type] ?? protocol.meeting_type}
</p>
</div>
{protocol.is_published && (
<Badge variant="default" className="ml-2 shrink-0">Veröffentlicht</Badge>
)}
</Link>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Überfällige Aufgaben</CardTitle>
</CardHeader>
<CardContent>
{overdueTasks.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine überfälligen Aufgaben.
</p>
) : (
<div className="space-y-3">
{overdueTasks.map((task) => (
<div
key={task.id}
className="rounded-lg border border-destructive/20 bg-destructive/5 p-3"
>
<p className="text-sm font-medium">{task.title}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
{task.responsible_person && (
<span>Zuständig: {task.responsible_person}</span>
)}
{task.due_date && (
<span>
Fällig: {new Date(task.due_date).toLocaleDateString('de-DE')}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
Protokoll: {task.meeting_protocols.title}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import Link from 'next/link';
import { cn } from '@kit/ui/utils';
interface MeetingsTabNavigationProps {
account: string;
activeTab: string;
}
const TABS = [
{ id: 'overview', label: 'Übersicht', path: '' },
{ id: 'protocols', label: 'Protokolle', path: '/protocols' },
{ id: 'tasks', label: 'Offene Aufgaben', path: '/tasks' },
] as const;
export function MeetingsTabNavigation({
account,
activeTab,
}: MeetingsTabNavigationProps) {
const basePath = `/home/${account}/meetings`;
return (
<div className="mb-6 border-b">
<nav className="-mb-px flex space-x-1 overflow-x-auto" aria-label="Sitzungsprotokolle Navigation">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<Link
key={tab.id}
href={`${basePath}${tab.path}`}
className={cn(
'whitespace-nowrap border-b-2 px-4 py-2.5 text-sm font-medium transition-colors',
isActive
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground',
)}
>
{tab.label}
</Link>
);
})}
</nav>
</div>
);
}

View File

@@ -0,0 +1,189 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '../lib/meetings-constants';
interface OpenTask {
id: string;
title: string;
description: string | null;
responsible_person: string | null;
due_date: string | null;
status: string;
meeting_protocols: {
id: string;
title: string;
meeting_date: string;
meeting_type: string;
};
}
interface OpenTasksViewProps {
data: OpenTask[];
total: number;
page: number;
pageSize: number;
account: string;
}
export function OpenTasksView({
data,
total,
page,
pageSize,
account,
}: OpenTasksViewProps) {
const router = useRouter();
const searchParams = useSearchParams();
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
const today = new Date();
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Offene Aufgaben ({total})</CardTitle>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine offenen Aufgaben</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Alle Tagesordnungspunkte sind erledigt oder vertagt.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Aufgabe</th>
<th className="p-3 text-left font-medium">Protokoll</th>
<th className="p-3 text-left font-medium">Zuständig</th>
<th className="p-3 text-left font-medium">Fällig</th>
<th className="p-3 text-center font-medium">Status</th>
</tr>
</thead>
<tbody>
{data.map((task) => {
const isOverdue =
task.due_date && new Date(task.due_date) < today;
return (
<tr
key={task.id}
className="cursor-pointer border-b hover:bg-muted/30"
onClick={() =>
router.push(
`/home/${account}/meetings/protocols/${task.meeting_protocols.id}`,
)
}
>
<td className="p-3">
<div>
<p className="font-medium">{task.title}</p>
{task.description && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
{task.description}
</p>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
<div>
<p className="text-sm">{task.meeting_protocols.title}</p>
<p className="text-xs">
{new Date(task.meeting_protocols.meeting_date).toLocaleDateString('de-DE')}
</p>
</div>
</td>
<td className="p-3 text-muted-foreground">
{task.responsible_person ?? '—'}
</td>
<td className={`p-3 ${isOverdue ? 'font-medium text-destructive' : 'text-muted-foreground'}`}>
{task.due_date
? new Date(task.due_date).toLocaleDateString('de-DE')
: '—'}
{isOverdue && (
<span className="ml-1 text-xs">(überfällig)</span>
)}
</td>
<td className="p-3 text-center">
<Badge
variant={
(ITEM_STATUS_COLORS[task.status] as 'default' | 'secondary' | 'destructive' | 'outline') ??
'outline'
}
>
{ITEM_STATUS_LABELS[task.status] ?? task.status}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { toast } from '@kit/ui/sonner';
import { ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '../lib/meetings-constants';
import { updateItemStatus, deleteProtocolItem } from '../server/actions/meetings-actions';
import type { MeetingItemStatus } from '../schema/meetings.schema';
interface ProtocolItem {
id: string;
title: string;
description: string | null;
responsible_person: string | null;
due_date: string | null;
status: string;
sort_order: number;
}
interface ProtocolItemsListProps {
items: ProtocolItem[];
protocolId: string;
account: string;
}
const STATUS_TRANSITIONS: Record<string, MeetingItemStatus> = {
offen: 'in_bearbeitung',
in_bearbeitung: 'erledigt',
erledigt: 'offen',
vertagt: 'offen',
};
export function ProtocolItemsList({
items,
protocolId,
account,
}: ProtocolItemsListProps) {
const { execute: executeStatusUpdate, isPending: isUpdating } = useAction(updateItemStatus, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Status aktualisiert');
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
},
});
const { execute: executeDelete, isPending: isDeleting } = useAction(deleteProtocolItem, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Tagesordnungspunkt gelöscht');
}
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Fehler beim Löschen');
},
});
const handleToggleStatus = (item: ProtocolItem) => {
const nextStatus = STATUS_TRANSITIONS[item.status] ?? 'offen';
executeStatusUpdate({ itemId: item.id, status: nextStatus });
};
const handleDelete = (itemId: string) => {
if (confirm('Tagesordnungspunkt wirklich löschen?')) {
executeDelete({ itemId });
}
};
return (
<Card>
<CardHeader>
<CardTitle>Tagesordnungspunkte ({items.length})</CardTitle>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<h3 className="text-lg font-semibold">Keine Tagesordnungspunkte</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Fügen Sie Tagesordnungspunkte zu diesem Protokoll hinzu.
</p>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">#</th>
<th className="p-3 text-left font-medium">Titel</th>
<th className="p-3 text-left font-medium">Zuständig</th>
<th className="p-3 text-left font-medium">Fällig</th>
<th className="p-3 text-center font-medium">Status</th>
<th className="p-3 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const isOverdue =
item.due_date &&
['offen', 'in_bearbeitung'].includes(item.status) &&
new Date(item.due_date) < new Date();
return (
<tr key={item.id} className="border-b hover:bg-muted/30">
<td className="p-3 text-muted-foreground">{index + 1}</td>
<td className="p-3">
<div>
<p className="font-medium">{item.title}</p>
{item.description && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
{item.description}
</p>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
{item.responsible_person ?? '—'}
</td>
<td className={`p-3 ${isOverdue ? 'font-medium text-destructive' : 'text-muted-foreground'}`}>
{item.due_date
? new Date(item.due_date).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3 text-center">
<Badge
variant={
(ITEM_STATUS_COLORS[item.status] as 'default' | 'secondary' | 'destructive' | 'outline') ??
'outline'
}
className="cursor-pointer"
onClick={() => handleToggleStatus(item)}
>
{ITEM_STATUS_LABELS[item.status] ?? item.status}
</Badge>
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isDeleting}
onClick={() => handleDelete(item.id)}
>
Löschen
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,233 @@
'use client';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { MEETING_TYPE_LABELS } from '../lib/meetings-constants';
interface ProtocolsDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
}
const MEETING_TYPE_OPTIONS = [
{ value: '', label: 'Alle Sitzungsarten' },
{ value: 'vorstand', label: 'Vorstandssitzung' },
{ value: 'mitglieder', label: 'Mitgliederversammlung' },
{ value: 'ausschuss', label: 'Ausschusssitzung' },
{ value: 'sonstige', label: 'Sonstige' },
] as const;
export function ProtocolsDataTable({
data,
total,
page,
pageSize,
account,
}: ProtocolsDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const currentSearch = searchParams.get('q') ?? '';
const currentType = searchParams.get('type') ?? '';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const form = useForm({
defaultValues: { search: currentSearch },
});
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
if (!('page' in updates)) {
params.delete('page');
}
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
updateParams({ q: form.getValues('search') });
},
[form, updateParams],
);
const handleTypeChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ type: e.target.value });
},
[updateParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form onSubmit={handleSearch} className="flex gap-2">
<Input
placeholder="Protokoll suchen..."
className="w-64"
{...form.register('search')}
/>
<Button type="submit" variant="outline" size="sm">
Suchen
</Button>
</form>
<div className="flex items-center gap-3">
<select
value={currentType}
onChange={handleTypeChange}
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
{MEETING_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Link href={`/home/${account}/meetings/protocols/new`}>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Neues Protokoll
</Button>
</Link>
</div>
</div>
{/* Table */}
<Card>
<CardHeader>
<CardTitle>Protokolle ({total})</CardTitle>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">Keine Protokolle vorhanden</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Erstellen Sie Ihr erstes Sitzungsprotokoll, um loszulegen.
</p>
<Link href={`/home/${account}/meetings/protocols/new`} className="mt-4">
<Button>Neues Protokoll</Button>
</Link>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="p-3 text-left font-medium">Datum</th>
<th className="p-3 text-left font-medium">Titel</th>
<th className="p-3 text-left font-medium">Sitzungsart</th>
<th className="p-3 text-left font-medium">Ort</th>
<th className="p-3 text-center font-medium">Status</th>
</tr>
</thead>
<tbody>
{data.map((protocol) => (
<tr
key={String(protocol.id)}
className="cursor-pointer border-b hover:bg-muted/30"
onClick={() =>
router.push(
`/home/${account}/meetings/protocols/${String(protocol.id)}`,
)
}
>
<td className="p-3 text-muted-foreground">
{protocol.meeting_date
? new Date(String(protocol.meeting_date)).toLocaleDateString('de-DE')
: '—'}
</td>
<td className="p-3 font-medium">
<Link
href={`/home/${account}/meetings/protocols/${String(protocol.id)}`}
className="hover:underline"
>
{String(protocol.title)}
</Link>
</td>
<td className="p-3">
<Badge variant="secondary">
{MEETING_TYPE_LABELS[String(protocol.meeting_type)] ??
String(protocol.meeting_type)}
</Badge>
</td>
<td className="p-3 text-muted-foreground">
{String(protocol.location ?? '—')}
</td>
<td className="p-3 text-center">
{protocol.is_published ? (
<Badge variant="default">Veröffentlicht</Badge>
) : (
<Badge variant="outline">Entwurf</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Seite {page} von {totalPages} ({total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,36 @@
/**
* German label mappings for Sitzungsprotokolle enums and status colors.
*/
// =====================================================
// Meeting Type Labels
// =====================================================
export const MEETING_TYPE_LABELS: Record<string, string> = {
vorstand: 'Vorstandssitzung',
mitglieder: 'Mitgliederversammlung',
ausschuss: 'Ausschusssitzung',
sonstige: 'Sonstige',
};
// =====================================================
// Item Status Labels
// =====================================================
export const ITEM_STATUS_LABELS: Record<string, string> = {
offen: 'Offen',
in_bearbeitung: 'In Bearbeitung',
erledigt: 'Erledigt',
vertagt: 'Vertagt',
};
// =====================================================
// Item Status Colors (Badge variants)
// =====================================================
export const ITEM_STATUS_COLORS: Record<string, string> = {
offen: 'outline',
in_bearbeitung: 'secondary',
erledigt: 'default',
vertagt: 'destructive',
};

View File

@@ -0,0 +1,95 @@
import { z } from 'zod';
// =====================================================
// Enums
// =====================================================
export const meetingItemStatusEnum = z.enum([
'offen',
'in_bearbeitung',
'erledigt',
'vertagt',
]);
export type MeetingItemStatus = z.infer<typeof meetingItemStatusEnum>;
export const meetingTypeEnum = z.enum([
'vorstand',
'mitglieder',
'ausschuss',
'sonstige',
]);
export type MeetingType = z.infer<typeof meetingTypeEnum>;
// =====================================================
// Protocol Schemas
// =====================================================
export const CreateMeetingProtocolSchema = z.object({
accountId: z.string().uuid(),
title: z.string().min(1, 'Titel ist erforderlich'),
meetingDate: z.string().min(1, 'Datum ist erforderlich'),
meetingType: meetingTypeEnum,
location: z.string().optional(),
attendees: z.string().optional(),
remarks: z.string().optional(),
isPublished: z.boolean().default(false),
});
export type CreateMeetingProtocolInput = z.infer<typeof CreateMeetingProtocolSchema>;
export const UpdateMeetingProtocolSchema = z.object({
protocolId: z.string().uuid(),
title: z.string().min(1).optional(),
meetingDate: z.string().optional(),
meetingType: meetingTypeEnum.optional(),
location: z.string().optional(),
attendees: z.string().optional(),
remarks: z.string().optional(),
isPublished: z.boolean().optional(),
});
export type UpdateMeetingProtocolInput = z.infer<typeof UpdateMeetingProtocolSchema>;
// =====================================================
// Protocol Item Schemas
// =====================================================
export const CreateProtocolItemSchema = z.object({
protocolId: z.string().uuid(),
title: z.string().min(1, 'Titel ist erforderlich'),
description: z.string().optional(),
responsiblePerson: z.string().optional(),
dueDate: z.string().optional(),
status: meetingItemStatusEnum.default('offen'),
sortOrder: z.number().int().default(0),
});
export type CreateProtocolItemInput = z.infer<typeof CreateProtocolItemSchema>;
export const UpdateProtocolItemSchema = z.object({
itemId: z.string().uuid(),
title: z.string().min(1).optional(),
description: z.string().optional(),
responsiblePerson: z.string().optional(),
dueDate: z.string().optional(),
status: meetingItemStatusEnum.optional(),
sortOrder: z.number().int().optional(),
});
export type UpdateProtocolItemInput = z.infer<typeof UpdateProtocolItemSchema>;
export const UpdateItemStatusSchema = z.object({
itemId: z.string().uuid(),
status: meetingItemStatusEnum,
});
export type UpdateItemStatusInput = z.infer<typeof UpdateItemStatusSchema>;
export const ReorderItemsSchema = z.object({
protocolId: z.string().uuid(),
itemIds: z.array(z.string().uuid()),
});
export type ReorderItemsInput = z.infer<typeof ReorderItemsSchema>;

View File

@@ -0,0 +1,206 @@
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CreateMeetingProtocolSchema,
UpdateMeetingProtocolSchema,
CreateProtocolItemSchema,
UpdateProtocolItemSchema,
UpdateItemStatusSchema,
ReorderItemsSchema,
} from '../../schema/meetings.schema';
import { createMeetingsApi } from '../api';
const REVALIDATION_PATH = '/home/[account]/meetings';
// =====================================================
// Protocols
// =====================================================
export const createProtocol = authActionClient
.inputSchema(CreateMeetingProtocolSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.protocol.create' }, 'Protokoll wird erstellt...');
const result = await api.createProtocol(input, userId);
logger.info({ name: 'meetings.protocol.create' }, 'Protokoll erstellt');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true, data: result };
});
export const updateProtocol = authActionClient
.inputSchema(UpdateMeetingProtocolSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.protocol.update' }, 'Protokoll wird aktualisiert...');
const result = await api.updateProtocol(input, userId);
logger.info({ name: 'meetings.protocol.update' }, 'Protokoll aktualisiert');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true, data: result };
});
export const deleteProtocol = authActionClient
.inputSchema(
z.object({
protocolId: z.string().uuid(),
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
logger.info({ name: 'meetings.protocol.delete' }, 'Protokoll wird gelöscht...');
await api.deleteProtocol(input.protocolId);
logger.info({ name: 'meetings.protocol.delete' }, 'Protokoll gelöscht');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true };
});
// =====================================================
// Protocol Items
// =====================================================
export const createProtocolItem = authActionClient
.inputSchema(CreateProtocolItemSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.item.create' }, 'Tagesordnungspunkt wird erstellt...');
const result = await api.createItem(input, userId);
logger.info({ name: 'meetings.item.create' }, 'Tagesordnungspunkt erstellt');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true, data: result };
});
export const updateProtocolItem = authActionClient
.inputSchema(UpdateProtocolItemSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.item.update' }, 'Tagesordnungspunkt wird aktualisiert...');
const result = await api.updateItem(input, userId);
logger.info({ name: 'meetings.item.update' }, 'Tagesordnungspunkt aktualisiert');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true, data: result };
});
export const updateItemStatus = authActionClient
.inputSchema(UpdateItemStatusSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.item.status' }, 'Status wird geändert...');
const result = await api.updateItemStatus(input, userId);
logger.info({ name: 'meetings.item.status' }, 'Status geändert');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true, data: result };
});
export const deleteProtocolItem = authActionClient
.inputSchema(
z.object({
itemId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
logger.info({ name: 'meetings.item.delete' }, 'Tagesordnungspunkt wird gelöscht...');
await api.deleteItem(input.itemId);
logger.info({ name: 'meetings.item.delete' }, 'Tagesordnungspunkt gelöscht');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true };
});
export const reorderProtocolItems = authActionClient
.inputSchema(ReorderItemsSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
logger.info({ name: 'meetings.items.reorder' }, 'Reihenfolge wird aktualisiert...');
await api.reorderItems(input);
logger.info({ name: 'meetings.items.reorder' }, 'Reihenfolge aktualisiert');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true };
});
// =====================================================
// Attachments
// =====================================================
export const addProtocolAttachment = authActionClient
.inputSchema(
z.object({
protocolId: z.string().uuid(),
fileName: z.string().min(1),
filePath: z.string().min(1),
fileSize: z.number().int().positive(),
mimeType: z.string().min(1),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
const userId = ctx.user.id;
logger.info({ name: 'meetings.attachment.add' }, 'Anhang wird hinzugefügt...');
const result = await api.addAttachment(
input.protocolId,
input.fileName,
input.filePath,
input.fileSize,
input.mimeType,
userId,
);
logger.info({ name: 'meetings.attachment.add' }, 'Anhang hinzugefügt');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true, data: result };
});
export const deleteProtocolAttachment = authActionClient
.inputSchema(
z.object({
attachmentId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMeetingsApi(client);
logger.info({ name: 'meetings.attachment.delete' }, 'Anhang wird gelöscht...');
await api.deleteAttachment(input.attachmentId);
logger.info({ name: 'meetings.attachment.delete' }, 'Anhang gelöscht');
revalidatePath(REVALIDATION_PATH, 'page');
return { success: true };
});

View File

@@ -0,0 +1,394 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type {
CreateMeetingProtocolInput,
UpdateMeetingProtocolInput,
CreateProtocolItemInput,
UpdateProtocolItemInput,
UpdateItemStatusInput,
ReorderItemsInput,
} from '../schema/meetings.schema';
/**
* Factory for the Sitzungsprotokolle (Meeting Protocols) API.
*/
export function createMeetingsApi(client: SupabaseClient<Database>) {
return {
// =====================================================
// Protocols
// =====================================================
async listProtocols(
accountId: string,
opts?: {
search?: string;
meetingType?: string;
year?: number;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('meeting_protocols')
.select(
'id, title, meeting_date, meeting_type, location, attendees, remarks, is_published, created_at, updated_at',
{ count: 'exact' },
)
.eq('account_id', accountId)
.order('meeting_date', { ascending: false });
if (opts?.search) {
query = query.or(
`title.ilike.%${opts.search}%,location.ilike.%${opts.search}%,attendees.ilike.%${opts.search}%`,
);
}
if (opts?.meetingType) {
query = query.eq('meeting_type', opts.meetingType);
}
if (opts?.year) {
query = query
.gte('meeting_date', `${opts.year}-01-01`)
.lte('meeting_date', `${opts.year}-12-31`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async getProtocol(protocolId: string) {
const { data, error } = await client
.from('meeting_protocols')
.select('*')
.eq('id', protocolId)
.single();
if (error) throw error;
return data;
},
async createProtocol(input: CreateMeetingProtocolInput, userId: string) {
const { data, error } = await client
.from('meeting_protocols')
.insert({
account_id: input.accountId,
title: input.title,
meeting_date: input.meetingDate,
meeting_type: input.meetingType,
location: input.location,
attendees: input.attendees,
remarks: input.remarks,
is_published: input.isPublished,
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateProtocol(input: UpdateMeetingProtocolInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.title !== undefined) updateData.title = input.title;
if (input.meetingDate !== undefined) updateData.meeting_date = input.meetingDate;
if (input.meetingType !== undefined) updateData.meeting_type = input.meetingType;
if (input.location !== undefined) updateData.location = input.location;
if (input.attendees !== undefined) updateData.attendees = input.attendees;
if (input.remarks !== undefined) updateData.remarks = input.remarks;
if (input.isPublished !== undefined) updateData.is_published = input.isPublished;
const { data, error } = await client
.from('meeting_protocols')
.update(updateData)
.eq('id', input.protocolId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteProtocol(protocolId: string) {
const { error } = await client
.from('meeting_protocols')
.delete()
.eq('id', protocolId);
if (error) throw error;
},
// =====================================================
// Protocol Items (Tagesordnungspunkte)
// =====================================================
async listItems(
protocolId: string,
opts?: { status?: string },
) {
let query = client
.from('meeting_protocol_items')
.select(
'id, protocol_id, title, description, responsible_person, due_date, status, sort_order, created_at, updated_at',
)
.eq('protocol_id', protocolId)
.order('sort_order')
.order('created_at');
if (opts?.status) {
query = query.eq('status', opts.status);
}
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async createItem(input: CreateProtocolItemInput, userId: string) {
const { data, error } = await client
.from('meeting_protocol_items')
.insert({
protocol_id: input.protocolId,
title: input.title,
description: input.description,
responsible_person: input.responsiblePerson,
due_date: input.dueDate,
status: input.status,
sort_order: input.sortOrder,
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateItem(input: UpdateProtocolItemInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
if (input.title !== undefined) updateData.title = input.title;
if (input.description !== undefined) updateData.description = input.description;
if (input.responsiblePerson !== undefined) updateData.responsible_person = input.responsiblePerson;
if (input.dueDate !== undefined) updateData.due_date = input.dueDate;
if (input.status !== undefined) updateData.status = input.status;
if (input.sortOrder !== undefined) updateData.sort_order = input.sortOrder;
const { data, error } = await client
.from('meeting_protocol_items')
.update(updateData)
.eq('id', input.itemId)
.select()
.single();
if (error) throw error;
return data;
},
async updateItemStatus(input: UpdateItemStatusInput, userId: string) {
const { data, error } = await client
.from('meeting_protocol_items')
.update({
status: input.status,
updated_by: userId,
})
.eq('id', input.itemId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteItem(itemId: string) {
const { error } = await client
.from('meeting_protocol_items')
.delete()
.eq('id', itemId);
if (error) throw error;
},
async reorderItems(input: ReorderItemsInput) {
// Update sort_order for each item based on position in the array
const updates = input.itemIds.map((itemId, index) =>
client
.from('meeting_protocol_items')
.update({ sort_order: index })
.eq('id', itemId)
.eq('protocol_id', input.protocolId),
);
await Promise.all(updates);
},
// =====================================================
// Open Tasks (cross-protocol)
// =====================================================
async listOpenTasks(
accountId: string,
opts?: { page?: number; pageSize?: number },
) {
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 50;
const { data, error, count } = await client
.from('meeting_protocol_items')
.select(
'id, protocol_id, title, description, responsible_person, due_date, status, sort_order, created_at, updated_at, meeting_protocols!inner ( id, title, meeting_date, meeting_type, account_id )',
{ count: 'exact' },
)
.eq('meeting_protocols.account_id', accountId)
.in('status', ['offen', 'in_bearbeitung'])
.order('due_date', { ascending: true, nullsFirst: false })
.range((page - 1) * pageSize, page * pageSize - 1);
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
// =====================================================
// Attachments
// =====================================================
async listAttachments(protocolId: string) {
const { data, error } = await client
.from('meeting_protocol_attachments')
.select(
'id, protocol_id, file_name, file_path, file_size, mime_type, created_at',
)
.eq('protocol_id', protocolId)
.order('created_at');
if (error) throw error;
return data ?? [];
},
async addAttachment(
protocolId: string,
fileName: string,
filePath: string,
fileSize: number,
mimeType: string,
userId: string,
) {
const { data, error } = await client
.from('meeting_protocol_attachments')
.insert({
protocol_id: protocolId,
file_name: fileName,
file_path: filePath,
file_size: fileSize,
mime_type: mimeType,
created_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async deleteAttachment(attachmentId: string) {
const { error } = await client
.from('meeting_protocol_attachments')
.delete()
.eq('id', attachmentId);
if (error) throw error;
},
// =====================================================
// Dashboard Stats
// =====================================================
async getDashboardStats(accountId: string) {
const currentYear = new Date().getFullYear();
const [
totalProtocolsResult,
thisYearProtocolsResult,
openTasksResult,
overdueTasksResult,
] = await Promise.all([
// Total protocols
client
.from('meeting_protocols')
.select('id', { count: 'exact', head: true })
.eq('account_id', accountId),
// Protocols this year
client
.from('meeting_protocols')
.select('id', { count: 'exact', head: true })
.eq('account_id', accountId)
.gte('meeting_date', `${currentYear}-01-01`)
.lte('meeting_date', `${currentYear}-12-31`),
// Open tasks (offen + in_bearbeitung)
client
.from('meeting_protocol_items')
.select(
'id, meeting_protocols!inner ( account_id )',
{ count: 'exact', head: true },
)
.eq('meeting_protocols.account_id', accountId)
.in('status', ['offen', 'in_bearbeitung']),
// Overdue tasks
client
.from('meeting_protocol_items')
.select(
'id, meeting_protocols!inner ( account_id )',
{ count: 'exact', head: true },
)
.eq('meeting_protocols.account_id', accountId)
.in('status', ['offen', 'in_bearbeitung'])
.lt('due_date', new Date().toISOString().split('T')[0]!),
]);
return {
totalProtocols: totalProtocolsResult.count ?? 0,
thisYearProtocols: thisYearProtocolsResult.count ?? 0,
openTasks: openTasksResult.count ?? 0,
overdueTasks: overdueTasksResult.count ?? 0,
};
},
// =====================================================
// Recent Protocols (for dashboard)
// =====================================================
async getRecentProtocols(accountId: string, limit = 5) {
const { data, error } = await client
.from('meeting_protocols')
.select('id, title, meeting_date, meeting_type, is_published')
.eq('account_id', accountId)
.order('meeting_date', { ascending: false })
.limit(limit);
if (error) throw error;
return data ?? [];
},
// =====================================================
// Overdue Tasks (for dashboard)
// =====================================================
async getOverdueTasks(accountId: string, limit = 5) {
const today = new Date().toISOString().split('T')[0]!;
const { data, error } = await client
.from('meeting_protocol_items')
.select(
'id, title, responsible_person, due_date, status, meeting_protocols!inner ( id, title, account_id )',
)
.eq('meeting_protocols.account_id', accountId)
.in('status', ['offen', 'in_bearbeitung'])
.lt('due_date', today)
.order('due_date', { ascending: true })
.limit(limit);
if (error) throw error;
return data ?? [];
},
};
}