Files
marco-trest-test/apps/web/src/store/camera-slice.ts
T
marco.chan d3b6c5032f 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.
2026-09-04 12:05:52 +08:00

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