- Add 7D/30D/3M toggle on Daily Activity and Comparison Between Villages charts - Switch LineChart to AreaChart with fillOpacity 0.4 for bold gradient fill - Fix broken tooltip on all charts with custom dark card content - Apply consistent chart style (tickLine, gridAxis, glow) to village detail page - api.ts: getDailyActivity and getComparisonActivity now accept range param
268 lines
9.6 KiB
TypeScript
268 lines
9.6 KiB
TypeScript
import { VillageActivityLineChart, VillageComparisonBarChart } from '@/frontend/components/DashboardCharts'
|
|
import { ErrorDataTable, type ErrorDataTableHandle } from '@/frontend/components/ErrorDataTable'
|
|
import { SummaryCard } from '@/frontend/components/SummaryCard'
|
|
import { useSession } from '@/frontend/hooks/useAuth'
|
|
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Button,
|
|
Group,
|
|
Modal,
|
|
SimpleGrid,
|
|
Stack,
|
|
Switch,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
Title,
|
|
Tooltip,
|
|
} from '@mantine/core'
|
|
import { useDisclosure } from '@mantine/hooks'
|
|
import { notifications } from '@mantine/notifications'
|
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
TbActivity,
|
|
TbAlertTriangle,
|
|
TbBuildingCommunity,
|
|
TbRefresh,
|
|
TbVersions,
|
|
} from 'react-icons/tb'
|
|
import useSWR from 'swr'
|
|
import { API_URLS } from '../config/api'
|
|
|
|
export const Route = createFileRoute('/apps/$appId/')({
|
|
component: AppOverviewPage,
|
|
})
|
|
|
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
|
|
|
function AppOverviewPage() {
|
|
const { appId } = useParams({ from: '/apps/$appId/' })
|
|
const navigate = useNavigate()
|
|
const isDesaPlus = appId === 'desa-plus'
|
|
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
|
|
const { data: session } = useSession()
|
|
const isDeveloper = session?.user?.role === 'DEVELOPER'
|
|
const errorTableRef = useRef<ErrorDataTableHandle>(null)
|
|
|
|
const [latestVersion, setLatestVersion] = useState('')
|
|
const [minVersion, setMinVersion] = useState('')
|
|
const [messageUpdate, setMessageUpdate] = useState('')
|
|
const [maintenance, setMaintenance] = useState(false)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
|
|
const [dailyRange, setDailyRange] = useState<7 | 30 | 90>(7)
|
|
const [comparisonRange, setComparisonRange] = useState<7 | 30 | 90>(7)
|
|
|
|
const { data: gridRes, isLoading: gridLoading, mutate: mutateGrid } = useSWR(isDesaPlus ? API_URLS.getGridOverview() : null, fetcher)
|
|
const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity(dailyRange) : null, fetcher)
|
|
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity(comparisonRange) : null, fetcher)
|
|
|
|
const { data: appData, isLoading: appLoading } = useSWR(`/api/apps/${appId}`, fetcher)
|
|
|
|
const grid = gridRes?.data
|
|
const dailyData = dailyRes?.data || []
|
|
const comparisonData = comparisonRes?.data || []
|
|
|
|
// Ref so the modal-sync effect always reads current grid without re-running on every background refetch
|
|
const gridRef = useRef(grid)
|
|
gridRef.current = grid
|
|
|
|
useEffect(() => {
|
|
if (versionModalOpened && gridRef.current?.version) {
|
|
const v = gridRef.current.version
|
|
setLatestVersion(v.mobile_latest_version || '')
|
|
setMinVersion(v.mobile_minimum_version || '')
|
|
setMessageUpdate(v.mobile_message_update || '')
|
|
setMaintenance(v.mobile_maintenance === 'true')
|
|
}
|
|
}, [versionModalOpened])
|
|
|
|
const handleRefresh = () => {
|
|
mutateGrid()
|
|
mutateDaily()
|
|
mutateComparison()
|
|
errorTableRef.current?.refresh()
|
|
}
|
|
|
|
const handleSaveVersion = async () => {
|
|
setIsSaving(true)
|
|
try {
|
|
const response = await fetch(API_URLS.postVersionUpdate(), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
mobile_latest_version: latestVersion,
|
|
mobile_minimum_version: minVersion,
|
|
mobile_maintenance: maintenance,
|
|
mobile_message_update: messageUpdate,
|
|
}),
|
|
})
|
|
|
|
if (response.ok) {
|
|
await fetch(API_URLS.createLog(), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type: 'UPDATE', message: `Updated version info: latest=${latestVersion}, min=${minVersion}, maintenance=${maintenance}` }),
|
|
}).catch(console.error)
|
|
|
|
notifications.show({ title: 'Updated', message: 'Application version information has been saved.', color: 'teal' })
|
|
mutateGrid()
|
|
closeVersionModal()
|
|
} else {
|
|
notifications.show({ title: 'Failed', message: 'Could not update version info. Please try again.', color: 'red' })
|
|
}
|
|
} catch {
|
|
notifications.show({ title: 'Network Error', message: 'Could not connect to the server.', color: 'red' })
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const maintenanceOn = grid?.version?.mobile_maintenance === 'true'
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
opened={versionModalOpened}
|
|
onClose={closeVersionModal}
|
|
title={<Text fw={700} size="lg">Update Version Info</Text>}
|
|
radius="xl"
|
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
|
>
|
|
<Stack gap="md">
|
|
<TextInput
|
|
label="Active Version"
|
|
placeholder="e.g. 2.0.5"
|
|
value={latestVersion}
|
|
onChange={(e) => setLatestVersion(e.currentTarget.value)}
|
|
/>
|
|
<TextInput
|
|
label="Minimum Version"
|
|
placeholder="e.g. 2.0.0"
|
|
value={minVersion}
|
|
onChange={(e) => setMinVersion(e.currentTarget.value)}
|
|
/>
|
|
<Textarea
|
|
label="Update Message"
|
|
placeholder="Enter release notes or update message..."
|
|
value={messageUpdate}
|
|
onChange={(e) => setMessageUpdate(e.currentTarget.value)}
|
|
minRows={3}
|
|
autosize
|
|
/>
|
|
<Switch
|
|
label="Maintenance Mode"
|
|
description="Enable to put the app in maintenance mode for users."
|
|
checked={maintenance}
|
|
onChange={(e) => setMaintenance(e.currentTarget.checked)}
|
|
/>
|
|
<Button
|
|
fullWidth
|
|
mt="md"
|
|
variant="gradient"
|
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
|
onClick={handleSaveVersion}
|
|
loading={isSaving}
|
|
>
|
|
Save Changes
|
|
</Button>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Stack gap="xl">
|
|
<Group justify="space-between" align="flex-start">
|
|
<Stack gap={4}>
|
|
<Title order={3}>Overview</Title>
|
|
<Text size="sm" c="dimmed">
|
|
Real-time metrics and activity for {isDesaPlus ? 'Desa+' : appId}.
|
|
</Text>
|
|
</Stack>
|
|
<Tooltip label="Refresh data" withArrow>
|
|
<ActionIcon
|
|
variant="light"
|
|
color="brand-blue"
|
|
size="lg"
|
|
radius="md"
|
|
onClick={handleRefresh}
|
|
loading={gridLoading || dailyLoading || comparisonLoading}
|
|
>
|
|
<TbRefresh size={18} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
|
<SummaryCard
|
|
title="Active Version"
|
|
value={gridLoading ? '...' : (grid?.version?.mobile_latest_version || 'N/A')}
|
|
icon={TbVersions}
|
|
color="brand-blue"
|
|
onClick={isDeveloper ? openVersionModal : undefined}
|
|
>
|
|
<Group justify="space-between" mt="md">
|
|
<Stack gap={0}>
|
|
<Text size="xs" c="dimmed">Min. Version</Text>
|
|
<Text size="sm" fw={600}>{grid?.version?.mobile_minimum_version || '—'}</Text>
|
|
</Stack>
|
|
<Stack gap={0} align="flex-end">
|
|
<Text size="xs" c="dimmed">Maintenance</Text>
|
|
<Badge size="sm" color={maintenanceOn ? 'orange' : 'teal'} variant="light">
|
|
{maintenanceOn ? 'On' : 'Off'}
|
|
</Badge>
|
|
</Stack>
|
|
</Group>
|
|
</SummaryCard>
|
|
|
|
<SummaryCard
|
|
title="Total Activity Today"
|
|
value={gridLoading ? '...' : (grid?.activity?.today?.toLocaleString() ?? '0')}
|
|
icon={TbActivity}
|
|
color="teal"
|
|
trend={grid?.activity?.increase != null && Number(grid.activity.increase) !== 0
|
|
? { value: `${grid.activity.increase}%`, positive: Number(grid.activity.increase) > 0 }
|
|
: undefined}
|
|
/>
|
|
|
|
<SummaryCard
|
|
title="Active Villages"
|
|
value={gridLoading ? '...' : (grid?.village?.active ?? '0')}
|
|
icon={TbBuildingCommunity}
|
|
color="indigo"
|
|
onClick={() => navigate({ to: `/apps/${appId}/villages` })}
|
|
>
|
|
<Group justify="space-between" mt="md">
|
|
<Text size="xs" c="dimmed">Inactive</Text>
|
|
<Badge size="sm" color="red" variant="light">{grid?.village?.inactive ?? 0}</Badge>
|
|
</Group>
|
|
</SummaryCard>
|
|
|
|
<SummaryCard
|
|
title="Open Errors"
|
|
value={appLoading ? '...' : (appData?.errors ?? 0)}
|
|
icon={TbAlertTriangle}
|
|
color="red"
|
|
isError
|
|
onClick={() => navigate({ to: `/apps/${appId}/errors` })}
|
|
/>
|
|
</SimpleGrid>
|
|
|
|
<Group justify="space-between" align="flex-end">
|
|
<Stack gap={2}>
|
|
<Title order={4}>Analytics</Title>
|
|
<Text size="sm" c="dimmed">Activity trends and village comparisons.</Text>
|
|
</Stack>
|
|
</Group>
|
|
|
|
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
|
<VillageActivityLineChart data={dailyData} isLoading={dailyLoading} range={dailyRange} onRangeChange={setDailyRange} />
|
|
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} range={comparisonRange} onRangeChange={setComparisonRange} />
|
|
</SimpleGrid>
|
|
|
|
<ErrorDataTable ref={errorTableRef} appId={appId} />
|
|
</Stack>
|
|
</>
|
|
)
|
|
}
|