Major changes: - Docker Compose: full Supabase stack (11 services) equivalent to supabase CLI - Fischerei module: 16 DB tables, waters/species/stocking/catch books/competitions - Sitzungsprotokolle module: meeting protocols, agenda items, task tracking - Verbandsverwaltung module: federation management, member clubs, contacts, fees - Per-account module activation via Modules page toggle - Site Builder: live CMS data in Puck blocks (courses, events, membership registration) - Public registration APIs: course signup, event registration, membership application - Document generation: PDF member cards, Excel reports, HTML labels - Landing page: real Com.BISS content (no filler text) - UX audit fixes: AccountNotFound component, shared status badges, confirm dialog, pagination, duplicate heading removal, emoji→badge replacement, a11y fixes - QA: healthcheck fix, API auth fix, enum mismatch fix, password required attribute
236 lines
6.5 KiB
TypeScript
236 lines
6.5 KiB
TypeScript
import { cache } from 'react';
|
|
import { use } from 'react';
|
|
|
|
import { cookies } from 'next/headers';
|
|
import { redirect } from 'next/navigation';
|
|
|
|
import { Fish, FileSignature, Building2 } from 'lucide-react';
|
|
import * as z from 'zod';
|
|
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components';
|
|
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
|
|
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
|
import { SidebarProvider } from '@kit/ui/sidebar';
|
|
|
|
import { AppLogo } from '~/components/app-logo';
|
|
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
|
|
|
|
// local imports
|
|
import { TeamAccountLayoutMobileNavigation } from './_components/team-account-layout-mobile-navigation';
|
|
import { TeamAccountLayoutSidebar } from './_components/team-account-layout-sidebar';
|
|
import { TeamAccountNavigationMenu } from './_components/team-account-navigation-menu';
|
|
import { loadTeamWorkspace } from './_lib/server/team-account-workspace.loader';
|
|
|
|
type TeamWorkspaceLayoutProps = React.PropsWithChildren<{
|
|
params: Promise<{ account: string }>;
|
|
}>;
|
|
|
|
function TeamWorkspaceLayout({ children, params }: TeamWorkspaceLayoutProps) {
|
|
const account = use(params).account;
|
|
const state = use(getLayoutState(account));
|
|
|
|
if (state.style === 'sidebar') {
|
|
return <SidebarLayout account={account}>{children}</SidebarLayout>;
|
|
}
|
|
|
|
return <HeaderLayout account={account}>{children}</HeaderLayout>;
|
|
}
|
|
|
|
/**
|
|
* Query account_settings.features for a given account slug.
|
|
* Cached per-request so multiple calls don't hit the DB twice.
|
|
*/
|
|
const getAccountFeatures = cache(async (accountSlug: string) => {
|
|
const client = getSupabaseServerClient();
|
|
|
|
const { data: accountData } = await client
|
|
.from('accounts')
|
|
.select('id')
|
|
.eq('slug', accountSlug)
|
|
.single();
|
|
|
|
if (!accountData) return {};
|
|
|
|
const { data: settings } = await client
|
|
.from('account_settings')
|
|
.select('features')
|
|
.eq('account_id', accountData.id)
|
|
.maybeSingle();
|
|
|
|
return (settings?.features as Record<string, boolean>) ?? {};
|
|
});
|
|
|
|
/**
|
|
* Inject per-account feature routes (e.g. Fischerei) into the parsed
|
|
* navigation config. The entry is inserted right after "Veranstaltungen".
|
|
*/
|
|
function injectAccountFeatureRoutes(
|
|
config: z.output<typeof NavigationConfigSchema>,
|
|
account: string,
|
|
features: Record<string, boolean>,
|
|
): z.output<typeof NavigationConfigSchema> {
|
|
if (!features.fischerei && !features.meetings && !features.verband) return config;
|
|
|
|
const featureEntries: Array<{
|
|
label: string;
|
|
path: string;
|
|
Icon: React.ReactNode;
|
|
}> = [];
|
|
|
|
if (features.fischerei) {
|
|
featureEntries.push({
|
|
label: 'common.routes.fischerei',
|
|
path: `/home/${account}/fischerei`,
|
|
Icon: <Fish className="w-4" />,
|
|
});
|
|
}
|
|
|
|
if (features.meetings) {
|
|
featureEntries.push({
|
|
label: 'common.routes.meetings',
|
|
path: `/home/${account}/meetings`,
|
|
Icon: <FileSignature className="w-4" />,
|
|
});
|
|
}
|
|
|
|
if (features.verband) {
|
|
featureEntries.push({
|
|
label: 'common.routes.verband',
|
|
path: `/home/${account}/verband`,
|
|
Icon: <Building2 className="w-4" />,
|
|
});
|
|
}
|
|
|
|
return {
|
|
...config,
|
|
routes: config.routes.map((group) => {
|
|
if (!('children' in group)) return group;
|
|
|
|
const eventsIndex = group.children.findIndex(
|
|
(child) => child.label === 'common.routes.events',
|
|
);
|
|
|
|
if (eventsIndex === -1) return group;
|
|
|
|
const newChildren = [...group.children];
|
|
newChildren.splice(eventsIndex + 1, 0, ...featureEntries);
|
|
|
|
return { ...group, children: newChildren };
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function SidebarLayout({
|
|
account,
|
|
children,
|
|
}: React.PropsWithChildren<{
|
|
account: string;
|
|
}>) {
|
|
const [data, state, features] = await Promise.all([
|
|
loadTeamWorkspace(account),
|
|
getLayoutState(account),
|
|
getAccountFeatures(account),
|
|
]);
|
|
|
|
if (!data) {
|
|
redirect('/');
|
|
}
|
|
|
|
const baseConfig = getTeamAccountSidebarConfig(account);
|
|
const config = injectAccountFeatureRoutes(baseConfig, account, features);
|
|
|
|
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({
|
|
label: name,
|
|
value: slug,
|
|
image: picture_url,
|
|
}));
|
|
|
|
return (
|
|
<TeamAccountWorkspaceContextProvider value={data}>
|
|
<SidebarProvider defaultOpen={state.open}>
|
|
<Page style={'sidebar'}>
|
|
<PageNavigation>
|
|
<TeamAccountLayoutSidebar
|
|
account={account}
|
|
accountId={data.account.id}
|
|
accounts={accounts}
|
|
user={data.user}
|
|
config={config}
|
|
/>
|
|
</PageNavigation>
|
|
|
|
<PageMobileNavigation className={'flex items-center justify-between'}>
|
|
<AppLogo />
|
|
|
|
<div className={'flex space-x-4'}>
|
|
<TeamAccountLayoutMobileNavigation
|
|
userId={data.user.id}
|
|
accounts={accounts}
|
|
account={account}
|
|
config={config}
|
|
/>
|
|
</div>
|
|
</PageMobileNavigation>
|
|
|
|
{children}
|
|
</Page>
|
|
</SidebarProvider>
|
|
</TeamAccountWorkspaceContextProvider>
|
|
);
|
|
}
|
|
|
|
async function HeaderLayout({
|
|
account,
|
|
children,
|
|
}: React.PropsWithChildren<{
|
|
account: string;
|
|
}>) {
|
|
const [data, features] = await Promise.all([
|
|
loadTeamWorkspace(account),
|
|
getAccountFeatures(account),
|
|
]);
|
|
|
|
const baseConfig = getTeamAccountSidebarConfig(account);
|
|
const config = injectAccountFeatureRoutes(baseConfig, account, features);
|
|
|
|
return (
|
|
<TeamAccountWorkspaceContextProvider value={data}>
|
|
<Page style={'header'}>
|
|
<PageNavigation>
|
|
<TeamAccountNavigationMenu workspace={data} config={config} />
|
|
</PageNavigation>
|
|
|
|
{children}
|
|
</Page>
|
|
</TeamAccountWorkspaceContextProvider>
|
|
);
|
|
}
|
|
|
|
async function getLayoutState(account: string) {
|
|
const cookieStore = await cookies();
|
|
const config = getTeamAccountSidebarConfig(account);
|
|
|
|
const LayoutStyleSchema = z
|
|
.enum(['sidebar', 'header', 'custom'])
|
|
.default(config.style);
|
|
|
|
const sidebarOpenCookie = cookieStore.get('sidebar_state');
|
|
const layoutCookie = cookieStore.get('layout-style');
|
|
|
|
const layoutStyle = LayoutStyleSchema.safeParse(layoutCookie?.value);
|
|
|
|
const sidebarOpenCookieValue = sidebarOpenCookie
|
|
? sidebarOpenCookie.value === 'true'
|
|
: !config.sidebarCollapsed;
|
|
|
|
const style = layoutStyle.success ? layoutStyle.data : config.style;
|
|
|
|
return {
|
|
open: sidebarOpenCookieValue,
|
|
style,
|
|
};
|
|
}
|
|
|
|
export default TeamWorkspaceLayout;
|