- 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.
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
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
|