Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link2,
|
||||
Network,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import {
|
||||
linkChildAccount,
|
||||
unlinkChildAccount,
|
||||
} from '../server/actions/hierarchy-actions';
|
||||
import type { HierarchyNode } from '../server/api';
|
||||
|
||||
interface HierarchyTreeProps {
|
||||
accountId: string;
|
||||
tree: HierarchyNode;
|
||||
availableAccounts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
function countDescendants(node: HierarchyNode): number {
|
||||
let count = node.children.length;
|
||||
for (const child of node.children) {
|
||||
count += countDescendants(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function TreeNodeRow({
|
||||
node,
|
||||
onUnlink,
|
||||
isUnlinking,
|
||||
}: {
|
||||
node: HierarchyNode;
|
||||
onUnlink: (childId: string) => void;
|
||||
isUnlinking: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(node.depth < 2);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isRoot = node.depth === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="hover:bg-muted/50 flex items-center gap-2 rounded-md px-2 py-2 transition-colors"
|
||||
style={{ paddingLeft: `${node.depth * 24 + 8}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
|
||||
<Building2 className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
|
||||
<span className="flex-1 truncate text-sm font-medium">
|
||||
{node.name}
|
||||
{node.slug && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
/{node.slug}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{hasChildren && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{node.children.length} direkt
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isRoot ? (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Dachverband
|
||||
</Badge>
|
||||
) : node.depth === 1 ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Unterverband
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Verein
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{!isRoot && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
||||
onClick={() => onUnlink(node.id)}
|
||||
disabled={isUnlinking}
|
||||
title="Verknüpfung lösen"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded &&
|
||||
hasChildren &&
|
||||
node.children.map((child) => (
|
||||
<TreeNodeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
onUnlink={onUnlink}
|
||||
isUnlinking={isUnlinking}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HierarchyTree({
|
||||
accountId,
|
||||
tree,
|
||||
availableAccounts,
|
||||
}: HierarchyTreeProps) {
|
||||
const [selectedAccountId, setSelectedAccountId] = useState('');
|
||||
|
||||
const totalDescendants = countDescendants(tree);
|
||||
|
||||
const { execute: executeLink, isPending: isLinking } = useAction(
|
||||
linkChildAccount,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Organisation erfolgreich verknüpft');
|
||||
setSelectedAccountId('');
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(
|
||||
error.serverError ?? 'Fehler beim Verknüpfen der Organisation',
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { execute: executeUnlink, isPending: isUnlinking } = useAction(
|
||||
unlinkChildAccount,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Verknüpfung gelöst');
|
||||
}
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(
|
||||
error.serverError ?? 'Fehler beim Entfernen der Verknüpfung',
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function handleLink() {
|
||||
if (!selectedAccountId) return;
|
||||
executeLink({
|
||||
parentAccountId: accountId,
|
||||
childAccountId: selectedAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnlink(childId: string) {
|
||||
executeUnlink({
|
||||
childAccountId: childId,
|
||||
parentAccountId: accountId,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Direkte Unterverbände
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{tree.children.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Organisationen gesamt
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{totalDescendants}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Verfügbar zum Verknüpfen
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{availableAccounts.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Network className="h-4 w-4" />
|
||||
Organisationsstruktur
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border p-2">
|
||||
<TreeNodeRow
|
||||
node={tree}
|
||||
onUnlink={handleUnlink}
|
||||
isUnlinking={isUnlinking}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Link Account */}
|
||||
{availableAccounts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Link2 className="h-4 w-4" />
|
||||
Organisation hinzufügen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="link-account"
|
||||
className="text-muted-foreground mb-1 block text-sm"
|
||||
>
|
||||
Verfügbare Organisationen
|
||||
</label>
|
||||
<select
|
||||
id="link-account"
|
||||
value={selectedAccountId}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value)}
|
||||
className="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
<option value="">Organisation auswählen...</option>
|
||||
{availableAccounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
{a.slug ? ` (/${a.slug})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLink}
|
||||
disabled={!selectedAccountId || isLinking}
|
||||
>
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
{isLinking ? 'Wird verknüpft...' : 'Verknüpfen'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user