Files
myeasycms-v2/packages/features/verbandsverwaltung/src/components/hierarchy-tree.tsx

290 lines
7.7 KiB
TypeScript

'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>
);
}