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

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

View File

@@ -1,16 +1,19 @@
import Link from 'next/link';
import { AlertTriangle } from 'lucide-react';
import { Button } from '@kit/ui/button';
export function AccountNotFound() {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-4 rounded-full bg-destructive/10 p-4">
<AlertTriangle className="h-8 w-8 text-destructive" />
<div className="bg-destructive/10 mb-4 rounded-full p-4">
<AlertTriangle className="text-destructive h-8 w-8" />
</div>
<h2 className="text-xl font-semibold">Konto nicht gefunden</h2>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
Das angeforderte Konto existiert nicht oder Sie haben keine Berechtigung darauf zuzugreifen.
<p className="text-muted-foreground mt-2 max-w-md text-sm">
Das angeforderte Konto existiert nicht oder Sie haben keine Berechtigung
darauf zuzugreifen.
</p>
<div className="mt-6">
<Link href="/home">

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { PageBody } from '@kit/ui/page';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { TeamAccountLayoutPageHeader } from '~/home/[account]/_components/team-account-layout-page-header';
@@ -16,7 +16,12 @@ interface CmsPageShellProps {
* Shared CMS page shell — wraps PageBody + header + breadcrumbs.
* Use in every CMS feature page to maintain consistent layout.
*/
export function CmsPageShell({ account, title, description, children }: CmsPageShellProps) {
export function CmsPageShell({
account,
title,
description,
children,
}: CmsPageShellProps) {
return (
<>
<TeamAccountLayoutPageHeader
@@ -25,9 +30,7 @@ export function CmsPageShell({ account, title, description, children }: CmsPageS
description={description ?? <AppBreadcrumbs />}
/>
<PageBody>
{children}
</PageBody>
<PageBody>{children}</PageBody>
</>
);
}

View File

@@ -33,8 +33,9 @@ export function ConfirmDialog({
}: ConfirmDialogProps) {
return (
<AlertDialog>
<AlertDialogTrigger render={trigger as React.ReactElement}>
</AlertDialogTrigger>
<AlertDialogTrigger
render={trigger as React.ReactElement}
></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
@@ -44,7 +45,11 @@ export function ConfirmDialog({
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className={variant === 'destructive' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
className={
variant === 'destructive'
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: ''
}
>
{confirmLabel}
</AlertDialogAction>

View File

@@ -13,16 +13,25 @@ interface EmptyStateProps {
* Reusable empty state with icon + CTA.
* Used when DataTables have 0 rows.
*/
export function EmptyState({ icon, title, description, actionLabel, actionHref, onAction }: EmptyStateProps) {
export function EmptyState({
icon,
title,
description,
actionLabel,
actionHref,
onAction,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
{icon && (
<div className="mb-4 rounded-full bg-muted p-4 text-muted-foreground">
<div className="bg-muted text-muted-foreground mb-4 rounded-full p-4">
{icon}
</div>
)}
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
{description}
</p>
{actionLabel && (
<div className="mt-6">
{actionHref ? (

View File

@@ -12,30 +12,38 @@ interface StatsCardProps {
* Reusable stat card with icon + value + label + optional trend.
* Used on dashboard and list pages.
*/
export function StatsCard({ title, value, icon, description, trend }: StatsCardProps) {
export function StatsCard({
title,
value,
icon,
description,
trend,
}: StatsCardProps) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-muted-foreground text-sm font-medium">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
<p className="text-muted-foreground text-xs">{description}</p>
)}
</div>
{icon && (
<div className="rounded-full bg-primary/10 p-3 text-primary">
<div className="bg-primary/10 text-primary rounded-full p-3">
{icon}
</div>
)}
</div>
{trend && (
<div className="mt-2 flex items-center text-xs">
<span className={trend.value >= 0 ? 'text-green-600' : 'text-red-600'}>
<span
className={trend.value >= 0 ? 'text-green-600' : 'text-red-600'}
>
{trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
</span>
<span className="ml-1 text-muted-foreground">{trend.label}</span>
<span className="text-muted-foreground ml-1">{trend.label}</span>
</div>
)}
</CardContent>

View File

@@ -1,11 +1,27 @@
'use client';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, Legend,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
const COLORS = ['#0d9488', '#14b8a6', '#2dd4bf', '#5eead4', '#99f6e4', '#ccfbf1'];
const COLORS = [
'#0d9488',
'#14b8a6',
'#2dd4bf',
'#5eead4',
'#99f6e4',
'#ccfbf1',
];
interface BarChartData {
name: string;
@@ -17,10 +33,16 @@ interface PieChartData {
value: number;
}
export function StatsBarChart({ data, title }: { data: BarChartData[]; title?: string }) {
if (data.length === 0 || data.every(d => d.value === 0)) {
export function StatsBarChart({
data,
title,
}: {
data: BarChartData[];
title?: string;
}) {
if (data.length === 0 || data.every((d) => d.value === 0)) {
return (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
<div className="text-muted-foreground flex h-64 items-center justify-center text-sm">
Noch keine Daten vorhanden
</div>
);
@@ -28,25 +50,39 @@ export function StatsBarChart({ data, title }: { data: BarChartData[]; title?: s
return (
<div className="h-64">
{title && <p className="mb-2 text-sm font-medium text-muted-foreground">{title}</p>}
{title && (
<p className="text-muted-foreground mb-2 text-sm font-medium">
{title}
</p>
)}
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="name" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip />
<Bar dataKey="value" fill="var(--primary, #0d9488)" radius={[4, 4, 0, 0]} />
<Bar
dataKey="value"
fill="var(--primary, #0d9488)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
}
export function StatsPieChart({ data, title }: { data: PieChartData[]; title?: string }) {
const filtered = data.filter(d => d.value > 0);
export function StatsPieChart({
data,
title,
}: {
data: PieChartData[];
title?: string;
}) {
const filtered = data.filter((d) => d.value > 0);
if (filtered.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
<div className="text-muted-foreground flex h-64 items-center justify-center text-sm">
Noch keine Daten vorhanden
</div>
);
@@ -54,10 +90,24 @@ export function StatsPieChart({ data, title }: { data: PieChartData[]; title?: s
return (
<div className="h-64">
{title && <p className="mb-2 text-sm font-medium text-muted-foreground">{title}</p>}
{title && (
<p className="text-muted-foreground mb-2 text-sm font-medium">
{title}
</p>
)}
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={filtered} cx="50%" cy="50%" innerRadius={50} outerRadius={80} dataKey="value" label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}>
<Pie
data={filtered}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
dataKey="value"
label={({ name, percent }) =>
`${name} (${((percent ?? 0) * 100).toFixed(0)}%)`
}
>
{filtered.map((_, i) => (
<Cell key={`cell-${i}`} fill={COLORS[i % COLORS.length]} />
))}