841 lines
24 KiB
TypeScript
841 lines
24 KiB
TypeScript
// 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<string[]>([]);
|
||
|
||
// 🔹 Bulk-Delete-Transition
|
||
const [bulkDeleting, startBulkDeleteTransition] = useTransition();
|
||
|
||
// 🔹 Edit-Dialog (global, nicht pro Zeile)
|
||
const [editUser, setEditUser] = useState<UserWithAvatar | null>(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<UserWithAvatar | null>(null);
|
||
const [newPassword, setNewPassword] = useState('');
|
||
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
|
||
const [pwError, setPwError] = useState<string | null>(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<string | null>(
|
||
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<string, GroupCluster>();
|
||
|
||
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<string>(() =>
|
||
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<string>('__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<UserWithAvatar>[] = useMemo(
|
||
() => [
|
||
{
|
||
key: 'avatarUrl',
|
||
header: '',
|
||
sortable: false,
|
||
headerClassName: 'w-10',
|
||
cellClassName: 'w-10',
|
||
render: (row) => (
|
||
<UserAvatar
|
||
name={row.arbeitsname as any}
|
||
avatarUrl={row.avatarUrl}
|
||
size="md"
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: 'arbeitsname',
|
||
header: 'Arbeitsname',
|
||
sortable: true,
|
||
render: (row) => {
|
||
const isCurrent =
|
||
!!currentUserId && row.nwkennung === currentUserId;
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span>{row.arbeitsname}</span>
|
||
{isCurrent && (
|
||
<Badge size="sm" tone="green" variant="flat">
|
||
Du
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
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 (
|
||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||
Keine Gruppe
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<span
|
||
className={
|
||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ' +
|
||
(canEdit
|
||
? 'bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-300'
|
||
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300')
|
||
}
|
||
>
|
||
{canEdit ? 'Ja' : 'Nein'}
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
|
||
{
|
||
key: 'groupId',
|
||
header: 'Gruppe',
|
||
sortable: false,
|
||
render: (row) => (
|
||
<AssignGroupForm
|
||
user={row}
|
||
defaultGroupId={row.groupId ?? null}
|
||
allGroups={allGroups}
|
||
/>
|
||
),
|
||
},
|
||
],
|
||
[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 (
|
||
<div className="space-y-4">
|
||
{/* 1. Tab-Reihe: Hauptgruppen (Cluster) + Cluster-Löschen-Button */}
|
||
<div className="mt-2 space-y-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<Tabs
|
||
tabs={mainTabs}
|
||
variant='pillsBrand'
|
||
value={safeActiveMainTab}
|
||
onChange={setActiveMainTab}
|
||
ariaLabel="Usergruppen (Cluster) auswählen"
|
||
/>
|
||
|
||
{canDeleteCurrentCluster && (
|
||
<Button
|
||
type="button"
|
||
variant="soft"
|
||
tone="rose"
|
||
size="lg"
|
||
onClick={handleDeleteActiveCluster}
|
||
disabled={deleteGroupPending}
|
||
>
|
||
{deleteGroupPending
|
||
? 'Lösche Cluster …'
|
||
: 'Cluster löschen'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 2. Tab-Reihe: Untergruppen + Untergruppe-löschen-Button */}
|
||
{safeActiveMainTab !== 'ungrouped' && subTabs.length > 0 && (
|
||
<div className="flex items-center justify-between gap-3">
|
||
<Tabs
|
||
tabs={subTabs}
|
||
variant='pillsBrand'
|
||
value={safeActiveSubTab}
|
||
onChange={setActiveSubTab}
|
||
ariaLabel="Untergruppen auswählen"
|
||
/>
|
||
|
||
{canDeleteCurrentGroup && (
|
||
<Button
|
||
type="button"
|
||
variant="soft"
|
||
tone="rose"
|
||
size="lg"
|
||
onClick={handleDeleteActiveGroup}
|
||
disabled={deleteGroupPending}
|
||
>
|
||
{deleteGroupPending
|
||
? 'Lösche Gruppe …'
|
||
: 'Untergruppe löschen'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<Table<UserWithAvatar>
|
||
data={tableData}
|
||
columns={userColumns}
|
||
getRowId={(row) => row.nwkennung}
|
||
actionsHeader="Aktionen"
|
||
selectable
|
||
onSelectionChange={handleSelectionChange}
|
||
renderActions={(row) => (
|
||
<UserRowActions
|
||
user={row}
|
||
currentUserId={currentUserId}
|
||
onEdit={() => 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 && (
|
||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 flex justify-center">
|
||
<Card className="pointer-events-auto shadow-lg bg-white dark:bg-gray-900">
|
||
<div className="flex items-center gap-3 px-4 py-3">
|
||
<span className="text-sm text-gray-700 dark:text-gray-200">
|
||
{selectedUserIds.length === 1
|
||
? '1 Benutzer ausgewählt'
|
||
: `${selectedUserIds.length} Benutzer ausgewählt`}
|
||
</span>
|
||
|
||
<Button
|
||
type="button"
|
||
variant="soft"
|
||
tone="rose"
|
||
size="sm"
|
||
onClick={handleDeleteSelectedUsers}
|
||
disabled={bulkDeleting}
|
||
>
|
||
{bulkDeleting
|
||
? 'Lösche ausgewählte …'
|
||
: 'Ausgewählte Benutzer löschen'}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Edit-Modal */}
|
||
{editUser && (
|
||
<EditUserModal
|
||
open={!!editUser}
|
||
user={editUser}
|
||
arbeitsname={editArbeitsname}
|
||
firstName={editFirstName}
|
||
lastName={editLastName}
|
||
saving={savingEdit}
|
||
onArbeitsnameChange={setEditArbeitsname}
|
||
onFirstNameChange={setEditFirstName}
|
||
onLastNameChange={setEditLastName}
|
||
onClose={() => setEditUser(null)}
|
||
onSubmit={handleSaveEdit}
|
||
canEditDevices={(() => {
|
||
const group = allGroups.find((g) => g.id === editUser.groupId);
|
||
return !!group?.canEditDevices;
|
||
})()}
|
||
/>
|
||
)}
|
||
|
||
{/* Passwort-Modal */}
|
||
{pwUser && (
|
||
<ChangePasswordModal
|
||
open={!!pwUser}
|
||
user={pwUser}
|
||
newPassword={newPassword}
|
||
newPasswordConfirm={newPasswordConfirm}
|
||
pwChecks={pwChecks}
|
||
pwError={pwError}
|
||
saving={savingPw}
|
||
canSubmitPw={canSubmitPw}
|
||
onNewPasswordChange={(value) => {
|
||
setNewPassword(value);
|
||
setPwError(null);
|
||
}}
|
||
onNewPasswordConfirmChange={(value) => {
|
||
setNewPasswordConfirm(value);
|
||
setPwError(null);
|
||
}}
|
||
onClose={() => setPwUser(null)}
|
||
onSubmit={handleChangePassword}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|