feat: inisialisasi project kit v2

This commit is contained in:
2026-05-21 15:57:29 +07:00
commit d4fd478e1f
271 changed files with 35300 additions and 0 deletions
+418
View File
@@ -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>
);
}