feat: server-side filter, village filter, and sortable columns on users page
This commit is contained in:
@@ -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`,
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user