- tambah Elysia Swagger di /docs dengan deskripsi lengkap semua endpoint - tambah API key auth (X-API-Key) untuk klien eksternal di POST /api/bugs - tambah normalisasi BugSource: SYSTEM/USER untuk eksternal, QC/SYSTEM/USER untuk dashboard - perbaiki source schema jadi optional string agar tidak reject nilai unknown dari klien lama - hapus field status dari form create bug (selalu OPEN) - perbaiki typo desa_plus → appId di apps.$appId.errors.tsx - tambah toggle hide/show stack trace di bug-reports.tsx dan apps.$appId.errors.tsx - perbaiki grafik desa (width(-1)/height(-1)) dengan minWidth: 0 pada grid item - perbaiki error &[data-active] inline style di DashboardLayout → pindah ke CSS class - update CLAUDE.md dengan arsitektur lengkap Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
547 lines
18 KiB
TypeScript
547 lines
18 KiB
TypeScript
import { AreaChart } from '@mantine/charts'
|
|
import {
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Group,
|
|
Modal,
|
|
Paper,
|
|
SegmentedControl,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Title
|
|
} from '@mantine/core'
|
|
import { useDisclosure } from '@mantine/hooks'
|
|
import { notifications } from '@mantine/notifications'
|
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
|
import { useState } from 'react'
|
|
import {
|
|
TbArrowLeft,
|
|
TbBuildingCommunity,
|
|
TbCalendar,
|
|
TbCalendarEvent,
|
|
TbChartBar,
|
|
TbEdit,
|
|
TbHome2,
|
|
TbLayoutKanban,
|
|
TbMapPin,
|
|
TbPower,
|
|
TbUser,
|
|
TbUsers,
|
|
TbUsersGroup,
|
|
TbWifi
|
|
} from 'react-icons/tb'
|
|
import useSWR from 'swr'
|
|
import { API_URLS } from '../config/api'
|
|
import { useSession } from '../hooks/useAuth'
|
|
|
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
|
|
|
export const Route = createFileRoute('/apps/$appId/villages/$villageId')({
|
|
component: VillageDetailPage,
|
|
})
|
|
|
|
// ── Mock Data ────────────────────────────────────────────────────────────────
|
|
|
|
// Mock data removed as it is replaced by API calls
|
|
|
|
// Remove chart data generators as they are replaced by API calls
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
// ── Activity Chart ────────────────────────────────────────────────────────────
|
|
|
|
type ChartPeriod = 'daily' | 'monthly' | 'yearly'
|
|
|
|
function ActivityChart({ villageId }: { villageId: string }) {
|
|
const [period, setPeriod] = useState<ChartPeriod>('daily')
|
|
|
|
const { data: response, isLoading } = useSWR(
|
|
API_URLS.graphLogVillages(villageId, period),
|
|
fetcher
|
|
)
|
|
|
|
const labels: Record<ChartPeriod, string> = {
|
|
daily: 'Daily (last 14 days)',
|
|
monthly: 'Monthly (this year)',
|
|
yearly: 'Yearly',
|
|
}
|
|
|
|
const rawData: any[] = Array.isArray(response?.data) ? response.data : []
|
|
|
|
// Normalize: map any field names from external API → { label, activity }
|
|
const data = rawData.map((item) => {
|
|
const label = item.label
|
|
const activity = item.aktivitas
|
|
return { label: String(label), activity: Number(activity) }
|
|
})
|
|
|
|
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">Village Activity Log</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: 'Daily' },
|
|
{ value: 'monthly', label: 'Monthly' },
|
|
{ value: 'yearly', label: 'Yearly' },
|
|
]}
|
|
/>
|
|
</Group>
|
|
|
|
{isLoading ? (
|
|
<Stack h={280} align="center" justify="center">
|
|
<Text size="sm" c="dimmed">Loading chart data...</Text>
|
|
</Stack>
|
|
) : (
|
|
<AreaChart
|
|
h={280}
|
|
data={data}
|
|
dataKey="label"
|
|
series={[{ name: 'activity', color: '#2563EB' }]}
|
|
curveType="monotone"
|
|
withTooltip={true}
|
|
withDots={true}
|
|
withPointLabels={false}
|
|
tooltipAnimationDuration={150}
|
|
tooltipProps={{
|
|
allowEscapeViewBox: { x: true, y: false },
|
|
}}
|
|
activeDotProps={{
|
|
r: 6,
|
|
strokeWidth: 2,
|
|
}}
|
|
/>
|
|
)}
|
|
</Paper>
|
|
)
|
|
}
|
|
|
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
|
|
|
function VillageDetailPage() {
|
|
const { appId, villageId } = useParams({ from: '/apps/$appId/villages/$villageId' })
|
|
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: gridRes, isLoading: gridLoading } = useSWR(API_URLS.gridVillages(villageId), fetcher)
|
|
|
|
const [confirmModalOpened, { open: openConfirmModal, close: closeConfirmModal }] = useDisclosure(false)
|
|
const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false)
|
|
const [isUpdating, setIsUpdating] = useState(false)
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [editForm, setEditForm] = useState({ name: '', desc: '' })
|
|
|
|
const village = infoRes?.data
|
|
const stats = gridRes?.data
|
|
|
|
const openEdit = () => {
|
|
setEditForm({
|
|
name: village?.name || '',
|
|
desc: village?.desc || ''
|
|
})
|
|
openEditModal()
|
|
}
|
|
|
|
const handleEditVillage = async () => {
|
|
if (!village) return
|
|
|
|
if (!editForm.name.trim() || !editForm.desc.trim()) {
|
|
notifications.show({
|
|
title: 'Validation Error',
|
|
message: 'All fields are required.',
|
|
color: 'red'
|
|
})
|
|
return
|
|
}
|
|
|
|
setIsEditing(true)
|
|
try {
|
|
const res = await fetch(API_URLS.editVillages(), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
id: village.id,
|
|
name: editForm.name,
|
|
desc: editForm.desc
|
|
})
|
|
})
|
|
|
|
if (res.ok) {
|
|
await fetch(API_URLS.createLog(), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type: 'UPDATE', message: `Data desa (${appId}) diperbarui: ${editForm.name}-${village.id}` })
|
|
}).catch(console.error)
|
|
|
|
notifications.show({
|
|
title: 'Success',
|
|
message: 'Village data has been updated successfully.',
|
|
color: 'teal'
|
|
})
|
|
mutate()
|
|
closeEditModal()
|
|
} else {
|
|
notifications.show({
|
|
title: 'Error',
|
|
message: 'Failed to update village data.',
|
|
color: 'red'
|
|
})
|
|
}
|
|
} catch (error) {
|
|
notifications.show({
|
|
title: 'Error',
|
|
message: 'A network error occurred.',
|
|
color: 'red'
|
|
})
|
|
} finally {
|
|
setIsEditing(false)
|
|
}
|
|
}
|
|
|
|
const handleConfirmToggle = async () => {
|
|
if (!village) return
|
|
|
|
setIsUpdating(true)
|
|
try {
|
|
const res = await fetch(API_URLS.updateStatusVillages(), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
id: village.id,
|
|
active: !village.isActive
|
|
})
|
|
})
|
|
|
|
if (res.ok) {
|
|
await fetch(API_URLS.createLog(), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type: 'UPDATE', message: `Status desa (${appId}) diperbarui (${!village.isActive ? 'activated' : 'deactivated'}): ${village.name}-${village.id}` })
|
|
}).catch(console.error)
|
|
|
|
notifications.show({
|
|
title: 'Success',
|
|
message: `Village status has been ${!village.isActive ? 'activated' : 'deactivated'}.`,
|
|
color: 'teal'
|
|
})
|
|
mutate()
|
|
closeConfirmModal()
|
|
} else {
|
|
notifications.show({
|
|
title: 'Error',
|
|
message: 'Failed to update village status.',
|
|
color: 'red'
|
|
})
|
|
}
|
|
} catch (error) {
|
|
notifications.show({
|
|
title: 'Error',
|
|
message: 'A network error occurred.',
|
|
color: 'red'
|
|
})
|
|
} finally {
|
|
setIsUpdating(false)
|
|
}
|
|
}
|
|
|
|
const goBack = () => navigate({ to: '/apps/$appId/villages', params: { appId } })
|
|
|
|
if (infoLoading || gridLoading) {
|
|
return (
|
|
<Stack align="center" py="xl" gap="md">
|
|
<Text c="dimmed">Loading village data...</Text>
|
|
</Stack>
|
|
)
|
|
}
|
|
|
|
if (!village) {
|
|
return (
|
|
<Stack align="center" py="xl" gap="md">
|
|
<TbBuildingCommunity size={48} color="gray" opacity={0.4} />
|
|
<Title order={4}>Village not found</Title>
|
|
<Text c="dimmed">Village ID "{villageId}" is not registered in the system.</Text>
|
|
<Button variant="light" leftSection={<TbArrowLeft size={16} />} onClick={goBack}>
|
|
Back to List
|
|
</Button>
|
|
</Stack>
|
|
)
|
|
}
|
|
|
|
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}
|
|
>
|
|
Village List
|
|
</Button>
|
|
|
|
{/* Action Buttons */}
|
|
<Group gap="sm">
|
|
<Button
|
|
variant="filled"
|
|
color={village.isActive ? 'red' : 'green'}
|
|
leftSection={village.isActive ? <TbPower size={16} /> : <TbPower size={16} />}
|
|
onClick={openConfirmModal}
|
|
radius="md"
|
|
loading={isUpdating}
|
|
disabled={!isDeveloper}
|
|
>
|
|
{village.isActive ? 'Deactivate' : 'Active'}
|
|
</Button>
|
|
<Button
|
|
variant="light"
|
|
color="blue"
|
|
leftSection={<TbEdit size={16} />}
|
|
onClick={openEdit}
|
|
radius="md"
|
|
>
|
|
Edit
|
|
</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)' }}>
|
|
Location data not available
|
|
</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)' }}>
|
|
Village Head: <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>
|
|
</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.isActive ? 'ACTIVE' : 'NON-ACTIVE'}</Text>
|
|
</Group>
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
|
|
{/* ── Stats Cards ── */}
|
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
|
{[
|
|
{ icon: TbUsers, label: 'Total Users', active: stats?.user?.active, nonActive: stats?.user?.nonActive, color: 'blue' },
|
|
{ icon: TbUsersGroup, label: 'Total Groups', active: stats?.group?.active, nonActive: stats?.group?.nonActive, color: 'violet' },
|
|
{ icon: TbLayoutKanban, label: 'Total Divisions', active: stats?.division?.active, nonActive: stats?.division?.nonActive, color: 'teal' },
|
|
{ icon: TbCalendarEvent, label: 'Total Activities', active: stats?.project?.active, nonActive: stats?.project?.nonActive, color: 'orange' },
|
|
].map((s) => (
|
|
<Card key={s.label} withBorder radius="xl" padding="lg" className="premium-card">
|
|
<Group justify="space-between" align="flex-start" mb="xs">
|
|
<ThemeIcon size={36} radius="md" variant="light" color={s.color}>
|
|
<s.icon size={18} />
|
|
</ThemeIcon>
|
|
<Stack gap={0} align="flex-end">
|
|
<Text size="10px" c="dimmed" fw={700}>NON-ACTIVE</Text>
|
|
<Text size="xs" fw={700}>{s.nonActive?.toLocaleString('id-ID') || 0}</Text>
|
|
</Stack>
|
|
</Group>
|
|
<Text size="xs" c="dimmed" fw={600} style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
|
{s.label}
|
|
</Text>
|
|
<Text size="xl" fw={800} mt={2}>{s.active?.toLocaleString('id-ID') || 0}</Text>
|
|
</Card>
|
|
))}
|
|
</SimpleGrid>
|
|
|
|
{/* ── Chart + Info Panels ── */}
|
|
<Box
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '3fr 1fr',
|
|
gap: '1rem',
|
|
alignItems: 'start',
|
|
}}
|
|
>
|
|
{/* Left (3/4): Activity Chart */}
|
|
<Box style={{ minWidth: 0 }}>
|
|
<ActivityChart villageId={villageId} />
|
|
</Box>
|
|
|
|
{/* 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">System Information</Text>
|
|
</Group>
|
|
<Stack gap={0}>
|
|
{[
|
|
{ label: 'Date Created', value: village.createdAt },
|
|
{ label: 'Created By', value: '-' },
|
|
{ label: 'Last Updated', value: 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>
|
|
|
|
{/* ── Confirmation Modal ── */}
|
|
<Modal
|
|
opened={confirmModalOpened}
|
|
onClose={closeConfirmModal}
|
|
title={<Text fw={700}>Confirm Status Change</Text>}
|
|
radius="xl"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
Are you sure you want to <strong>{village.isActive ? 'deactivate' : 'activate'}</strong> village <strong>{village.name}</strong>?
|
|
</Text>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button variant="light" color="gray" onClick={closeConfirmModal} radius="md">
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color={village.isActive ? 'red' : 'green'}
|
|
onClick={handleConfirmToggle}
|
|
loading={isUpdating}
|
|
radius="md"
|
|
>
|
|
Confirm
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{/* ── Edit Village Modal ── */}
|
|
<Modal
|
|
opened={editModalOpened}
|
|
onClose={closeEditModal}
|
|
title={<Text fw={700}>Edit Village Details</Text>}
|
|
radius="xl"
|
|
size="md"
|
|
>
|
|
<Stack gap="md">
|
|
<TextInput
|
|
label="Village Name"
|
|
placeholder="Enter village name"
|
|
required
|
|
value={editForm.name}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, name: e.currentTarget.value }))}
|
|
/>
|
|
<Textarea
|
|
label="Description"
|
|
placeholder="Enter village description..."
|
|
minRows={3}
|
|
required
|
|
value={editForm.desc}
|
|
onChange={(e) => setEditForm(prev => ({ ...prev, desc: e.currentTarget.value }))}
|
|
/>
|
|
<Group justify="flex-end" gap="sm" mt="md">
|
|
<Button variant="light" color="gray" onClick={closeEditModal} radius="md">
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="filled"
|
|
color="blue"
|
|
onClick={handleEditVillage}
|
|
loading={isEditing}
|
|
radius="md"
|
|
>
|
|
Save Changes
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
</Stack>
|
|
)
|
|
}
|