feat(camera): add camera management functionality and data flow documentation
- Implemented camera slice in Redux store for managing camera state. - Added new tables in PostgreSQL for camera devices, lending, and audit logs. - Created camera-related schemas in contracts for validation. - Developed camera data flow documentation outlining ingest and read processes. - Introduced async thunks for fetching, creating, lending, returning, and updating cameras.
This commit is contained in:
1 parent
7923ba1b8d
commit
d3b6c5032f
11 files changed
+2524
-114
No files matched your search
+10
-3
@@ -1,4 +1,4 @@
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { DevicesPage } from './pages/DevicesPage'
|
||||
import { GreetingPage } from './pages/GreetingPage'
|
||||
|
||||
@@ -8,10 +8,17 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
}`
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation()
|
||||
const isDevicesRoute = location.pathname.startsWith('/devices')
|
||||
const containerClass = isDevicesRoute ? 'mx-auto w-full max-w-[96rem] px-4 py-8' : 'mx-auto max-w-3xl px-4 py-8'
|
||||
const headerContainerClass = isDevicesRoute
|
||||
? 'mx-auto flex w-full max-w-[96rem] items-center justify-between px-4 py-3'
|
||||
: 'mx-auto flex max-w-3xl items-center justify-between px-4 py-3'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||
<div className={headerContainerClass}>
|
||||
<span className="font-semibold text-slate-900">marco-trest-test</span>
|
||||
<nav className="flex gap-2">
|
||||
<NavLink to="/greeting" className={linkClass}>
|
||||
@@ -23,7 +30,7 @@ export default function App() {
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<main className={containerClass}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/devices" replace />} />
|
||||
<Route path="/greeting" element={<GreetingPage />} />
|
||||
|
||||
@@ -1,104 +1,594 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { DeviceStatus } from 'contracts'
|
||||
import { PRESENCE_TTL_SECONDS } from 'contracts'
|
||||
import { fetchDeviceStatus, pingDevice } from '../store/device-slice'
|
||||
import type { CameraListItem } from 'contracts'
|
||||
import {
|
||||
closeCreateModal,
|
||||
closeAuditModal,
|
||||
closeEditModal,
|
||||
closeLendModal,
|
||||
createCamera,
|
||||
fetchCameraAudit,
|
||||
fetchCameras,
|
||||
openCreateModal,
|
||||
openEditModal,
|
||||
openLendModal,
|
||||
returnCamera,
|
||||
saveCameraUpdates,
|
||||
setCreateCameraId,
|
||||
setCreateIsActive,
|
||||
setCreateLocation,
|
||||
setCreateModel,
|
||||
setCreateName,
|
||||
setCreateNotes,
|
||||
setEditIsActive,
|
||||
setEditLocation,
|
||||
setEditModel,
|
||||
setEditName,
|
||||
setEditNotes,
|
||||
setLendBorrower,
|
||||
setLendDueDate,
|
||||
setLendPurpose,
|
||||
simulateCameraIngest,
|
||||
submitCameraLend,
|
||||
toggleCameraActivation,
|
||||
} from '../store/camera-slice'
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks'
|
||||
|
||||
function StatusCard({ title, status }: { title: string; status: DeviceStatus | undefined }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<article className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<h2 className="font-semibold text-slate-900">{title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">No data yet.</p>
|
||||
</article>
|
||||
)
|
||||
function statusPillClass(health: string) {
|
||||
if (health === 'online') {
|
||||
return 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<h2 className="font-semibold text-slate-900">{title}</h2>
|
||||
<dl className="mt-3 space-y-1 text-sm text-slate-700">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>online</dt>
|
||||
<dd className={status.online ? 'font-medium text-emerald-700' : 'text-slate-500'}>
|
||||
{status.online ? 'yes' : 'no'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>source</dt>
|
||||
<dd>{status.source}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>age</dt>
|
||||
<dd>{status.ageMs === null ? '—' : `${status.ageMs} ms`}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>lastSeen</dt>
|
||||
<dd className="truncate">{status.lastSeen ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>payload</dt>
|
||||
<dd className="mt-1 overflow-auto rounded bg-slate-50 p-2 font-mono text-xs">
|
||||
{JSON.stringify(status.payload)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
)
|
||||
if (health === 'offline') {
|
||||
return 'bg-rose-50 text-rose-700 ring-1 ring-rose-200'
|
||||
}
|
||||
return 'bg-slate-100 text-slate-600 ring-1 ring-slate-200'
|
||||
}
|
||||
|
||||
export function DevicesPage() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { deviceId, pair, pinging, error } = useAppSelector((state) => state.device)
|
||||
const {
|
||||
cameras,
|
||||
loading,
|
||||
error,
|
||||
message,
|
||||
isCreating,
|
||||
simulatingCameraId,
|
||||
createModalOpen,
|
||||
lendModalCameraId,
|
||||
editModalCameraId,
|
||||
auditModalCameraId,
|
||||
auditLogs,
|
||||
isAuditLoading,
|
||||
isLending,
|
||||
isUpdating,
|
||||
isReturning,
|
||||
isTogglingActivation,
|
||||
createForm,
|
||||
lendForm,
|
||||
editForm,
|
||||
} = useAppSelector((state) => state.cameraPage)
|
||||
|
||||
const lendModalCamera = cameras.find((camera) => camera.cameraId === lendModalCameraId) ?? null
|
||||
const editModalCamera = cameras.find((camera) => camera.cameraId === editModalCameraId) ?? null
|
||||
const auditModalCamera = cameras.find((camera) => camera.cameraId === auditModalCameraId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchDeviceStatus(deviceId))
|
||||
void dispatch(fetchCameras())
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void dispatch(fetchDeviceStatus(deviceId))
|
||||
void dispatch(fetchCameras())
|
||||
}, 1000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [dispatch, deviceId])
|
||||
}, [dispatch])
|
||||
|
||||
function submitLend(camera: CameraListItem) {
|
||||
void dispatch(submitCameraLend(camera.cameraId))
|
||||
}
|
||||
|
||||
function submitUpdate(camera: CameraListItem) {
|
||||
void dispatch(saveCameraUpdates(camera.cameraId))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold text-slate-900">Device status</h1>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Camera Operations</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Product UI reads Express → Postgres. Redis is only a TTL presence copy. After{' '}
|
||||
{PRESENCE_TTL_SECONDS}s without a ping, Redis goes empty; Postgres keeps the last row.
|
||||
Manage camera status in one table, auto-refreshed every second.
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium uppercase tracking-wide text-amber-700">
|
||||
Ping device talks to ingest-api — demo only. Production UI never calls the device API.
|
||||
<p className="mt-1 text-xs font-medium uppercase tracking-wide text-emerald-700">
|
||||
Auto ingest is running every second.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pinging}
|
||||
onClick={() => void dispatch(pingDevice(deviceId))}
|
||||
className="mt-4 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{pinging ? 'Pinging…' : `Ping ${deviceId}`}
|
||||
</button>
|
||||
{error ? (
|
||||
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500">Total cameras: {cameras.length}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(openCreateModal())}
|
||||
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white"
|
||||
>
|
||||
Create camera
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mt-3 rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p> : null}
|
||||
{message ? (
|
||||
<p className="mt-3 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-xl border border-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200 bg-white text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="px-4 py-3 font-semibold">Camera</th>
|
||||
<th className="px-4 py-3 font-semibold">Location</th>
|
||||
<th className="px-4 py-3 font-semibold">Health</th>
|
||||
<th className="px-4 py-3 font-semibold">Last Seen</th>
|
||||
<th className="px-4 py-3 font-semibold">Latest Payload</th>
|
||||
<th className="px-4 py-3 font-semibold">Lending</th>
|
||||
<th className="px-4 py-3 font-semibold">State</th>
|
||||
<th className="px-4 py-3 font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading && cameras.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||
Loading cameras...
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!loading && cameras.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||
No cameras found.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{cameras.map((camera) => (
|
||||
<tr key={camera.cameraId} className="align-top">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-semibold text-slate-900">{camera.name}</p>
|
||||
<p className="font-mono text-xs text-slate-500">{camera.cameraId}</p>
|
||||
<p className="text-xs text-slate-500">{camera.model ?? 'Model n/a'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">{camera.location}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${statusPillClass(camera.latest.health)}`}
|
||||
>
|
||||
{camera.latest.health}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">{camera.latest.lastSeen ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<pre className="max-w-xs overflow-auto rounded bg-slate-50 p-2 font-mono text-xs text-slate-700">
|
||||
{JSON.stringify(camera.latest.payload ?? {}, null, 0)}
|
||||
</pre>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{camera.lend ? (
|
||||
<div>
|
||||
<p className="font-medium">{camera.lend.borrower}</p>
|
||||
<p className="text-xs">Due {new Date(camera.lend.dueDate).toLocaleString()}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">Available</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{camera.isActive ? (
|
||||
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-200">
|
||||
Activated
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 ring-1 ring-slate-200">
|
||||
Deactivated
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(simulateCameraIngest(camera.cameraId))}
|
||||
disabled={simulatingCameraId === camera.cameraId || !camera.isActive}
|
||||
className="rounded-md bg-slate-900 px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{simulatingCameraId === camera.cameraId ? 'Simulating...' : 'Simulate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(openLendModal(camera.cameraId))}
|
||||
disabled={Boolean(camera.lend) || !camera.isActive}
|
||||
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{camera.lend ? 'Lent' : 'Lend'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(returnCamera(camera.cameraId))}
|
||||
disabled={!camera.lend || isReturning === camera.cameraId}
|
||||
className="rounded-md bg-cyan-700 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isReturning === camera.cameraId ? 'Returning...' : 'Return'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(openEditModal(camera))}
|
||||
className="rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void dispatch(
|
||||
toggleCameraActivation({ cameraId: camera.cameraId, isActive: camera.isActive }),
|
||||
)
|
||||
}
|
||||
disabled={isTogglingActivation === camera.cameraId}
|
||||
className="rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isTogglingActivation === camera.cameraId
|
||||
? 'Saving...'
|
||||
: camera.isActive
|
||||
? 'Deactivate'
|
||||
: 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(fetchCameraAudit(camera.cameraId))}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
|
||||
>
|
||||
Audit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<p className="mb-2 font-medium text-slate-900">Data flow</p>
|
||||
<pre className="font-mono text-xs leading-6">{`Device ping → Koa ingest :3001
|
||||
→ Mongo (raw event)
|
||||
→ Redis SET device:id EX ${PRESENCE_TTL_SECONDS}s
|
||||
→ Postgres UPSERT device_current_state
|
||||
{lendModalCamera ? (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Lend camera</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{lendModalCamera.name} ({lendModalCamera.cameraId})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeLendModal())}
|
||||
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
React poll 1s → Express crud :3000
|
||||
→ SELECT postgres snapshot ← near-real-time product path
|
||||
→ GET redis key ← optional presence`}</pre>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitLend(lendModalCamera)
|
||||
}}
|
||||
className="mt-4 space-y-3"
|
||||
>
|
||||
<label className="block text-sm text-slate-700">
|
||||
Borrower
|
||||
<input
|
||||
required
|
||||
value={lendForm.borrower}
|
||||
onChange={(event) => dispatch(setLendBorrower(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<StatusCard title="Postgres (Express product path)" status={pair?.postgres} />
|
||||
<StatusCard title="Redis (TTL presence)" status={pair?.redis} />
|
||||
</div>
|
||||
<label className="block text-sm text-slate-700">
|
||||
Due date
|
||||
<input
|
||||
required
|
||||
type="datetime-local"
|
||||
value={lendForm.dueDate}
|
||||
onChange={(event) => dispatch(setLendDueDate(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Purpose (optional)
|
||||
<input
|
||||
value={lendForm.purpose}
|
||||
onChange={(event) => dispatch(setLendPurpose(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeLendModal())}
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLending}
|
||||
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isLending ? 'Submitting...' : 'Confirm lend'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{createModalOpen ? (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Create camera</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">Register a new camera device in the catalog.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeCreateModal())}
|
||||
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void dispatch(createCamera())
|
||||
}}
|
||||
className="mt-4 space-y-3"
|
||||
>
|
||||
<label className="block text-sm text-slate-700">
|
||||
Camera ID
|
||||
<input
|
||||
required
|
||||
value={createForm.cameraId}
|
||||
onChange={(event) => dispatch(setCreateCameraId(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
placeholder="cam-entrance-04"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Name
|
||||
<input
|
||||
required
|
||||
value={createForm.name}
|
||||
onChange={(event) => dispatch(setCreateName(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Location
|
||||
<input
|
||||
required
|
||||
value={createForm.location}
|
||||
onChange={(event) => dispatch(setCreateLocation(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Model
|
||||
<input
|
||||
value={createForm.model}
|
||||
onChange={(event) => dispatch(setCreateModel(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Notes
|
||||
<input
|
||||
value={createForm.notes}
|
||||
onChange={(event) => dispatch(setCreateNotes(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.isActive}
|
||||
onChange={(event) => dispatch(setCreateIsActive(event.target.checked))}
|
||||
/>
|
||||
Activated
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeCreateModal())}
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editModalCamera ? (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Update camera</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{editModalCamera.name} ({editModalCamera.cameraId})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeEditModal())}
|
||||
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitUpdate(editModalCamera)
|
||||
}}
|
||||
className="mt-4 space-y-3"
|
||||
>
|
||||
<label className="block text-sm text-slate-700">
|
||||
Name
|
||||
<input
|
||||
required
|
||||
value={editForm.name}
|
||||
onChange={(event) => dispatch(setEditName(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Location
|
||||
<input
|
||||
required
|
||||
value={editForm.location}
|
||||
onChange={(event) => dispatch(setEditLocation(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Model
|
||||
<input
|
||||
value={editForm.model}
|
||||
onChange={(event) => dispatch(setEditModel(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Notes
|
||||
<input
|
||||
value={editForm.notes}
|
||||
onChange={(event) => dispatch(setEditNotes(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.isActive}
|
||||
onChange={(event) => dispatch(setEditIsActive(event.target.checked))}
|
||||
/>
|
||||
Activated
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeEditModal())}
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUpdating}
|
||||
className="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isUpdating ? 'Saving...' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{auditModalCamera ? (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
|
||||
<div className="w-full max-w-3xl rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Audit log</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{auditModalCamera.name} ({auditModalCamera.cameraId})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeAuditModal())}
|
||||
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-96 overflow-auto rounded-lg border border-slate-200">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="px-3 py-2 font-semibold">Time</th>
|
||||
<th className="px-3 py-2 font-semibold">Action</th>
|
||||
<th className="px-3 py-2 font-semibold">Actor</th>
|
||||
<th className="px-3 py-2 font-semibold">Metadata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{isAuditLoading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
|
||||
Loading audit logs...
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!isAuditLoading && auditLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
|
||||
No audit logs found.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{auditLogs.map((log) => (
|
||||
<tr key={log.id} className="align-top">
|
||||
<td className="px-3 py-2 text-slate-700">{new Date(log.createdAt).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-900">{log.action}</td>
|
||||
<td className="px-3 py-2 text-slate-700">{log.actor}</td>
|
||||
<td className="px-3 py-2">
|
||||
<pre className="max-w-md overflow-auto rounded bg-slate-50 p-2 font-mono text-xs text-slate-700">
|
||||
{JSON.stringify(log.metadata)}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||
import {
|
||||
cameraAuditLogListSchema,
|
||||
cameraCreateRequestSchema,
|
||||
cameraDeviceUpdateSchema,
|
||||
cameraListResponseSchema,
|
||||
cameraReturnResponseSchema,
|
||||
cameraSimulateIngestResponseSchema,
|
||||
type CameraAuditLog,
|
||||
type CameraListItem,
|
||||
} from 'contracts'
|
||||
import { ingestApi } from '../api/client'
|
||||
import { toErrorMessage } from './greeting-slice'
|
||||
import type { RootState } from './index'
|
||||
|
||||
export type CameraFormState = {
|
||||
borrower: string
|
||||
purpose: string
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
export type CameraEditFormState = {
|
||||
name: string
|
||||
location: string
|
||||
model: string
|
||||
notes: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type CameraPageState = {
|
||||
cameras: CameraListItem[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
message: string | null
|
||||
isCreating: boolean
|
||||
simulatingCameraId: string | null
|
||||
createModalOpen: boolean
|
||||
lendModalCameraId: string | null
|
||||
editModalCameraId: string | null
|
||||
auditModalCameraId: string | null
|
||||
auditLogs: CameraAuditLog[]
|
||||
isAuditLoading: boolean
|
||||
isLending: boolean
|
||||
isUpdating: boolean
|
||||
isReturning: string | null
|
||||
isTogglingActivation: string | null
|
||||
lendForm: CameraFormState
|
||||
createForm: CameraEditFormState & { cameraId: string }
|
||||
editForm: CameraEditFormState
|
||||
}
|
||||
|
||||
const initialState: CameraPageState = {
|
||||
cameras: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
message: null,
|
||||
isCreating: false,
|
||||
simulatingCameraId: null,
|
||||
createModalOpen: false,
|
||||
lendModalCameraId: null,
|
||||
editModalCameraId: null,
|
||||
auditModalCameraId: null,
|
||||
auditLogs: [],
|
||||
isAuditLoading: false,
|
||||
isLending: false,
|
||||
isUpdating: false,
|
||||
isReturning: null,
|
||||
isTogglingActivation: null,
|
||||
lendForm: {
|
||||
borrower: '',
|
||||
purpose: '',
|
||||
dueDate: '',
|
||||
},
|
||||
createForm: {
|
||||
cameraId: '',
|
||||
name: '',
|
||||
location: '',
|
||||
model: '',
|
||||
notes: '',
|
||||
isActive: true,
|
||||
},
|
||||
editForm: {
|
||||
name: '',
|
||||
location: '',
|
||||
model: '',
|
||||
notes: '',
|
||||
isActive: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const fetchCameras = createAsyncThunk<CameraListItem[], void, { rejectValue: string }>(
|
||||
'cameraPage/fetchCameras',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.get('/cameras')
|
||||
const parsed = cameraListResponseSchema.parse(response.data)
|
||||
return parsed.items
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const createCamera = createAsyncThunk<string, void, { state: RootState; rejectValue: string }>(
|
||||
'cameraPage/createCamera',
|
||||
async (_, { dispatch, getState, rejectWithValue }) => {
|
||||
const { createForm } = getState().cameraPage
|
||||
try {
|
||||
const body = cameraCreateRequestSchema.parse({
|
||||
cameraId: createForm.cameraId,
|
||||
name: createForm.name,
|
||||
location: createForm.location,
|
||||
model: createForm.model ? createForm.model : null,
|
||||
notes: createForm.notes ? createForm.notes : null,
|
||||
isActive: createForm.isActive,
|
||||
})
|
||||
|
||||
await ingestApi.post('/cameras', body)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return body.cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const simulateCameraIngest = createAsyncThunk<string, string, { rejectValue: string }>(
|
||||
'cameraPage/simulateIngest',
|
||||
async (cameraId, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.post(`/cameras/${cameraId}/simulate-ingest`, {})
|
||||
cameraSimulateIngestResponseSchema.parse(response.data)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const submitCameraLend = createAsyncThunk<string, string, { state: RootState; rejectValue: string }>(
|
||||
'cameraPage/submitLend',
|
||||
async (cameraId, { dispatch, getState, rejectWithValue }) => {
|
||||
const { lendForm } = getState().cameraPage
|
||||
if (!lendForm.borrower || !lendForm.dueDate) {
|
||||
return rejectWithValue('Borrower and due date are required')
|
||||
}
|
||||
|
||||
try {
|
||||
await ingestApi.post(`/cameras/${cameraId}/lend`, {
|
||||
borrower: lendForm.borrower,
|
||||
dueDate: new Date(lendForm.dueDate).toISOString(),
|
||||
purpose: lendForm.purpose || undefined,
|
||||
})
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const returnCamera = createAsyncThunk<string, string, { rejectValue: string }>(
|
||||
'cameraPage/returnCamera',
|
||||
async (cameraId, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.post(`/cameras/${cameraId}/return`, {})
|
||||
cameraReturnResponseSchema.parse(response.data)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const saveCameraUpdates = createAsyncThunk<
|
||||
string,
|
||||
string,
|
||||
{ state: RootState; rejectValue: string }
|
||||
>('cameraPage/saveUpdates', async (cameraId, { dispatch, getState, rejectWithValue }) => {
|
||||
const { editForm } = getState().cameraPage
|
||||
|
||||
try {
|
||||
const patch = cameraDeviceUpdateSchema.parse({
|
||||
name: editForm.name,
|
||||
location: editForm.location,
|
||||
model: editForm.model ? editForm.model : null,
|
||||
notes: editForm.notes ? editForm.notes : null,
|
||||
isActive: editForm.isActive,
|
||||
})
|
||||
await ingestApi.patch(`/cameras/${cameraId}`, patch)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
export const toggleCameraActivation = createAsyncThunk<
|
||||
{ cameraId: string; nextActive: boolean },
|
||||
{ cameraId: string; isActive: boolean },
|
||||
{ rejectValue: string }
|
||||
>('cameraPage/toggleActivation', async ({ cameraId, isActive }, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const nextActive = !isActive
|
||||
await ingestApi.patch(`/cameras/${cameraId}`, { isActive: nextActive })
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return { cameraId, nextActive }
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
export const fetchCameraAudit = createAsyncThunk<
|
||||
{ cameraId: string; items: CameraAuditLog[] },
|
||||
string,
|
||||
{ rejectValue: string }
|
||||
>('cameraPage/fetchAudit', async (cameraId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.get(`/cameras/${cameraId}/audit`, { params: { limit: 50 } })
|
||||
const parsed = cameraAuditLogListSchema.parse(response.data)
|
||||
return { cameraId, items: parsed.items }
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
const cameraPageSlice = createSlice({
|
||||
name: 'cameraPage',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearBanner(state) {
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
openLendModal(state, action: PayloadAction<string>) {
|
||||
state.lendModalCameraId = action.payload
|
||||
state.lendForm = { borrower: '', purpose: '', dueDate: '' }
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
openCreateModal(state) {
|
||||
state.createModalOpen = true
|
||||
state.createForm = {
|
||||
cameraId: '',
|
||||
name: '',
|
||||
location: '',
|
||||
model: '',
|
||||
notes: '',
|
||||
isActive: true,
|
||||
}
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
closeCreateModal(state) {
|
||||
state.createModalOpen = false
|
||||
},
|
||||
setCreateCameraId(state, action: PayloadAction<string>) {
|
||||
state.createForm.cameraId = action.payload
|
||||
},
|
||||
setCreateName(state, action: PayloadAction<string>) {
|
||||
state.createForm.name = action.payload
|
||||
},
|
||||
setCreateLocation(state, action: PayloadAction<string>) {
|
||||
state.createForm.location = action.payload
|
||||
},
|
||||
setCreateModel(state, action: PayloadAction<string>) {
|
||||
state.createForm.model = action.payload
|
||||
},
|
||||
setCreateNotes(state, action: PayloadAction<string>) {
|
||||
state.createForm.notes = action.payload
|
||||
},
|
||||
setCreateIsActive(state, action: PayloadAction<boolean>) {
|
||||
state.createForm.isActive = action.payload
|
||||
},
|
||||
closeLendModal(state) {
|
||||
state.lendModalCameraId = null
|
||||
},
|
||||
setLendBorrower(state, action: PayloadAction<string>) {
|
||||
state.lendForm.borrower = action.payload
|
||||
},
|
||||
setLendPurpose(state, action: PayloadAction<string>) {
|
||||
state.lendForm.purpose = action.payload
|
||||
},
|
||||
setLendDueDate(state, action: PayloadAction<string>) {
|
||||
state.lendForm.dueDate = action.payload
|
||||
},
|
||||
openEditModal(state, action: PayloadAction<CameraListItem>) {
|
||||
const camera = action.payload
|
||||
state.editModalCameraId = camera.cameraId
|
||||
state.editForm = {
|
||||
name: camera.name,
|
||||
location: camera.location,
|
||||
model: camera.model ?? '',
|
||||
notes: camera.notes ?? '',
|
||||
isActive: camera.isActive,
|
||||
}
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
closeEditModal(state) {
|
||||
state.editModalCameraId = null
|
||||
},
|
||||
setEditName(state, action: PayloadAction<string>) {
|
||||
state.editForm.name = action.payload
|
||||
},
|
||||
setEditLocation(state, action: PayloadAction<string>) {
|
||||
state.editForm.location = action.payload
|
||||
},
|
||||
setEditModel(state, action: PayloadAction<string>) {
|
||||
state.editForm.model = action.payload
|
||||
},
|
||||
setEditNotes(state, action: PayloadAction<string>) {
|
||||
state.editForm.notes = action.payload
|
||||
},
|
||||
setEditIsActive(state, action: PayloadAction<boolean>) {
|
||||
state.editForm.isActive = action.payload
|
||||
},
|
||||
closeAuditModal(state) {
|
||||
state.auditModalCameraId = null
|
||||
state.auditLogs = []
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchCameras.pending, (state) => {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchCameras.fulfilled, (state, action) => {
|
||||
state.loading = false
|
||||
state.cameras = action.payload
|
||||
})
|
||||
.addCase(fetchCameras.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to fetch camera list'
|
||||
})
|
||||
.addCase(createCamera.pending, (state) => {
|
||||
state.isCreating = true
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(createCamera.fulfilled, (state, action) => {
|
||||
state.isCreating = false
|
||||
state.createModalOpen = false
|
||||
state.message = `Camera ${action.payload} created successfully`
|
||||
})
|
||||
.addCase(createCamera.rejected, (state, action) => {
|
||||
state.isCreating = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to create camera'
|
||||
})
|
||||
.addCase(simulateCameraIngest.pending, (state, action) => {
|
||||
state.simulatingCameraId = action.meta.arg
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(simulateCameraIngest.fulfilled, (state, action) => {
|
||||
state.simulatingCameraId = null
|
||||
state.message = `Simulated ingest for ${action.payload}`
|
||||
})
|
||||
.addCase(simulateCameraIngest.rejected, (state, action) => {
|
||||
state.simulatingCameraId = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to simulate camera ingest'
|
||||
})
|
||||
.addCase(submitCameraLend.pending, (state) => {
|
||||
state.isLending = true
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(submitCameraLend.fulfilled, (state, action) => {
|
||||
state.isLending = false
|
||||
state.lendModalCameraId = null
|
||||
state.lendForm = { borrower: '', purpose: '', dueDate: '' }
|
||||
state.message = `Camera ${action.payload} lent successfully`
|
||||
})
|
||||
.addCase(submitCameraLend.rejected, (state, action) => {
|
||||
state.isLending = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to lend camera'
|
||||
})
|
||||
.addCase(returnCamera.pending, (state, action) => {
|
||||
state.isReturning = action.meta.arg
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(returnCamera.fulfilled, (state, action) => {
|
||||
state.isReturning = null
|
||||
state.message = `Camera ${action.payload} returned successfully`
|
||||
})
|
||||
.addCase(returnCamera.rejected, (state, action) => {
|
||||
state.isReturning = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to return camera'
|
||||
})
|
||||
.addCase(saveCameraUpdates.pending, (state) => {
|
||||
state.isUpdating = true
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(saveCameraUpdates.fulfilled, (state, action) => {
|
||||
state.isUpdating = false
|
||||
state.editModalCameraId = null
|
||||
state.message = `Camera ${action.payload} updated successfully`
|
||||
})
|
||||
.addCase(saveCameraUpdates.rejected, (state, action) => {
|
||||
state.isUpdating = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to update camera'
|
||||
})
|
||||
.addCase(toggleCameraActivation.pending, (state, action) => {
|
||||
state.isTogglingActivation = action.meta.arg.cameraId
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(toggleCameraActivation.fulfilled, (state, action) => {
|
||||
state.isTogglingActivation = null
|
||||
state.message = action.payload.nextActive
|
||||
? `Camera ${action.payload.cameraId} activated`
|
||||
: `Camera ${action.payload.cameraId} deactivated`
|
||||
})
|
||||
.addCase(toggleCameraActivation.rejected, (state, action) => {
|
||||
state.isTogglingActivation = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to update camera activation status'
|
||||
})
|
||||
.addCase(fetchCameraAudit.pending, (state, action) => {
|
||||
state.auditModalCameraId = action.meta.arg
|
||||
state.auditLogs = []
|
||||
state.isAuditLoading = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchCameraAudit.fulfilled, (state, action) => {
|
||||
state.isAuditLoading = false
|
||||
state.auditModalCameraId = action.payload.cameraId
|
||||
state.auditLogs = action.payload.items
|
||||
})
|
||||
.addCase(fetchCameraAudit.rejected, (state, action) => {
|
||||
state.isAuditLoading = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to load audit logs'
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const {
|
||||
clearBanner,
|
||||
openCreateModal,
|
||||
closeCreateModal,
|
||||
setCreateCameraId,
|
||||
setCreateName,
|
||||
setCreateLocation,
|
||||
setCreateModel,
|
||||
setCreateNotes,
|
||||
setCreateIsActive,
|
||||
openLendModal,
|
||||
closeLendModal,
|
||||
setLendBorrower,
|
||||
setLendPurpose,
|
||||
setLendDueDate,
|
||||
openEditModal,
|
||||
closeEditModal,
|
||||
setEditName,
|
||||
setEditLocation,
|
||||
setEditModel,
|
||||
setEditNotes,
|
||||
setEditIsActive,
|
||||
closeAuditModal,
|
||||
} = cameraPageSlice.actions
|
||||
|
||||
export default cameraPageSlice.reducer
|
||||
@@ -1,4 +1,5 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import cameraPageReducer from './camera-slice'
|
||||
import deviceReducer from './device-slice'
|
||||
import greetingReducer from './greeting-slice'
|
||||
|
||||
@@ -6,6 +7,7 @@ export const store = configureStore({
|
||||
reducer: {
|
||||
greeting: greetingReducer,
|
||||
device: deviceReducer,
|
||||
cameraPage: cameraPageReducer,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user