feat: server-side filter, village filter, and sortable columns on users page

This commit is contained in:
2026-05-22 11:17:38 +08:00
parent ed9f59f404
commit 603a0a04b7
2 changed files with 115 additions and 48 deletions

View File

@@ -9,8 +9,15 @@ export const API_URLS = {
`${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`, `${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`,
graphLogVillages: (id: string, time: string) => graphLogVillages: (id: string, time: string) =>
`${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`, `${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`,
getUsers: (page: number, search: string) => getUsers: (page: number, search: string, isActive?: string, idUserRole?: string, idVillage?: string, orderBy?: string, orderDir?: string) => {
`${DESA_PLUS_PROXY}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`, const params = new URLSearchParams({ page: String(page), search })
if (isActive !== undefined) params.set('isActive', isActive)
if (idUserRole) params.set('idUserRole', idUserRole)
if (idVillage) params.set('idVillage', idVillage)
if (orderBy) params.set('orderBy', orderBy)
if (orderDir) params.set('orderDir', orderDir)
return `${DESA_PLUS_PROXY}/api/monitoring/user?${params}`
},
getLogsAllVillages: (page: number, search: string) => getLogsAllVillages: (page: number, search: string) =>
`${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`, `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`,
getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`, getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`,

View File

@@ -28,6 +28,9 @@ import { createFileRoute, useParams } from '@tanstack/react-router'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
TbAlertCircle, TbAlertCircle,
TbArrowDown,
TbArrowsSort,
TbArrowUp,
TbBriefcase, TbBriefcase,
TbCircleCheck, TbCircleCheck,
TbCircleX, TbCircleX,
@@ -224,22 +227,39 @@ function UsersIndexPage() {
const [debouncedSearch] = useDebouncedValue(search, 400) const [debouncedSearch] = useDebouncedValue(search, 400)
const [filterStatus, setFilterStatus] = useState<string | null>(null) const [filterStatus, setFilterStatus] = useState<string | null>(null)
const [filterRole, setFilterRole] = useState<string | null>(null) const [filterRole, setFilterRole] = useState<string | null>(null)
const [filterVillageSearch, setFilterVillageSearch] = useState('')
const [filterVillageId, setFilterVillageId] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const handleSort = (col: string) => {
if (sortBy === col) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortBy(col)
setSortDir('asc')
}
setPage(1)
}
const isDesaPlus = appId === 'desa-plus' const isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null
const filterStatusParam = filterStatus === 'active' ? 'true' : filterStatus === 'inactive' ? 'false' : undefined
const apiUrl = isDesaPlus
? API_URLS.getUsers(
page,
searchQuery,
filterStatusParam,
filterRole ?? undefined,
filterVillageId ?? undefined,
sortBy ?? undefined,
sortBy ? sortDir : undefined,
)
: null
const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher) const { data: response, error, isLoading, mutate } = useSWR(apiUrl, fetcher)
const users: APIUser[] = response?.data?.user || [] const users: APIUser[] = response?.data?.user || []
const filteredUsers = users.filter((user) => {
if (filterStatus === 'active' && !user.isActive) return false
if (filterStatus === 'inactive' && user.isActive) return false
if (filterRole && user.role !== filterRole) return false
return true
})
const roleFilterOptions = Array.from(new Set(users.map((u) => u.role))).map((r) => ({ value: r, label: r }))
useEffect(() => { useEffect(() => {
if (debouncedSearch.length >= 3 || debouncedSearch.length === 0) { if (debouncedSearch.length >= 3 || debouncedSearch.length === 0) {
setSearchQuery(debouncedSearch) setSearchQuery(debouncedSearch)
@@ -247,6 +267,10 @@ function UsersIndexPage() {
} }
}, [debouncedSearch]) }, [debouncedSearch])
useEffect(() => {
setPage(1)
}, [filterStatus, filterRole, filterVillageId])
const handleClearSearch = () => { const handleClearSearch = () => {
setSearch('') setSearch('')
setSearchQuery('') setSearchQuery('')
@@ -291,7 +315,11 @@ function UsersIndexPage() {
const targetVillageId = opened ? form.idVillage : editForm.idVillage const targetVillageId = opened ? form.idVillage : editForm.idVillage
const targetGroupId = opened ? form.idGroup : editForm.idGroup const targetGroupId = opened ? form.idGroup : editForm.idGroup
const { data: rolesResp } = useSWR(isAnyModalOpened ? API_URLS.listRole() : null, fetcher) const { data: rolesResp } = useSWR(isDesaPlus ? API_URLS.listRole() : null, fetcher)
const { data: filterVillagesResp } = useSWR(
isDesaPlus && filterVillageSearch.length >= 1 ? API_URLS.getVillages(1, filterVillageSearch) : null,
fetcher
)
const { data: villagesResp } = useSWR( const { data: villagesResp } = useSWR(
isAnyModalOpened && villageSearch.length >= 1 ? API_URLS.getVillages(1, villageSearch) : null, isAnyModalOpened && villageSearch.length >= 1 ? API_URLS.getVillages(1, villageSearch) : null,
fetcher fetcher
@@ -306,6 +334,7 @@ function UsersIndexPage() {
) )
const rolesOptions = (rolesResp?.data || []).map((r: any) => ({ value: r.id, label: r.name })) const rolesOptions = (rolesResp?.data || []).map((r: any) => ({ value: r.id, label: r.name }))
const filterVillagesOptions = (filterVillagesResp?.data || []).map((v: any) => ({ value: v.id, label: v.name }))
const villagesOptions = (villagesResp?.data || []).map((v: any) => ({ value: v.id, label: v.name })) const villagesOptions = (villagesResp?.data || []).map((v: any) => ({ value: v.id, label: v.name }))
const groupsOptions = (groupsResp?.data || []).map((g: any) => ({ value: g.id, label: g.name })) const groupsOptions = (groupsResp?.data || []).map((g: any) => ({ value: g.id, label: g.name }))
const positionsOptions = (positionsResp?.data || []).map((p: any) => ({ value: p.id, label: p.name })) const positionsOptions = (positionsResp?.data || []).map((p: any) => ({ value: p.id, label: p.name }))
@@ -573,9 +602,8 @@ function UsersIndexPage() {
{/* Search / Filter */} {/* Search / Filter */}
<Paper withBorder p="md" className="glass"> <Paper withBorder p="md" className="glass">
<Group gap="sm" align="flex-end" wrap="nowrap"> <Stack gap="sm">
<TextInput <TextInput
style={{ flex: 1 }}
placeholder="Search name, NIK, or email... (min. 3 characters)" placeholder="Search name, NIK, or email... (min. 3 characters)"
leftSection={<TbSearch size={16} />} leftSection={<TbSearch size={16} />}
size="sm" size="sm"
@@ -592,31 +620,44 @@ function UsersIndexPage() {
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => setSearch(e.currentTarget.value)}
radius="md" radius="md"
/> />
<Select <Group gap="sm" wrap="nowrap">
size="sm" <Select
placeholder="Status" size="sm"
data={[ placeholder="Status"
{ value: 'active', label: 'Active' }, data={[
{ value: 'inactive', label: 'Inactive' }, { value: 'active', label: 'Active' },
]} { value: 'inactive', label: 'Inactive' },
value={filterStatus} ]}
onChange={setFilterStatus} value={filterStatus}
radius="md" onChange={setFilterStatus}
clearable radius="md"
w={130} clearable
/> style={{ flex: 1 }}
<Select />
size="sm" <Select
placeholder="Role" size="sm"
data={roleFilterOptions} placeholder="Role"
value={filterRole} data={rolesOptions}
onChange={setFilterRole} value={filterRole}
radius="md" onChange={setFilterRole}
clearable radius="md"
w={150} clearable
disabled={roleFilterOptions.length === 0} style={{ flex: 1 }}
/> />
</Group> <Select
size="sm"
placeholder="Search village..."
searchable
onSearchChange={setFilterVillageSearch}
data={filterVillagesOptions}
value={filterVillageId}
onChange={setFilterVillageId}
radius="md"
clearable
style={{ flex: 1 }}
/>
</Group>
</Stack>
</Paper> </Paper>
{isLoading ? ( {isLoading ? (
@@ -630,12 +671,12 @@ function UsersIndexPage() {
<Text size="sm" c="dimmed">Failed to load users from the API.</Text> <Text size="sm" c="dimmed">Failed to load users from the API.</Text>
</Stack> </Stack>
</Paper> </Paper>
) : filteredUsers.length === 0 ? ( ) : users.length === 0 ? (
<Paper withBorder radius="2xl" className="glass" p="md"> <Paper withBorder radius="2xl" className="glass" p="md">
<Stack align="center" gap="xs" py="xl"> <Stack align="center" gap="xs" py="xl">
<TbUsers size={32} style={{ opacity: 0.25 }} /> <TbUsers size={32} style={{ opacity: 0.25 }} />
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{searchQuery || filterStatus || filterRole ? 'No users match your filters.' : 'No users found.'} {searchQuery || filterStatus || filterRole || filterVillageId ? 'No users match your filters.' : 'No users found.'}
</Text> </Text>
</Stack> </Stack>
</Paper> </Paper>
@@ -656,15 +697,34 @@ function UsersIndexPage() {
> >
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th style={{ width: isMobile ? undefined : '28%' }}>User & ID</Table.Th> {[
<Table.Th style={{ width: isMobile ? undefined : '25%' }}>Contact</Table.Th> { label: 'User & ID', col: 'name', width: '28%' },
<Table.Th style={{ width: isMobile ? undefined : '22%' }}>Organization</Table.Th> { label: 'Contact', col: null, width: '25%' },
<Table.Th style={{ width: isMobile ? undefined : '15%' }}>Role</Table.Th> { label: 'Organization', col: null, width: '22%' },
<Table.Th style={{ width: isMobile ? undefined : '10%' }}>Status</Table.Th> { label: 'Role', col: 'idUserRole', width: '15%' },
{ label: 'Status', col: 'isActive', width: '10%' },
].map(({ label, col, width }) => (
<Table.Th
key={label}
style={{ width: isMobile ? undefined : width, cursor: col ? 'pointer' : undefined, userSelect: 'none' }}
onClick={col ? () => handleSort(col) : undefined}
>
<Group gap={4} wrap="nowrap">
<span>{label}</span>
{col && (
sortBy === col
? sortDir === 'asc'
? <TbArrowUp size={13} />
: <TbArrowDown size={13} />
: <TbArrowsSort size={13} style={{ opacity: 0.35 }} />
)}
</Group>
</Table.Th>
))}
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{filteredUsers.map((user) => ( {users.map((user) => (
<Table.Tr <Table.Tr
key={user.id} key={user.id}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}