diff --git a/package.json b/package.json
index ee5a8454..97246baa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "desa-darmasaba",
- "version": "0.1.57",
+ "version": "0.1.58",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/prisma/_seeder_list/keamanan/seed_cctv.ts b/prisma/_seeder_list/keamanan/seed_cctv.ts
new file mode 100644
index 00000000..7fa88d8a
--- /dev/null
+++ b/prisma/_seeder_list/keamanan/seed_cctv.ts
@@ -0,0 +1,35 @@
+import prisma from "@/lib/prisma";
+import { loadJsonData } from "../../load-json";
+
+const cctvData = loadJsonData("keamanan/cctv/cctv.json");
+
+export async function seedCctv() {
+ console.log("🔄 Seeding CCTV Keamanan...");
+
+ for (const c of cctvData) {
+ await prisma.cctvKeamanan.upsert({
+ where: { id: c.id },
+ update: {
+ kode: c.kode,
+ nama: c.nama,
+ lokasi: c.lokasi,
+ latitude: c.latitude ?? null,
+ longitude: c.longitude ?? null,
+ status: c.status,
+ lastActive: new Date(c.lastActive),
+ },
+ create: {
+ id: c.id,
+ kode: c.kode,
+ nama: c.nama,
+ lokasi: c.lokasi,
+ latitude: c.latitude ?? null,
+ longitude: c.longitude ?? null,
+ status: c.status,
+ lastActive: new Date(c.lastActive),
+ },
+ });
+ }
+
+ console.log(`✅ CCTV Keamanan seeded: ${cctvData.length} data`);
+}
diff --git a/prisma/data/keamanan/cctv/cctv.json b/prisma/data/keamanan/cctv/cctv.json
new file mode 100644
index 00000000..2ae933b2
--- /dev/null
+++ b/prisma/data/keamanan/cctv/cctv.json
@@ -0,0 +1,82 @@
+[
+ {
+ "id": "cctv_darmasaba_01",
+ "kode": "CCTV-01",
+ "nama": "Balai Desa",
+ "lokasi": "Jl. Raya Darmasaba, Depan Balai Desa",
+ "latitude": -8.5712,
+ "longitude": 115.1923,
+ "status": "Online",
+ "lastActive": "2026-02-12T14:30:00.000Z"
+ },
+ {
+ "id": "cctv_darmasaba_02",
+ "kode": "CCTV-02",
+ "nama": "Pintu Masuk Desa Utara",
+ "lokasi": "Jl. Raya Darmasaba, Pintu Masuk Utara",
+ "latitude": -8.5685,
+ "longitude": 115.1917,
+ "status": "Online",
+ "lastActive": "2026-02-12T13:45:00.000Z"
+ },
+ {
+ "id": "cctv_darmasaba_03",
+ "kode": "CCTV-03",
+ "nama": "Taman Desa",
+ "lokasi": "Area Taman Desa Darmasaba",
+ "latitude": -8.5730,
+ "longitude": 115.1935,
+ "status": "Offline",
+ "lastActive": "2026-02-11T09:00:00.000Z"
+ },
+ {
+ "id": "cctv_darmasaba_04",
+ "kode": "CCTV-04",
+ "nama": "Pasar Desa",
+ "lokasi": "Pasar Tradisional Darmasaba",
+ "latitude": -8.5698,
+ "longitude": 115.1945,
+ "status": "Online",
+ "lastActive": "2026-02-12T15:00:00.000Z"
+ },
+ {
+ "id": "cctv_darmasaba_05",
+ "kode": "CCTV-05",
+ "nama": "Pintu Masuk Desa Selatan",
+ "lokasi": "Jl. Raya Darmasaba, Pintu Masuk Selatan",
+ "latitude": -8.5755,
+ "longitude": 115.1920,
+ "status": "Online",
+ "lastActive": "2026-02-12T14:55:00.000Z"
+ },
+ {
+ "id": "cctv_darmasaba_06",
+ "kode": "CCTV-06",
+ "nama": "SD Negeri Darmasaba",
+ "lokasi": "Depan SD Negeri 1 Darmasaba",
+ "latitude": -8.5720,
+ "longitude": 115.1910,
+ "status": "Online",
+ "lastActive": "2026-02-12T12:30:00.000Z"
+ },
+ {
+ "id": "cctv_darmasaba_07",
+ "kode": "CCTV-07",
+ "nama": "Pura Desa",
+ "lokasi": "Area Pura Desa Darmasaba",
+ "latitude": -8.5708,
+ "longitude": 115.1950,
+ "status": "Offline",
+ "lastActive": "2026-02-10T18:00:00.000Z"
+ },
+ {
+ "id": "cctv_darmasaba_08",
+ "kode": "CCTV-08",
+ "nama": "Persimpangan Utama",
+ "lokasi": "Persimpangan Jl. Raya Darmasaba - Jl. Abiansemal",
+ "latitude": -8.5695,
+ "longitude": 115.1930,
+ "status": "Online",
+ "lastActive": "2026-02-12T15:10:00.000Z"
+ }
+]
diff --git a/prisma/migrations/20260506074407_add_cctv_keamanan_model/migration.sql b/prisma/migrations/20260506074407_add_cctv_keamanan_model/migration.sql
new file mode 100644
index 00000000..8acbef85
--- /dev/null
+++ b/prisma/migrations/20260506074407_add_cctv_keamanan_model/migration.sql
@@ -0,0 +1,23 @@
+-- CreateEnum
+CREATE TYPE "StatusCctv" AS ENUM ('Online', 'Offline');
+
+-- AlterEnum
+ALTER TYPE "StatusLaporan" ADD VALUE 'Baru';
+
+-- CreateTable
+CREATE TABLE "CctvKeamanan" (
+ "id" TEXT NOT NULL,
+ "kode" TEXT NOT NULL,
+ "nama" TEXT NOT NULL,
+ "lokasi" TEXT NOT NULL,
+ "latitude" DOUBLE PRECISION,
+ "longitude" DOUBLE PRECISION,
+ "status" "StatusCctv" NOT NULL DEFAULT 'Online',
+ "lastActive" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "deletedAt" TIMESTAMP(3),
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+
+ CONSTRAINT "CctvKeamanan_pkey" PRIMARY KEY ("id")
+);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f451099b..4f3f5712 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1395,11 +1395,33 @@ model PenangananLaporanPublik {
}
enum StatusLaporan {
+ Baru
Selesai
Proses
Gagal
}
+// ========================================= CCTV KEAMANAN ========================================= //
+enum StatusCctv {
+ Online
+ Offline
+}
+
+model CctvKeamanan {
+ id String @id @default(cuid())
+ kode String // e.g. "CCTV-01"
+ nama String // e.g. "Balai Desa"
+ lokasi String
+ latitude Float?
+ longitude Float?
+ status StatusCctv @default(Online)
+ lastActive DateTime @default(now())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ deletedAt DateTime?
+ isActive Boolean @default(true)
+}
+
model Pelapor {
id String @id @default(cuid())
nama String
diff --git a/prisma/seed.ts b/prisma/seed.ts
index c6b13fec..c0520f77 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -32,6 +32,7 @@ import { seedInfoTeknologi } from "./_seeder_list/inovasi/seed_info_teknologi";
import { seedKolaborasiInovasi } from "./_seeder_list/inovasi/seed_kolaborasi_inovasi";
import { seedLayananOnlineDesa } from "./_seeder_list/inovasi/seed_layanan_online_desa";
import { seedProgramKreatifDesa } from "./_seeder_list/inovasi/seed_program_kreatif_desa";
+import { seedCctv } from "./_seeder_list/keamanan/seed_cctv";
import { seedKeamananLingkungan } from "./_seeder_list/keamanan/seed_keamanan_lingkungan";
import { seedKontakDaruratKeamanan } from "./_seeder_list/keamanan/seed_kontak_darurat";
import { seedLaporanPublik } from "./_seeder_list/keamanan/seed_laporan_publik";
@@ -280,6 +281,8 @@ import seedAssets from "./seed_assets";
await seedPencegahanKriminalitas();
// // ==================== SUBMENU LAPORAN PUBLIK =================
await seedLaporanPublik();
+ // // ==================== SUBMENU CCTV KEAMANAN ==================
+ await seedCctv();
// // ==================== SUBMENU TIPS KEAMANAN ==================
await seedKeamananLingkungan();
diff --git a/src/app/admin/(dashboard)/_state/keamanan/cctv.ts b/src/app/admin/(dashboard)/_state/keamanan/cctv.ts
new file mode 100644
index 00000000..f43b4b7e
--- /dev/null
+++ b/src/app/admin/(dashboard)/_state/keamanan/cctv.ts
@@ -0,0 +1,239 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import ApiFetch from "@/lib/api-fetch";
+import { toast } from "react-toastify";
+import { proxy } from "valtio";
+import { z } from "zod";
+
+export type StatusCctv = "Online" | "Offline";
+
+export interface CctvData {
+ id: string;
+ kode: string;
+ nama: string;
+ lokasi: string;
+ latitude: number | null;
+ longitude: number | null;
+ status: StatusCctv;
+ lastActive: string;
+ createdAt: string;
+ updatedAt: string;
+ isActive: boolean;
+}
+
+const templateForm = z.object({
+ kode: z.string().min(1, "Kode CCTV wajib diisi"),
+ nama: z.string().min(1, "Nama CCTV wajib diisi"),
+ lokasi: z.string().min(1, "Lokasi wajib diisi"),
+});
+
+interface FormData {
+ kode: string;
+ nama: string;
+ lokasi: string;
+ latitude: string;
+ longitude: string;
+ status: StatusCctv;
+ lastActive: string;
+}
+
+const defaultForm: FormData = {
+ kode: "",
+ nama: "",
+ lokasi: "",
+ latitude: "",
+ longitude: "",
+ status: "Online",
+ lastActive: new Date().toISOString(),
+};
+
+const cctvState = proxy({
+ create: {
+ form: { ...defaultForm },
+ loading: false,
+ async create() {
+ const cek = templateForm.safeParse(cctvState.create.form);
+ if (!cek.success) {
+ const err = `[${cek.error.issues.map((v) => v.path.join(".")).join("\n")}] required`;
+ return toast.error(err);
+ }
+ try {
+ cctvState.create.loading = true;
+ const form = cctvState.create.form;
+ const res = await ApiFetch.api.keamanan.cctv["create"].post({
+ kode: form.kode,
+ nama: form.nama,
+ lokasi: form.lokasi,
+ latitude: form.latitude ? Number(form.latitude) : undefined,
+ longitude: form.longitude ? Number(form.longitude) : undefined,
+ status: form.status,
+ lastActive: form.lastActive,
+ });
+ if (res.error) throw new Error("Failed to create CCTV");
+ if (res.status === 200) {
+ await cctvState.findMany.load();
+ return toast.success("CCTV berhasil ditambahkan");
+ }
+ return toast.error("Gagal menambahkan CCTV");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Gagal membuat CCTV");
+ } finally {
+ cctvState.create.loading = false;
+ }
+ },
+ resetForm() {
+ cctvState.create.form = { ...defaultForm };
+ },
+ },
+
+ findMany: {
+ data: null as CctvData[] | null,
+ loading: false,
+ page: 1,
+ limit: 10,
+ totalPages: 1,
+ search: "",
+ async load() {
+ try {
+ cctvState.findMany.loading = true;
+ const res = await ApiFetch.api.keamanan.cctv["find-many"].get({
+ query: {
+ page: String(cctvState.findMany.page),
+ limit: String(cctvState.findMany.limit),
+ search: cctvState.findMany.search,
+ },
+ });
+ if (res.data?.success) {
+ cctvState.findMany.data = (res.data.data as any) ?? [];
+ cctvState.findMany.totalPages = res.data.totalPages ?? 1;
+ } else {
+ cctvState.findMany.data = [];
+ cctvState.findMany.totalPages = 1;
+ }
+ } catch (err) {
+ console.error("Gagal fetch CCTV:", err);
+ cctvState.findMany.data = [];
+ cctvState.findMany.totalPages = 1;
+ } finally {
+ cctvState.findMany.loading = false;
+ }
+ },
+ },
+
+ findUnique: {
+ data: null as CctvData | null,
+ loading: false,
+ async load(id: string) {
+ if (!id) return null;
+ try {
+ cctvState.findUnique.loading = true;
+ const res = await ApiFetch.api.keamanan.cctv({ id }).get();
+ if (res.data?.success) {
+ cctvState.findUnique.data = res.data.data as any;
+ }
+ return res.data?.data ?? null;
+ } catch (err) {
+ console.error("Gagal fetch CCTV by id:", err);
+ return null;
+ } finally {
+ cctvState.findUnique.loading = false;
+ }
+ },
+ },
+
+ delete: {
+ loading: false,
+ async remove(id: string) {
+ try {
+ cctvState.delete.loading = true;
+ const response = await fetch(`/api/keamanan/cctv/del/${id}`, {
+ method: "DELETE",
+ });
+ const result = await response.json();
+ if (response.ok && result?.success) {
+ toast.success(result.message || "CCTV berhasil dihapus");
+ await cctvState.findMany.load();
+ } else {
+ toast.error(result?.message || "Gagal menghapus CCTV");
+ }
+ } catch (error) {
+ console.error("Gagal delete CCTV:", error);
+ toast.error("Terjadi kesalahan saat menghapus CCTV");
+ } finally {
+ cctvState.delete.loading = false;
+ }
+ },
+ },
+
+ edit: {
+ id: "",
+ form: { ...defaultForm },
+ loading: false,
+ async load(id: string) {
+ if (!id) return null;
+ const data = await cctvState.findUnique.load(id);
+ if (data) {
+ cctvState.edit.id = id;
+ cctvState.edit.form = {
+ kode: (data as any).kode ?? "",
+ nama: (data as any).nama ?? "",
+ lokasi: (data as any).lokasi ?? "",
+ latitude: (data as any).latitude != null ? String((data as any).latitude) : "",
+ longitude: (data as any).longitude != null ? String((data as any).longitude) : "",
+ status: (data as any).status ?? "Online",
+ lastActive: (data as any).lastActive ?? new Date().toISOString(),
+ };
+ }
+ return data;
+ },
+ async update() {
+ const cek = templateForm.safeParse(cctvState.edit.form);
+ if (!cek.success) {
+ const err = `[${cek.error.issues.map((v) => v.path.join(".")).join("\n")}] required`;
+ return toast.error(err);
+ }
+ try {
+ cctvState.edit.loading = true;
+ const form = cctvState.edit.form;
+ const res = await ApiFetch.api.keamanan.cctv({ id: cctvState.edit.id }).put({
+ kode: form.kode,
+ nama: form.nama,
+ lokasi: form.lokasi,
+ latitude: form.latitude ? Number(form.latitude) : undefined,
+ longitude: form.longitude ? Number(form.longitude) : undefined,
+ status: form.status,
+ lastActive: form.lastActive,
+ });
+ if (res.error) throw new Error("Failed to update CCTV");
+ if (res.status === 200) {
+ await cctvState.findMany.load();
+ return toast.success("CCTV berhasil diperbarui");
+ }
+ return toast.error("Gagal memperbarui CCTV");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Gagal update CCTV");
+ } finally {
+ cctvState.edit.loading = false;
+ }
+ },
+ },
+
+ stats: {
+ data: null as { cctvOnline: number; laporanMingguIni: number } | null,
+ loading: false,
+ async load() {
+ try {
+ cctvState.stats.loading = true;
+ const res = await ApiFetch.api.keamanan.cctv["stats"].get();
+ if (res.data?.success) {
+ cctvState.stats.data = res.data.data as any;
+ }
+ } catch (err) {
+ console.error("Gagal fetch CCTV stats:", err);
+ } finally {
+ cctvState.stats.loading = false;
+ }
+ },
+ },
+});
+
+export default cctvState;
diff --git a/src/app/admin/(dashboard)/keamanan/cctv/[id]/edit/page.tsx b/src/app/admin/(dashboard)/keamanan/cctv/[id]/edit/page.tsx
new file mode 100644
index 00000000..c5c0b549
--- /dev/null
+++ b/src/app/admin/(dashboard)/keamanan/cctv/[id]/edit/page.tsx
@@ -0,0 +1,190 @@
+'use client'
+import colors from '@/con/colors';
+import {
+ Box,
+ Button,
+ Group,
+ Loader,
+ Paper,
+ Select,
+ Skeleton,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+} from '@mantine/core';
+import { DateTimePicker } from '@mantine/dates';
+import { IconArrowBack, IconMapPin } from '@tabler/icons-react';
+import dynamic from 'next/dynamic';
+import { useParams, useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+import { useProxy } from 'valtio/utils';
+import cctvState from '../../../../_state/keamanan/cctv';
+
+const DEFAULT_CENTER = { lat: -8.5712, lng: 115.1923 };
+
+const LeafletMapEdit = dynamic(
+ () => import('../../../../_com/leafletMapEdit'),
+ { ssr: false, loading: () => }
+);
+
+function EditCctv() {
+ const router = useRouter();
+ const state = useProxy(cctvState);
+ const params = useParams();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+
+ if (!loaded) {
+ setLoaded(true);
+ cctvState.edit.load(params?.id as string);
+ }
+
+ const isFormValid = () => {
+ const f = state.edit.form;
+ return f.kode.trim() !== '' && f.nama.trim() !== '' && f.lokasi.trim() !== '';
+ };
+
+ const mapCenter = {
+ lat: state.edit.form.latitude ? Number(state.edit.form.latitude) : DEFAULT_CENTER.lat,
+ lng: state.edit.form.longitude ? Number(state.edit.form.longitude) : DEFAULT_CENTER.lng,
+ };
+
+ const hasCoord = !!state.edit.form.latitude && !!state.edit.form.longitude;
+
+ const handleMapChange = (pos: { lat: number; lng: number }) => {
+ cctvState.edit.form.latitude = String(pos.lat);
+ cctvState.edit.form.longitude = String(pos.lng);
+ };
+
+ const handleSubmit = async () => {
+ try {
+ setIsSubmitting(true);
+ cctvState.edit.id = params?.id as string;
+ await cctvState.edit.update();
+ router.push(`/admin/keamanan/cctv/${params?.id}`);
+ } catch (error) {
+ console.error('Gagal update CCTV:', error);
+ toast.error('Gagal memperbarui CCTV');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (state.edit.loading && !state.edit.form.kode) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Edit CCTV
+
+
+
+
+ Kode CCTV}
+ placeholder="Contoh: CCTV-01"
+ value={state.edit.form.kode}
+ onChange={(e) => { cctvState.edit.form.kode = e.currentTarget.value; }}
+ required
+ />
+
+ Nama / Deskripsi}
+ placeholder="Contoh: Balai Desa"
+ value={state.edit.form.nama}
+ onChange={(e) => { cctvState.edit.form.nama = e.currentTarget.value; }}
+ required
+ />
+
+ Lokasi}
+ placeholder="Contoh: Jl. Raya Darmasaba No. 1"
+ value={state.edit.form.lokasi}
+ onChange={(e) => { cctvState.edit.form.lokasi = e.currentTarget.value; }}
+ required
+ />
+
+
+
+ Titik Lokasi di Peta
+ (klik pada peta untuk memindahkan posisi)
+
+
+
+
+ {hasCoord && (
+
+
+
+ Posisi: {Number(state.edit.form.latitude).toFixed(6)}, {Number(state.edit.form.longitude).toFixed(6)}
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default EditCctv;
diff --git a/src/app/admin/(dashboard)/keamanan/cctv/[id]/page.tsx b/src/app/admin/(dashboard)/keamanan/cctv/[id]/page.tsx
new file mode 100644
index 00000000..e63ed1d7
--- /dev/null
+++ b/src/app/admin/(dashboard)/keamanan/cctv/[id]/page.tsx
@@ -0,0 +1,160 @@
+'use client'
+import colors from '@/con/colors';
+import {
+ Badge,
+ Box,
+ Button,
+ Group,
+ Paper,
+ Skeleton,
+ Stack,
+ Text,
+ Title,
+} from '@mantine/core';
+import { useShallowEffect } from '@mantine/hooks';
+import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
+import dynamic from 'next/dynamic';
+import { useParams, useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { useProxy } from 'valtio/utils';
+import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
+import cctvState from '../../../_state/keamanan/cctv';
+
+const LeafletMap = dynamic(
+ () => import('../../../_com/leafletMapCreate'),
+ { ssr: false, loading: () => }
+);
+
+function DetailCctv() {
+ const [modalHapus, setModalHapus] = useState(false);
+ const state = useProxy(cctvState);
+ const params = useParams();
+ const router = useRouter();
+
+ useShallowEffect(() => {
+ cctvState.findUnique.load(params?.id as string);
+ }, []);
+
+ const handleDelete = async () => {
+ if (params?.id) {
+ await cctvState.delete.remove(params.id as string);
+ setModalHapus(false);
+ router.push('/admin/keamanan/cctv');
+ }
+ };
+
+ if (state.findUnique.loading || !state.findUnique.data) {
+ return (
+
+
+
+ );
+ }
+
+ const data = state.findUnique.data;
+
+ return (
+
+
+
+ Detail CCTV
+
+
+
+
+
+ {data.kode}
+
+ {data.status}
+
+
+
+
+ Nama
+ {data.nama}
+
+
+
+ Lokasi
+ {data.lokasi}
+
+
+ {data.latitude != null && data.longitude != null && (
+
+ Lokasi di Peta
+
+
+
+
+ )}
+
+
+ Terakhir Aktif
+
+ {new Date(data.lastActive).toLocaleString('id-ID', {
+ weekday: 'long', day: '2-digit', month: 'long',
+ year: 'numeric', hour: '2-digit', minute: '2-digit',
+ })}
+
+
+
+
+ Dibuat
+
+ {new Date(data.createdAt).toLocaleDateString('id-ID', {
+ day: '2-digit', month: 'long', year: 'numeric',
+ })}
+
+
+
+
+
+
+
+
+
+
+ setModalHapus(false)}
+ onConfirm={handleDelete}
+ loading={state.delete.loading}
+ text="Apakah anda yakin ingin menghapus CCTV ini?"
+ />
+
+ );
+}
+
+export default DetailCctv;
diff --git a/src/app/admin/(dashboard)/keamanan/cctv/create/page.tsx b/src/app/admin/(dashboard)/keamanan/cctv/create/page.tsx
new file mode 100644
index 00000000..762c87ef
--- /dev/null
+++ b/src/app/admin/(dashboard)/keamanan/cctv/create/page.tsx
@@ -0,0 +1,177 @@
+'use client'
+import colors from '@/con/colors';
+import {
+ Box,
+ Button,
+ Group,
+ Loader,
+ Paper,
+ Select,
+ Skeleton,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+} from '@mantine/core';
+import { DateTimePicker } from '@mantine/dates';
+import { IconArrowBack, IconMapPin } from '@tabler/icons-react';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+import { useProxy } from 'valtio/utils';
+import cctvState from '../../../_state/keamanan/cctv';
+
+// Darmasaba default center
+const DEFAULT_CENTER = { lat: -8.5712, lng: 115.1923 };
+
+const LeafletMap = dynamic(
+ () => import('../../../_com/leafletMapCreate'),
+ { ssr: false, loading: () => }
+);
+
+function CreateCctv() {
+ const router = useRouter();
+ const state = useProxy(cctvState);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [markerSet, setMarkerSet] = useState(false);
+
+ const isFormValid = () => {
+ const f = state.create.form;
+ return f.kode.trim() !== '' && f.nama.trim() !== '' && f.lokasi.trim() !== '';
+ };
+
+ const handleMapSelect = (pos: { lat: number; lng: number }) => {
+ cctvState.create.form.latitude = String(pos.lat);
+ cctvState.create.form.longitude = String(pos.lng);
+ setMarkerSet(true);
+ };
+
+ const handleSubmit = async () => {
+ try {
+ setIsSubmitting(true);
+ await cctvState.create.create();
+ cctvState.create.resetForm();
+ setMarkerSet(false);
+ router.push('/admin/keamanan/cctv');
+ } catch (error) {
+ console.error('Gagal menambahkan CCTV:', error);
+ toast.error('Gagal menambahkan CCTV');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ Tambah CCTV
+
+
+
+
+ Kode CCTV}
+ placeholder="Contoh: CCTV-01"
+ value={state.create.form.kode}
+ onChange={(e) => { cctvState.create.form.kode = e.currentTarget.value; }}
+ required
+ />
+
+ Nama / Deskripsi}
+ placeholder="Contoh: Balai Desa"
+ value={state.create.form.nama}
+ onChange={(e) => { cctvState.create.form.nama = e.currentTarget.value; }}
+ required
+ />
+
+ Lokasi}
+ placeholder="Contoh: Jl. Raya Darmasaba No. 1"
+ value={state.create.form.lokasi}
+ onChange={(e) => { cctvState.create.form.lokasi = e.currentTarget.value; }}
+ required
+ />
+
+
+
+ Titik Lokasi di Peta
+ (klik pada peta untuk menentukan posisi)
+
+
+
+
+ {markerSet && (
+
+
+
+ Posisi dipilih: {Number(state.create.form.latitude).toFixed(6)}, {Number(state.create.form.longitude).toFixed(6)}
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default CreateCctv;
diff --git a/src/app/admin/(dashboard)/keamanan/cctv/page.tsx b/src/app/admin/(dashboard)/keamanan/cctv/page.tsx
new file mode 100644
index 00000000..27432b11
--- /dev/null
+++ b/src/app/admin/(dashboard)/keamanan/cctv/page.tsx
@@ -0,0 +1,215 @@
+'use client'
+import colors from '@/con/colors';
+import {
+ Badge,
+ Box,
+ Button,
+ Center,
+ Group,
+ Pagination,
+ Paper,
+ Skeleton,
+ Stack,
+ Table,
+ TableTbody,
+ TableTd,
+ TableTh,
+ TableThead,
+ TableTr,
+ Text,
+ Title,
+} from '@mantine/core';
+import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
+import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { useProxy } from 'valtio/utils';
+import HeaderSearch from '../../_com/header';
+import cctvState from '../../_state/keamanan/cctv';
+
+function CctvPage() {
+ const [search, setSearch] = useState('');
+
+ return (
+
+ }
+ value={search}
+ onChange={(e) => setSearch(e.currentTarget.value)}
+ />
+
+
+ );
+}
+
+function ListCctv({ search }: { search: string }) {
+ const state = useProxy(cctvState);
+ const router = useRouter();
+ const [debouncedSearch] = useDebouncedValue(search, 500);
+
+ const { data, page, totalPages, loading } = state.findMany;
+
+ useShallowEffect(() => {
+ cctvState.findMany.search = debouncedSearch;
+ cctvState.findMany.load();
+ }, [page, debouncedSearch]);
+
+ if (loading || !data) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Daftar CCTV
+ }
+ color="blue"
+ variant="light"
+ onClick={() => router.push('/admin/keamanan/cctv/create')}
+ >
+ Tambah CCTV
+
+
+
+ {/* Desktop Table */}
+
+
+
+
+ Kode
+ Nama
+ Lokasi
+ Status
+ Terakhir Aktif
+ Aksi
+
+
+
+ {data.length > 0 ? (
+ data.map((item) => (
+
+
+ {item.kode}
+
+
+ {item.nama}
+
+
+ {item.lokasi}
+
+
+
+ {item.status}
+
+
+
+
+ {new Date(item.lastActive).toLocaleString('id-ID', {
+ day: '2-digit', month: 'short', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
+ })}
+
+
+
+
+
+
+ ))
+ ) : (
+
+
+
+ Tidak ada data CCTV
+
+
+
+ )}
+
+
+
+
+ {/* Mobile Card */}
+
+ {data.length > 0 ? (
+ data.map((item) => (
+
+
+
+ {item.kode}
+
+ {item.status}
+
+
+ {item.nama}
+ {item.lokasi}
+
+ Terakhir aktif:{' '}
+ {new Date(item.lastActive).toLocaleString('id-ID', {
+ day: '2-digit', month: 'short', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
+ })}
+
+
+
+
+ ))
+ ) : (
+
+ Tidak ada data CCTV
+
+ )}
+
+
+
+
+ {
+ cctvState.findMany.page = newPage;
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }}
+ total={totalPages}
+ mt="md"
+ mb="md"
+ color="blue"
+ radius="md"
+ />
+
+
+ );
+}
+
+export default CctvPage;
diff --git a/src/app/admin/_com/list_PageAdmin.tsx b/src/app/admin/_com/list_PageAdmin.tsx
index 60846531..51de5e91 100644
--- a/src/app/admin/_com/list_PageAdmin.tsx
+++ b/src/app/admin/_com/list_PageAdmin.tsx
@@ -208,6 +208,11 @@ export const devBar = [
id: "Keamanan_6",
name: "Tips Keamanan",
path: "/admin/keamanan/tips-keamanan"
+ },
+ {
+ id: "Keamanan_7",
+ name: "CCTV",
+ path: "/admin/keamanan/cctv"
}
]
},
@@ -649,6 +654,11 @@ export const navBar = [
id: "Keamanan_6",
name: "Tips Keamanan",
path: "/admin/keamanan/tips-keamanan"
+ },
+ {
+ id: "Keamanan_7",
+ name: "CCTV",
+ path: "/admin/keamanan/cctv"
}
]
},
@@ -1063,6 +1073,11 @@ export const role1 = [
id: "Keamanan_6",
name: "Tips Keamanan",
path: "/admin/keamanan/tips-keamanan"
+ },
+ {
+ id: "Keamanan_7",
+ name: "CCTV",
+ path: "/admin/keamanan/cctv"
}
]
},
diff --git a/src/app/api/[[...slugs]]/_lib/keamanan/cctv/create.ts b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/create.ts
new file mode 100644
index 00000000..17ed993b
--- /dev/null
+++ b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/create.ts
@@ -0,0 +1,33 @@
+import prisma from "@/lib/prisma";
+import { Context } from "elysia";
+
+type CctvInput = {
+ kode: string;
+ nama: string;
+ lokasi: string;
+ latitude?: number;
+ longitude?: number;
+ status?: "Online" | "Offline";
+ lastActive?: string;
+};
+
+const cctvCreate = async (context: Context) => {
+ const { kode, nama, lokasi, latitude, longitude, status, lastActive } =
+ (await context.body) as CctvInput;
+
+ const data = await prisma.cctvKeamanan.create({
+ data: {
+ kode,
+ nama,
+ lokasi,
+ latitude,
+ longitude,
+ status: status ?? "Online",
+ lastActive: lastActive ? new Date(lastActive) : new Date(),
+ },
+ });
+
+ return { success: true, data };
+};
+
+export default cctvCreate;
diff --git a/src/app/api/[[...slugs]]/_lib/keamanan/cctv/del.ts b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/del.ts
new file mode 100644
index 00000000..563b1aa8
--- /dev/null
+++ b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/del.ts
@@ -0,0 +1,26 @@
+import prisma from "@/lib/prisma";
+import { Context } from "elysia";
+
+const cctvDelete = async (context: Context) => {
+ const id = context.params.id as string;
+
+ try {
+ const cctv = await prisma.cctvKeamanan.findUnique({ where: { id } });
+
+ if (!cctv) {
+ return { success: false, message: "CCTV tidak ditemukan" };
+ }
+
+ await prisma.cctvKeamanan.update({
+ where: { id },
+ data: { isActive: false, deletedAt: new Date() },
+ });
+
+ return { success: true, message: "CCTV berhasil dihapus" };
+ } catch (error) {
+ console.error("Gagal delete CCTV:", error);
+ return { success: false, message: "Terjadi kesalahan saat menghapus CCTV" };
+ }
+};
+
+export default cctvDelete;
diff --git a/src/app/api/[[...slugs]]/_lib/keamanan/cctv/findMany.ts b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/findMany.ts
new file mode 100644
index 00000000..70ec2e11
--- /dev/null
+++ b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/findMany.ts
@@ -0,0 +1,47 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import prisma from "@/lib/prisma";
+import { Context } from "elysia";
+
+async function cctvFindMany(context: Context) {
+ const page = Number(context.query.page) || 1;
+ const limit = Number(context.query.limit) || 10;
+ const search = (context.query.search as string) || "";
+ const skip = (page - 1) * limit;
+
+ const where: any = { isActive: true };
+
+ if (search) {
+ where.OR = [
+ { kode: { contains: search, mode: "insensitive" } },
+ { nama: { contains: search, mode: "insensitive" } },
+ { lokasi: { contains: search, mode: "insensitive" } },
+ ];
+ }
+
+ try {
+ const [data, total] = await Promise.all([
+ prisma.cctvKeamanan.findMany({
+ where,
+ skip,
+ take: limit,
+ orderBy: { kode: "asc" },
+ }),
+ prisma.cctvKeamanan.count({ where }),
+ ]);
+
+ return {
+ success: true,
+ message: "Berhasil ambil data CCTV",
+ data,
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit),
+ };
+ } catch (e) {
+ console.error("Error di cctvFindMany:", e);
+ return { success: false, message: "Gagal mengambil data CCTV" };
+ }
+}
+
+export default cctvFindMany;
diff --git a/src/app/api/[[...slugs]]/_lib/keamanan/cctv/findUnique.ts b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/findUnique.ts
new file mode 100644
index 00000000..f0ccc17a
--- /dev/null
+++ b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/findUnique.ts
@@ -0,0 +1,16 @@
+import prisma from "@/lib/prisma";
+import { Context } from "elysia";
+
+const cctvFindUnique = async (context: Context) => {
+ const id = context.params.id as string;
+
+ const data = await prisma.cctvKeamanan.findUnique({ where: { id } });
+
+ if (!data) {
+ return { success: false, message: "CCTV tidak ditemukan" };
+ }
+
+ return { success: true, data };
+};
+
+export default cctvFindUnique;
diff --git a/src/app/api/[[...slugs]]/_lib/keamanan/cctv/index.ts b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/index.ts
new file mode 100644
index 00000000..f4d1e14b
--- /dev/null
+++ b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/index.ts
@@ -0,0 +1,40 @@
+import Elysia, { t } from "elysia";
+import cctvCreate from "./create";
+import cctvFindMany from "./findMany";
+import cctvFindUnique from "./findUnique";
+import cctvUpdate from "./updt";
+import cctvDelete from "./del";
+import cctvStats from "./stats";
+
+const CctvKeamanan = new Elysia({
+ prefix: "cctv",
+ tags: ["Keamanan/CCTV"],
+})
+ .get("/stats", cctvStats)
+ .get("/find-many", cctvFindMany)
+ .get("/:id", cctvFindUnique)
+ .post("/create", cctvCreate, {
+ body: t.Object({
+ kode: t.String(),
+ nama: t.String(),
+ lokasi: t.String(),
+ latitude: t.Optional(t.Number()),
+ longitude: t.Optional(t.Number()),
+ status: t.Optional(t.Union([t.Literal("Online"), t.Literal("Offline")])),
+ lastActive: t.Optional(t.String()),
+ }),
+ })
+ .put("/:id", cctvUpdate, {
+ body: t.Object({
+ kode: t.String(),
+ nama: t.String(),
+ lokasi: t.String(),
+ latitude: t.Optional(t.Number()),
+ longitude: t.Optional(t.Number()),
+ status: t.Union([t.Literal("Online"), t.Literal("Offline")]),
+ lastActive: t.Optional(t.String()),
+ }),
+ })
+ .delete("/del/:id", cctvDelete);
+
+export default CctvKeamanan;
diff --git a/src/app/api/[[...slugs]]/_lib/keamanan/cctv/stats.ts b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/stats.ts
new file mode 100644
index 00000000..043a1f5c
--- /dev/null
+++ b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/stats.ts
@@ -0,0 +1,32 @@
+import prisma from "@/lib/prisma";
+
+const cctvStats = async () => {
+ const now = new Date();
+ const startOfWeek = new Date(now);
+ startOfWeek.setDate(now.getDate() - now.getDay());
+ startOfWeek.setHours(0, 0, 0, 0);
+
+ try {
+ const [cctvOnline, laporanMingguIni] = await Promise.all([
+ prisma.cctvKeamanan.count({
+ where: { isActive: true, status: "Online" },
+ }),
+ prisma.laporanPublik.count({
+ where: {
+ isActive: true,
+ tanggalWaktu: { gte: startOfWeek },
+ },
+ }),
+ ]);
+
+ return {
+ success: true,
+ data: { cctvOnline, laporanMingguIni },
+ };
+ } catch (e) {
+ console.error("Gagal ambil stats keamanan:", e);
+ return { success: false, message: "Gagal mengambil statistik keamanan" };
+ }
+};
+
+export default cctvStats;
diff --git a/src/app/api/[[...slugs]]/_lib/keamanan/cctv/updt.ts b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/updt.ts
new file mode 100644
index 00000000..4946d84d
--- /dev/null
+++ b/src/app/api/[[...slugs]]/_lib/keamanan/cctv/updt.ts
@@ -0,0 +1,40 @@
+import prisma from "@/lib/prisma";
+import { Context } from "elysia";
+
+type CctvUpdateInput = {
+ kode: string;
+ nama: string;
+ lokasi: string;
+ latitude?: number;
+ longitude?: number;
+ status: "Online" | "Offline";
+ lastActive?: string;
+};
+
+const cctvUpdate = async (context: Context) => {
+ const id = context.params.id as string;
+ const { kode, nama, lokasi, latitude, longitude, status, lastActive } =
+ (await context.body) as CctvUpdateInput;
+
+ try {
+ const data = await prisma.cctvKeamanan.update({
+ where: { id },
+ data: {
+ kode,
+ nama,
+ lokasi,
+ latitude,
+ longitude,
+ status,
+ lastActive: lastActive ? new Date(lastActive) : undefined,
+ },
+ });
+
+ return { success: true, data };
+ } catch (e) {
+ console.error("Gagal update CCTV:", e);
+ return { success: false, message: "Gagal memperbarui CCTV" };
+ }
+};
+
+export default cctvUpdate;
diff --git a/src/app/api/[[...slugs]]/_lib/keamanan/index.ts b/src/app/api/[[...slugs]]/_lib/keamanan/index.ts
index 428de450..a69d5ce4 100644
--- a/src/app/api/[[...slugs]]/_lib/keamanan/index.ts
+++ b/src/app/api/[[...slugs]]/_lib/keamanan/index.ts
@@ -4,6 +4,7 @@ import PolsekTerdekat from "./polsek-terdekat";
import PencegahanKriminalitas from "./pencegahan-kriminalitas";
import MenuTipsKeamanan from "./tips-keamanan";
import LaporanPublik from "./laporan-publik";
+import CctvKeamanan from "./cctv";
import KontakDaruratKeamanan from "./kontak-darurat-keamanan";
import KontakItem from "./kontak-darurat-keamanan/kontak-item";
@@ -15,6 +16,7 @@ const Keamanan = new Elysia({ prefix: "/keamanan", tags: ["Keamanan"] })
.use(PencegahanKriminalitas)
.use(MenuTipsKeamanan)
.use(LaporanPublik)
+.use(CctvKeamanan)
.use(LayananPolsek)
.use(KontakDaruratKeamanan)
.use(KontakItem)