feat(keamanan): tambah halaman publik CCTV dengan peta interaktif
- Buat halaman publik /darmasaba/keamanan/cctv dengan grid card + search + pagination - Tambah CctvMapSection (Leaflet, SSR=false) — marker hijau=Online, merah=Offline - Popup marker menampilkan kode, nama, lokasi, dan status CCTV - Tambah submenu CCTV (id 4.7) di navbar menu Keamanan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
'use client'
|
||||||
|
import { CctvData } from '@/app/admin/(dashboard)/_state/keamanan/cctv';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: CctvData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function createCctvIcon(status: 'Online' | 'Offline') {
|
||||||
|
const color = status === 'Online' ? '#2f9e44' : '#e03131';
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
background:${color};
|
||||||
|
border:2.5px solid white;
|
||||||
|
border-radius:50%;
|
||||||
|
width:18px;height:18px;
|
||||||
|
box-shadow:0 1px 5px rgba(0,0,0,0.45);
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [18, 18],
|
||||||
|
iconAnchor: [9, 9],
|
||||||
|
popupAnchor: [0, -12],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CctvMapSection({ data }: Props) {
|
||||||
|
const markers = data.filter((c) => c.latitude != null && c.longitude != null);
|
||||||
|
if (markers.length === 0) return null;
|
||||||
|
|
||||||
|
const center: [number, number] = [
|
||||||
|
markers.reduce((s, m) => s + m.latitude!, 0) / markers.length,
|
||||||
|
markers.reduce((s, m) => s + m.longitude!, 0) / markers.length,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={center}
|
||||||
|
zoom={16}
|
||||||
|
style={{ height: '100%', width: '100%', zIndex: 0 }}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{markers.map((cctv) => (
|
||||||
|
<Marker
|
||||||
|
key={cctv.id}
|
||||||
|
position={[cctv.latitude!, cctv.longitude!]}
|
||||||
|
icon={createCctvIcon(cctv.status)}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div style={{ padding: '2px 4px', minWidth: 160 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>{cctv.kode}</strong>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: cctv.status === 'Online' ? '#2f9e44' : '#e03131',
|
||||||
|
background: cctv.status === 'Online' ? '#d3f9d8' : '#ffe3e3',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '1px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cctv.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 2 }}>{cctv.nama}</div>
|
||||||
|
<div style={{ color: '#666', fontSize: 12 }}>{cctv.lokasi}</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
src/app/darmasaba/(pages)/keamanan/cctv/page.tsx
Normal file
239
src/app/darmasaba/(pages)/keamanan/cctv/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
'use client'
|
||||||
|
import cctvState, { CctvData } from '@/app/admin/(dashboard)/_state/keamanan/cctv';
|
||||||
|
import colors from '@/con/colors';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
Grid,
|
||||||
|
GridCol,
|
||||||
|
Group,
|
||||||
|
Pagination,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||||
|
import { IconCamera, IconClock, IconMapPin, IconSearch, IconVideo } from '@tabler/icons-react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import BackButton from '../../desa/layanan/_com/BackButto';
|
||||||
|
|
||||||
|
const CctvMapSection = dynamic(() => import('./_com/CctvMapSection'), { ssr: false });
|
||||||
|
|
||||||
|
function Page() {
|
||||||
|
const state = useProxy(cctvState);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 500);
|
||||||
|
const [allCctv, setAllCctv] = useState<CctvData[]>([]);
|
||||||
|
const [mapLoading, setMapLoading] = useState(true);
|
||||||
|
|
||||||
|
const { data, page, totalPages, loading } = state.findMany;
|
||||||
|
|
||||||
|
useShallowEffect(() => {
|
||||||
|
cctvState.findMany.search = debouncedSearch;
|
||||||
|
cctvState.findMany.limit = 9;
|
||||||
|
cctvState.findMany.load();
|
||||||
|
}, [page, debouncedSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/keamanan/cctv/find-many?limit=200&page=1')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((res) => {
|
||||||
|
if (res.success) setAllCctv(res.data ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setMapLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markersCount = allCctv.filter((c) => c.latitude != null && c.longitude != null).length;
|
||||||
|
const onlineCount = allCctv.filter((c) => c.status === 'Online').length;
|
||||||
|
const offlineCount = allCctv.filter((c) => c.status === 'Offline').length;
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<Box py={10}>
|
||||||
|
<Skeleton h={500} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack pos="relative" bg={colors.Bg} py="xl" gap={22}>
|
||||||
|
<Box px={{ base: 'md', md: 100 }}>
|
||||||
|
<BackButton />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Box>
|
||||||
|
<Grid align="center" px={{ base: 'md', md: 100 }}>
|
||||||
|
<GridCol span={{ base: 12, md: 9 }}>
|
||||||
|
<Title order={1} c={colors['blue-button']} lh={1.15}>
|
||||||
|
CCTV Keamanan Desa
|
||||||
|
</Title>
|
||||||
|
</GridCol>
|
||||||
|
<GridCol span={{ base: 12, md: 3 }}>
|
||||||
|
<TextInput
|
||||||
|
radius="lg"
|
||||||
|
placeholder="Cari kode, nama, atau lokasi..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftSection={<IconSearch size={20} />}
|
||||||
|
w="100%"
|
||||||
|
/>
|
||||||
|
</GridCol>
|
||||||
|
</Grid>
|
||||||
|
<Text
|
||||||
|
px={{ base: 'md', md: 100 }}
|
||||||
|
pt={16}
|
||||||
|
ta="justify"
|
||||||
|
fz={{ base: 'sm', md: 'md' }}
|
||||||
|
lh={1.55}
|
||||||
|
c="black"
|
||||||
|
>
|
||||||
|
Informasi titik-titik CCTV yang terpasang di wilayah Desa Darmasaba untuk mendukung
|
||||||
|
keamanan dan ketertiban lingkungan.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Map Section */}
|
||||||
|
<Box px={{ base: 'md', md: 100 }}>
|
||||||
|
<Paper withBorder radius="xl" shadow="md">
|
||||||
|
{/* Map Legend */}
|
||||||
|
<Group px="md" pt="md" pb="xs" justify="space-between" wrap="wrap" gap="xs">
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconVideo size={18} color={colors['blue-button']} />
|
||||||
|
<Text fw={600} fz="sm" c={colors['blue-button']}>
|
||||||
|
Peta Titik CCTV
|
||||||
|
</Text>
|
||||||
|
{markersCount > 0 && (
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
({markersCount} titik terpetakan)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="md">
|
||||||
|
<Group gap={6}>
|
||||||
|
<Box w={12} h={12} style={{ borderRadius: '50%', background: '#2f9e44', border: '2px solid white', boxShadow: '0 1px 3px rgba(0,0,0,0.3)' }} />
|
||||||
|
<Text fz="xs" c="dimmed">Online ({onlineCount})</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Box w={12} h={12} style={{ borderRadius: '50%', background: '#e03131', border: '2px solid white', boxShadow: '0 1px 3px rgba(0,0,0,0.3)' }} />
|
||||||
|
<Text fz="xs" c="dimmed">Offline ({offlineCount})</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<Box style={{ height: 420, position: 'relative' }}>
|
||||||
|
{mapLoading ? (
|
||||||
|
<Skeleton height={420} radius={0} />
|
||||||
|
) : markersCount === 0 ? (
|
||||||
|
<Center h={420}>
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<IconMapPin size={36} color="gray" opacity={0.4} />
|
||||||
|
<Text c="dimmed" fz="sm">Belum ada titik koordinat CCTV</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<CctvMapSection data={allCctv} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Card Grid */}
|
||||||
|
<Box px={{ base: 'md', md: 100 }}>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<Center py={60}>
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<IconCamera size={48} color={colors['blue-button']} opacity={0.4} />
|
||||||
|
<Text c="dimmed" fz="sm">Tidak ada data CCTV ditemukan</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<Grid gutter="xl">
|
||||||
|
{data.map((item) => (
|
||||||
|
<GridCol key={item.id} span={{ base: 12, sm: 6, md: 4 }}>
|
||||||
|
<Paper
|
||||||
|
radius="xl"
|
||||||
|
shadow="md"
|
||||||
|
withBorder
|
||||||
|
p="lg"
|
||||||
|
bg={colors['white-trans-1']}
|
||||||
|
h="100%"
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconCamera size={18} color={colors['blue-button']} />
|
||||||
|
<Text fz="sm" fw={700} c={colors['blue-button']}>
|
||||||
|
{item.kode}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Badge
|
||||||
|
color={item.status === 'Online' ? 'green' : 'red'}
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text fz="md" fw={600} c="dark.8" lh={1.3}>
|
||||||
|
{item.nama}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group gap="xs" align="flex-start">
|
||||||
|
<IconMapPin size={15} color="gray" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Text fz="sm" c="dimmed" lh={1.4}>
|
||||||
|
{item.lokasi}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="xs" mt="auto">
|
||||||
|
<IconClock size={13} color="gray" />
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{new Date(item.lastActive).toLocaleString('id-ID', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</GridCol>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Center mt="xl">
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
onChange={(newPage) => {
|
||||||
|
cctvState.findMany.page = newPage;
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
total={totalPages}
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
styles={{
|
||||||
|
control: {
|
||||||
|
border: `1px solid ${colors['blue-button']}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -168,6 +168,11 @@ const navbarListMenu = [
|
|||||||
id: "4.6",
|
id: "4.6",
|
||||||
name: "Tips Keamanan",
|
name: "Tips Keamanan",
|
||||||
href: "/darmasaba/keamanan/tips-keamanan"
|
href: "/darmasaba/keamanan/tips-keamanan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4.7",
|
||||||
|
name: "CCTV",
|
||||||
|
href: "/darmasaba/keamanan/cctv"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user