Merge pull request 'amalia/04-apr-26' (#3) from amalia/04-apr-26 into main
Reviewed-on: #3
This commit is contained in:
@@ -149,8 +149,7 @@ export function ErrorDataTable() {
|
|||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
styles={{
|
styles={{
|
||||||
header: { padding: '24px', borderBottom: '1px solid rgba(255,255,255,0.1)' },
|
header: { padding: '24px', borderBottom: '1px solid var(--mantine-color-default-border)' },
|
||||||
content: { background: 'rgba(15, 23, 42, 0.95)', backdropFilter: 'blur(12px)' }
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedError && (
|
{selectedError && (
|
||||||
@@ -175,11 +174,9 @@ export function ErrorDataTable() {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="xs" fw={700} c="dimmed" mb="sm">STACK TRACE</Text>
|
<Text size="xs" fw={700} c="dimmed" mb="sm">STACK TRACE</Text>
|
||||||
<Paper p="md" radius="md" bg="dark.8" style={{ border: '1px solid rgba(255,255,255,0.1)' }}>
|
<Code block color="red" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, border: '1px solid var(--mantine-color-default-border)' }}>
|
||||||
<Code block color="red" bg="transparent" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
|
||||||
{selectedError.stackTrace}
|
{selectedError.stackTrace}
|
||||||
</Code>
|
</Code>
|
||||||
</Paper>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="xl">
|
<Group justify="flex-end" mt="xl">
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface SummaryCardProps {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
isError?: boolean
|
isError?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SummaryCard({
|
export function SummaryCard({
|
||||||
@@ -25,7 +27,9 @@ export function SummaryCard({
|
|||||||
color = 'brand-blue',
|
color = 'brand-blue',
|
||||||
trend,
|
trend,
|
||||||
progress,
|
progress,
|
||||||
isError
|
isError,
|
||||||
|
onClick,
|
||||||
|
children
|
||||||
}: SummaryCardProps) {
|
}: SummaryCardProps) {
|
||||||
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||||
|
|
||||||
@@ -35,6 +39,8 @@ export function SummaryCard({
|
|||||||
padding="xl"
|
padding="xl"
|
||||||
radius="2xl"
|
radius="2xl"
|
||||||
className="glass"
|
className="glass"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ cursor: onClick ? 'pointer' : 'default' }}
|
||||||
styles={(theme) => ({
|
styles={(theme) => ({
|
||||||
root: {
|
root: {
|
||||||
backgroundColor: isError && Number(value) > 0
|
backgroundColor: isError && Number(value) > 0
|
||||||
@@ -95,6 +101,8 @@ export function SummaryCard({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ function AppErrorsPage() {
|
|||||||
<Accordion.Item
|
<Accordion.Item
|
||||||
key={error.id}
|
key={error.id}
|
||||||
value={error.id.toString()}
|
value={error.id.toString()}
|
||||||
style={{ border: '1px solid rgba(255,255,255,0.05)', background: 'rgba(255,255,255,0.02)', marginBottom: '12px' }}
|
style={{ border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-default)', marginBottom: '12px' }}
|
||||||
>
|
>
|
||||||
<Accordion.Control>
|
<Accordion.Control>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
@@ -153,11 +153,9 @@ function AppErrorsPage() {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
|
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
|
||||||
<Paper p="sm" radius="md" bg="dark.8" style={{ border: '1px solid rgba(255,255,255,0.1)' }}>
|
<Code block color="red" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px', border: '1px solid var(--mantine-color-default-border)' }}>
|
||||||
<Code block color="red" bg="transparent" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap', fontSize: '11px' }}>
|
|
||||||
{error.stackTrace}
|
{error.stackTrace}
|
||||||
</Code>
|
</Code>
|
||||||
</Paper>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group justify="flex-end" pt="sm">
|
<Group justify="flex-end" pt="sm">
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title
|
Title,
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
TextInput,
|
||||||
|
Switch,
|
||||||
|
Badge,
|
||||||
|
Textarea
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
|
import { createFileRoute, useParams, useNavigate } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
TbActivity,
|
TbActivity,
|
||||||
TbAlertTriangle,
|
TbAlertTriangle,
|
||||||
@@ -24,9 +31,27 @@ export const Route = createFileRoute('/apps/$appId/')({
|
|||||||
|
|
||||||
function AppOverviewPage() {
|
function AppOverviewPage() {
|
||||||
const { appId } = useParams({ from: '/apps/$appId/' })
|
const { appId } = useParams({ from: '/apps/$appId/' })
|
||||||
|
const navigate = useNavigate()
|
||||||
const isDesaPlus = appId === 'desa-plus'
|
const isDesaPlus = appId === 'desa-plus'
|
||||||
|
const [versionModalOpened, { open: openVersionModal, close: closeVersionModal }] = useDisclosure(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Modal opened={versionModalOpened} onClose={closeVersionModal} title="Update Version Information" radius="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput label="Active Version" defaultValue="v1.2.0" />
|
||||||
|
<TextInput label="Minimum Version" defaultValue="v1.0.0" />
|
||||||
|
<Textarea
|
||||||
|
label="Update Message"
|
||||||
|
placeholder="Enter release notes or update message..."
|
||||||
|
minRows={3}
|
||||||
|
autosize
|
||||||
|
/>
|
||||||
|
<Switch label="Maintenance Mode" description="Enable to put the app in maintenance mode for users." />
|
||||||
|
<Button fullWidth onClick={closeVersionModal}>Save Changes</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
{/* 🔝 HEADER SECTION */}
|
{/* 🔝 HEADER SECTION */}
|
||||||
{/* <Paper withBorder p="lg" radius="2xl" className="glass"> */}
|
{/* <Paper withBorder p="lg" radius="2xl" className="glass"> */}
|
||||||
@@ -67,7 +92,19 @@ function AppOverviewPage() {
|
|||||||
value="v1.2.0"
|
value="v1.2.0"
|
||||||
icon={TbVersions}
|
icon={TbVersions}
|
||||||
color="brand-blue"
|
color="brand-blue"
|
||||||
/>
|
onClick={openVersionModal}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mt="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed">Min. Version</Text>
|
||||||
|
<Text size="sm" fw={600}>v1.0.0</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} align="flex-end">
|
||||||
|
<Text size="xs" c="dimmed">Maintenance</Text>
|
||||||
|
<Badge size="sm" color="gray" variant="light">False</Badge>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</SummaryCard>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
title="Total Activity Today"
|
title="Total Activity Today"
|
||||||
value="3,842"
|
value="3,842"
|
||||||
@@ -80,7 +117,13 @@ function AppOverviewPage() {
|
|||||||
value="138"
|
value="138"
|
||||||
icon={TbBuildingCommunity}
|
icon={TbBuildingCommunity}
|
||||||
color="indigo"
|
color="indigo"
|
||||||
/>
|
onClick={() => navigate({ to: `/apps/${appId}/villages` })}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mt="md">
|
||||||
|
<Text size="xs" c="dimmed">Nonactive Villages</Text>
|
||||||
|
<Badge size="sm" color="red" variant="light">24</Badge>
|
||||||
|
</Group>
|
||||||
|
</SummaryCard>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
title="Errors Today"
|
title="Errors Today"
|
||||||
value="12"
|
value="12"
|
||||||
@@ -100,5 +143,6 @@ function AppOverviewPage() {
|
|||||||
{/* 🐞 4. LATEST ERROR REPORTS */}
|
{/* 🐞 4. LATEST ERROR REPORTS */}
|
||||||
<ErrorDataTable />
|
<ErrorDataTable />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function ProductsPage() {
|
|||||||
{mockProducts.map((product) => (
|
{mockProducts.map((product) => (
|
||||||
<Card key={product.id} withBorder radius="2xl" p="md" className="glass h-full">
|
<Card key={product.id} withBorder radius="2xl" p="md" className="glass h-full">
|
||||||
<Card.Section>
|
<Card.Section>
|
||||||
<Box h={160} style={{ background: 'rgba(255,255,255,0.03)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Box h={160} style={{ background: 'var(--mantine-color-default-hover)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<ThemeIcon variant="light" size={60} radius="xl" color="brand-blue">
|
<ThemeIcon variant="light" size={60} radius="xl" color="brand-blue">
|
||||||
<TbArchive size={34} />
|
<TbArchive size={34} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
@@ -90,7 +90,7 @@ function ProductsPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md" pt="sm" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
<Group justify="flex-end" mt="md" pt="sm" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
|
||||||
<Tooltip label="Edit Product">
|
<Tooltip label="Edit Product">
|
||||||
<ActionIcon variant="light" size="sm" color="blue">
|
<ActionIcon variant="light" size="sm" color="blue">
|
||||||
<TbPencil size={14} />
|
<TbPencil size={14} />
|
||||||
|
|||||||
468
src/frontend/routes/apps.$appId.villages.$villageId.tsx
Normal file
468
src/frontend/routes/apps.$appId.villages.$villageId.tsx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
import { AreaChart } from '@mantine/charts'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
TbArrowLeft,
|
||||||
|
TbBuildingCommunity,
|
||||||
|
TbCalendar,
|
||||||
|
TbCalendarEvent,
|
||||||
|
TbChartBar,
|
||||||
|
TbCircleCheck,
|
||||||
|
TbEdit,
|
||||||
|
TbHome2,
|
||||||
|
TbLayoutKanban,
|
||||||
|
TbMapPin,
|
||||||
|
TbPower,
|
||||||
|
TbUser,
|
||||||
|
TbUsers,
|
||||||
|
TbUsersGroup,
|
||||||
|
TbWifi
|
||||||
|
} from 'react-icons/tb'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
|
||||||
|
component: VillageDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Mock Data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockVillages: Record<string, any> = {
|
||||||
|
'sukatani': {
|
||||||
|
id: 'sukatani',
|
||||||
|
name: 'Sukatani',
|
||||||
|
kecamatan: 'Tapos',
|
||||||
|
kabupaten: 'Kota Depok',
|
||||||
|
provinsi: 'Jawa Barat',
|
||||||
|
kodePos: '16455',
|
||||||
|
perbekel: 'H. Suryana, S.Sos',
|
||||||
|
createdAt: '2024-03-12',
|
||||||
|
createdBy: 'Admin Pusat',
|
||||||
|
updatedAt: '2024-04-01',
|
||||||
|
status: 'fully integrated',
|
||||||
|
lastSync: '2 menit lalu',
|
||||||
|
stats: { users: 1240, groups: 34, divisions: 8, activities: 4520 },
|
||||||
|
},
|
||||||
|
'sukamaju': {
|
||||||
|
id: 'sukamaju',
|
||||||
|
name: 'Sukamaju',
|
||||||
|
kecamatan: 'Cilodong',
|
||||||
|
kabupaten: 'Kota Depok',
|
||||||
|
provinsi: 'Jawa Barat',
|
||||||
|
kodePos: '16413',
|
||||||
|
perbekel: 'Drs. H. Mujiono',
|
||||||
|
createdAt: '2024-04-01',
|
||||||
|
createdBy: 'Amel',
|
||||||
|
updatedAt: '2024-04-10',
|
||||||
|
status: 'sync active',
|
||||||
|
lastSync: '15 menit lalu',
|
||||||
|
stats: { users: 980, groups: 28, divisions: 6, activities: 3180 },
|
||||||
|
},
|
||||||
|
'cikini': {
|
||||||
|
id: 'cikini',
|
||||||
|
name: 'Cikini',
|
||||||
|
kecamatan: 'Menteng',
|
||||||
|
kabupaten: 'Jakarta Pusat',
|
||||||
|
provinsi: 'DKI Jakarta',
|
||||||
|
kodePos: '10330',
|
||||||
|
perbekel: 'Ir. Budi Santoso',
|
||||||
|
createdAt: '2024-05-20',
|
||||||
|
createdBy: 'Jane Smith',
|
||||||
|
updatedAt: '2024-05-25',
|
||||||
|
status: 'sync pending',
|
||||||
|
lastSync: 'Belum pernah sync',
|
||||||
|
stats: { users: 420, groups: 12, divisions: 3, activities: 640 },
|
||||||
|
},
|
||||||
|
'bojong-gede': {
|
||||||
|
id: 'bojong-gede',
|
||||||
|
name: 'Bojong Gede',
|
||||||
|
kecamatan: 'Bojong Gede',
|
||||||
|
kabupaten: 'Kabupaten Bogor',
|
||||||
|
provinsi: 'Jawa Barat',
|
||||||
|
kodePos: '16920',
|
||||||
|
perbekel: 'H. Rahmat Hidayat, M.Si',
|
||||||
|
createdAt: '2024-02-15',
|
||||||
|
createdBy: 'Rahmat',
|
||||||
|
updatedAt: '2024-04-02',
|
||||||
|
status: 'fully integrated',
|
||||||
|
lastSync: '1 jam lalu',
|
||||||
|
stats: { users: 1890, groups: 51, divisions: 12, activities: 7340 },
|
||||||
|
},
|
||||||
|
'ciputat': {
|
||||||
|
id: 'ciputat',
|
||||||
|
name: 'Ciputat',
|
||||||
|
kecamatan: 'Ciputat',
|
||||||
|
kabupaten: 'Tangerang Selatan',
|
||||||
|
provinsi: 'Banten',
|
||||||
|
kodePos: '15411',
|
||||||
|
perbekel: 'Drs. Ahmad Fauzi',
|
||||||
|
createdAt: '2024-06-10',
|
||||||
|
createdBy: 'Admin Pusat',
|
||||||
|
updatedAt: '2024-06-15',
|
||||||
|
status: 'sync active',
|
||||||
|
lastSync: '30 menit lalu',
|
||||||
|
stats: { users: 1120, groups: 30, divisions: 7, activities: 3860 },
|
||||||
|
},
|
||||||
|
'serpong': {
|
||||||
|
id: 'serpong',
|
||||||
|
name: 'Serpong',
|
||||||
|
kecamatan: 'Serpong',
|
||||||
|
kabupaten: 'Tangerang Selatan',
|
||||||
|
provinsi: 'Banten',
|
||||||
|
kodePos: '15310',
|
||||||
|
perbekel: 'H. Bambang Wijaya',
|
||||||
|
createdAt: '2024-07-05',
|
||||||
|
createdBy: 'Amel',
|
||||||
|
updatedAt: '2024-07-10',
|
||||||
|
status: 'sync pending',
|
||||||
|
lastSync: 'Belum tersinkronisasi',
|
||||||
|
stats: { users: 280, groups: 8, divisions: 2, activities: 310 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chart Data Generators ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function generateDailyData() {
|
||||||
|
const days = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']
|
||||||
|
const today = new Date()
|
||||||
|
return Array.from({ length: 14 }, (_, i) => {
|
||||||
|
const d = new Date(today)
|
||||||
|
d.setDate(today.getDate() - (13 - i))
|
||||||
|
const dayName = days[d.getDay() === 0 ? 6 : d.getDay() - 1]
|
||||||
|
const dateStr = `${dayName} ${d.getDate()}/${d.getMonth() + 1}`
|
||||||
|
return {
|
||||||
|
label: dateStr,
|
||||||
|
aktivitas: Math.floor(Math.random() * 300 + 60),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMonthlyData() {
|
||||||
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des']
|
||||||
|
return months.map((m) => ({
|
||||||
|
label: m,
|
||||||
|
aktivitas: Math.floor(Math.random() * 2000 + 800),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateYearlyData() {
|
||||||
|
return ['2021', '2022', '2023', '2024'].map((y) => ({
|
||||||
|
label: y,
|
||||||
|
aktivitas: Math.floor(Math.random() * 15000 + 5000),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
'fully integrated': { color: 'teal', label: 'Terintegrasi Penuh' },
|
||||||
|
'sync active': { color: 'blue', label: 'Sync Aktif' },
|
||||||
|
'sync pending': { color: 'orange', label: 'Menunggu Sync' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('id-ID', {
|
||||||
|
day: 'numeric', month: 'long', year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity Chart ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
|
||||||
|
|
||||||
|
function ActivityChart() {
|
||||||
|
const [period, setPeriod] = useState<ChartPeriod>('monthly')
|
||||||
|
|
||||||
|
const dataMap: Record<ChartPeriod, any[]> = {
|
||||||
|
daily: generateDailyData(),
|
||||||
|
monthly: generateMonthlyData(),
|
||||||
|
yearly: generateYearlyData(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels: Record<ChartPeriod, string> = {
|
||||||
|
daily: 'Harian (14 hari terakhir)',
|
||||||
|
monthly: 'Bulanan (tahun ini)',
|
||||||
|
yearly: 'Tahunan',
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = dataMap[period]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="xl" p="lg">
|
||||||
|
<Group justify="space-between" mb="lg" wrap="wrap" gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size={28} radius="md" variant="light" color="blue">
|
||||||
|
<TbChartBar size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={700} size="sm">Log Aktivitas Desa</Text>
|
||||||
|
<Text size="xs" c="dimmed">{labels[period]}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SegmentedControl
|
||||||
|
value={period}
|
||||||
|
onChange={(v) => setPeriod(v as ChartPeriod)}
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
data={[
|
||||||
|
{ value: 'daily', label: 'Harian' },
|
||||||
|
{ value: 'monthly', label: 'Bulanan' },
|
||||||
|
{ value: 'yearly', label: 'Tahunan' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<AreaChart
|
||||||
|
h={280}
|
||||||
|
data={data}
|
||||||
|
dataKey="label"
|
||||||
|
series={[{ name: 'aktivitas', color: '#2563EB', label: 'Aktivitas' }]}
|
||||||
|
curveType="monotone"
|
||||||
|
withTooltip
|
||||||
|
withDots
|
||||||
|
tickLine="none"
|
||||||
|
gridAxis="x"
|
||||||
|
tooltipAnimationDuration={150}
|
||||||
|
fillOpacity={1}
|
||||||
|
areaProps={{
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
fill: 'url(#villageAreaGrad)',
|
||||||
|
stroke: '#2563EB',
|
||||||
|
filter: 'drop-shadow(0 4px 12px rgba(37,99,235,0.3))',
|
||||||
|
}}
|
||||||
|
dotProps={{
|
||||||
|
r: 4,
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function VillageDetailPage() {
|
||||||
|
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const village = mockVillages[villageId]
|
||||||
|
|
||||||
|
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
|
||||||
|
|
||||||
|
if (!village) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" py="xl" gap="md">
|
||||||
|
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
|
||||||
|
<Title order={4}>Desa tidak ditemukan</Title>
|
||||||
|
<Text c="dimmed">ID desa "{villageId}" tidak terdaftar dalam sistem.</Text>
|
||||||
|
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
|
||||||
|
Kembali ke Daftar
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = statusConfig[village.status as keyof typeof statusConfig]
|
||||||
|
const { stats } = village
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
|
||||||
|
{/* ── Back Button ── */}
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<TbArrowLeft size={16} />}
|
||||||
|
radius="md"
|
||||||
|
onClick={goBack}
|
||||||
|
>
|
||||||
|
Daftar Desa
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color={village.status === 'fully integrated' || village.status === 'sync active' ? 'red' : 'green'}
|
||||||
|
leftSection={village.status === 'fully integrated' || village.status === 'sync active' ? <TbPower size={16} /> : <TbPower size={16} />}
|
||||||
|
onClick={() => alert(`Toggle status for ${village.name}`)}
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
{village.status === 'fully integrated' || village.status === 'sync active' ? 'Nonaktifkan Desa' : 'Aktifkan Desa'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
leftSection={<TbEdit size={16} />}
|
||||||
|
onClick={() => alert(`Edit settings for ${village.name}`)}
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Edit Desa
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* ── Header Banner ── */}
|
||||||
|
<Paper
|
||||||
|
radius="xl"
|
||||||
|
p="xl"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #1d4ed8 0%, #6d28d9 60%, #7c3aed 100%)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Decorative blobs */}
|
||||||
|
<Box style={{ position: 'absolute', top: -50, right: -50, width: 220, height: 220, borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }} />
|
||||||
|
<Box style={{ position: 'absolute', bottom: -70, right: 100, width: 160, height: 160, borderRadius: '50%', background: 'rgba(255,255,255,0.04)' }} />
|
||||||
|
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<Group gap="lg">
|
||||||
|
<ThemeIcon
|
||||||
|
size={68}
|
||||||
|
radius="xl"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(10px)', border: '1px solid rgba(255,255,255,0.2)' }}
|
||||||
|
>
|
||||||
|
<TbHome2 size={32} color="white" />
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Title order={2} style={{ color: 'white', lineHeight: 1.1 }}>{village.name}</Title>
|
||||||
|
|
||||||
|
<Group gap={6}>
|
||||||
|
<TbMapPin size={14} color="rgba(255,255,255,0.8)" />
|
||||||
|
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||||
|
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={6}>
|
||||||
|
<TbUser size={14} color="rgba(255,255,255,0.8)" />
|
||||||
|
<Text size="sm" style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||||
|
Perbekel: <strong style={{ color: 'white' }}>{village.perbekel}</strong>
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs" mt={2}>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
radius="sm"
|
||||||
|
size="sm"
|
||||||
|
style={{ color: 'white', borderColor: 'rgba(255,255,255,0.45)' }}
|
||||||
|
leftSection={<TbCircleCheck size={11} />}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
radius="sm"
|
||||||
|
size="sm"
|
||||||
|
style={{ color: 'rgba(255,255,255,0.8)', borderColor: 'rgba(255,255,255,0.25)' }}
|
||||||
|
>
|
||||||
|
Kode Pos: {village.kodePos}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Last Sync block */}
|
||||||
|
<Stack gap={4} align="flex-end">
|
||||||
|
<Text size="xs" style={{ color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Last Sync</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
<TbWifi size={15} color="rgba(255,255,255,0.9)" />
|
||||||
|
<Text size="sm" fw={700} style={{ color: 'white' }}>{village.lastSync}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* ── Stats Cards ── */}
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||||
|
{[
|
||||||
|
{ icon: TbUsers, label: 'Jumlah User', value: stats.users.toLocaleString('id-ID'), color: 'blue' },
|
||||||
|
{ icon: TbUsersGroup, label: 'Jumlah Grup', value: stats.groups.toLocaleString('id-ID'), color: 'violet' },
|
||||||
|
{ icon: TbLayoutKanban, label: 'Jumlah Divisi', value: stats.divisions.toLocaleString('id-ID'), color: 'teal' },
|
||||||
|
{ icon: TbCalendarEvent, label: 'Jumlah Kegiatan', value: stats.activities.toLocaleString('id-ID'), color: 'orange' },
|
||||||
|
].map((s) => (
|
||||||
|
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
|
||||||
|
<ThemeIcon size={36} radius="md" variant="light" color={s.color} mb="xs">
|
||||||
|
<s.icon size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
{s.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" fw={800} mt={2}>{s.value}</Text>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* ── Chart + Info Panels ── */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '3fr 1fr',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left (3/4): Activity Chart */}
|
||||||
|
<ActivityChart />
|
||||||
|
|
||||||
|
{/* Right (1/4): Informasi Sistem */}
|
||||||
|
<Paper withBorder radius="xl" p="lg">
|
||||||
|
<Group gap="xs" mb="md">
|
||||||
|
<ThemeIcon size={28} radius="md" variant="light" color="teal">
|
||||||
|
<TbCalendar size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} size="sm">Informasi Sistem</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{[
|
||||||
|
{ label: 'Tanggal Dibuat', value: formatDate(village.createdAt) },
|
||||||
|
{ label: 'Dibuat Oleh', value: village.createdBy },
|
||||||
|
{ label: 'Terakhir Diperbarui', value: formatDate(village.updatedAt) },
|
||||||
|
].map((item, idx, arr) => (
|
||||||
|
<Group
|
||||||
|
key={item.label}
|
||||||
|
justify="space-between"
|
||||||
|
py="xs"
|
||||||
|
wrap="wrap"
|
||||||
|
style={{
|
||||||
|
borderBottom: idx < arr.length - 1 ? '1px solid var(--mantine-color-default-border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed">{item.label}</Text>
|
||||||
|
<Text size="xs" fw={600} ta="right">{item.value}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
366
src/frontend/routes/apps.$appId.villages.index.tsx
Normal file
366
src/frontend/routes/apps.$appId.villages.index.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
Paper,
|
||||||
|
Button,
|
||||||
|
ActionIcon,
|
||||||
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
|
SimpleGrid,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
SegmentedControl,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
ThemeIcon,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
TbPlus,
|
||||||
|
TbSearch,
|
||||||
|
TbBuildingCommunity,
|
||||||
|
TbLayoutGrid,
|
||||||
|
TbList,
|
||||||
|
TbMapPin,
|
||||||
|
TbCalendar,
|
||||||
|
TbUser,
|
||||||
|
TbHome2,
|
||||||
|
TbArrowRight,
|
||||||
|
TbChevronRight,
|
||||||
|
} from 'react-icons/tb'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/apps/$appId/villages/')({
|
||||||
|
component: AppVillagesIndexPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockVillages = [
|
||||||
|
{
|
||||||
|
id: 'sukatani',
|
||||||
|
name: 'Sukatani',
|
||||||
|
kecamatan: 'Tapos',
|
||||||
|
kabupaten: 'Kota Depok',
|
||||||
|
provinsi: 'Jawa Barat',
|
||||||
|
perbekel: 'H. Suryana, S.Sos',
|
||||||
|
createdAt: '2024-03-12',
|
||||||
|
createdBy: 'Admin Pusat',
|
||||||
|
status: 'fully integrated',
|
||||||
|
population: 4500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sukamaju',
|
||||||
|
name: 'Sukamaju',
|
||||||
|
kecamatan: 'Cilodong',
|
||||||
|
kabupaten: 'Kota Depok',
|
||||||
|
provinsi: 'Jawa Barat',
|
||||||
|
perbekel: 'Drs. H. Mujiono',
|
||||||
|
createdAt: '2024-04-01',
|
||||||
|
createdBy: 'Amel',
|
||||||
|
status: 'sync active',
|
||||||
|
population: 3800,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cikini',
|
||||||
|
name: 'Cikini',
|
||||||
|
kecamatan: 'Menteng',
|
||||||
|
kabupaten: 'Jakarta Pusat',
|
||||||
|
provinsi: 'DKI Jakarta',
|
||||||
|
perbekel: 'Ir. Budi Santoso',
|
||||||
|
createdAt: '2024-05-20',
|
||||||
|
createdBy: 'Jane Smith',
|
||||||
|
status: 'sync pending',
|
||||||
|
population: 2100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bojong-gede',
|
||||||
|
name: 'Bojong Gede',
|
||||||
|
kecamatan: 'Bojong Gede',
|
||||||
|
kabupaten: 'Kabupaten Bogor',
|
||||||
|
provinsi: 'Jawa Barat',
|
||||||
|
perbekel: 'H. Rahmat Hidayat, M.Si',
|
||||||
|
createdAt: '2024-02-15',
|
||||||
|
createdBy: 'Rahmat',
|
||||||
|
status: 'fully integrated',
|
||||||
|
population: 6700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ciputat',
|
||||||
|
name: 'Ciputat',
|
||||||
|
kecamatan: 'Ciputat',
|
||||||
|
kabupaten: 'Tangerang Selatan',
|
||||||
|
provinsi: 'Banten',
|
||||||
|
perbekel: 'Drs. Ahmad Fauzi',
|
||||||
|
createdAt: '2024-06-10',
|
||||||
|
createdBy: 'Admin Pusat',
|
||||||
|
status: 'sync active',
|
||||||
|
population: 5200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'serpong',
|
||||||
|
name: 'Serpong',
|
||||||
|
kecamatan: 'Serpong',
|
||||||
|
kabupaten: 'Tangerang Selatan',
|
||||||
|
provinsi: 'Banten',
|
||||||
|
perbekel: 'H. Bambang Wijaya',
|
||||||
|
createdAt: '2024-07-05',
|
||||||
|
createdBy: 'Amel',
|
||||||
|
status: 'sync pending',
|
||||||
|
population: 8900,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
'fully integrated': { color: 'teal', label: 'Terintegrasi' },
|
||||||
|
'sync active': { color: 'blue', label: 'Sync Aktif' },
|
||||||
|
'sync pending': { color: 'orange', label: 'Sync Pending' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('id-ID', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function VillageGridCard({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
|
||||||
|
const cfg = statusConfig[village.status as keyof typeof statusConfig]
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="xl"
|
||||||
|
padding="lg"
|
||||||
|
className="village-card"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<ThemeIcon
|
||||||
|
size={46}
|
||||||
|
radius="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
>
|
||||||
|
<TbHome2 size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Badge color={cfg.color} variant="light" radius="sm" size="sm">
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text fw={800} size="lg" mb={2}>
|
||||||
|
{village.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4} mb="md">
|
||||||
|
<TbMapPin size={13} color="var(--mantine-color-dimmed)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Kec. {village.kecamatan} · {village.kabupaten}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed" fw={600} mb={6} style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{village.provinsi}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<TbUser size={13} color="var(--mantine-color-dimmed)" />
|
||||||
|
<Text size="xs" c="dimmed">Perbekel:</Text>
|
||||||
|
<Text size="xs" fw={600}>{village.perbekel}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<TbCalendar size={13} color="var(--mantine-color-dimmed)" />
|
||||||
|
<Text size="xs" c="dimmed">Dibuat:</Text>
|
||||||
|
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Avatar size={14} radius="xl" color="brand-blue" src={null} />
|
||||||
|
<Text size="xs" c="dimmed">Oleh:</Text>
|
||||||
|
<Text size="xs" fw={600}>{village.createdBy}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="brand-blue"
|
||||||
|
size="compact-sm"
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
radius="md"
|
||||||
|
rightSection={<TbArrowRight size={14} />}
|
||||||
|
styles={{ root: { fontSize: 12 } }}
|
||||||
|
>
|
||||||
|
Lihat Detail
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VillageListRow({ village, onClick }: { village: typeof mockVillages[0]; onClick: () => void }) {
|
||||||
|
const cfg = statusConfig[village.status as keyof typeof statusConfig]
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="md"
|
||||||
|
className="village-list-row"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<ThemeIcon
|
||||||
|
size={40}
|
||||||
|
radius="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
>
|
||||||
|
<TbHome2 size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text fw={700} size="sm">{village.name}</Text>
|
||||||
|
<Badge color={cfg.color} variant="light" radius="sm" size="xs">
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6}>
|
||||||
|
<TbMapPin size={12} color="var(--mantine-color-dimmed)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Kec. {village.kecamatan} · {village.kabupaten} · {village.provinsi}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xl" visibleFrom="md">
|
||||||
|
<Stack gap={0} align="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Perbekel</Text>
|
||||||
|
<Text size="xs" fw={600}>{village.perbekel}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} align="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Dibuat</Text>
|
||||||
|
<Text size="xs" fw={600}>{formatDate(village.createdAt)}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0} align="center">
|
||||||
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Oleh</Text>
|
||||||
|
<Group gap={4}>
|
||||||
|
<Avatar size={16} radius="xl" color="brand-blue" src={null} />
|
||||||
|
<Text size="xs" fw={600}>{village.createdBy}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ActionIcon variant="light" color="brand-blue" radius="md">
|
||||||
|
<TbChevronRight size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppVillagesIndexPage() {
|
||||||
|
const { appId } = useParams({ from: '/apps/$appId' })
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const isDesaPlus = appId === 'desa-plus'
|
||||||
|
|
||||||
|
const filtered = mockVillages.filter((v) =>
|
||||||
|
[v.name, v.kecamatan, v.kabupaten, v.provinsi, v.perbekel]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleVillageClick = (villageId: string) => {
|
||||||
|
navigate({ to: '/apps/$appId/villages/$villageId', params: { appId, villageId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDesaPlus) {
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
||||||
|
<TbBuildingCommunity size={48} color="gray" opacity={0.5} />
|
||||||
|
<Title order={3} mt="md">General Management</Title>
|
||||||
|
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Title order={3}>Daftar Desa</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{filtered.length} desa terdaftar dalam platform <strong>Desa+</strong>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
|
leftSection={<TbPlus size={18} />}
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
Tambah Desa
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Cari desa, kecamatan, kabupaten..."
|
||||||
|
leftSection={<TbSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
radius="md"
|
||||||
|
style={{ flex: 1, maxWidth: 400 }}
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
value={viewMode}
|
||||||
|
onChange={(v) => setViewMode(v as 'grid' | 'list')}
|
||||||
|
data={[
|
||||||
|
{ value: 'grid', label: <Tooltip label="Grid View"><Box><TbLayoutGrid size={16} /></Box></Tooltip> },
|
||||||
|
{ value: 'list', label: <Tooltip label="List View"><Box><TbList size={16} /></Box></Tooltip> },
|
||||||
|
]}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
||||||
|
<TbBuildingCommunity size={40} color="gray" opacity={0.4} />
|
||||||
|
<Text c="dimmed" mt="md">Tidak ada desa yang cocok dengan pencarian.</Text>
|
||||||
|
</Paper>
|
||||||
|
) : viewMode === 'grid' ? (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
{filtered.map((village) => (
|
||||||
|
<VillageGridCard
|
||||||
|
key={village.id}
|
||||||
|
village={village}
|
||||||
|
onClick={() => handleVillageClick(village.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{filtered.map((village) => (
|
||||||
|
<VillageListRow
|
||||||
|
key={village.id}
|
||||||
|
village={village}
|
||||||
|
onClick={() => handleVillageClick(village.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,278 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Container,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
Paper,
|
|
||||||
Table,
|
|
||||||
Button,
|
|
||||||
ActionIcon,
|
|
||||||
TextInput,
|
|
||||||
Select,
|
|
||||||
Tooltip,
|
|
||||||
SimpleGrid,
|
|
||||||
Modal,
|
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
NumberInput,
|
|
||||||
} from '@mantine/core'
|
|
||||||
import { useDisclosure } from '@mantine/hooks'
|
|
||||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
|
||||||
import {
|
|
||||||
TbPlus,
|
|
||||||
TbSearch,
|
|
||||||
TbPencil,
|
|
||||||
TbTrash,
|
|
||||||
TbUserPlus,
|
|
||||||
TbCircleCheck,
|
|
||||||
TbRefresh,
|
|
||||||
TbUser,
|
|
||||||
TbBuildingCommunity,
|
|
||||||
} from 'react-icons/tb'
|
|
||||||
import { StatsCard } from '@/frontend/components/StatsCard'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/apps/$appId/villages')({
|
export const Route = createFileRoute('/apps/$appId/villages')({
|
||||||
component: AppVillagesPage,
|
component: VillagesLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockDevelopers = [
|
function VillagesLayout() {
|
||||||
{ value: 'john-doe', label: 'John Doe', avatar: null },
|
return <Outlet />
|
||||||
{ value: 'amel', label: 'Amel', avatar: null },
|
|
||||||
{ value: 'jane-smith', label: 'Jane Smith', avatar: null },
|
|
||||||
{ value: 'rahmat', label: 'Rahmat Hidayat', avatar: null },
|
|
||||||
]
|
|
||||||
|
|
||||||
function AppVillagesPage() {
|
|
||||||
const { appId } = useParams({ from: '/apps/$appId' })
|
|
||||||
const [initModalOpened, { open: openInit, close: closeInit }] = useDisclosure(false)
|
|
||||||
const [assignModalOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false)
|
|
||||||
const [selectedVillage, setSelectedVillage] = useState<any>(null)
|
|
||||||
|
|
||||||
const isDesaPlus = appId === 'desa-plus'
|
|
||||||
|
|
||||||
const mockVillages = [
|
|
||||||
{ id: 1, name: 'Sukatani', kecamatan: 'Tapos', population: 4500, status: 'fully integrated', developer: 'John Doe', lastUpdate: '2 mins ago' },
|
|
||||||
{ id: 2, name: 'Sukamaju', kecamatan: 'Cilodong', population: 3800, status: 'sync active', developer: 'Amel', lastUpdate: '15 mins ago' },
|
|
||||||
{ id: 3, name: 'Cikini', kecamatan: 'Menteng', population: 2100, status: 'sync pending', developer: 'Jane Smith', lastUpdate: '-' },
|
|
||||||
{ id: 4, name: 'Bojong Gede', kecamatan: 'Bojong Gede', population: 6700, status: 'fully integrated', developer: 'Rahmat', lastUpdate: '1 hour ago' },
|
|
||||||
]
|
|
||||||
|
|
||||||
if (!isDesaPlus) {
|
|
||||||
return (
|
|
||||||
<Container size="xl" py="xl">
|
|
||||||
<Paper p="xl" radius="xl" className="glass" style={{ textAlign: 'center' }}>
|
|
||||||
<TbBuildingCommunity size={48} color="gray" opacity={0.5} />
|
|
||||||
<Title order={3} mt="md">General Management</Title>
|
|
||||||
<Text c="dimmed">This feature is currently customized for Desa+. Other apps coming soon.</Text>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="xl">
|
|
||||||
{/* Metrics Row */}
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 4 }} spacing="lg">
|
|
||||||
<StatsCard
|
|
||||||
title="Total Integrations"
|
|
||||||
value={140}
|
|
||||||
icon={TbBuildingCommunity}
|
|
||||||
color="brand-blue"
|
|
||||||
trend={{ value: '12%', positive: true }}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Daily Sync Rate"
|
|
||||||
value="94.2%"
|
|
||||||
icon={TbRefresh}
|
|
||||||
color="teal"
|
|
||||||
trend={{ value: '2.5%', positive: true }}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Avg. Sync Delay"
|
|
||||||
value="45s"
|
|
||||||
icon={TbRefresh}
|
|
||||||
color="orange"
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Pending Documents"
|
|
||||||
value={124}
|
|
||||||
icon={TbUser}
|
|
||||||
color="red"
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<Group justify="space-between" align="flex-end">
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Title order={3}>Village Deployment Center</Title>
|
|
||||||
<Text size="sm" c="dimmed">Monitor and configure **Desa+** village instances across all districts.</Text>
|
|
||||||
</Stack>
|
|
||||||
<Button
|
|
||||||
variant="gradient"
|
|
||||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
|
||||||
leftSection={<TbPlus size={18} />}
|
|
||||||
radius="md"
|
|
||||||
onClick={openInit}
|
|
||||||
>
|
|
||||||
Initialize New Village
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Paper withBorder radius="2xl" className="glass" p="md">
|
|
||||||
<Group mb="md">
|
|
||||||
<TextInput
|
|
||||||
placeholder="Search village or district..."
|
|
||||||
leftSection={<TbSearch size={16} />}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Table className="data-table" verticalSpacing="md" highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Village Profile</Table.Th>
|
|
||||||
<Table.Th>District</Table.Th>
|
|
||||||
<Table.Th>Integration Status</Table.Th>
|
|
||||||
<Table.Th>Lead Developer</Table.Th>
|
|
||||||
<Table.Th>Last Sync</Table.Th>
|
|
||||||
<Table.Th>Actions</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{mockVillages.map((village) => (
|
|
||||||
<Table.Tr key={village.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text fw={700} size="sm">{village.name}</Text>
|
|
||||||
<Text size="xs" c="dimmed">{village.population.toLocaleString()} Residents</Text>
|
|
||||||
</Stack>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" fw={500}>{village.kecamatan}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge
|
|
||||||
color={
|
|
||||||
village.status === 'fully integrated' ? 'teal' :
|
|
||||||
village.status === 'sync active' ? 'brand-blue' : 'orange'
|
|
||||||
}
|
|
||||||
variant={village.status === 'sync pending' ? 'outline' : 'light'}
|
|
||||||
leftSection={village.status !== 'sync pending' && <TbCircleCheck size={12} />}
|
|
||||||
radius="sm"
|
|
||||||
style={{ textTransform: 'uppercase', fontVariant: 'small-caps' }}
|
|
||||||
>
|
|
||||||
{village.status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Avatar size="xs" radius="xl" color="brand-blue" src={null} />
|
|
||||||
<Text size="sm">{village.developer}</Text>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => { setSelectedVillage(village); openAssign(); }}
|
|
||||||
>
|
|
||||||
<TbUserPlus size={12} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" fw={500} c={village.lastUpdate === '-' ? 'dimmed' : 'teal'}>
|
|
||||||
{village.lastUpdate}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs">
|
|
||||||
{village.status === 'sync pending' && (
|
|
||||||
<Button variant="light" size="compact-xs" color="blue" onClick={openInit}>
|
|
||||||
START SYNC
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Tooltip label="Village Settings">
|
|
||||||
<ActionIcon variant="light" size="sm" color="gray">
|
|
||||||
<TbPencil size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label="Unlink Village">
|
|
||||||
<ActionIcon variant="light" size="sm" color="red">
|
|
||||||
<TbTrash size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* MODALS */}
|
|
||||||
<Modal
|
|
||||||
opened={initModalOpened}
|
|
||||||
onClose={closeInit}
|
|
||||||
title={<Title order={4}>Desa+ Instance Initialization</Title>}
|
|
||||||
radius="xl"
|
|
||||||
centered
|
|
||||||
padding="xl"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<SimpleGrid cols={2}>
|
|
||||||
<TextInput label="Village Name" placeholder="e.g. Sukatani" radius="md" required />
|
|
||||||
<TextInput label="Kecamatan" placeholder="e.g. Tapos" radius="md" required />
|
|
||||||
</SimpleGrid>
|
|
||||||
<Group grow>
|
|
||||||
<Select
|
|
||||||
label="Population Data Source"
|
|
||||||
placeholder="Select source..."
|
|
||||||
data={['SIAK Terpusat', 'BPS Proyeksi', 'Manual Upload']}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
<NumberInput label="Target Residents" placeholder="1000" radius="md" />
|
|
||||||
</Group>
|
|
||||||
<Box>
|
|
||||||
<Text size="xs" fw={700} c="dimmed" mb="xs">INITIAL SYNC MODULES</Text>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Badge variant="outline" color="blue">PENDUDUK</Badge>
|
|
||||||
<Badge variant="outline" color="teal">KEUANGAN</Badge>
|
|
||||||
<Badge variant="outline" color="brand-purple">PELAYANAN</Badge>
|
|
||||||
<Badge variant="outline" color="orange">APBDes</Badge>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="subtle" color="gray" onClick={closeInit}>Cancel</Button>
|
|
||||||
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} radius="md">Deploy Instance</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={assignModalOpened}
|
|
||||||
onClose={closeAssign}
|
|
||||||
title={<Title order={4}>Assign Lead Developer</Title>}
|
|
||||||
radius="xl"
|
|
||||||
centered
|
|
||||||
padding="xl"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Text size="sm">Assign a dedicated reviewer for <b>{selectedVillage?.name}</b> instance stability.</Text>
|
|
||||||
<Select
|
|
||||||
label="Technical Lead"
|
|
||||||
placeholder="Search developer..."
|
|
||||||
data={mockDevelopers}
|
|
||||||
leftSection={<TbUser size={16} />}
|
|
||||||
radius="md"
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="subtle" color="gray" onClick={closeAssign}>Cancel</Button>
|
|
||||||
<Button variant="gradient" gradient={{ from: '#2563EB', to: '#7C3AED' }} radius="md">Set Lead</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@import '@mantine/core/styles.css';
|
@import '@mantine/core/styles.css';
|
||||||
|
@import '@mantine/charts/styles.css';
|
||||||
:root {
|
:root {
|
||||||
--font-inter: 'Inter', system-ui, -apple-system, sans-serif;
|
--font-inter: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
|
||||||
@@ -111,3 +111,40 @@ body {
|
|||||||
.data-table tbody tr:hover {
|
.data-table tbody tr:hover {
|
||||||
background: rgba(124, 58, 237, 0.03);
|
background: rgba(124, 58, 237, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Village Cards */
|
||||||
|
.village-card {
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
background: var(--mantine-color-body);
|
||||||
|
border-color: rgba(128, 128, 128, 0.12) !important;
|
||||||
|
}
|
||||||
|
.village-card:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
box-shadow: 0 16px 32px -12px rgba(37, 99, 235, 0.25);
|
||||||
|
border-color: rgba(37, 99, 235, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.village-list-row {
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
background: var(--mantine-color-body);
|
||||||
|
border-color: rgba(128, 128, 128, 0.12) !important;
|
||||||
|
}
|
||||||
|
.village-list-row:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: 0 4px 16px -6px rgba(37, 99, 235, 0.2);
|
||||||
|
border-color: rgba(37, 99, 235, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Village Detail Page Grid */
|
||||||
|
.village-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.village-detail-grid {
|
||||||
|
grid-template-columns: 3fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user