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:
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",
|
||||
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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
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 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)
|
||||
|
||||
Reference in New Issue
Block a user