amalia/28-mei-26 #27

Merged
amaliadwiy merged 16 commits from amalia/28-mei-26 into main 2026-05-28 17:22:56 +08:00
12 changed files with 329 additions and 184 deletions
Showing only changes of commit 0e2c97df47 - Show all commits

View File

@@ -33,6 +33,11 @@ export const API_URLS = {
return `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?${params}`
},
getStaleVillages: (days: 7 | 14 | 30 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/stale-villages?days=${days}`,
getInactiveUsers: (days: 7 | 14 | 30 = 7, idVillage?: string, page = 1) => {
const params = new URLSearchParams({ days: String(days), page: String(page) })
if (idVillage) params.set('idVillage', idVillage)
return `${DESA_PLUS_PROXY}/api/monitoring/inactive-users?${params}`
},
getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`,
getDailyActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity?range=${range}`,
getComparisonActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity?range=${range}`,

View File

@@ -240,6 +240,7 @@ function UsersIndexPage() {
const [filterRole, setFilterRole] = useState<string | null>(null)
const [filterVillageSearch, setFilterVillageSearch] = useState('')
const [filterVillageId, setFilterVillageId] = useState<string | null>(null)
const [filterInactiveDays, setFilterInactiveDays] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
@@ -254,22 +255,21 @@ function UsersIndexPage() {
}
const isDesaPlus = appId === 'desa-plus'
const isInactiveMode = !!filterInactiveDays
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,
)
? isInactiveMode
? API_URLS.getInactiveUsers(Number(filterInactiveDays) as 7 | 14 | 30, filterVillageId ?? undefined, page)
: 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 users: APIUser[] = response?.data?.user || []
const users: APIUser[] = isInactiveMode
? (response?.data?.users || [])
: (response?.data?.user || [])
const totalPages = response?.data?.totalPage ?? 0
const totalUsers = response?.data?.total ?? 0
useEffect(() => {
if (debouncedSearch.length >= 3 || debouncedSearch.length === 0) {
@@ -280,7 +280,7 @@ function UsersIndexPage() {
useEffect(() => {
setPage(1)
}, [filterStatus, filterRole, filterVillageId])
}, [filterStatus, filterRole, filterVillageId, filterInactiveDays])
const handleClearSearch = () => {
setSearch('')
@@ -524,7 +524,6 @@ function UsersIndexPage() {
onChange={(updates) => setForm((f) => ({ ...f, ...updates }))}
{...sharedFormProps}
/>
<Button
fullWidth
mt="lg"
@@ -597,7 +596,11 @@ function UsersIndexPage() {
<Stack gap={4}>
<Title order={3}>User Management</Title>
<Text size="sm" c="dimmed">
{isLoading ? 'Loading users...' : `${response?.data?.total ?? 0} users registered in the Desa+ system`}
{isLoading
? 'Loading users...'
: isInactiveMode
? `${totalUsers} users with no activity in the last ${filterInactiveDays} days`
: `${totalUsers} users registered in the Desa+ system`}
</Text>
</Stack>
<Button
@@ -611,26 +614,33 @@ function UsersIndexPage() {
</Button>
</Group>
{/* Search / Filter */}
{/* Filter */}
<Paper withBorder p="md" className="glass">
<Stack gap="sm">
<TextInput
placeholder="Search name, NIK, or email... (min. 3 characters)"
leftSection={<TbSearch size={16} />}
size="sm"
rightSection={
search ? (
<Tooltip label="Clear search" withArrow>
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="sm">
<TbX size={16} />
</ActionIcon>
</Tooltip>
) : null
}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md"
/>
<Tooltip
label="Search is disabled when Inactive filter is active"
disabled={!isInactiveMode}
withArrow
>
<TextInput
placeholder="Search name, NIK, or email... (min. 3 characters)"
leftSection={<TbSearch size={16} />}
size="sm"
disabled={isInactiveMode}
rightSection={
search ? (
<Tooltip label="Clear search" withArrow>
<ActionIcon variant="transparent" color="gray" onClick={handleClearSearch} size="sm">
<TbX size={16} />
</ActionIcon>
</Tooltip>
) : null
}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md"
/>
</Tooltip>
<Group gap="sm" wrap="nowrap">
<Select
size="sm"
@@ -643,6 +653,7 @@ function UsersIndexPage() {
onChange={setFilterStatus}
radius="md"
clearable
disabled={isInactiveMode}
style={{ flex: 1 }}
/>
<Select
@@ -653,6 +664,7 @@ function UsersIndexPage() {
onChange={setFilterRole}
radius="md"
clearable
disabled={isInactiveMode}
style={{ flex: 1 }}
/>
<Select
@@ -667,6 +679,27 @@ function UsersIndexPage() {
clearable
style={{ flex: 1 }}
/>
<Select
size="sm"
placeholder="Inactive since..."
data={[
{ value: '7', label: 'No activity 7D' },
{ value: '14', label: 'No activity 14D' },
{ value: '30', label: 'No activity 30D' },
]}
value={filterInactiveDays}
onChange={(v) => {
setFilterInactiveDays(v)
setFilterStatus(null)
setFilterRole(null)
setSearch('')
setSearchQuery('')
setPage(1)
}}
radius="md"
clearable
style={{ flex: 1 }}
/>
</Group>
</Stack>
</Paper>
@@ -687,7 +720,11 @@ function UsersIndexPage() {
<Stack align="center" gap="xs" py="xl">
<TbUsers size={32} style={{ opacity: 0.25 }} />
<Text size="sm" c="dimmed">
{searchQuery || filterStatus || filterRole || filterVillageId ? 'No users match your filters.' : 'No users found.'}
{isInactiveMode
? `No users with ${filterInactiveDays}+ days of inactivity.`
: searchQuery || filterStatus || filterRole || filterVillageId
? 'No users match your filters.'
: 'No users found.'}
</Text>
</Stack>
</Paper>
@@ -718,12 +755,12 @@ function UsersIndexPage() {
].map(({ label, col, width }) => (
<Table.Th
key={label}
style={{ width: isMobile ? undefined : width, cursor: col ? 'pointer' : undefined, userSelect: 'none' }}
onClick={col ? () => handleSort(col) : undefined}
style={{ width: isMobile ? undefined : width, cursor: col && !isInactiveMode ? 'pointer' : undefined, userSelect: 'none' }}
onClick={col && !isInactiveMode ? () => handleSort(col) : undefined}
>
<Group gap={4} wrap="nowrap">
<span>{label}</span>
{col && (
{col && !isInactiveMode && (
sortBy === col
? sortDir === 'asc'
? <TbArrowUp size={13} />
@@ -744,13 +781,7 @@ function UsersIndexPage() {
>
<Table.Td>
<Group gap="md" wrap="nowrap">
<Avatar
size="lg"
radius="md"
variant="light"
color={getRoleColor(user.role)}
style={{ flexShrink: 0 }}
>
<Avatar size="lg" radius="md" variant="light" color={getRoleColor(user.role)} style={{ flexShrink: 0 }}>
{user.name.charAt(0)}
</Avatar>
<Stack gap={2} style={{ overflow: 'hidden' }}>
@@ -847,12 +878,12 @@ function UsersIndexPage() {
</Paper>
)}
{!isLoading && !error && response?.data?.totalPage > 1 && (
{!isLoading && !error && totalPages > 1 && (
<Group justify="center">
<Pagination
value={page}
onChange={setPage}
total={response.data.totalPage}
total={totalPages}
size="sm"
radius="md"
withEdges={false}