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:
2026-05-05 12:42:41 +08:00
parent ee543a16ad
commit ef852842b4
14 changed files with 1570 additions and 1365 deletions

View File

@@ -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>