diff --git a/src/app/darmasaba/(pages)/keamanan/cctv/_com/CctvMapSection.tsx b/src/app/darmasaba/(pages)/keamanan/cctv/_com/CctvMapSection.tsx
new file mode 100644
index 00000000..2d6b7c13
--- /dev/null
+++ b/src/app/darmasaba/(pages)/keamanan/cctv/_com/CctvMapSection.tsx
@@ -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: `
`,
+ 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 (
+
+
+ {markers.map((cctv) => (
+
+
+
+
+ {cctv.kode}
+
+ {cctv.status}
+
+
+
{cctv.nama}
+
{cctv.lokasi}
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/app/darmasaba/(pages)/keamanan/cctv/page.tsx b/src/app/darmasaba/(pages)/keamanan/cctv/page.tsx
new file mode 100644
index 00000000..951fcbc7
--- /dev/null
+++ b/src/app/darmasaba/(pages)/keamanan/cctv/page.tsx
@@ -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([]);
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+ CCTV Keamanan Desa
+
+
+
+ setSearch(e.target.value)}
+ leftSection={}
+ w="100%"
+ />
+
+
+
+ Informasi titik-titik CCTV yang terpasang di wilayah Desa Darmasaba untuk mendukung
+ keamanan dan ketertiban lingkungan.
+
+
+
+ {/* Map Section */}
+
+
+ {/* Map Legend */}
+
+
+
+
+ Peta Titik CCTV
+
+ {markersCount > 0 && (
+
+ ({markersCount} titik terpetakan)
+
+ )}
+
+
+
+
+ Online ({onlineCount})
+
+
+
+ Offline ({offlineCount})
+
+
+
+
+ {/* Map */}
+
+ {mapLoading ? (
+
+ ) : markersCount === 0 ? (
+
+
+
+ Belum ada titik koordinat CCTV
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Card Grid */}
+
+ {data.length === 0 ? (
+
+
+
+ Tidak ada data CCTV ditemukan
+
+
+ ) : (
+
+ {data.map((item) => (
+
+
+
+
+
+
+ {item.kode}
+
+
+
+ {item.status}
+
+
+
+
+ {item.nama}
+
+
+
+
+
+ {item.lokasi}
+
+
+
+
+
+
+ {new Date(item.lastActive).toLocaleString('id-ID', {
+ day: '2-digit',
+ month: 'short',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {
+ cctvState.findMany.page = newPage;
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }}
+ total={totalPages}
+ size="lg"
+ radius="xl"
+ styles={{
+ control: {
+ border: `1px solid ${colors['blue-button']}`,
+ },
+ }}
+ />
+
+
+ );
+}
+
+export default Page;
diff --git a/src/con/navbar-list-menu.ts b/src/con/navbar-list-menu.ts
index f59ccfa5..428820f1 100644
--- a/src/con/navbar-list-menu.ts
+++ b/src/con/navbar-list-menu.ts
@@ -168,6 +168,11 @@ const navbarListMenu = [
id: "4.6",
name: "Tips Keamanan",
href: "/darmasaba/keamanan/tips-keamanan"
+ },
+ {
+ id: "4.7",
+ name: "CCTV",
+ href: "/darmasaba/keamanan/cctv"
}
]
},