feat: add cross-organization member search and template cloning functionality
This commit is contained in:
@@ -27,7 +27,16 @@ export default async function ClubDetailPage({ params }: Props) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createVerbandApi(client);
|
const api = createVerbandApi(client);
|
||||||
const detail = await api.getClubDetail(clubId);
|
|
||||||
|
let detail: Awaited<ReturnType<typeof api.getClubDetail>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
detail = await api.getClubDetail(clubId);
|
||||||
|
} catch {
|
||||||
|
return <AccountNotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail?.club) return <AccountNotFound />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={`Verein – ${detail.club.name}`}>
|
<CmsPageShell account={account} title={`Verein – ${detail.club.name}`}>
|
||||||
|
|||||||
65
apps/web/app/[locale]/home/[account]/verband/events/page.tsx
Normal file
65
apps/web/app/[locale]/home/[account]/verband/events/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
|
||||||
|
import {
|
||||||
|
VerbandTabNavigation,
|
||||||
|
HierarchyEvents,
|
||||||
|
} from '@kit/verbandsverwaltung/components';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
searchParams: Promise<{
|
||||||
|
status?: string;
|
||||||
|
sharedOnly?: string;
|
||||||
|
fromDate?: string;
|
||||||
|
page?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HierarchyEventsPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps) {
|
||||||
|
const { account } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
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 page = Math.max(1, Number(sp.page) || 1);
|
||||||
|
const pageSize = 25;
|
||||||
|
|
||||||
|
const result = await api.listHierarchyEvents(acct.id, {
|
||||||
|
status: sp.status,
|
||||||
|
sharedOnly: sp.sharedOnly === 'true',
|
||||||
|
fromDate: sp.fromDate,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title="Verbandsverwaltung - Veranstaltungen"
|
||||||
|
description="Veranstaltungen aller verknüpften Organisationen anzeigen und filtern"
|
||||||
|
>
|
||||||
|
<VerbandTabNavigation account={account} activeTab="events" />
|
||||||
|
<HierarchyEvents
|
||||||
|
account={account}
|
||||||
|
events={result.data}
|
||||||
|
total={result.total}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
/>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
|
||||||
|
import {
|
||||||
|
VerbandTabNavigation,
|
||||||
|
CrossOrgMemberSearch,
|
||||||
|
} from '@kit/verbandsverwaltung/components';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
searchParams: Promise<{
|
||||||
|
q?: string;
|
||||||
|
status?: string;
|
||||||
|
accountId?: string;
|
||||||
|
page?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CrossOrgMembersPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps) {
|
||||||
|
const { account } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
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 page = Math.max(1, Number(sp.page) || 1);
|
||||||
|
const pageSize = 25;
|
||||||
|
|
||||||
|
const [result, hierarchy] = await Promise.all([
|
||||||
|
api.searchMembersAcrossHierarchy(acct.id, {
|
||||||
|
search: sp.q,
|
||||||
|
status: sp.status,
|
||||||
|
accountId: sp.accountId,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
api.getHierarchyTree(acct.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build flat list of child accounts for the filter dropdown
|
||||||
|
const childAccounts: Array<{ id: string; name: string }> = [];
|
||||||
|
|
||||||
|
function collectChildren(node: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
children?: unknown[];
|
||||||
|
}) {
|
||||||
|
if (node.id !== acct!.id) {
|
||||||
|
childAccounts.push({ id: node.id, name: node.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
collectChildren(
|
||||||
|
child as { id: string; name: string; children?: unknown[] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collectChildren(hierarchy);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title="Verbandsverwaltung - Mitgliedersuche"
|
||||||
|
description="Suchen Sie Mitglieder in allen verknüpften Organisationen"
|
||||||
|
>
|
||||||
|
<VerbandTabNavigation account={account} activeTab="members" />
|
||||||
|
<CrossOrgMemberSearch
|
||||||
|
account={account}
|
||||||
|
members={result.data}
|
||||||
|
total={result.total}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
childAccounts={childAccounts}
|
||||||
|
/>
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
|
||||||
|
import {
|
||||||
|
VerbandTabNavigation,
|
||||||
|
HierarchyReport,
|
||||||
|
} 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 ReportingPage({ 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 [summary, report] = await Promise.all([
|
||||||
|
api.getHierarchySummary(acct.id),
|
||||||
|
api.getHierarchyReport(acct.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title="Verbandsverwaltung - Berichte"
|
||||||
|
description="Aggregierte Berichte und Kennzahlen aller Organisationen im Verband"
|
||||||
|
>
|
||||||
|
<VerbandTabNavigation account={account} activeTab="reporting" />
|
||||||
|
<HierarchyReport summary={summary} report={report} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
import { createVerbandApi } from '@kit/verbandsverwaltung/api';
|
||||||
|
import {
|
||||||
|
VerbandTabNavigation,
|
||||||
|
SharedTemplates,
|
||||||
|
} 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 TemplatesPage({ 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 templates = await api.listSharedTemplates(acct.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title="Verbandsverwaltung - Vorlagen"
|
||||||
|
description="Geteilte Vorlagen aus der Verbandshierarchie klonen und verwenden"
|
||||||
|
>
|
||||||
|
<VerbandTabNavigation account={account} activeTab="templates" />
|
||||||
|
<SharedTemplates accountId={acct.id} templates={templates} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
306
apps/web/i18n/messages/de/verband.json
Normal file
306
apps/web/i18n/messages/de/verband.json
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"clubs": "Vereine",
|
||||||
|
"hierarchy": "Hierarchie",
|
||||||
|
"memberSearch": "Mitgliedersuche",
|
||||||
|
"events": "Veranstaltungen",
|
||||||
|
"reporting": "Berichte",
|
||||||
|
"templates": "Vorlagen",
|
||||||
|
"statistics": "Statistik",
|
||||||
|
"settings": "Einstellungen"
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"overviewTitle": "Verbandsverwaltung",
|
||||||
|
"clubsTitle": "Verbandsverwaltung - Vereine",
|
||||||
|
"hierarchyTitle": "Verbandsverwaltung - Hierarchie",
|
||||||
|
"hierarchyDescription": "Verwalten Sie die Organisationsstruktur Ihres Verbands",
|
||||||
|
"memberSearchTitle": "Verbandsverwaltung - Mitgliedersuche",
|
||||||
|
"memberSearchDescription": "Suchen Sie Mitglieder in allen verknüpften Organisationen",
|
||||||
|
"eventsTitle": "Verbandsverwaltung - Veranstaltungen",
|
||||||
|
"eventsDescription": "Veranstaltungen aller verknüpften Organisationen anzeigen und filtern",
|
||||||
|
"reportingTitle": "Verbandsverwaltung - Berichte",
|
||||||
|
"reportingDescription": "Aggregierte Berichte und Kennzahlen aller Organisationen im Verband",
|
||||||
|
"templatesTitle": "Verbandsverwaltung - Vorlagen",
|
||||||
|
"templatesDescription": "Geteilte Vorlagen aus der Verbandshierarchie klonen und verwenden",
|
||||||
|
"statisticsTitle": "Verbandsverwaltung - Statistik",
|
||||||
|
"settingsTitle": "Verbandsverwaltung - Einstellungen"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"search": "Suchen",
|
||||||
|
"filter": "Filtern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"create": "Erstellen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"saving": "Wird gespeichert...",
|
||||||
|
"name": "Name",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"location": "Ort",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"action": "Aktion",
|
||||||
|
"type": "Typ",
|
||||||
|
"date": "Datum",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"noEntries": "Keine Einträge vorhanden.",
|
||||||
|
"allTypes": "Alle Typen",
|
||||||
|
"allStatuses": "Alle Status",
|
||||||
|
"allOrganizations": "Alle Organisationen",
|
||||||
|
"organization": "Organisation",
|
||||||
|
"archived": "Archiviert",
|
||||||
|
"showArchived": "Archivierte anzeigen",
|
||||||
|
"hideArchived": "Archivierte ausblenden"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"page": "Seite {page} von {totalPages} ({total} Einträge)",
|
||||||
|
"back": "Zurück",
|
||||||
|
"next": "Weiter"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Verbandsverwaltung – Übersicht",
|
||||||
|
"subtitle": "Vereine, Beiträge, Kontakte und Aufgaben verwalten",
|
||||||
|
"activeClubs": "Aktive Vereine",
|
||||||
|
"totalMembers": "Gesamtmitglieder",
|
||||||
|
"openFees": "Offene Beiträge",
|
||||||
|
"invoiceCount": "{count} Rechnungen",
|
||||||
|
"openTasks": "Offene Aufgaben",
|
||||||
|
"clubTypes": "Vereinstypen",
|
||||||
|
"archivedClubs": "Archivierte Vereine",
|
||||||
|
"clubsWithoutContact": "Vereine ohne Ansprechpartner",
|
||||||
|
"allClubsWithContact": "Alle Vereine haben mindestens einen Ansprechpartner.",
|
||||||
|
"addContact": "Kontakt hinzufügen"
|
||||||
|
},
|
||||||
|
"clubs": {
|
||||||
|
"searchPlaceholder": "Verein suchen...",
|
||||||
|
"newClub": "Neuer Verein",
|
||||||
|
"title": "Vereine ({count})",
|
||||||
|
"noClubs": "Keine Vereine vorhanden",
|
||||||
|
"createFirst": "Erstellen Sie Ihren ersten Verein, um loszulegen.",
|
||||||
|
"members": "Mitglieder",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"founded": "Gegr. {year}"
|
||||||
|
},
|
||||||
|
"clubForm": {
|
||||||
|
"basicData": "Grunddaten",
|
||||||
|
"name": "Name *",
|
||||||
|
"shortName": "Kurzname",
|
||||||
|
"associationType": "Vereinstyp",
|
||||||
|
"noType": "— Kein Typ —",
|
||||||
|
"foundingYear": "Gründungsjahr",
|
||||||
|
"memberCount": "Mitgliederanzahl",
|
||||||
|
"address": "Adresse",
|
||||||
|
"street": "Straße",
|
||||||
|
"zip": "PLZ",
|
||||||
|
"city": "Ort",
|
||||||
|
"website": "Website",
|
||||||
|
"bankData": "Bankdaten",
|
||||||
|
"accountHolder": "Kontoinhaber",
|
||||||
|
"iban": "IBAN",
|
||||||
|
"bic": "BIC",
|
||||||
|
"updateClub": "Verein aktualisieren",
|
||||||
|
"createClub": "Verein erstellen",
|
||||||
|
"clubUpdated": "Verein aktualisiert",
|
||||||
|
"clubCreated": "Verein erstellt",
|
||||||
|
"errorSaving": "Fehler beim Speichern"
|
||||||
|
},
|
||||||
|
"contacts": {
|
||||||
|
"title": "Ansprechpartner",
|
||||||
|
"addContact": "Kontakt hinzufügen",
|
||||||
|
"noContacts": "Keine Ansprechpartner vorhanden.",
|
||||||
|
"firstName": "Vorname *",
|
||||||
|
"lastName": "Nachname *",
|
||||||
|
"role": "Funktion",
|
||||||
|
"update": "Aktualisieren",
|
||||||
|
"created": "Kontakt erstellt",
|
||||||
|
"updated": "Kontakt aktualisiert",
|
||||||
|
"deleted": "Kontakt gelöscht",
|
||||||
|
"errorCreating": "Fehler beim Erstellen",
|
||||||
|
"errorUpdating": "Fehler beim Aktualisieren",
|
||||||
|
"errorDeleting": "Fehler beim Löschen"
|
||||||
|
},
|
||||||
|
"billing": {
|
||||||
|
"title": "Beitragsabrechnungen",
|
||||||
|
"feeType": "Beitragsart",
|
||||||
|
"year": "Jahr",
|
||||||
|
"amount": "Betrag",
|
||||||
|
"dueDate": "Fällig",
|
||||||
|
"paymentMethod": "Zahlung",
|
||||||
|
"noBillings": "Keine Beitragsabrechnungen vorhanden.",
|
||||||
|
"showAll": "Alle anzeigen",
|
||||||
|
"showOpen": "Nur offene",
|
||||||
|
"markAsPaid": "Als bezahlt markieren",
|
||||||
|
"paid": "Beitrag als bezahlt markiert",
|
||||||
|
"deleted": "Beitragsabrechnung gelöscht",
|
||||||
|
"errorUpdating": "Fehler beim Aktualisieren",
|
||||||
|
"errorDeleting": "Fehler beim Löschen"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"title": "Notizen & Aufgaben ({count} offen)",
|
||||||
|
"noNotes": "Keine Notizen vorhanden.",
|
||||||
|
"dueDate": "Fällig: {date}",
|
||||||
|
"completed": "Erledigt ({count})",
|
||||||
|
"markDone": "Als erledigt markieren",
|
||||||
|
"taskCompleted": "Aufgabe erledigt",
|
||||||
|
"noteDeleted": "Notiz gelöscht",
|
||||||
|
"errorUpdating": "Fehler beim Aktualisieren",
|
||||||
|
"errorDeleting": "Fehler beim Löschen"
|
||||||
|
},
|
||||||
|
"hierarchy": {
|
||||||
|
"structure": "Organisationsstruktur",
|
||||||
|
"directChildren": "Direkte Unterverbände",
|
||||||
|
"totalOrganizations": "Organisationen gesamt",
|
||||||
|
"availableToLink": "Verfügbar zum Verknüpfen",
|
||||||
|
"addOrganization": "Organisation hinzufügen",
|
||||||
|
"availableOrganizations": "Verfügbare Organisationen",
|
||||||
|
"selectOrganization": "Organisation auswählen...",
|
||||||
|
"link": "Verknüpfen",
|
||||||
|
"linking": "Wird verknüpft...",
|
||||||
|
"linked": "Organisation erfolgreich verknüpft",
|
||||||
|
"unlinkTitle": "Verknüpfung lösen",
|
||||||
|
"unlinked": "Verknüpfung gelöst",
|
||||||
|
"directCount": "{count} direkt",
|
||||||
|
"rootLevel": "Dachverband",
|
||||||
|
"subLevel": "Unterverband",
|
||||||
|
"clubLevel": "Verein",
|
||||||
|
"errorLinking": "Fehler beim Verknüpfen der Organisation",
|
||||||
|
"errorUnlinking": "Fehler beim Entfernen der Verknüpfung"
|
||||||
|
},
|
||||||
|
"memberSearch": {
|
||||||
|
"searchPlaceholder": "Name, E-Mail oder Mitgliedsnr. suchen...",
|
||||||
|
"title": "Mitglieder ({count})",
|
||||||
|
"noMembers": "Keine Mitglieder gefunden",
|
||||||
|
"tryOtherSearch": "Versuchen Sie einen anderen Suchbegriff.",
|
||||||
|
"noMembersInHierarchy": "In den verknüpften Organisationen sind noch keine Mitglieder vorhanden.",
|
||||||
|
"joinDate": "Eintritt",
|
||||||
|
"memberStatus": {
|
||||||
|
"active": "Aktiv",
|
||||||
|
"inactive": "Inaktiv",
|
||||||
|
"pending": "Ausstehend",
|
||||||
|
"resigned": "Ausgetreten",
|
||||||
|
"excluded": "Ausgeschlossen",
|
||||||
|
"deceased": "Verstorben"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfer": {
|
||||||
|
"title": "Mitglied transferieren",
|
||||||
|
"description": "{name} wird von {source} in eine andere Organisation verschoben.",
|
||||||
|
"loadingPreview": "Lade Transfervorschau...",
|
||||||
|
"targetOrganization": "Zielorganisation",
|
||||||
|
"selectTarget": "Organisation auswählen...",
|
||||||
|
"keepSepaData": "SEPA-Bankdaten (IBAN/BIC) übernehmen",
|
||||||
|
"keepSepaHelp": "Bankverbindung wird übernommen, Mandat muss im Zielverein neu bestätigt werden.",
|
||||||
|
"reason": "Grund (optional)",
|
||||||
|
"reasonPlaceholder": "z.B. Umzug, Vereinswechsel...",
|
||||||
|
"transferring": "Wird transferiert...",
|
||||||
|
"confirm": "Transferieren",
|
||||||
|
"transferred": "Mitglied erfolgreich transferiert",
|
||||||
|
"errorTransfer": "Fehler beim Transfer",
|
||||||
|
"activeEnrollments": "{count} aktive Kurseinschreibung(en)",
|
||||||
|
"retained": "bleibt erhalten",
|
||||||
|
"openInvoices": "{count} offene Rechnung(en)",
|
||||||
|
"remainsAtSource": "verbleibt beim Quellverein",
|
||||||
|
"activeMandates": "{count} aktive(s) SEPA-Mandat(e)",
|
||||||
|
"willReset": "wird zurückgesetzt",
|
||||||
|
"newsletters": "{count} Newsletter-Abonnement(s)",
|
||||||
|
"resetSection": "Wird zurückgesetzt:",
|
||||||
|
"memberNumberReset": "Mitgliedsnr. #{number} — Neuvergabe im Zielverein nötig",
|
||||||
|
"duesCategoryReset": "Beitragskategorie — muss im Zielverein neu zugewiesen werden",
|
||||||
|
"sepaStatusReset": "SEPA-Mandatstatus → \"ausstehend\" (Neubestätigung nötig)",
|
||||||
|
"noSideEffects": "Keine aktiven Verknüpfungen gefunden",
|
||||||
|
"noSideEffectsHelp": "Transfer kann ohne Seiteneffekte durchgeführt werden."
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Veranstaltungen ({count})",
|
||||||
|
"noEvents": "Keine Veranstaltungen gefunden",
|
||||||
|
"tryOtherFilters": "Versuchen Sie andere Filterkriterien.",
|
||||||
|
"noEventsInHierarchy": "In den verknüpften Organisationen sind noch keine Veranstaltungen vorhanden.",
|
||||||
|
"event": "Veranstaltung",
|
||||||
|
"capacity": "Kapazität",
|
||||||
|
"fee": "Gebühr",
|
||||||
|
"shared": "Geteilt",
|
||||||
|
"sharedOnly": "Nur geteilte",
|
||||||
|
"eventStatus": {
|
||||||
|
"planned": "Geplant",
|
||||||
|
"open": "Offen",
|
||||||
|
"full": "Ausgebucht",
|
||||||
|
"running": "Laufend",
|
||||||
|
"completed": "Abgeschlossen",
|
||||||
|
"cancelled": "Abgesagt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reporting": {
|
||||||
|
"perOrganization": "Bericht pro Organisation",
|
||||||
|
"noOrganizations": "Keine Organisationen vorhanden",
|
||||||
|
"hierarchyEmpty": "Die Hierarchie enthält noch keine Organisationen.",
|
||||||
|
"organizations": "Organisationen",
|
||||||
|
"activeMembers": "Aktive Mitglieder",
|
||||||
|
"ofTotal": "von {total} gesamt",
|
||||||
|
"newThisYear": "Neue Mitglieder (Jahr)",
|
||||||
|
"upcomingEvents": "Anstehende Termine",
|
||||||
|
"activeCourses": "Aktive Kurse",
|
||||||
|
"openInvoices": "Offene Rechnungen",
|
||||||
|
"invoiceCount": "{count} Rechnungen",
|
||||||
|
"level": "Ebene",
|
||||||
|
"activeMembersShort": "Aktive Mitgl.",
|
||||||
|
"totalShort": "Gesamt",
|
||||||
|
"newYearShort": "Neu (Jahr)",
|
||||||
|
"courses": "Kurse",
|
||||||
|
"eventsShort": "Termine",
|
||||||
|
"openInvoicesShort": "Offene Rechn.",
|
||||||
|
"openAmount": "Offener Betrag"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"sharedTemplates": "Geteilte Vorlagen",
|
||||||
|
"noTemplates": "Keine geteilten Vorlagen vorhanden",
|
||||||
|
"templatesHelp": "Vorlagen, die von anderen Organisationen in Ihrer Hierarchie geteilt werden, erscheinen hier.",
|
||||||
|
"filterAll": "Alle",
|
||||||
|
"filterNewsletter": "Newsletter",
|
||||||
|
"filterDocument": "Dokumente",
|
||||||
|
"templateType": "Template-Typ",
|
||||||
|
"created": "Erstellt",
|
||||||
|
"clone": "Klonen",
|
||||||
|
"cloneTitle": "Vorlage klonen",
|
||||||
|
"cloneDescription": "Erstellen Sie eine Kopie der Vorlage \"{name}\" in Ihrer Organisation.",
|
||||||
|
"cloneName": "Name der Kopie",
|
||||||
|
"cloneNamePlaceholder": "Name der neuen Vorlage",
|
||||||
|
"cloning": "Wird geklont...",
|
||||||
|
"cloneConfirm": "Vorlage klonen",
|
||||||
|
"cloned": "Vorlage wurde erfolgreich geklont",
|
||||||
|
"errorCloning": "Fehler beim Klonen der Vorlage",
|
||||||
|
"newsletter": "Newsletter",
|
||||||
|
"document": "Dokument",
|
||||||
|
"templateTypes": {
|
||||||
|
"generic": "Allgemein",
|
||||||
|
"member_card": "Mitgliedsausweis",
|
||||||
|
"invoice": "Rechnung",
|
||||||
|
"receipt": "Quittung",
|
||||||
|
"certificate": "Urkunde",
|
||||||
|
"letter": "Brief",
|
||||||
|
"label": "Etikett",
|
||||||
|
"report": "Bericht"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Einstellungen",
|
||||||
|
"subtitle": "Funktionen, Vereinstypen und Beitragsarten verwalten",
|
||||||
|
"roles": "Funktionen (Rollen)",
|
||||||
|
"types": "Vereinstypen",
|
||||||
|
"feeTypes": "Beitragsarten",
|
||||||
|
"descriptionOptional": "Beschreibung (optional)"
|
||||||
|
},
|
||||||
|
"statistics": {
|
||||||
|
"title": "Statistik",
|
||||||
|
"subtitle": "Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf",
|
||||||
|
"clubDevelopment": "Vereinsentwicklung",
|
||||||
|
"memberDevelopment": "Mitgliederentwicklung",
|
||||||
|
"clubsLabel": "Vereine",
|
||||||
|
"membersLabel": "Mitglieder",
|
||||||
|
"helpText": "Die Statistiken werden automatisch aus den Vereinsdaten und der Verbandshistorie berechnet."
|
||||||
|
}
|
||||||
|
}
|
||||||
306
apps/web/i18n/messages/en/verband.json
Normal file
306
apps/web/i18n/messages/en/verband.json
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"clubs": "Clubs",
|
||||||
|
"hierarchy": "Hierarchy",
|
||||||
|
"memberSearch": "Member Search",
|
||||||
|
"events": "Events",
|
||||||
|
"reporting": "Reports",
|
||||||
|
"templates": "Templates",
|
||||||
|
"statistics": "Statistics",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"overviewTitle": "Association Management",
|
||||||
|
"clubsTitle": "Association Management - Clubs",
|
||||||
|
"hierarchyTitle": "Association Management - Hierarchy",
|
||||||
|
"hierarchyDescription": "Manage the organizational structure of your association",
|
||||||
|
"memberSearchTitle": "Association Management - Member Search",
|
||||||
|
"memberSearchDescription": "Search members across all linked organizations",
|
||||||
|
"eventsTitle": "Association Management - Events",
|
||||||
|
"eventsDescription": "View and filter events across all linked organizations",
|
||||||
|
"reportingTitle": "Association Management - Reports",
|
||||||
|
"reportingDescription": "Aggregated reports and metrics across all organizations",
|
||||||
|
"templatesTitle": "Association Management - Templates",
|
||||||
|
"templatesDescription": "Clone and use shared templates from the association hierarchy",
|
||||||
|
"statisticsTitle": "Association Management - Statistics",
|
||||||
|
"settingsTitle": "Association Management - Settings"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"search": "Search",
|
||||||
|
"filter": "Filter",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"add": "Add",
|
||||||
|
"create": "Create",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
|
"location": "Location",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions",
|
||||||
|
"action": "Action",
|
||||||
|
"type": "Type",
|
||||||
|
"date": "Date",
|
||||||
|
"description": "Description",
|
||||||
|
"noEntries": "No entries found.",
|
||||||
|
"allTypes": "All Types",
|
||||||
|
"allStatuses": "All Statuses",
|
||||||
|
"allOrganizations": "All Organizations",
|
||||||
|
"organization": "Organization",
|
||||||
|
"archived": "Archived",
|
||||||
|
"showArchived": "Show Archived",
|
||||||
|
"hideArchived": "Hide Archived"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"page": "Page {page} of {totalPages} ({total} entries)",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Association Management – Overview",
|
||||||
|
"subtitle": "Manage clubs, fees, contacts, and tasks",
|
||||||
|
"activeClubs": "Active Clubs",
|
||||||
|
"totalMembers": "Total Members",
|
||||||
|
"openFees": "Open Fees",
|
||||||
|
"invoiceCount": "{count} invoices",
|
||||||
|
"openTasks": "Open Tasks",
|
||||||
|
"clubTypes": "Club Types",
|
||||||
|
"archivedClubs": "Archived Clubs",
|
||||||
|
"clubsWithoutContact": "Clubs Without Contact",
|
||||||
|
"allClubsWithContact": "All clubs have at least one contact person.",
|
||||||
|
"addContact": "Add Contact"
|
||||||
|
},
|
||||||
|
"clubs": {
|
||||||
|
"searchPlaceholder": "Search clubs...",
|
||||||
|
"newClub": "New Club",
|
||||||
|
"title": "Clubs ({count})",
|
||||||
|
"noClubs": "No clubs found",
|
||||||
|
"createFirst": "Create your first club to get started.",
|
||||||
|
"members": "Members",
|
||||||
|
"contact": "Contact",
|
||||||
|
"founded": "Est. {year}"
|
||||||
|
},
|
||||||
|
"clubForm": {
|
||||||
|
"basicData": "Basic Data",
|
||||||
|
"name": "Name *",
|
||||||
|
"shortName": "Short Name",
|
||||||
|
"associationType": "Association Type",
|
||||||
|
"noType": "— No Type —",
|
||||||
|
"foundingYear": "Founding Year",
|
||||||
|
"memberCount": "Member Count",
|
||||||
|
"address": "Address",
|
||||||
|
"street": "Street",
|
||||||
|
"zip": "ZIP",
|
||||||
|
"city": "City",
|
||||||
|
"website": "Website",
|
||||||
|
"bankData": "Bank Details",
|
||||||
|
"accountHolder": "Account Holder",
|
||||||
|
"iban": "IBAN",
|
||||||
|
"bic": "BIC",
|
||||||
|
"updateClub": "Update Club",
|
||||||
|
"createClub": "Create Club",
|
||||||
|
"clubUpdated": "Club updated",
|
||||||
|
"clubCreated": "Club created",
|
||||||
|
"errorSaving": "Error saving"
|
||||||
|
},
|
||||||
|
"contacts": {
|
||||||
|
"title": "Contacts",
|
||||||
|
"addContact": "Add Contact",
|
||||||
|
"noContacts": "No contacts found.",
|
||||||
|
"firstName": "First Name *",
|
||||||
|
"lastName": "Last Name *",
|
||||||
|
"role": "Role",
|
||||||
|
"update": "Update",
|
||||||
|
"created": "Contact created",
|
||||||
|
"updated": "Contact updated",
|
||||||
|
"deleted": "Contact deleted",
|
||||||
|
"errorCreating": "Error creating contact",
|
||||||
|
"errorUpdating": "Error updating contact",
|
||||||
|
"errorDeleting": "Error deleting contact"
|
||||||
|
},
|
||||||
|
"billing": {
|
||||||
|
"title": "Fee Billings",
|
||||||
|
"feeType": "Fee Type",
|
||||||
|
"year": "Year",
|
||||||
|
"amount": "Amount",
|
||||||
|
"dueDate": "Due Date",
|
||||||
|
"paymentMethod": "Payment",
|
||||||
|
"noBillings": "No fee billings found.",
|
||||||
|
"showAll": "Show All",
|
||||||
|
"showOpen": "Open Only",
|
||||||
|
"markAsPaid": "Mark as Paid",
|
||||||
|
"paid": "Fee marked as paid",
|
||||||
|
"deleted": "Fee billing deleted",
|
||||||
|
"errorUpdating": "Error updating",
|
||||||
|
"errorDeleting": "Error deleting"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"title": "Notes & Tasks ({count} open)",
|
||||||
|
"noNotes": "No notes found.",
|
||||||
|
"dueDate": "Due: {date}",
|
||||||
|
"completed": "Completed ({count})",
|
||||||
|
"markDone": "Mark as done",
|
||||||
|
"taskCompleted": "Task completed",
|
||||||
|
"noteDeleted": "Note deleted",
|
||||||
|
"errorUpdating": "Error updating",
|
||||||
|
"errorDeleting": "Error deleting"
|
||||||
|
},
|
||||||
|
"hierarchy": {
|
||||||
|
"structure": "Organization Structure",
|
||||||
|
"directChildren": "Direct Sub-associations",
|
||||||
|
"totalOrganizations": "Total Organizations",
|
||||||
|
"availableToLink": "Available to Link",
|
||||||
|
"addOrganization": "Add Organization",
|
||||||
|
"availableOrganizations": "Available Organizations",
|
||||||
|
"selectOrganization": "Select organization...",
|
||||||
|
"link": "Link",
|
||||||
|
"linking": "Linking...",
|
||||||
|
"linked": "Organization linked successfully",
|
||||||
|
"unlinkTitle": "Unlink",
|
||||||
|
"unlinked": "Link removed",
|
||||||
|
"directCount": "{count} direct",
|
||||||
|
"rootLevel": "Federation",
|
||||||
|
"subLevel": "Sub-association",
|
||||||
|
"clubLevel": "Club",
|
||||||
|
"errorLinking": "Error linking organization",
|
||||||
|
"errorUnlinking": "Error removing link"
|
||||||
|
},
|
||||||
|
"memberSearch": {
|
||||||
|
"searchPlaceholder": "Search name, email, or member no...",
|
||||||
|
"title": "Members ({count})",
|
||||||
|
"noMembers": "No members found",
|
||||||
|
"tryOtherSearch": "Try a different search term.",
|
||||||
|
"noMembersInHierarchy": "No members found in linked organizations.",
|
||||||
|
"joinDate": "Joined",
|
||||||
|
"memberStatus": {
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"pending": "Pending",
|
||||||
|
"resigned": "Resigned",
|
||||||
|
"excluded": "Excluded",
|
||||||
|
"deceased": "Deceased"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transfer": {
|
||||||
|
"title": "Transfer Member",
|
||||||
|
"description": "{name} will be moved from {source} to another organization.",
|
||||||
|
"loadingPreview": "Loading transfer preview...",
|
||||||
|
"targetOrganization": "Target Organization",
|
||||||
|
"selectTarget": "Select organization...",
|
||||||
|
"keepSepaData": "Keep SEPA bank data (IBAN/BIC)",
|
||||||
|
"keepSepaHelp": "Bank details will be kept, mandate must be re-confirmed in the target organization.",
|
||||||
|
"reason": "Reason (optional)",
|
||||||
|
"reasonPlaceholder": "e.g. Relocation, club change...",
|
||||||
|
"transferring": "Transferring...",
|
||||||
|
"confirm": "Transfer",
|
||||||
|
"transferred": "Member transferred successfully",
|
||||||
|
"errorTransfer": "Error during transfer",
|
||||||
|
"activeEnrollments": "{count} active course enrollment(s)",
|
||||||
|
"retained": "retained",
|
||||||
|
"openInvoices": "{count} open invoice(s)",
|
||||||
|
"remainsAtSource": "remains at source organization",
|
||||||
|
"activeMandates": "{count} active SEPA mandate(s)",
|
||||||
|
"willReset": "will be reset",
|
||||||
|
"newsletters": "{count} newsletter subscription(s)",
|
||||||
|
"resetSection": "Will be reset:",
|
||||||
|
"memberNumberReset": "Member no. #{number} — reassignment needed in target org",
|
||||||
|
"duesCategoryReset": "Dues category — must be reassigned in target organization",
|
||||||
|
"sepaStatusReset": "SEPA mandate status → \"pending\" (re-confirmation needed)",
|
||||||
|
"noSideEffects": "No active relationships found",
|
||||||
|
"noSideEffectsHelp": "Transfer can be performed without side effects."
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Events ({count})",
|
||||||
|
"noEvents": "No events found",
|
||||||
|
"tryOtherFilters": "Try different filter criteria.",
|
||||||
|
"noEventsInHierarchy": "No events found in linked organizations.",
|
||||||
|
"event": "Event",
|
||||||
|
"capacity": "Capacity",
|
||||||
|
"fee": "Fee",
|
||||||
|
"shared": "Shared",
|
||||||
|
"sharedOnly": "Shared Only",
|
||||||
|
"eventStatus": {
|
||||||
|
"planned": "Planned",
|
||||||
|
"open": "Open",
|
||||||
|
"full": "Full",
|
||||||
|
"running": "Running",
|
||||||
|
"completed": "Completed",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reporting": {
|
||||||
|
"perOrganization": "Report per Organization",
|
||||||
|
"noOrganizations": "No organizations found",
|
||||||
|
"hierarchyEmpty": "The hierarchy does not contain any organizations yet.",
|
||||||
|
"organizations": "Organizations",
|
||||||
|
"activeMembers": "Active Members",
|
||||||
|
"ofTotal": "of {total} total",
|
||||||
|
"newThisYear": "New Members (Year)",
|
||||||
|
"upcomingEvents": "Upcoming Events",
|
||||||
|
"activeCourses": "Active Courses",
|
||||||
|
"openInvoices": "Open Invoices",
|
||||||
|
"invoiceCount": "{count} invoices",
|
||||||
|
"level": "Level",
|
||||||
|
"activeMembersShort": "Active",
|
||||||
|
"totalShort": "Total",
|
||||||
|
"newYearShort": "New (Year)",
|
||||||
|
"courses": "Courses",
|
||||||
|
"eventsShort": "Events",
|
||||||
|
"openInvoicesShort": "Open Inv.",
|
||||||
|
"openAmount": "Open Amount"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"sharedTemplates": "Shared Templates",
|
||||||
|
"noTemplates": "No shared templates found",
|
||||||
|
"templatesHelp": "Templates shared by other organizations in your hierarchy appear here.",
|
||||||
|
"filterAll": "All",
|
||||||
|
"filterNewsletter": "Newsletter",
|
||||||
|
"filterDocument": "Documents",
|
||||||
|
"templateType": "Template Type",
|
||||||
|
"created": "Created",
|
||||||
|
"clone": "Clone",
|
||||||
|
"cloneTitle": "Clone Template",
|
||||||
|
"cloneDescription": "Create a copy of template \"{name}\" in your organization.",
|
||||||
|
"cloneName": "Copy Name",
|
||||||
|
"cloneNamePlaceholder": "Name for the new template",
|
||||||
|
"cloning": "Cloning...",
|
||||||
|
"cloneConfirm": "Clone Template",
|
||||||
|
"cloned": "Template cloned successfully",
|
||||||
|
"errorCloning": "Error cloning template",
|
||||||
|
"newsletter": "Newsletter",
|
||||||
|
"document": "Document",
|
||||||
|
"templateTypes": {
|
||||||
|
"generic": "General",
|
||||||
|
"member_card": "Member Card",
|
||||||
|
"invoice": "Invoice",
|
||||||
|
"receipt": "Receipt",
|
||||||
|
"certificate": "Certificate",
|
||||||
|
"letter": "Letter",
|
||||||
|
"label": "Label",
|
||||||
|
"report": "Report"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"subtitle": "Manage roles, association types, and fee types",
|
||||||
|
"roles": "Roles",
|
||||||
|
"types": "Association Types",
|
||||||
|
"feeTypes": "Fee Types",
|
||||||
|
"descriptionOptional": "Description (optional)"
|
||||||
|
},
|
||||||
|
"statistics": {
|
||||||
|
"title": "Statistics",
|
||||||
|
"subtitle": "Club and member development over time",
|
||||||
|
"clubDevelopment": "Club Development",
|
||||||
|
"memberDevelopment": "Member Development",
|
||||||
|
"clubsLabel": "Clubs",
|
||||||
|
"membersLabel": "Members",
|
||||||
|
"helpText": "Statistics are automatically calculated from club data and association history."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const namespaces = [
|
|||||||
'billing',
|
'billing',
|
||||||
'marketing',
|
'marketing',
|
||||||
'cms',
|
'cms',
|
||||||
|
'verband',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -60,9 +60,10 @@ verify_enabled = true
|
|||||||
enroll_enabled = true
|
enroll_enabled = true
|
||||||
|
|
||||||
# Enable WebAuthn/Passkey MFA
|
# Enable WebAuthn/Passkey MFA
|
||||||
[auth.mfa.webauthn]
|
# Note: requires Supabase CLI >= 2.90. Uncomment when upgrading.
|
||||||
verify_enabled = true
|
# [auth.mfa.webauthn]
|
||||||
enroll_enabled = true
|
# verify_enabled = true
|
||||||
|
# enroll_enabled = true
|
||||||
|
|
||||||
[auth.email]
|
[auth.email]
|
||||||
# Allow/disallow new user signups via email to your project.
|
# Allow/disallow new user signups via email to your project.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Pre-module enum values: add all module-specific permission enum values
|
||||||
|
* in a separate transaction. Postgres requires new enum values to be
|
||||||
|
* committed before they can be used in policies/functions.
|
||||||
|
*
|
||||||
|
* Covers: fischerei, sitzungsprotokolle, verbandsverwaltung
|
||||||
|
*/
|
||||||
|
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.read';
|
||||||
|
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.write';
|
||||||
|
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.read';
|
||||||
|
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.write';
|
||||||
|
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.read';
|
||||||
|
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.write';
|
||||||
@@ -40,11 +40,10 @@ CREATE TYPE public.fish_size_category AS ENUM(
|
|||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 2. Extend app_permissions
|
-- 2. Extend app_permissions
|
||||||
|
-- (Moved to 20260411900001_fischerei_enum_values.sql — Postgres requires
|
||||||
|
-- new enum values to be committed in a separate transaction)
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.read';
|
|
||||||
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'fischerei.write';
|
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 3. cost_centers (shared, may already exist)
|
-- 3. cost_centers (shared, may already exist)
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|||||||
@@ -7,11 +7,9 @@
|
|||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 1. Extend app_permissions
|
-- 1. Extend app_permissions
|
||||||
|
-- (Moved to 20260411900001_fischerei_enum_values.sql)
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.read';
|
|
||||||
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'meetings.write';
|
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 2. Enums
|
-- 2. Enums
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|||||||
@@ -8,11 +8,9 @@
|
|||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 1. Extend app_permissions
|
-- 1. Extend app_permissions
|
||||||
|
-- (Moved to 20260411900001_fischerei_enum_values.sql)
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|
||||||
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.read';
|
|
||||||
ALTER TYPE public.app_permissions ADD VALUE IF NOT EXISTS 'verband.write';
|
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- 2. association_types (Verbandsarten)
|
-- 2. association_types (Verbandsarten)
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
|
|||||||
@@ -54,16 +54,16 @@ BEGIN
|
|||||||
-- Walk up from the proposed parent; if we find NEW.id, it's a cycle
|
-- Walk up from the proposed parent; if we find NEW.id, it's a cycle
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
WITH RECURSIVE ancestors AS (
|
WITH RECURSIVE ancestors AS (
|
||||||
SELECT id, parent_account_id
|
SELECT acc.id, acc.parent_account_id, ARRAY[acc.id] AS path
|
||||||
FROM public.accounts
|
FROM public.accounts acc
|
||||||
WHERE id = NEW.parent_account_id
|
WHERE acc.id = NEW.parent_account_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a.id, a.parent_account_id
|
SELECT a.id, a.parent_account_id, anc.path || a.id
|
||||||
FROM public.accounts a
|
FROM public.accounts a
|
||||||
JOIN ancestors anc ON a.id = anc.parent_account_id
|
JOIN ancestors anc ON a.id = anc.parent_account_id
|
||||||
|
WHERE NOT a.id = ANY(anc.path) -- cycle guard
|
||||||
)
|
)
|
||||||
CYCLE id SET is_cycle USING path
|
SELECT 1 FROM ancestors WHERE id = NEW.id
|
||||||
SELECT 1 FROM ancestors WHERE id = NEW.id AND NOT is_cycle
|
|
||||||
) THEN
|
) THEN
|
||||||
RAISE EXCEPTION 'Setting parent_account_id would create a hierarchy cycle';
|
RAISE EXCEPTION 'Setting parent_account_id would create a hierarchy cycle';
|
||||||
END IF;
|
END IF;
|
||||||
@@ -73,7 +73,9 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
CREATE OR REPLACE TRIGGER prevent_account_hierarchy_cycle
|
DROP TRIGGER IF EXISTS prevent_account_hierarchy_cycle ON public.accounts;
|
||||||
|
|
||||||
|
CREATE TRIGGER prevent_account_hierarchy_cycle
|
||||||
BEFORE INSERT OR UPDATE OF parent_account_id
|
BEFORE INSERT OR UPDATE OF parent_account_id
|
||||||
ON public.accounts
|
ON public.accounts
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
@@ -82,7 +84,8 @@ CREATE OR REPLACE TRIGGER prevent_account_hierarchy_cycle
|
|||||||
|
|
||||||
-- -------------------------------------------------------
|
-- -------------------------------------------------------
|
||||||
-- Helper: get all descendant account IDs (recursive, cycle-safe)
|
-- Helper: get all descendant account IDs (recursive, cycle-safe)
|
||||||
-- Restricted to service_role — called via RLS helper functions
|
-- Uses path array for cycle detection (works on Postgres 14+)
|
||||||
|
-- Restricted to service_role — called via RLS SECURITY DEFINER functions
|
||||||
-- -------------------------------------------------------
|
-- -------------------------------------------------------
|
||||||
CREATE OR REPLACE FUNCTION public.get_account_descendants(root_id uuid)
|
CREATE OR REPLACE FUNCTION public.get_account_descendants(root_id uuid)
|
||||||
RETURNS SETOF uuid
|
RETURNS SETOF uuid
|
||||||
@@ -90,15 +93,15 @@ LANGUAGE sql STABLE
|
|||||||
SET search_path = ''
|
SET search_path = ''
|
||||||
AS $$
|
AS $$
|
||||||
WITH RECURSIVE tree AS (
|
WITH RECURSIVE tree AS (
|
||||||
SELECT id, parent_account_id
|
SELECT acc.id, ARRAY[acc.id] AS path
|
||||||
FROM public.accounts WHERE id = root_id
|
FROM public.accounts acc WHERE acc.id = root_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a.id, a.parent_account_id
|
SELECT a.id, t.path || a.id
|
||||||
FROM public.accounts a
|
FROM public.accounts a
|
||||||
JOIN tree t ON a.parent_account_id = t.id
|
JOIN tree t ON a.parent_account_id = t.id
|
||||||
|
WHERE NOT a.id = ANY(t.path)
|
||||||
)
|
)
|
||||||
CYCLE id SET is_cycle USING path
|
SELECT tree.id FROM tree;
|
||||||
SELECT id FROM tree WHERE NOT is_cycle;
|
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION public.get_account_descendants(uuid)
|
GRANT EXECUTE ON FUNCTION public.get_account_descendants(uuid)
|
||||||
@@ -106,7 +109,7 @@ GRANT EXECUTE ON FUNCTION public.get_account_descendants(uuid)
|
|||||||
|
|
||||||
-- -------------------------------------------------------
|
-- -------------------------------------------------------
|
||||||
-- Helper: get all ancestor account IDs (walk up, cycle-safe)
|
-- Helper: get all ancestor account IDs (walk up, cycle-safe)
|
||||||
-- Restricted to service_role — called via RLS helper functions
|
-- Restricted to service_role
|
||||||
-- -------------------------------------------------------
|
-- -------------------------------------------------------
|
||||||
CREATE OR REPLACE FUNCTION public.get_account_ancestors(child_id uuid)
|
CREATE OR REPLACE FUNCTION public.get_account_ancestors(child_id uuid)
|
||||||
RETURNS SETOF uuid
|
RETURNS SETOF uuid
|
||||||
@@ -114,15 +117,15 @@ LANGUAGE sql STABLE
|
|||||||
SET search_path = ''
|
SET search_path = ''
|
||||||
AS $$
|
AS $$
|
||||||
WITH RECURSIVE tree AS (
|
WITH RECURSIVE tree AS (
|
||||||
SELECT id, parent_account_id
|
SELECT acc.id, acc.parent_account_id, ARRAY[acc.id] AS path
|
||||||
FROM public.accounts WHERE id = child_id
|
FROM public.accounts acc WHERE acc.id = child_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a.id, a.parent_account_id
|
SELECT a.id, a.parent_account_id, t.path || a.id
|
||||||
FROM public.accounts a
|
FROM public.accounts a
|
||||||
JOIN tree t ON a.id = t.parent_account_id
|
JOIN tree t ON a.id = t.parent_account_id
|
||||||
|
WHERE NOT a.id = ANY(t.path)
|
||||||
)
|
)
|
||||||
CYCLE id SET is_cycle USING path
|
SELECT tree.id FROM tree;
|
||||||
SELECT id FROM tree WHERE NOT is_cycle;
|
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION public.get_account_ancestors(uuid)
|
GRANT EXECUTE ON FUNCTION public.get_account_ancestors(uuid)
|
||||||
@@ -138,16 +141,16 @@ LANGUAGE sql STABLE
|
|||||||
SET search_path = ''
|
SET search_path = ''
|
||||||
AS $$
|
AS $$
|
||||||
WITH RECURSIVE tree AS (
|
WITH RECURSIVE tree AS (
|
||||||
SELECT id, parent_account_id, 0 AS depth
|
SELECT acc.id, acc.parent_account_id, 0 AS depth, ARRAY[acc.id] AS path
|
||||||
FROM public.accounts
|
FROM public.accounts acc
|
||||||
WHERE id = account_id
|
WHERE acc.id = get_account_depth.account_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a.id, a.parent_account_id, t.depth + 1
|
SELECT a.id, a.parent_account_id, t.depth + 1, t.path || a.id
|
||||||
FROM public.accounts a
|
FROM public.accounts a
|
||||||
JOIN tree t ON a.id = t.parent_account_id
|
JOIN tree t ON a.id = t.parent_account_id
|
||||||
|
WHERE NOT a.id = ANY(t.path)
|
||||||
)
|
)
|
||||||
CYCLE id SET is_cycle USING path
|
SELECT MAX(depth) FROM tree;
|
||||||
SELECT MAX(depth) FROM tree WHERE NOT is_cycle;
|
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
GRANT EXECUTE ON FUNCTION public.get_account_depth(uuid)
|
GRANT EXECUTE ON FUNCTION public.get_account_depth(uuid)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ AS $$
|
|||||||
FROM public.accounts_memberships membership
|
FROM public.accounts_memberships membership
|
||||||
WHERE membership.user_id = (SELECT auth.uid())
|
WHERE membership.user_id = (SELECT auth.uid())
|
||||||
AND membership.account_id IN (
|
AND membership.account_id IN (
|
||||||
SELECT id FROM public.get_account_ancestors(target_account_id)
|
SELECT public.get_account_ancestors(target_account_id)
|
||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
membership.account_role = has_role_on_account_or_ancestor.account_role
|
membership.account_role = has_role_on_account_or_ancestor.account_role
|
||||||
@@ -62,7 +62,7 @@ BEGIN
|
|||||||
JOIN public.role_permissions rp ON am.account_role = rp.role
|
JOIN public.role_permissions rp ON am.account_role = rp.role
|
||||||
WHERE am.user_id = has_permission_or_ancestor.user_id
|
WHERE am.user_id = has_permission_or_ancestor.user_id
|
||||||
AND am.account_id IN (
|
AND am.account_id IN (
|
||||||
SELECT id FROM public.get_account_ancestors(target_account_id)
|
SELECT public.get_account_ancestors(target_account_id)
|
||||||
)
|
)
|
||||||
AND rp.permission = has_permission_or_ancestor.permission_name
|
AND rp.permission = has_permission_or_ancestor.permission_name
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* -------------------------------------------------------
|
||||||
|
* Cross-Organization Member Search
|
||||||
|
*
|
||||||
|
* Enables Verband admins to search members across all
|
||||||
|
* descendant accounts in the hierarchy. Uses the existing
|
||||||
|
* members_hierarchy_read RLS policy for access control.
|
||||||
|
* -------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Enable pg_trgm for trigram-based text search
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
-- Full-text search index on member names for fast lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_name_trgm
|
||||||
|
ON public.members USING gin (
|
||||||
|
(lower(first_name || ' ' || last_name)) gin_trgm_ops
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Search members across a hierarchy with text search + filters
|
||||||
|
CREATE OR REPLACE FUNCTION public.search_members_across_hierarchy(
|
||||||
|
root_account_id uuid,
|
||||||
|
search_term text DEFAULT NULL,
|
||||||
|
status_filter text DEFAULT NULL,
|
||||||
|
account_filter uuid DEFAULT NULL,
|
||||||
|
page_number int DEFAULT 1,
|
||||||
|
page_size int DEFAULT 25
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id uuid,
|
||||||
|
account_id uuid,
|
||||||
|
account_name varchar,
|
||||||
|
account_slug text,
|
||||||
|
member_number text,
|
||||||
|
first_name text,
|
||||||
|
last_name text,
|
||||||
|
email text,
|
||||||
|
phone text,
|
||||||
|
city text,
|
||||||
|
status public.membership_status,
|
||||||
|
entry_date date,
|
||||||
|
total_count bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_offset int;
|
||||||
|
v_total bigint;
|
||||||
|
BEGIN
|
||||||
|
-- Verify caller has a role on the root account
|
||||||
|
IF NOT public.has_role_on_account(root_account_id) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_offset := (page_number - 1) * page_size;
|
||||||
|
|
||||||
|
-- Count total matches first
|
||||||
|
SELECT count(*) INTO v_total
|
||||||
|
FROM public.members m
|
||||||
|
JOIN public.accounts a ON a.id = m.account_id
|
||||||
|
WHERE m.account_id IN (
|
||||||
|
SELECT d.id FROM public.get_account_descendants(root_account_id) d(id)
|
||||||
|
)
|
||||||
|
AND (search_term IS NULL OR search_term = '' OR
|
||||||
|
lower(m.first_name || ' ' || m.last_name) LIKE '%' || lower(search_term) || '%' OR
|
||||||
|
lower(m.email) LIKE '%' || lower(search_term) || '%' OR
|
||||||
|
m.member_number ILIKE '%' || search_term || '%'
|
||||||
|
)
|
||||||
|
AND (status_filter IS NULL OR m.status = status_filter::public.membership_status)
|
||||||
|
AND (account_filter IS NULL OR m.account_id = account_filter);
|
||||||
|
|
||||||
|
-- Return results with total count
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
a.slug AS account_slug,
|
||||||
|
m.member_number,
|
||||||
|
m.first_name,
|
||||||
|
m.last_name,
|
||||||
|
m.email,
|
||||||
|
m.phone,
|
||||||
|
m.city,
|
||||||
|
m.status,
|
||||||
|
m.entry_date,
|
||||||
|
v_total AS total_count
|
||||||
|
FROM public.members m
|
||||||
|
JOIN public.accounts a ON a.id = m.account_id
|
||||||
|
WHERE m.account_id IN (
|
||||||
|
SELECT d.id FROM public.get_account_descendants(root_account_id) d(id)
|
||||||
|
)
|
||||||
|
AND (search_term IS NULL OR search_term = '' OR
|
||||||
|
lower(m.first_name || ' ' || m.last_name) LIKE '%' || lower(search_term) || '%' OR
|
||||||
|
lower(m.email) LIKE '%' || lower(search_term) || '%' OR
|
||||||
|
m.member_number ILIKE '%' || search_term || '%'
|
||||||
|
)
|
||||||
|
AND (status_filter IS NULL OR m.status = status_filter::public.membership_status)
|
||||||
|
AND (account_filter IS NULL OR m.account_id = account_filter)
|
||||||
|
ORDER BY m.last_name, m.first_name
|
||||||
|
LIMIT page_size
|
||||||
|
OFFSET v_offset;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.search_members_across_hierarchy(uuid, text, text, uuid, int, int)
|
||||||
|
TO authenticated, service_role;
|
||||||
198
apps/web/supabase/migrations/20260414000004_member_transfer.sql
Normal file
198
apps/web/supabase/migrations/20260414000004_member_transfer.sql
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/*
|
||||||
|
* -------------------------------------------------------
|
||||||
|
* Member Transfer Between Accounts
|
||||||
|
*
|
||||||
|
* Enables transferring a member from one Verein to another
|
||||||
|
* within the same Verband hierarchy.
|
||||||
|
*
|
||||||
|
* Design:
|
||||||
|
* - Personal data always moves with the member
|
||||||
|
* - Course enrollments, event registrations, bookings are
|
||||||
|
* linked via member_id (not account_id) so they survive
|
||||||
|
* the transfer automatically
|
||||||
|
* - Only org-specific admin data is cleared:
|
||||||
|
* dues_category_id (org-specific pricing),
|
||||||
|
* member_number (unique per org)
|
||||||
|
* - SEPA bank data (IBAN/BIC) is preserved, but mandate
|
||||||
|
* status resets to 'pending' (needs re-confirmation)
|
||||||
|
* - Financial records (invoices, SEPA batches) stay in
|
||||||
|
* source org — they're legally tied to that entity
|
||||||
|
* -------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Transfer log table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_transfers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||||
|
source_account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE SET NULL,
|
||||||
|
target_account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE SET NULL,
|
||||||
|
transferred_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
reason text,
|
||||||
|
-- Snapshot what was cleared so it can be reviewed later
|
||||||
|
cleared_data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
transferred_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_member_transfers_member
|
||||||
|
ON public.member_transfers(member_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_member_transfers_source
|
||||||
|
ON public.member_transfers(source_account_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_member_transfers_target
|
||||||
|
ON public.member_transfers(target_account_id);
|
||||||
|
|
||||||
|
ALTER TABLE public.member_transfers ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
REVOKE ALL ON public.member_transfers FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT ON public.member_transfers TO authenticated;
|
||||||
|
GRANT ALL ON public.member_transfers TO service_role;
|
||||||
|
|
||||||
|
-- Readable by members of source or target account (or ancestor via hierarchy)
|
||||||
|
CREATE POLICY member_transfers_read ON public.member_transfers
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
public.has_role_on_account_or_ancestor(source_account_id) OR
|
||||||
|
public.has_role_on_account_or_ancestor(target_account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- Transfer function
|
||||||
|
--
|
||||||
|
-- Only clears org-specific admin data. All cross-org
|
||||||
|
-- relationships (courses, events, bookings) survive because
|
||||||
|
-- they reference member_id, not account_id.
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.transfer_member(
|
||||||
|
p_member_id uuid,
|
||||||
|
p_target_account_id uuid,
|
||||||
|
p_reason text DEFAULT NULL,
|
||||||
|
p_keep_sepa boolean DEFAULT true
|
||||||
|
)
|
||||||
|
RETURNS uuid -- returns the transfer log ID
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_source_account_id uuid;
|
||||||
|
v_transfer_id uuid;
|
||||||
|
v_caller_id uuid;
|
||||||
|
v_old_member_number text;
|
||||||
|
v_old_dues_category_id uuid;
|
||||||
|
v_old_sepa_mandate_id text;
|
||||||
|
v_source_name text;
|
||||||
|
v_target_name text;
|
||||||
|
v_active_courses int;
|
||||||
|
v_active_events int;
|
||||||
|
BEGIN
|
||||||
|
v_caller_id := (SELECT auth.uid());
|
||||||
|
|
||||||
|
-- Get the member's current account and data we'll clear
|
||||||
|
SELECT account_id, member_number, dues_category_id, sepa_mandate_id
|
||||||
|
INTO v_source_account_id, v_old_member_number, v_old_dues_category_id, v_old_sepa_mandate_id
|
||||||
|
FROM public.members
|
||||||
|
WHERE id = p_member_id;
|
||||||
|
|
||||||
|
IF v_source_account_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Mitglied nicht gefunden';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_source_account_id = p_target_account_id THEN
|
||||||
|
RAISE EXCEPTION 'Mitglied ist bereits in dieser Organisation';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Target must be a team account
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.accounts
|
||||||
|
WHERE id = p_target_account_id AND is_personal_account = false
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Zielorganisation nicht gefunden';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verify caller has visibility on BOTH accounts via hierarchy
|
||||||
|
IF NOT (
|
||||||
|
public.has_role_on_account_or_ancestor(v_source_account_id)
|
||||||
|
AND public.has_role_on_account_or_ancestor(p_target_account_id)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Keine Berechtigung für den Transfer';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verify both accounts share a common ancestor (same Verband)
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.get_account_ancestors(v_source_account_id) sa
|
||||||
|
JOIN public.get_account_ancestors(p_target_account_id) ta ON sa = ta
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Organisationen gehören nicht zum selben Verband';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get org names for the transfer note
|
||||||
|
SELECT name INTO v_source_name FROM public.accounts WHERE id = v_source_account_id;
|
||||||
|
SELECT name INTO v_target_name FROM public.accounts WHERE id = p_target_account_id;
|
||||||
|
|
||||||
|
-- Count active relationships (informational, for the log)
|
||||||
|
SELECT count(*) INTO v_active_courses
|
||||||
|
FROM public.course_participants cp
|
||||||
|
JOIN public.courses c ON c.id = cp.course_id
|
||||||
|
WHERE cp.member_id = p_member_id AND cp.status = 'enrolled';
|
||||||
|
|
||||||
|
SELECT count(*) INTO v_active_events
|
||||||
|
FROM public.event_registrations er
|
||||||
|
JOIN public.events e ON e.id = er.event_id
|
||||||
|
WHERE er.email = (SELECT email FROM public.members WHERE id = p_member_id)
|
||||||
|
AND er.status IN ('confirmed', 'pending')
|
||||||
|
AND e.event_date >= current_date;
|
||||||
|
|
||||||
|
-- Perform the transfer
|
||||||
|
UPDATE public.members
|
||||||
|
SET
|
||||||
|
account_id = p_target_account_id,
|
||||||
|
-- Clear org-specific admin data
|
||||||
|
dues_category_id = NULL,
|
||||||
|
member_number = NULL,
|
||||||
|
-- SEPA: keep bank data (IBAN/BIC/account_holder), just reset mandate status
|
||||||
|
sepa_mandate_id = CASE WHEN p_keep_sepa THEN sepa_mandate_id ELSE NULL END,
|
||||||
|
sepa_mandate_date = CASE WHEN p_keep_sepa THEN sepa_mandate_date ELSE NULL END,
|
||||||
|
sepa_mandate_status = 'pending', -- always needs re-confirmation in new org
|
||||||
|
-- Append transfer note
|
||||||
|
notes = COALESCE(notes, '') ||
|
||||||
|
E'\n[Transfer ' || now()::date || '] ' ||
|
||||||
|
v_source_name || ' → ' || v_target_name ||
|
||||||
|
COALESCE(E' — ' || p_reason, '') ||
|
||||||
|
CASE WHEN v_active_courses > 0
|
||||||
|
THEN E'\n ↳ ' || v_active_courses || ' aktive Kurseinschreibungen bleiben erhalten'
|
||||||
|
ELSE '' END ||
|
||||||
|
CASE WHEN v_active_events > 0
|
||||||
|
THEN E'\n ↳ ' || v_active_events || ' aktive Veranstaltungsanmeldungen bleiben erhalten'
|
||||||
|
ELSE '' END,
|
||||||
|
updated_by = v_caller_id,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_member_id;
|
||||||
|
|
||||||
|
-- Log the transfer with snapshot of cleared data
|
||||||
|
INSERT INTO public.member_transfers (
|
||||||
|
member_id, source_account_id, target_account_id, transferred_by, reason, cleared_data
|
||||||
|
) VALUES (
|
||||||
|
p_member_id,
|
||||||
|
v_source_account_id,
|
||||||
|
p_target_account_id,
|
||||||
|
v_caller_id,
|
||||||
|
p_reason,
|
||||||
|
jsonb_build_object(
|
||||||
|
'old_member_number', v_old_member_number,
|
||||||
|
'old_dues_category_id', v_old_dues_category_id,
|
||||||
|
'old_sepa_mandate_id', v_old_sepa_mandate_id,
|
||||||
|
'active_courses_at_transfer', v_active_courses,
|
||||||
|
'active_events_at_transfer', v_active_events,
|
||||||
|
'sepa_kept', p_keep_sepa
|
||||||
|
)
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_transfer_id;
|
||||||
|
|
||||||
|
RETURN v_transfer_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.transfer_member(uuid, uuid, text, boolean)
|
||||||
|
TO authenticated, service_role;
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/*
|
||||||
|
* -------------------------------------------------------
|
||||||
|
* Feature: Shared Events, Consolidated SEPA, Reporting
|
||||||
|
*
|
||||||
|
* 1. Shared events: flag on events to share across hierarchy
|
||||||
|
* 2. Consolidated SEPA: Verband-level batch across child accounts
|
||||||
|
* 3. Reporting: aggregated stats RPC functions
|
||||||
|
* -------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 1. Shared Events
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Flag to share an event with the entire hierarchy
|
||||||
|
ALTER TABLE public.events
|
||||||
|
ADD COLUMN IF NOT EXISTS shared_with_hierarchy boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Hierarchy-aware event listing: events visible to current user
|
||||||
|
-- either directly (own account) or via hierarchy sharing
|
||||||
|
CREATE OR REPLACE FUNCTION public.list_hierarchy_events(
|
||||||
|
root_account_id uuid,
|
||||||
|
p_from_date date DEFAULT NULL,
|
||||||
|
p_status text DEFAULT NULL,
|
||||||
|
p_shared_only boolean DEFAULT false,
|
||||||
|
p_page int DEFAULT 1,
|
||||||
|
p_page_size int DEFAULT 25
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id uuid,
|
||||||
|
account_id uuid,
|
||||||
|
account_name varchar,
|
||||||
|
name text,
|
||||||
|
description text,
|
||||||
|
event_date date,
|
||||||
|
event_time time,
|
||||||
|
end_date date,
|
||||||
|
location text,
|
||||||
|
capacity integer,
|
||||||
|
fee numeric,
|
||||||
|
status text,
|
||||||
|
registration_deadline date,
|
||||||
|
registration_count bigint,
|
||||||
|
shared_with_hierarchy boolean,
|
||||||
|
total_count bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_offset int;
|
||||||
|
v_total bigint;
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(root_account_id) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_offset := (p_page - 1) * p_page_size;
|
||||||
|
|
||||||
|
-- Count
|
||||||
|
SELECT count(*) INTO v_total
|
||||||
|
FROM public.events e
|
||||||
|
WHERE e.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
|
||||||
|
AND (NOT p_shared_only OR e.shared_with_hierarchy = true)
|
||||||
|
AND (p_from_date IS NULL OR e.event_date >= p_from_date)
|
||||||
|
AND (p_status IS NULL OR e.status = p_status);
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
e.name,
|
||||||
|
e.description,
|
||||||
|
e.event_date,
|
||||||
|
e.event_time,
|
||||||
|
e.end_date,
|
||||||
|
e.location,
|
||||||
|
e.capacity,
|
||||||
|
e.fee,
|
||||||
|
e.status,
|
||||||
|
e.registration_deadline,
|
||||||
|
(SELECT count(*) FROM public.event_registrations er WHERE er.event_id = e.id AND er.status IN ('confirmed', 'pending'))::bigint AS registration_count,
|
||||||
|
e.shared_with_hierarchy,
|
||||||
|
v_total AS total_count
|
||||||
|
FROM public.events e
|
||||||
|
JOIN public.accounts a ON a.id = e.account_id
|
||||||
|
WHERE e.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
|
||||||
|
AND (NOT p_shared_only OR e.shared_with_hierarchy = true)
|
||||||
|
AND (p_from_date IS NULL OR e.event_date >= p_from_date)
|
||||||
|
AND (p_status IS NULL OR e.status = p_status)
|
||||||
|
ORDER BY e.event_date ASC
|
||||||
|
LIMIT p_page_size OFFSET v_offset;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.list_hierarchy_events(uuid, date, text, boolean, int, int)
|
||||||
|
TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 2. Consolidated SEPA Billing
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Track which child accounts are included in a consolidated batch
|
||||||
|
ALTER TABLE public.sepa_batches
|
||||||
|
ADD COLUMN IF NOT EXISTS is_consolidated boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- RPC: gather members with active SEPA mandates across hierarchy
|
||||||
|
CREATE OR REPLACE FUNCTION public.list_hierarchy_sepa_eligible_members(
|
||||||
|
root_account_id uuid,
|
||||||
|
p_account_filter uuid DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
member_id uuid,
|
||||||
|
account_id uuid,
|
||||||
|
account_name varchar,
|
||||||
|
first_name text,
|
||||||
|
last_name text,
|
||||||
|
iban text,
|
||||||
|
bic text,
|
||||||
|
account_holder text,
|
||||||
|
mandate_id text,
|
||||||
|
mandate_date date,
|
||||||
|
dues_amount numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(root_account_id) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
m.id AS member_id,
|
||||||
|
m.account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
m.first_name,
|
||||||
|
m.last_name,
|
||||||
|
m.iban,
|
||||||
|
m.bic,
|
||||||
|
m.account_holder,
|
||||||
|
m.sepa_mandate_id AS mandate_id,
|
||||||
|
m.sepa_mandate_date AS mandate_date,
|
||||||
|
COALESCE(dc.amount, 0) AS dues_amount
|
||||||
|
FROM public.members m
|
||||||
|
JOIN public.accounts a ON a.id = m.account_id
|
||||||
|
LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id
|
||||||
|
WHERE m.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
|
||||||
|
AND m.status = 'active'
|
||||||
|
AND m.iban IS NOT NULL
|
||||||
|
AND m.sepa_mandate_status = 'active'
|
||||||
|
AND (p_account_filter IS NULL OR m.account_id = p_account_filter)
|
||||||
|
ORDER BY a.name, m.last_name, m.first_name;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.list_hierarchy_sepa_eligible_members(uuid, uuid)
|
||||||
|
TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 3. Aggregated Reporting
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Comprehensive stats across the hierarchy
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_hierarchy_report(
|
||||||
|
root_account_id uuid
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
org_id uuid,
|
||||||
|
org_name varchar,
|
||||||
|
org_slug text,
|
||||||
|
depth int,
|
||||||
|
active_members bigint,
|
||||||
|
inactive_members bigint,
|
||||||
|
total_members bigint,
|
||||||
|
new_members_this_year bigint,
|
||||||
|
active_courses bigint,
|
||||||
|
upcoming_events bigint,
|
||||||
|
open_invoices bigint,
|
||||||
|
open_invoice_amount numeric,
|
||||||
|
sepa_batches_this_year bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(root_account_id) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH descendants AS (
|
||||||
|
SELECT d AS id FROM public.get_account_descendants(root_account_id) d
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
a.id AS org_id,
|
||||||
|
a.name AS org_name,
|
||||||
|
a.slug AS org_slug,
|
||||||
|
(SELECT public.get_account_depth(a.id)) AS depth,
|
||||||
|
-- Members
|
||||||
|
(SELECT count(*) FROM public.members m WHERE m.account_id = a.id AND m.status = 'active')::bigint AS active_members,
|
||||||
|
(SELECT count(*) FROM public.members m WHERE m.account_id = a.id AND m.status != 'active')::bigint AS inactive_members,
|
||||||
|
(SELECT count(*) FROM public.members m WHERE m.account_id = a.id)::bigint AS total_members,
|
||||||
|
(SELECT count(*) FROM public.members m WHERE m.account_id = a.id AND m.entry_date >= date_trunc('year', current_date))::bigint AS new_members_this_year,
|
||||||
|
-- Courses
|
||||||
|
(SELECT count(*) FROM public.courses c WHERE c.account_id = a.id AND c.end_date >= current_date)::bigint AS active_courses,
|
||||||
|
-- Events
|
||||||
|
(SELECT count(*) FROM public.events e WHERE e.account_id = a.id AND e.event_date >= current_date AND e.status IN ('planned', 'open'))::bigint AS upcoming_events,
|
||||||
|
-- Finance
|
||||||
|
(SELECT count(*) FROM public.invoices i WHERE i.account_id = a.id AND i.status IN ('sent', 'overdue'))::bigint AS open_invoices,
|
||||||
|
COALESCE((SELECT sum(i.total_amount - i.paid_amount) FROM public.invoices i WHERE i.account_id = a.id AND i.status IN ('sent', 'overdue')), 0) AS open_invoice_amount,
|
||||||
|
(SELECT count(*) FROM public.sepa_batches sb WHERE sb.account_id = a.id AND sb.created_at >= date_trunc('year', current_date))::bigint AS sepa_batches_this_year
|
||||||
|
FROM public.accounts a
|
||||||
|
JOIN descendants d ON d.id = a.id
|
||||||
|
WHERE a.is_personal_account = false
|
||||||
|
ORDER BY a.name;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_hierarchy_report(uuid)
|
||||||
|
TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- Summary totals across entire hierarchy
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_hierarchy_summary(
|
||||||
|
root_account_id uuid
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_orgs bigint,
|
||||||
|
total_active_members bigint,
|
||||||
|
total_members bigint,
|
||||||
|
new_members_this_year bigint,
|
||||||
|
total_upcoming_events bigint,
|
||||||
|
total_active_courses bigint,
|
||||||
|
total_open_invoices bigint,
|
||||||
|
total_open_invoice_amount numeric,
|
||||||
|
total_sepa_batches_this_year bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(root_account_id) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH desc_ids AS (
|
||||||
|
SELECT d AS id FROM public.get_account_descendants(root_account_id) d
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT count(*) FROM desc_ids di JOIN public.accounts ac ON ac.id = di.id WHERE ac.is_personal_account = false)::bigint,
|
||||||
|
(SELECT count(*) FROM public.members m WHERE m.account_id IN (SELECT di.id FROM desc_ids di) AND m.status = 'active')::bigint,
|
||||||
|
(SELECT count(*) FROM public.members m WHERE m.account_id IN (SELECT di.id FROM desc_ids di))::bigint,
|
||||||
|
(SELECT count(*) FROM public.members m WHERE m.account_id IN (SELECT di.id FROM desc_ids di) AND m.entry_date >= date_trunc('year', current_date))::bigint,
|
||||||
|
(SELECT count(*) FROM public.events e WHERE e.account_id IN (SELECT di.id FROM desc_ids di) AND e.event_date >= current_date AND e.status IN ('planned', 'open'))::bigint,
|
||||||
|
(SELECT count(*) FROM public.courses c WHERE c.account_id IN (SELECT di.id FROM desc_ids di) AND c.end_date >= current_date)::bigint,
|
||||||
|
(SELECT count(*) FROM public.invoices i WHERE i.account_id IN (SELECT di.id FROM desc_ids di) AND i.status IN ('sent', 'overdue'))::bigint,
|
||||||
|
COALESCE((SELECT sum(i.total_amount - i.paid_amount) FROM public.invoices i WHERE i.account_id IN (SELECT di.id FROM desc_ids di) AND i.status IN ('sent', 'overdue')), 0),
|
||||||
|
(SELECT count(*) FROM public.sepa_batches sb WHERE sb.account_id IN (SELECT di.id FROM desc_ids di) AND sb.created_at >= date_trunc('year', current_date))::bigint;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_hierarchy_summary(uuid)
|
||||||
|
TO authenticated, service_role;
|
||||||
282
apps/web/supabase/migrations/20260414000006_shared_templates.sql
Normal file
282
apps/web/supabase/migrations/20260414000006_shared_templates.sql
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/*
|
||||||
|
* -------------------------------------------------------
|
||||||
|
* Shared Templates Across Hierarchy
|
||||||
|
*
|
||||||
|
* 1. Add shared_with_hierarchy flag to newsletter_templates
|
||||||
|
* 2. Create document_templates table with hierarchy sharing
|
||||||
|
* 3. Template cloning function for child orgs
|
||||||
|
* 4. Hierarchy-aware RLS for template reads
|
||||||
|
* -------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 1. Newsletter Templates: add sharing flag
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
ALTER TABLE public.newsletter_templates
|
||||||
|
ADD COLUMN IF NOT EXISTS shared_with_hierarchy boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Allow child orgs to read parent's shared templates
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_policies
|
||||||
|
WHERE schemaname = 'public' AND tablename = 'newsletter_templates'
|
||||||
|
AND policyname = 'newsletter_templates_hierarchy_read'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'CREATE POLICY newsletter_templates_hierarchy_read
|
||||||
|
ON public.newsletter_templates
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
shared_with_hierarchy = true
|
||||||
|
AND public.has_role_on_account_or_ancestor(account_id)
|
||||||
|
)';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 2. Document Templates table
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.document_templates (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
template_type text NOT NULL DEFAULT 'generic'
|
||||||
|
CHECK (template_type IN (
|
||||||
|
'generic', 'member_card', 'invoice', 'receipt',
|
||||||
|
'certificate', 'letter', 'label', 'report'
|
||||||
|
)),
|
||||||
|
-- Template content: HTML with variable placeholders like {{member.first_name}}
|
||||||
|
body_html text NOT NULL DEFAULT '',
|
||||||
|
-- Available variables for this template
|
||||||
|
variables jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
-- Page settings
|
||||||
|
page_format text NOT NULL DEFAULT 'A4'
|
||||||
|
CHECK (page_format IN ('A4', 'A5', 'A6', 'letter', 'label')),
|
||||||
|
orientation text NOT NULL DEFAULT 'portrait'
|
||||||
|
CHECK (orientation IN ('portrait', 'landscape')),
|
||||||
|
-- Hierarchy sharing
|
||||||
|
shared_with_hierarchy boolean NOT NULL DEFAULT false,
|
||||||
|
-- Meta
|
||||||
|
is_default boolean NOT NULL DEFAULT false,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_document_templates_account
|
||||||
|
ON public.document_templates(account_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_document_templates_type
|
||||||
|
ON public.document_templates(account_id, template_type);
|
||||||
|
|
||||||
|
ALTER TABLE public.document_templates ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
REVOKE ALL ON public.document_templates FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.document_templates TO authenticated;
|
||||||
|
GRANT ALL ON public.document_templates TO service_role;
|
||||||
|
|
||||||
|
-- Own account read/write
|
||||||
|
CREATE POLICY document_templates_select ON public.document_templates
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
CREATE POLICY document_templates_mutate ON public.document_templates
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'documents.generate'::public.app_permissions));
|
||||||
|
|
||||||
|
-- Hierarchy: child orgs can read parent's shared templates
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_policies
|
||||||
|
WHERE schemaname = 'public' AND tablename = 'document_templates'
|
||||||
|
AND policyname = 'document_templates_hierarchy_read'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'CREATE POLICY document_templates_hierarchy_read
|
||||||
|
ON public.document_templates
|
||||||
|
FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
shared_with_hierarchy = true
|
||||||
|
AND public.has_role_on_account_or_ancestor(account_id)
|
||||||
|
)';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_document_templates_updated_at
|
||||||
|
BEFORE UPDATE ON public.document_templates
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_account_settings_timestamp();
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 3. Template cloning function
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Clone a shared template into a child org's own templates
|
||||||
|
CREATE OR REPLACE FUNCTION public.clone_template(
|
||||||
|
p_template_type text, -- 'newsletter' or 'document'
|
||||||
|
p_template_id uuid,
|
||||||
|
p_target_account_id uuid,
|
||||||
|
p_new_name text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_new_id uuid;
|
||||||
|
v_caller_id uuid;
|
||||||
|
v_source_account_id uuid;
|
||||||
|
BEGIN
|
||||||
|
v_caller_id := (SELECT auth.uid());
|
||||||
|
|
||||||
|
-- Verify caller has a role on the target account
|
||||||
|
IF NOT public.has_role_on_account(p_target_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Keine Berechtigung für die Zielorganisation';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_template_type = 'newsletter' THEN
|
||||||
|
-- Get source account for verification
|
||||||
|
SELECT account_id INTO v_source_account_id
|
||||||
|
FROM public.newsletter_templates WHERE id = p_template_id;
|
||||||
|
|
||||||
|
IF v_source_account_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Vorlage nicht gefunden';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Must be shared or from own account
|
||||||
|
IF v_source_account_id != p_target_account_id THEN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.newsletter_templates
|
||||||
|
WHERE id = p_template_id AND shared_with_hierarchy = true
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Vorlage ist nicht für die Hierarchie freigegeben';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.newsletter_templates (
|
||||||
|
account_id, name, subject, body_html, body_text, variables
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p_target_account_id,
|
||||||
|
COALESCE(p_new_name, name || ' (Kopie)'),
|
||||||
|
subject, body_html, body_text, variables
|
||||||
|
FROM public.newsletter_templates
|
||||||
|
WHERE id = p_template_id
|
||||||
|
RETURNING id INTO v_new_id;
|
||||||
|
|
||||||
|
ELSIF p_template_type = 'document' THEN
|
||||||
|
SELECT account_id INTO v_source_account_id
|
||||||
|
FROM public.document_templates WHERE id = p_template_id;
|
||||||
|
|
||||||
|
IF v_source_account_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Vorlage nicht gefunden';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_source_account_id != p_target_account_id THEN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.document_templates
|
||||||
|
WHERE id = p_template_id AND shared_with_hierarchy = true
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Vorlage ist nicht für die Hierarchie freigegeben';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.document_templates (
|
||||||
|
account_id, name, description, template_type,
|
||||||
|
body_html, variables, page_format, orientation, created_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p_target_account_id,
|
||||||
|
COALESCE(p_new_name, name || ' (Kopie)'),
|
||||||
|
description, template_type,
|
||||||
|
body_html, variables, page_format, orientation, v_caller_id
|
||||||
|
FROM public.document_templates
|
||||||
|
WHERE id = p_template_id
|
||||||
|
RETURNING id INTO v_new_id;
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION 'Ungültiger Vorlagentyp: %', p_template_type;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN v_new_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.clone_template(text, uuid, uuid, text)
|
||||||
|
TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 4. List shared templates across hierarchy
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.list_hierarchy_shared_templates(
|
||||||
|
root_account_id uuid,
|
||||||
|
p_template_type text DEFAULT NULL -- 'newsletter', 'document', or NULL for both
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id uuid,
|
||||||
|
account_id uuid,
|
||||||
|
account_name varchar,
|
||||||
|
template_source text, -- 'newsletter' or 'document'
|
||||||
|
name text,
|
||||||
|
description text,
|
||||||
|
template_type text,
|
||||||
|
shared_with_hierarchy boolean,
|
||||||
|
created_at timestamptz
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(root_account_id) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
-- Newsletter templates
|
||||||
|
SELECT
|
||||||
|
nt.id,
|
||||||
|
nt.account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
'newsletter'::text AS template_source,
|
||||||
|
nt.name,
|
||||||
|
NULL::text AS description,
|
||||||
|
'newsletter'::text AS template_type,
|
||||||
|
nt.shared_with_hierarchy,
|
||||||
|
nt.created_at
|
||||||
|
FROM public.newsletter_templates nt
|
||||||
|
JOIN public.accounts a ON a.id = nt.account_id
|
||||||
|
WHERE nt.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
|
||||||
|
AND nt.shared_with_hierarchy = true
|
||||||
|
AND (p_template_type IS NULL OR p_template_type = 'newsletter')
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Document templates
|
||||||
|
SELECT
|
||||||
|
dt.id,
|
||||||
|
dt.account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
'document'::text AS template_source,
|
||||||
|
dt.name,
|
||||||
|
dt.description,
|
||||||
|
dt.template_type,
|
||||||
|
dt.shared_with_hierarchy,
|
||||||
|
dt.created_at
|
||||||
|
FROM public.document_templates dt
|
||||||
|
JOIN public.accounts a ON a.id = dt.account_id
|
||||||
|
WHERE dt.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
|
||||||
|
AND dt.shared_with_hierarchy = true
|
||||||
|
AND (p_template_type IS NULL OR p_template_type = 'document')
|
||||||
|
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.list_hierarchy_shared_templates(uuid, text)
|
||||||
|
TO authenticated, service_role;
|
||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: postgres
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
entrypoint: ['/bin/sh', '-c']
|
||||||
command:
|
command:
|
||||||
- |
|
- |
|
||||||
echo "🔑 Ensuring role passwords are set (idempotent)..."
|
echo "🔑 Ensuring role passwords are set (idempotent)..."
|
||||||
@@ -63,7 +63,7 @@ services:
|
|||||||
echo "✅ App migrations complete."
|
echo "✅ App migrations complete."
|
||||||
echo ""
|
echo ""
|
||||||
sh /app-seed/dev-bootstrap.sh
|
sh /app-seed/dev-bootstrap.sh
|
||||||
restart: "no"
|
restart: 'no'
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# Supabase Auth (GoTrue)
|
# Supabase Auth (GoTrue)
|
||||||
@@ -102,7 +102,15 @@ services:
|
|||||||
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
|
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
|
||||||
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
|
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'wget',
|
||||||
|
'--no-verbose',
|
||||||
|
'--tries=1',
|
||||||
|
'--spider',
|
||||||
|
'http://localhost:9999/health',
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -123,9 +131,9 @@ services:
|
|||||||
PGRST_DB_SCHEMAS: public,storage,graphql_public
|
PGRST_DB_SCHEMAS: public,storage,graphql_public
|
||||||
PGRST_DB_ANON_ROLE: anon
|
PGRST_DB_ANON_ROLE: anon
|
||||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
PGRST_DB_USE_LEGACY_GUCS: 'false'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "head -c0 </dev/tcp/localhost/3000 || exit 1"]
|
test: ['CMD-SHELL', 'head -c0 </dev/tcp/localhost/3000 || exit 1']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -149,20 +157,20 @@ services:
|
|||||||
DB_USER: supabase_admin
|
DB_USER: supabase_admin
|
||||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
DB_NAME: postgres
|
DB_NAME: postgres
|
||||||
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
|
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
|
||||||
DB_ENC_KEY: supabaserealtime
|
DB_ENC_KEY: supabaserealtime
|
||||||
API_JWT_SECRET: ${JWT_SECRET}
|
API_JWT_SECRET: ${JWT_SECRET}
|
||||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq}
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq}
|
||||||
ERL_AFLAGS: "-proto_dist inet_tcp"
|
ERL_AFLAGS: '-proto_dist inet_tcp'
|
||||||
DNS_NODES: "''"
|
DNS_NODES: "''"
|
||||||
RLIMIT_NOFILE: "10000"
|
RLIMIT_NOFILE: '10000'
|
||||||
APP_NAME: realtime
|
APP_NAME: realtime
|
||||||
SEED_SELF_HOST: "true"
|
SEED_SELF_HOST: 'true'
|
||||||
REPLICATION_MODE: RLS
|
REPLICATION_MODE: RLS
|
||||||
REPLICATION_POLL_INTERVAL: 100
|
REPLICATION_POLL_INTERVAL: 100
|
||||||
SECURE_CHANNELS: "true"
|
SECURE_CHANNELS: 'true'
|
||||||
SLOT_NAME: supabase_realtime_rls
|
SLOT_NAME: supabase_realtime_rls
|
||||||
TEMPORARY_SLOT: "true"
|
TEMPORARY_SLOT: 'true'
|
||||||
MAX_RECORD_BYTES: 1048576
|
MAX_RECORD_BYTES: 1048576
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
@@ -196,7 +204,11 @@ services:
|
|||||||
GLOBAL_S3_BUCKET: stub
|
GLOBAL_S3_BUCKET: stub
|
||||||
IMGPROXY_URL: http://supabase-imgproxy:8080
|
IMGPROXY_URL: http://supabase-imgproxy:8080
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5000/status || exit 1"]
|
test:
|
||||||
|
[
|
||||||
|
'CMD-SHELL',
|
||||||
|
'wget --no-verbose --tries=1 --spider http://localhost:5000/status || exit 1',
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -209,10 +221,10 @@ services:
|
|||||||
image: darthsim/imgproxy:v3.8.0
|
image: darthsim/imgproxy:v3.8.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
IMGPROXY_BIND: ":8080"
|
IMGPROXY_BIND: ':8080'
|
||||||
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
||||||
IMGPROXY_USE_ETAG: "true"
|
IMGPROXY_USE_ETAG: 'true'
|
||||||
IMGPROXY_ENABLE_WEBP_DETECTION: "true"
|
IMGPROXY_ENABLE_WEBP_DETECTION: 'true'
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# Supabase pg_meta (DB introspection for Studio)
|
# Supabase pg_meta (DB introspection for Studio)
|
||||||
@@ -252,10 +264,16 @@ services:
|
|||||||
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||||
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
AUTH_JWT_SECRET: ${JWT_SECRET}
|
AUTH_JWT_SECRET: ${JWT_SECRET}
|
||||||
NEXT_PUBLIC_ENABLE_LOGS: "true"
|
NEXT_PUBLIC_ENABLE_LOGS: 'true'
|
||||||
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
|
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/profile', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"]
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'node',
|
||||||
|
'-e',
|
||||||
|
"require('http').get('http://localhost:3000/api/profile', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))",
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -284,7 +302,7 @@ services:
|
|||||||
entrypoint: >
|
entrypoint: >
|
||||||
sh -c "sed 's|\$${SUPABASE_ANON_KEY}|'\"$$SUPABASE_ANON_KEY\"'|g; s|\$${SUPABASE_SERVICE_KEY}|'\"$$SUPABASE_SERVICE_KEY\"'|g' /var/lib/kong/kong.yml.tpl > /tmp/kong.yml && KONG_DECLARATIVE_CONFIG=/tmp/kong.yml /docker-entrypoint.sh kong docker-start"
|
sh -c "sed 's|\$${SUPABASE_ANON_KEY}|'\"$$SUPABASE_ANON_KEY\"'|g; s|\$${SUPABASE_SERVICE_KEY}|'\"$$SUPABASE_SERVICE_KEY\"'|g' /var/lib/kong/kong.yml.tpl > /tmp/kong.yml && KONG_DECLARATIVE_CONFIG=/tmp/kong.yml /docker-entrypoint.sh kong docker-start"
|
||||||
environment:
|
environment:
|
||||||
KONG_DATABASE: "off"
|
KONG_DATABASE: 'off'
|
||||||
KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
|
KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
|
||||||
KONG_DNS_ORDER: LAST,A,CNAME
|
KONG_DNS_ORDER: LAST,A,CNAME
|
||||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
||||||
@@ -295,7 +313,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./docker/kong.yml:/var/lib/kong/kong.yml.tpl:ro
|
- ./docker/kong.yml:/var/lib/kong/kong.yml.tpl:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "kong", "health"]
|
test: ['CMD', 'kong', 'health']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -331,12 +349,12 @@ services:
|
|||||||
SUPABASE_DB_WEBHOOK_SECRET: ${DB_WEBHOOK_SECRET:-webhooksecret}
|
SUPABASE_DB_WEBHOOK_SECRET: ${DB_WEBHOOK_SECRET:-webhooksecret}
|
||||||
EMAIL_SENDER: ${EMAIL_SENDER:-noreply@myeasycms.de}
|
EMAIL_SENDER: ${EMAIL_SENDER:-noreply@myeasycms.de}
|
||||||
NEXT_PUBLIC_PRODUCT_NAME: MyEasyCMS
|
NEXT_PUBLIC_PRODUCT_NAME: MyEasyCMS
|
||||||
NEXT_PUBLIC_ENABLE_THEME_TOGGLE: "true"
|
NEXT_PUBLIC_ENABLE_THEME_TOGGLE: 'true'
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS: "true"
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS: 'true'
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION: "true"
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION: 'true'
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: "false"
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: 'false'
|
||||||
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: "false"
|
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: 'false'
|
||||||
NEXT_PUBLIC_ENABLE_NOTIFICATIONS: "true"
|
NEXT_PUBLIC_ENABLE_NOTIFICATIONS: 'true'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
supabase-db-data:
|
supabase-db-data:
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export function ClubContactsManager({
|
|||||||
<CardTitle className="text-base">Ansprechpartner</CardTitle>
|
<CardTitle className="text-base">Ansprechpartner</CardTitle>
|
||||||
{!showForm && (
|
{!showForm && (
|
||||||
<Button
|
<Button
|
||||||
|
data-test="contact-add-btn"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@@ -240,6 +241,7 @@ export function ClubContactsManager({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
data-test="contact-cancel-btn"
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -249,7 +251,11 @@ export function ClubContactsManager({
|
|||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isCreating || isUpdating}>
|
<Button
|
||||||
|
data-test="contact-submit-btn"
|
||||||
|
type="submit"
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
|
>
|
||||||
{isCreating || isUpdating
|
{isCreating || isUpdating
|
||||||
? 'Wird gespeichert...'
|
? 'Wird gespeichert...'
|
||||||
: editingId
|
: editingId
|
||||||
@@ -301,6 +307,7 @@ export function ClubContactsManager({
|
|||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
data-test="contact-edit-btn"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleEdit(contact)}
|
onClick={() => handleEdit(contact)}
|
||||||
@@ -308,6 +315,7 @@ export function ClubContactsManager({
|
|||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-test="contact-delete-btn"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -93,30 +93,38 @@ export function ClubsDataTable({
|
|||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<form onSubmit={handleSearch} className="flex gap-2">
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
data-test="clubs-search-input"
|
||||||
placeholder="Verein suchen..."
|
placeholder="Verein suchen..."
|
||||||
className="w-64"
|
className="w-64"
|
||||||
{...form.register('search')}
|
{...form.register('search')}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="outline" size="sm">
|
<Button
|
||||||
|
data-test="clubs-search-btn"
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
Suchen
|
Suchen
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<select
|
<select
|
||||||
|
data-test="clubs-type-filter"
|
||||||
value={currentType}
|
value={currentType}
|
||||||
onChange={handleTypeChange}
|
onChange={handleTypeChange}
|
||||||
className="border-input bg-background flex h-9 rounded-md border 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>
|
<option value="">Alle Typen</option>
|
||||||
{types.map((t) => (
|
{types.map((associationType) => (
|
||||||
<option key={t.id} value={t.id}>
|
<option key={associationType.id} value={associationType.id}>
|
||||||
{t.name}
|
{associationType.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
data-test="clubs-archive-toggle"
|
||||||
variant={showArchived ? 'secondary' : 'outline'}
|
variant={showArchived ? 'secondary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleArchivedToggle}
|
onClick={handleArchivedToggle}
|
||||||
@@ -125,7 +133,7 @@ export function ClubsDataTable({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Link href={`/home/${account}/verband/clubs/new`}>
|
<Link href={`/home/${account}/verband/clubs/new`}>
|
||||||
<Button size="sm">
|
<Button data-test="clubs-new-btn" size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Neuer Verein
|
Neuer Verein
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -128,9 +128,12 @@ export function CreateClubForm({
|
|||||||
className="border-input bg-background flex h-10 w-full rounded-md border 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="">— Kein Typ —</option>
|
<option value="">— Kein Typ —</option>
|
||||||
{types.map((t) => (
|
{types.map((associationType) => (
|
||||||
<option key={t.id} value={t.id}>
|
<option
|
||||||
{t.name}
|
key={associationType.id}
|
||||||
|
value={associationType.id}
|
||||||
|
>
|
||||||
|
{associationType.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ArrowRightLeft, Search, Users } from 'lucide-react';
|
||||||
|
import { useAction } from 'next-safe-action/hooks';
|
||||||
|
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';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { Textarea } from '@kit/ui/textarea';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTransferPreview,
|
||||||
|
transferMember,
|
||||||
|
} from '../server/actions/hierarchy-actions';
|
||||||
|
import type { CrossOrgMember, TransferPreview } from '../server/api';
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Aktiv',
|
||||||
|
inactive: 'Inaktiv',
|
||||||
|
pending: 'Ausstehend',
|
||||||
|
resigned: 'Ausgetreten',
|
||||||
|
excluded: 'Ausgeschlossen',
|
||||||
|
deceased: 'Verstorben',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'default',
|
||||||
|
inactive: 'secondary',
|
||||||
|
pending: 'outline',
|
||||||
|
resigned: 'secondary',
|
||||||
|
excluded: 'destructive',
|
||||||
|
deceased: 'secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CrossOrgMemberSearchProps {
|
||||||
|
account: string;
|
||||||
|
members: CrossOrgMember[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
childAccounts: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrossOrgMemberSearch({
|
||||||
|
account,
|
||||||
|
members,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
childAccounts,
|
||||||
|
}: CrossOrgMemberSearchProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [transferTarget, setTransferTarget] = useState<CrossOrgMember | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [targetAccountId, setTargetAccountId] = useState('');
|
||||||
|
const [transferReason, setTransferReason] = useState('');
|
||||||
|
const [keepSepa, setKeepSepa] = useState(true);
|
||||||
|
const [preview, setPreview] = useState<TransferPreview | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
|
||||||
|
const currentSearch = searchParams.get('q') ?? '';
|
||||||
|
const currentStatus = searchParams.get('status') ?? '';
|
||||||
|
const currentAccount = searchParams.get('accountId') ?? '';
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: { search: currentSearch },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute: executePreview } = useAction(getTransferPreview, {
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
if (data) setPreview(data);
|
||||||
|
setPreviewLoading(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execute: executeTransfer, isPending: isTransferring } = useAction(
|
||||||
|
transferMember,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Mitglied erfolgreich transferiert');
|
||||||
|
setTransferTarget(null);
|
||||||
|
setTargetAccountId('');
|
||||||
|
setTransferReason('');
|
||||||
|
setKeepSepa(true);
|
||||||
|
setPreview(null);
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Transfer');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildUrl = useCallback(
|
||||||
|
(params: Record<string, string | number | null>) => {
|
||||||
|
const urlSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(params)) {
|
||||||
|
if (val === null || val === '' || val === undefined) {
|
||||||
|
urlSearchParams.delete(key);
|
||||||
|
} else {
|
||||||
|
urlSearchParams.set(key, String(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/home/${account}/verband/members?${urlSearchParams.toString()}`;
|
||||||
|
},
|
||||||
|
[account, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(values: { search: string }) => {
|
||||||
|
router.push(buildUrl({ q: values.search || null, page: null }));
|
||||||
|
},
|
||||||
|
[router, buildUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStatusChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
router.push(buildUrl({ status: e.target.value || null, page: null }));
|
||||||
|
},
|
||||||
|
[router, buildUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAccountChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
router.push(buildUrl({ accountId: e.target.value || null, page: null }));
|
||||||
|
},
|
||||||
|
[router, buildUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
router.push(buildUrl({ page: newPage > 1 ? newPage : null }));
|
||||||
|
},
|
||||||
|
[router, buildUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleTransferConfirm() {
|
||||||
|
if (!transferTarget || !targetAccountId) return;
|
||||||
|
|
||||||
|
executeTransfer({
|
||||||
|
memberId: transferTarget.id,
|
||||||
|
targetAccountId,
|
||||||
|
reason: transferReason || undefined,
|
||||||
|
keepSepa,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts available as transfer targets (exclude member's current account)
|
||||||
|
const transferTargetAccounts = transferTarget
|
||||||
|
? childAccounts.filter((a) => a.id !== transferTarget.account_id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Search + Filters */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(handleSearch)}
|
||||||
|
className="flex flex-1 gap-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Name, E-Mail oder Mitgliedsnr. suchen..."
|
||||||
|
{...form.register('search')}
|
||||||
|
data-test="cross-org-search-input"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
data-test="cross-org-search-btn"
|
||||||
|
>
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
Suchen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={currentStatus}
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||||
|
data-test="cross-org-status-filter"
|
||||||
|
>
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([val, label]) => (
|
||||||
|
<option key={val} value={val}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{childAccounts.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={currentAccount}
|
||||||
|
onChange={handleAccountChange}
|
||||||
|
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||||
|
data-test="cross-org-account-filter"
|
||||||
|
>
|
||||||
|
<option value="">Alle Organisationen</option>
|
||||||
|
{childAccounts.map((childAccount) => (
|
||||||
|
<option key={childAccount.id} value={childAccount.id}>
|
||||||
|
{childAccount.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Mitglieder ({total})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{members.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 Mitglieder gefunden
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||||
|
{currentSearch
|
||||||
|
? 'Versuchen Sie einen anderen Suchbegriff.'
|
||||||
|
: 'In den verknüpften Organisationen sind noch keine Mitglieder vorhanden.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<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">Organisation</th>
|
||||||
|
<th className="p-3 text-left font-medium">E-Mail</th>
|
||||||
|
<th className="p-3 text-left font-medium">Ort</th>
|
||||||
|
<th className="p-3 text-center font-medium">Status</th>
|
||||||
|
<th className="p-3 text-left font-medium">Eintritt</th>
|
||||||
|
<th className="p-3 text-right font-medium">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<tr key={member.id} className="hover:bg-muted/30 border-b">
|
||||||
|
<td className="p-3 font-medium">
|
||||||
|
{member.last_name}, {member.first_name}
|
||||||
|
{member.member_number && (
|
||||||
|
<span className="text-muted-foreground ml-2 text-xs">
|
||||||
|
#{member.member_number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{member.account_slug ? (
|
||||||
|
<Link
|
||||||
|
href={`/home/${member.account_slug}/members-cms`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{member.account_name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span>{member.account_name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground p-3">
|
||||||
|
{member.email ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground p-3">
|
||||||
|
{member.city ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
(STATUS_COLORS[member.status] ?? 'outline') as
|
||||||
|
| 'default'
|
||||||
|
| 'secondary'
|
||||||
|
| 'destructive'
|
||||||
|
| 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[member.status] ?? member.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground p-3">
|
||||||
|
{formatDate(member.entry_date)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setTransferTarget(member);
|
||||||
|
setTargetAccountId('');
|
||||||
|
setTransferReason('');
|
||||||
|
setPreview(null);
|
||||||
|
setPreviewLoading(true);
|
||||||
|
executePreview({ memberId: member.id });
|
||||||
|
}}
|
||||||
|
title="Mitglied transferieren"
|
||||||
|
data-test="transfer-member-btn"
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Seite {page} von {totalPages} ({total} Einträge)
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Transfer Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!transferTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setTransferTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mitglied transferieren</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{transferTarget && (
|
||||||
|
<>
|
||||||
|
<strong>
|
||||||
|
{transferTarget.first_name} {transferTarget.last_name}
|
||||||
|
</strong>{' '}
|
||||||
|
wird von <strong>{transferTarget.account_name}</strong> in
|
||||||
|
eine andere Organisation verschoben.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Preview: side effects */}
|
||||||
|
{previewLoading && (
|
||||||
|
<div className="bg-muted/50 animate-pulse rounded-md p-4 text-center text-sm">
|
||||||
|
Lade Transfervorschau...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && !previewLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Active courses */}
|
||||||
|
{preview.courses.length > 0 && (
|
||||||
|
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-sm dark:border-blue-800 dark:bg-blue-950">
|
||||||
|
<p className="mb-1 font-medium text-blue-800 dark:text-blue-200">
|
||||||
|
{preview.courses.length} aktive Kurseinschreibung(en)
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
{preview.courses.map((course) => (
|
||||||
|
<li key={course.id}>
|
||||||
|
{course.name} ({course.accountName})
|
||||||
|
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||||
|
bleibt erhalten
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Open invoices */}
|
||||||
|
{preview.invoices.length > 0 && (
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950">
|
||||||
|
<p className="mb-1 font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
{preview.invoices.length} offene Rechnung(en)
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
{preview.invoices.map((inv) => (
|
||||||
|
<li key={inv.id}>
|
||||||
|
{inv.invoiceNumber} — {inv.amount.toFixed(2)} EUR (
|
||||||
|
{inv.accountName})
|
||||||
|
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||||
|
verbleibt beim Quellverein
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SEPA mandates */}
|
||||||
|
{preview.mandates.length > 0 && (
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950">
|
||||||
|
<p className="mb-1 font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
{preview.mandates.length} aktive(s) SEPA-Mandat(e)
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
{preview.mandates.map((mandate) => (
|
||||||
|
<li key={mandate.id}>
|
||||||
|
{mandate.reference} (Status: {mandate.status})
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="ml-2 text-[10px]"
|
||||||
|
>
|
||||||
|
wird zurückgesetzt
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Newsletters */}
|
||||||
|
{preview.newsletters.length > 0 && (
|
||||||
|
<div className="bg-muted/50 rounded-md border p-3 text-sm">
|
||||||
|
<p className="mb-1 font-medium">
|
||||||
|
{preview.newsletters.length} Newsletter-Abonnement(s)
|
||||||
|
</p>
|
||||||
|
<ul className="text-muted-foreground space-y-1 text-xs">
|
||||||
|
{preview.newsletters.map((newsletter) => (
|
||||||
|
<li key={newsletter.id}>
|
||||||
|
{newsletter.name} ({newsletter.accountName})
|
||||||
|
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||||
|
bleibt erhalten
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* What gets cleared */}
|
||||||
|
<div className="bg-muted/50 rounded-md p-3 text-sm">
|
||||||
|
<p className="mb-1 font-medium">Wird zurückgesetzt:</p>
|
||||||
|
<ul className="text-muted-foreground space-y-1 text-xs">
|
||||||
|
{preview.member.memberNumber && (
|
||||||
|
<li>
|
||||||
|
Mitgliedsnr.{' '}
|
||||||
|
<strong>#{preview.member.memberNumber}</strong> —
|
||||||
|
Neuvergabe im Zielverein nötig
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{preview.member.hasDuesCategory && (
|
||||||
|
<li>
|
||||||
|
Beitragskategorie — muss im Zielverein neu zugewiesen
|
||||||
|
werden
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
SEPA-Mandatstatus → "ausstehend" (Neubestätigung
|
||||||
|
nötig)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No side effects */}
|
||||||
|
{preview.courses.length === 0 &&
|
||||||
|
preview.invoices.length === 0 &&
|
||||||
|
preview.mandates.length === 0 &&
|
||||||
|
preview.newsletters.length === 0 && (
|
||||||
|
<div className="rounded-md border border-green-200 bg-green-50 p-3 text-sm dark:border-green-800 dark:bg-green-950">
|
||||||
|
<p className="font-medium text-green-800 dark:text-green-200">
|
||||||
|
Keine aktiven Verknüpfungen gefunden
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-green-700 dark:text-green-300">
|
||||||
|
Transfer kann ohne Seiteneffekte durchgeführt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="transfer-target" className="text-sm font-medium">
|
||||||
|
Zielorganisation
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="transfer-target"
|
||||||
|
value={targetAccountId}
|
||||||
|
onChange={(e) => setTargetAccountId(e.target.value)}
|
||||||
|
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||||
|
data-test="transfer-target-select"
|
||||||
|
>
|
||||||
|
<option value="">Organisation auswählen...</option>
|
||||||
|
{transferTargetAccounts.map((targetAccount) => (
|
||||||
|
<option key={targetAccount.id} value={targetAccount.id}>
|
||||||
|
{targetAccount.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={keepSepa}
|
||||||
|
onChange={(e) => setKeepSepa(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
data-test="transfer-keep-sepa"
|
||||||
|
/>
|
||||||
|
SEPA-Bankdaten (IBAN/BIC) übernehmen
|
||||||
|
</label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Bankverbindung wird übernommen, Mandat muss im Zielverein neu
|
||||||
|
bestätigt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="transfer-reason" className="text-sm font-medium">
|
||||||
|
Grund (optional)
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="transfer-reason"
|
||||||
|
value={transferReason}
|
||||||
|
onChange={(e) => setTransferReason(e.target.value)}
|
||||||
|
placeholder="z.B. Umzug, Vereinswechsel..."
|
||||||
|
rows={2}
|
||||||
|
data-test="transfer-reason-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setTransferTarget(null)}
|
||||||
|
disabled={isTransferring}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleTransferConfirm}
|
||||||
|
disabled={!targetAccountId || isTransferring}
|
||||||
|
data-test="transfer-confirm-btn"
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="mr-2 h-4 w-4" />
|
||||||
|
{isTransferring ? 'Wird transferiert...' : 'Transferieren'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { CalendarDays, Share2 } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
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 { Input } from '@kit/ui/input';
|
||||||
|
|
||||||
|
interface HierarchyEvent {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
account_name: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
event_date: string;
|
||||||
|
event_time: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
location: string | null;
|
||||||
|
capacity: number | null;
|
||||||
|
fee: number;
|
||||||
|
status: string;
|
||||||
|
registration_deadline: string | null;
|
||||||
|
registration_count: number;
|
||||||
|
shared_with_hierarchy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HierarchyEventsProps {
|
||||||
|
account: string;
|
||||||
|
events: HierarchyEvent[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
planned: 'Geplant',
|
||||||
|
open: 'Offen',
|
||||||
|
full: 'Ausgebucht',
|
||||||
|
running: 'Laufend',
|
||||||
|
completed: 'Abgeschlossen',
|
||||||
|
cancelled: 'Abgesagt',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
planned: 'outline',
|
||||||
|
open: 'default',
|
||||||
|
full: 'secondary',
|
||||||
|
running: 'default',
|
||||||
|
completed: 'secondary',
|
||||||
|
cancelled: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HierarchyEvents({
|
||||||
|
account,
|
||||||
|
events,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}: HierarchyEventsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const currentStatus = searchParams.get('status') ?? '';
|
||||||
|
const currentSharedOnly = searchParams.get('sharedOnly') === 'true';
|
||||||
|
const currentFromDate = searchParams.get('fromDate') ?? '';
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: { fromDate: currentFromDate },
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildUrl = useCallback(
|
||||||
|
(params: Record<string, string | number | boolean | null>) => {
|
||||||
|
const urlSearchParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(params)) {
|
||||||
|
if (val === null || val === '' || val === undefined || val === false) {
|
||||||
|
urlSearchParams.delete(key);
|
||||||
|
} else {
|
||||||
|
urlSearchParams.set(key, String(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/home/${account}/verband/events?${urlSearchParams.toString()}`;
|
||||||
|
},
|
||||||
|
[account, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStatusChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
router.push(buildUrl({ status: e.target.value || null, page: null }));
|
||||||
|
},
|
||||||
|
[router, buildUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSharedToggle = useCallback(() => {
|
||||||
|
router.push(
|
||||||
|
buildUrl({
|
||||||
|
sharedOnly: !currentSharedOnly || null,
|
||||||
|
page: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [router, buildUrl, currentSharedOnly]);
|
||||||
|
|
||||||
|
const handleDateFilter = useCallback(
|
||||||
|
(values: { fromDate: string }) => {
|
||||||
|
router.push(buildUrl({ fromDate: values.fromDate || null, page: null }));
|
||||||
|
},
|
||||||
|
[router, buildUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
router.push(buildUrl({ page: newPage > 1 ? newPage : null }));
|
||||||
|
},
|
||||||
|
[router, buildUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(handleDateFilter)}
|
||||||
|
className="flex flex-1 gap-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
{...form.register('fromDate')}
|
||||||
|
data-test="hierarchy-events-date-filter"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
data-test="hierarchy-events-date-btn"
|
||||||
|
>
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={currentStatus}
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||||
|
data-test="hierarchy-events-status-filter"
|
||||||
|
>
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([val, label]) => (
|
||||||
|
<option key={val} value={val}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={currentSharedOnly ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSharedToggle}
|
||||||
|
className="h-9"
|
||||||
|
data-test="hierarchy-events-shared-toggle"
|
||||||
|
>
|
||||||
|
<Share2 className="mr-2 h-4 w-4" />
|
||||||
|
Nur geteilte
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
Veranstaltungen ({total})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{events.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 Veranstaltungen gefunden
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||||
|
{currentStatus || currentSharedOnly || currentFromDate
|
||||||
|
? 'Versuchen Sie andere Filterkriterien.'
|
||||||
|
: 'In den verknüpften Organisationen sind noch keine Veranstaltungen vorhanden.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 border-b">
|
||||||
|
<th className="p-3 text-left font-medium">Veranstaltung</th>
|
||||||
|
<th className="p-3 text-left font-medium">Organisation</th>
|
||||||
|
<th className="p-3 text-left font-medium">Datum</th>
|
||||||
|
<th className="p-3 text-left font-medium">Ort</th>
|
||||||
|
<th className="p-3 text-center font-medium">Kapazität</th>
|
||||||
|
<th className="p-3 text-right font-medium">Gebühr</th>
|
||||||
|
<th className="p-3 text-center font-medium">Status</th>
|
||||||
|
<th className="p-3 text-center font-medium">Geteilt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.map((evt) => (
|
||||||
|
<tr key={evt.id} className="hover:bg-muted/30 border-b">
|
||||||
|
<td className="p-3 font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/home/${account}/verband/events`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{evt.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{evt.account_name}</td>
|
||||||
|
<td className="text-muted-foreground p-3">
|
||||||
|
{formatDate(evt.event_date)}
|
||||||
|
{evt.event_time && (
|
||||||
|
<span className="ml-1 text-xs">{evt.event_time}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground p-3">
|
||||||
|
{evt.location ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
evt.capacity &&
|
||||||
|
evt.registration_count >= evt.capacity
|
||||||
|
? 'font-semibold text-red-600'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{evt.registration_count}
|
||||||
|
</span>
|
||||||
|
{evt.capacity != null && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
/{evt.capacity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{formatCurrencyAmount(evt.fee)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
(STATUS_COLORS[evt.status] ?? 'outline') as
|
||||||
|
| 'default'
|
||||||
|
| 'secondary'
|
||||||
|
| 'destructive'
|
||||||
|
| 'outline'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[evt.status] ?? evt.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
{evt.shared_with_hierarchy && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
<Share2 className="mr-1 h-3 w-3" />
|
||||||
|
Geteilt
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Seite {page} von {totalPages} ({total} Einträge)
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Users,
|
||||||
|
CalendarDays,
|
||||||
|
BookOpen,
|
||||||
|
Euro,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { formatNumber, formatCurrencyAmount } from '@kit/shared/formatters';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
|
interface HierarchyReportProps {
|
||||||
|
summary: {
|
||||||
|
total_orgs: number;
|
||||||
|
total_active_members: number;
|
||||||
|
total_members: number;
|
||||||
|
new_members_this_year: number;
|
||||||
|
total_upcoming_events: number;
|
||||||
|
total_active_courses: number;
|
||||||
|
total_open_invoices: number;
|
||||||
|
total_open_invoice_amount: number;
|
||||||
|
total_sepa_batches_this_year: number;
|
||||||
|
};
|
||||||
|
report: Array<{
|
||||||
|
org_id: string;
|
||||||
|
org_name: string;
|
||||||
|
org_slug: string | null;
|
||||||
|
depth: number;
|
||||||
|
active_members: number;
|
||||||
|
inactive_members: number;
|
||||||
|
total_members: number;
|
||||||
|
new_members_this_year: number;
|
||||||
|
active_courses: number;
|
||||||
|
upcoming_events: number;
|
||||||
|
open_invoices: number;
|
||||||
|
open_invoice_amount: number;
|
||||||
|
sepa_batches_this_year: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDepthLabel(depth: number) {
|
||||||
|
switch (depth) {
|
||||||
|
case 0:
|
||||||
|
return 'Verband';
|
||||||
|
case 1:
|
||||||
|
return 'Unterverband';
|
||||||
|
default:
|
||||||
|
return 'Verein';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDepthVariant(depth: number) {
|
||||||
|
switch (depth) {
|
||||||
|
case 0:
|
||||||
|
return 'default' as const;
|
||||||
|
case 1:
|
||||||
|
return 'secondary' as const;
|
||||||
|
default:
|
||||||
|
return 'outline' as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HierarchyReport({ summary, report }: HierarchyReportProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-6">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Organisationen
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatNumber(summary.total_orgs)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Aktive Mitglieder
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatNumber(summary.total_active_members)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
von {formatNumber(summary.total_members)} gesamt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Neue Mitglieder (Jahr)
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatNumber(summary.new_members_this_year)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Anstehende Termine
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatNumber(summary.total_upcoming_events)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||||
|
<CalendarDays className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Aktive Kurse
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatNumber(summary.total_active_courses)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Offene Rechnungen
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatCurrencyAmount(summary.total_open_invoice_amount)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatNumber(summary.total_open_invoices)} Rechnungen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-primary/10 text-primary rounded-full p-3">
|
||||||
|
<Euro className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-Org Report Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bericht pro Organisation</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{report.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 Organisationen vorhanden
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
|
||||||
|
Die Hierarchie enthält noch keine Organisationen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<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">Ebene</th>
|
||||||
|
<th className="p-3 text-right font-medium">
|
||||||
|
Aktive Mitgl.
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-right font-medium">Gesamt</th>
|
||||||
|
<th className="p-3 text-right font-medium">Neu (Jahr)</th>
|
||||||
|
<th className="p-3 text-right font-medium">Kurse</th>
|
||||||
|
<th className="p-3 text-right font-medium">Termine</th>
|
||||||
|
<th className="p-3 text-right font-medium">
|
||||||
|
Offene Rechn.
|
||||||
|
</th>
|
||||||
|
<th className="p-3 text-right font-medium">
|
||||||
|
Offener Betrag
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.map((row) => (
|
||||||
|
<tr key={row.org_id} className="hover:bg-muted/30 border-b">
|
||||||
|
<td className="p-3 font-medium">
|
||||||
|
<span style={{ paddingLeft: `${row.depth * 1.25}rem` }}>
|
||||||
|
{row.org_name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge variant={getDepthVariant(row.depth)}>
|
||||||
|
{getDepthLabel(row.depth)}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{formatNumber(row.active_members)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{formatNumber(row.total_members)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{formatNumber(row.new_members_this_year)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{formatNumber(row.active_courses)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{formatNumber(row.upcoming_events)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{formatNumber(row.open_invoices)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
{formatCurrencyAmount(row.open_invoice_amount)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ function TreeNodeRow({
|
|||||||
>
|
>
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<button
|
<button
|
||||||
|
data-test="hierarchy-node-toggle"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -108,6 +109,7 @@ function TreeNodeRow({
|
|||||||
|
|
||||||
{!isRoot && (
|
{!isRoot && (
|
||||||
<Button
|
<Button
|
||||||
|
data-test="hierarchy-unlink-btn"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
||||||
@@ -260,20 +262,27 @@ export function HierarchyTree({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="link-account"
|
id="link-account"
|
||||||
|
data-test="hierarchy-link-select"
|
||||||
value={selectedAccountId}
|
value={selectedAccountId}
|
||||||
onChange={(e) => setSelectedAccountId(e.target.value)}
|
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"
|
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>
|
<option value="">Organisation auswählen...</option>
|
||||||
{availableAccounts.map((a) => (
|
{availableAccounts.map((availableAccount) => (
|
||||||
<option key={a.id} value={a.id}>
|
<option
|
||||||
{a.name}
|
key={availableAccount.id}
|
||||||
{a.slug ? ` (/${a.slug})` : ''}
|
value={availableAccount.id}
|
||||||
|
>
|
||||||
|
{availableAccount.name}
|
||||||
|
{availableAccount.slug
|
||||||
|
? ` (/${availableAccount.slug})`
|
||||||
|
: ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
data-test="hierarchy-link-btn"
|
||||||
onClick={handleLink}
|
onClick={handleLink}
|
||||||
disabled={!selectedAccountId || isLinking}
|
disabled={!selectedAccountId || isLinking}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ export { ClubContactsManager } from './club-contacts-manager';
|
|||||||
export { ClubFeeBillingTable } from './club-fee-billing-table';
|
export { ClubFeeBillingTable } from './club-fee-billing-table';
|
||||||
export { ClubNotesList } from './club-notes-list';
|
export { ClubNotesList } from './club-notes-list';
|
||||||
export { HierarchyTree } from './hierarchy-tree';
|
export { HierarchyTree } from './hierarchy-tree';
|
||||||
|
export { CrossOrgMemberSearch } from './cross-org-member-search';
|
||||||
|
export { HierarchyEvents } from './hierarchy-events';
|
||||||
|
export { HierarchyReport } from './hierarchy-report';
|
||||||
|
export { SharedTemplates } from './shared-templates';
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Copy, FileText, Mail } from 'lucide-react';
|
||||||
|
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@kit/ui/dialog';
|
||||||
|
import { Input } from '@kit/ui/input';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
|
||||||
|
import { cloneTemplate } from '../server/actions/hierarchy-actions';
|
||||||
|
|
||||||
|
interface SharedTemplate {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
account_name: string;
|
||||||
|
template_source: 'newsletter' | 'document';
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
template_type: string;
|
||||||
|
shared_with_hierarchy: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharedTemplatesProps {
|
||||||
|
accountId: string;
|
||||||
|
templates: SharedTemplate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'newsletter' | 'document';
|
||||||
|
|
||||||
|
export function SharedTemplates({
|
||||||
|
accountId,
|
||||||
|
templates,
|
||||||
|
}: SharedTemplatesProps) {
|
||||||
|
const [filter, setFilter] = useState<FilterType>('all');
|
||||||
|
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] =
|
||||||
|
useState<SharedTemplate | null>(null);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
|
const { execute: executeClone, isPending: isCloning } = useAction(
|
||||||
|
cloneTemplate,
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Vorlage wurde erfolgreich geklont');
|
||||||
|
setCloneDialogOpen(false);
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
setNewName('');
|
||||||
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
|
toast.error(error.serverError ?? 'Fehler beim Klonen der Vorlage');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTemplates = templates.filter((t) => {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
return t.template_source === filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCloneDialog(template: SharedTemplate) {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setNewName(`${template.name} (Kopie)`);
|
||||||
|
setCloneDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClone() {
|
||||||
|
if (!selectedTemplate) return;
|
||||||
|
|
||||||
|
executeClone({
|
||||||
|
templateType: selectedTemplate.template_source,
|
||||||
|
templateId: selectedTemplate.id,
|
||||||
|
targetAccountId: accountId,
|
||||||
|
newName: newName.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterButtons: { value: FilterType; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'Alle' },
|
||||||
|
{ value: 'newsletter', label: 'Newsletter' },
|
||||||
|
{ value: 'document', label: 'Dokumente' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getTemplateTypeLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
generic: 'Allgemein',
|
||||||
|
member_card: 'Mitgliedsausweis',
|
||||||
|
invoice: 'Rechnung',
|
||||||
|
certificate: 'Urkunde',
|
||||||
|
confirmation: 'Bestätigung',
|
||||||
|
letter: 'Brief',
|
||||||
|
};
|
||||||
|
return labels[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Geteilte Vorlagen</CardTitle>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{filterButtons.map((btn) => (
|
||||||
|
<Button
|
||||||
|
key={btn.value}
|
||||||
|
data-test={`templates-filter-${btn.value}`}
|
||||||
|
variant={filter === btn.value ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter(btn.value)}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{filteredTemplates.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<FileText className="mb-3 h-10 w-10 opacity-50" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Keine geteilten Vorlagen vorhanden
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
|
Vorlagen, die von anderen Organisationen in Ihrer Hierarchie
|
||||||
|
geteilt werden, erscheinen hier.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b text-left">
|
||||||
|
<th className="pr-4 pb-2 font-medium">Name</th>
|
||||||
|
<th className="pr-4 pb-2 font-medium">Typ</th>
|
||||||
|
<th className="pr-4 pb-2 font-medium">Template-Typ</th>
|
||||||
|
<th className="pr-4 pb-2 font-medium">Organisation</th>
|
||||||
|
<th className="pr-4 pb-2 font-medium">Erstellt</th>
|
||||||
|
<th className="pb-2 font-medium">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredTemplates.map((template) => (
|
||||||
|
<tr
|
||||||
|
key={`${template.template_source}-${template.id}`}
|
||||||
|
className="border-b last:border-0"
|
||||||
|
>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{template.template_source === 'newsletter' ? (
|
||||||
|
<Mail className="text-muted-foreground h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium">{template.name}</span>
|
||||||
|
</div>
|
||||||
|
{template.description && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
template.template_source === 'newsletter'
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{template.template_source === 'newsletter'
|
||||||
|
? 'Newsletter'
|
||||||
|
: 'Dokument'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground py-3 pr-4">
|
||||||
|
{getTemplateTypeLabel(template.template_type)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground py-3 pr-4">
|
||||||
|
{template.account_name}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground py-3 pr-4 whitespace-nowrap">
|
||||||
|
{formatDate(template.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<Button
|
||||||
|
data-test="template-clone-btn"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openCloneDialog(template)}
|
||||||
|
>
|
||||||
|
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Klonen
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={cloneDialogOpen} onOpenChange={setCloneDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Vorlage klonen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Erstellen Sie eine Kopie der Vorlage{' '}
|
||||||
|
<strong>{selectedTemplate?.name}</strong> in Ihrer Organisation.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<label
|
||||||
|
htmlFor="clone-name"
|
||||||
|
className="mb-2 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Name der Kopie
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="clone-name"
|
||||||
|
data-test="template-clone-name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Name der neuen Vorlage"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCloneDialogOpen(false)}
|
||||||
|
disabled={isCloning}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-test="template-clone-confirm"
|
||||||
|
onClick={handleClone}
|
||||||
|
disabled={isCloning}
|
||||||
|
>
|
||||||
|
{isCloning ? 'Wird geklont...' : 'Vorlage klonen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
import { cn } from '@kit/ui/utils';
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
interface VerbandTabNavigationProps {
|
interface VerbandTabNavigationProps {
|
||||||
@@ -10,11 +11,15 @@ interface VerbandTabNavigationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'overview', label: 'Übersicht', path: '' },
|
{ id: 'overview', i18nKey: 'verband:nav.overview', path: '' },
|
||||||
{ id: 'clubs', label: 'Vereine', path: '/clubs' },
|
{ id: 'clubs', i18nKey: 'verband:nav.clubs', path: '/clubs' },
|
||||||
{ id: 'hierarchy', label: 'Hierarchie', path: '/hierarchy' },
|
{ id: 'hierarchy', i18nKey: 'verband:nav.hierarchy', path: '/hierarchy' },
|
||||||
{ id: 'statistics', label: 'Statistik', path: '/statistics' },
|
{ id: 'members', i18nKey: 'verband:nav.memberSearch', path: '/members' },
|
||||||
{ id: 'settings', label: 'Einstellungen', path: '/settings' },
|
{ id: 'events', i18nKey: 'verband:nav.events', path: '/events' },
|
||||||
|
{ id: 'reporting', i18nKey: 'verband:nav.reporting', path: '/reporting' },
|
||||||
|
{ id: 'templates', i18nKey: 'verband:nav.templates', path: '/templates' },
|
||||||
|
{ id: 'statistics', i18nKey: 'verband:nav.statistics', path: '/statistics' },
|
||||||
|
{ id: 'settings', i18nKey: 'verband:nav.settings', path: '/settings' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function VerbandTabNavigation({
|
export function VerbandTabNavigation({
|
||||||
@@ -27,7 +32,7 @@ export function VerbandTabNavigation({
|
|||||||
<div className="mb-6 border-b">
|
<div className="mb-6 border-b">
|
||||||
<nav
|
<nav
|
||||||
className="-mb-px flex space-x-1 overflow-x-auto"
|
className="-mb-px flex space-x-1 overflow-x-auto"
|
||||||
aria-label="Verbandsverwaltung Navigation"
|
aria-label="Association Management Navigation"
|
||||||
>
|
>
|
||||||
{TABS.map((tab) => {
|
{TABS.map((tab) => {
|
||||||
const isActive = tab.id === activeTab;
|
const isActive = tab.id === activeTab;
|
||||||
@@ -43,7 +48,7 @@ export function VerbandTabNavigation({
|
|||||||
: 'text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground border-transparent',
|
: 'text-muted-foreground hover:border-muted-foreground/30 hover:text-foreground border-transparent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
<Trans i18nKey={tab.i18nKey} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -218,3 +218,35 @@ export const RemoveAccountParentSchema = z.object({
|
|||||||
export type RemoveAccountParentInput = z.infer<
|
export type RemoveAccountParentInput = z.infer<
|
||||||
typeof RemoveAccountParentSchema
|
typeof RemoveAccountParentSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Member Transfer
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export const TransferPreviewSchema = z.object({
|
||||||
|
memberId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TransferPreviewInput = z.infer<typeof TransferPreviewSchema>;
|
||||||
|
|
||||||
|
export const TransferMemberSchema = z.object({
|
||||||
|
memberId: z.string().uuid(),
|
||||||
|
targetAccountId: z.string().uuid(),
|
||||||
|
reason: z.string().max(500).optional(),
|
||||||
|
keepSepa: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TransferMemberInput = z.infer<typeof TransferMemberSchema>;
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Clone Template
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export const CloneTemplateSchema = z.object({
|
||||||
|
templateType: z.enum(['newsletter', 'document']),
|
||||||
|
templateId: z.string().uuid(),
|
||||||
|
targetAccountId: z.string().uuid(),
|
||||||
|
newName: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CloneTemplateInput = z.infer<typeof CloneTemplateSchema>;
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|||||||
import {
|
import {
|
||||||
SetAccountParentSchema,
|
SetAccountParentSchema,
|
||||||
RemoveAccountParentSchema,
|
RemoveAccountParentSchema,
|
||||||
|
TransferPreviewSchema,
|
||||||
|
TransferMemberSchema,
|
||||||
|
CloneTemplateSchema,
|
||||||
} from '../../schema/verband.schema';
|
} from '../../schema/verband.schema';
|
||||||
|
import { createVerbandApi } from '../api';
|
||||||
|
|
||||||
const REVALIDATE_PATH = '/home/[account]/verband';
|
const REVALIDATE_PATH = '/home/[account]/verband';
|
||||||
|
|
||||||
@@ -124,3 +128,79 @@ export const unlinkChildAccount = authActionClient
|
|||||||
revalidatePath(REVALIDATE_PATH, 'page');
|
revalidatePath(REVALIDATE_PATH, 'page');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getTransferPreview = authActionClient
|
||||||
|
.inputSchema(TransferPreviewSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createVerbandApi(client);
|
||||||
|
|
||||||
|
return api.getTransferPreview(input.memberId);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const transferMember = authActionClient
|
||||||
|
.inputSchema(TransferMemberSchema)
|
||||||
|
.action(async ({ parsedInput: input, ctx }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createVerbandApi(client);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
name: 'verband.member.transfer',
|
||||||
|
memberId: input.memberId,
|
||||||
|
targetAccountId: input.targetAccountId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
'Transferring member to another organisation...',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { transferId } = await api.transferMember(
|
||||||
|
input.memberId,
|
||||||
|
input.targetAccountId,
|
||||||
|
input.reason,
|
||||||
|
input.keepSepa,
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ name: 'verband.member.transfer', transferId },
|
||||||
|
'Member transferred successfully',
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath(REVALIDATE_PATH, 'page');
|
||||||
|
return { success: true, transferId };
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : 'Fehler beim Transfer';
|
||||||
|
logger.error({ error: err }, 'Member transfer failed');
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cloneTemplate = authActionClient
|
||||||
|
.inputSchema(CloneTemplateSchema)
|
||||||
|
.action(async ({ parsedInput: input }) => {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
const api = createVerbandApi(client);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
name: 'verband.template.clone',
|
||||||
|
templateId: input.templateId,
|
||||||
|
targetAccountId: input.targetAccountId,
|
||||||
|
},
|
||||||
|
'Cloning shared template...',
|
||||||
|
);
|
||||||
|
|
||||||
|
const { newTemplateId } = await api.cloneTemplate(
|
||||||
|
input.templateType,
|
||||||
|
input.templateId,
|
||||||
|
input.targetAccountId,
|
||||||
|
input.newName,
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath(REVALIDATE_PATH, 'page');
|
||||||
|
return { success: true, newTemplateId };
|
||||||
|
});
|
||||||
|
|||||||
@@ -787,16 +787,16 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
|||||||
async getParentAccount(
|
async getParentAccount(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
): Promise<HierarchyAccount | null> {
|
): Promise<HierarchyAccount | null> {
|
||||||
const { data: acct, error: acctError } = await client
|
const { data: accountData, error: accountError } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select(
|
.select(
|
||||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||||
)
|
)
|
||||||
.eq('id', accountId)
|
.eq('id', accountId)
|
||||||
.single();
|
.single();
|
||||||
if (acctError) throw acctError;
|
if (accountError) throw accountError;
|
||||||
|
|
||||||
const account = acct as unknown as HierarchyAccount;
|
const account = accountData as unknown as HierarchyAccount;
|
||||||
if (!account.parent_account_id) return null;
|
if (!account.parent_account_id) return null;
|
||||||
|
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
@@ -880,14 +880,14 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback
|
// Fallback
|
||||||
const { data: fallback, error: fbErr } = await client
|
const { data: fallback, error: fallbackError } = await client
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select(
|
.select(
|
||||||
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
'id, name, slug, email, is_personal_account, parent_account_id, created_at' as '*',
|
||||||
)
|
)
|
||||||
.eq('id', rootAccountId)
|
.eq('id', rootAccountId)
|
||||||
.single();
|
.single();
|
||||||
if (fbErr) throw fbErr;
|
if (fallbackError) throw fallbackError;
|
||||||
return {
|
return {
|
||||||
...(fallback as unknown as HierarchyAccount),
|
...(fallback as unknown as HierarchyAccount),
|
||||||
children: [],
|
children: [],
|
||||||
@@ -935,5 +935,478 @@ export function createVerbandApi(client: SupabaseClient<Database>) {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return (data ?? []) as unknown as HierarchyAccount[];
|
return (data ?? []) as unknown as HierarchyAccount[];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Cross-Org Member Search
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
async searchMembersAcrossHierarchy(
|
||||||
|
rootAccountId: string,
|
||||||
|
opts?: {
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
accountId?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { data, error } = await client.rpc(
|
||||||
|
'search_members_across_hierarchy' as any,
|
||||||
|
{
|
||||||
|
root_account_id: rootAccountId,
|
||||||
|
search_term: opts?.search || null,
|
||||||
|
status_filter: opts?.status || null,
|
||||||
|
account_filter: opts?.accountId || null,
|
||||||
|
page_number: opts?.page ?? 1,
|
||||||
|
page_size: opts?.pageSize ?? 25,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const rows = (data ?? []) as unknown as CrossOrgMemberRow[];
|
||||||
|
const total = rows.length > 0 ? Number(rows[0]!.total_count) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows.map(({ total_count: _, ...member }) => member),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Member Transfer
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-flight check: fetch all active relationships for a member
|
||||||
|
* so the admin can see side effects before confirming transfer.
|
||||||
|
*/
|
||||||
|
async getTransferPreview(memberId: string): Promise<TransferPreview> {
|
||||||
|
// Fetch member basic info
|
||||||
|
const { data: member } = await client
|
||||||
|
.from('members')
|
||||||
|
.select(
|
||||||
|
'id, first_name, last_name, account_id, email, iban, sepa_mandate_status, dues_category_id, member_number',
|
||||||
|
)
|
||||||
|
.eq('id', memberId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!member) throw new Error('Mitglied nicht gefunden');
|
||||||
|
|
||||||
|
// Active course enrollments
|
||||||
|
const { data: courses } = await client
|
||||||
|
.from('course_participants')
|
||||||
|
.select(
|
||||||
|
'id, course_id, status, courses(id, name, account_id, start_date, end_date, accounts(name))',
|
||||||
|
)
|
||||||
|
.eq('member_id', memberId)
|
||||||
|
.eq('status', 'enrolled');
|
||||||
|
|
||||||
|
// Open invoices referencing this member
|
||||||
|
const { data: invoices } = await client
|
||||||
|
.from('invoices')
|
||||||
|
.select(
|
||||||
|
'id, invoice_number, total_amount, status, account_id, accounts(name)',
|
||||||
|
)
|
||||||
|
.eq('member_id', memberId)
|
||||||
|
.in('status', ['draft', 'sent', 'overdue']);
|
||||||
|
|
||||||
|
// Active SEPA mandates
|
||||||
|
const { data: mandates } = await client
|
||||||
|
.from('sepa_mandates')
|
||||||
|
.select('id, mandate_reference, status, account_id')
|
||||||
|
.eq('member_id', memberId)
|
||||||
|
.in('status', ['active', 'pending']);
|
||||||
|
|
||||||
|
// Newsletter subscriptions
|
||||||
|
const { data: newsletters } = await client
|
||||||
|
.from('newsletter_recipients')
|
||||||
|
.select(
|
||||||
|
'id, newsletter_id, newsletters(id, name, account_id, accounts(name))',
|
||||||
|
)
|
||||||
|
.eq('member_id', memberId);
|
||||||
|
|
||||||
|
const courseList = (courses ?? []).map((courseParticipant) => {
|
||||||
|
const course = courseParticipant.courses as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null;
|
||||||
|
const courseAccount = course?.accounts as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null;
|
||||||
|
return {
|
||||||
|
id: String(courseParticipant.id),
|
||||||
|
name: String(course?.name ?? '—'),
|
||||||
|
accountName: String(courseAccount?.name ?? '—'),
|
||||||
|
startDate: course?.start_date as string | null,
|
||||||
|
endDate: course?.end_date as string | null,
|
||||||
|
survives: true, // FK is member_id, enrollment stays
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceList = (invoices ?? []).map((invoiceRecord) => {
|
||||||
|
const invAccount = invoiceRecord.accounts as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null;
|
||||||
|
return {
|
||||||
|
id: String(invoiceRecord.id),
|
||||||
|
invoiceNumber: String(invoiceRecord.invoice_number ?? '—'),
|
||||||
|
amount: Number(invoiceRecord.total_amount ?? 0),
|
||||||
|
status: String(invoiceRecord.status),
|
||||||
|
accountName: String(invAccount?.name ?? '—'),
|
||||||
|
survives: true, // stays in source org, member_id FK preserved
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mandateList = (mandates ?? []).map((mandateRecord) => ({
|
||||||
|
id: String(mandateRecord.id),
|
||||||
|
reference: String(mandateRecord.mandate_reference ?? '—'),
|
||||||
|
status: String(mandateRecord.status),
|
||||||
|
survives: false, // mandate is org-specific, gets reset
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newsletterList = (newsletters ?? []).map((recipientRecord) => {
|
||||||
|
const newsletterData = recipientRecord.newsletters as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null;
|
||||||
|
const newsletterAccountData = newsletterData?.accounts as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null;
|
||||||
|
return {
|
||||||
|
id: String(recipientRecord.id),
|
||||||
|
name: String(newsletterData?.name ?? '—'),
|
||||||
|
accountName: String(newsletterAccountData?.name ?? '—'),
|
||||||
|
survives: true, // FK on member_id, stays linked
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
member: {
|
||||||
|
id: String(member.id),
|
||||||
|
firstName: String(member.first_name),
|
||||||
|
lastName: String(member.last_name),
|
||||||
|
email: member.email as string | null,
|
||||||
|
hasIban: !!member.iban,
|
||||||
|
hasMandate:
|
||||||
|
member.sepa_mandate_status !== 'pending' &&
|
||||||
|
!!member.sepa_mandate_status,
|
||||||
|
hasDuesCategory: !!member.dues_category_id,
|
||||||
|
memberNumber: member.member_number as string | null,
|
||||||
|
},
|
||||||
|
courses: courseList,
|
||||||
|
invoices: invoiceList,
|
||||||
|
mandates: mandateList,
|
||||||
|
newsletters: newsletterList,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async transferMember(
|
||||||
|
memberId: string,
|
||||||
|
targetAccountId: string,
|
||||||
|
reason?: string,
|
||||||
|
keepSepa = true,
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { data, error } = await client.rpc('transfer_member' as any, {
|
||||||
|
p_member_id: memberId,
|
||||||
|
p_target_account_id: targetAccountId,
|
||||||
|
p_reason: reason || null,
|
||||||
|
p_keep_sepa: keepSepa,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return { transferId: data as unknown as string };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMemberTransferHistory(memberId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('member_transfers' as string)
|
||||||
|
.select(
|
||||||
|
'id, member_id, source_account_id, target_account_id, reason, transferred_at' as '*',
|
||||||
|
)
|
||||||
|
.eq('member_id', memberId)
|
||||||
|
.order('transferred_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return (data ?? []) as unknown as MemberTransfer[];
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Shared Events (Hierarchy)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
async listHierarchyEvents(
|
||||||
|
rootAccountId: string,
|
||||||
|
opts?: {
|
||||||
|
fromDate?: string;
|
||||||
|
status?: string;
|
||||||
|
sharedOnly?: boolean;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { data, error } = await client.rpc('list_hierarchy_events' as any, {
|
||||||
|
root_account_id: rootAccountId,
|
||||||
|
p_from_date: opts?.fromDate || null,
|
||||||
|
p_status: opts?.status || null,
|
||||||
|
p_shared_only: opts?.sharedOnly ?? false,
|
||||||
|
p_page: opts?.page ?? 1,
|
||||||
|
p_page_size: opts?.pageSize ?? 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
const rows = (data ?? []) as unknown as HierarchyEventRow[];
|
||||||
|
const total = rows.length > 0 ? Number(rows[0]!.total_count) : 0;
|
||||||
|
return {
|
||||||
|
data: rows.map(({ total_count: _, ...event }) => event),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Consolidated SEPA Billing
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
async listSepaEligibleMembers(
|
||||||
|
rootAccountId: string,
|
||||||
|
accountFilter?: string,
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { data, error } = await client.rpc(
|
||||||
|
'list_hierarchy_sepa_eligible_members' as any,
|
||||||
|
{
|
||||||
|
root_account_id: rootAccountId,
|
||||||
|
p_account_filter: accountFilter || null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return (data ?? []) as unknown as SepaEligibleMember[];
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Aggregated Reporting
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
async getHierarchyReport(rootAccountId: string) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { data, error } = await client.rpc('get_hierarchy_report' as any, {
|
||||||
|
root_account_id: rootAccountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return (data ?? []) as unknown as HierarchyReportRow[];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHierarchySummary(rootAccountId: string) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { data, error } = await client.rpc('get_hierarchy_summary' as any, {
|
||||||
|
root_account_id: rootAccountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
const rows = (data ?? []) as unknown as HierarchySummary[];
|
||||||
|
return (
|
||||||
|
rows[0] ?? {
|
||||||
|
total_orgs: 0,
|
||||||
|
total_active_members: 0,
|
||||||
|
total_members: 0,
|
||||||
|
new_members_this_year: 0,
|
||||||
|
total_upcoming_events: 0,
|
||||||
|
total_active_courses: 0,
|
||||||
|
total_open_invoices: 0,
|
||||||
|
total_open_invoice_amount: 0,
|
||||||
|
total_sepa_batches_this_year: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Shared Templates
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
async listSharedTemplates(
|
||||||
|
rootAccountId: string,
|
||||||
|
templateType?: 'newsletter' | 'document',
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { data, error } = await client.rpc(
|
||||||
|
'list_hierarchy_shared_templates' as any,
|
||||||
|
{
|
||||||
|
root_account_id: rootAccountId,
|
||||||
|
p_template_type: templateType || null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return (data ?? []) as unknown as SharedTemplate[];
|
||||||
|
},
|
||||||
|
|
||||||
|
async cloneTemplate(
|
||||||
|
templateType: 'newsletter' | 'document',
|
||||||
|
templateId: string,
|
||||||
|
targetAccountId: string,
|
||||||
|
newName?: string,
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { data, error } = await client.rpc('clone_template' as any, {
|
||||||
|
p_template_type: templateType,
|
||||||
|
p_template_id: templateId,
|
||||||
|
p_target_account_id: targetAccountId,
|
||||||
|
p_new_name: newName || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return { newTemplateId: data as unknown as string };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberTransfer {
|
||||||
|
id: string;
|
||||||
|
member_id: string;
|
||||||
|
source_account_id: string;
|
||||||
|
target_account_id: string;
|
||||||
|
reason: string | null;
|
||||||
|
transferred_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrossOrgMemberRow {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
account_name: string;
|
||||||
|
account_slug: string | null;
|
||||||
|
member_number: string | null;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
city: string | null;
|
||||||
|
status: string;
|
||||||
|
entry_date: string | null;
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CrossOrgMember = Omit<CrossOrgMemberRow, 'total_count'>;
|
||||||
|
|
||||||
|
export interface TransferPreview {
|
||||||
|
member: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string | null;
|
||||||
|
hasIban: boolean;
|
||||||
|
hasMandate: boolean;
|
||||||
|
hasDuesCategory: boolean;
|
||||||
|
memberNumber: string | null;
|
||||||
|
};
|
||||||
|
courses: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accountName: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
survives: boolean;
|
||||||
|
}>;
|
||||||
|
invoices: Array<{
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
accountName: string;
|
||||||
|
survives: boolean;
|
||||||
|
}>;
|
||||||
|
mandates: Array<{
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
status: string;
|
||||||
|
survives: boolean;
|
||||||
|
}>;
|
||||||
|
newsletters: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accountName: string;
|
||||||
|
survives: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HierarchyEventRow {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
account_name: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
event_date: string;
|
||||||
|
event_time: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
location: string | null;
|
||||||
|
capacity: number | null;
|
||||||
|
fee: number;
|
||||||
|
status: string;
|
||||||
|
registration_deadline: string | null;
|
||||||
|
registration_count: number;
|
||||||
|
shared_with_hierarchy: boolean;
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HierarchyEvent = Omit<HierarchyEventRow, 'total_count'>;
|
||||||
|
|
||||||
|
export interface SepaEligibleMember {
|
||||||
|
member_id: string;
|
||||||
|
account_id: string;
|
||||||
|
account_name: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
iban: string;
|
||||||
|
bic: string | null;
|
||||||
|
account_holder: string | null;
|
||||||
|
mandate_id: string | null;
|
||||||
|
mandate_date: string | null;
|
||||||
|
dues_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HierarchyReportRow {
|
||||||
|
org_id: string;
|
||||||
|
org_name: string;
|
||||||
|
org_slug: string | null;
|
||||||
|
depth: number;
|
||||||
|
active_members: number;
|
||||||
|
inactive_members: number;
|
||||||
|
total_members: number;
|
||||||
|
new_members_this_year: number;
|
||||||
|
active_courses: number;
|
||||||
|
upcoming_events: number;
|
||||||
|
open_invoices: number;
|
||||||
|
open_invoice_amount: number;
|
||||||
|
sepa_batches_this_year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HierarchySummary {
|
||||||
|
total_orgs: number;
|
||||||
|
total_active_members: number;
|
||||||
|
total_members: number;
|
||||||
|
new_members_this_year: number;
|
||||||
|
total_upcoming_events: number;
|
||||||
|
total_active_courses: number;
|
||||||
|
total_open_invoices: number;
|
||||||
|
total_open_invoice_amount: number;
|
||||||
|
total_sepa_batches_this_year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedTemplate {
|
||||||
|
id: string;
|
||||||
|
account_id: string;
|
||||||
|
account_name: string;
|
||||||
|
template_source: 'newsletter' | 'document';
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
template_type: string;
|
||||||
|
shared_with_hierarchy: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ export function getSupabaseClientKeys() {
|
|||||||
const isServer = typeof window === 'undefined';
|
const isServer = typeof window === 'undefined';
|
||||||
|
|
||||||
const url = isServer
|
const url = isServer
|
||||||
? (process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL)
|
? process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||||
: process.env.NEXT_PUBLIC_SUPABASE_URL;
|
: process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
|
||||||
return z
|
return z
|
||||||
|
|||||||
Reference in New Issue
Block a user