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:
marco.chan committed 2026-09-04 12:05:52 +08:00
1 parent 7923ba1b8d
commit d3b6c5032f
11 files changed
+2524 -114

No files matched your search

+10 -3
View File
@@ -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 />} />
+566 -76
View File
@@ -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>
)
}
+466
View File
@@ -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
+2
View File
@@ -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,
},
})