// app/(app)/users/UsersTablesClient.tsx 'use client'; import { useMemo, useState, useTransition, useCallback, } from 'react'; import { useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; import Table, { type TableColumn } from '@/components/ui/Table'; import Tabs, { type TabItem } from '@/components/ui/Tabs'; import UserAvatar from '@/components/ui/UserAvatar'; import Button from '@/components/ui/Button'; import Badge from '@/components/ui/Badge'; import type { User } from '@/generated/prisma/client'; import type { GroupWithUsers, SimpleGroup, UserWithAvatar, } from './types'; import AssignGroupForm from './AssignGroupForm'; import UserRowActions from './UserRowActions'; import EditUserModal from './EditUserModal'; import ChangePasswordModal from './ChangePasswordModal'; import { evaluatePassword } from './passwordUtils'; import Card from '@/components/ui/Card'; type Props = { groups: GroupWithUsers[]; ungrouped: User[]; allGroups: SimpleGroup[]; }; type GroupCluster = { baseKey: string; // z.B. "Gruppe" oder "Test" label: string; // Anzeige-Label im Haupt-Tab groups: GroupWithUsers[]; // alle Gruppen wie "Gruppe1", "Gruppe1-Test", "Test1", "Test2" ... totalCount: number; // Summe aller User in diesem Cluster }; /* ───────── Helper: Cluster-Key aus Gruppennamen ───────── */ function getBaseGroupName(name: string): string { const trimmed = name.trim(); if (!trimmed) return ''; const beforeDash = trimmed.split('-')[0].trim() || trimmed; const withoutDigits = beforeDash.replace(/\d+$/, '').trim(); return withoutDigits || beforeDash; } /* ───────── Haupt-Client ───────── */ export default function UsersTablesClient({ groups, ungrouped, allGroups, }: Props) { const router = useRouter(); const { data: session } = useSession(); const [deleteGroupPending, startDeleteGroupTransition] = useTransition(); // 🔹 Ausgewählte Benutzer in der Tabelle const [selectedUserIds, setSelectedUserIds] = useState([]); // 🔹 Bulk-Delete-Transition const [bulkDeleting, startBulkDeleteTransition] = useTransition(); // 🔹 Edit-Dialog (global, nicht pro Zeile) const [editUser, setEditUser] = useState(null); const [editArbeitsname, setEditArbeitsname] = useState(''); const [editFirstName, setEditFirstName] = useState(''); const [editLastName, setEditLastName] = useState(''); const [savingEdit, startEditTransition] = useTransition(); const openEditForUser = useCallback((user: UserWithAvatar) => { setEditUser(user); setEditArbeitsname(user.arbeitsname ?? ''); setEditFirstName(user.firstName ?? ''); setEditLastName(user.lastName ?? ''); }, []); function handleSaveEdit() { if (!editUser) return; startEditTransition(async () => { try { const res = await fetch( `/api/users/${encodeURIComponent(editUser.nwkennung)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ arbeitsname: editArbeitsname, firstName: editFirstName, lastName: editLastName, }), }, ); if (!res.ok) { console.error( 'Fehler beim Aktualisieren der User', await res.text(), ); return; } setEditUser(null); router.refresh(); } catch (err) { console.error('Fehler beim Aktualisieren der User', err); } }); } // 🔹 Passwort-Dialog (global) const [pwUser, setPwUser] = useState(null); const [newPassword, setNewPassword] = useState(''); const [newPasswordConfirm, setNewPasswordConfirm] = useState(''); const [pwError, setPwError] = useState(null); const [savingPw, startPwTransition] = useTransition(); const openPwForUser = useCallback((user: UserWithAvatar) => { setPwUser(user); setNewPassword(''); setNewPasswordConfirm(''); setPwError(null); }, []); const pwChecks = useMemo( () => evaluatePassword(newPassword), [newPassword], ); const passwordsMatch = newPassword.length > 0 && newPassword === newPasswordConfirm; const canSubmitPw = pwChecks.allOk && passwordsMatch && !savingPw; function handleChangePassword() { if (!pwUser) return; setPwError(null); if (!pwChecks.allOk) { setPwError( 'Das Passwort erfüllt noch nicht alle Sicherheitskriterien.', ); return; } if (!passwordsMatch) { setPwError('Die Passwörter stimmen nicht überein.'); return; } startPwTransition(async () => { try { const res = await fetch( `/api/users/${encodeURIComponent(pwUser.nwkennung)}/password`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: newPassword }), }, ); if (!res.ok) { const data = await res.json().catch(() => null); console.error( 'Fehler beim Ändern des Passworts', res.status, data, ); setPwError( data?.error ?? 'Fehler beim Ändern des Passworts. Details in der Konsole.', ); return; } setPwUser(null); setNewPassword(''); setNewPasswordConfirm(''); setPwError(null); router.refresh(); window.alert('Passwort wurde aktualisiert.'); } catch (err) { console.error('Fehler beim Ändern des Passworts', err); setPwError('Fehler beim Ändern des Passworts.'); } }); } // 🔹 Single-User-Delete (global, nicht pro Zeile) const [deleteUserPending, startDeleteUserTransition] = useTransition(); const [deletingUserId, setDeletingUserId] = useState( null, ); function handleDeleteUser(user: UserWithAvatar) { const ok = window.confirm( `User "${user.arbeitsname || user.firstName || user.lastName}" wirklich löschen?`, ); if (!ok) return; setDeletingUserId(user.nwkennung); startDeleteUserTransition(async () => { try { const res = await fetch( `/api/users/${encodeURIComponent(user.nwkennung)}`, { method: 'DELETE' }, ); if (!res.ok) { console.error( 'Fehler beim Löschen der User', await res.text(), ); return; } router.refresh(); } catch (err) { console.error('Fehler beim Löschen der User', err); } finally { setDeletingUserId(null); } }); } // User-ID aus der Session – passe das ggf. an dein Session-Objekt an const currentUserId = (session?.user as any)?.userId ?? (session?.user as any)?.id ?? null; // Cluster nach Basis-Key bauen const { clustersList, clustersByBaseKey } = useMemo(() => { const byKey = new Map(); for (const g of groups) { const baseKey = getBaseGroupName(g.name); let cluster = byKey.get(baseKey); if (!cluster) { cluster = { baseKey, label: baseKey, groups: [], totalCount: 0, }; byKey.set(baseKey, cluster); } cluster.groups.push(g); cluster.totalCount += g.users.length; } for (const cluster of byKey.values()) { const root = cluster.baseKey; const dashedNames = cluster.groups.map((g) => g.name.split('-')[0].trim(), ); if (dashedNames.includes(root)) { cluster.label = root; } else { const candidates = dashedNames.length > 0 ? dashedNames : cluster.groups.map((g) => g.name); cluster.label = candidates.reduce( (shortest, curr) => curr.length < shortest.length ? curr : shortest, candidates[0], ); } } const list = Array.from(byKey.values()).sort((a, b) => a.label.localeCompare(b.label, 'de'), ); return { clustersList: list, clustersByBaseKey: byKey }; }, [groups]); // Haupt-Tabs: pro Cluster + "Ohne Gruppe" const mainTabs: TabItem[] = useMemo(() => { const baseTabs: TabItem[] = clustersList.map((cluster) => ({ id: cluster.baseKey, label: cluster.label, count: cluster.totalCount, })); baseTabs.push({ id: 'ungrouped', label: 'Ohne Gruppe', count: ungrouped.length, }); return baseTabs; }, [clustersList, ungrouped.length]); // Aktiver Haupt-Tab (State) + "sicherer" Wert const [activeMainTab, setActiveMainTab] = useState(() => mainTabs[0]?.id ? mainTabs[0].id : 'ungrouped', ); const safeActiveMainTab = useMemo(() => { if (!mainTabs.length) return 'ungrouped'; const exists = mainTabs.some((t) => t.id === activeMainTab); if (!exists) { return mainTabs[0].id; } return activeMainTab; }, [mainTabs, activeMainTab]); // Unter-Tabs: pro Cluster z.B. [Alle, Test1, Test2, Test-Test, …] const subTabs: TabItem[] = useMemo(() => { if (safeActiveMainTab === 'ungrouped') return []; const cluster = clustersByBaseKey.get(safeActiveMainTab); if (!cluster) return []; const items: TabItem[] = []; items.push({ id: '__all', label: 'Alle', count: cluster.totalCount, }); const sortedGroups = [...cluster.groups].sort((a, b) => a.name.localeCompare(b.name, 'de'), ); for (const g of sortedGroups) { items.push({ id: g.id, label: g.name, count: g.users.length, }); } return items; }, [safeActiveMainTab, clustersByBaseKey]); // Aktiver Unter-Tab (State) + "sicherer" Wert const [activeSubTab, setActiveSubTab] = useState('__all'); const safeActiveSubTab = useMemo(() => { // Wenn "Ohne Gruppe" oder keine Sub-Tabs: immer "__all" if (safeActiveMainTab === 'ungrouped' || subTabs.length === 0) { return '__all'; } // Wenn der aktuelle Sub-Tab existiert, nutzen const exists = subTabs.some((t) => t.id === activeSubTab); if (exists) return activeSubTab; // Sonst erster Sub-Tab return subTabs[0]?.id ?? '__all'; }, [safeActiveMainTab, subTabs, activeSubTab]); // Tabelle: Daten nach aktivem Haupt-Tab + Sub-Tab filtern const tableData: UserWithAvatar[] = useMemo(() => { let rows: UserWithAvatar[] = []; if (safeActiveMainTab === 'ungrouped') { rows = ungrouped as UserWithAvatar[]; } else { const cluster = clustersByBaseKey.get(safeActiveMainTab); if (!cluster) return []; if (safeActiveSubTab === '__all') { rows = cluster.groups.flatMap( (g) => g.users, ) as UserWithAvatar[]; } else { const group = cluster.groups.find( (g) => g.id === safeActiveSubTab, ); if (!group) return []; rows = group.users as UserWithAvatar[]; } } // Standard-Sortierung nach Arbeitsname (auf Kopie arbeiten) return [...rows].sort((a, b) => { const aName = (a.arbeitsname ?? '').toLocaleLowerCase('de-DE'); const bName = (b.arbeitsname ?? '').toLocaleLowerCase('de-DE'); return aName.localeCompare(bName, 'de', { sensitivity: 'base', }); }); }, [ safeActiveMainTab, safeActiveSubTab, ungrouped, clustersByBaseKey, ]); // Gruppe löschen (konkrete Untergruppe, nicht der ganze Cluster) function handleDeleteActiveGroup() { if (safeActiveMainTab === 'ungrouped') return; if (safeActiveSubTab === '__all') { alert( 'Bitte zuerst eine konkrete Untergruppe auswählen, bevor du sie löschen kannst.', ); return; } const group = allGroups.find((g) => g.id === safeActiveSubTab); if (!group) return; const ok = window.confirm( `Gruppe "${group.name}" wirklich löschen?\n\nAlle Benutzer dieser Gruppe werden NICHT gelöscht, aber ihre Gruppen-Zuordnung wird entfernt (sie landen unter "Ohne Gruppe").`, ); if (!ok) return; startDeleteGroupTransition(async () => { try { const res = await fetch(`/api/user-groups/${group.id}`, { method: 'DELETE', }); if (!res.ok) { console.error( 'Fehler beim Löschen der Gruppe', await res.text(), ); alert('Gruppe konnte nicht gelöscht werden.'); return; } router.refresh(); } catch (err) { console.error('Fehler beim Löschen der Gruppe', err); alert('Fehler beim Löschen der Gruppe.'); } }); } // Ausgewählte Benutzer löschen function handleDeleteSelectedUsers() { if (selectedUserIds.length === 0) return; const selectedUsers = tableData.filter((u) => selectedUserIds.includes(u.nwkennung), ); const deletableUsers = selectedUsers.filter( (u) => !currentUserId || u.nwkennung !== currentUserId, ); if (deletableUsers.length === 0) { alert( 'Die Auswahl enthält nur deinen eigenen Benutzer. Dieser kann nicht gelöscht werden.', ); return; } const previewNames = deletableUsers .slice(0, 5) .map( (u) => u.arbeitsname || [u.firstName, u.lastName].filter(Boolean).join(' ') || u.nwkennung, ) .join(', '); const moreCount = deletableUsers.length - 5; const msg = `Möchtest du wirklich ${deletableUsers.length} Benutzer löschen?\n\n` + previewNames + (moreCount > 0 ? ` … und ${moreCount} weitere` : '') + `\n\nHinweis: Dein eigener Benutzer wird, falls ausgewählt, nicht gelöscht.`; const ok = window.confirm(msg); if (!ok) return; startBulkDeleteTransition(async () => { try { for (const u of deletableUsers) { const res = await fetch( `/api/users/${encodeURIComponent(u.nwkennung)}`, { method: 'DELETE' }, ); if (!res.ok) { console.error( 'Fehler beim Löschen des Users', u.nwkennung, await res.text(), ); } } setSelectedUserIds([]); router.refresh(); } catch (err) { console.error('Fehler beim Bulk-Löschen der Benutzer', err); alert('Fehler beim Löschen der Benutzer.'); } }); } // ganzen Cluster löschen function handleDeleteActiveCluster() { if (safeActiveMainTab === 'ungrouped') return; const cluster = clustersByBaseKey.get(safeActiveMainTab); if (!cluster || cluster.groups.length === 0) return; const groupNames = cluster.groups.map((g) => g.name).join(', '); const ok = window.confirm( `Cluster "${cluster.label}" wirklich löschen?\n\nEs werden alle folgenden Gruppen gelöscht:\n${groupNames}\n\nAlle Benutzer dieser Gruppen werden NICHT gelöscht, aber ihre Gruppen-Zuordnung wird entfernt (sie landen unter "Ohne Gruppe").`, ); if (!ok) return; startDeleteGroupTransition(async () => { try { for (const g of cluster.groups) { const res = await fetch(`/api/user-groups/${g.id}`, { method: 'DELETE', }); if (!res.ok) { console.error( 'Fehler beim Löschen der Gruppe', g.name, await res.text(), ); } } router.refresh(); } catch (err) { console.error('Fehler beim Löschen des Clusters', err); alert('Fehler beim Löschen des Clusters.'); } }); } // Columns inkl. Gruppen-Spalte const userColumns: TableColumn[] = useMemo( () => [ { key: 'avatarUrl', header: '', sortable: false, headerClassName: 'w-10', cellClassName: 'w-10', render: (row) => ( ), }, { key: 'arbeitsname', header: 'Arbeitsname', sortable: true, render: (row) => { const isCurrent = !!currentUserId && row.nwkennung === currentUserId; return (
{row.arbeitsname} {isCurrent && ( Du )}
); }, }, { key: 'lastName', header: 'Nachname', sortable: true, }, { key: 'firstName', header: 'Vorname', sortable: true, }, // 🔹 NEUE SPALTE: Darf Geräte bearbeiten { key: 'canEditDevices', header: 'Geräte bearbeiten', sortable: false, headerClassName: 'w-40', cellClassName: 'w-40', render: (row) => { const group = allGroups.find((g) => g.id === row.groupId); const canEdit = !!group?.canEditDevices; if (!row.groupId) { return ( Keine Gruppe ); } return ( {canEdit ? 'Ja' : 'Nein'} ); }, }, { key: 'groupId', header: 'Gruppe', sortable: false, render: (row) => ( ), }, ], [allGroups, currentUserId], ); const canDeleteCurrentGroup = safeActiveMainTab !== 'ungrouped' && safeActiveSubTab !== '__all' && subTabs.length > 0; const canDeleteCurrentCluster = safeActiveMainTab !== 'ungrouped' && !!clustersByBaseKey.get(safeActiveMainTab)?.groups.length; const handleSelectionChange = useCallback((rows: unknown[]) => { const typed = rows as UserWithAvatar[]; const nextIds = typed.map((r) => r.nwkennung); setSelectedUserIds((prev) => { if ( prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx]) ) { return prev; } return nextIds; }); }, []); return (
{/* 1. Tab-Reihe: Hauptgruppen (Cluster) + Cluster-Löschen-Button */}
{canDeleteCurrentCluster && ( )}
{/* 2. Tab-Reihe: Untergruppen + Untergruppe-löschen-Button */} {safeActiveMainTab !== 'ungrouped' && subTabs.length > 0 && (
{canDeleteCurrentGroup && ( )}
)}
data={tableData} columns={userColumns} getRowId={(row) => row.nwkennung} actionsHeader="Aktionen" selectable onSelectionChange={handleSelectionChange} renderActions={(row) => ( openEditForUser(row)} onChangePassword={() => openPwForUser(row)} onDelete={() => handleDeleteUser(row)} isDeleting={ deleteUserPending && deletingUserId === row.nwkennung } isSavingPassword={ savingPw && pwUser?.nwkennung === row.nwkennung } /> )} defaultSortKey="arbeitsname" defaultSortDirection="asc" /> {/* Floating Actions in Card, unten mittig über der Tabelle */} {selectedUserIds.length > 0 && (
{selectedUserIds.length === 1 ? '1 Benutzer ausgewählt' : `${selectedUserIds.length} Benutzer ausgewählt`}
)}
{/* Edit-Modal */} {editUser && ( setEditUser(null)} onSubmit={handleSaveEdit} canEditDevices={(() => { const group = allGroups.find((g) => g.id === editUser.groupId); return !!group?.canEditDevices; })()} /> )} {/* Passwort-Modal */} {pwUser && ( { setNewPassword(value); setPwError(null); }} onNewPasswordConfirmChange={(value) => { setNewPasswordConfirm(value); setPwError(null); }} onClose={() => setPwUser(null)} onSubmit={handleChangePassword} /> )}
); }