upd: api monitoring user

This commit is contained in:
2026-04-10 13:41:38 +08:00
parent e1b9241c35
commit ac3c673500
3 changed files with 529 additions and 41 deletions

View File

@@ -18,4 +18,9 @@ export const API_URLS = {
getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`, getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`,
postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`, postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`,
createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`, createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`,
createUser: () => `${API_BASE_URL}/api/monitoring/create-user`,
listRole: () => `${API_BASE_URL}/api/monitoring/list-userrole-villages`,
listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`,
listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`,
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
} }

View File

@@ -112,7 +112,7 @@ function AppLogsPage() {
{isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`} {isLoading ? 'Loading logs...' : `Auditing ${response?.data?.total || 0} events across all villages`}
</Text> </Text>
</Stack> </Stack>
<Button {/* <Button
variant="light" variant="light"
color="gray" color="gray"
leftSection={<TbDownload size={18} />} leftSection={<TbDownload size={18} />}
@@ -120,7 +120,7 @@ function AppLogsPage() {
size="md" size="md"
> >
Export Export
</Button> </Button> */}
</Group> </Group>
<TextInput <TextInput

View File

@@ -1,39 +1,45 @@
import { useState } from 'react'
import useSWR from 'swr'
import { import {
Badge,
Container,
Group,
Stack,
Text,
Title,
Paper,
Button,
ActionIcon, ActionIcon,
TextInput,
Table,
Avatar, Avatar,
Badge,
Box, Box,
Button,
Container,
Divider,
Group,
Modal,
Pagination, Pagination,
ThemeIcon, Paper,
ScrollArea, ScrollArea,
Tooltip, Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
Switch,
} from '@mantine/core' } from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks' import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { createFileRoute, useParams } from '@tanstack/react-router' import { createFileRoute, useParams } from '@tanstack/react-router'
import { useState } from 'react'
import { import {
TbBriefcase,
TbCircleCheck,
TbCircleX,
TbEdit,
TbHome2,
TbId,
TbMail,
TbPhone,
TbPlus, TbPlus,
TbSearch, TbSearch,
TbUsers, TbUsers,
TbX, TbX,
TbMail,
TbPhone,
TbId,
TbBriefcase,
TbHome2,
TbCircleCheck,
TbCircleX,
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
export const Route = createFileRoute('/apps/$appId/users/')({ export const Route = createFileRoute('/apps/$appId/users/')({
@@ -46,12 +52,17 @@ interface APIUser {
nik: string nik: string
phone: string phone: string
email: string email: string
gender: string
isWithoutOTP: boolean isWithoutOTP: boolean
isActive: boolean isActive: boolean
role: string role: string
village: string village: string
group: string group: string
position?: string position?: string
idUserRole: string
idVillage: string
idGroup: string
idPosition: string
} }
const fetcher = (url: string) => fetch(url).then((res) => res.json()) const fetcher = (url: string) => fetch(url).then((res) => res.json())
@@ -65,7 +76,7 @@ function UsersIndexPage() {
const isDesaPlus = appId === 'desa-plus' const isDesaPlus = appId === 'desa-plus'
const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null const apiUrl = isDesaPlus ? API_URLS.getUsers(page, searchQuery) : null
const { data: response, error, isLoading } = 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 handleSearchChange = (val: string) => { const handleSearchChange = (val: string) => {
@@ -76,6 +87,198 @@ function UsersIndexPage() {
} }
} }
// --- ADD USER LOGIC ---
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [villageSearch, setVillageSearch] = useState('')
const [form, setForm] = useState({
name: '',
nik: '',
phone: '',
email: '',
gender: '',
idUserRole: '',
idVillage: '',
idGroup: '',
idPosition: ''
})
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
const [editForm, setEditForm] = useState({
id: '',
name: '',
nik: '',
phone: '',
email: '',
gender: '',
idUserRole: '',
idVillage: '',
idGroup: '',
idPosition: '',
isActive: true,
isWithoutOTP: false
})
// Options Data (Shared for both Add and Edit modals)
const isAnyModalOpened = opened || editOpened
const targetVillageId = opened ? form.idVillage : editForm.idVillage
const targetGroupId = opened ? form.idGroup : editForm.idGroup
const { data: rolesResp } = useSWR(isAnyModalOpened ? API_URLS.listRole() : null, fetcher)
const { data: villagesResp } = useSWR(
isAnyModalOpened && villageSearch.length >= 1 ? API_URLS.getVillages(1, villageSearch) : null,
fetcher
)
const { data: groupsResp } = useSWR(
isAnyModalOpened && targetVillageId ? API_URLS.listGroup(targetVillageId) : null,
fetcher
)
const { data: positionsResp } = useSWR(
isAnyModalOpened && targetGroupId ? API_URLS.listPosition(targetGroupId) : null,
fetcher
)
const rolesOptions = (rolesResp?.data || []).map((r: any) => ({ value: r.id, label: r.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 positionsOptions = (positionsResp?.data || []).map((p: any) => ({ value: p.id, label: p.name }))
const handleCreateUser = async () => {
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
const missing = requiredFields.filter(f => !form[f as keyof typeof form])
if (missing.length > 0) {
notifications.show({
title: 'Validation Error',
message: `Please fill in all required fields: ${missing.join(', ')}`,
color: 'red'
})
return
}
setIsSubmitting(true)
try {
const res = await fetch(API_URLS.createUser(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
})
const result = await res.json()
if (result.success) {
notifications.show({
title: 'Success',
message: 'User has been created successfully.',
color: 'teal',
icon: <TbCircleCheck size={18} />
})
mutate() // Refresh user list
close()
setForm({
name: '',
nik: '',
phone: '',
email: '',
gender: '',
idUserRole: '',
idVillage: '',
idGroup: '',
idPosition: ''
})
} else {
notifications.show({
title: 'Error',
message: result.message || 'Failed to create user.',
color: 'red',
icon: <TbCircleX size={18} />
})
}
} catch (e) {
notifications.show({
title: 'Network Error',
message: 'Unable to connect to the server.',
color: 'red'
})
} finally {
setIsSubmitting(false)
}
}
const handleEditOpen = (user: APIUser) => {
setEditForm({
id: user.id,
name: user.name,
nik: user.nik,
phone: user.phone,
email: user.email,
gender: user.gender,
idUserRole: user.idUserRole,
idVillage: user.idVillage,
idGroup: user.idGroup,
idPosition: user.idPosition,
isActive: user.isActive,
isWithoutOTP: user.isWithoutOTP
})
setVillageSearch(user.village)
openEdit()
}
const handleUpdateUser = async () => {
const requiredFields = ['name', 'nik', 'phone', 'email', 'gender', 'idUserRole', 'idVillage', 'idGroup']
const missing = requiredFields.filter(f => !editForm[f as keyof typeof editForm])
if (missing.length > 0) {
notifications.show({
title: 'Validation Error',
message: `Please fill in all required fields: ${missing.join(', ')}`,
color: 'red'
})
return
}
setIsSubmitting(true)
try {
const res = await fetch(API_URLS.editUser(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(editForm)
})
const result = await res.json()
if (result.success) {
notifications.show({
title: 'Success',
message: 'User has been updated successfully.',
color: 'teal',
icon: <TbCircleCheck size={18} />
})
mutate()
closeEdit()
} else {
notifications.show({
title: 'Error',
message: result.message || 'Failed to update user.',
color: 'red',
icon: <TbCircleX size={18} />
})
}
} catch (e) {
notifications.show({
title: 'Network Error',
message: 'Unable to connect to the server.',
color: 'red'
})
} finally {
setIsSubmitting(false)
}
}
const handleClearSearch = () => { const handleClearSearch = () => {
setSearch('') setSearch('')
setSearchQuery('') setSearchQuery('')
@@ -126,11 +329,279 @@ function UsersIndexPage() {
leftSection={<TbPlus size={18} />} leftSection={<TbPlus size={18} />}
radius="md" radius="md"
size="md" size="md"
onClick={open}
> >
Add User Add User
</Button> </Button>
</Group> </Group>
<Modal
opened={opened}
onClose={close}
title={<Text fw={700} size="lg">Add New User</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Personal Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={form.name}
onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={form.nik}
onChange={(e) => setForm(f => ({ ...f, nik: e.target.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email Address"
placeholder="email@example.com"
required
value={form.email}
onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))}
/>
<TextInput
label="Phone Number"
placeholder="628xxxxxxxxxx"
required
value={form.phone}
onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={[
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
]}
mt="sm"
required
value={form.gender}
onChange={(v) => setForm(f => ({ ...f, gender: v || '' }))}
/>
</Box>
<Divider label="Role & Organization" labelPosition="center" my="sm" />
<Box>
<Select
label="User Role"
placeholder="Select user role"
data={rolesOptions}
required
value={form.idUserRole}
onChange={(v) => setForm(f => ({ ...f, idUserRole: v || '' }))}
/>
<Select
label="Village"
placeholder="Type to search village..."
searchable
onSearchChange={setVillageSearch}
data={villagesOptions}
mt="sm"
required
value={form.idVillage}
onChange={(v) => {
setForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
}}
/>
<SimpleGrid cols={2} spacing="md" mt="sm">
<Select
label="Group"
placeholder={form.idVillage ? "Select group" : "Select village first"}
data={groupsOptions}
disabled={!form.idVillage}
required
value={form.idGroup}
onChange={(v) => {
setForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
}}
/>
<Select
label="Position"
placeholder={form.idGroup ? "Select position" : "Select group first"}
data={positionsOptions}
disabled={!form.idGroup}
value={form.idPosition || ''}
onChange={(v) => setForm(f => ({ ...f, idPosition: v || '' }))}
/>
</SimpleGrid>
</Box>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleCreateUser}
>
Register User
</Button>
</Stack>
</Modal>
<Modal
opened={editOpened}
onClose={closeEdit}
title={<Text fw={700} size="lg">Edit User</Text>}
radius="xl"
size="lg"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Box>
<Text size="xs" fw={700} c="dimmed" mb={8} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Personal Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Full Name"
placeholder="Enter full name"
required
value={editForm.name}
onChange={(e) => setEditForm(f => ({ ...f, name: e.target.value }))}
/>
<TextInput
label="NIK"
placeholder="16-digit identity number"
required
value={editForm.nik}
onChange={(e) => setEditForm(f => ({ ...f, nik: e.target.value }))}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md" mt="sm">
<TextInput
label="Email Address"
placeholder="email@example.com"
required
value={editForm.email}
onChange={(e) => setEditForm(f => ({ ...f, email: e.target.value }))}
/>
<TextInput
label="Phone Number"
placeholder="628xxxxxxxxxx"
required
value={editForm.phone}
onChange={(e) => setEditForm(f => ({ ...f, phone: e.target.value }))}
/>
</SimpleGrid>
<Select
label="Gender"
placeholder="Select gender"
data={[
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
]}
mt="sm"
required
value={editForm.gender}
onChange={(v) => setEditForm(f => ({ ...f, gender: v || '' }))}
/>
</Box>
<Divider label="Role & Organization" labelPosition="center" my="sm" />
<Box>
<Select
label="User Role"
placeholder="Select user role"
data={rolesOptions}
required
value={editForm.idUserRole}
onChange={(v) => setEditForm(f => ({ ...f, idUserRole: v || '' }))}
/>
<Select
label="Village"
placeholder="Type to search village..."
searchable
onSearchChange={setVillageSearch}
data={villagesOptions}
mt="sm"
required
value={editForm.idVillage}
onChange={(v) => {
setEditForm(f => ({ ...f, idVillage: v || '', idGroup: '', idPosition: '' }))
}}
/>
<SimpleGrid cols={2} spacing="md" mt="sm">
<Select
label="Group"
placeholder={editForm.idVillage ? "Select group" : "Select village first"}
data={groupsOptions}
disabled={!editForm.idVillage}
required
value={editForm.idGroup}
onChange={(v) => {
setEditForm(f => ({ ...f, idGroup: v || '', idPosition: '' }))
}}
/>
<Select
label="Position"
placeholder={editForm.idGroup ? "Select position" : "Select group first"}
data={positionsOptions}
disabled={!editForm.idGroup}
value={editForm.idPosition || ''}
onChange={(v) => setEditForm(f => ({ ...f, idPosition: v || '' }))}
/>
</SimpleGrid>
</Box>
<Divider label="System Access" labelPosition="center" my="sm" />
<SimpleGrid cols={2} spacing="xl">
<Switch
label="Account Active"
description="Enable or disable user access"
checked={editForm.isActive}
onChange={(event) => setEditForm(f => ({ ...f, isActive: event.currentTarget.checked }))}
/>
<Switch
label="Without OTP"
description="Bypass login OTP verification"
checked={editForm.isWithoutOTP}
onChange={(event) => setEditForm(f => ({ ...f, isWithoutOTP: event.currentTarget.checked }))}
/>
</SimpleGrid>
<Button
fullWidth
mt="lg"
radius="md"
size="md"
variant="gradient"
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
loading={isSubmitting}
onClick={handleUpdateUser}
>
Update User
</Button>
</Stack>
</Modal>
<TextInput <TextInput
placeholder="Search name, NIK, or email..." placeholder="Search name, NIK, or email..."
leftSection={<TbSearch size={18} />} leftSection={<TbSearch size={18} />}
@@ -167,13 +638,13 @@ function UsersIndexPage() {
) : ( ) : (
<Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}> <Paper withBorder radius="2xl" className="glass" style={{ overflow: 'hidden' }}>
<ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars> <ScrollArea h={isMobile ? undefined : 'auto'} offsetScrollbars>
<Table <Table
verticalSpacing="lg" verticalSpacing="lg"
horizontalSpacing="xl" horizontalSpacing="xl"
highlightOnHover highlightOnHover
withColumnBorders={false} withColumnBorders={false}
style={{ style={{
tableLayout: isMobile ? 'auto' : 'fixed', tableLayout: isMobile ? 'auto' : 'fixed',
width: '100%', width: '100%',
minWidth: isMobile ? 900 : 'unset' minWidth: isMobile ? 900 : 'unset'
}} }}
@@ -182,9 +653,10 @@ function UsersIndexPage() {
<Table.Tr> <Table.Tr>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '28%' }}>User & ID</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Contact Detail</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '25%' }}>Organization</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '22%' }}>Organization</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Role</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '12%' }}>Role</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th> <Table.Th style={{ border: 'none', width: isMobile ? undefined : '10%' }}>Status</Table.Th>
<Table.Th style={{ border: 'none', width: isMobile ? undefined : '8%' }}>Actions</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -192,9 +664,9 @@ function UsersIndexPage() {
<Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}> <Table.Tr key={user.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<Table.Td> <Table.Td>
<Group gap="md" wrap="nowrap"> <Group gap="md" wrap="nowrap">
<Avatar <Avatar
size="lg" size="lg"
radius="md" radius="md"
variant="light" variant="light"
color={getRoleColor(user.role)} color={getRoleColor(user.role)}
style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }} style={{ border: '1px solid rgba(255,255,255,0.1)', flexShrink: 0 }}
@@ -243,10 +715,10 @@ function UsersIndexPage() {
</Stack> </Stack>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge <Badge
variant="filled" variant="filled"
color={getRoleColor(user.role)} color={getRoleColor(user.role)}
radius="md" radius="md"
size="sm" size="sm"
fullWidth={false} fullWidth={false}
styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }} styles={{ root: { textTransform: 'uppercase', fontWeight: 800, whiteSpace: 'nowrap' } }}
@@ -273,6 +745,17 @@ function UsersIndexPage() {
)} )}
</Stack> </Stack>
</Table.Td> </Table.Td>
<Table.Td>
<ActionIcon
variant="light"
color="brand-blue"
onClick={() => handleEditOpen(user)}
size="md"
radius="md"
>
<TbEdit size={18} />
</ActionIcon>
</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>