mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-03 23:33:18 +00:00
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:
parent
04d28cd793
commit
47c3b8e5eb
18 changed files with 935 additions and 0 deletions
74
mobile/src/App.tsx
Normal file
74
mobile/src/App.tsx
Normal 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
12
mobile/src/config.ts
Normal 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;
|
||||||
33
mobile/src/constants/index.ts
Normal file
33
mobile/src/constants/index.ts
Normal 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;
|
||||||
9
mobile/src/constants/queryKeys.ts
Normal file
9
mobile/src/constants/queryKeys.ts
Normal 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;
|
||||||
41
mobile/src/hooks/useAuth.ts
Normal file
41
mobile/src/hooks/useAuth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
100
mobile/src/hooks/useOfflineDraft.ts
Normal file
100
mobile/src/hooks/useOfflineDraft.ts
Normal 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;
|
||||||
|
}
|
||||||
73
mobile/src/hooks/usePaginatedList.ts
Normal file
73
mobile/src/hooks/usePaginatedList.ts
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
76
mobile/src/lib/api/admin.ts
Normal file
76
mobile/src/lib/api/admin.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
78
mobile/src/lib/api/auth.ts
Normal file
78
mobile/src/lib/api/auth.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
64
mobile/src/lib/api/client.ts
Normal file
64
mobile/src/lib/api/client.ts
Normal 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;
|
||||||
24
mobile/src/lib/api/customers.ts
Normal file
24
mobile/src/lib/api/customers.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
50
mobile/src/lib/api/lineItems.ts
Normal file
50
mobile/src/lib/api/lineItems.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
113
mobile/src/lib/api/proposals.ts
Normal file
113
mobile/src/lib/api/proposals.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
11
mobile/src/lib/queryClient.ts
Normal file
11
mobile/src/lib/queryClient.ts
Normal 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
67
mobile/src/lib/storage.ts
Normal 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
17
mobile/src/store/index.ts
Normal 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;
|
||||||
57
mobile/src/store/slices/authSlice.ts
Normal file
57
mobile/src/store/slices/authSlice.ts
Normal 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
36
mobile/src/theme/index.ts
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue