xpeditis2.0/apps/frontend/app/[locale]/dashboard/settings/users/page.tsx
2026-05-12 21:01:52 +02:00

849 lines
36 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.

'use client';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslations, useLocale } from 'next-intl';
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations';
import { useAuth } from '@/lib/context/auth-context';
import { Link, useRouter } from '@/i18n/navigation';
import ExportButton from '@/components/ExportButton';
import { PageHeader } from '@/components/ui/PageHeader';
const PAGE_SIZE = 5;
function Pagination({
page,
total,
onPage,
}: {
page: number;
total: number;
onPage: (p: number) => void;
}) {
const t = useTranslations('dashboard.usersManagement.pagination');
const totalPages = Math.ceil(total / PAGE_SIZE);
if (totalPages <= 1) return null;
return (
<div className="px-6 py-3 flex items-center justify-between border-t border-gray-200">
<p className="text-sm text-gray-500">
{t('info', {
from: Math.min((page - 1) * PAGE_SIZE + 1, total),
to: Math.min(page * PAGE_SIZE, total),
total,
})}
</p>
<div className="flex items-center gap-1">
<button
onClick={() => onPage(page - 1)}
disabled={page === 1}
className="px-3 py-1 rounded text-sm border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
<button
key={p}
onClick={() => onPage(p)}
className={`px-3 py-1 rounded text-sm border ${
p === page
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
{p}
</button>
))}
<button
onClick={() => onPage(page + 1)}
disabled={page === totalPages}
className="px-3 py-1 rounded text-sm border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
>
</button>
</div>
</div>
);
}
export default function UsersManagementPage() {
const t = useTranslations('dashboard.usersManagement');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const router = useRouter();
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [showInviteModal, setShowInviteModal] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [usersPage, setUsersPage] = useState(1);
const [invitationsPage, setInvitationsPage] = useState(1);
const [inviteForm, setInviteForm] = useState({
email: '',
firstName: '',
lastName: '',
role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER',
});
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => listUsers(),
});
const { data: licenseStatus } = useQuery({
queryKey: ['canInvite'],
queryFn: () => canInviteUser(),
});
const { data: pendingInvitations } = useQuery({
queryKey: ['invitations'],
queryFn: () => listInvitations(),
});
const inviteMutation = useMutation({
mutationFn: (data: typeof inviteForm) => createInvitation(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
queryClient.invalidateQueries({ queryKey: ['invitations'] });
setSuccess(t('messages.inviteSuccess'));
setShowInviteModal(false);
setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' });
setInvitationsPage(1);
setTimeout(() => setSuccess(''), 5000);
},
onError: (err: any) => {
setError(err.response?.data?.message || t('messages.inviteError'));
setTimeout(() => setError(''), 5000);
},
});
const changeRoleMutation = useMutation({
mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) =>
updateUser(id, { role }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess(t('messages.roleSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || t('messages.roleError'));
setTimeout(() => setError(''), 5000);
},
});
const toggleActiveMutation = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
updateUser(id, { isActive: !isActive }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess(t('messages.statusSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || t('messages.statusError'));
setTimeout(() => setError(''), 5000);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess(t('messages.deleteSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || t('messages.deleteError'));
setTimeout(() => setError(''), 5000);
},
});
const cancelInvitationMutation = useMutation({
mutationFn: (id: string) => cancelInvitation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invitations'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess(t('messages.cancelInviteSuccess'));
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || t('messages.cancelInviteError'));
setTimeout(() => setError(''), 5000);
},
});
useEffect(() => {
if (currentUser && currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER') {
router.push('/dashboard');
}
}, [currentUser, router]);
if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
const handleInvite = (e: React.FormEvent) => {
e.preventDefault();
setError('');
inviteMutation.mutate(inviteForm);
};
const handleRoleChange = (userId: string, newRole: string) => {
changeRoleMutation.mutate({
id: userId,
role: newRole as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER',
});
};
const handleToggleActive = (userId: string, isActive: boolean) => {
const action = isActive ? t('confirms.toggleDeactivate') : t('confirms.toggleActivate');
if (window.confirm(t('confirms.toggleActive', { action }))) {
toggleActiveMutation.mutate({ id: userId, isActive });
}
};
const handleDelete = (userId: string) => {
if (window.confirm(t('confirms.delete'))) {
deleteMutation.mutate(userId);
}
};
const handleCancelInvitation = (invId: string, name: string) => {
if (window.confirm(t('confirms.cancelInvite', { name }))) {
cancelInvitationMutation.mutate(invId);
}
};
const getRoleBadgeColor = (role: string) => {
const colors: Record<string, string> = {
ADMIN: 'bg-red-100 text-red-800',
MANAGER: 'bg-blue-100 text-blue-800',
USER: 'bg-green-100 text-green-800',
VIEWER: 'bg-gray-100 text-gray-800',
};
return colors[role] || 'bg-gray-100 text-gray-800';
};
const allUsers = users?.users || [];
const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE);
const allPending = (pendingInvitations || []).filter(inv => !inv.isUsed);
const pagedInvitations = allPending.slice(
(invitationsPage - 1) * PAGE_SIZE,
invitationsPage * PAGE_SIZE
);
return (
<div className="space-y-6">
{licenseStatus && !licenseStatus.canInvite && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">{t('license.limitTitle')}</h3>
<p className="mt-1 text-sm text-amber-700">
{t('license.limitMessage', {
used: licenseStatus.usedLicenses,
max: licenseStatus.maxLicenses,
})}
</p>
<div className="mt-3">
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
>
{t('license.upgradeLink')}
</Link>
</div>
</div>
</div>
</div>
)}
{licenseStatus &&
licenseStatus.canInvite &&
licenseStatus.availableLicenses <= 2 &&
licenseStatus.maxLicenses !== -1 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-blue-800">
{t('license.remaining', {
count: licenseStatus.availableLicenses,
used: licenseStatus.usedLicenses,
max: licenseStatus.maxLicenses,
})}
</span>
</div>
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
{t('license.manageLink')}
</Link>
</div>
</div>
)}
<PageHeader
title={t('header.title')}
description={t('header.subtitle')}
actions={
<>
<ExportButton
data={allUsers}
filename={t('exportFilename')}
columns={[
{ key: 'firstName', label: t('export.firstName') },
{ key: 'lastName', label: t('export.lastName') },
{ key: 'email', label: t('export.email') },
{
key: 'role',
label: t('export.role'),
format: v => t(`modal.rolesExport.${v}` as any) || v,
},
{
key: 'isActive',
label: t('export.status'),
format: v => (v ? t('users.active') : t('users.inactive')),
},
{
key: 'createdAt',
label: t('export.createdAt'),
format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''),
},
]}
/>
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-1.5">+</span>
<span className="hidden sm:inline">{t('actions.invite')}</span>
<span className="sm:hidden">{t('actions.inviteShort')}</span>
</button>
) : (
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
>
<span className="mr-1.5">+</span>
<span className="hidden sm:inline">{t('actions.upgrade')}</span>
<span className="sm:hidden">{t('actions.upgradeShort')}</span>
</Link>
)}
</>
}
/>
{success && (
<div className="rounded-md bg-green-50 p-4">
<div className="text-sm text-green-800">{success}</div>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-800">{error}</div>
</div>
)}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">{t('users.title')}</h2>
{allUsers.length > 0 && (
<p className="text-sm text-gray-500 mt-1">
{t('users.membersCount', { count: allUsers.length })}
</p>
)}
</div>
{isLoading ? (
<div className="px-6 py-12 text-center text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
{t('loading')}
</div>
) : pagedUsers.length > 0 ? (
<>
<div className="overflow-x-auto overflow-y-visible">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.createdAt')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pagedUsers.map(user => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
{user.firstName[0]}
{user.lastName[0]}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={user.role}
onChange={e => handleRoleChange(user.id, e.target.value)}
className={`text-xs font-semibold rounded-full px-3 py-1 ${getRoleBadgeColor(user.role)}`}
disabled={
changeRoleMutation.isPending ||
(user.role === 'ADMIN' && currentUser?.role !== 'ADMIN') ||
user.id === currentUser?.id
}
>
{currentUser?.role === 'ADMIN' && (
<option value="ADMIN">{t('modal.roles.ADMIN')}</option>
)}
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
<option value="USER">{t('modal.roles.USER')}</option>
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
>
{user.isActive ? t('users.active') : t('users.inactive')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString(dateLocale)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={e => {
if (openMenuId === user.id) {
setOpenMenuId(null);
setMenuPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
setOpenMenuId(user.id);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg
className="w-5 h-5 text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={usersPage}
total={allUsers.length}
onPage={p => {
setUsersPage(p);
setOpenMenuId(null);
}}
/>
</>
) : (
<div className="px-6 py-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('users.empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p>
<div className="mt-6">
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2">+</span>
{t('actions.invite')}
</button>
) : (
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
>
<span className="mr-2">+</span>
{t('actions.upgrade')}
</Link>
)}
</div>
</div>
)}
</div>
{allPending.length > 0 && (
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">{t('invitations.title')}</h2>
<p className="text-sm text-gray-500 mt-1">
{t('invitations.subtitle', { count: allPending.length })}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.expires')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.status')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('invitations.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pagedInvitations.map(inv => {
const isExpired = new Date(inv.expiresAt) < new Date();
return (
<tr key={inv.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center text-gray-500 font-semibold">
{inv.firstName[0]}
{inv.lastName[0]}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{inv.firstName} {inv.lastName}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{inv.email}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getRoleBadgeColor(inv.role)}`}
>
{inv.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(inv.expiresAt).toLocaleDateString(dateLocale)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${isExpired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}
>
{isExpired ? t('invitations.expired') : t('invitations.pending')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() =>
handleCancelInvitation(inv.id, `${inv.firstName} ${inv.lastName}`)
}
disabled={cancelInvitationMutation.isPending}
className="inline-flex items-center px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50"
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{t('invitations.cancel')}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<Pagination
page={invitationsPage}
total={allPending.length}
onPage={setInvitationsPage}
/>
</div>
)}
{openMenuId && menuPosition && (
<>
<div
className="fixed inset-0 z-[998]"
onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
}}
/>
<div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
>
<div className="py-2">
<button
onClick={() => {
const user = users?.users.find(u => u.id === openMenuId);
if (user) handleToggleActive(user.id, user.isActive);
setOpenMenuId(null);
setMenuPosition(null);
}}
disabled={toggleActiveMutation.isPending}
className="w-full px-4 py-3 text-left hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
>
{users?.users.find(u => u.id === openMenuId)?.isActive ? (
<>
<svg
className="w-5 h-5 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
<span className="text-sm font-medium text-gray-700">
{t('users.actions.deactivate')}
</span>
</>
) : (
<>
<svg
className="w-5 h-5 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-sm font-medium text-gray-700">
{t('users.actions.activate')}
</span>
</>
)}
</button>
<button
onClick={() => {
if (openMenuId) handleDelete(openMenuId);
setOpenMenuId(null);
setMenuPosition(null);
}}
disabled={deleteMutation.isPending}
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
>
<svg
className="w-5 h-5 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span className="text-sm font-medium text-red-600">
{t('users.actions.delete')}
</span>
</button>
</div>
</div>
</>
)}
{showInviteModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
onClick={() => setShowInviteModal(false)}
/>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">{t('modal.title')}</h3>
<button
onClick={() => setShowInviteModal(false)}
className="text-gray-400 hover:text-gray-500"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
{t('modal.firstName')} *
</label>
<input
type="text"
required
value={inviteForm.firstName}
onChange={e => setInviteForm({ ...inviteForm, firstName: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t('modal.lastName')} *
</label>
<input
type="text"
required
value={inviteForm.lastName}
onChange={e => setInviteForm({ ...inviteForm, lastName: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t('modal.email')} *
</label>
<input
type="email"
required
value={inviteForm.email}
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
{t('modal.role')} *
</label>
<select
value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
>
<option value="USER">{t('modal.roles.USER')}</option>
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
<option value="VIEWER">{t('modal.roles.VIEWER')}</option>
</select>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button
type="submit"
disabled={inviteMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
>
{inviteMutation.isPending ? t('modal.submitting') : t('modal.submit')}
</button>
<button
type="button"
onClick={() => setShowInviteModal(false)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:col-start-1 sm:text-sm"
>
{t('modal.cancel')}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
)}
</div>
);
}