merge: feat(keamanan) tambah modul CCTV — schema, API, admin UI, seeder

This commit is contained in:
2026-05-06 16:40:35 +08:00
20 changed files with 1398 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "desa-darmasaba", "name": "desa-darmasaba",
"version": "0.1.57", "version": "0.1.58",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -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`);
}

View File

@@ -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"
}
]

View File

@@ -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")
);

View File

@@ -1395,11 +1395,33 @@ model PenangananLaporanPublik {
} }
enum StatusLaporan { enum StatusLaporan {
Baru
Selesai Selesai
Proses Proses
Gagal 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 { model Pelapor {
id String @id @default(cuid()) id String @id @default(cuid())
nama String nama String

View File

@@ -32,6 +32,7 @@ import { seedInfoTeknologi } from "./_seeder_list/inovasi/seed_info_teknologi";
import { seedKolaborasiInovasi } from "./_seeder_list/inovasi/seed_kolaborasi_inovasi"; import { seedKolaborasiInovasi } from "./_seeder_list/inovasi/seed_kolaborasi_inovasi";
import { seedLayananOnlineDesa } from "./_seeder_list/inovasi/seed_layanan_online_desa"; import { seedLayananOnlineDesa } from "./_seeder_list/inovasi/seed_layanan_online_desa";
import { seedProgramKreatifDesa } from "./_seeder_list/inovasi/seed_program_kreatif_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 { seedKeamananLingkungan } from "./_seeder_list/keamanan/seed_keamanan_lingkungan";
import { seedKontakDaruratKeamanan } from "./_seeder_list/keamanan/seed_kontak_darurat"; import { seedKontakDaruratKeamanan } from "./_seeder_list/keamanan/seed_kontak_darurat";
import { seedLaporanPublik } from "./_seeder_list/keamanan/seed_laporan_publik"; import { seedLaporanPublik } from "./_seeder_list/keamanan/seed_laporan_publik";
@@ -280,6 +281,8 @@ import seedAssets from "./seed_assets";
await seedPencegahanKriminalitas(); await seedPencegahanKriminalitas();
// // ==================== SUBMENU LAPORAN PUBLIK ================= // // ==================== SUBMENU LAPORAN PUBLIK =================
await seedLaporanPublik(); await seedLaporanPublik();
// // ==================== SUBMENU CCTV KEAMANAN ==================
await seedCctv();
// // ==================== SUBMENU TIPS KEAMANAN ================== // // ==================== SUBMENU TIPS KEAMANAN ==================
await seedKeamananLingkungan(); await seedKeamananLingkungan();

View File

@@ -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;

View File

@@ -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: () => <Skeleton height={300} radius="md" /> }
);
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 (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">Edit CCTV</Title>
</Group>
<Paper
w={{ base: '100%', md: '55%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label={<Text fw="bold" fz="sm">Kode CCTV</Text>}
placeholder="Contoh: CCTV-01"
value={state.edit.form.kode}
onChange={(e) => { cctvState.edit.form.kode = e.currentTarget.value; }}
required
/>
<TextInput
label={<Text fw="bold" fz="sm">Nama / Deskripsi</Text>}
placeholder="Contoh: Balai Desa"
value={state.edit.form.nama}
onChange={(e) => { cctvState.edit.form.nama = e.currentTarget.value; }}
required
/>
<TextInput
label={<Text fw="bold" fz="sm">Lokasi</Text>}
placeholder="Contoh: Jl. Raya Darmasaba No. 1"
value={state.edit.form.lokasi}
onChange={(e) => { cctvState.edit.form.lokasi = e.currentTarget.value; }}
required
/>
<Box>
<Group mb={6} gap={6}>
<Text fw="bold" fz="sm">Titik Lokasi di Peta</Text>
<Text fz="xs" c="dimmed">(klik pada peta untuk memindahkan posisi)</Text>
</Group>
<Box style={{ height: 300, borderRadius: 8, overflow: 'hidden', border: '1px solid #e0e0e0' }}>
<LeafletMapEdit
initialPosition={mapCenter}
onChange={handleMapChange}
/>
</Box>
{hasCoord && (
<Group mt={6} gap={4}>
<IconMapPin size={14} color="green" />
<Text fz="xs" c="green">
Posisi: {Number(state.edit.form.latitude).toFixed(6)}, {Number(state.edit.form.longitude).toFixed(6)}
</Text>
</Group>
)}
</Box>
<Select
label={<Text fw="bold" fz="sm">Status</Text>}
value={state.edit.form.status}
onChange={(val) => { cctvState.edit.form.status = (val as 'Online' | 'Offline') ?? 'Online'; }}
data={[
{ value: 'Online', label: 'Online' },
{ value: 'Offline', label: 'Offline' },
]}
required
/>
<DateTimePicker
label={<Text fw="bold" fz="sm">Terakhir Aktif</Text>}
value={state.edit.form.lastActive ? new Date(state.edit.form.lastActive) : new Date()}
onChange={(val) => {
cctvState.edit.form.lastActive = val ? new Date(val).toISOString() : new Date().toISOString();
}}
/>
<Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={() => router.back()}>
Batal
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79,172,254,0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditCctv;

View File

@@ -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: () => <Skeleton height={260} radius="md" /> }
);
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 (
<Stack py={10}>
<Skeleton height={400} radius="md" />
</Stack>
);
}
const data = state.findUnique.data;
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">Detail CCTV</Title>
</Group>
<Paper
w={{ base: '100%', md: '55%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Group justify="space-between">
<Text fz="xl" fw="bold">{data.kode}</Text>
<Badge
color={data.status === 'Online' ? 'green' : 'red'}
variant="light"
size="lg"
radius="sm"
>
{data.status}
</Badge>
</Group>
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={2}>Nama</Text>
<Text fz="md">{data.nama}</Text>
</Box>
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={2}>Lokasi</Text>
<Text fz="md">{data.lokasi}</Text>
</Box>
{data.latitude != null && data.longitude != null && (
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={6}>Lokasi di Peta</Text>
<Box style={{ height: 260, borderRadius: 8, overflow: 'hidden', border: '1px solid #e0e0e0' }}>
<LeafletMap
defaultCenter={{ lat: data.latitude, lng: data.longitude }}
readOnly
/>
</Box>
</Box>
)}
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={2}>Terakhir Aktif</Text>
<Text fz="md">
{new Date(data.lastActive).toLocaleString('id-ID', {
weekday: 'long', day: '2-digit', month: 'long',
year: 'numeric', hour: '2-digit', minute: '2-digit',
})}
</Text>
</Box>
<Box>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={2}>Dibuat</Text>
<Text fz="sm" c="dimmed">
{new Date(data.createdAt).toLocaleDateString('id-ID', {
day: '2-digit', month: 'long', year: 'numeric',
})}
</Text>
</Box>
<Group gap="sm" mt="sm">
<Button
color="red"
variant="light"
radius="md"
onClick={() => setModalHapus(true)}
loading={state.delete.loading}
>
<IconTrash size={20} />
</Button>
<Button
color="green"
variant="light"
radius="md"
onClick={() => router.push(`/admin/keamanan/cctv/${data.id}/edit`)}
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
loading={state.delete.loading}
text="Apakah anda yakin ingin menghapus CCTV ini?"
/>
</Box>
);
}
export default DetailCctv;

View File

@@ -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: () => <Skeleton height={300} radius="md" /> }
);
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 (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">Tambah CCTV</Title>
</Group>
<Paper
w={{ base: '100%', md: '55%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label={<Text fw="bold" fz="sm">Kode CCTV</Text>}
placeholder="Contoh: CCTV-01"
value={state.create.form.kode}
onChange={(e) => { cctvState.create.form.kode = e.currentTarget.value; }}
required
/>
<TextInput
label={<Text fw="bold" fz="sm">Nama / Deskripsi</Text>}
placeholder="Contoh: Balai Desa"
value={state.create.form.nama}
onChange={(e) => { cctvState.create.form.nama = e.currentTarget.value; }}
required
/>
<TextInput
label={<Text fw="bold" fz="sm">Lokasi</Text>}
placeholder="Contoh: Jl. Raya Darmasaba No. 1"
value={state.create.form.lokasi}
onChange={(e) => { cctvState.create.form.lokasi = e.currentTarget.value; }}
required
/>
<Box>
<Group mb={6} gap={6}>
<Text fw="bold" fz="sm">Titik Lokasi di Peta</Text>
<Text fz="xs" c="dimmed">(klik pada peta untuk menentukan posisi)</Text>
</Group>
<Box style={{ height: 300, borderRadius: 8, overflow: 'hidden', border: '1px solid #e0e0e0' }}>
<LeafletMap
defaultCenter={DEFAULT_CENTER}
onSelect={handleMapSelect}
/>
</Box>
{markerSet && (
<Group mt={6} gap={4}>
<IconMapPin size={14} color="green" />
<Text fz="xs" c="green">
Posisi dipilih: {Number(state.create.form.latitude).toFixed(6)}, {Number(state.create.form.longitude).toFixed(6)}
</Text>
</Group>
)}
</Box>
<Select
label={<Text fw="bold" fz="sm">Status</Text>}
value={state.create.form.status}
onChange={(val) => { cctvState.create.form.status = (val as 'Online' | 'Offline') ?? 'Online'; }}
data={[
{ value: 'Online', label: 'Online' },
{ value: 'Offline', label: 'Offline' },
]}
required
/>
<DateTimePicker
label={<Text fw="bold" fz="sm">Terakhir Aktif</Text>}
value={state.create.form.lastActive ? new Date(state.create.form.lastActive) : new Date()}
onChange={(val) => {
cctvState.create.form.lastActive = val ? new Date(val).toISOString() : new Date().toISOString();
}}
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
onClick={() => { cctvState.create.resetForm(); setMarkerSet(false); }}
>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? 'linear-gradient(135deg, #cccccc, #eeeeee)'
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79,172,254,0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateCctv;

View File

@@ -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 (
<Box>
<HeaderSearch
title="CCTV Keamanan"
placeholder="Cari kode, nama, atau lokasi..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListCctv search={search} />
</Box>
);
}
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 (
<Stack py={{ base: 'sm', md: 'md' }}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Stack py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'sm', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4}>Daftar CCTV</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/keamanan/cctv/create')}
>
Tambah CCTV
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table highlightOnHover style={{ tableLayout: 'fixed', width: '100%' }}>
<TableThead>
<TableTr>
<TableTh style={{ width: '15%' }}>Kode</TableTh>
<TableTh style={{ width: '20%' }}>Nama</TableTh>
<TableTh style={{ width: '25%' }}>Lokasi</TableTh>
<TableTh style={{ width: '15%' }}>Status</TableTh>
<TableTh style={{ width: '15%' }}>Terakhir Aktif</TableTh>
<TableTh style={{ width: '10%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.length > 0 ? (
data.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="sm" fw={600}>{item.kode}</Text>
</TableTd>
<TableTd>
<Text fz="sm" fw={500} lineClamp={1}>{item.nama}</Text>
</TableTd>
<TableTd>
<Text fz="sm" c="dimmed" lineClamp={1}>{item.lokasi}</Text>
</TableTd>
<TableTd>
<Badge
color={item.status === 'Online' ? 'green' : 'red'}
variant="light"
radius="sm"
>
{item.status}
</Badge>
</TableTd>
<TableTd>
<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>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
size="xs"
onClick={() => router.push(`/admin/keamanan/cctv/${item.id}`)}
>
<IconDeviceImacCog size={16} />
<Text ml={4} fz="xs" fw={500}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={6}>
<Center py={20}>
<Text c="dimmed" fz="sm">Tidak ada data CCTV</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Stack hiddenFrom="md" gap="xs">
{data.length > 0 ? (
data.map((item) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap="xs">
<Group justify="space-between">
<Text fz="sm" fw={700}>{item.kode}</Text>
<Badge
color={item.status === 'Online' ? 'green' : 'red'}
variant="light"
radius="sm"
>
{item.status}
</Badge>
</Group>
<Text fz="sm" fw={500}>{item.nama}</Text>
<Text fz="xs" c="dimmed">{item.lokasi}</Text>
<Text fz="xs" c="dimmed">
Terakhir aktif:{' '}
{new Date(item.lastActive).toLocaleString('id-ID', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</Text>
<Button
variant="light"
color="blue"
fullWidth
size="xs"
onClick={() => router.push(`/admin/keamanan/cctv/${item.id}`)}
>
<IconDeviceImacCog size={16} />
<Text ml={4} fz="xs" fw={500}>Detail</Text>
</Button>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm">Tidak ada data CCTV</Text>
</Center>
)}
</Stack>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
cctvState.findMany.page = newPage;
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Stack>
);
}
export default CctvPage;

View File

@@ -208,6 +208,11 @@ export const devBar = [
id: "Keamanan_6", id: "Keamanan_6",
name: "Tips Keamanan", name: "Tips Keamanan",
path: "/admin/keamanan/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", id: "Keamanan_6",
name: "Tips Keamanan", name: "Tips Keamanan",
path: "/admin/keamanan/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", id: "Keamanan_6",
name: "Tips Keamanan", name: "Tips Keamanan",
path: "/admin/keamanan/tips-keamanan" path: "/admin/keamanan/tips-keamanan"
},
{
id: "Keamanan_7",
name: "CCTV",
path: "/admin/keamanan/cctv"
} }
] ]
}, },

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -4,6 +4,7 @@ import PolsekTerdekat from "./polsek-terdekat";
import PencegahanKriminalitas from "./pencegahan-kriminalitas"; import PencegahanKriminalitas from "./pencegahan-kriminalitas";
import MenuTipsKeamanan from "./tips-keamanan"; import MenuTipsKeamanan from "./tips-keamanan";
import LaporanPublik from "./laporan-publik"; import LaporanPublik from "./laporan-publik";
import CctvKeamanan from "./cctv";
import KontakDaruratKeamanan from "./kontak-darurat-keamanan"; import KontakDaruratKeamanan from "./kontak-darurat-keamanan";
import KontakItem from "./kontak-darurat-keamanan/kontak-item"; import KontakItem from "./kontak-darurat-keamanan/kontak-item";
@@ -15,6 +16,7 @@ const Keamanan = new Elysia({ prefix: "/keamanan", tags: ["Keamanan"] })
.use(PencegahanKriminalitas) .use(PencegahanKriminalitas)
.use(MenuTipsKeamanan) .use(MenuTipsKeamanan)
.use(LaporanPublik) .use(LaporanPublik)
.use(CctvKeamanan)
.use(LayananPolsek) .use(LayananPolsek)
.use(KontakDaruratKeamanan) .use(KontakDaruratKeamanan)
.use(KontakItem) .use(KontakItem)