Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]} />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user