Add mobile core: API client, auth, storage, state, and offline support

Keychain token storage, Axios client with async token injection, Cognito
PKCE auth via react-native-app-auth, Redux auth slice, TanStack Query
config, offline draft queue with NetInfo auto-submit, and SHOC theme.
This commit is contained in:
Adam Moussa 2026-05-17 15:09:23 -04:00
parent 04d28cd793
commit 47c3b8e5eb
18 changed files with 935 additions and 0 deletions

74
mobile/src/App.tsx Normal file
View file

@ -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 (
<ReduxProvider store={store}>
<QueryClientProvider client={queryClient}>
<PaperProvider theme={theme}>
<SafeAreaProvider>
<AuthBootstrap>
<RootNavigator />
</AuthBootstrap>
</SafeAreaProvider>
</PaperProvider>
</QueryClientProvider>
</ReduxProvider>
);
}

12
mobile/src/config.ts Normal file
View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,9 @@
export const QUERY_KEYS = {
proposals: 'proposals',
proposal: 'proposal',
proposalLineItems: 'proposalLineItems',
customers: 'customers',
users: 'users',
dashboard: 'dashboard',
auditTrail: 'auditTrail',
} as const;

View file

@ -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,
};
}

View file

@ -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<T extends Record<string, unknown>>(
initialData: T,
) {
const [draft, setDraft] = useState<T>(initialData);
const [isLoaded, setIsLoaded] = useState(false);
const saveTimeout = useRef<ReturnType<typeof setTimeout>>();
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<T>) => {
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<void> {
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<unknown>,
): Promise<number> {
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<number> {
const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY);
if (!stored) return 0;
return JSON.parse(stored).length;
}

View file

@ -0,0 +1,73 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants';
interface PaginatedResult<T> {
items: T[];
totalCount: number;
}
type FetchFn<T> = (
params: Record<string, unknown>,
) => Promise<PaginatedResult<T>>;
export function usePaginatedList<T>(
fetchFn: FetchFn<T>,
extraParams: Record<string, unknown> = {},
) {
const [rows, setRows] = useState<T[]>([]);
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<ReturnType<typeof setTimeout> | 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],
);
}

View file

@ -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<string, unknown>;
timestamp: string;
ipAddress: string | null;
}
export const adminApi = {
getDashboard: async (): Promise<DashboardStats> => {
const res = await apiClient.get('/admin/dashboard');
return res.data;
},
updateProposal: async (
id: string,
data: UpdateProposalRequest,
): Promise<void> => {
await apiClient.put(`/proposals/${id}`, data);
},
approveProposal: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/approve`);
},
sendProposal: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/send`);
},
reviseProposal: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/revise`);
},
getAudit: async (id: string): Promise<AuditEntry[]> => {
const res = await apiClient.get(`/proposals/${id}/audit`);
return res.data;
},
generateSuggestions: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/generate-suggestions`);
},
getSimilar: async (id: string): Promise<unknown[]> => {
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;
}
},
};

View file

@ -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<StoredUser> => {
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<void> => {
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<void> => {
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<StoredUser> => {
const res = await apiClient.get('/users/me');
return res.data;
},
};

View file

@ -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;

View file

@ -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<Customer[]> => {
const params = query ? `?query=${encodeURIComponent(query)}` : '';
const res = await apiClient.get(`/customers${params}`);
return res.data;
},
create: async (data: {
name: string;
addresses?: string[];
}): Promise<Customer> => {
const res = await apiClient.post('/customers', data);
return res.data;
},
};

View file

@ -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<LineItem[]> => {
const res = await apiClient.get(`/proposals/${proposalId}/line-items`);
return res.data;
},
bulkUpdate: async (
proposalId: string,
lineItems: UpdateLineItemEntry[],
): Promise<LineItem[]> => {
const res = await apiClient.put(`/proposals/${proposalId}/line-items`, {
lineItems,
});
return res.data;
},
delete: async (proposalId: string, itemId: string): Promise<void> => {
await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`);
},
};

View file

@ -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<T> {
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<ProposalDetail> => {
const res = await apiClient.post('/proposals', data);
return res.data;
},
getAll: async (
filters: ProposalFilters = {},
): Promise<PagedResponse<ProposalListItem>> => {
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<ProposalDetail> => {
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<ProposalStats> => {
const res = await apiClient.get('/proposals/stats');
return res.data;
},
};

View file

@ -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,
},
},
});

67
mobile/src/lib/storage.ts Normal file
View file

@ -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<void> => {
await Keychain.setGenericPassword('tokens', JSON.stringify(tokens), {
service: KEYCHAIN_SERVICE,
});
},
get: async (): Promise<StoredTokens | null> => {
const result = await Keychain.getGenericPassword({
service: KEYCHAIN_SERVICE,
});
if (!result) return null;
try {
return JSON.parse(result.password);
} catch {
return null;
}
},
clear: async (): Promise<void> => {
await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE });
},
isExpired: async (): Promise<boolean> => {
const tokens = await tokenStorage.get();
if (!tokens) return true;
return new Date(tokens.expiresAt) <= new Date();
},
};
export const userStorage = {
save: async (user: StoredUser): Promise<void> => {
await AsyncStorage.setItem(STORAGE_KEY_USER, JSON.stringify(user));
},
get: async (): Promise<StoredUser | null> => {
const data = await AsyncStorage.getItem(STORAGE_KEY_USER);
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
},
clear: async (): Promise<void> => {
await AsyncStorage.removeItem(STORAGE_KEY_USER);
},
};

17
mobile/src/store/index.ts Normal file
View file

@ -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<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View file

@ -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<StoredUser>) => {
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<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string>) => {
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;

36
mobile/src/theme/index.ts Normal file
View file

@ -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<string, string> = {
Draft: '#9E9E9E',
InReview: '#1565C0',
Approved: '#2E7D32',
Sent: '#7B1FA2',
Revised: '#E65100',
};
export const priorityColors: Record<string, string> = {
Standard: '#757575',
Urgent: '#E65100',
Emergency: '#D32F2F',
};
export type AppTheme = typeof theme;