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 />;
|
||||
|
||||
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 (
|
||||
<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',
|
||||
'marketing',
|
||||
'cms',
|
||||
'verband',
|
||||
] as const;
|
||||
|
||||
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
|
||||
|
||||
# Enable WebAuthn/Passkey MFA
|
||||
[auth.mfa.webauthn]
|
||||
verify_enabled = true
|
||||
enroll_enabled = true
|
||||
# Note: requires Supabase CLI >= 2.90. Uncomment when upgrading.
|
||||
# [auth.mfa.webauthn]
|
||||
# verify_enabled = true
|
||||
# enroll_enabled = true
|
||||
|
||||
[auth.email]
|
||||
# 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
|
||||
-- (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)
|
||||
-- =====================================================
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
|
||||
-- =====================================================
|
||||
-- 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
|
||||
-- =====================================================
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
|
||||
-- =====================================================
|
||||
-- 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)
|
||||
-- =====================================================
|
||||
|
||||
@@ -54,16 +54,16 @@ BEGIN
|
||||
-- Walk up from the proposed parent; if we find NEW.id, it's a cycle
|
||||
IF EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, parent_account_id
|
||||
FROM public.accounts
|
||||
WHERE id = NEW.parent_account_id
|
||||
SELECT acc.id, acc.parent_account_id, ARRAY[acc.id] AS path
|
||||
FROM public.accounts acc
|
||||
WHERE acc.id = NEW.parent_account_id
|
||||
UNION ALL
|
||||
SELECT a.id, a.parent_account_id
|
||||
SELECT a.id, a.parent_account_id, anc.path || a.id
|
||||
FROM public.accounts a
|
||||
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 AND NOT is_cycle
|
||||
SELECT 1 FROM ancestors WHERE id = NEW.id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Setting parent_account_id would create a hierarchy cycle';
|
||||
END IF;
|
||||
@@ -73,7 +73,9 @@ BEGIN
|
||||
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
|
||||
ON public.accounts
|
||||
FOR EACH ROW
|
||||
@@ -82,7 +84,8 @@ CREATE OR REPLACE TRIGGER prevent_account_hierarchy_cycle
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- 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)
|
||||
RETURNS SETOF uuid
|
||||
@@ -90,15 +93,15 @@ LANGUAGE sql STABLE
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, parent_account_id
|
||||
FROM public.accounts WHERE id = root_id
|
||||
SELECT acc.id, ARRAY[acc.id] AS path
|
||||
FROM public.accounts acc WHERE acc.id = root_id
|
||||
UNION ALL
|
||||
SELECT a.id, a.parent_account_id
|
||||
SELECT a.id, t.path || a.id
|
||||
FROM public.accounts a
|
||||
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 id FROM tree WHERE NOT is_cycle;
|
||||
SELECT tree.id FROM tree;
|
||||
$$;
|
||||
|
||||
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)
|
||||
-- Restricted to service_role — called via RLS helper functions
|
||||
-- Restricted to service_role
|
||||
-- -------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.get_account_ancestors(child_id uuid)
|
||||
RETURNS SETOF uuid
|
||||
@@ -114,15 +117,15 @@ LANGUAGE sql STABLE
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, parent_account_id
|
||||
FROM public.accounts WHERE id = child_id
|
||||
SELECT acc.id, acc.parent_account_id, ARRAY[acc.id] AS path
|
||||
FROM public.accounts acc WHERE acc.id = child_id
|
||||
UNION ALL
|
||||
SELECT a.id, a.parent_account_id
|
||||
SELECT a.id, a.parent_account_id, t.path || a.id
|
||||
FROM public.accounts a
|
||||
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 id FROM tree WHERE NOT is_cycle;
|
||||
SELECT tree.id FROM tree;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_account_ancestors(uuid)
|
||||
@@ -138,16 +141,16 @@ LANGUAGE sql STABLE
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, parent_account_id, 0 AS depth
|
||||
FROM public.accounts
|
||||
WHERE id = account_id
|
||||
SELECT acc.id, acc.parent_account_id, 0 AS depth, ARRAY[acc.id] AS path
|
||||
FROM public.accounts acc
|
||||
WHERE acc.id = get_account_depth.account_id
|
||||
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
|
||||
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 WHERE NOT is_cycle;
|
||||
SELECT MAX(depth) FROM tree;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_account_depth(uuid)
|
||||
|
||||
@@ -30,7 +30,7 @@ AS $$
|
||||
FROM public.accounts_memberships membership
|
||||
WHERE membership.user_id = (SELECT auth.uid())
|
||||
AND membership.account_id IN (
|
||||
SELECT id FROM public.get_account_ancestors(target_account_id)
|
||||
SELECT public.get_account_ancestors(target_account_id)
|
||||
)
|
||||
AND (
|
||||
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
|
||||
WHERE am.user_id = has_permission_or_ancestor.user_id
|
||||
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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user