Add account hierarchy framework with migrations, RLS policies, and UI components

This commit is contained in:
T. Zehetbauer
2026-03-31 22:18:04 +02:00
parent 7e7da0b465
commit 59546ad6d2
262 changed files with 11671 additions and 3927 deletions

View File

@@ -7,8 +7,8 @@ import {
ClubNotesList,
} from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string; clubId: string }>;
@@ -41,9 +41,11 @@ export default async function ClubDetailPage({ params }: Props) {
{detail.club.short_name && (
<p className="text-muted-foreground">{detail.club.short_name}</p>
)}
<div className="mt-2 flex flex-wrap gap-4 text-sm text-muted-foreground">
<div className="text-muted-foreground mt-2 flex flex-wrap gap-4 text-sm">
{detail.club.city && (
<span>{detail.club.zip} {detail.club.city}</span>
<span>
{detail.club.zip} {detail.club.city}
</span>
)}
{detail.club.member_count != null && (
<span>{detail.club.member_count} Mitglieder</span>

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import { VerbandTabNavigation, CreateClubForm } from '@kit/verbandsverwaltung/components';
import {
VerbandTabNavigation,
CreateClubForm,
} from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import { VerbandTabNavigation, ClubsDataTable } from '@kit/verbandsverwaltung/components';
import {
VerbandTabNavigation,
ClubsDataTable,
} from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;

View File

@@ -0,0 +1,48 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import {
VerbandTabNavigation,
HierarchyTree,
} from '@kit/verbandsverwaltung/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
}
export default async function HierarchyPage({ params }: PageProps) {
const { account } = await params;
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
const api = createVerbandApi(client);
const [tree, availableAccounts] = await Promise.all([
api.getHierarchyTree(acct.id),
api.searchAvailableAccounts(acct.id),
]);
return (
<CmsPageShell
account={account}
title="Verbandsverwaltung - Hierarchie"
description="Verwalten Sie die Organisationsstruktur Ihres Verbands"
>
<VerbandTabNavigation account={account} activeTab="hierarchy" />
<HierarchyTree
accountId={acct.id}
tree={tree}
availableAccounts={availableAccounts}
/>
</CmsPageShell>
);
}

View File

@@ -1,9 +1,12 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import { VerbandTabNavigation, VerbandDashboard } from '@kit/verbandsverwaltung/components';
import {
VerbandTabNavigation,
VerbandDashboard,
} from '@kit/verbandsverwaltung/components';
import { CmsPageShell } from '~/components/cms-page-shell';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;

View File

@@ -1,15 +1,14 @@
'use client';
import { useState } from 'react';
import { useAction } from 'next-safe-action/hooks';
import { Plus, Pencil, Trash2, Settings } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import {
createRole,
updateRole,
@@ -93,18 +92,27 @@ function SettingsSection({
>
Erstellen
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAdd(false)}>
<Button
size="sm"
variant="outline"
onClick={() => setShowAdd(false)}
>
Abbrechen
</Button>
</div>
)}
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Einträge vorhanden.</p>
<p className="text-muted-foreground text-sm">
Keine Einträge vorhanden.
</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<div key={String(item.id)} className="flex items-center justify-between rounded-lg border p-3">
<div
key={String(item.id)}
className="flex items-center justify-between rounded-lg border p-3"
>
{editingId === String(item.id) ? (
<div className="flex flex-1 gap-2">
<Input
@@ -122,7 +130,11 @@ function SettingsSection({
>
Speichern
</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>
<Button
size="sm"
variant="outline"
onClick={() => setEditingId(null)}
>
Abbrechen
</Button>
</div>
@@ -131,7 +143,9 @@ function SettingsSection({
<div>
<span className="font-medium">{String(item.name)}</span>
{item.description && (
<p className="text-xs text-muted-foreground">{String(item.description)}</p>
<p className="text-muted-foreground text-xs">
{String(item.description)}
</p>
)}
</div>
<div className="flex gap-1">
@@ -150,7 +164,7 @@ function SettingsSection({
size="sm"
onClick={() => onDelete(String(item.id))}
>
<Trash2 className="h-4 w-4 text-destructive" />
<Trash2 className="text-destructive h-4 w-4" />
</Button>
</div>
</>
@@ -171,42 +185,60 @@ export default function SettingsContent({
feeTypes,
}: SettingsContentProps) {
// Roles
const { execute: execCreateRole, isPending: isCreatingRole } = useAction(createRole, {
onSuccess: () => toast.success('Funktion erstellt'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execUpdateRole, isPending: isUpdatingRole } = useAction(updateRole, {
onSuccess: () => toast.success('Funktion aktualisiert'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execCreateRole, isPending: isCreatingRole } = useAction(
createRole,
{
onSuccess: () => toast.success('Funktion erstellt'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
},
);
const { execute: execUpdateRole, isPending: isUpdatingRole } = useAction(
updateRole,
{
onSuccess: () => toast.success('Funktion aktualisiert'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
},
);
const { execute: execDeleteRole } = useAction(deleteRole, {
onSuccess: () => toast.success('Funktion gelöscht'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
// Types
const { execute: execCreateType, isPending: isCreatingType } = useAction(createAssociationType, {
onSuccess: () => toast.success('Vereinstyp erstellt'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execUpdateType, isPending: isUpdatingType } = useAction(updateAssociationType, {
onSuccess: () => toast.success('Vereinstyp aktualisiert'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execCreateType, isPending: isCreatingType } = useAction(
createAssociationType,
{
onSuccess: () => toast.success('Vereinstyp erstellt'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
},
);
const { execute: execUpdateType, isPending: isUpdatingType } = useAction(
updateAssociationType,
{
onSuccess: () => toast.success('Vereinstyp aktualisiert'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
},
);
const { execute: execDeleteType } = useAction(deleteAssociationType, {
onSuccess: () => toast.success('Vereinstyp gelöscht'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
// Fee Types
const { execute: execCreateFeeType, isPending: isCreatingFee } = useAction(createFeeType, {
onSuccess: () => toast.success('Beitragsart erstellt'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execUpdateFeeType, isPending: isUpdatingFee } = useAction(updateFeeType, {
onSuccess: () => toast.success('Beitragsart aktualisiert'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
const { execute: execCreateFeeType, isPending: isCreatingFee } = useAction(
createFeeType,
{
onSuccess: () => toast.success('Beitragsart erstellt'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
},
);
const { execute: execUpdateFeeType, isPending: isUpdatingFee } = useAction(
updateFeeType,
{
onSuccess: () => toast.success('Beitragsart aktualisiert'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
},
);
const { execute: execDeleteFeeType } = useAction(deleteFeeType, {
onSuccess: () => toast.success('Beitragsart gelöscht'),
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
@@ -224,7 +256,9 @@ export default function SettingsContent({
<SettingsSection
title="Funktionen (Rollen)"
items={roles}
onAdd={(name, description) => execCreateRole({ accountId, name, description, sortOrder: 0 })}
onAdd={(name, description) =>
execCreateRole({ accountId, name, description, sortOrder: 0 })
}
onUpdate={(id, name) => execUpdateRole({ roleId: id, name })}
onDelete={(id) => execDeleteRole({ roleId: id })}
isAdding={isCreatingRole}
@@ -234,7 +268,9 @@ export default function SettingsContent({
<SettingsSection
title="Vereinstypen"
items={types}
onAdd={(name, description) => execCreateType({ accountId, name, description, sortOrder: 0 })}
onAdd={(name, description) =>
execCreateType({ accountId, name, description, sortOrder: 0 })
}
onUpdate={(id, name) => execUpdateType({ typeId: id, name })}
onDelete={(id) => execDeleteType({ typeId: id })}
isAdding={isCreatingType}
@@ -244,7 +280,9 @@ export default function SettingsContent({
<SettingsSection
title="Beitragsarten"
items={feeTypes}
onAdd={(name, description) => execCreateFeeType({ accountId, name, description, isActive: true })}
onAdd={(name, description) =>
execCreateFeeType({ accountId, name, description, isActive: true })
}
onUpdate={(id, name) => execUpdateFeeType({ feeTypeId: id, name })}
onDelete={(id) => execDeleteFeeType({ feeTypeId: id })}
isAdding={isCreatingFee}

View File

@@ -2,10 +2,10 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import SettingsContent from './_components/settings-content';
import { AccountNotFound } from '~/components/account-not-found';
interface Props {
params: Promise<{ account: string }>;

View File

@@ -1,6 +1,5 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { BarChart3 } from 'lucide-react';
import {
AreaChart,
@@ -12,6 +11,8 @@ import {
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
const PLACEHOLDER_DATA = [
{ year: '2020', vereine: 12, mitglieder: 850 },
{ year: '2021', vereine: 14, mitglieder: 920 },
@@ -89,9 +90,10 @@ export default function StatisticsContent() {
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">
Die Statistiken werden automatisch aus den Vereinsdaten und der Verbandshistorie berechnet.
Pflegen Sie die Mitgliederzahlen in den einzelnen Vereinsdetails, um aktuelle Auswertungen zu erhalten.
<p className="text-muted-foreground text-sm">
Die Statistiken werden automatisch aus den Vereinsdaten und der
Verbandshistorie berechnet. Pflegen Sie die Mitgliederzahlen in den
einzelnen Vereinsdetails, um aktuelle Auswertungen zu erhalten.
</p>
</CardContent>
</Card>