feat(keamanan): tambah modul CCTV — schema, API, admin UI, seeder
- Tambah model CctvKeamanan + enum StatusCctv ke prisma schema - Tambah status Baru ke enum StatusLaporan - Migration: add_cctv_keamanan_model - API CRUD + stats endpoint di /api/keamanan/cctv/... - Admin state (valtio proxy) dengan create/findMany/edit/delete/stats - Admin pages: list, create, detail (peta Leaflet), edit (peta picker) - Seeder 8 data CCTV lokasi Darmasaba - Tambah submenu CCTV di sidebar nav keamanan - Bump version 0.1.57 → 0.1.58 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
35
prisma/_seeder_list/keamanan/seed_cctv.ts
Normal file
35
prisma/_seeder_list/keamanan/seed_cctv.ts
Normal 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`);
|
||||||
|
}
|
||||||
82
prisma/data/keamanan/cctv/cctv.json
Normal file
82
prisma/data/keamanan/cctv/cctv.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
239
src/app/admin/(dashboard)/_state/keamanan/cctv.ts
Normal file
239
src/app/admin/(dashboard)/_state/keamanan/cctv.ts
Normal 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;
|
||||||
190
src/app/admin/(dashboard)/keamanan/cctv/[id]/edit/page.tsx
Normal file
190
src/app/admin/(dashboard)/keamanan/cctv/[id]/edit/page.tsx
Normal 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;
|
||||||
160
src/app/admin/(dashboard)/keamanan/cctv/[id]/page.tsx
Normal file
160
src/app/admin/(dashboard)/keamanan/cctv/[id]/page.tsx
Normal 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;
|
||||||
177
src/app/admin/(dashboard)/keamanan/cctv/create/page.tsx
Normal file
177
src/app/admin/(dashboard)/keamanan/cctv/create/page.tsx
Normal 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;
|
||||||
215
src/app/admin/(dashboard)/keamanan/cctv/page.tsx
Normal file
215
src/app/admin/(dashboard)/keamanan/cctv/page.tsx
Normal 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;
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
33
src/app/api/[[...slugs]]/_lib/keamanan/cctv/create.ts
Normal file
33
src/app/api/[[...slugs]]/_lib/keamanan/cctv/create.ts
Normal 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;
|
||||||
26
src/app/api/[[...slugs]]/_lib/keamanan/cctv/del.ts
Normal file
26
src/app/api/[[...slugs]]/_lib/keamanan/cctv/del.ts
Normal 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;
|
||||||
47
src/app/api/[[...slugs]]/_lib/keamanan/cctv/findMany.ts
Normal file
47
src/app/api/[[...slugs]]/_lib/keamanan/cctv/findMany.ts
Normal 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;
|
||||||
16
src/app/api/[[...slugs]]/_lib/keamanan/cctv/findUnique.ts
Normal file
16
src/app/api/[[...slugs]]/_lib/keamanan/cctv/findUnique.ts
Normal 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;
|
||||||
40
src/app/api/[[...slugs]]/_lib/keamanan/cctv/index.ts
Normal file
40
src/app/api/[[...slugs]]/_lib/keamanan/cctv/index.ts
Normal 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;
|
||||||
32
src/app/api/[[...slugs]]/_lib/keamanan/cctv/stats.ts
Normal file
32
src/app/api/[[...slugs]]/_lib/keamanan/cctv/stats.ts
Normal 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;
|
||||||
40
src/app/api/[[...slugs]]/_lib/keamanan/cctv/updt.ts
Normal file
40
src/app/api/[[...slugs]]/_lib/keamanan/cctv/updt.ts
Normal 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;
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user