mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 08:53:15 +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