974 lines
30 KiB
TypeScript
974 lines
30 KiB
TypeScript
// 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 (a–z)</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 (A–Z)</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 (0–9)</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>
|
||
);
|
||
}
|