feat: inisialisasi project kit v2
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { PageProps, User } from '@/types';
|
||||
|
||||
interface UserShowProps extends PageProps {
|
||||
viewUser: User & {
|
||||
roles: any[];
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-0 py-3.5 border-b border-gray-50 last:border-0">
|
||||
<dt className="text-xs font-semibold text-gray-500 sm:w-44 shrink-0 mt-0.5">{label}</dt>
|
||||
<dd className="text-sm font-semibold text-[#3D4E4B] tracking-tight">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserShow({ viewUser }: UserShowProps) {
|
||||
const initials = `${viewUser.first_name?.charAt(0) || ''}${viewUser.last_name?.charAt(0) || ''}`.toUpperCase();
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`${viewUser.first_name} ${viewUser.last_name} — Users`} />
|
||||
|
||||
<div className="flex items-center gap-2 text-sm mb-6 anim-fade">
|
||||
<Link href="/users" className="text-gray-400 hover:text-[#3D4E4B] transition-colors font-semibold">Users</Link>
|
||||
<svg className="w-3.5 h-3.5 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-[#3D4E4B] font-bold">{viewUser.first_name} {viewUser.last_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: profile card */}
|
||||
<div className="lg:col-span-1 space-y-4 anim-up">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-[#3D4E4B] flex items-center justify-center text-white text-2xl font-bold overflow-hidden mb-4">
|
||||
{viewUser.avatar_url
|
||||
? <img src={viewUser.avatar_url} className="w-full h-full object-cover" alt="" />
|
||||
: initials}
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-[#3D4E4B] tracking-tight">{viewUser.first_name} {viewUser.last_name}</h2>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">{viewUser.email}</p>
|
||||
<div className="flex flex-wrap justify-center gap-1.5 mt-3">
|
||||
{viewUser.roles?.map((role: any) => (
|
||||
<span key={role.name || role} className="px-2.5 py-1 text-xs font-bold bg-white text-gray-500 border border-gray-100 rounded-md">
|
||||
{role.name || role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold border ${
|
||||
viewUser.status === 'active'
|
||||
? 'text-green-600 bg-white border-green-100'
|
||||
: 'text-gray-400 bg-white border-gray-100'
|
||||
}`}>
|
||||
<span className={`w-1 h-1 rounded-full ${viewUser.status === 'active' ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
{viewUser.status === 'active' ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 space-y-1">
|
||||
<Link href="/users"
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold text-gray-500 hover:bg-gray-50 hover:text-[#3D4E4B] transition-colors w-full">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: details */}
|
||||
<div className="lg:col-span-2 space-y-4 anim-up" style={{ animationDelay: '0.08s' }}>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-50 bg-gray-50/30">
|
||||
<h3 className="text-sm font-bold text-[#3D4E4B] tracking-tight">Personal Information</h3>
|
||||
</div>
|
||||
<div className="px-6">
|
||||
<dl>
|
||||
<InfoRow label="First name">{viewUser.first_name}</InfoRow>
|
||||
<InfoRow label="Last name">{viewUser.last_name}</InfoRow>
|
||||
<InfoRow label="Email">{viewUser.email}</InfoRow>
|
||||
<InfoRow label="User ID"><span className="font-mono text-gray-400 text-xs">#{viewUser.id}</span></InfoRow>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-50 bg-gray-50/30">
|
||||
<h3 className="text-sm font-bold text-[#3D4E4B] tracking-tight">Roles & Permissions</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 tracking-tight mb-2">Assigned Roles</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{viewUser.roles?.length ? viewUser.roles.map((role: any) => (
|
||||
<span key={role.name || role} className="px-3 py-1.5 text-xs font-bold bg-[#3D4E4B] text-white rounded-lg">
|
||||
{role.name || role}
|
||||
</span>
|
||||
)) : <span className="text-sm text-gray-400 font-semibold">No roles assigned</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 tracking-tight mb-2">Granted Permissions</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
||||
{viewUser.permissions?.length ? viewUser.permissions.map((p: any) => (
|
||||
<div key={p.name || p} className="flex items-center gap-2 text-xs text-gray-600 font-semibold bg-gray-50 border border-gray-100 px-2.5 py-1.5 rounded-lg">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[#21A59F] shrink-0" />
|
||||
{p.name || p}
|
||||
</div>
|
||||
)) : <span className="text-sm text-gray-400 font-semibold col-span-full">Inherited from roles</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-50 bg-gray-50/30">
|
||||
<h3 className="text-sm font-bold text-[#3D4E4B] tracking-tight">Activity Timeline</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-[#E3EBE8] flex items-center justify-center shrink-0">
|
||||
<svg className="w-4 h-4 text-[#3D4E4B]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#3D4E4B] tracking-tight">Account Created</p>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">
|
||||
{new Date(viewUser.created_at).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-[#E3EBE8] flex items-center justify-center shrink-0">
|
||||
<svg className="w-4 h-4 text-[#3D4E4B]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#3D4E4B] tracking-tight">Last Updated</p>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">
|
||||
{new Date(viewUser.updated_at).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user