feat: improve UI/UX consistency across all dashboard pages
Apply uniform design system across all routes and components: - Consistent header pattern with gradient-text titles, dimmed subtitles - Loader type="dots" replacing text-based loading states - Icon + text empty/error states with Paper+glass containers - Full STATUS_COLOR/STATUS_LABEL maps for all BugStatus values - dayjs timestamps, Tooltip on action icons, size="sm" on badges/pagination - Modals with overlayProps blur and gradient save buttons - Replace left-border Papers with clean Stack headers - Translate all remaining Indonesian UI strings to English - New monitoring-themed SVG logo and redesigned splash screen
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
Paper,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
TbTrash,
|
||||
TbUserCheck,
|
||||
TbUserPlus,
|
||||
TbUsers,
|
||||
} from 'react-icons/tb'
|
||||
import useSWR from 'swr'
|
||||
import { API_URLS } from '../config/api'
|
||||
@@ -52,45 +54,50 @@ export const Route = createFileRoute('/users')({
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
if (role === 'DEVELOPER') return 'violet'
|
||||
if (role === 'ADMIN') return 'brand-blue'
|
||||
return 'gray'
|
||||
const ROLE_COLOR: Record<string, string> = {
|
||||
DEVELOPER: 'violet',
|
||||
ADMIN: 'brand-blue',
|
||||
USER: 'gray',
|
||||
}
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
DEVELOPER: 'Developer',
|
||||
ADMIN: 'Admin',
|
||||
USER: 'User',
|
||||
}
|
||||
|
||||
const roles = [
|
||||
{
|
||||
name: 'DEVELOPER',
|
||||
color: 'violet',
|
||||
description: 'Super admin dengan akses penuh ke seluruh sistem termasuk Dev Console.',
|
||||
description: 'Super admin with full system access, including the Dev Console.',
|
||||
permissions: [
|
||||
'Akses Dev Console (/dev)',
|
||||
'Manajemen user & role',
|
||||
'Kelola bug report & feedback',
|
||||
'Lihat semua app & log aktivitas',
|
||||
'Kelola versi & status aplikasi',
|
||||
'Hapus log sistem',
|
||||
'Access Dev Console (/dev)',
|
||||
'User & role management',
|
||||
'Manage bug reports & feedback',
|
||||
'View all apps & activity logs',
|
||||
'Manage app versions & status',
|
||||
'Delete system logs',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ADMIN',
|
||||
color: 'blue',
|
||||
description: 'Operator yang dapat mengelola aplikasi, bug, dan melihat log aktivitas.',
|
||||
description: 'Operator who can manage applications, bugs, and view activity logs.',
|
||||
permissions: [
|
||||
'Lihat & kelola semua aplikasi',
|
||||
'Kelola bug report',
|
||||
'Lihat log aktivitas',
|
||||
'Lihat data user, desa, orders',
|
||||
'Update status village & produk',
|
||||
'View & manage all applications',
|
||||
'Manage bug reports',
|
||||
'View activity logs',
|
||||
'View user, village, and order data',
|
||||
'Update village & product status',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'USER',
|
||||
color: 'gray',
|
||||
description: 'Akun baru yang belum disetujui. Menunggu approval dari Admin atau Developer.',
|
||||
description: 'New account pending approval. Awaiting review by an Admin or Developer.',
|
||||
permissions: [
|
||||
'Akses halaman profil',
|
||||
'Lihat status persetujuan akun',
|
||||
'Access profile page',
|
||||
'View account approval status',
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -110,7 +117,7 @@ function UsersPage() {
|
||||
const { data: stats, mutate: mutateStats } = useSWR(API_URLS.getOperatorStats(), fetcher)
|
||||
const { data: response, isLoading, mutate: mutateOperators } = useSWR(
|
||||
API_URLS.getOperators(page, debouncedSearch),
|
||||
fetcher
|
||||
fetcher,
|
||||
)
|
||||
|
||||
const operators = response?.data || []
|
||||
@@ -118,19 +125,13 @@ function UsersPage() {
|
||||
// ── Create User Modal ──
|
||||
const [createOpened, { open: openCreate, close: closeCreate }] = useDisclosure(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'ADMIN',
|
||||
})
|
||||
const [createForm, setCreateForm] = useState({ name: '', email: '', password: '', role: 'ADMIN' })
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!createForm.name || !createForm.email || !createForm.password) {
|
||||
notifications.show({ title: 'Validation Error', message: 'Please fill in all required fields.', color: 'red' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const res = await fetch(API_URLS.createOperator(), {
|
||||
@@ -138,7 +139,6 @@ function UsersPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(createForm),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
notifications.show({ title: 'Success', message: 'User has been created.', color: 'teal', icon: <TbCircleCheck size={18} /> })
|
||||
mutateOperators()
|
||||
@@ -160,11 +160,7 @@ function UsersPage() {
|
||||
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null)
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
role: '',
|
||||
})
|
||||
const [editForm, setEditForm] = useState({ name: '', email: '', role: '' })
|
||||
|
||||
const handleOpenEdit = (user: any) => {
|
||||
setEditingUserId(user.id)
|
||||
@@ -174,7 +170,6 @@ function UsersPage() {
|
||||
|
||||
const handleEditUser = async () => {
|
||||
if (!editingUserId || !editForm.name || !editForm.email) return
|
||||
|
||||
setIsEditing(true)
|
||||
try {
|
||||
const res = await fetch(API_URLS.editOperator(editingUserId), {
|
||||
@@ -182,7 +177,6 @@ function UsersPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editForm),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
notifications.show({ title: 'Success', message: 'User has been updated.', color: 'teal', icon: <TbCircleCheck size={18} /> })
|
||||
mutateOperators()
|
||||
@@ -190,14 +184,14 @@ function UsersPage() {
|
||||
} else {
|
||||
throw new Error('Failed to update user')
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
|
||||
} finally {
|
||||
setIsEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete User ──
|
||||
// ── Delete User Modal ──
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [deletingUser, setDeletingUser] = useState<any>(null)
|
||||
@@ -209,13 +203,9 @@ function UsersPage() {
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deletingUser) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch(API_URLS.deleteOperator(deletingUser.id), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const res = await fetch(API_URLS.deleteOperator(deletingUser.id), { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
notifications.show({ title: 'Success', message: 'User has been deleted.', color: 'teal', icon: <TbCircleCheck size={18} /> })
|
||||
mutateOperators()
|
||||
@@ -242,7 +232,7 @@ function UsersPage() {
|
||||
body: JSON.stringify({ active: true }),
|
||||
})
|
||||
if (res.ok) {
|
||||
notifications.show({ title: 'Success', message: `${user.name} telah diaktifkan kembali.`, color: 'teal', icon: <TbCircleCheck size={18} /> })
|
||||
notifications.show({ title: 'Success', message: `${user.name} has been reactivated.`, color: 'teal', icon: <TbCircleCheck size={18} /> })
|
||||
mutateOperators()
|
||||
mutateStats()
|
||||
} else {
|
||||
@@ -258,39 +248,52 @@ function UsersPage() {
|
||||
<DashboardLayout>
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} className="gradient-text">Users</Title>
|
||||
<Text size="sm" c="dimmed">Manage system users, security roles, and application access control.</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={4}>
|
||||
<Title order={2} className="gradient-text">User Management</Title>
|
||||
<Text size="sm" c="dimmed">Manage platform users, security roles, and access control.</Text>
|
||||
</Stack>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
||||
<StatsCard title="Total Staff" value={stats?.totalStaff ?? 0} icon={TbUserCheck} color="brand-blue" />
|
||||
<StatsCard title="Active Now" value={stats?.activeNow ?? 0} icon={TbAccessPoint} color="teal" />
|
||||
<StatsCard title="Security Roles" value={stats?.rolesCount ?? 0} icon={TbShieldCheck} color="purple-primary" />
|
||||
<StatsCard
|
||||
title="Total Staff"
|
||||
value={stats?.totalStaff ?? 0}
|
||||
description="Registered platform users"
|
||||
icon={TbUserCheck}
|
||||
color="brand-blue"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Now"
|
||||
value={stats?.activeNow ?? 0}
|
||||
description="Users with active sessions"
|
||||
icon={TbAccessPoint}
|
||||
color="teal"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Security Roles"
|
||||
value={stats?.rolesCount ?? 0}
|
||||
description="Defined permission levels"
|
||||
icon={TbShieldCheck}
|
||||
color="purple-primary"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="users" leftSection={<TbUserCheck size={16} />}>User Management</Tabs.Tab>
|
||||
<Tabs.Tab value="roles" leftSection={<TbShieldCheck size={16} />}>Role Management</Tabs.Tab>
|
||||
<Tabs.Tab value="roles" leftSection={<TbShieldCheck size={16} />}>Role Reference</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="users" pt="xl">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
placeholder="Search users..."
|
||||
placeholder="Search by name or email..."
|
||||
leftSection={<TbSearch size={16} />}
|
||||
radius="md"
|
||||
w={350}
|
||||
w={320}
|
||||
variant="filled"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value)
|
||||
setPage(1)
|
||||
}}
|
||||
onChange={(e) => { setSearch(e.currentTarget.value); setPage(1) }}
|
||||
/>
|
||||
{isDeveloper && (
|
||||
<Button
|
||||
@@ -298,6 +301,7 @@ function UsersPage() {
|
||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||
leftSection={<TbPlus size={18} />}
|
||||
radius="md"
|
||||
size="sm"
|
||||
onClick={openCreate}
|
||||
>
|
||||
Add New User
|
||||
@@ -311,21 +315,26 @@ function UsersPage() {
|
||||
<Table.Tr>
|
||||
<Table.Th>Name & Contact</Table.Th>
|
||||
<Table.Th>Role</Table.Th>
|
||||
<Table.Th>Joined Date</Table.Th>
|
||||
<Table.Th>Joined</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4} align="center">
|
||||
<Text size="sm" c="dimmed" py="xl">Loading user data...</Text>
|
||||
<Table.Td colSpan={4}>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" type="dots" />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : operators.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4} align="center">
|
||||
<Text size="sm" c="dimmed" py="xl">No users found.</Text>
|
||||
<Table.Td colSpan={4}>
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<TbUsers size={32} style={{ opacity: 0.25 }} />
|
||||
<Text size="sm" c="dimmed">No users found.</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
@@ -334,7 +343,12 @@ function UsersPage() {
|
||||
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
|
||||
<Group gap="sm">
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<Avatar size="sm" radius="xl" color={user.active === false ? 'gray' : getRoleColor(user.role)} src={user.image}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={user.active === false ? 'gray' : ROLE_COLOR[user.role] ?? 'gray'}
|
||||
src={user.image}
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</Avatar>
|
||||
{user.active === false && (
|
||||
@@ -350,7 +364,9 @@ function UsersPage() {
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
<Group gap={6}>
|
||||
<Text fw={600} size="sm" c={user.active === false ? 'dimmed' : undefined}>{user.name}</Text>
|
||||
<Text fw={600} size="sm" c={user.active === false ? 'dimmed' : undefined}>
|
||||
{user.name}
|
||||
</Text>
|
||||
{user.active === false && (
|
||||
<Badge size="xs" color="red" variant="light">Inactive</Badge>
|
||||
)}
|
||||
@@ -360,31 +376,61 @@ function UsersPage() {
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
|
||||
<Badge variant="light" color={user.active === false ? 'gray' : getRoleColor(user.role)}>
|
||||
{user.role}
|
||||
<Badge
|
||||
variant="light"
|
||||
size="sm"
|
||||
color={user.active === false ? 'gray' : ROLE_COLOR[user.role] ?? 'gray'}
|
||||
>
|
||||
{ROLE_LABEL[user.role] ?? user.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
|
||||
<Text size="xs" fw={500} c={user.active === false ? 'dimmed' : undefined}>
|
||||
{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
{new Date(user.createdAt).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{user.active === false ? (
|
||||
<Tooltip label="Aktifkan user" withArrow>
|
||||
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="teal" onClick={() => handleActivateUser(user)}>
|
||||
<Tooltip label="Reactivate user" withArrow>
|
||||
<ActionIcon
|
||||
disabled={!isDeveloper}
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="teal"
|
||||
onClick={() => handleActivateUser(user)}
|
||||
>
|
||||
<TbUserPlus size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
<Tooltip label="Edit user" withArrow>
|
||||
<ActionIcon
|
||||
disabled={!isDeveloper}
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="blue"
|
||||
onClick={() => handleOpenEdit(user)}
|
||||
>
|
||||
<TbPencil size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete user" withArrow>
|
||||
<ActionIcon
|
||||
disabled={!isDeveloper}
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={() => handleOpenDelete(user)}
|
||||
>
|
||||
<TbTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
@@ -398,12 +444,7 @@ function UsersPage() {
|
||||
|
||||
{response?.totalPages > 1 && (
|
||||
<Group justify="center" mt="md">
|
||||
<Pagination
|
||||
total={response.totalPages}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
radius="md"
|
||||
/>
|
||||
<Pagination total={response.totalPages} value={page} onChange={setPage} size="sm" radius="md" />
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -414,20 +455,18 @@ function UsersPage() {
|
||||
{roles.map((role) => (
|
||||
<Card key={role.name} withBorder radius="2xl" padding="xl" className="glass">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
|
||||
<TbShieldCheck size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
|
||||
<TbShieldCheck size={28} />
|
||||
</ThemeIcon>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Title order={4}>{role.name}</Title>
|
||||
<Title order={4}>{ROLE_LABEL[role.name] ?? role.name}</Title>
|
||||
<Text size="sm" c="dimmed">{role.description}</Text>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">Key Permissions</Text>
|
||||
<List
|
||||
spacing="xs"
|
||||
size="sm"
|
||||
@@ -442,10 +481,6 @@ function UsersPage() {
|
||||
<List.Item key={p}>{p}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* <Button fullWidth variant="light" color={role.color} mt="md" radius="md">
|
||||
Edit Permissions
|
||||
</Button> */}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
@@ -460,7 +495,7 @@ function UsersPage() {
|
||||
opened={createOpened}
|
||||
onClose={closeCreate}
|
||||
title={<Text fw={700} size="lg">Add New User</Text>}
|
||||
radius="xl"
|
||||
radius="md"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
@@ -492,7 +527,7 @@ function UsersPage() {
|
||||
{ value: 'DEVELOPER', label: 'Developer' },
|
||||
]}
|
||||
value={createForm.role}
|
||||
onChange={(val) => setCreateForm({ ...createForm, role: val || 'USER' })}
|
||||
onChange={(val) => setCreateForm({ ...createForm, role: val || 'ADMIN' })}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -512,7 +547,7 @@ function UsersPage() {
|
||||
opened={editOpened}
|
||||
onClose={closeEdit}
|
||||
title={<Text fw={700} size="lg">Edit User</Text>}
|
||||
radius="xl"
|
||||
radius="md"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
@@ -558,21 +593,19 @@ function UsersPage() {
|
||||
opened={deleteOpened}
|
||||
onClose={closeDelete}
|
||||
title={<Text fw={700} size="lg">Delete User</Text>}
|
||||
radius="xl"
|
||||
radius="md"
|
||||
size="sm"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
Are you sure you want to delete <Text component="span" fw={700}>{deletingUser?.name}</Text>? This action cannot be undone.
|
||||
Are you sure you want to delete{' '}
|
||||
<Text component="span" fw={700}>{deletingUser?.name}</Text>?
|
||||
This action cannot be undone.
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="subtle" color="gray" onClick={closeDelete}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" loading={isDeleting} onClick={handleDeleteUser}>
|
||||
Delete User
|
||||
</Button>
|
||||
<Button variant="subtle" color="gray" onClick={closeDelete}>Cancel</Button>
|
||||
<Button color="red" loading={isDeleting} onClick={handleDeleteUser}>Delete User</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user