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( '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( '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( '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( '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( '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) { 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) { state.createForm.cameraId = action.payload }, setCreateName(state, action: PayloadAction) { state.createForm.name = action.payload }, setCreateLocation(state, action: PayloadAction) { state.createForm.location = action.payload }, setCreateModel(state, action: PayloadAction) { state.createForm.model = action.payload }, setCreateNotes(state, action: PayloadAction) { state.createForm.notes = action.payload }, setCreateIsActive(state, action: PayloadAction) { state.createForm.isActive = action.payload }, closeLendModal(state) { state.lendModalCameraId = null }, setLendBorrower(state, action: PayloadAction) { state.lendForm.borrower = action.payload }, setLendPurpose(state, action: PayloadAction) { state.lendForm.purpose = action.payload }, setLendDueDate(state, action: PayloadAction) { state.lendForm.dueDate = action.payload }, openEditModal(state, action: PayloadAction) { 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) { state.editForm.name = action.payload }, setEditLocation(state, action: PayloadAction) { state.editForm.location = action.payload }, setEditModel(state, action: PayloadAction) { state.editForm.model = action.payload }, setEditNotes(state, action: PayloadAction) { state.editForm.notes = action.payload }, setEditIsActive(state, action: PayloadAction) { 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