feat: complete CMS v2 with Docker, Fischerei, Meetings, Verband modules + UX audit fixes
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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 };
|
||||
});
|
||||
394
packages/features/sitzungsprotokolle/src/server/api.ts
Normal file
394
packages/features/sitzungsprotokolle/src/server/api.ts
Normal 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 ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user