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)} + + + )} + + + Status} + 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 + /> + + Terakhir Aktif} + 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(); + }} + /> + + + + + + + + + ); +} + +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 + + + + {/* 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)