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:
2026-05-06 16:40:31 +08:00
parent 60841039dd
commit 936dd14ca9
20 changed files with 1398 additions and 1 deletions

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

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 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)