Merge pull request 'amalia/16-apr-26' (#10) from amalia/16-apr-26 into main
Reviewed-on: #10
This commit is contained in:
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import { BarChart, LineChart } from '@mantine/charts'
|
||||||
Paper,
|
import {
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
ThemeIcon,
|
|
||||||
Box,
|
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
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[]
|
||||||
@@ -18,7 +18,7 @@ interface ChartProps {
|
|||||||
|
|
||||||
export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
|
export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
||||||
<Stack gap="md" h="100%">
|
<Stack gap="md" h="100%">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
TbPlus,
|
import { useEffect, useState } from 'react'
|
||||||
TbSearch,
|
import {
|
||||||
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,
|
||||||
@@ -59,13 +58,13 @@ const getRoleColor = (role: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roles = [
|
const roles = [
|
||||||
{
|
{
|
||||||
name: 'DEVELOPER',
|
name: 'DEVELOPER',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
|
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ADMIN',
|
name: 'ADMIN',
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
permissions: ['View All Apps', 'View Logs', 'Report Errors']
|
permissions: ['View All Apps', 'View Logs', 'Report Errors']
|
||||||
},
|
},
|
||||||
@@ -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>
|
||||||
@@ -340,14 +343,14 @@ function UsersPage() {
|
|||||||
<TbShieldCheck size={28} />
|
<TbShieldCheck size={28} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Title order={4}>{role.name.replace('_', ' ')}</Title>
|
<Title order={4}>{role.name.replace('_', ' ')}</Title>
|
||||||
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
|
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
|
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
|
||||||
<List
|
<List
|
||||||
spacing="xs"
|
spacing="xs"
|
||||||
|
|||||||
Reference in New Issue
Block a user