geraete/app/(app)/users/UsersTablesClient.tsx
2025-11-26 08:02:48 +01:00

974 lines
30 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 { useEffect, useMemo, useState, useTransition } 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 Dropdown from '@/components/ui/Dropdown';
import UserAvatar from '@/components/ui/UserAvatar';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import Badge from '@/components/ui/Badge';
import type { User, UserGroup } from '@/generated/prisma/client';
import { PencilIcon, TrashIcon, KeyIcon, CheckIcon } from '@heroicons/react/24/outline';
import type { GroupWithUsers, SimpleGroup, UserWithAvatar } from './types';
import AssignGroupForm from './AssignGroupForm';
type Props = {
groups: GroupWithUsers[];
ungrouped: User[];
allGroups: SimpleGroup[];
};
type PasswordChecks = {
lengthOk: boolean;
lowerOk: boolean;
upperOk: boolean;
digitOk: boolean;
specialOk: boolean;
allOk: boolean;
};
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
};
type UserRowActionsProps = {
user: UserWithAvatar;
currentUserId: string | null;
};
/* ───────── Helper: Cluster-Key aus Gruppennamen ───────── */
/**
* Idee:
* 1) Bis zum ersten '-' kürzen (z.B. "Test-Test" -> "Test", "Gruppe1-Test" -> "Gruppe1")
* 2) Danach Ziffern am Ende entfernen (z.B. "Test1" -> "Test", "Test2" -> "Test")
* 3) Fallback: wenn danach nichts übrig bleibt, den ursprünglichen Teil nehmen
*
* Beispiele:
* - "Gruppe1" -> beforeDash: "Gruppe1" -> ohne Ziffern: "Gruppe" -> Key: "Gruppe"
* - "Gruppe1-Test" -> beforeDash: "Gruppe1" -> ohne Ziffern: "Gruppe" -> Key: "Gruppe"
* - "Test1" -> beforeDash: "Test1" -> ohne Ziffern: "Test" -> Key: "Test"
* - "Test2" -> beforeDash: "Test2" -> ohne Ziffern: "Test" -> Key: "Test"
* - "Test-Test" -> beforeDash: "Test" -> ohne Ziffern: "Test" -> Key: "Test"
* - "Test" -> beforeDash: "Test" -> ohne Ziffern: "Test" -> Key: "Test"
*/
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;
}
function evaluatePassword(password: string): PasswordChecks {
const lengthOk = password.length >= 12;
const lowerOk = /[a-z]/.test(password);
const upperOk = /[A-Z]/.test(password);
const digitOk = /\d/.test(password);
const specialOk = /[^A-Za-z0-9]/.test(password);
return {
lengthOk,
lowerOk,
upperOk,
digitOk,
specialOk,
allOk: lengthOk && lowerOk && upperOk && digitOk && specialOk,
};
}
/* ───────── Zeilen-Aktionen: Bearbeiten + Löschen ───────── */
function UserRowActions({ user, currentUserId }: UserRowActionsProps) {
const router = useRouter();
const isCurrentUser =
!!currentUserId && user.nwkennung === currentUserId;
// Edit-Modal
const [editOpen, setEditOpen] = useState(false);
const [editNwKennung, setEditNwKennung] = useState(
user.nwkennung ?? '',
);
const [editArbeitsname, setEditArbeitsname] = useState(
user.arbeitsname ?? '',
);
const [editFirstName, setEditFirstName] = useState(
user.firstName ?? '',
);
const [editLastName, setEditLastName] = useState(user.lastName ?? '');
const [savingEdit, startEditTransition] = useTransition();
// Löschen
const [deleting, startDeleteTransition] = useTransition();
// 🔹 NEU: Passwort ändern
const [pwOpen, setPwOpen] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const [pwError, setPwError] = useState<string | null>(null);
const [savingPw, startPwTransition] = useTransition();
const pwChecks = useMemo(
() => evaluatePassword(newPassword),
[newPassword],
);
const passwordsMatch =
newPassword.length > 0 && newPassword === newPasswordConfirm;
const canSubmitPw = pwChecks.allOk && passwordsMatch && !savingPw;
async function handleSaveEdit() {
startEditTransition(async () => {
try {
const res = await fetch(`/api/users/${user.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;
}
setEditOpen(false);
router.refresh();
} catch (err) {
console.error('Fehler beim Aktualisieren der User', err);
}
});
}
async function handleDelete() {
const ok = window.confirm(
`User "${user.arbeitsname || user.firstName || user.lastName}" wirklich löschen?`,
);
if (!ok) return;
startDeleteTransition(async () => {
try {
const res = await fetch(`/api/users/${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);
}
});
}
async function handleChangePassword() {
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(user.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;
}
// success
setPwOpen(false);
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.');
}
});
}
return (
<>
<div className="flex items-center gap-3">
{/* Desktop / Tablet: Buttons */}
<div className="hidden sm:flex items-center gap-2">
<Button
type="button"
size="lg"
variant="secondary"
icon={<PencilIcon className="size-5" />}
onClick={() => setEditOpen(true)}
/>
{/* 🔹 NEU: Passwort ändern */}
<Button
type="button"
size="lg"
variant="soft"
tone="indigo"
icon={<KeyIcon className="size-5" />}
onClick={() => setPwOpen(true)}
/>
{/* Löschen nur anzeigen, wenn NICHT eigener User */}
{!isCurrentUser && (
<Button
type="button"
size="lg"
variant="soft"
tone="rose"
icon={<TrashIcon className="size-5" />}
onClick={handleDelete}
disabled={deleting}
/>
)}
</div>
{/* Mobile: Dropdown mit denselben Actions */}
<div className="sm:hidden">
<Dropdown
triggerVariant="icon"
ariaLabel="Weitere Aktionen"
align="right"
sections={[
{
id: 'main',
items: [
{
id: 'edit',
label: 'Bearbeiten',
onClick: () => setEditOpen(true),
},
{
id: 'change-password',
label: savingPw ? 'Passwort …' : 'Passwort ändern',
onClick: () => setPwOpen(true),
},
// Delete nur, wenn nicht eigener User
...(!isCurrentUser
? [
{
id: 'delete',
label: deleting ? 'Lösche …' : 'Löschen',
tone: 'danger' as const,
onClick: handleDelete,
disabled: deleting,
},
]
: []),
],
},
]}
/>
</div>
</div>
{/* Edit-Modal */}
<Modal
open={editOpen}
onClose={() => setEditOpen(false)}
title="User bearbeiten"
tone="info"
variant="centered"
size="md"
primaryAction={{
label: savingEdit ? 'Speichere …' : 'Speichern',
onClick: handleSaveEdit,
variant: 'primary',
}}
secondaryAction={{
label: 'Abbrechen',
onClick: () => setEditOpen(false),
variant: 'secondary',
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
handleSaveEdit();
}}
className="space-y-3 text-sm"
>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
NW-Kennung *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 disabled"
value={editNwKennung}
onChange={(e) => setEditNwKennung(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Arbeitsname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={editArbeitsname}
onChange={(e) => setEditArbeitsname(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Vorname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={editFirstName}
onChange={(e) => setEditFirstName(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Nachname *
</label>
<input
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={editLastName}
onChange={(e) => setEditLastName(e.target.value)}
/>
</div>
</div>
</form>
</Modal>
{/* 🔹 NEU: Passwort ändern-Modal */}
<Modal
open={pwOpen}
onClose={() => setPwOpen(false)}
title="Passwort ändern"
tone="warning"
variant="centered"
size="md"
primaryAction={{
label: savingPw ? 'Speichere …' : 'Passwort setzen',
onClick: handleChangePassword,
variant: 'primary',
disabled: !canSubmitPw,
}}
secondaryAction={{
label: 'Abbrechen',
onClick: () => setPwOpen(false),
variant: 'secondary',
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
handleChangePassword();
}}
className="space-y-3 text-sm"
>
<p className="text-xs text-gray-600 dark:text-gray-400">
Das neue Passwort gilt sofort für den Benutzer{' '}
<strong>{user.arbeitsname || user.nwkennung}</strong>.
</p>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Neues Passwort *
</label>
<input
type="password"
required
minLength={12}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
setPwError(null);
}}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Passwort bestätigen *
</label>
<input
type="password"
required
minLength={12}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={newPasswordConfirm}
onChange={(e) => {
setNewPasswordConfirm(e.target.value);
setPwError(null);
}}
/>
</div>
<div className="mt-2 rounded-md bg-gray-50 p-2 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<p className="font-medium mb-1">Sicherheitskriterien:</p>
<ul className="space-y-0.5">
<li
className={
(pwChecks.lengthOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.lengthOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens 12 Zeichen</span>
</li>
<li
className={
(pwChecks.lowerOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.lowerOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens ein Kleinbuchstabe (az)</span>
</li>
<li
className={
(pwChecks.upperOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.upperOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens ein Großbuchstabe (AZ)</span>
</li>
<li
className={
(pwChecks.digitOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.digitOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens eine Ziffer (09)</span>
</li>
<li
className={
(pwChecks.specialOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.specialOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens ein Sonderzeichen (!, ?, #, )</span>
</li>
</ul>
</div>
{pwError && (
<p className="text-xs text-red-600 dark:text-red-400">
{pwError}
</p>
)}
</form>
</Modal>
</>
);
}
/* ───────── Haupt-Client ───────── */
export default function UsersTablesClient({
groups,
ungrouped,
allGroups,
}: Props) {
const router = useRouter();
const { data: session } = useSession();
const [deleteGroupPending, startDeleteGroupTransition] = useTransition();
// 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, z.B. "Gruppe" für "Gruppe1"/"Gruppe1-Test",
// "Test" für "Test1", "Test2", "Test-Test", ...
const { clustersList, clustersByBaseKey } = useMemo(() => {
const byKey = new Map<string, GroupCluster>();
for (const g of groups) {
const baseKey = getBaseGroupName(g.name); // z.B. "Test" aus "Test1"
let cluster = byKey.get(baseKey);
if (!cluster) {
cluster = {
baseKey,
label: baseKey, // wird gleich noch ggf. verfeinert
groups: [],
totalCount: 0,
};
byKey.set(baseKey, cluster);
}
cluster.groups.push(g);
cluster.totalCount += g.users.length;
}
// Label bestimmen:
// - Wenn es eine Gruppe mit Name-Basis == baseKey gibt (z.B. "Test-Test" -> "Test"),
// nimm genau diesen Basisnamen ("Test").
// - Sonst: kürzester Name vor dem Bindestrich (z.B. "Gruppe1" bei "Gruppe1" + "Gruppe1-Test")
for (const cluster of byKey.values()) {
const root = cluster.baseKey; // z.B. "Test"
const dashedNames = cluster.groups.map((g) =>
g.name.split('-')[0].trim(),
);
if (dashedNames.includes(root)) {
// z.B. "Test1", "Test2", "Test-Test" -> root = "Test" vorhanden -> Label = "Test"
cluster.label = root;
} else {
// z.B. "Gruppe1", "Gruppe1-Test" -> root = "Gruppe"
// Kein "Gruppe" vorhanden, nimm den kürzesten "beforeDash": "Gruppe1"
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 (Cluster, z.B. "Test" oder "Gruppe" oder "ungrouped")
const [activeMainTab, setActiveMainTab] = useState<string>(
() => mainTabs[0]?.id ?? 'ungrouped',
);
useEffect(() => {
if (!mainTabs.some((t) => t.id === activeMainTab)) {
setActiveMainTab(mainTabs[0]?.id ?? 'ungrouped');
}
}, [mainTabs, activeMainTab]);
// Unter-Tabs: pro Cluster z.B. [Alle, Test1, Test2, Test-Test, …]
const subTabs: TabItem[] = useMemo(() => {
if (activeMainTab === 'ungrouped') return [];
const cluster = clustersByBaseKey.get(activeMainTab);
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;
}, [activeMainTab, clustersByBaseKey]);
// Aktiver Unter-Tab
const [activeSubTab, setActiveSubTab] = useState<string>('__all');
useEffect(() => {
// Bei Wechsel auf "Ohne Gruppe" oder wenn es keine Sub-Tabs gibt: immer "__all"
if (activeMainTab === 'ungrouped' || subTabs.length === 0) {
if (activeSubTab !== '__all') {
setActiveSubTab('__all');
}
return;
}
// Sicherstellen, dass der aktive Sub-Tab existiert
if (!subTabs.some((t) => t.id === activeSubTab)) {
setActiveSubTab(subTabs[0].id);
}
}, [activeMainTab, subTabs, activeSubTab]);
// Tabelle: Daten nach aktivem Haupt-Tab + Sub-Tab filtern
const tableData: UserWithAvatar[] = useMemo(() => {
let rows: UserWithAvatar[] = [];
if (activeMainTab === 'ungrouped') {
rows = ungrouped as UserWithAvatar[];
} else {
const cluster = clustersByBaseKey.get(activeMainTab);
if (!cluster) return [];
if (activeSubTab === '__all') {
rows = cluster.groups.flatMap((g) => g.users) as UserWithAvatar[];
} else {
const group = cluster.groups.find((g) => g.id === activeSubTab);
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' });
});
}, [activeMainTab, activeSubTab, ungrouped, clustersByBaseKey]);
// Gruppe löschen (konkrete Untergruppe, nicht der ganze Cluster)
function handleDeleteActiveGroup() {
if (activeMainTab === 'ungrouped') return;
if (activeSubTab === '__all') {
alert(
'Bitte zuerst eine konkrete Untergruppe auswählen, bevor du sie löschen kannst.',
);
return;
}
const group = allGroups.find((g) => g.id === activeSubTab);
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.');
}
});
}
// NEU: ganzen Cluster (alle Gruppen darunter) löschen
function handleDeleteActiveCluster() {
if (activeMainTab === 'ungrouped') return;
const cluster = clustersByBaseKey.get(activeMainTab);
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 {
// Alle Gruppen dieses Clusters nacheinander löschen
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 mit Dropdown + "Du"-Badge anhand der ID
const userColumns: TableColumn<UserWithAvatar>[] = useMemo(
() => [
{
// Avatar-Spalte
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,
},
{
key: 'groupId',
header: 'Gruppe',
sortable: false,
render: (row) => (
<AssignGroupForm
user={row}
defaultGroupId={row.groupId ?? null}
allGroups={allGroups}
/>
),
},
],
[allGroups, currentUserId],
);
const canDeleteCurrentGroup =
activeMainTab !== 'ungrouped' &&
activeSubTab !== '__all' &&
subTabs.length > 0;
const canDeleteCurrentCluster =
activeMainTab !== 'ungrouped' &&
clustersByBaseKey.get(activeMainTab)?.groups.length;
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}
value={activeMainTab}
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 */}
{activeMainTab !== 'ungrouped' && subTabs.length > 0 && (
<div className="flex items-center justify-between gap-3">
<Tabs
tabs={subTabs}
value={activeSubTab}
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>
<Table<UserWithAvatar>
data={tableData}
columns={userColumns}
getRowId={(row) => row.nwkennung}
actionsHeader="Aktionen"
renderActions={(row) => (
<UserRowActions
user={row}
currentUserId={currentUserId}
/>
)}
defaultSortKey="arbeitsname"
defaultSortDirection="asc"
/>
</div>
</div>
);
}