geraete/app/(app)/users/UsersTablesClient.tsx
2025-12-05 13:53:29 +01:00

841 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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