Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
'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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { todayISO } from '@kit/shared/dates';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from '@kit/ui/form';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Switch } from '@kit/ui/switch';
|
||||
|
||||
@@ -39,7 +41,7 @@ export function CreateProtocolForm({
|
||||
defaultValues: {
|
||||
accountId,
|
||||
title: '',
|
||||
meetingDate: new Date().toISOString().split('T')[0]!,
|
||||
meetingDate: todayISO(),
|
||||
meetingType: 'vorstand' as const,
|
||||
location: '',
|
||||
attendees: '',
|
||||
@@ -80,7 +82,10 @@ export function CreateProtocolForm({
|
||||
<FormItem>
|
||||
<FormLabel>Titel *</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Vorstandssitzung März 2026" {...field} />
|
||||
<Input
|
||||
placeholder="z.B. Vorstandssitzung März 2026"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -111,7 +116,7 @@ export function CreateProtocolForm({
|
||||
<FormControl>
|
||||
<select
|
||||
{...field}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="vorstand">Vorstandssitzung</option>
|
||||
<option value="mitglieder">Mitgliederversammlung</option>
|
||||
@@ -156,7 +161,7 @@ export function CreateProtocolForm({
|
||||
<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"
|
||||
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -174,7 +179,7 @@ export function CreateProtocolForm({
|
||||
<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"
|
||||
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -196,9 +201,12 @@ export function CreateProtocolForm({
|
||||
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>
|
||||
<FormLabel className="text-base">
|
||||
Protokoll veröffentlichen
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Veröffentlichte Protokolle sind für alle Mitglieder sichtbar.
|
||||
Veröffentlichte Protokolle sind für alle Mitglieder
|
||||
sichtbar.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
|
||||
@@ -2,13 +2,9 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
ListChecks,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { FileText, Calendar, ListChecks, AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
@@ -71,10 +67,12 @@ export function MeetingsDashboard({
|
||||
<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-muted-foreground text-sm font-medium">
|
||||
Protokolle gesamt
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{stats.totalProtocols}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,10 +85,14 @@ export function MeetingsDashboard({
|
||||
<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>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
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">
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<Calendar className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,10 +105,12 @@ export function MeetingsDashboard({
|
||||
<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-muted-foreground text-sm font-medium">
|
||||
Offene Aufgaben
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{stats.openTasks}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<ListChecks className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,10 +123,12 @@ export function MeetingsDashboard({
|
||||
<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-muted-foreground text-sm font-medium">
|
||||
Ü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">
|
||||
<div className="bg-destructive/10 text-destructive rounded-full p-3">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +145,7 @@ export function MeetingsDashboard({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentProtocols.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Noch keine Protokolle vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
@@ -148,18 +154,23 @@ export function MeetingsDashboard({
|
||||
<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"
|
||||
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-3 transition-colors"
|
||||
>
|
||||
<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')}
|
||||
<p className="truncate text-sm font-medium">
|
||||
{protocol.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatDate(protocol.meeting_date)}
|
||||
{' · '}
|
||||
{MEETING_TYPE_LABELS[protocol.meeting_type] ?? protocol.meeting_type}
|
||||
{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>
|
||||
<Badge variant="default" className="ml-2 shrink-0">
|
||||
Veröffentlicht
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
@@ -174,7 +185,7 @@ export function MeetingsDashboard({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overdueTasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Keine überfälligen Aufgaben.
|
||||
</p>
|
||||
) : (
|
||||
@@ -182,20 +193,18 @@ export function MeetingsDashboard({
|
||||
{overdueTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="rounded-lg border border-destructive/20 bg-destructive/5 p-3"
|
||||
className="border-destructive/20 bg-destructive/5 rounded-lg border 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">
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
{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>
|
||||
<span>Fällig: {formatDate(task.due_date)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Protokoll: {task.meeting_protocols.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
interface MeetingsTabNavigationProps {
|
||||
@@ -22,7 +23,10 @@ export function MeetingsTabNavigation({
|
||||
|
||||
return (
|
||||
<div className="mb-6 border-b">
|
||||
<nav className="-mb-px flex space-x-1 overflow-x-auto" aria-label="Sitzungsprotokolle Navigation">
|
||||
<nav
|
||||
className="-mb-px flex space-x-1 overflow-x-auto"
|
||||
aria-label="Sitzungsprotokolle Navigation"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
|
||||
@@ -31,10 +35,10 @@ export function MeetingsTabNavigation({
|
||||
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',
|
||||
'border-b-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors',
|
||||
isActive
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground',
|
||||
: 'text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground border-transparent',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
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';
|
||||
import {
|
||||
ITEM_STATUS_LABELS,
|
||||
ITEM_STATUS_COLORS,
|
||||
} from '../lib/meetings-constants';
|
||||
|
||||
interface OpenTask {
|
||||
id: string;
|
||||
@@ -78,7 +83,7 @@ export function OpenTasksView({
|
||||
{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">
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
Alle Tagesordnungspunkte sind erledigt oder vertagt.
|
||||
</p>
|
||||
</div>
|
||||
@@ -86,7 +91,7 @@ export function OpenTasksView({
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<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>
|
||||
@@ -102,7 +107,7 @@ export function OpenTasksView({
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
className="cursor-pointer border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 cursor-pointer border-b"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/home/${account}/meetings/protocols/${task.meeting_protocols.id}`,
|
||||
@@ -113,27 +118,29 @@ export function OpenTasksView({
|
||||
<div>
|
||||
<p className="font-medium">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-1 text-xs">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<td className="text-muted-foreground p-3">
|
||||
<div>
|
||||
<p className="text-sm">{task.meeting_protocols.title}</p>
|
||||
<p className="text-sm">
|
||||
{task.meeting_protocols.title}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{new Date(task.meeting_protocols.meeting_date).toLocaleDateString('de-DE')}
|
||||
{formatDate(task.meeting_protocols.meeting_date)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<td className="text-muted-foreground p-3">
|
||||
{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')
|
||||
: '—'}
|
||||
<td
|
||||
className={`p-3 ${isOverdue ? 'text-destructive font-medium' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{task.due_date ? formatDate(task.due_date) : '—'}
|
||||
{isOverdue && (
|
||||
<span className="ml-1 text-xs">(überfällig)</span>
|
||||
)}
|
||||
@@ -141,8 +148,11 @@ export function OpenTasksView({
|
||||
<td className="p-3 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
(ITEM_STATUS_COLORS[task.status] as 'default' | 'secondary' | 'destructive' | 'outline') ??
|
||||
'outline'
|
||||
(ITEM_STATUS_COLORS[task.status] as
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'destructive'
|
||||
| 'outline') ?? 'outline'
|
||||
}
|
||||
>
|
||||
{ITEM_STATUS_LABELS[task.status] ?? task.status}
|
||||
@@ -159,7 +169,7 @@ export function OpenTasksView({
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Seite {page} von {totalPages} ({total} Einträge)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
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 {
|
||||
ITEM_STATUS_LABELS,
|
||||
ITEM_STATUS_COLORS,
|
||||
} from '../lib/meetings-constants';
|
||||
import type { MeetingItemStatus } from '../schema/meetings.schema';
|
||||
import {
|
||||
updateItemStatus,
|
||||
deleteProtocolItem,
|
||||
} from '../server/actions/meetings-actions';
|
||||
|
||||
interface ProtocolItem {
|
||||
id: string;
|
||||
@@ -40,27 +46,33 @@ export function ProtocolItemsList({
|
||||
protocolId,
|
||||
account,
|
||||
}: ProtocolItemsListProps) {
|
||||
const { execute: executeStatusUpdate, isPending: isUpdating } = useAction(updateItemStatus, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Status aktualisiert');
|
||||
}
|
||||
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');
|
||||
},
|
||||
},
|
||||
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');
|
||||
}
|
||||
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');
|
||||
},
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Löschen');
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const handleToggleStatus = (item: ProtocolItem) => {
|
||||
const nextStatus = STATUS_TRANSITIONS[item.status] ?? 'offen';
|
||||
@@ -82,7 +94,7 @@ export function ProtocolItemsList({
|
||||
{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">
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
Fügen Sie Tagesordnungspunkte zu diesem Protokoll hinzu.
|
||||
</p>
|
||||
</div>
|
||||
@@ -90,7 +102,7 @@ export function ProtocolItemsList({
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<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>
|
||||
@@ -107,31 +119,34 @@ export function ProtocolItemsList({
|
||||
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>
|
||||
<tr key={item.id} className="hover:bg-muted/30 border-b">
|
||||
<td className="text-muted-foreground p-3">{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">
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-1 text-xs">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<td className="text-muted-foreground p-3">
|
||||
{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
|
||||
className={`p-3 ${isOverdue ? 'text-destructive font-medium' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{formatDate(item.due_date)}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
(ITEM_STATUS_COLORS[item.status] as 'default' | 'secondary' | 'destructive' | 'outline') ??
|
||||
'outline'
|
||||
(ITEM_STATUS_COLORS[item.status] as
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'destructive'
|
||||
| 'outline') ?? 'outline'
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleToggleStatus(item)}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
@@ -107,7 +109,7 @@ export function ProtocolsDataTable({
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={handleTypeChange}
|
||||
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
{MEETING_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -133,11 +135,16 @@ export function ProtocolsDataTable({
|
||||
<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">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Keine Protokolle vorhanden
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||
Erstellen Sie Ihr erstes Sitzungsprotokoll, um loszulegen.
|
||||
</p>
|
||||
<Link href={`/home/${account}/meetings/protocols/new`} className="mt-4">
|
||||
<Link
|
||||
href={`/home/${account}/meetings/protocols/new`}
|
||||
className="mt-4"
|
||||
>
|
||||
<Button>Neues Protokoll</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -145,7 +152,7 @@ export function ProtocolsDataTable({
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<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>
|
||||
@@ -157,17 +164,15 @@ export function ProtocolsDataTable({
|
||||
{data.map((protocol) => (
|
||||
<tr
|
||||
key={String(protocol.id)}
|
||||
className="cursor-pointer border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 cursor-pointer border-b"
|
||||
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 className="text-muted-foreground p-3">
|
||||
{formatDate(protocol.meeting_date as string)}
|
||||
</td>
|
||||
<td className="p-3 font-medium">
|
||||
<Link
|
||||
@@ -183,7 +188,7 @@ export function ProtocolsDataTable({
|
||||
String(protocol.meeting_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<td className="text-muted-foreground p-3">
|
||||
{String(protocol.location ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
@@ -203,7 +208,7 @@ export function ProtocolsDataTable({
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Seite {page} von {totalPages} ({total} Einträge)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -37,7 +37,9 @@ export const CreateMeetingProtocolSchema = z.object({
|
||||
isPublished: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CreateMeetingProtocolInput = z.infer<typeof CreateMeetingProtocolSchema>;
|
||||
export type CreateMeetingProtocolInput = z.infer<
|
||||
typeof CreateMeetingProtocolSchema
|
||||
>;
|
||||
|
||||
export const UpdateMeetingProtocolSchema = z.object({
|
||||
protocolId: z.string().uuid(),
|
||||
@@ -50,7 +52,9 @@ export const UpdateMeetingProtocolSchema = z.object({
|
||||
isPublished: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateMeetingProtocolInput = z.infer<typeof UpdateMeetingProtocolSchema>;
|
||||
export type UpdateMeetingProtocolInput = z.infer<
|
||||
typeof UpdateMeetingProtocolSchema
|
||||
>;
|
||||
|
||||
// =====================================================
|
||||
// Protocol Item Schemas
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
@@ -14,7 +16,6 @@ import {
|
||||
UpdateItemStatusSchema,
|
||||
ReorderItemsSchema,
|
||||
} from '../../schema/meetings.schema';
|
||||
|
||||
import { createMeetingsApi } from '../api';
|
||||
|
||||
const REVALIDATION_PATH = '/home/[account]/meetings';
|
||||
@@ -31,7 +32,10 @@ export const createProtocol = authActionClient
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.protocol.create' }, 'Protokoll wird erstellt...');
|
||||
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');
|
||||
@@ -46,7 +50,10 @@ export const updateProtocol = authActionClient
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.protocol.update' }, 'Protokoll wird aktualisiert...');
|
||||
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');
|
||||
@@ -65,7 +72,10 @@ export const deleteProtocol = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
logger.info({ name: 'meetings.protocol.delete' }, 'Protokoll wird gelöscht...');
|
||||
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');
|
||||
@@ -84,9 +94,15 @@ export const createProtocolItem = authActionClient
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.item.create' }, 'Tagesordnungspunkt wird erstellt...');
|
||||
logger.info(
|
||||
{ name: 'meetings.item.create' },
|
||||
'Tagesordnungspunkt wird erstellt...',
|
||||
);
|
||||
const result = await api.createItem(input, userId);
|
||||
logger.info({ name: 'meetings.item.create' }, 'Tagesordnungspunkt erstellt');
|
||||
logger.info(
|
||||
{ name: 'meetings.item.create' },
|
||||
'Tagesordnungspunkt erstellt',
|
||||
);
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
@@ -99,9 +115,15 @@ export const updateProtocolItem = authActionClient
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.item.update' }, 'Tagesordnungspunkt wird aktualisiert...');
|
||||
logger.info(
|
||||
{ name: 'meetings.item.update' },
|
||||
'Tagesordnungspunkt wird aktualisiert...',
|
||||
);
|
||||
const result = await api.updateItem(input, userId);
|
||||
logger.info({ name: 'meetings.item.update' }, 'Tagesordnungspunkt aktualisiert');
|
||||
logger.info(
|
||||
{ name: 'meetings.item.update' },
|
||||
'Tagesordnungspunkt aktualisiert',
|
||||
);
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
@@ -132,9 +154,15 @@ export const deleteProtocolItem = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
logger.info({ name: 'meetings.item.delete' }, 'Tagesordnungspunkt wird gelöscht...');
|
||||
logger.info(
|
||||
{ name: 'meetings.item.delete' },
|
||||
'Tagesordnungspunkt wird gelöscht...',
|
||||
);
|
||||
await api.deleteItem(input.itemId);
|
||||
logger.info({ name: 'meetings.item.delete' }, 'Tagesordnungspunkt gelöscht');
|
||||
logger.info(
|
||||
{ name: 'meetings.item.delete' },
|
||||
'Tagesordnungspunkt gelöscht',
|
||||
);
|
||||
revalidatePath(REVALIDATION_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
@@ -146,7 +174,10 @@ export const reorderProtocolItems = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
logger.info({ name: 'meetings.items.reorder' }, 'Reihenfolge wird aktualisiert...');
|
||||
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');
|
||||
@@ -173,7 +204,10 @@ export const addProtocolAttachment = authActionClient
|
||||
const api = createMeetingsApi(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'meetings.attachment.add' }, 'Anhang wird hinzugefügt...');
|
||||
logger.info(
|
||||
{ name: 'meetings.attachment.add' },
|
||||
'Anhang wird hinzugefügt...',
|
||||
);
|
||||
const result = await api.addAttachment(
|
||||
input.protocolId,
|
||||
input.fileName,
|
||||
@@ -198,7 +232,10 @@ export const deleteProtocolAttachment = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createMeetingsApi(client);
|
||||
|
||||
logger.info({ name: 'meetings.attachment.delete' }, 'Anhang wird gelöscht...');
|
||||
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');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type {
|
||||
CreateMeetingProtocolInput,
|
||||
UpdateMeetingProtocolInput,
|
||||
@@ -98,12 +99,15 @@ export function createMeetingsApi(client: SupabaseClient<Database>) {
|
||||
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.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;
|
||||
if (input.isPublished !== undefined)
|
||||
updateData.is_published = input.isPublished;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocols')
|
||||
@@ -127,10 +131,7 @@ export function createMeetingsApi(client: SupabaseClient<Database>) {
|
||||
// Protocol Items (Tagesordnungspunkte)
|
||||
// =====================================================
|
||||
|
||||
async listItems(
|
||||
protocolId: string,
|
||||
opts?: { status?: string },
|
||||
) {
|
||||
async listItems(protocolId: string, opts?: { status?: string }) {
|
||||
let query = client
|
||||
.from('meeting_protocol_items')
|
||||
.select(
|
||||
@@ -173,11 +174,14 @@ export function createMeetingsApi(client: SupabaseClient<Database>) {
|
||||
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.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;
|
||||
if (input.sortOrder !== undefined)
|
||||
updateData.sort_order = input.sortOrder;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('meeting_protocol_items')
|
||||
@@ -327,19 +331,19 @@ export function createMeetingsApi(client: SupabaseClient<Database>) {
|
||||
// Open tasks (offen + in_bearbeitung)
|
||||
client
|
||||
.from('meeting_protocol_items')
|
||||
.select(
|
||||
'id, meeting_protocols!inner ( account_id )',
|
||||
{ count: 'exact', head: true },
|
||||
)
|
||||
.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 },
|
||||
)
|
||||
.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]!),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user