Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -1,16 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plus, Pencil, Trash2, Star } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
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 {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -19,10 +18,11 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { CreateClubContactSchema } from '../schema/verband.schema';
|
||||
import { CONTACT_ROLE_LABELS } from '../lib/verband-constants';
|
||||
import { CreateClubContactSchema } from '../schema/verband.schema';
|
||||
import {
|
||||
createContact,
|
||||
updateContact,
|
||||
@@ -34,7 +34,10 @@ interface ClubContactsManagerProps {
|
||||
contacts: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerProps) {
|
||||
export function ClubContactsManager({
|
||||
clubId,
|
||||
contacts,
|
||||
}: ClubContactsManagerProps) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
@@ -51,32 +54,54 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
},
|
||||
});
|
||||
|
||||
const { execute: executeCreate, isPending: isCreating } = useAction(createContact, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Kontakt erstellt');
|
||||
setShowForm(false);
|
||||
form.reset({ clubId, firstName: '', lastName: '', role: 'sonstige', phone: '', email: '', isPrimary: false });
|
||||
}
|
||||
const { execute: executeCreate, isPending: isCreating } = useAction(
|
||||
createContact,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Kontakt erstellt');
|
||||
setShowForm(false);
|
||||
form.reset({
|
||||
clubId,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: 'sonstige',
|
||||
phone: '',
|
||||
email: '',
|
||||
isPrimary: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Erstellen');
|
||||
},
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Erstellen');
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const { execute: executeUpdate, isPending: isUpdating } = useAction(updateContact, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Kontakt aktualisiert');
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
form.reset({ clubId, firstName: '', lastName: '', role: 'sonstige', phone: '', email: '', isPrimary: false });
|
||||
}
|
||||
const { execute: executeUpdate, isPending: isUpdating } = useAction(
|
||||
updateContact,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Kontakt aktualisiert');
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
form.reset({
|
||||
clubId,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: 'sonstige',
|
||||
phone: '',
|
||||
email: '',
|
||||
isPrimary: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
|
||||
},
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.serverError ?? 'Fehler beim Aktualisieren');
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const { execute: executeDelete } = useAction(deleteContact, {
|
||||
onSuccess: () => {
|
||||
@@ -103,7 +128,9 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
|
||||
const handleSubmit = (data: Record<string, unknown>) => {
|
||||
if (editingId) {
|
||||
executeUpdate({ contactId: editingId, ...data } as Parameters<typeof executeUpdate>[0]);
|
||||
executeUpdate({ contactId: editingId, ...data } as Parameters<
|
||||
typeof executeUpdate
|
||||
>[0]);
|
||||
} else {
|
||||
executeCreate(data as Parameters<typeof executeCreate>[0]);
|
||||
}
|
||||
@@ -114,7 +141,13 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Ansprechpartner</CardTitle>
|
||||
{!showForm && (
|
||||
<Button size="sm" onClick={() => { setEditingId(null); setShowForm(true); }}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Kontakt hinzufügen
|
||||
</Button>
|
||||
@@ -134,7 +167,9 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vorname *</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -145,7 +180,9 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nachname *</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -159,11 +196,15 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
<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"
|
||||
>
|
||||
{Object.entries(CONTACT_ROLE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
{Object.entries(CONTACT_ROLE_LABELS).map(
|
||||
([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -176,7 +217,9 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telefon</FormLabel>
|
||||
<FormControl><Input type="tel" {...field} /></FormControl>
|
||||
<FormControl>
|
||||
<Input type="tel" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -187,7 +230,9 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail</FormLabel>
|
||||
<FormControl><Input type="email" {...field} /></FormControl>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -197,7 +242,10 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowForm(false); setEditingId(null); }}
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
@@ -214,12 +262,14 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
)}
|
||||
|
||||
{contacts.length === 0 && !showForm ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Ansprechpartner vorhanden.</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Keine Ansprechpartner vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<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">Name</th>
|
||||
<th className="p-3 text-left font-medium">Funktion</th>
|
||||
<th className="p-3 text-left font-medium">Telefon</th>
|
||||
@@ -238,11 +288,16 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="secondary">
|
||||
{CONTACT_ROLE_LABELS[String(contact.role)] ?? String(contact.role)}
|
||||
{CONTACT_ROLE_LABELS[String(contact.role)] ??
|
||||
String(contact.role)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{String(contact.phone ?? '—')}</td>
|
||||
<td className="p-3 text-muted-foreground">{String(contact.email ?? '—')}</td>
|
||||
<td className="text-muted-foreground p-3">
|
||||
{String(contact.phone ?? '—')}
|
||||
</td>
|
||||
<td className="text-muted-foreground p-3">
|
||||
{String(contact.email ?? '—')}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
@@ -255,9 +310,11 @@ export function ClubContactsManager({ clubId, contacts }: ClubContactsManagerPro
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => executeDelete({ contactId: String(contact.id) })}
|
||||
onClick={() =>
|
||||
executeDelete({ contactId: String(contact.id) })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<Trash2 className="text-destructive h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { CheckCircle2, Euro, XCircle } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { formatCurrencyAmount } from '@kit/shared/formatters';
|
||||
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 { BILLING_STATUS_LABELS, BILLING_STATUS_COLORS, PAYMENT_METHOD_LABELS } from '../lib/verband-constants';
|
||||
import { markBillingPaid, deleteFeeBilling } from '../server/actions/verband-actions';
|
||||
import {
|
||||
BILLING_STATUS_LABELS,
|
||||
BILLING_STATUS_COLORS,
|
||||
PAYMENT_METHOD_LABELS,
|
||||
} from '../lib/verband-constants';
|
||||
import {
|
||||
markBillingPaid,
|
||||
deleteFeeBilling,
|
||||
} from '../server/actions/verband-actions';
|
||||
|
||||
interface ClubFeeBillingTableProps {
|
||||
billings: Array<Record<string, unknown>>;
|
||||
clubId: string;
|
||||
}
|
||||
|
||||
export function ClubFeeBillingTable({ billings, clubId }: ClubFeeBillingTableProps) {
|
||||
export function ClubFeeBillingTable({
|
||||
billings,
|
||||
clubId,
|
||||
}: ClubFeeBillingTableProps) {
|
||||
const [showPaid, setShowPaid] = useState(false);
|
||||
|
||||
const { execute: executeMarkPaid } = useAction(markBillingPaid, {
|
||||
@@ -60,12 +72,14 @@ export function ClubFeeBillingTable({ billings, clubId }: ClubFeeBillingTablePro
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredBillings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Beitragsabrechnungen vorhanden.</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Keine Beitragsabrechnungen vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<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">Beitragsart</th>
|
||||
<th className="p-3 text-center font-medium">Jahr</th>
|
||||
<th className="p-3 text-right font-medium">Betrag</th>
|
||||
@@ -77,7 +91,9 @@ export function ClubFeeBillingTable({ billings, clubId }: ClubFeeBillingTablePro
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredBillings.map((billing) => {
|
||||
const feeTypeName = (billing.club_fee_types as Record<string, unknown> | null)?.name;
|
||||
const feeTypeName = (
|
||||
billing.club_fee_types as Record<string, unknown> | null
|
||||
)?.name;
|
||||
const status = String(billing.status ?? 'offen');
|
||||
|
||||
return (
|
||||
@@ -85,26 +101,33 @@ export function ClubFeeBillingTable({ billings, clubId }: ClubFeeBillingTablePro
|
||||
<td className="p-3 font-medium">
|
||||
{feeTypeName ? String(feeTypeName) : '—'}
|
||||
</td>
|
||||
<td className="p-3 text-center">{String(billing.year)}</td>
|
||||
<td className="p-3 text-right">
|
||||
{Number(billing.amount).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
})}
|
||||
<td className="p-3 text-center">
|
||||
{String(billing.year)}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{billing.due_date
|
||||
? new Date(String(billing.due_date)).toLocaleDateString('de-DE')
|
||||
: '—'}
|
||||
<td className="p-3 text-right">
|
||||
{formatCurrencyAmount(billing.amount)}
|
||||
</td>
|
||||
<td className="text-muted-foreground p-3">
|
||||
{formatDate(billing.due_date)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={BILLING_STATUS_COLORS[status] as 'default' | 'secondary' | 'destructive' | 'outline' ?? 'outline'}>
|
||||
<Badge
|
||||
variant={
|
||||
(BILLING_STATUS_COLORS[status] as
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'destructive'
|
||||
| 'outline') ?? 'outline'
|
||||
}
|
||||
>
|
||||
{BILLING_STATUS_LABELS[status] ?? status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<td className="text-muted-foreground p-3">
|
||||
{billing.payment_method
|
||||
? PAYMENT_METHOD_LABELS[String(billing.payment_method)] ?? String(billing.payment_method)
|
||||
? (PAYMENT_METHOD_LABELS[
|
||||
String(billing.payment_method)
|
||||
] ?? String(billing.payment_method))
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
@@ -113,7 +136,11 @@ export function ClubFeeBillingTable({ billings, clubId }: ClubFeeBillingTablePro
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => executeMarkPaid({ billingId: String(billing.id) })}
|
||||
onClick={() =>
|
||||
executeMarkPaid({
|
||||
billingId: String(billing.id),
|
||||
})
|
||||
}
|
||||
title="Als bezahlt markieren"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
@@ -122,10 +149,12 @@ export function ClubFeeBillingTable({ billings, clubId }: ClubFeeBillingTablePro
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => executeDelete({ billingId: String(billing.id) })}
|
||||
onClick={() =>
|
||||
executeDelete({ billingId: String(billing.id) })
|
||||
}
|
||||
title="Löschen"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Trash2,
|
||||
StickyNote,
|
||||
ListTodo,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { CheckCircle2, Circle, Trash2, StickyNote, ListTodo, Bell } from 'lucide-react';
|
||||
|
||||
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 { NOTE_TYPE_LABELS } from '../lib/verband-constants';
|
||||
import { completeClubNote, deleteClubNote } from '../server/actions/verband-actions';
|
||||
import {
|
||||
completeClubNote,
|
||||
deleteClubNote,
|
||||
} from '../server/actions/verband-actions';
|
||||
|
||||
interface ClubNotesListProps {
|
||||
notes: Array<Record<string, unknown>>;
|
||||
@@ -55,7 +65,9 @@ export function ClubNotesList({ notes, clubId }: ClubNotesListProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Notizen vorhanden.</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Keine Notizen vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{openNotes.map((note) => {
|
||||
@@ -72,7 +84,7 @@ export function ClubNotesList({ notes, clubId }: ClubNotesListProps) {
|
||||
onClick={() => executeComplete({ noteId: String(note.id) })}
|
||||
title="Als erledigt markieren"
|
||||
>
|
||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||
<Circle className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -82,13 +94,15 @@ export function ClubNotesList({ notes, clubId }: ClubNotesListProps) {
|
||||
{NOTE_TYPE_LABELS[noteType] ?? noteType}
|
||||
</Badge>
|
||||
{note.due_date && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Fällig: {new Date(String(note.due_date)).toLocaleDateString('de-DE')}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Fällig: {formatDate(note.due_date)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{note.content && (
|
||||
<p className="text-sm text-muted-foreground">{String(note.content)}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{String(note.content)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -96,7 +110,7 @@ export function ClubNotesList({ notes, clubId }: ClubNotesListProps) {
|
||||
size="sm"
|
||||
onClick={() => executeDelete({ noteId: String(note.id) })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<Trash2 className="text-destructive h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -104,7 +118,7 @@ export function ClubNotesList({ notes, clubId }: ClubNotesListProps) {
|
||||
|
||||
{completedNotes.length > 0 && (
|
||||
<>
|
||||
<div className="pt-2 text-sm font-medium text-muted-foreground">
|
||||
<div className="text-muted-foreground pt-2 text-sm font-medium">
|
||||
Erledigt ({completedNotes.length})
|
||||
</div>
|
||||
{completedNotes.map((note) => (
|
||||
@@ -114,14 +128,16 @@ export function ClubNotesList({ notes, clubId }: ClubNotesListProps) {
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" />
|
||||
<div className="flex-1">
|
||||
<span className="font-medium line-through">{String(note.title)}</span>
|
||||
<span className="font-medium line-through">
|
||||
{String(note.title)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => executeDelete({ noteId: String(note.id) })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<Trash2 className="text-destructive h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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 { formatNumber } from '@kit/shared/formatters';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
@@ -104,7 +106,7 @@ export function ClubsDataTable({
|
||||
<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"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
{types.map((t) => (
|
||||
@@ -140,10 +142,13 @@ export function ClubsDataTable({
|
||||
{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 Vereine vorhanden</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">
|
||||
Erstellen Sie Ihren ersten Verein, um loszulegen.
|
||||
</p>
|
||||
<Link href={`/home/${account}/verband/clubs/new`} className="mt-4">
|
||||
<Link
|
||||
href={`/home/${account}/verband/clubs/new`}
|
||||
className="mt-4"
|
||||
>
|
||||
<Button>Neuer Verein</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -151,7 +156,7 @@ export function ClubsDataTable({
|
||||
<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">Name</th>
|
||||
<th className="p-3 text-left font-medium">Typ</th>
|
||||
<th className="p-3 text-right font-medium">Mitglieder</th>
|
||||
@@ -161,13 +166,17 @@ export function ClubsDataTable({
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((club) => {
|
||||
const typeName = (club.association_types as Record<string, unknown> | null)?.name;
|
||||
const typeName = (
|
||||
club.association_types as Record<string, unknown> | null
|
||||
)?.name;
|
||||
return (
|
||||
<tr
|
||||
key={String(club.id)}
|
||||
className="cursor-pointer border-b hover:bg-muted/30"
|
||||
className="hover:bg-muted/30 cursor-pointer border-b"
|
||||
onClick={() =>
|
||||
router.push(`/home/${account}/verband/clubs/${String(club.id)}`)
|
||||
router.push(
|
||||
`/home/${account}/verband/clubs/${String(club.id)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
@@ -178,23 +187,29 @@ export function ClubsDataTable({
|
||||
{String(club.name)}
|
||||
</Link>
|
||||
{club.is_archived && (
|
||||
<Badge variant="secondary" className="ml-2">Archiviert</Badge>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
Archiviert
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{typeName ? (
|
||||
<Badge variant="secondary">{String(typeName)}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{String(typeName)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{club.member_count != null ? Number(club.member_count).toLocaleString('de-DE') : '—'}
|
||||
{formatNumber(club.member_count)}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{club.city ? `${String(club.zip ?? '')} ${String(club.city)}`.trim() : '—'}
|
||||
<td className="text-muted-foreground p-3">
|
||||
{club.city
|
||||
? `${String(club.zip ?? '')} ${String(club.city)}`.trim()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
<td className="text-muted-foreground p-3">
|
||||
{String(club.email ?? club.phone ?? '—')}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -208,7 +223,7 @@ export function ClubsDataTable({
|
||||
{/* 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">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'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 { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} 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 { CreateMemberClubSchema } from '../schema/verband.schema';
|
||||
@@ -44,8 +45,10 @@ export function CreateClubForm({
|
||||
name: (club?.name as string) ?? '',
|
||||
shortName: (club?.short_name as string) ?? '',
|
||||
associationTypeId: (club?.association_type_id as string) ?? undefined,
|
||||
memberCount: club?.member_count != null ? Number(club.member_count) : undefined,
|
||||
foundedYear: club?.founded_year != null ? Number(club.founded_year) : undefined,
|
||||
memberCount:
|
||||
club?.member_count != null ? Number(club.member_count) : undefined,
|
||||
foundedYear:
|
||||
club?.founded_year != null ? Number(club.founded_year) : undefined,
|
||||
street: (club?.street as string) ?? '',
|
||||
zip: (club?.zip as string) ?? '',
|
||||
city: (club?.city as string) ?? '',
|
||||
@@ -119,8 +122,10 @@ export function CreateClubForm({
|
||||
<select
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => field.onChange(e.target.value || undefined)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value || undefined)
|
||||
}
|
||||
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Kein Typ —</option>
|
||||
{types.map((t) => (
|
||||
@@ -148,7 +153,9 @@ export function CreateClubForm({
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value ? Number(e.target.value) : undefined)
|
||||
field.onChange(
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -169,7 +176,9 @@ export function CreateClubForm({
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value ? Number(e.target.value) : undefined)
|
||||
field.onChange(
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link2,
|
||||
Network,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
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 {
|
||||
linkChildAccount,
|
||||
unlinkChildAccount,
|
||||
} from '../server/actions/hierarchy-actions';
|
||||
import type { HierarchyNode } from '../server/api';
|
||||
|
||||
interface HierarchyTreeProps {
|
||||
accountId: string;
|
||||
tree: HierarchyNode;
|
||||
availableAccounts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
function countDescendants(node: HierarchyNode): number {
|
||||
let count = node.children.length;
|
||||
for (const child of node.children) {
|
||||
count += countDescendants(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function TreeNodeRow({
|
||||
node,
|
||||
onUnlink,
|
||||
isUnlinking,
|
||||
}: {
|
||||
node: HierarchyNode;
|
||||
onUnlink: (childId: string) => void;
|
||||
isUnlinking: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(node.depth < 2);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isRoot = node.depth === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-2 transition-colors"
|
||||
style={{ paddingLeft: `${node.depth * 24 + 8}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
|
||||
<Building2 className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
|
||||
<span className="flex-1 truncate text-sm font-medium">
|
||||
{node.name}
|
||||
{node.slug && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
/{node.slug}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{hasChildren && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{node.children.length} direkt
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isRoot ? (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Dachverband
|
||||
</Badge>
|
||||
) : node.depth === 1 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Unterverband
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Verein
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{!isRoot && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
||||
onClick={() => onUnlink(node.id)}
|
||||
disabled={isUnlinking}
|
||||
title="Verknüpfung lösen"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded &&
|
||||
hasChildren &&
|
||||
node.children.map((child) => (
|
||||
<TreeNodeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
onUnlink={onUnlink}
|
||||
isUnlinking={isUnlinking}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HierarchyTree({
|
||||
accountId,
|
||||
tree,
|
||||
availableAccounts,
|
||||
}: HierarchyTreeProps) {
|
||||
const [selectedAccountId, setSelectedAccountId] = useState('');
|
||||
|
||||
const totalDescendants = countDescendants(tree);
|
||||
|
||||
const { execute: executeLink, isPending: isLinking } = useAction(
|
||||
linkChildAccount,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Organisation erfolgreich verknüpft');
|
||||
setSelectedAccountId('');
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(
|
||||
error.serverError ?? 'Fehler beim Verknüpfen der Organisation',
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { execute: executeUnlink, isPending: isUnlinking } = useAction(
|
||||
unlinkChildAccount,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Verknüpfung gelöst');
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(
|
||||
error.serverError ?? 'Fehler beim Entfernen der Verknüpfung',
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function handleLink() {
|
||||
if (!selectedAccountId) return;
|
||||
executeLink({
|
||||
parentAccountId: accountId,
|
||||
childAccountId: selectedAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnlink(childId: string) {
|
||||
executeUnlink({
|
||||
childAccountId: childId,
|
||||
parentAccountId: accountId,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Direkte Unterverbände
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{tree.children.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Organisationen gesamt
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{totalDescendants}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Verfügbar zum Verknüpfen
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{availableAccounts.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Network className="h-4 w-4" />
|
||||
Organisationsstruktur
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border p-2">
|
||||
<TreeNodeRow
|
||||
node={tree}
|
||||
onUnlink={handleUnlink}
|
||||
isUnlinking={isUnlinking}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Link Account */}
|
||||
{availableAccounts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Link2 className="h-4 w-4" />
|
||||
Organisation hinzufügen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="link-account"
|
||||
className="text-muted-foreground mb-1 block text-sm"
|
||||
>
|
||||
Verfügbare Organisationen
|
||||
</label>
|
||||
<select
|
||||
id="link-account"
|
||||
value={selectedAccountId}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value)}
|
||||
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Organisation auswählen...</option>
|
||||
{availableAccounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{a.slug ? ` (/${a.slug})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLink}
|
||||
disabled={!selectedAccountId || isLinking}
|
||||
>
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
{isLinking ? 'Wird verknüpft...' : 'Verknüpfen'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export { CreateClubForm } from './create-club-form';
|
||||
export { ClubContactsManager } from './club-contacts-manager';
|
||||
export { ClubFeeBillingTable } from './club-fee-billing-table';
|
||||
export { ClubNotesList } from './club-notes-list';
|
||||
export { HierarchyTree } from './hierarchy-tree';
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { formatNumber, formatCurrencyAmount } from '@kit/shared/formatters';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
interface DashboardStats {
|
||||
@@ -47,10 +48,12 @@ export function VerbandDashboard({ stats, account }: VerbandDashboardProps) {
|
||||
<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">Aktive Vereine</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Aktive Vereine
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{stats.activeClubsCount}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,10 +65,14 @@ export function VerbandDashboard({ stats, account }: VerbandDashboardProps) {
|
||||
<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">Gesamtmitglieder</p>
|
||||
<p className="text-2xl font-bold">{stats.totalMembers.toLocaleString('de-DE')}</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Gesamtmitglieder
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatNumber(stats.totalMembers)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,16 +83,17 @@ export function VerbandDashboard({ stats, account }: VerbandDashboardProps) {
|
||||
<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 Beiträge</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{stats.unpaidAmount.toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
})}
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Offene Beiträge
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrencyAmount(stats.unpaidAmount)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{stats.unpaidBillingsCount} Rechnungen
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.unpaidBillingsCount} Rechnungen</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<Euro className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,10 +104,12 @@ export function VerbandDashboard({ stats, account }: VerbandDashboardProps) {
|
||||
<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.openNotesCount}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<StickyNote className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,10 +121,12 @@ export function VerbandDashboard({ stats, account }: VerbandDashboardProps) {
|
||||
<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">Vereinstypen</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Vereinstypen
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{stats.typesCount}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,10 +138,12 @@ export function VerbandDashboard({ stats, account }: VerbandDashboardProps) {
|
||||
<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">Archivierte Vereine</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Archivierte Vereine
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{stats.archivedClubsCount}</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-primary/10 p-3 text-primary">
|
||||
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||
<Archive className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,17 +161,20 @@ export function VerbandDashboard({ stats, account }: VerbandDashboardProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.clubsWithoutContact.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Alle Vereine haben mindestens einen Ansprechpartner.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{stats.clubsWithoutContact.map((club) => (
|
||||
<div key={club.id} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div
|
||||
key={club.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<span className="text-sm font-medium">{club.name}</span>
|
||||
<Link
|
||||
href={`/home/${account}/verband/clubs/${club.id}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
className="text-primary text-sm hover:underline"
|
||||
>
|
||||
Kontakt hinzufügen
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
interface VerbandTabNavigationProps {
|
||||
@@ -11,6 +12,7 @@ interface VerbandTabNavigationProps {
|
||||
const TABS = [
|
||||
{ id: 'overview', label: 'Übersicht', path: '' },
|
||||
{ id: 'clubs', label: 'Vereine', path: '/clubs' },
|
||||
{ id: 'hierarchy', label: 'Hierarchie', path: '/hierarchy' },
|
||||
{ id: 'statistics', label: 'Statistik', path: '/statistics' },
|
||||
{ id: 'settings', label: 'Einstellungen', path: '/settings' },
|
||||
] as const;
|
||||
@@ -23,7 +25,10 @@ export function VerbandTabNavigation({
|
||||
|
||||
return (
|
||||
<div className="mb-6 border-b">
|
||||
<nav className="-mb-px flex space-x-1 overflow-x-auto" aria-label="Verbandsverwaltung Navigation">
|
||||
<nav
|
||||
className="-mb-px flex space-x-1 overflow-x-auto"
|
||||
aria-label="Verbandsverwaltung Navigation"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
|
||||
@@ -32,10 +37,10 @@ export function VerbandTabNavigation({
|
||||
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}
|
||||
|
||||
@@ -29,11 +29,7 @@ export const billingStatusSchema = z.enum([
|
||||
'storniert',
|
||||
]);
|
||||
|
||||
export const noteTypeSchema = z.enum([
|
||||
'notiz',
|
||||
'aufgabe',
|
||||
'erinnerung',
|
||||
]);
|
||||
export const noteTypeSchema = z.enum(['notiz', 'aufgabe', 'erinnerung']);
|
||||
|
||||
export const contactRoleSchema = z.enum([
|
||||
'vorsitzender',
|
||||
@@ -103,9 +99,11 @@ export const CreateClubContactSchema = z.object({
|
||||
|
||||
export type CreateClubContactInput = z.infer<typeof CreateClubContactSchema>;
|
||||
|
||||
export const UpdateClubContactSchema = CreateClubContactSchema.partial().extend({
|
||||
contactId: z.string().uuid(),
|
||||
});
|
||||
export const UpdateClubContactSchema = CreateClubContactSchema.partial().extend(
|
||||
{
|
||||
contactId: z.string().uuid(),
|
||||
},
|
||||
);
|
||||
|
||||
export type UpdateClubContactInput = z.infer<typeof UpdateClubContactSchema>;
|
||||
|
||||
@@ -133,7 +131,9 @@ export const CreateAssociationTypeSchema = z.object({
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type CreateAssociationTypeInput = z.infer<typeof CreateAssociationTypeSchema>;
|
||||
export type CreateAssociationTypeInput = z.infer<
|
||||
typeof CreateAssociationTypeSchema
|
||||
>;
|
||||
|
||||
// =====================================================
|
||||
// Club Fee Types (Beitragsarten)
|
||||
@@ -165,7 +165,9 @@ export const CreateClubFeeBillingSchema = z.object({
|
||||
notes: z.string().max(1024).optional(),
|
||||
});
|
||||
|
||||
export type CreateClubFeeBillingInput = z.infer<typeof CreateClubFeeBillingSchema>;
|
||||
export type CreateClubFeeBillingInput = z.infer<
|
||||
typeof CreateClubFeeBillingSchema
|
||||
>;
|
||||
|
||||
// =====================================================
|
||||
// Club Notes (Notizen / Aufgaben)
|
||||
@@ -193,4 +195,26 @@ export const CreateAssociationHistorySchema = z.object({
|
||||
notes: z.string().max(2048).optional(),
|
||||
});
|
||||
|
||||
export type CreateAssociationHistoryInput = z.infer<typeof CreateAssociationHistorySchema>;
|
||||
export type CreateAssociationHistoryInput = z.infer<
|
||||
typeof CreateAssociationHistorySchema
|
||||
>;
|
||||
|
||||
// =====================================================
|
||||
// Account Hierarchy
|
||||
// =====================================================
|
||||
|
||||
export const SetAccountParentSchema = z.object({
|
||||
childAccountId: z.string().uuid(),
|
||||
parentAccountId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type SetAccountParentInput = z.infer<typeof SetAccountParentSchema>;
|
||||
|
||||
export const RemoveAccountParentSchema = z.object({
|
||||
childAccountId: z.string().uuid(),
|
||||
parentAccountId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type RemoveAccountParentInput = z.infer<
|
||||
typeof RemoveAccountParentSchema
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
'use server';
|
||||
|
||||
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 {
|
||||
SetAccountParentSchema,
|
||||
RemoveAccountParentSchema,
|
||||
} from '../../schema/verband.schema';
|
||||
|
||||
const REVALIDATE_PATH = '/home/[account]/verband';
|
||||
|
||||
export const linkChildAccount = authActionClient
|
||||
.inputSchema(SetAccountParentSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: 'verband.hierarchy.link',
|
||||
parentId: input.parentAccountId,
|
||||
childId: input.childAccountId,
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
'Linking child account to parent...',
|
||||
);
|
||||
|
||||
// Verify caller has a role on the parent account
|
||||
const { data: hasRole } = await client.rpc('has_role_on_account', {
|
||||
account_id: input.parentAccountId,
|
||||
});
|
||||
|
||||
if (!hasRole) {
|
||||
throw new Error('Keine Berechtigung für diese Organisation');
|
||||
}
|
||||
|
||||
// Prevent linking to self
|
||||
if (input.parentAccountId === input.childAccountId) {
|
||||
throw new Error(
|
||||
'Eine Organisation kann nicht mit sich selbst verknüpft werden',
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await client
|
||||
.from('accounts')
|
||||
.update({
|
||||
parent_account_id: input.parentAccountId,
|
||||
} as Record<string, unknown>)
|
||||
.eq('id', input.childAccountId)
|
||||
.eq('is_personal_account', false);
|
||||
|
||||
if (error) {
|
||||
logger.error({ error }, 'Failed to link child account');
|
||||
throw new Error('Fehler beim Verknüpfen der Organisation');
|
||||
}
|
||||
|
||||
logger.info({ name: 'verband.hierarchy.link' }, 'Child account linked');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const unlinkChildAccount = authActionClient
|
||||
.inputSchema(RemoveAccountParentSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: 'verband.hierarchy.unlink',
|
||||
childId: input.childAccountId,
|
||||
parentId: input.parentAccountId,
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
'Unlinking child account from parent...',
|
||||
);
|
||||
|
||||
// Verify caller has a role on the parent account
|
||||
const { data: hasRole } = await client.rpc('has_role_on_account', {
|
||||
account_id: input.parentAccountId,
|
||||
});
|
||||
|
||||
if (!hasRole) {
|
||||
throw new Error('Keine Berechtigung für diese Organisation');
|
||||
}
|
||||
|
||||
// Verify the child actually belongs to the specified parent
|
||||
const { data: childAcct } = await client
|
||||
.from('accounts')
|
||||
.select('id, parent_account_id' as '*')
|
||||
.eq('id', input.childAccountId)
|
||||
.single();
|
||||
|
||||
if (!childAcct) {
|
||||
throw new Error('Organisation nicht gefunden');
|
||||
}
|
||||
|
||||
const child = childAcct as unknown as {
|
||||
id: string;
|
||||
parent_account_id: string | null;
|
||||
};
|
||||
|
||||
if (child.parent_account_id !== input.parentAccountId) {
|
||||
throw new Error(
|
||||
'Die Organisation ist kein direktes Kind der angegebenen Elternorganisation',
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await client
|
||||
.from('accounts')
|
||||
.update({ parent_account_id: null } as Record<string, unknown>)
|
||||
.eq('id', input.childAccountId);
|
||||
|
||||
if (error) {
|
||||
logger.error({ error }, 'Failed to unlink child account');
|
||||
throw new Error('Fehler beim Entfernen der Verknüpfung');
|
||||
}
|
||||
|
||||
logger.info({ name: 'verband.hierarchy.unlink' }, 'Child account unlinked');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true };
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { todayISO } from '@kit/shared/dates';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
@@ -18,7 +21,6 @@ import {
|
||||
CreateClubNoteSchema,
|
||||
CreateAssociationHistorySchema,
|
||||
} from '../../schema/verband.schema';
|
||||
|
||||
import { createVerbandApi } from '../api';
|
||||
|
||||
const REVALIDATE_PATH = '/home/[account]/verband';
|
||||
@@ -195,7 +197,10 @@ export const createAssociationType = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.type.create' }, 'Creating association type...');
|
||||
logger.info(
|
||||
{ name: 'verband.type.create' },
|
||||
'Creating association type...',
|
||||
);
|
||||
const result = await api.createType(input);
|
||||
logger.info({ name: 'verband.type.create' }, 'Association type created');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
@@ -217,7 +222,10 @@ export const updateAssociationType = authActionClient
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
const { typeId, ...updates } = input;
|
||||
logger.info({ name: 'verband.type.update' }, 'Updating association type...');
|
||||
logger.info(
|
||||
{ name: 'verband.type.update' },
|
||||
'Updating association type...',
|
||||
);
|
||||
const result = await api.updateType(typeId, updates);
|
||||
logger.info({ name: 'verband.type.update' }, 'Association type updated');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
@@ -235,7 +243,10 @@ export const deleteAssociationType = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.type.delete' }, 'Deleting association type...');
|
||||
logger.info(
|
||||
{ name: 'verband.type.delete' },
|
||||
'Deleting association type...',
|
||||
);
|
||||
await api.deleteType(input.typeId);
|
||||
logger.info({ name: 'verband.type.delete' }, 'Association type deleted');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
@@ -369,11 +380,19 @@ export const markBillingPaid = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.billing.markPaid' }, 'Marking billing as paid...');
|
||||
logger.info(
|
||||
{ name: 'verband.billing.markPaid' },
|
||||
'Marking billing as paid...',
|
||||
);
|
||||
const result = await api.updateFeeBilling(input.billingId, {
|
||||
status: 'bezahlt',
|
||||
paidDate: input.paidDate ?? new Date().toISOString().split('T')[0],
|
||||
paymentMethod: input.paymentMethod as 'bar' | 'lastschrift' | 'ueberweisung' | 'paypal' | undefined,
|
||||
paidDate: input.paidDate ?? todayISO(),
|
||||
paymentMethod: input.paymentMethod as
|
||||
| 'bar'
|
||||
| 'lastschrift'
|
||||
| 'ueberweisung'
|
||||
| 'paypal'
|
||||
| undefined,
|
||||
});
|
||||
logger.info({ name: 'verband.billing.markPaid' }, 'Billing marked as paid');
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
@@ -464,9 +483,15 @@ export const upsertAssociationHistory = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createVerbandApi(client);
|
||||
|
||||
logger.info({ name: 'verband.history.upsert' }, 'Upserting association history...');
|
||||
logger.info(
|
||||
{ name: 'verband.history.upsert' },
|
||||
'Upserting association history...',
|
||||
);
|
||||
const result = await api.upsertHistory(input);
|
||||
logger.info({ name: 'verband.history.upsert' }, 'Association history upserted');
|
||||
logger.info(
|
||||
{ name: 'verband.history.upsert' },
|
||||
'Association history upserted',
|
||||
);
|
||||
revalidatePath(REVALIDATE_PATH, 'page');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type {
|
||||
CreateMemberClubInput,
|
||||
UpdateMemberClubInput,
|
||||
@@ -14,6 +16,23 @@ import type {
|
||||
CreateAssociationHistoryInput,
|
||||
} from '../schema/verband.schema';
|
||||
|
||||
// Hierarchy types — will be auto-typed once `pnpm supabase:web:typegen` regenerates
|
||||
// after the 20260414000001_account_hierarchy migration
|
||||
export interface HierarchyAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
email: string | null;
|
||||
is_personal_account: boolean;
|
||||
parent_account_id: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface HierarchyNode extends HierarchyAccount {
|
||||
children: HierarchyNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for the Verbandsverwaltung (Association Management) API.
|
||||
*/
|
||||
@@ -110,10 +129,14 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
const updateData: Record<string, unknown> = { updated_by: userId };
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.shortName !== undefined) updateData.short_name = input.shortName;
|
||||
if (input.associationTypeId !== undefined) updateData.association_type_id = input.associationTypeId;
|
||||
if (input.memberCount !== undefined) updateData.member_count = input.memberCount;
|
||||
if (input.foundedYear !== undefined) updateData.founded_year = input.foundedYear;
|
||||
if (input.shortName !== undefined)
|
||||
updateData.short_name = input.shortName;
|
||||
if (input.associationTypeId !== undefined)
|
||||
updateData.association_type_id = input.associationTypeId;
|
||||
if (input.memberCount !== undefined)
|
||||
updateData.member_count = input.memberCount;
|
||||
if (input.foundedYear !== undefined)
|
||||
updateData.founded_year = input.foundedYear;
|
||||
if (input.street !== undefined) updateData.street = input.street;
|
||||
if (input.zip !== undefined) updateData.zip = input.zip;
|
||||
if (input.city !== undefined) updateData.city = input.city;
|
||||
@@ -122,8 +145,10 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
if (input.website !== undefined) updateData.website = input.website;
|
||||
if (input.iban !== undefined) updateData.iban = input.iban;
|
||||
if (input.bic !== undefined) updateData.bic = input.bic;
|
||||
if (input.accountHolder !== undefined) updateData.account_holder = input.accountHolder;
|
||||
if (input.isArchived !== undefined) updateData.is_archived = input.isArchived;
|
||||
if (input.accountHolder !== undefined)
|
||||
updateData.account_holder = input.accountHolder;
|
||||
if (input.isArchived !== undefined)
|
||||
updateData.is_archived = input.isArchived;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('member_clubs')
|
||||
@@ -144,33 +169,48 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
},
|
||||
|
||||
async getClubDetail(clubId: string) {
|
||||
const [clubResult, contactsResult, billingsResult, notesResult, historyResult] =
|
||||
await Promise.all([
|
||||
client.from('member_clubs').select('*, association_types ( id, name )').eq('id', clubId).single(),
|
||||
client
|
||||
.from('club_contacts')
|
||||
.select('id, club_id, first_name, last_name, role, phone, email, is_primary, created_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('is_primary', { ascending: false })
|
||||
.order('last_name'),
|
||||
client
|
||||
.from('club_fee_billings')
|
||||
.select('id, club_id, fee_type_id, year, amount, due_date, paid_date, payment_method, status, notes, created_at, club_fee_types ( id, name )')
|
||||
.eq('club_id', clubId)
|
||||
.order('year', { ascending: false })
|
||||
.limit(50),
|
||||
client
|
||||
.from('club_notes')
|
||||
.select('id, club_id, title, content, note_type, due_date, is_completed, created_at, updated_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50),
|
||||
client
|
||||
.from('association_history')
|
||||
.select('id, club_id, year, member_count, notes, created_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('year', { ascending: false }),
|
||||
]);
|
||||
const [
|
||||
clubResult,
|
||||
contactsResult,
|
||||
billingsResult,
|
||||
notesResult,
|
||||
historyResult,
|
||||
] = await Promise.all([
|
||||
client
|
||||
.from('member_clubs')
|
||||
.select('*, association_types ( id, name )')
|
||||
.eq('id', clubId)
|
||||
.single(),
|
||||
client
|
||||
.from('club_contacts')
|
||||
.select(
|
||||
'id, club_id, first_name, last_name, role, phone, email, is_primary, created_at',
|
||||
)
|
||||
.eq('club_id', clubId)
|
||||
.order('is_primary', { ascending: false })
|
||||
.order('last_name'),
|
||||
client
|
||||
.from('club_fee_billings')
|
||||
.select(
|
||||
'id, club_id, fee_type_id, year, amount, due_date, paid_date, payment_method, status, notes, created_at, club_fee_types ( id, name )',
|
||||
)
|
||||
.eq('club_id', clubId)
|
||||
.order('year', { ascending: false })
|
||||
.limit(50),
|
||||
client
|
||||
.from('club_notes')
|
||||
.select(
|
||||
'id, club_id, title, content, note_type, due_date, is_completed, created_at, updated_at',
|
||||
)
|
||||
.eq('club_id', clubId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50),
|
||||
client
|
||||
.from('association_history')
|
||||
.select('id, club_id, year, member_count, notes, created_at')
|
||||
.eq('club_id', clubId)
|
||||
.order('year', { ascending: false }),
|
||||
]);
|
||||
|
||||
if (clubResult.error) throw clubResult.error;
|
||||
|
||||
@@ -190,7 +230,9 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
async listContacts(clubId: string) {
|
||||
const { data, error } = await client
|
||||
.from('club_contacts')
|
||||
.select('id, club_id, first_name, last_name, role, phone, email, is_primary, created_at')
|
||||
.select(
|
||||
'id, club_id, first_name, last_name, role, phone, email, is_primary, created_at',
|
||||
)
|
||||
.eq('club_id', clubId)
|
||||
.order('is_primary', { ascending: false })
|
||||
.order('last_name');
|
||||
@@ -219,12 +261,14 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
async updateContact(input: UpdateClubContactInput) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (input.firstName !== undefined) updateData.first_name = input.firstName;
|
||||
if (input.firstName !== undefined)
|
||||
updateData.first_name = input.firstName;
|
||||
if (input.lastName !== undefined) updateData.last_name = input.lastName;
|
||||
if (input.role !== undefined) updateData.role = input.role;
|
||||
if (input.phone !== undefined) updateData.phone = input.phone;
|
||||
if (input.email !== undefined) updateData.email = input.email;
|
||||
if (input.isPrimary !== undefined) updateData.is_primary = input.isPrimary;
|
||||
if (input.isPrimary !== undefined)
|
||||
updateData.is_primary = input.isPrimary;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('club_contacts')
|
||||
@@ -274,11 +318,16 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateRole(roleId: string, updates: { name?: string; description?: string; sortOrder?: number }) {
|
||||
async updateRole(
|
||||
roleId: string,
|
||||
updates: { name?: string; description?: string; sortOrder?: number },
|
||||
) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (updates.name !== undefined) updateData.name = updates.name;
|
||||
if (updates.description !== undefined) updateData.description = updates.description;
|
||||
if (updates.sortOrder !== undefined) updateData.sort_order = updates.sortOrder;
|
||||
if (updates.description !== undefined)
|
||||
updateData.description = updates.description;
|
||||
if (updates.sortOrder !== undefined)
|
||||
updateData.sort_order = updates.sortOrder;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('club_roles')
|
||||
@@ -328,11 +377,16 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateType(typeId: string, updates: { name?: string; description?: string; sortOrder?: number }) {
|
||||
async updateType(
|
||||
typeId: string,
|
||||
updates: { name?: string; description?: string; sortOrder?: number },
|
||||
) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (updates.name !== undefined) updateData.name = updates.name;
|
||||
if (updates.description !== undefined) updateData.description = updates.description;
|
||||
if (updates.sortOrder !== undefined) updateData.sort_order = updates.sortOrder;
|
||||
if (updates.description !== undefined)
|
||||
updateData.description = updates.description;
|
||||
if (updates.sortOrder !== undefined)
|
||||
updateData.sort_order = updates.sortOrder;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('association_types')
|
||||
@@ -383,12 +437,23 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateFeeType(feeTypeId: string, updates: { name?: string; description?: string; defaultAmount?: number; isActive?: boolean }) {
|
||||
async updateFeeType(
|
||||
feeTypeId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
defaultAmount?: number;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (updates.name !== undefined) updateData.name = updates.name;
|
||||
if (updates.description !== undefined) updateData.description = updates.description;
|
||||
if (updates.defaultAmount !== undefined) updateData.default_amount = updates.defaultAmount;
|
||||
if (updates.isActive !== undefined) updateData.is_active = updates.isActive;
|
||||
if (updates.description !== undefined)
|
||||
updateData.description = updates.description;
|
||||
if (updates.defaultAmount !== undefined)
|
||||
updateData.default_amount = updates.defaultAmount;
|
||||
if (updates.isActive !== undefined)
|
||||
updateData.is_active = updates.isActive;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('club_fee_types')
|
||||
@@ -414,7 +479,12 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
|
||||
async listFeeBillings(
|
||||
clubId: string,
|
||||
opts?: { year?: number; status?: string; page?: number; pageSize?: number },
|
||||
opts?: {
|
||||
year?: number;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
let query = client
|
||||
.from('club_fee_billings')
|
||||
@@ -474,15 +544,21 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateFeeBilling(billingId: string, updates: Partial<CreateClubFeeBillingInput>) {
|
||||
async updateFeeBilling(
|
||||
billingId: string,
|
||||
updates: Partial<CreateClubFeeBillingInput>,
|
||||
) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (updates.feeTypeId !== undefined) updateData.fee_type_id = updates.feeTypeId;
|
||||
if (updates.feeTypeId !== undefined)
|
||||
updateData.fee_type_id = updates.feeTypeId;
|
||||
if (updates.year !== undefined) updateData.year = updates.year;
|
||||
if (updates.amount !== undefined) updateData.amount = updates.amount;
|
||||
if (updates.dueDate !== undefined) updateData.due_date = updates.dueDate;
|
||||
if (updates.paidDate !== undefined) updateData.paid_date = updates.paidDate;
|
||||
if (updates.paymentMethod !== undefined) updateData.payment_method = updates.paymentMethod;
|
||||
if (updates.paidDate !== undefined)
|
||||
updateData.paid_date = updates.paidDate;
|
||||
if (updates.paymentMethod !== undefined)
|
||||
updateData.payment_method = updates.paymentMethod;
|
||||
if (updates.status !== undefined) updateData.status = updates.status;
|
||||
if (updates.notes !== undefined) updateData.notes = updates.notes;
|
||||
|
||||
@@ -511,7 +587,9 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
async listNotes(clubId: string) {
|
||||
const { data, error } = await client
|
||||
.from('club_notes')
|
||||
.select('id, club_id, title, content, note_type, due_date, is_completed, created_at, updated_at')
|
||||
.select(
|
||||
'id, club_id, title, content, note_type, due_date, is_completed, created_at, updated_at',
|
||||
)
|
||||
.eq('club_id', clubId)
|
||||
.order('is_completed')
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -541,9 +619,11 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
|
||||
if (updates.title !== undefined) updateData.title = updates.title;
|
||||
if (updates.content !== undefined) updateData.content = updates.content;
|
||||
if (updates.noteType !== undefined) updateData.note_type = updates.noteType;
|
||||
if (updates.noteType !== undefined)
|
||||
updateData.note_type = updates.noteType;
|
||||
if (updates.dueDate !== undefined) updateData.due_date = updates.dueDate;
|
||||
if (updates.isCompleted !== undefined) updateData.is_completed = updates.isCompleted;
|
||||
if (updates.isCompleted !== undefined)
|
||||
updateData.is_completed = updates.isCompleted;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('club_notes')
|
||||
@@ -654,7 +734,8 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
);
|
||||
|
||||
const unpaidAmount = (unpaidResult.data ?? []).reduce(
|
||||
(sum, b) => sum + ((b as Record<string, unknown>).amount as number ?? 0),
|
||||
(sum, b) =>
|
||||
sum + (((b as Record<string, unknown>).amount as number) ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -678,5 +759,181 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
||||
clubsWithoutContact: clubsWithoutContact ?? [],
|
||||
};
|
||||
},
|
||||
|
||||
// =====================================================
|
||||
// Account Hierarchy
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Get direct child accounts of the given account.
|
||||
* Uses raw select since parent_account_id is not yet in generated types.
|
||||
*/
|
||||
async getChildAccounts(accountId: string): Promise<HierarchyAccount[]> {
|
||||
const { data, error } = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||
)
|
||||
.eq('parent_account_id' as string, accountId)
|
||||
.eq('is_personal_account', false)
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
return (data ?? []) as unknown as HierarchyAccount[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the parent account (if any) of the given account.
|
||||
*/
|
||||
async getParentAccount(
|
||||
accountId: string,
|
||||
): Promise<HierarchyAccount | null> {
|
||||
const { data: acct, error: acctError } = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||
)
|
||||
.eq('id', accountId)
|
||||
.single();
|
||||
if (acctError) throw acctError;
|
||||
|
||||
const account = acct as unknown as HierarchyAccount;
|
||||
if (!account.parent_account_id) return null;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||
)
|
||||
.eq('id', account.parent_account_id)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data as unknown as HierarchyAccount;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build the full hierarchy tree starting from root account.
|
||||
*/
|
||||
async getHierarchyTree(rootAccountId: string): Promise<HierarchyNode> {
|
||||
// Get all descendant IDs using the recursive CTE
|
||||
const { data: descendantIds, error: rpcError } = await client.rpc(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
'get_account_descendants' as any,
|
||||
{ root_id: rootAccountId },
|
||||
);
|
||||
if (rpcError) throw rpcError;
|
||||
|
||||
const ids = (descendantIds as unknown as string[]) ?? [];
|
||||
if (ids.length === 0) {
|
||||
const { data: root, error } = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||
)
|
||||
.eq('id', rootAccountId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
const rootAcct = root as unknown as HierarchyAccount;
|
||||
return { ...rootAcct, children: [], depth: 0 };
|
||||
}
|
||||
|
||||
// Fetch all accounts in the tree
|
||||
const { data: allAccounts, error } = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||
)
|
||||
.in('id', ids)
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
|
||||
const accounts = (allAccounts ?? []) as unknown as HierarchyAccount[];
|
||||
|
||||
// Build tree from flat list
|
||||
const accountMap = new Map<string, HierarchyNode>();
|
||||
for (const acct of accounts) {
|
||||
accountMap.set(acct.id, { ...acct, children: [], depth: 0 });
|
||||
}
|
||||
|
||||
let root: HierarchyNode | undefined;
|
||||
for (const node of accountMap.values()) {
|
||||
if (node.id === rootAccountId) {
|
||||
root = node;
|
||||
} else if (node.parent_account_id) {
|
||||
const parent = accountMap.get(node.parent_account_id);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate depths
|
||||
function setDepths(node: HierarchyNode, depth: number) {
|
||||
node.depth = depth;
|
||||
for (const child of node.children) {
|
||||
setDepths(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (root) {
|
||||
setDepths(root, 0);
|
||||
return root;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
const { data: fallback, error: fbErr } = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||
)
|
||||
.eq('id', rootAccountId)
|
||||
.single();
|
||||
if (fbErr) throw fbErr;
|
||||
return {
|
||||
...(fallback as unknown as HierarchyAccount),
|
||||
children: [],
|
||||
depth: 0,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Search for team accounts that can be added as children.
|
||||
* Excludes personal accounts, the current hierarchy, and accounts that already have a parent.
|
||||
*/
|
||||
async searchAvailableAccounts(
|
||||
accountId: string,
|
||||
search?: string,
|
||||
): Promise<HierarchyAccount[]> {
|
||||
// Get all accounts already in this hierarchy
|
||||
const { data: descendantIds } = await client.rpc(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
'get_account_descendants' as any,
|
||||
{ root_id: accountId },
|
||||
);
|
||||
const rawIds = (descendantIds as unknown as string[]) ?? [accountId];
|
||||
|
||||
// Validate all IDs are UUIDs to prevent injection via .not() filter
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const excludeIds = rawIds.filter((id) => UUID_RE.test(id));
|
||||
|
||||
let query = client
|
||||
.from('accounts')
|
||||
.select(
|
||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||
)
|
||||
.eq('is_personal_account', false)
|
||||
.is('parent_account_id' as string, null)
|
||||
.not('id', 'in', `(${excludeIds.join(',')})`)
|
||||
.order('name')
|
||||
.limit(20);
|
||||
|
||||
if (search) {
|
||||
query = query.or(`name.ilike.%${search}%,slug.ilike.%${search}%`);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return (data ?? []) as unknown as HierarchyAccount[];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user