diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx new file mode 100644 index 0000000..00ff3f4 --- /dev/null +++ b/mobile/src/App.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import { Provider as ReduxProvider, useDispatch } from 'react-redux'; +import { PaperProvider } from 'react-native-paper'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import NetInfo from '@react-native-community/netinfo'; +import { Alert } from 'react-native'; + +import { store } from './store'; +import { theme } from './theme'; +import { queryClient } from './lib/queryClient'; +import { RootNavigator } from './navigation/RootNavigator'; +import { tokenStorage, userStorage } from './lib/storage'; +import { setUser, setLoading } from './store/slices/authSlice'; +import { processOfflineQueue } from './hooks/useOfflineDraft'; +import { proposalsApi, CreateProposalRequest } from './lib/api/proposals'; + +function AuthBootstrap({ children }: { children: React.ReactNode }) { + const dispatch = useDispatch(); + + useEffect(() => { + (async () => { + try { + const tokens = await tokenStorage.get(); + const user = await userStorage.get(); + + if (tokens && user && new Date(tokens.expiresAt) > new Date()) { + dispatch(setUser(user)); + } else { + if (tokens) await tokenStorage.clear(); + dispatch(setLoading(false)); + } + } catch { + dispatch(setLoading(false)); + } + })(); + }, [dispatch]); + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state) => { + if (state.isConnected) { + processOfflineQueue((data) => + proposalsApi.create(data as CreateProposalRequest), + ).then((count) => { + if (count > 0) { + Alert.alert( + 'Synced', + `${count} offline proposal${count > 1 ? 's' : ''} submitted.`, + ); + } + }); + } + }); + return unsubscribe; + }, []); + + return <>{children}; +} + +export default function App() { + return ( + + + + + + + + + + + + ); +} diff --git a/mobile/src/config.ts b/mobile/src/config.ts new file mode 100644 index 0000000..2f6ed0a --- /dev/null +++ b/mobile/src/config.ts @@ -0,0 +1,12 @@ +const Config = { + API_URL: __DEV__ + ? 'http://localhost:5000/api' + : 'https://api.proposals.seahaven.com/api', + COGNITO_DOMAIN: 'proposal-system.auth.us-east-1.amazoncognito.com', + COGNITO_CLIENT_ID: '', + COGNITO_USER_POOL_ID: '', + COGNITO_REDIRECT_URI: 'com.seahavenind.proposals://auth/callback', + COGNITO_SCOPES: ['openid', 'email', 'profile'], +}; + +export default Config; diff --git a/mobile/src/constants/index.ts b/mobile/src/constants/index.ts new file mode 100644 index 0000000..1082a10 --- /dev/null +++ b/mobile/src/constants/index.ts @@ -0,0 +1,33 @@ +export const CACHE_TIME_SHORT = 2 * 60 * 1000; +export const CACHE_TIME_MEDIUM = 5 * 60 * 1000; +export const CACHE_TIME_LONG = 10 * 60 * 1000; + +export const STALE_TIME_SHORT = 1 * 60 * 1000; +export const STALE_TIME_MEDIUM = 5 * 60 * 1000; +export const STALE_TIME_LONG = 10 * 60 * 1000; + +export const DEFAULT_PAGE_SIZE = 12; +export const DEFAULT_PAGE = 1; + +export const DEBOUNCE_SEARCH = 600; + +export const STORAGE_KEY_USER = 'proposal_system_user'; +export const KEYCHAIN_SERVICE = 'com.seahavenind.proposals'; + +export const PROPOSAL_STATUSES = [ + 'Draft', + 'InReview', + 'Approved', + 'Sent', + 'Revised', +] as const; + +export const SERVICE_CATEGORIES = [ + 'HVAC', + 'Plumbing', + 'Electrical', + 'General', + 'Renovation', +] as const; + +export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const; diff --git a/mobile/src/constants/queryKeys.ts b/mobile/src/constants/queryKeys.ts new file mode 100644 index 0000000..08cfb08 --- /dev/null +++ b/mobile/src/constants/queryKeys.ts @@ -0,0 +1,9 @@ +export const QUERY_KEYS = { + proposals: 'proposals', + proposal: 'proposal', + proposalLineItems: 'proposalLineItems', + customers: 'customers', + users: 'users', + dashboard: 'dashboard', + auditTrail: 'auditTrail', +} as const; diff --git a/mobile/src/hooks/useAuth.ts b/mobile/src/hooks/useAuth.ts new file mode 100644 index 0000000..40e8dc5 --- /dev/null +++ b/mobile/src/hooks/useAuth.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { Alert } from 'react-native'; +import { + selectUser, + selectIsAuthenticated, + selectAuthLoading, + selectAuthError, + logout as logoutAction, +} from '../store/slices/authSlice'; +import { authApi } from '../lib/api/auth'; +import type { RootState } from '../store'; + +export function useAuth() { + const dispatch = useDispatch(); + + const user = useSelector((state: RootState) => selectUser(state)); + const isAuthenticated = useSelector((state: RootState) => + selectIsAuthenticated(state), + ); + const loading = useSelector((state: RootState) => selectAuthLoading(state)); + const error = useSelector((state: RootState) => selectAuthError(state)); + + const logout = useCallback(async () => { + try { + await authApi.logout(); + } catch { + // Best-effort + } + dispatch(logoutAction()); + Alert.alert('Signed Out', 'You have been logged out.'); + }, [dispatch]); + + return { + user, + isAuthenticated, + loading, + error, + logout, + }; +} diff --git a/mobile/src/hooks/useOfflineDraft.ts b/mobile/src/hooks/useOfflineDraft.ts new file mode 100644 index 0000000..d7519f2 --- /dev/null +++ b/mobile/src/hooks/useOfflineDraft.ts @@ -0,0 +1,100 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import NetInfo from '@react-native-community/netinfo'; + +const DRAFT_KEY = 'proposal_draft'; +const OFFLINE_QUEUE_KEY = 'proposal_offline_queue'; + +export function useOfflineDraft>( + initialData: T, +) { + const [draft, setDraft] = useState(initialData); + const [isLoaded, setIsLoaded] = useState(false); + const saveTimeout = useRef>(); + + useEffect(() => { + AsyncStorage.getItem(DRAFT_KEY).then((stored) => { + if (stored) { + try { + setDraft(JSON.parse(stored)); + } catch { + // Corrupted draft, use initial data + } + } + setIsLoaded(true); + }); + }, []); + + const updateDraft = useCallback( + (updates: Partial) => { + setDraft((prev) => { + const next = { ...prev, ...updates }; + if (saveTimeout.current) clearTimeout(saveTimeout.current); + saveTimeout.current = setTimeout(() => { + AsyncStorage.setItem(DRAFT_KEY, JSON.stringify(next)); + }, 1000); + return next; + }); + }, + [], + ); + + const clearDraft = useCallback(async () => { + if (saveTimeout.current) clearTimeout(saveTimeout.current); + await AsyncStorage.removeItem(DRAFT_KEY); + setDraft(initialData); + }, [initialData]); + + return { draft, updateDraft, clearDraft, isLoaded }; +} + +interface QueuedSubmission { + data: unknown; + timestamp: string; +} + +export async function queueOfflineSubmission(data: unknown): Promise { + const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY); + const queue: QueuedSubmission[] = stored ? JSON.parse(stored) : []; + queue.push({ data, timestamp: new Date().toISOString() }); + await AsyncStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue)); +} + +export async function processOfflineQueue( + submitFn: (data: unknown) => Promise, +): Promise { + const state = await NetInfo.fetch(); + if (!state.isConnected) return 0; + + const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY); + if (!stored) return 0; + + const queue: QueuedSubmission[] = JSON.parse(stored); + let processed = 0; + + for (const item of queue) { + try { + await submitFn(item.data); + processed++; + } catch { + break; + } + } + + if (processed === queue.length) { + await AsyncStorage.removeItem(OFFLINE_QUEUE_KEY); + } else if (processed > 0) { + await AsyncStorage.setItem( + OFFLINE_QUEUE_KEY, + JSON.stringify(queue.slice(processed)), + ); + } + + return processed; +} + +export async function getOfflineQueueCount(): Promise { + const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY); + if (!stored) return 0; + return JSON.parse(stored).length; +} diff --git a/mobile/src/hooks/usePaginatedList.ts b/mobile/src/hooks/usePaginatedList.ts new file mode 100644 index 0000000..8d439df --- /dev/null +++ b/mobile/src/hooks/usePaginatedList.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants'; + +interface PaginatedResult { + items: T[]; + totalCount: number; +} + +type FetchFn = ( + params: Record, +) => Promise>; + +export function usePaginatedList( + fetchFn: FetchFn, + extraParams: Record = {}, +) { + const [rows, setRows] = useState([]); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [page, setPage] = useState(DEFAULT_PAGE); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(''); + const debounceRef = useRef | null>(null); + + const extraKey = JSON.stringify(extraParams); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, DEBOUNCE_SEARCH); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [search]); + + const reload = useCallback(() => { + setLoading(true); + setErr(''); + fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams }) + .then(({ items, totalCount: total }) => { + setRows(items); + setTotalCount(total); + }) + .catch((e: Error) => setErr(e.message || 'Failed to load')) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchFn, debouncedSearch, page, pageSize, extraKey]); + + useEffect(() => { + reload(); + }, [reload]); + + return useMemo( + () => ({ + rows, + search, + setSearch, + page, + setPage, + pageSize, + setPageSize, + totalCount, + loading, + err, + reload, + }), + [rows, search, page, pageSize, totalCount, loading, err, reload], + ); +} diff --git a/mobile/src/lib/api/admin.ts b/mobile/src/lib/api/admin.ts new file mode 100644 index 0000000..634c795 --- /dev/null +++ b/mobile/src/lib/api/admin.ts @@ -0,0 +1,76 @@ +import apiClient from './client'; + +export interface DashboardStats { + pendingCount: number; + approvedThisWeek: number; + avgTurnaroundHours: number; + totalProposals: number; +} + +export interface UpdateProposalRequest { + refinedScope?: string; + notes?: string; + assignedAdminId?: string; +} + +export interface AuditEntry { + id: string; + proposalId: string | null; + userId: string; + userName: string; + action: string; + details: Record; + timestamp: string; + ipAddress: string | null; +} + +export const adminApi = { + getDashboard: async (): Promise => { + const res = await apiClient.get('/admin/dashboard'); + return res.data; + }, + + updateProposal: async ( + id: string, + data: UpdateProposalRequest, + ): Promise => { + await apiClient.put(`/proposals/${id}`, data); + }, + + approveProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/approve`); + }, + + sendProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/send`); + }, + + reviseProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/revise`); + }, + + getAudit: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/audit`); + return res.data; + }, + + generateSuggestions: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/generate-suggestions`); + }, + + getSimilar: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/similar`); + return res.data; + }, + + getPdf: async ( + id: string, + ): Promise<{ downloadUrl: string; expiresAt: string } | null> => { + try { + const res = await apiClient.get(`/proposals/${id}/pdf`); + return res.data; + } catch { + return null; + } + }, +}; diff --git a/mobile/src/lib/api/auth.ts b/mobile/src/lib/api/auth.ts new file mode 100644 index 0000000..8ca649c --- /dev/null +++ b/mobile/src/lib/api/auth.ts @@ -0,0 +1,78 @@ +import { authorize, refresh, revoke } from 'react-native-app-auth'; +import Config from '../../config'; +import apiClient from './client'; +import { + tokenStorage, + userStorage, + StoredTokens, + StoredUser, +} from '../storage'; + +const cognitoConfig = { + clientId: Config.COGNITO_CLIENT_ID, + redirectUrl: Config.COGNITO_REDIRECT_URI, + scopes: Config.COGNITO_SCOPES, + serviceConfiguration: { + authorizationEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/authorize`, + tokenEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/token`, + revocationEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/revoke`, + }, + usePKCE: true, + iosPrefersEphemeralSession: false, +}; + +export const authApi = { + login: async (): Promise => { + const result = await authorize(cognitoConfig); + + const tokens: StoredTokens = { + accessToken: result.accessToken, + idToken: result.idToken, + refreshToken: result.refreshToken, + expiresAt: result.accessTokenExpirationDate, + }; + await tokenStorage.save(tokens); + + const profile = await authApi.getMe(); + await userStorage.save(profile); + return profile; + }, + + refreshTokens: async (): Promise => { + const stored = await tokenStorage.get(); + if (!stored?.refreshToken) throw new Error('No refresh token'); + + const result = await refresh(cognitoConfig, { + refreshToken: stored.refreshToken, + }); + + await tokenStorage.save({ + accessToken: result.accessToken, + idToken: result.idToken || stored.idToken, + refreshToken: result.refreshToken || stored.refreshToken, + expiresAt: result.accessTokenExpirationDate, + }); + }, + + logout: async (): Promise => { + const stored = await tokenStorage.get(); + if (stored?.accessToken) { + try { + await revoke(cognitoConfig, { + tokenToRevoke: stored.accessToken, + includeBasicAuth: false, + sendClientId: true, + }); + } catch { + // Best-effort revocation + } + } + await tokenStorage.clear(); + await userStorage.clear(); + }, + + getMe: async (): Promise => { + const res = await apiClient.get('/users/me'); + return res.data; + }, +}; diff --git a/mobile/src/lib/api/client.ts b/mobile/src/lib/api/client.ts new file mode 100644 index 0000000..22aa326 --- /dev/null +++ b/mobile/src/lib/api/client.ts @@ -0,0 +1,64 @@ +import axios from 'axios'; +import Config from '../../config'; +import { tokenStorage } from '../storage'; + +const apiClient = axios.create({ + baseURL: Config.API_URL, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, +}); + +apiClient.interceptors.request.use( + async (config) => { + const tokens = await tokenStorage.get(); + if (tokens?.accessToken) { + config.headers.Authorization = `Bearer ${tokens.accessToken}`; + } + return config; + }, + (error) => Promise.reject(error), +); + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response) { + const { status, data } = error.response; + + if (status === 401) { + tokenStorage.clear(); + return Promise.reject( + new Error('Session expired. Please log in again.'), + ); + } + + if (status === 403) { + return Promise.reject( + new Error('You do not have permission to perform this action.'), + ); + } + + if (status === 404) { + return Promise.reject( + new Error('The requested resource was not found.'), + ); + } + + const message = + data?.detail || data?.title || data?.message || 'An error occurred'; + return Promise.reject(new Error(message)); + } + + if (error.request) { + return Promise.reject( + new Error('No response from server. Please check your connection.'), + ); + } + + return Promise.reject(error); + }, +); + +export default apiClient; diff --git a/mobile/src/lib/api/customers.ts b/mobile/src/lib/api/customers.ts new file mode 100644 index 0000000..ca6f414 --- /dev/null +++ b/mobile/src/lib/api/customers.ts @@ -0,0 +1,24 @@ +import apiClient from './client'; + +export interface Customer { + id: string; + name: string; + addresses: string[]; + createdAt: string; +} + +export const customersApi = { + search: async (query?: string): Promise => { + const params = query ? `?query=${encodeURIComponent(query)}` : ''; + const res = await apiClient.get(`/customers${params}`); + return res.data; + }, + + create: async (data: { + name: string; + addresses?: string[]; + }): Promise => { + const res = await apiClient.post('/customers', data); + return res.data; + }, +}; diff --git a/mobile/src/lib/api/lineItems.ts b/mobile/src/lib/api/lineItems.ts new file mode 100644 index 0000000..a8f759c --- /dev/null +++ b/mobile/src/lib/api/lineItems.ts @@ -0,0 +1,50 @@ +import apiClient from './client'; +import type { PricingMode, LineItemSource } from '@proposal-system/api-contracts'; + +export interface LineItem { + id: string; + proposalId: string; + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; + createdAt: string; + updatedAt: string; +} + +export interface UpdateLineItemEntry { + id: string | null; + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; +} + +export const lineItemsApi = { + getAll: async (proposalId: string): Promise => { + const res = await apiClient.get(`/proposals/${proposalId}/line-items`); + return res.data; + }, + + bulkUpdate: async ( + proposalId: string, + lineItems: UpdateLineItemEntry[], + ): Promise => { + const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { + lineItems, + }); + return res.data; + }, + + delete: async (proposalId: string, itemId: string): Promise => { + await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`); + }, +}; diff --git a/mobile/src/lib/api/proposals.ts b/mobile/src/lib/api/proposals.ts new file mode 100644 index 0000000..39cc916 --- /dev/null +++ b/mobile/src/lib/api/proposals.ts @@ -0,0 +1,113 @@ +import apiClient from './client'; +import type { CreateProposalRequest } from '@proposal-system/api-contracts'; + +export interface ProposalListItem { + id: string; + proposalNumber: string; + customerName: string; + workOrderNumber: string; + serviceCategory: string; + priority: string; + status: string; + totalBidAmount: number; + submittedAt: string; + submittedByName: string | null; + assignedAdminName: string | null; +} + +export interface ProposalDetail { + id: string; + proposalNumber: string; + workOrderNumber: string; + customerName: string; + customerAddress: string; + scopeOfWork: string; + refinedScope: string | null; + serviceCategory: string; + priority: string; + status: string; + totalBidAmount: number; + vendorTotalCost: number | null; + notes: string; + submittedById: string; + submittedByName: string | null; + submittedAt: string; + assignedAdminId: string | null; + approvedById: string | null; + approvedAt: string | null; + sentAt: string | null; + currentRevision: number; + parentProposalId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface PagedResponse { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +export interface ProposalFilters { + search?: string; + page?: number; + pageSize?: number; + status?: string; + serviceCategory?: string; + priority?: string; + fromDate?: string; + toDate?: string; +} + +export interface ProposalStats { + totalCount: number; + inReviewCount: number; + approvedCount: number; + sentCount: number; +} + +export { CreateProposalRequest }; + +export const proposalsApi = { + create: async (data: CreateProposalRequest): Promise => { + const res = await apiClient.post('/proposals', data); + return res.data; + }, + + getAll: async ( + filters: ProposalFilters = {}, + ): Promise> => { + const params = new URLSearchParams(); + if (filters.search) params.append('search', filters.search); + if (filters.page) params.append('page', String(filters.page)); + if (filters.pageSize) params.append('pageSize', String(filters.pageSize)); + if (filters.status) params.append('status', filters.status); + if (filters.serviceCategory) + params.append('serviceCategory', filters.serviceCategory); + if (filters.priority) params.append('priority', filters.priority); + + const res = await apiClient.get(`/proposals?${params.toString()}`); + return res.data; + }, + + getById: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}`); + return res.data; + }, + + uploadAttachment: async ( + proposalId: string, + fileName: string, + ): Promise<{ uploadUrl: string; s3Key: string }> => { + const res = await apiClient.post( + `/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`, + ); + return res.data; + }, + + getStats: async (): Promise => { + const res = await apiClient.get('/proposals/stats'); + return res.data; + }, +}; diff --git a/mobile/src/lib/queryClient.ts b/mobile/src/lib/queryClient.ts new file mode 100644 index 0000000..614a97e --- /dev/null +++ b/mobile/src/lib/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: 1, + }, + }, +}); diff --git a/mobile/src/lib/storage.ts b/mobile/src/lib/storage.ts new file mode 100644 index 0000000..2acc5de --- /dev/null +++ b/mobile/src/lib/storage.ts @@ -0,0 +1,67 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; +import { KEYCHAIN_SERVICE, STORAGE_KEY_USER } from '../constants'; + +export interface StoredTokens { + accessToken: string; + idToken: string; + refreshToken: string; + expiresAt: string; +} + +export interface StoredUser { + id: string; + email: string; + displayName: string; + role: 'Dispatcher' | 'Admin' | 'SysAdmin'; +} + +export const tokenStorage = { + save: async (tokens: StoredTokens): Promise => { + await Keychain.setGenericPassword('tokens', JSON.stringify(tokens), { + service: KEYCHAIN_SERVICE, + }); + }, + + get: async (): Promise => { + const result = await Keychain.getGenericPassword({ + service: KEYCHAIN_SERVICE, + }); + if (!result) return null; + try { + return JSON.parse(result.password); + } catch { + return null; + } + }, + + clear: async (): Promise => { + await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE }); + }, + + isExpired: async (): Promise => { + const tokens = await tokenStorage.get(); + if (!tokens) return true; + return new Date(tokens.expiresAt) <= new Date(); + }, +}; + +export const userStorage = { + save: async (user: StoredUser): Promise => { + await AsyncStorage.setItem(STORAGE_KEY_USER, JSON.stringify(user)); + }, + + get: async (): Promise => { + const data = await AsyncStorage.getItem(STORAGE_KEY_USER); + if (!data) return null; + try { + return JSON.parse(data); + } catch { + return null; + } + }, + + clear: async (): Promise => { + await AsyncStorage.removeItem(STORAGE_KEY_USER); + }, +}; diff --git a/mobile/src/store/index.ts b/mobile/src/store/index.ts new file mode 100644 index 0000000..00cafb7 --- /dev/null +++ b/mobile/src/store/index.ts @@ -0,0 +1,17 @@ +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from './slices/authSlice'; + +export const store = configureStore({ + reducer: { + auth: authReducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: ['auth/setUser'], + }, + }), +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/mobile/src/store/slices/authSlice.ts b/mobile/src/store/slices/authSlice.ts new file mode 100644 index 0000000..ede3d8d --- /dev/null +++ b/mobile/src/store/slices/authSlice.ts @@ -0,0 +1,57 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { StoredUser } from '../../lib/storage'; + +interface AuthState { + user: StoredUser | null; + isAuthenticated: boolean; + loading: boolean; + error: string | null; +} + +const initialState: AuthState = { + user: null, + isAuthenticated: false, + loading: true, + error: null, +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setUser: (state, action: PayloadAction) => { + state.user = action.payload; + state.isAuthenticated = true; + state.loading = false; + state.error = null; + }, + logout: (state) => { + state.user = null; + state.isAuthenticated = false; + state.error = null; + }, + setLoading: (state, action: PayloadAction) => { + state.loading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + state.loading = false; + }, + clearError: (state) => { + state.error = null; + }, + }, +}); + +export const { setUser, logout, setLoading, setError, clearError } = + authSlice.actions; + +export const selectUser = (state: { auth: AuthState }) => state.auth.user; +export const selectIsAuthenticated = (state: { auth: AuthState }) => + state.auth.isAuthenticated; +export const selectAuthLoading = (state: { auth: AuthState }) => + state.auth.loading; +export const selectAuthError = (state: { auth: AuthState }) => + state.auth.error; + +export default authSlice.reducer; diff --git a/mobile/src/theme/index.ts b/mobile/src/theme/index.ts new file mode 100644 index 0000000..7d1b6e5 --- /dev/null +++ b/mobile/src/theme/index.ts @@ -0,0 +1,36 @@ +import { MD3LightTheme } from 'react-native-paper'; + +export const theme = { + ...MD3LightTheme, + colors: { + ...MD3LightTheme.colors, + primary: '#1565C0', + primaryContainer: '#E3F2FD', + secondary: '#2E7D32', + secondaryContainer: '#E8F5E9', + tertiary: '#E65100', + tertiaryContainer: '#FFF3E0', + error: '#D32F2F', + errorContainer: '#FFEBEE', + background: '#FAFAFA', + surface: '#FFFFFF', + surfaceVariant: '#F5F5F5', + outline: '#BDBDBD', + }, +}; + +export const statusColors: Record = { + Draft: '#9E9E9E', + InReview: '#1565C0', + Approved: '#2E7D32', + Sent: '#7B1FA2', + Revised: '#E65100', +}; + +export const priorityColors: Record = { + Standard: '#757575', + Urgent: '#E65100', + Emergency: '#D32F2F', +}; + +export type AppTheme = typeof theme;