amalia/16-apr-26 #10

Merged
amaliadwiy merged 3 commits from amalia/16-apr-26 into main 2026-04-16 14:10:27 +08:00
4 changed files with 116 additions and 90 deletions

View File

@@ -1,15 +1,15 @@
import { BarChart, LineChart } from '@mantine/charts'
import { import {
Badge,
Box,
Group,
Paper, Paper,
Stack, Stack,
Text, Text,
Group,
ThemeIcon, ThemeIcon,
Box,
Badge,
useMantineTheme useMantineTheme
} from '@mantine/core' } from '@mantine/core'
import { LineChart, BarChart } from '@mantine/charts' import { TbArrowUpRight, TbChartBar, TbTimeline } from 'react-icons/tb'
import { TbTimeline, TbChartBar, TbArrowUpRight } from 'react-icons/tb'
interface ChartProps { interface ChartProps {
data?: any[] data?: any[]
@@ -32,9 +32,14 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
<Text size="xs" c="dimmed">Trend over the last 7 days</Text> <Text size="xs" c="dimmed">Trend over the last 7 days</Text>
</Box> </Box>
</Group> </Group>
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}> {
{isLoading ? '...' : 'Live'} isLoading && (
</Badge> <Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
...
</Badge>
)
}
</Group> </Group>
<Box h={300} mt="lg"> <Box h={300} mt="lg">
@@ -48,6 +53,9 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
gridAxis="x" gridAxis="x"
withTooltip withTooltip
tooltipAnimationDuration={200} tooltipAnimationDuration={200}
tooltipProps={{
allowEscapeViewBox: { x: true, y: false },
}}
styles={{ styles={{
root: { root: {
'.recharts-line-curve': { '.recharts-line-curve': {
@@ -86,17 +94,38 @@ export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps)
h={300} h={300}
data={data} data={data}
dataKey="village" dataKey="village"
series={[{ name: 'activity', color: 'indigo.6' }]} series={[{ name: 'activity', color: 'blue.6' }]} // Menggunakan warna dari theme
withTooltip withTooltip
tickLine="none"
gridAxis="y"
barProps={{ barProps={{
radius: [8, 8, 4, 4], radius: [8, 8, 0, 0],
fill: 'url(#barGradient)', // Menggunakan gradient yang Anda buat
}} }}
styles={{ tooltipProps={{
bar: { cursor: { fill: '#373A40', opacity: 0.4 },
fill: 'url(#barGradient)', allowEscapeViewBox: { x: false, y: false },
content: ({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div style={{
backgroundColor: '#1A1B1E',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #373A40',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
pointerEvents: 'none', // Sangat penting agar tidak mengganggu hover
whiteSpace: 'nowrap' // Mencegah teks turun ke bawah
}}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#fff', marginBottom: '4px' }}>
{payload[0].payload.village}
</div>
<div style={{ fontSize: '11px', color: '#2563EB' }}>
Activity: <span style={{ fontWeight: 700 }}>{payload[0].value}</span>
</div>
</div>
);
} }
return null;
},
}} }}
> >
<defs> <defs>

View File

@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts' import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
import { ErrorDataTable } from '@/frontend/components/ErrorDataTable' import { ErrorDataTable } from '@/frontend/components/ErrorDataTable'
import { SummaryCard } from '@/frontend/components/SummaryCard' import { SummaryCard } from '@/frontend/components/SummaryCard'
import { useSession } from '@/frontend/hooks/useAuth'
import { import {
Badge, Badge,
Button, Button,
@@ -39,6 +40,8 @@ function AppOverviewPage() {
const navigate = useNavigate() const navigate = useNavigate()
const isDesaPlus = appId === 'desa-plus' const isDesaPlus = appId === 'desa-plus'
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false) const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
// Form State // Form State
const [latestVersion, setLatestVersion] = useState('') const [latestVersion, setLatestVersion] = useState('')
@@ -177,7 +180,7 @@ function AppOverviewPage() {
value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')} value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')}
icon={TbVersions} icon={TbVersions}
color="brand-blue" color="brand-blue"
onClick={openVersionModal} onClick={isDeveloper ? openVersionModal : undefined}
> >
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Stack gap={0}> <Stack gap={0}>
@@ -220,6 +223,7 @@ function AppOverviewPage() {
icon={TbAlertTriangle} icon={TbAlertTriangle}
color="red" color="red"
isError={true} isError={true}
onClick={() => navigate({ to: `/apps/${appId}/errors` })}
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -37,6 +37,7 @@ import {
} from 'react-icons/tb' } from 'react-icons/tb'
import useSWR from 'swr' import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
import { useSession } from '../hooks/useAuth'
const fetcher = (url: string) => fetch(url).then((res) => res.json()) const fetcher = (url: string) => fetch(url).then((res) => res.json())
@@ -109,35 +110,20 @@ function ActivityChart({ villageId }: { villageId: string }) {
h={280} h={280}
data={data} data={data}
dataKey="label" dataKey="label"
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Activity' }]} series={[{ name: 'activity', color: '#2563EB' }]}
curveType="monotone" curveType="monotone"
withTooltip withTooltip={true}
withDots withDots={true}
tickLine="none" withPointLabels={false}
gridAxis="x"
tooltipAnimationDuration={150} tooltipAnimationDuration={150}
fillOpacity={1} tooltipProps={{
areaProps={{ allowEscapeViewBox: { x: true, y: false },
strokeWidth: 2.5,
fill: 'url(#villageAreaGrad)',
stroke: '#2563EB',
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
}} }}
dotProps={{ activeDotProps={{
r: 4, r: 6,
strokeWidth: 2, strokeWidth: 2,
stroke: '#2563EB',
fill: 'white',
}} }}
> />
<defs>
<linearGradient id="villageAreaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563EB" stopOpacity={0.35} />
<stop offset="75%" stopColor="#7C3AED" stopOpacity={0.08} />
<stop offset="100%" stopColor="#7C3AED" stopOpacity={0} />
</linearGradient>
</defs>
</AreaChart>
)} )}
</Paper> </Paper>
) )
@@ -149,6 +135,9 @@ function VillageDetailPage() {
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' }) const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
const navigate = useNavigate() const navigate = useNavigate()
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
const { data: infoRes, isLoading: infoLoading, mutate } = useSWR(API_URLS.infoVillages(villageId), fetcher) const { data: infoRes, isLoading: infoLoading, mutate } = useSWR(API_URLS.infoVillages(villageId), fetcher)
const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher) const { data: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher)
@@ -323,6 +312,7 @@ function VillageDetailPage() {
onClick={openConfirmModal} onClick={openConfirmModal}
radius="md" radius="md"
loading={isUpdating} loading={isUpdating}
disabled={!isDeveloper}
> >
{village.isActive ? 'Deactivate' : 'Active'} {village.isActive ? 'Deactivate' : 'Active'}
</Button> </Button>

View File

@@ -1,48 +1,47 @@
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import { import {
ActionIcon, ActionIcon,
Avatar,
Badge, Badge,
Button, Button,
Card, Card,
Container, Container,
Divider,
Group, Group,
List,
Modal,
Pagination,
Paper,
PasswordInput,
Select,
SimpleGrid,
Stack, Stack,
Table, Table,
Tabs,
Text, Text,
TextInput, TextInput,
Title,
Paper,
Tabs,
Avatar,
SimpleGrid,
ThemeIcon, ThemeIcon,
List, Title,
Divider,
Pagination,
Modal,
Select,
PasswordInput,
} from '@mantine/core' } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { useDisclosure } from '@mantine/hooks' import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications' import { notifications } from '@mantine/notifications'
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { import {
TbPlus,
TbSearch,
TbPencil,
TbTrash,
TbUserCheck,
TbShieldCheck,
TbAccessPoint, TbAccessPoint,
TbCircleCheck, TbCircleCheck,
TbCircleX, TbCircleX,
TbClock, TbPencil,
TbApps, TbPlus,
TbSearch,
TbShieldCheck,
TbTrash,
TbUserCheck
} from 'react-icons/tb' } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import { StatsCard } from '@/frontend/components/StatsCard'
import useSWR from 'swr' import useSWR from 'swr'
import { API_URLS } from '../config/api' import { API_URLS } from '../config/api'
import { useSession } from '../hooks/useAuth'
export const Route = createFileRoute('/users')({ export const Route = createFileRoute('/users')({
component: UsersPage, component: UsersPage,
@@ -75,6 +74,8 @@ function UsersPage() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const { data: session } = useSession()
const isDeveloper = session?.user?.role === 'DEVELOPER'
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300) const timer = setTimeout(() => setDebouncedSearch(search), 300)
@@ -244,15 +245,17 @@ function UsersPage() {
setPage(1) setPage(1)
}} }}
/> />
<Button {isDeveloper && (
variant="gradient" <Button
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }} variant="gradient"
leftSection={<TbPlus size={18} />} gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
radius="md" leftSection={<TbPlus size={18} />}
onClick={openCreate} radius="md"
> onClick={openCreate}
Add New User >
</Button> Add New User
</Button>
)}
</Group> </Group>
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}> <Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
@@ -302,12 +305,12 @@ function UsersPage() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs"> <Group gap="xs">
<ActionIcon variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}> <ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
<TbPencil size={14} /> <TbPencil size={14} />
</ActionIcon> </ActionIcon>
<ActionIcon variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}> <ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
<TbTrash size={14} /> <TbTrash size={14} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>