419 lines
26 KiB
TypeScript
419 lines
26 KiB
TypeScript
import React, { useState, useCallback } from 'react';
|
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
|
import { Head, usePage, router } from '@inertiajs/react';
|
|
import { PageProps, User } from '@/types';
|
|
import { Can } from '@/Components/Can';
|
|
import { DataTable } from '@/Components/DataTable';
|
|
import { Portal } from '@/Components/Portal';
|
|
import { swal } from '@/lib/swal';
|
|
import _ from 'lodash';
|
|
|
|
interface UsersPageProps extends PageProps {
|
|
users: { data: User[]; meta: any; links: any[]; };
|
|
availableRoles: string[];
|
|
filters: any;
|
|
}
|
|
|
|
/* ─── User Create/Edit Modal (Professional & Compact) ─────────────── */
|
|
/* ─── User Create/Edit Modal (Professional & Compact) ─────────────── */
|
|
function UserModal({ user, availableRoles, onClose }: {
|
|
user: Partial<User> | null;
|
|
availableRoles: string[];
|
|
onClose: () => void;
|
|
}) {
|
|
const existingRoles = user?.id ? ((user as any).roles || []).map((r: any) => r.name || r) : [];
|
|
const [form, setForm] = useState({
|
|
first_name: user?.first_name || '',
|
|
last_name: user?.last_name || '',
|
|
email: user?.email || '',
|
|
password: '',
|
|
status: user?.status || 'active',
|
|
roles: existingRoles as string[],
|
|
});
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [processing, setProcessing] = useState(false);
|
|
|
|
const validate = () => {
|
|
const e: Record<string, string> = {};
|
|
if (!form.first_name.trim()) e.first_name = 'Required';
|
|
if (!form.last_name.trim()) e.last_name = 'Required';
|
|
if (!form.email.trim()) e.email = 'Required';
|
|
else if (!/\S+@\S+\.\S+/.test(form.email)) e.email = 'Invalid';
|
|
if (!user?.id && !form.password) e.password = 'Required';
|
|
setErrors(e);
|
|
return Object.keys(e).length === 0;
|
|
};
|
|
|
|
const toggleRole = (roleName: string) => {
|
|
setForm(prev => ({
|
|
...prev,
|
|
roles: prev.roles.includes(roleName) ? prev.roles.filter(r => r !== roleName) : [...prev.roles, roleName],
|
|
}));
|
|
};
|
|
|
|
const handleSubmit = (e: React.SyntheticEvent) => {
|
|
e.preventDefault();
|
|
if (!validate()) return;
|
|
setProcessing(true);
|
|
const payload = { ...form };
|
|
if (user?.id) {
|
|
router.patch(`/users/${user.id}`, payload, {
|
|
preserveScroll: true,
|
|
onSuccess: () => { onClose(); swal.success('Updated', 'User updated successfully.'); },
|
|
onError: (errs) => { setErrors(errs as any); setProcessing(false); },
|
|
});
|
|
} else {
|
|
router.post('/users', payload, {
|
|
preserveScroll: true,
|
|
onSuccess: () => { onClose(); swal.success('Created', 'New user created successfully.'); },
|
|
onError: (errs) => { setErrors(errs as any); setProcessing(false); },
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Portal>
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[#3D4E4B]/60 backdrop-blur-md anim-fade">
|
|
<div className="bg-white w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden anim-zoom border border-gray-100">
|
|
<div className="p-8">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-[#3D4E4B] tracking-tight">{user?.id ? 'Edit user' : 'New user'}</h2>
|
|
<p className="text-sm text-gray-400 font-medium mt-1">Fill in the user details below.</p>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-50 rounded-xl transition-colors">
|
|
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">First Name</label>
|
|
<input type="text" value={form.first_name} onChange={e => setForm({ ...form, first_name: e.target.value })} className={`input-field${errors.first_name ? ' is-error' : ''}`} placeholder="John" required />
|
|
{errors.first_name && <p className="text-[10px] text-red-500 font-bold ml-1">{errors.first_name}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">Last Name</label>
|
|
<input type="text" value={form.last_name} onChange={e => setForm({ ...form, last_name: e.target.value })} className={`input-field${errors.last_name ? ' is-error' : ''}`} placeholder="Doe" required />
|
|
{errors.last_name && <p className="text-[10px] text-red-500 font-bold ml-1">{errors.last_name}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">Email Address</label>
|
|
<input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} className={`input-field${errors.email ? ' is-error' : ''}`} placeholder="john.doe@example.com" required />
|
|
{errors.email && <p className="text-[10px] text-red-500 font-bold ml-1">{errors.email}</p>}
|
|
</div>
|
|
|
|
{!user?.id && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">Initial Password</label>
|
|
<input type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} className="input-field" placeholder="••••••••" required />
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<label className="text-xs font-semibold text-gray-500 tracking-tight px-1">Assigned Roles</label>
|
|
<div className="flex flex-wrap gap-2 p-1">
|
|
{availableRoles.map(role => (
|
|
<button
|
|
key={role} type="button"
|
|
onClick={() => toggleRole(role)}
|
|
className={`px-4 py-2 rounded-xl text-xs font-bold tracking-tight transition-all border ${form.roles.includes(role) ? 'bg-[#3D4E4B] text-white border-[#3D4E4B] shadow-md shadow-[#3D4E4B]/20' : 'bg-white text-gray-400 border-gray-100 hover:border-gray-200'}`}
|
|
>
|
|
{role}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 flex gap-3">
|
|
<button type="button" onClick={onClose} className="flex-1 h-9 bg-white border border-gray-200 rounded-xl text-sm font-bold text-gray-400 hover:bg-gray-50 transition-all">Cancel</button>
|
|
<button type="submit" disabled={processing} className="flex-1 h-9 bg-[#3D4E4B] text-white rounded-xl text-sm font-bold hover:bg-[#2D3A38] transition-all shadow-lg shadow-[#3D4E4B]/20 flex items-center justify-center gap-2 disabled:opacity-60">
|
|
{processing ? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> : (user?.id ? 'Save changes' : 'Create user')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
|
|
/* ─── Users Page ──────────────────────────────────────────────────── */
|
|
export default function UsersIndex({ users, availableRoles, filters }: UsersPageProps) {
|
|
const { permissions } = usePage<PageProps>().props.auth;
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editUser, setEditUser] = useState<User | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [selectedIds, setSelectedIds] = useState<(number | string)[]>([]);
|
|
const [localFilters, setLocalFilters] = useState({
|
|
search: filters.search || '',
|
|
status: filters.status || '',
|
|
role: filters.role || '',
|
|
trashed: filters.trashed || '',
|
|
per_page: filters.per_page || 15,
|
|
sort_field: filters.sort_field || 'created_at',
|
|
sort_direction: filters.sort_direction || 'desc'
|
|
});
|
|
|
|
// Sync local filters with server props
|
|
React.useEffect(() => {
|
|
setLocalFilters({
|
|
search: filters.search || '',
|
|
status: filters.status || '',
|
|
role: filters.role || '',
|
|
sort_field: filters.sort_field || 'created_at',
|
|
sort_direction: filters.sort_direction || 'desc',
|
|
per_page: filters.per_page || 15,
|
|
trashed: filters.trashed || '',
|
|
});
|
|
}, [filters]);
|
|
|
|
const debouncedFilter = useCallback(_.debounce((params) => {
|
|
setIsLoading(true);
|
|
router.get('/users', params, {
|
|
preserveState: true,
|
|
preserveScroll: true,
|
|
replace: true,
|
|
only: ['users', 'filters'],
|
|
onFinish: () => setIsLoading(false)
|
|
});
|
|
}, 400), []);
|
|
|
|
const updateFilter = (key: string, value: any) => {
|
|
const newFilters = { ...localFilters, [key]: value };
|
|
setLocalFilters(newFilters);
|
|
|
|
const params = { ...newFilters, page: 1 };
|
|
setSelectedIds([]); // Clear selection on filter change
|
|
debouncedFilter(params);
|
|
};
|
|
|
|
const handleBulkAction = (action: string) => {
|
|
const actionMap: any = {
|
|
archive: { url: route('users.bulk-archive'), text: 'Archive' },
|
|
restore: { url: route('users.bulk-restore'), text: 'Restore' },
|
|
delete: { url: route('users.bulk-force-delete'), text: 'Delete' }
|
|
};
|
|
const config = actionMap[action];
|
|
|
|
swal.confirm(`${config.text} Selected?`, `Are you sure you want to ${action} ${selectedIds.length} users?`, config.text)
|
|
.then(result => {
|
|
if (result.isConfirmed) {
|
|
router.post(config.url, { ids: selectedIds }, {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
setSelectedIds([]);
|
|
swal.success('Success', `${selectedIds.length} users ${action}d successfully.`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
header: 'User',
|
|
accessorKey: 'first_name',
|
|
sortable: true,
|
|
cell: (u: User) => (
|
|
<div className="flex items-center gap-4">
|
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold overflow-hidden shrink-0 ${u.deleted_at ? 'bg-gray-400' : 'bg-[#3D4E4B]'}`}>
|
|
{u.avatar_url ? <img src={u.avatar_url} className="w-full h-full object-cover" /> : `${u.first_name?.charAt(0)}${u.last_name?.charAt(0)}`}
|
|
</div>
|
|
<div>
|
|
<div className={`text-sm font-bold tracking-tight ${u.deleted_at ? 'text-gray-400 line-through' : 'text-[#3D4E4B]'}`}>{u.first_name} {u.last_name}</div>
|
|
<div className="text-xs text-gray-400 font-semibold mt-0.5">{u.email}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
header: 'Roles',
|
|
accessorKey: 'roles',
|
|
cell: (u: User) => (
|
|
<div className="flex gap-1.5">
|
|
{(u as any).roles?.length ? (u as any).roles.map((r: any) => (
|
|
<span key={r.name || r} className={`px-2 py-0.5 text-sm font-bold tracking-tight bg-white border border-gray-100 rounded-md ${u.deleted_at ? 'text-gray-300' : 'text-gray-500'}`}>{r.name || r}</span>
|
|
)) : <span className="text-sm font-semibold text-gray-300 italic tracking-tight">Unassigned</span>}
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
header: 'Status',
|
|
accessorKey: 'status',
|
|
sortable: true,
|
|
cell: (u: User) => (
|
|
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold tracking-tight border border-gray-100 ${u.deleted_at ? 'text-gray-300 bg-gray-50/50' : (u.status === 'active' ? 'text-green-600 bg-white border-green-100' : 'text-gray-400 bg-white')}`}>
|
|
<span className={`w-1 h-1 rounded-full ${u.deleted_at ? 'bg-gray-300' : (u.status === 'active' ? 'bg-green-500' : 'bg-gray-300')}`} />
|
|
{u.deleted_at ? 'Archived' : u.status}
|
|
</span>
|
|
)
|
|
},
|
|
{
|
|
header: localFilters.trashed === 'only' ? 'Archived at' : 'Joined',
|
|
accessorKey: localFilters.trashed === 'only' ? 'deleted_at' : 'created_at',
|
|
sortable: true,
|
|
cell: (u: User) => (
|
|
<span className="text-sm font-semibold text-gray-400 tracking-tight">
|
|
{new Date((u as any)[u.deleted_at ? 'deleted_at' : 'created_at']).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })}
|
|
</span>
|
|
)
|
|
}
|
|
];
|
|
|
|
const isTrashed = localFilters.trashed === 'only';
|
|
|
|
return (
|
|
<AuthenticatedLayout>
|
|
<Head title="Users" />
|
|
|
|
{/* Row 1: title + toolbar */}
|
|
<div className="flex items-center justify-between mb-8 anim-down">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">User Management</h1>
|
|
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Maintain and configure the global user registry</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative w-[240px]">
|
|
<svg className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
|
<input type="text" placeholder="Search users…" value={localFilters.search} onChange={e => updateFilter('search', e.target.value)}
|
|
className="w-full h-11 pl-10 pr-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 placeholder-gray-400 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm" />
|
|
</div>
|
|
|
|
{!isTrashed && (
|
|
<>
|
|
<select value={localFilters.status} onChange={e => updateFilter('status', e.target.value)}
|
|
className="h-11 px-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm cursor-pointer min-w-[140px]">
|
|
<option value="">All Status</option>
|
|
<option value="active">Active</option>
|
|
<option value="inactive">Inactive</option>
|
|
</select>
|
|
<select value={localFilters.role} onChange={e => updateFilter('role', e.target.value)}
|
|
className="h-11 px-4 rounded-2xl border border-gray-100 bg-white text-sm font-semibold text-gray-700 focus:outline-none focus:border-[#D4A017] focus:ring-4 focus:ring-[#D4A017]/5 transition-all shadow-sm cursor-pointer min-w-[140px]">
|
|
<option value="">All Roles</option>
|
|
{availableRoles.map(r => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
</>
|
|
)}
|
|
|
|
{!isTrashed && (
|
|
<div className="flex items-center gap-2 border-l border-gray-100 pl-3">
|
|
<a href={route('users.export')} className="flex items-center gap-2 h-11 px-4 rounded-2xl bg-white border border-gray-100 text-[#3D4E4B] text-sm font-bold hover:bg-gray-50 transition-all shadow-sm">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
|
Export
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{!isTrashed && (
|
|
<Can ability="user.create">
|
|
<button onClick={() => { setEditUser(null); setShowModal(true); }}
|
|
className="flex items-center gap-2 h-11 px-6 rounded-2xl bg-[#D4A017] text-white text-sm font-bold hover:bg-[#B88B14] transition-all shadow-lg shadow-[#D4A017]/20 hover:-translate-y-0.5 active:translate-y-0 ml-1">
|
|
New user
|
|
</button>
|
|
</Can>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: tabs + archived notice */}
|
|
<div className="flex items-center justify-between mb-5 anim-down" style={{ animationDelay: '0.05s' }}>
|
|
<div className="flex items-center gap-1 border-b border-gray-100 w-full">
|
|
<button type="button" onClick={() => updateFilter('trashed', '')}
|
|
className={`relative pb-3 px-1 mr-4 text-sm font-bold tracking-tight transition-colors ${!isTrashed ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
|
|
Active users
|
|
{!isTrashed && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-[#D4A017] rounded-t-full" />}
|
|
</button>
|
|
<button type="button" onClick={() => updateFilter('trashed', 'only')}
|
|
className={`relative pb-3 px-1 text-sm font-bold tracking-tight transition-colors ${isTrashed ? 'text-[#3D4E4B]' : 'text-gray-400 hover:text-[#3D4E4B]'}`}>
|
|
Archived
|
|
{isTrashed && <span className="absolute bottom-0 left-0 w-full h-0.5 bg-red-400 rounded-t-full" />}
|
|
</button>
|
|
{isTrashed && (
|
|
<span className="ml-auto mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-600 bg-amber-50 border border-amber-100 px-2.5 py-1 rounded-lg">
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /></svg>
|
|
Archived users cannot log in
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="anim-up relative" style={{ animationDelay: '0.08s' }}>
|
|
<DataTable
|
|
data={users.data} columns={columns as any} meta={users.meta} links={users.links} filters={localFilters}
|
|
onSort={(f, d) => { updateFilter('sort_field', f); updateFilter('sort_direction', d); }}
|
|
isLoading={isLoading}
|
|
selectedIds={selectedIds}
|
|
onSelectionChange={setSelectedIds}
|
|
canEdit={!isTrashed && permissions.includes('user.edit')}
|
|
emptyAction={!isTrashed && permissions.includes('user.create') ? (
|
|
<button onClick={() => { setEditUser(null); setShowModal(true); }}
|
|
className="h-9 px-5 rounded-xl bg-[#D4A017] text-white text-xs font-bold hover:bg-[#B88B14] transition-all shadow-md shadow-[#D4A017]/20">
|
|
Add first user
|
|
</button>
|
|
) : undefined}
|
|
onEdit={(u) => { setEditUser(u); setShowModal(true); }}
|
|
onDelete={!isTrashed ? (u) => {
|
|
swal.confirm('Archive user?', `Move ${u.first_name} ${u.last_name} to the archived list?`, 'Archive')
|
|
.then(result => {
|
|
if (result.isConfirmed) router.delete(`/users/${u.id}`, { preserveScroll: true, onSuccess: () => swal.success('Archived', 'User archived successfully.') });
|
|
});
|
|
} : undefined}
|
|
onRestore={isTrashed ? (u) => {
|
|
swal.confirm('Restore user?', `Restore ${u.first_name} ${u.last_name} to active status?`, 'Restore')
|
|
.then(result => {
|
|
if (result.isConfirmed) router.post(`/users/${u.id}/restore`, {}, { preserveScroll: true, onSuccess: () => swal.success('Restored', 'User restored successfully.') });
|
|
});
|
|
} : undefined}
|
|
onPermanentDelete={isTrashed ? (u) => {
|
|
swal.confirmDelete(`${u.first_name} ${u.last_name}`)
|
|
.then(result => {
|
|
if (result.isConfirmed) router.delete(`/users/${u.id}/force-delete`, { preserveScroll: true, onSuccess: () => swal.success('Deleted', 'User permanently deleted.') });
|
|
});
|
|
} : undefined}
|
|
/>
|
|
</div>
|
|
|
|
{showModal && <UserModal user={editUser} availableRoles={availableRoles} onClose={() => { setShowModal(false); setEditUser(null); }} />}
|
|
|
|
{/* Floating Bulk Actions Bar */}
|
|
<Portal>
|
|
<div className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-40 transition-all duration-500 ${selectedIds.length > 0 ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'}`}>
|
|
<div className="bg-[#3D4E4B] rounded-2xl shadow-2xl px-6 py-4 flex items-center gap-6 border border-white/10 backdrop-blur-xl">
|
|
<div className="flex items-center gap-3 pr-6 border-r border-white/10">
|
|
<span className="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center text-white text-xs font-bold">{selectedIds.length}</span>
|
|
<span className="text-white text-sm font-bold tracking-tight">Items selected</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isTrashed ? (
|
|
<>
|
|
<button onClick={() => handleBulkAction('restore')} className="h-10 px-5 rounded-xl bg-green-500 text-white text-xs font-bold hover:bg-green-600 transition-all flex items-center gap-2">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
|
Bulk Restore
|
|
</button>
|
|
<button onClick={() => handleBulkAction('delete')} className="h-10 px-5 rounded-xl bg-red-500 text-white text-xs font-bold hover:bg-red-600 transition-all flex items-center gap-2">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path 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>
|
|
Bulk Delete
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button onClick={() => handleBulkAction('archive')} className="h-10 px-5 rounded-xl bg-white/10 text-white text-xs font-bold hover:bg-white/20 transition-all flex items-center gap-2">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path 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>
|
|
Bulk Archive
|
|
</button>
|
|
)}
|
|
<button onClick={() => setSelectedIds([])} className="h-10 px-4 text-white/40 text-xs font-bold hover:text-white transition-colors">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
</AuthenticatedLayout>
|
|
);
|
|
}
|