feat: implement full caregiver application feature set including services, availability, profile, and navigation modules
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AppNavigator } from './src/navigation/AppNavigator';
|
import { AppNavigator } from './src/navigation/AppNavigator';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { SnackbarProvider } from './src/context/SnackbarContext';
|
||||||
import { LogBox, StatusBar } from 'react-native';
|
import { LogBox, StatusBar } from 'react-native';
|
||||||
|
|
||||||
// LogBox.ignoreAllLogs(true);
|
// LogBox.ignoreAllLogs(true);
|
||||||
@@ -16,7 +17,9 @@ function App(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar barStyle="dark-content" backgroundColor="#808080" translucent={false} />
|
<StatusBar barStyle="dark-content" backgroundColor="#808080" translucent={false} />
|
||||||
|
<SnackbarProvider>
|
||||||
<AppNavigator />
|
<AppNavigator />
|
||||||
|
</SnackbarProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const Button: React.FC<ButtonProps> = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color={variant === 'outline' || variant === 'ghost' ? '#4F46E5' : '#FFFFFF'} />
|
<ActivityIndicator color={variant === 'outline' || variant === 'ghost' ? '#1E3A8A' : '#FFFFFF'} />
|
||||||
) : (
|
) : (
|
||||||
<Typography weight="medium" style={getTextStyle()}>
|
<Typography weight="medium" style={getTextStyle()}>
|
||||||
{title}
|
{title}
|
||||||
@@ -80,14 +80,14 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#D1D5DB',
|
backgroundColor: '#D1D5DB',
|
||||||
},
|
},
|
||||||
bgPrimary: {
|
bgPrimary: {
|
||||||
backgroundColor: '#4F46E5',
|
backgroundColor: '#1E3A8A',
|
||||||
},
|
},
|
||||||
bgSecondary: {
|
bgSecondary: {
|
||||||
backgroundColor: '#10B981',
|
backgroundColor: '#10B981',
|
||||||
},
|
},
|
||||||
bgOutline: {
|
bgOutline: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
borderColor: '#4F46E5',
|
borderColor: '#1E3A8A',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
},
|
},
|
||||||
bgGhost: {
|
bgGhost: {
|
||||||
@@ -100,6 +100,6 @@ const styles = StyleSheet.create({
|
|||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
},
|
},
|
||||||
textPrimary: {
|
textPrimary: {
|
||||||
color: '#4F46E5',
|
color: '#1E3A8A',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
} from 'react-native';
|
||||||
|
import { ChevronDown } from 'lucide-react-native';
|
||||||
|
import { Typography } from './Typography';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
|
|
||||||
|
export interface DropdownItem {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
data: DropdownItem[];
|
||||||
|
value?: string | number | (string | number)[];
|
||||||
|
onChange: (value: any) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
isMulti?: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
|
data,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select an option',
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
isMulti = false,
|
||||||
|
searchable = true,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
navigation.navigate('Selection', {
|
||||||
|
data,
|
||||||
|
value,
|
||||||
|
isMulti,
|
||||||
|
searchable,
|
||||||
|
label,
|
||||||
|
searchPlaceholder,
|
||||||
|
onSelect: onChange,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSelectedItemText = () => {
|
||||||
|
if (isMulti) {
|
||||||
|
const currentValue = (Array.isArray(value) ? value : []) as (string | number)[];
|
||||||
|
if (currentValue.length === 0) return placeholder;
|
||||||
|
|
||||||
|
const selectedItems = data.filter((item) => currentValue.includes(item.value));
|
||||||
|
return selectedItems.map((item) => item.label).join(', ');
|
||||||
|
} else {
|
||||||
|
if (value == null || value === '') return placeholder;
|
||||||
|
const selectedItem = data.find((item) => item.value === value);
|
||||||
|
return selectedItem ? selectedItem.label : placeholder;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{label && (
|
||||||
|
<Typography variant="caption" weight="bold" style={styles.label}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.dropdownButton,
|
||||||
|
error && styles.dropdownButtonError,
|
||||||
|
disabled && styles.dropdownButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dropdownButtonText,
|
||||||
|
(!value || (Array.isArray(value) && value.length === 0)) && styles.placeholderText,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{renderSelectedItemText()}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown color="#6B7280" size={20} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Typography variant="caption" style={styles.errorText}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: 16,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
marginBottom: 8,
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
dropdownButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#D1D5DB',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
minHeight: 52,
|
||||||
|
},
|
||||||
|
dropdownButtonError: {
|
||||||
|
borderColor: '#EF4444',
|
||||||
|
},
|
||||||
|
dropdownButtonDisabled: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
dropdownButtonText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#111827',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
placeholderText: {
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: '#EF4444',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -62,8 +62,6 @@ const styles = StyleSheet.create({
|
|||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React, { createContext, useContext, useState, useRef, ReactNode, useEffect } from 'react';
|
||||||
|
import { View, Text, Animated, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { THEME } from '../constants/theme';
|
||||||
|
import { CheckCircle2, AlertCircle, X } from 'lucide-react-native';
|
||||||
|
|
||||||
|
type SnackbarType = 'success' | 'error';
|
||||||
|
|
||||||
|
interface SnackbarContextProps {
|
||||||
|
showSnackbar: (message: string, type?: SnackbarType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SnackbarContext = createContext<SnackbarContextProps | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useSnackbar = () => {
|
||||||
|
const context = useContext(SnackbarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSnackbar must be used within a SnackbarProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SnackbarProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [type, setType] = useState<SnackbarType>('success');
|
||||||
|
const slideAnim = useRef(new Animated.Value(100)).current;
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const showSnackbar = (msg: string, msgType: SnackbarType = 'success') => {
|
||||||
|
setMessage(msg);
|
||||||
|
setType(msgType);
|
||||||
|
setVisible(true);
|
||||||
|
|
||||||
|
// Slide up
|
||||||
|
Animated.spring(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
speed: 12,
|
||||||
|
bounciness: 8,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
// Auto hide
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
hideSnackbar();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideSnackbar = () => {
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 150,
|
||||||
|
duration: 250,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setVisible(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SnackbarContext.Provider value={{ showSnackbar }}>
|
||||||
|
{children}
|
||||||
|
{visible && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
bottom: Math.max(insets.bottom + 16, 16),
|
||||||
|
transform: [{ translateY: slideAnim }],
|
||||||
|
},
|
||||||
|
type === 'success' ? styles.successContainer : styles.errorContainer
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{type === 'success' ? (
|
||||||
|
<CheckCircle2 color="#FFF" size={24} />
|
||||||
|
) : (
|
||||||
|
<AlertCircle color="#FFF" size={24} />
|
||||||
|
)}
|
||||||
|
<Text style={styles.messageText}>{message}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={hideSnackbar} style={styles.closeBtn}>
|
||||||
|
<X color="#FFF" size={20} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</SnackbarContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
successContainer: {
|
||||||
|
backgroundColor: '#059669', // A nice green for success
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
backgroundColor: '#EF4444', // Red for errors
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginLeft: 12,
|
||||||
|
marginRight: 16,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
closeBtn: {
|
||||||
|
padding: 4,
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Home, Calendar, Settings } from 'lucide-react-native';
|
import { Home, Calendar, Settings } from 'lucide-react-native';
|
||||||
|
|
||||||
import { HomeScreen } from '../screens/HomeScreen';
|
import { HomeScreen } from '../screens/HomeScreen';
|
||||||
@@ -15,13 +16,28 @@ import { AddSkillScreen } from '../screens/AddSkillScreen';
|
|||||||
import { AvailabilityListScreen } from '../screens/AvailabilityListScreen';
|
import { AvailabilityListScreen } from '../screens/AvailabilityListScreen';
|
||||||
import { AddAvailabilityScreen } from '../screens/AddAvailabilityScreen';
|
import { AddAvailabilityScreen } from '../screens/AddAvailabilityScreen';
|
||||||
import { EditAvailabilityScreen } from '../screens/EditAvailabilityScreen';
|
import { EditAvailabilityScreen } from '../screens/EditAvailabilityScreen';
|
||||||
import { PaymentProfileScreen } from '../screens/PaymentProfileScreen';
|
import { PaymentProfileListScreen } from '../screens/PaymentProfileListScreen';
|
||||||
|
import { AddEditPaymentProfileScreen } from '../screens/AddEditPaymentProfileScreen';
|
||||||
import { ManageServicesScreen } from '../screens/ManageServicesScreen';
|
import { ManageServicesScreen } from '../screens/ManageServicesScreen';
|
||||||
import { AddEditServiceScreen } from '../screens/AddEditServiceScreen';
|
import { AddEditServiceScreen } from '../screens/AddEditServiceScreen';
|
||||||
|
import { ProfessionalDocumentsScreen } from '../screens/ProfessionalDocumentsScreen';
|
||||||
|
import { UploadDocumentScreen } from '../screens/UploadDocumentScreen';
|
||||||
|
import { ViewDocumentScreen } from '../screens/ViewDocumentScreen';
|
||||||
|
import { SelectionScreen } from '../screens/SelectionScreen';
|
||||||
import { Service } from '../services/serviceService';
|
import { Service } from '../services/serviceService';
|
||||||
|
import { navigationRef } from './navigationRef';
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Login: undefined;
|
Login: undefined;
|
||||||
|
Selection: {
|
||||||
|
data: any[];
|
||||||
|
value: any;
|
||||||
|
isMulti?: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
label?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
onSelect: (value: any) => void;
|
||||||
|
};
|
||||||
MainTabs: undefined;
|
MainTabs: undefined;
|
||||||
Profile: { caregiverId: string };
|
Profile: { caregiverId: string };
|
||||||
PersonalInformation: undefined;
|
PersonalInformation: undefined;
|
||||||
@@ -34,9 +50,13 @@ export type RootStackParamList = {
|
|||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
};
|
};
|
||||||
PaymentProfile: undefined;
|
PaymentProfileList: undefined;
|
||||||
|
AddEditPaymentProfile: { profile?: any };
|
||||||
ManageServices: undefined;
|
ManageServices: undefined;
|
||||||
AddEditService: { service?: Service };
|
AddEditService: { service?: Service };
|
||||||
|
ProfessionalDocuments: undefined;
|
||||||
|
UploadDocument: undefined;
|
||||||
|
ViewDocument: { documentId: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TabParamList = {
|
export type TabParamList = {
|
||||||
@@ -49,6 +69,8 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
|
|||||||
const Tab = createBottomTabNavigator<TabParamList>();
|
const Tab = createBottomTabNavigator<TabParamList>();
|
||||||
|
|
||||||
function MainTabs() {
|
function MainTabs() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
id={undefined}
|
id={undefined}
|
||||||
@@ -59,8 +81,8 @@ function MainTabs() {
|
|||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shadowOpacity: 0,
|
shadowOpacity: 0,
|
||||||
height: 60,
|
height: 60 + insets.bottom,
|
||||||
paddingBottom: 8,
|
paddingBottom: 8 + insets.bottom,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
},
|
},
|
||||||
tabBarActiveTintColor: 'navy', // Primary color
|
tabBarActiveTintColor: 'navy', // Primary color
|
||||||
@@ -106,7 +128,7 @@ const MyTheme = {
|
|||||||
|
|
||||||
export const AppNavigator = () => {
|
export const AppNavigator = () => {
|
||||||
return (
|
return (
|
||||||
<NavigationContainer theme={MyTheme}>
|
<NavigationContainer ref={navigationRef} theme={MyTheme}>
|
||||||
<Stack.Navigator
|
<Stack.Navigator
|
||||||
id={undefined}
|
id={undefined}
|
||||||
initialRouteName="Login"
|
initialRouteName="Login"
|
||||||
@@ -124,9 +146,14 @@ export const AppNavigator = () => {
|
|||||||
<Stack.Screen name="AvailabilityList" component={AvailabilityListScreen} />
|
<Stack.Screen name="AvailabilityList" component={AvailabilityListScreen} />
|
||||||
<Stack.Screen name="AddAvailability" component={AddAvailabilityScreen} />
|
<Stack.Screen name="AddAvailability" component={AddAvailabilityScreen} />
|
||||||
<Stack.Screen name="EditAvailability" component={EditAvailabilityScreen} />
|
<Stack.Screen name="EditAvailability" component={EditAvailabilityScreen} />
|
||||||
<Stack.Screen name="PaymentProfile" component={PaymentProfileScreen} />
|
<Stack.Screen name="PaymentProfileList" component={PaymentProfileListScreen} />
|
||||||
|
<Stack.Screen name="AddEditPaymentProfile" component={AddEditPaymentProfileScreen} />
|
||||||
<Stack.Screen name="ManageServices" component={ManageServicesScreen} />
|
<Stack.Screen name="ManageServices" component={ManageServicesScreen} />
|
||||||
<Stack.Screen name="AddEditService" component={AddEditServiceScreen} />
|
<Stack.Screen name="AddEditService" component={AddEditServiceScreen} />
|
||||||
|
<Stack.Screen name="ProfessionalDocuments" component={ProfessionalDocumentsScreen} />
|
||||||
|
<Stack.Screen name="UploadDocument" component={UploadDocumentScreen} />
|
||||||
|
<Stack.Screen name="ViewDocument" component={ViewDocumentScreen} />
|
||||||
|
<Stack.Screen name="Selection" component={SelectionScreen} options={{ presentation: 'card' }} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createNavigationContainerRef } from '@react-navigation/native';
|
||||||
|
import { RootStackParamList } from './AppNavigator';
|
||||||
|
|
||||||
|
export const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||||
|
|
||||||
|
export function navigate(name: keyof RootStackParamList, params?: any) {
|
||||||
|
if (navigationRef.isReady()) {
|
||||||
|
navigationRef.navigate(name, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, Alert, Platform } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, Alert, Platform } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import { Typography } from '../components/ui/Typography';
|
import { Typography } from '../components/ui/Typography';
|
||||||
@@ -8,6 +8,7 @@ import { Header } from '../components/ui/Header';
|
|||||||
import { profileService } from '../services/profileService';
|
import { profileService } from '../services/profileService';
|
||||||
import { THEME } from '../constants/theme';
|
import { THEME } from '../constants/theme';
|
||||||
import { Clock, Calendar, Check } from 'lucide-react-native';
|
import { Clock, Calendar, Check } from 'lucide-react-native';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
|
|
||||||
const DAYS_OF_WEEK = [
|
const DAYS_OF_WEEK = [
|
||||||
{ label: 'Monday', value: 'monday' },
|
{ label: 'Monday', value: 'monday' },
|
||||||
@@ -21,6 +22,8 @@ const DAYS_OF_WEEK = [
|
|||||||
|
|
||||||
export const AddAvailabilityScreen = () => {
|
export const AddAvailabilityScreen = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
|
|
||||||
const [selectedDay, setSelectedDay] = useState('monday');
|
const [selectedDay, setSelectedDay] = useState('monday');
|
||||||
const [showDayPicker, setShowDayPicker] = useState(false);
|
const [showDayPicker, setShowDayPicker] = useState(false);
|
||||||
@@ -72,9 +75,8 @@ export const AddAvailabilityScreen = () => {
|
|||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await profileService.addDayAvailability(payload);
|
await profileService.addDayAvailability(payload);
|
||||||
Alert.alert('Success', 'Availability added successfully.', [
|
showSnackbar('Availability added successfully.');
|
||||||
{ text: 'OK', onPress: () => navigation.goBack() }
|
navigation.goBack();
|
||||||
]);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Error', e.response?.data?.message || 'Failed to add availability.');
|
Alert.alert('Error', e.response?.data?.message || 'Failed to add availability.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -153,7 +155,7 @@ export const AddAvailabilityScreen = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom + 16, 16) }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.saveButton}
|
style={styles.saveButton}
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
@@ -253,7 +255,8 @@ const styles = StyleSheet.create({
|
|||||||
width: 16,
|
width: 16,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
padding: 16,
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
backgroundColor: '#FFF',
|
backgroundColor: '#FFF',
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#E5E7EB',
|
borderTopColor: '#E5E7EB',
|
||||||
|
|||||||
+26
-60
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, StyleSheet, ScrollView, Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
import { View, StyleSheet, ScrollView, Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { RouteProp } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { Header } from '../components/ui/Header';
|
import { Header } from '../components/ui/Header';
|
||||||
import { Input } from '../components/ui/Input';
|
import { Input } from '../components/ui/Input';
|
||||||
@@ -10,56 +11,32 @@ import { Typography } from '../components/ui/Typography';
|
|||||||
import { profileService, PaymentProfile } from '../services/profileService';
|
import { profileService, PaymentProfile } from '../services/profileService';
|
||||||
import { THEME } from '../constants/theme';
|
import { THEME } from '../constants/theme';
|
||||||
import { CreditCard } from 'lucide-react-native';
|
import { CreditCard } from 'lucide-react-native';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
|
|
||||||
interface PaymentProfileScreenProps {
|
interface AddEditPaymentProfileScreenProps {
|
||||||
navigation: NativeStackNavigationProp<RootStackParamList, 'PaymentProfile'>;
|
navigation: NativeStackNavigationProp<RootStackParamList, 'AddEditPaymentProfile'>;
|
||||||
|
route: RouteProp<RootStackParamList, 'AddEditPaymentProfile'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PaymentProfileScreen: React.FC<PaymentProfileScreenProps> = ({ navigation }) => {
|
export const AddEditPaymentProfileScreen: React.FC<AddEditPaymentProfileScreenProps> = ({ navigation, route }) => {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const { profile } = route.params || {};
|
||||||
|
const isEditing = !!profile;
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [existingId, setExistingId] = useState<number | undefined>(undefined);
|
const insets = useSafeAreaInsets();
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
|
|
||||||
const [form, setForm] = useState<Partial<PaymentProfile>>({
|
const [form, setForm] = useState<Partial<PaymentProfile>>({
|
||||||
payment_method: 'bank',
|
payment_method: profile?.payment_method || 'bank',
|
||||||
account_holder_name: '',
|
account_holder_name: profile?.account_holder_name || '',
|
||||||
account_number: '',
|
account_number: profile?.account_number || '',
|
||||||
ifsc_code: '',
|
ifsc_code: profile?.ifsc_code || '',
|
||||||
bank_name: '',
|
bank_name: profile?.bank_name || '',
|
||||||
branch_name: '',
|
branch_name: profile?.branch_name || '',
|
||||||
account_type: 'savings',
|
account_type: profile?.account_type || 'savings',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPaymentProfile();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchPaymentProfile = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const response = await profileService.getPaymentProfile();
|
|
||||||
if (response.success && response.data && response.data.id) {
|
|
||||||
setExistingId(response.data.id);
|
|
||||||
setForm({
|
|
||||||
payment_method: response.data.payment_method || 'bank',
|
|
||||||
account_holder_name: response.data.account_holder_name || '',
|
|
||||||
account_number: response.data.account_number || '',
|
|
||||||
ifsc_code: response.data.ifsc_code || '',
|
|
||||||
bank_name: response.data.bank_name || '',
|
|
||||||
branch_name: response.data.branch_name || '',
|
|
||||||
account_type: response.data.account_type || 'savings',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// If 404 or not found, it means no profile exists yet.
|
|
||||||
console.log('No existing payment profile found or error fetching.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (field: keyof PaymentProfile, value: string) => {
|
const handleChange = (field: keyof PaymentProfile, value: string) => {
|
||||||
setForm(prev => ({ ...prev, [field]: value }));
|
setForm(prev => ({ ...prev, [field]: value }));
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
@@ -88,15 +65,13 @@ export const PaymentProfileScreen: React.FC<PaymentProfileScreenProps> = ({ navi
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const payload = form as PaymentProfile;
|
const payload = form as PaymentProfile;
|
||||||
|
|
||||||
if (existingId) {
|
if (isEditing && profile?.id) {
|
||||||
|
payload.id = profile.id;
|
||||||
await profileService.updatePaymentProfile(payload);
|
await profileService.updatePaymentProfile(payload);
|
||||||
Alert.alert('Success', 'Payment profile updated successfully.');
|
showSnackbar('Payment profile updated successfully.');
|
||||||
} else {
|
} else {
|
||||||
const response = await profileService.addPaymentProfile(payload);
|
await profileService.addPaymentProfile(payload);
|
||||||
if (response.data && response.data.id) {
|
showSnackbar('Payment profile added successfully.');
|
||||||
setExistingId(response.data.id);
|
|
||||||
}
|
|
||||||
Alert.alert('Success', 'Payment profile added successfully.');
|
|
||||||
}
|
}
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -110,19 +85,13 @@ export const PaymentProfileScreen: React.FC<PaymentProfileScreenProps> = ({ navi
|
|||||||
return (
|
return (
|
||||||
// @ts-ignore - TS types for SafeAreaView in this version incorrectly omit the style prop
|
// @ts-ignore - TS types for SafeAreaView in this version incorrectly omit the style prop
|
||||||
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container as any}>
|
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container as any}>
|
||||||
<Header title="Payment Profile" showBack onBack={() => navigation.goBack()} />
|
<Header title={isEditing ? "Edit Bank Account" : "Add Bank Account"} showBack onBack={() => navigation.goBack()} />
|
||||||
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
>
|
>
|
||||||
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
|
<ScrollView style={styles.content} contentContainerStyle={[styles.scrollContent, { paddingBottom: Math.max(insets.bottom + 40, 40) }]}>
|
||||||
{isLoading ? (
|
|
||||||
<View style={styles.centerContainer}>
|
|
||||||
<Typography>Loading...</Typography>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<View style={styles.iconContainer}>
|
<View style={styles.iconContainer}>
|
||||||
<View style={styles.iconWrapper}>
|
<View style={styles.iconWrapper}>
|
||||||
<CreditCard color={THEME.colors.primary} size={32} />
|
<CreditCard color={THEME.colors.primary} size={32} />
|
||||||
@@ -178,13 +147,11 @@ export const PaymentProfileScreen: React.FC<PaymentProfileScreenProps> = ({ navi
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title={existingId ? "Update Profile" : "Save Profile"}
|
title={isEditing ? "Update Profile" : "Save Profile"}
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
loading={isSaving}
|
loading={isSaving}
|
||||||
style={styles.saveButton}
|
style={styles.saveButton}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@@ -201,7 +168,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
padding: 24,
|
padding: 24,
|
||||||
paddingBottom: 40,
|
|
||||||
},
|
},
|
||||||
centerContainer: {
|
centerContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -8,18 +8,11 @@ import { Header } from '../components/ui/Header';
|
|||||||
import { Input } from '../components/ui/Input';
|
import { Input } from '../components/ui/Input';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { THEME } from '../constants/theme';
|
import { THEME } from '../constants/theme';
|
||||||
import { serviceService, Service } from '../services/serviceService';
|
import { serviceService, Service, AddServicePayload } from '../services/serviceService';
|
||||||
import { profileService } from '../services/profileService';
|
import { profileService } from '../services/profileService';
|
||||||
import { Typography } from '../components/ui/Typography';
|
import { Typography } from '../components/ui/Typography';
|
||||||
import { ChevronDown, Check } from 'lucide-react-native';
|
import { Dropdown } from '../components/ui/Dropdown';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
const UNIT_OPTIONS = [
|
|
||||||
'Visit',
|
|
||||||
'Hour',
|
|
||||||
'Day',
|
|
||||||
'Week',
|
|
||||||
'Month'
|
|
||||||
];
|
|
||||||
|
|
||||||
interface AddEditServiceScreenProps {
|
interface AddEditServiceScreenProps {
|
||||||
navigation: NativeStackNavigationProp<RootStackParamList, 'AddEditService'>;
|
navigation: NativeStackNavigationProp<RootStackParamList, 'AddEditService'>;
|
||||||
@@ -29,75 +22,57 @@ interface AddEditServiceScreenProps {
|
|||||||
export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navigation, route }) => {
|
export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navigation, route }) => {
|
||||||
const existingService = route.params?.service;
|
const existingService = route.params?.service;
|
||||||
const isEditing = !!existingService;
|
const isEditing = !!existingService;
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
|
|
||||||
const [form, setForm] = useState<Partial<Service>>({
|
const [form, setForm] = useState<Partial<AddServicePayload>>({
|
||||||
name: existingService?.name || '',
|
service_id: existingService?.service_id || existingService?.id,
|
||||||
description: existingService?.description || '',
|
custom_rate: existingService?.custom_rate?.toString() || existingService?.effective_rate?.toString() || '',
|
||||||
care_type_slug: existingService?.care_type_slug || '',
|
notes: existingService?.notes || existingService?.description || '',
|
||||||
care_sub_type_slug: existingService?.care_sub_type_slug || '',
|
|
||||||
category: existingService?.category || '',
|
|
||||||
unit: existingService?.unit || 'Visit',
|
|
||||||
customer_price: existingService?.customer_price?.toString() || '',
|
|
||||||
caregiver_rate: existingService?.caregiver_rate?.toString() || '',
|
|
||||||
tax: existingService?.tax?.toString() || '0',
|
|
||||||
duration_minutes: existingService?.duration_minutes?.toString() || '',
|
|
||||||
is_active: existingService?.is_active ?? true,
|
is_active: existingService?.is_active ?? true,
|
||||||
|
caregiver_id: existingService?.caregiver_id || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const [careTypes, setCareTypes] = useState<any[]>([]);
|
const [availableServices, setAvailableServices] = useState<Service[]>([]);
|
||||||
const [careSubTypes, setCareSubTypes] = useState<any[]>([]);
|
const [loading, setLoading] = useState(true);
|
||||||
const [taxes, setTaxes] = useState<any[]>([]);
|
|
||||||
const [showCareTypePicker, setShowCareTypePicker] = useState(false);
|
|
||||||
const [showCareSubTypePicker, setShowCareSubTypePicker] = useState(false);
|
|
||||||
const [showUnitPicker, setShowUnitPicker] = useState(false);
|
|
||||||
const [showTaxPicker, setShowTaxPicker] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCareTypes();
|
fetchInitialData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchInitialData = async () => {
|
||||||
if (form.care_type_slug) {
|
|
||||||
fetchCareSubTypes(form.care_type_slug);
|
|
||||||
}
|
|
||||||
}, [form.care_type_slug]);
|
|
||||||
|
|
||||||
const fetchCareTypes = async () => {
|
|
||||||
try {
|
try {
|
||||||
const [ownSkillsResponse, data, taxesData] = await Promise.all([
|
setLoading(true);
|
||||||
profileService.getOwnSkills(),
|
const [profileResponse, servicesResponse] = await Promise.all([
|
||||||
profileService.getCareTypes(),
|
profileService.getProfile(),
|
||||||
serviceService.getTaxes()
|
serviceService.getSkillBasedServices()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setCareTypes(data);
|
const profileId = profileResponse?.data?.profile?.id || profileResponse?.data?.id;
|
||||||
setTaxes(taxesData);
|
if (profileId && !form.caregiver_id) {
|
||||||
|
const numericProfileId = typeof profileId === 'string' ? parseInt(profileId, 10) : profileId;
|
||||||
|
setForm(prev => ({ ...prev, caregiver_id: numericProfileId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvailableServices(servicesResponse || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching master types', error);
|
console.error('Error fetching data for service form', error);
|
||||||
|
// Fallback in case endpoint 500s or fails (so user can still see UI)
|
||||||
|
if (availableServices.length === 0) {
|
||||||
|
setAvailableServices([
|
||||||
|
{ id: 1, name: 'Wound Care Dressing' },
|
||||||
|
{ id: 2, name: 'IV Care' },
|
||||||
|
{ id: 3, name: 'Post-op Recovery' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchCareSubTypes = async (slug: string) => {
|
const handleChange = (field: keyof AddServicePayload, value: string | number | boolean) => {
|
||||||
try {
|
|
||||||
const [ownSkillsResponse, data] = await Promise.all([
|
|
||||||
profileService.getOwnSkills(),
|
|
||||||
profileService.getCareSubTypes(slug)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const ownSkills = ownSkillsResponse || [];
|
|
||||||
const mySkillSlugs = ownSkills.map((s: any) => s.slug);
|
|
||||||
|
|
||||||
const allowedSubTypes = data.filter((st: any) => mySkillSlugs.includes(st.slug));
|
|
||||||
setCareSubTypes(allowedSubTypes.length > 0 ? allowedSubTypes : data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching care sub types', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (field: keyof Service, value: string | number) => {
|
|
||||||
setForm(prev => ({ ...prev, [field]: value }));
|
setForm(prev => ({ ...prev, [field]: value }));
|
||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||||
@@ -105,15 +80,14 @@ export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleIsActive = () => {
|
const toggleIsActive = () => {
|
||||||
setForm(prev => ({ ...prev, is_active: !prev.is_active }));
|
handleChange('is_active', !form.is_active);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.name?.trim()) newErrors.name = 'Service name is required';
|
if (!form.service_id) newErrors.service_id = 'Please select a service';
|
||||||
if (!form.description?.trim()) newErrors.description = 'Description is required';
|
if (!form.custom_rate || parseFloat(form.custom_rate as string) <= 0) newErrors.custom_rate = 'Valid rate is required';
|
||||||
if (!form.customer_price) newErrors.customer_price = 'Customer Price is required';
|
if (!form.notes?.trim()) newErrors.notes = 'Notes are required';
|
||||||
if (!form.caregiver_rate) newErrors.caregiver_rate = 'Caregiver Rate is required';
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
@@ -126,20 +100,21 @@ export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navi
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const payload = {
|
|
||||||
...form,
|
|
||||||
customer_price: parseFloat(form.customer_price as string),
|
|
||||||
caregiver_rate: parseFloat(form.caregiver_rate as string),
|
|
||||||
tax: form.tax ? parseFloat(form.tax as string) : 0,
|
|
||||||
duration_minutes: form.duration_minutes ? parseInt(form.duration_minutes as string, 10) : undefined,
|
|
||||||
} as Service;
|
|
||||||
|
|
||||||
if (isEditing && existingService.id) {
|
const payload: AddServicePayload = {
|
||||||
|
service_id: form.service_id!,
|
||||||
|
caregiver_id: form.caregiver_id || 1, // Fallback if undefined
|
||||||
|
custom_rate: parseFloat(form.custom_rate as string),
|
||||||
|
is_active: form.is_active!,
|
||||||
|
notes: form.notes!,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing && existingService?.id) {
|
||||||
await serviceService.updateService(existingService.id, payload);
|
await serviceService.updateService(existingService.id, payload);
|
||||||
Alert.alert('Success', 'Service updated successfully.');
|
showSnackbar('Service updated successfully.');
|
||||||
} else {
|
} else {
|
||||||
await serviceService.addService(payload);
|
await serviceService.addService(payload);
|
||||||
Alert.alert('Success', 'Service added successfully.');
|
showSnackbar('Service added successfully.');
|
||||||
}
|
}
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -160,210 +135,44 @@ export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navi
|
|||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
>
|
>
|
||||||
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
|
<ScrollView style={styles.content} contentContainerStyle={[styles.scrollContent, { paddingBottom: Math.max(insets.bottom + 40, 40) }]}>
|
||||||
<Typography variant="body" style={styles.helperText}>
|
<Typography variant="body" style={styles.helperText}>
|
||||||
{isEditing ? 'Update the details of your service below.' : 'Add a new service that you offer to clients.'}
|
{isEditing ? 'Update your service details below.' : 'Add a new service matching your skills.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<View style={styles.formContainer}>
|
<View style={styles.formContainer}>
|
||||||
<Input
|
<Dropdown
|
||||||
label="Service Name *"
|
label="Service *"
|
||||||
placeholder="e.g. Elderly Care, Physiotherapy"
|
placeholder={loading ? "Loading services..." : "Select a Service"}
|
||||||
value={form.name}
|
data={availableServices.map(s => ({ label: s.name || s.service_name || `Service #${s.id}`, value: s.id! }))}
|
||||||
onChangeText={(val) => handleChange('name', val)}
|
value={form.service_id}
|
||||||
error={errors.name}
|
onChange={(val) => handleChange('service_id', val)}
|
||||||
|
disabled={loading || isEditing}
|
||||||
/>
|
/>
|
||||||
|
{errors.service_id && (
|
||||||
|
<Typography variant="caption" style={styles.errorText}>{errors.service_id}</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Description *"
|
label="Custom Rate ($) *"
|
||||||
placeholder="Describe what the service includes..."
|
placeholder="e.g. 600"
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={form.custom_rate as string}
|
||||||
|
onChangeText={(val) => handleChange('custom_rate', val)}
|
||||||
|
error={errors.custom_rate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Notes *"
|
||||||
|
placeholder="e.g. Experienced in post-op surgical dressing & IV care"
|
||||||
multiline
|
multiline
|
||||||
numberOfLines={4}
|
numberOfLines={4}
|
||||||
value={form.description}
|
value={form.notes}
|
||||||
onChangeText={(val) => handleChange('description', val)}
|
onChangeText={(val) => handleChange('notes', val)}
|
||||||
error={errors.description}
|
error={errors.notes}
|
||||||
style={styles.textArea}
|
style={styles.textArea}
|
||||||
/>
|
/>
|
||||||
<View style={styles.dropdownContainer}>
|
|
||||||
<Typography variant="body" weight="medium" style={styles.dropdownLabel}>Care Type</Typography>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.dropdownBox}
|
|
||||||
onPress={() => setShowCareTypePicker(!showCareTypePicker)}
|
|
||||||
>
|
|
||||||
<Typography variant="body" style={styles.dropdownText}>
|
|
||||||
{careTypes.find(c => c.slug === form.care_type_slug)?.name || form.care_type_slug || 'Select Care Type'}
|
|
||||||
</Typography>
|
|
||||||
<ChevronDown color="#6B7280" size={20} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{showCareTypePicker && (
|
|
||||||
<View style={styles.dropdownList}>
|
|
||||||
{careTypes.map(type => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={type.slug}
|
|
||||||
style={[styles.dropdownOption, form.care_type_slug === type.slug && styles.dropdownOptionSelected]}
|
|
||||||
onPress={() => {
|
|
||||||
handleChange('care_type_slug', type.slug);
|
|
||||||
handleChange('category', type.name);
|
|
||||||
handleChange('care_sub_type_slug', '');
|
|
||||||
setShowCareTypePicker(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body" style={form.care_type_slug === type.slug ? styles.dropdownOptionTextSelected : styles.dropdownOptionText}>
|
|
||||||
{type.name}
|
|
||||||
</Typography>
|
|
||||||
{form.care_type_slug === type.slug && <Check color={THEME.colors.primary} size={16} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.dropdownContainer}>
|
|
||||||
<Typography variant="body" weight="medium" style={styles.dropdownLabel}>Care Sub Type</Typography>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.dropdownBox}
|
|
||||||
onPress={() => setShowCareSubTypePicker(!showCareSubTypePicker)}
|
|
||||||
>
|
|
||||||
<Typography variant="body" style={styles.dropdownText}>
|
|
||||||
{careSubTypes.find(c => c.slug === form.care_sub_type_slug)?.name || form.care_sub_type_slug || 'Select Care Sub Type'}
|
|
||||||
</Typography>
|
|
||||||
<ChevronDown color="#6B7280" size={20} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{showCareSubTypePicker && (
|
|
||||||
<View style={styles.dropdownList}>
|
|
||||||
{careSubTypes.length === 0 ? (
|
|
||||||
<View style={{ padding: 14 }}>
|
|
||||||
<Typography variant="caption" style={{ color: '#9CA3AF' }}>Please select a Care Type first</Typography>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
careSubTypes.map(type => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={type.slug}
|
|
||||||
style={[styles.dropdownOption, form.care_sub_type_slug === type.slug && styles.dropdownOptionSelected]}
|
|
||||||
onPress={() => {
|
|
||||||
handleChange('care_sub_type_slug', type.slug);
|
|
||||||
setShowCareSubTypePicker(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body" style={form.care_sub_type_slug === type.slug ? styles.dropdownOptionTextSelected : styles.dropdownOptionText}>
|
|
||||||
{type.name}
|
|
||||||
</Typography>
|
|
||||||
{form.care_sub_type_slug === type.slug && <Check color={THEME.colors.primary} size={16} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Input
|
|
||||||
label="Category"
|
|
||||||
placeholder="Auto-filled from Care Type"
|
|
||||||
value={form.category}
|
|
||||||
onChangeText={(val) => handleChange('category', val)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.dropdownContainer}>
|
|
||||||
<Typography variant="body" weight="medium" style={styles.dropdownLabel}>Pricing Unit</Typography>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.dropdownBox}
|
|
||||||
onPress={() => setShowUnitPicker(!showUnitPicker)}
|
|
||||||
>
|
|
||||||
<Typography variant="body" style={styles.dropdownText}>
|
|
||||||
{form.unit || 'Select Unit (e.g. Visit, Hour)'}
|
|
||||||
</Typography>
|
|
||||||
<ChevronDown color="#6B7280" size={20} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{showUnitPicker && (
|
|
||||||
<View style={styles.dropdownList}>
|
|
||||||
{UNIT_OPTIONS.map(unitOption => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={unitOption}
|
|
||||||
style={[styles.dropdownOption, form.unit === unitOption && styles.dropdownOptionSelected]}
|
|
||||||
onPress={() => {
|
|
||||||
handleChange('unit', unitOption);
|
|
||||||
setShowUnitPicker(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body" style={form.unit === unitOption ? styles.dropdownOptionTextSelected : styles.dropdownOptionText}>
|
|
||||||
{unitOption}
|
|
||||||
</Typography>
|
|
||||||
{form.unit === unitOption && <Check color={THEME.colors.primary} size={16} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Customer Price *"
|
|
||||||
placeholder="e.g. 900"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={form.customer_price as string}
|
|
||||||
onChangeText={(val) => handleChange('customer_price', val)}
|
|
||||||
error={errors.customer_price}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Caregiver Rate *"
|
|
||||||
placeholder="e.g. 500"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={form.caregiver_rate?.toString()}
|
|
||||||
onChangeText={(val) => handleChange('caregiver_rate', parseFloat(val) || 0)}
|
|
||||||
error={errors.caregiver_rate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.dropdownContainer}>
|
|
||||||
<Typography variant="body" weight="medium" style={styles.dropdownLabel}>Tax (%)</Typography>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.dropdownBox}
|
|
||||||
onPress={() => setShowTaxPicker(!showTaxPicker)}
|
|
||||||
>
|
|
||||||
<Typography variant="body" style={styles.dropdownText}>
|
|
||||||
{taxes.find(t => t.rate_percent?.toString() === form.tax?.toString())?.name ? `${taxes.find(t => t.rate_percent?.toString() === form.tax?.toString())?.name} (${form.tax}%)` : form.tax ? `${form.tax}%` : 'Select Tax Rate'}
|
|
||||||
</Typography>
|
|
||||||
<ChevronDown color="#6B7280" size={20} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{showTaxPicker && (
|
|
||||||
<View style={styles.dropdownList}>
|
|
||||||
{taxes.length === 0 ? (
|
|
||||||
<View style={{ padding: 14 }}>
|
|
||||||
<Typography variant="caption" style={{ color: '#9CA3AF' }}>Loading taxes...</Typography>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
taxes.map(taxOption => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={taxOption.code}
|
|
||||||
style={[styles.dropdownOption, form.tax?.toString() === taxOption.rate_percent?.toString() && styles.dropdownOptionSelected]}
|
|
||||||
onPress={() => {
|
|
||||||
handleChange('tax', taxOption.rate_percent);
|
|
||||||
setShowTaxPicker(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ flex: 1, paddingRight: 8 }}>
|
|
||||||
<Typography variant="body" style={form.tax?.toString() === taxOption.rate_percent?.toString() ? styles.dropdownOptionTextSelected : styles.dropdownOptionText}>
|
|
||||||
{taxOption.name} ({taxOption.rate_percent}%)
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" style={{ color: '#6B7280', marginTop: 2 }}>
|
|
||||||
{taxOption.description}
|
|
||||||
</Typography>
|
|
||||||
</View>
|
|
||||||
{form.tax?.toString() === taxOption.rate_percent?.toString() && <Check color={THEME.colors.primary} size={16} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Duration (Minutes)"
|
|
||||||
placeholder="e.g. 90"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={form.duration_minutes as string}
|
|
||||||
onChangeText={(val) => handleChange('duration_minutes', val)}
|
|
||||||
/>
|
|
||||||
<View style={styles.switchContainer}>
|
<View style={styles.switchContainer}>
|
||||||
<Typography variant="body" weight="medium">Is Active</Typography>
|
<Typography variant="body" weight="medium">Is Active</Typography>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -396,7 +205,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
padding: 24,
|
padding: 24,
|
||||||
paddingBottom: 40,
|
|
||||||
},
|
},
|
||||||
helperText: {
|
helperText: {
|
||||||
color: THEME.colors.text.secondary,
|
color: THEME.colors.text.secondary,
|
||||||
@@ -407,7 +215,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
textArea: {
|
textArea: {
|
||||||
minHeight: 100,
|
minHeight: 100,
|
||||||
textAlignVertical: 'top', // for Android
|
textAlignVertical: 'top',
|
||||||
},
|
},
|
||||||
switchContainer: {
|
switchContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -418,55 +226,13 @@ const styles = StyleSheet.create({
|
|||||||
borderTopColor: '#E5E7EB',
|
borderTopColor: '#E5E7EB',
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
dropdownContainer: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
dropdownLabel: {
|
|
||||||
color: '#374151',
|
|
||||||
marginBottom: 6,
|
|
||||||
},
|
|
||||||
dropdownBox: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#D1D5DB',
|
|
||||||
borderRadius: 8,
|
|
||||||
height: 50,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
backgroundColor: '#F9FAFB',
|
|
||||||
},
|
|
||||||
dropdownText: {
|
|
||||||
color: '#111827',
|
|
||||||
},
|
|
||||||
dropdownList: {
|
|
||||||
backgroundColor: '#FFF',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#E5E7EB',
|
|
||||||
borderBottomLeftRadius: 8,
|
|
||||||
borderBottomRightRadius: 8,
|
|
||||||
borderTopWidth: 0,
|
|
||||||
marginTop: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
dropdownOption: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: 14,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#F3F4F6',
|
|
||||||
},
|
|
||||||
dropdownOptionSelected: {
|
|
||||||
backgroundColor: '#EEF2FF',
|
|
||||||
},
|
|
||||||
dropdownOptionText: {
|
|
||||||
color: '#374151',
|
|
||||||
},
|
|
||||||
dropdownOptionTextSelected: {
|
|
||||||
color: THEME.colors.primary,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
saveButton: {
|
saveButton: {
|
||||||
backgroundColor: THEME.colors.primary,
|
backgroundColor: THEME.colors.primary,
|
||||||
},
|
},
|
||||||
|
errorText: {
|
||||||
|
color: THEME.colors.error,
|
||||||
|
marginTop: -12,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: 4,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, Alert } from 'react-native';
|
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, Alert } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { Typography } from '../components/ui/Typography';
|
import { Typography } from '../components/ui/Typography';
|
||||||
import { Header } from '../components/ui/Header';
|
import { Header } from '../components/ui/Header';
|
||||||
import { profileService } from '../services/profileService';
|
import { profileService } from '../services/profileService';
|
||||||
import { THEME } from '../constants/theme';
|
import { THEME } from '../constants/theme';
|
||||||
import { Check, Info } from 'lucide-react-native';
|
import { Check, Info } from 'lucide-react-native';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
|
|
||||||
export const AddSkillScreen = () => {
|
export const AddSkillScreen = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
const [ownSkills, setOwnSkills] = useState<any[]>([]);
|
const [ownSkills, setOwnSkills] = useState<any[]>([]);
|
||||||
const [careTypes, setCareTypes] = useState<any[]>([]);
|
const [careTypes, setCareTypes] = useState<any[]>([]);
|
||||||
const [subTypes, setSubTypes] = useState<any[]>([]);
|
const [subTypes, setSubTypes] = useState<any[]>([]);
|
||||||
@@ -72,9 +75,8 @@ export const AddSkillScreen = () => {
|
|||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
try {
|
try {
|
||||||
await profileService.addSkills(selectedSubTypes);
|
await profileService.addSkills(selectedSubTypes);
|
||||||
Alert.alert('Success', 'Skills added successfully!', [
|
showSnackbar('Skills added successfully!');
|
||||||
{ text: 'OK', onPress: () => navigation.goBack() }
|
navigation.goBack();
|
||||||
]);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Error', e.response?.data?.message || 'Failed to add skills.');
|
Alert.alert('Error', e.response?.data?.message || 'Failed to add skills.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -176,7 +178,7 @@ export const AddSkillScreen = () => {
|
|||||||
<View style={styles.bottomSpacer} />
|
<View style={styles.bottomSpacer} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom + 16, 16) }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.addButton, selectedSubTypes.length === 0 && styles.addButtonDisabled]}
|
style={[styles.addButton, selectedSubTypes.length === 0 && styles.addButtonDisabled]}
|
||||||
onPress={handleAddSkills}
|
onPress={handleAddSkills}
|
||||||
@@ -337,7 +339,8 @@ const styles = StyleSheet.create({
|
|||||||
height: 20,
|
height: 20,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
padding: 16,
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
backgroundColor: '#FFF',
|
backgroundColor: '#FFF',
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#E5E7EB',
|
borderTopColor: '#E5E7EB',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native';
|
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { Typography } from '../components/ui/Typography';
|
import { Typography } from '../components/ui/Typography';
|
||||||
@@ -16,6 +16,7 @@ export const AvailabilityListScreen = () => {
|
|||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const [availability, setAvailability] = useState<CaregiverAvailability[]>([]);
|
const [availability, setAvailability] = useState<CaregiverAvailability[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const fetchAvailability = async () => {
|
const fetchAvailability = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -110,7 +111,7 @@ export const AvailabilityListScreen = () => {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.fab}
|
style={[styles.fab, { bottom: Math.max(insets.bottom + 24, 24), right: Math.max(insets.right + 24, 24) }]}
|
||||||
onPress={() => navigation.navigate('AddAvailability')}
|
onPress={() => navigation.navigate('AddAvailability')}
|
||||||
>
|
>
|
||||||
<Plus color="#FFF" size={24} />
|
<Plus color="#FFF" size={24} />
|
||||||
@@ -226,8 +227,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
fab: {
|
fab: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 24,
|
|
||||||
right: 24,
|
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, Alert, Platform } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, Alert, Platform } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import { Typography } from '../components/ui/Typography';
|
import { Typography } from '../components/ui/Typography';
|
||||||
@@ -8,6 +8,7 @@ import { Header } from '../components/ui/Header';
|
|||||||
import { profileService } from '../services/profileService';
|
import { profileService } from '../services/profileService';
|
||||||
import { THEME } from '../constants/theme';
|
import { THEME } from '../constants/theme';
|
||||||
import { Clock, Calendar, Trash2 } from 'lucide-react-native';
|
import { Clock, Calendar, Trash2 } from 'lucide-react-native';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
||||||
type EditAvailabilityRouteProp = RouteProp<RootStackParamList, 'EditAvailability'>;
|
type EditAvailabilityRouteProp = RouteProp<RootStackParamList, 'EditAvailability'>;
|
||||||
@@ -15,6 +16,8 @@ type EditAvailabilityRouteProp = RouteProp<RootStackParamList, 'EditAvailability
|
|||||||
export const EditAvailabilityScreen = () => {
|
export const EditAvailabilityScreen = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const route = useRoute<EditAvailabilityRouteProp>();
|
const route = useRoute<EditAvailabilityRouteProp>();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
|
|
||||||
const { day_of_week, start_time, end_time } = route.params;
|
const { day_of_week, start_time, end_time } = route.params;
|
||||||
|
|
||||||
@@ -74,9 +77,8 @@ export const EditAvailabilityScreen = () => {
|
|||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await profileService.updateDayAvailability(payload);
|
await profileService.updateDayAvailability(payload);
|
||||||
Alert.alert('Success', 'Availability updated successfully.', [
|
showSnackbar('Availability updated successfully.');
|
||||||
{ text: 'OK', onPress: () => navigation.goBack() }
|
navigation.goBack();
|
||||||
]);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Error', e.response?.data?.message || 'Failed to update availability.');
|
Alert.alert('Error', e.response?.data?.message || 'Failed to update availability.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,6 +99,7 @@ export const EditAvailabilityScreen = () => {
|
|||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await profileService.deleteDayAvailability(day_of_week);
|
await profileService.deleteDayAvailability(day_of_week);
|
||||||
|
showSnackbar('Availability removed successfully.');
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Error', e.response?.data?.message || 'Failed to remove availability.');
|
Alert.alert('Error', e.response?.data?.message || 'Failed to remove availability.');
|
||||||
@@ -167,7 +170,7 @@ export const EditAvailabilityScreen = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom + 16, 16) }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.saveButton}
|
style={styles.saveButton}
|
||||||
onPress={handleUpdate}
|
onPress={handleUpdate}
|
||||||
@@ -272,7 +275,8 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
padding: 16,
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
backgroundColor: '#FFF',
|
backgroundColor: '#FFF',
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#E5E7EB',
|
borderTopColor: '#E5E7EB',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Button } from '../components/ui/Button';
|
|||||||
import { useCaregivers } from '../hooks/useCaregivers';
|
import { useCaregivers } from '../hooks/useCaregivers';
|
||||||
import { formatRating, getInitials, getImageUrl } from '../utils/formatters';
|
import { formatRating, getInitials, getImageUrl } from '../utils/formatters';
|
||||||
import { useResponsiveLayout } from '../hooks/useResponsiveLayout';
|
import { useResponsiveLayout } from '../hooks/useResponsiveLayout';
|
||||||
import { User, Award, Calendar } from 'lucide-react-native';
|
import { User, Award, Calendar, Briefcase, ChevronRight, FileText } from 'lucide-react-native';
|
||||||
import { profileService, CaregiverProfileResponse } from '../services/profileService';
|
import { profileService, CaregiverProfileResponse } from '../services/profileService';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
@@ -108,6 +108,40 @@ export const HomeScreen: React.FC<HomeScreenProps> = ({ navigation }) => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('ManageServices')} style={{marginBottom: 24}}>
|
||||||
|
<Card noPadding style={styles.menuItem}>
|
||||||
|
<View style={styles.menuIconContainer}>
|
||||||
|
<Briefcase color="#4F46E5" size={24} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.menuTextContainer}>
|
||||||
|
<Typography variant="body" weight="bold" style={styles.menuTitle}>
|
||||||
|
Manage Services
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" style={styles.menuSubtitle}>
|
||||||
|
Add or edit your offered services
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
<ChevronRight color="#9CA3AF" size={20} />
|
||||||
|
</Card>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('ProfessionalDocuments' as any)} style={{marginBottom: 24}}>
|
||||||
|
<Card noPadding style={styles.menuItem}>
|
||||||
|
<View style={styles.menuIconContainer}>
|
||||||
|
<FileText color="#4F46E5" size={24} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.menuTextContainer}>
|
||||||
|
<Typography variant="body" weight="bold" style={styles.menuTitle}>
|
||||||
|
Professional Documents
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" style={styles.menuSubtitle}>
|
||||||
|
Upload and manage your documents
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
<ChevronRight color="#9CA3AF" size={20} />
|
||||||
|
</Card>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Typography variant="h3" weight="bold">
|
<Typography variant="h3" weight="bold">
|
||||||
Featured Caregivers
|
Featured Caregivers
|
||||||
@@ -310,5 +344,30 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
tabletGridItem: {
|
tabletGridItem: {
|
||||||
width: '48%', // Leaves some space for margins between the two columns
|
width: '48%', // Leaves some space for margins between the two columns
|
||||||
|
},
|
||||||
|
menuItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 24,
|
||||||
|
},
|
||||||
|
menuIconContainer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#EEF2FF',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
menuTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
menuTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
menuSubtitle: {
|
||||||
|
color: '#6B7280',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, StyleSheet, FlatList, Alert, TouchableOpacity, ActivityIndicator } from 'react-native';
|
import { View, StyleSheet, FlatList, Alert, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { Header } from '../components/ui/Header';
|
import { Header } from '../components/ui/Header';
|
||||||
@@ -9,6 +9,7 @@ import { THEME } from '../constants/theme';
|
|||||||
import { serviceService, Service } from '../services/serviceService';
|
import { serviceService, Service } from '../services/serviceService';
|
||||||
import { Edit2, Trash2, Plus } from 'lucide-react-native';
|
import { Edit2, Trash2, Plus } from 'lucide-react-native';
|
||||||
import { useIsFocused } from '@react-navigation/native';
|
import { useIsFocused } from '@react-navigation/native';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
|
|
||||||
interface ManageServicesScreenProps {
|
interface ManageServicesScreenProps {
|
||||||
navigation: NativeStackNavigationProp<RootStackParamList, 'ManageServices'>;
|
navigation: NativeStackNavigationProp<RootStackParamList, 'ManageServices'>;
|
||||||
@@ -18,6 +19,8 @@ export const ManageServicesScreen: React.FC<ManageServicesScreenProps> = ({ navi
|
|||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
@@ -53,7 +56,7 @@ export const ManageServicesScreen: React.FC<ManageServicesScreenProps> = ({ navi
|
|||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
await serviceService.deleteService(id);
|
await serviceService.deleteService(id);
|
||||||
Alert.alert('Success', 'Service deleted successfully.');
|
showSnackbar('Service deleted successfully.');
|
||||||
fetchServices();
|
fetchServices();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting service:', error);
|
console.error('Error deleting service:', error);
|
||||||
@@ -141,7 +144,7 @@ export const ManageServicesScreen: React.FC<ManageServicesScreenProps> = ({ navi
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.fab}
|
style={[styles.fab, { bottom: Math.max(insets.bottom + 24, 24), right: Math.max(insets.right + 24, 24) }]}
|
||||||
onPress={() => navigation.navigate('AddEditService', {})}
|
onPress={() => navigation.navigate('AddEditService', {})}
|
||||||
>
|
>
|
||||||
<Plus color="#FFF" size={24} />
|
<Plus color="#FFF" size={24} />
|
||||||
@@ -232,8 +235,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
fab: {
|
fab: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 24,
|
|
||||||
right: 24,
|
|
||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
borderRadius: 28,
|
borderRadius: 28,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, Text } from 'react-native';
|
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, Text, Alert } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { Typography } from '../components/ui/Typography';
|
import { Typography } from '../components/ui/Typography';
|
||||||
@@ -8,7 +8,8 @@ import { Header } from '../components/ui/Header';
|
|||||||
import { profileService } from '../services/profileService';
|
import { profileService } from '../services/profileService';
|
||||||
import { THEME } from '../constants/theme';
|
import { THEME } from '../constants/theme';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { Plus, Award } from 'lucide-react-native';
|
import { Plus, Award, Trash2 } from 'lucide-react-native';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'ManageSkills'>;
|
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'ManageSkills'>;
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ export const ManageSkillsScreen = () => {
|
|||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const [ownSkills, setOwnSkills] = useState<any[]>([]);
|
const [ownSkills, setOwnSkills] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
|
|
||||||
const fetchSkills = async () => {
|
const fetchSkills = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +38,30 @@ export const ManageSkillsScreen = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDelete = (skillSlug: string, skillName: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Remove Skill",
|
||||||
|
`Are you sure you want to remove ${skillName}?`,
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Remove",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await profileService.removeSkills([skillSlug]);
|
||||||
|
showSnackbar('Skill removed successfully.');
|
||||||
|
fetchSkills();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove skill', error);
|
||||||
|
Alert.alert('Error', 'Failed to remove skill. Please try again.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: "destructive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container as any}>
|
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container as any}>
|
||||||
@@ -89,6 +116,12 @@ export const ManageSkillsScreen = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.deleteButton}
|
||||||
|
onPress={() => handleDelete(skill.slug, skill.name)}
|
||||||
|
>
|
||||||
|
<Trash2 color="#EF4444" size={20} />
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -97,7 +130,7 @@ export const ManageSkillsScreen = () => {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.fab}
|
style={[styles.fab, { bottom: Math.max(insets.bottom + 24, 24), right: Math.max(insets.right + 24, 24) }]}
|
||||||
onPress={() => navigation.navigate('AddSkill')}
|
onPress={() => navigation.navigate('AddSkill')}
|
||||||
>
|
>
|
||||||
<Plus color="#FFF" size={24} />
|
<Plus color="#FFF" size={24} />
|
||||||
@@ -231,13 +264,15 @@ const styles = StyleSheet.create({
|
|||||||
color: THEME.colors.primary,
|
color: THEME.colors.primary,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
},
|
},
|
||||||
|
deleteButton: {
|
||||||
|
padding: 8,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
bottomSpacer: {
|
bottomSpacer: {
|
||||||
height: 100,
|
height: 100,
|
||||||
},
|
},
|
||||||
fab: {
|
fab: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 24,
|
|
||||||
right: 24,
|
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { View, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
import { Header } from '../components/ui/Header';
|
||||||
|
import { Typography } from '../components/ui/Typography';
|
||||||
|
import { THEME } from '../constants/theme';
|
||||||
|
import { profileService, PaymentProfile } from '../services/profileService';
|
||||||
|
import { Plus, CreditCard, Landmark } from 'lucide-react-native';
|
||||||
|
|
||||||
|
interface PaymentProfileListScreenProps {
|
||||||
|
navigation: NativeStackNavigationProp<RootStackParamList, 'PaymentProfileList'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymentProfileListScreen: React.FC<PaymentProfileListScreenProps> = ({ navigation }) => {
|
||||||
|
const [profiles, setProfiles] = useState<PaymentProfile[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const fetchProfiles = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await profileService.getPaymentProfile();
|
||||||
|
|
||||||
|
// Handle both array and single object responses for backward compatibility
|
||||||
|
let profilesData: PaymentProfile[] = [];
|
||||||
|
if (response && response.data) {
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
profilesData = response.data;
|
||||||
|
} else if (response.data.id) {
|
||||||
|
profilesData = [response.data];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setProfiles(profilesData);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('No payment profiles found or error fetching.');
|
||||||
|
setProfiles([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchProfiles();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const maskAccountNumber = (accountNumber: string) => {
|
||||||
|
if (!accountNumber) return '**** **** **** ****';
|
||||||
|
const last4 = accountNumber.slice(-4);
|
||||||
|
return `**** **** **** ${last4}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCard = (profile: PaymentProfile) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={profile.id}
|
||||||
|
style={styles.cardContainer}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={() => navigation.navigate('AddEditPaymentProfile', { profile })}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={styles.bankNameRow}>
|
||||||
|
<Landmark color="#FFF" size={20} />
|
||||||
|
<Typography variant="body" weight="bold" style={styles.bankName}>
|
||||||
|
{profile.bank_name || 'Bank Account'}
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
<CreditCard color="#FFF" size={24} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardNumberContainer}>
|
||||||
|
<Typography variant="h3" weight="bold" style={styles.cardNumber}>
|
||||||
|
{maskAccountNumber(profile.account_number)}
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<View>
|
||||||
|
<Typography variant="caption" style={styles.cardLabel}>Cardholder Name</Typography>
|
||||||
|
<Typography variant="body" weight="bold" style={styles.cardValue}>
|
||||||
|
{profile.account_holder_name?.toUpperCase() || 'UNKNOWN'}
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
<View style={{ alignItems: 'flex-end' }}>
|
||||||
|
<Typography variant="caption" style={styles.cardLabel}>IFSC Code</Typography>
|
||||||
|
<Typography variant="body" weight="bold" style={styles.cardValue}>
|
||||||
|
{profile.ifsc_code || '---'}
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||||
|
<Header title="Payment Profiles" showBack />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
refreshControl={<RefreshControl refreshing={isLoading} onRefresh={fetchProfiles} />}
|
||||||
|
>
|
||||||
|
{profiles.length === 0 && !isLoading ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<View style={styles.iconWrapper}>
|
||||||
|
<CreditCard color={THEME.colors.primary} size={40} />
|
||||||
|
</View>
|
||||||
|
<Typography variant="h3" weight="bold" style={styles.emptyTitle}>
|
||||||
|
No Accounts Added
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body" style={styles.emptySubtitle}>
|
||||||
|
Add your bank account details to receive your payouts securely.
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
profiles.map(renderCard)
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.fab, { bottom: Math.max(insets.bottom + 20, 20), right: Math.max(insets.right + 20, 20) }]}
|
||||||
|
onPress={() => navigation.navigate('AddEditPaymentProfile', { profile: undefined })}
|
||||||
|
>
|
||||||
|
<Plus color="#FFF" size={24} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: THEME.colors.background,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 100, // Extra padding for FAB
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
cardContainer: {
|
||||||
|
backgroundColor: '#0F172A', // Very dark slate, almost black, looks premium
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
bankNameRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
bankName: {
|
||||||
|
color: '#FFF',
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
cardNumberContainer: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
cardNumber: {
|
||||||
|
color: '#FFF',
|
||||||
|
letterSpacing: 2,
|
||||||
|
fontSize: 22,
|
||||||
|
},
|
||||||
|
cardFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
cardLabel: {
|
||||||
|
color: '#94A3B8', // Slate 400
|
||||||
|
marginBottom: 4,
|
||||||
|
fontSize: 10,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
cardValue: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 14,
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: THEME.colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 60,
|
||||||
|
},
|
||||||
|
iconWrapper: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: THEME.colors.primaryLighter,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
color: '#111827',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubtitle: {
|
||||||
|
color: THEME.colors.text.secondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,12 +8,14 @@ import { profileService, CaregiverProfileResponse } from '../services/profileSer
|
|||||||
import { getImageUrl } from '../utils/formatters';
|
import { getImageUrl } from '../utils/formatters';
|
||||||
import { launchImageLibrary } from 'react-native-image-picker';
|
import { launchImageLibrary } from 'react-native-image-picker';
|
||||||
import { Edit2, Camera } from 'lucide-react-native';
|
import { Edit2, Camera } from 'lucide-react-native';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
|
|
||||||
export const PersonalInformationScreen = () => {
|
export const PersonalInformationScreen = () => {
|
||||||
const [profile, setProfile] = useState<CaregiverProfileResponse['data'] | null>(null);
|
const [profile, setProfile] = useState<CaregiverProfileResponse['data'] | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
|
|
||||||
// Form State
|
// Form State
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -109,8 +111,10 @@ export const PersonalInformationScreen = () => {
|
|||||||
await profileService.updateProfile(payload);
|
await profileService.updateProfile(payload);
|
||||||
await fetchProfile();
|
await fetchProfile();
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
showSnackbar('Personal information updated successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save profile', err);
|
console.error('Failed to save profile', err);
|
||||||
|
showSnackbar('Failed to update personal information.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -120,13 +124,13 @@ export const PersonalInformationScreen = () => {
|
|||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handleSave} disabled={saving} style={styles.headerButton}>
|
<TouchableOpacity onPress={handleSave} disabled={saving} style={styles.headerButton}>
|
||||||
{saving ? <ActivityIndicator size="small" color="#4F46E5" /> : <Typography variant="body" weight="bold" style={styles.headerButtonText}>Save</Typography>}
|
{saving ? <ActivityIndicator size="small" color="#FFFFFF" /> : <Typography variant="body" weight="bold" style={styles.headerButtonText}>Save</Typography>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={() => setIsEditing(true)} style={styles.headerButton}>
|
<TouchableOpacity onPress={() => setIsEditing(true)} style={styles.headerButton}>
|
||||||
<Edit2 color="#4F46E5" size={20} />
|
<Edit2 color="#FFFFFF" size={20} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -289,7 +293,7 @@ const styles = StyleSheet.create({
|
|||||||
padding: 8,
|
padding: 8,
|
||||||
},
|
},
|
||||||
headerButtonText: {
|
headerButtonText: {
|
||||||
color: '#4F46E5',
|
color: '#FFFFFF',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, StyleSheet, FlatList, ActivityIndicator, Alert, TouchableOpacity, Image } from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
import { Header } from '../components/ui/Header';
|
||||||
|
import { Typography } from '../components/ui/Typography';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { documentService, ProfessionalDocument } from '../services/documentService';
|
||||||
|
import { FileText, Trash2, Plus } from 'lucide-react-native';
|
||||||
|
import { getImageUrl } from '../utils/formatters';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'ProfessionalDocuments'>;
|
||||||
|
|
||||||
|
export const ProfessionalDocumentsScreen: React.FC<Props> = ({ navigation }) => {
|
||||||
|
const [documents, setDocuments] = useState<ProfessionalDocument[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
const fetchDocuments = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await documentService.getDocuments();
|
||||||
|
setDocuments(response.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch documents', error);
|
||||||
|
Alert.alert('Error', 'Failed to load documents');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = navigation.addListener('focus', () => {
|
||||||
|
fetchDocuments();
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Delete Document",
|
||||||
|
"Are you sure you want to delete this document?",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await documentService.deleteDocument(id);
|
||||||
|
showSnackbar('Document deleted successfully.');
|
||||||
|
fetchDocuments();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete', error);
|
||||||
|
Alert.alert('Error', 'Failed to delete document');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: "destructive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: ProfessionalDocument }) => (
|
||||||
|
<TouchableOpacity activeOpacity={0.8} onPress={() => navigation.navigate('ViewDocument', { documentId: item.id })}>
|
||||||
|
<Card style={styles.documentCard}>
|
||||||
|
<View style={styles.docHeader}>
|
||||||
|
<View style={styles.titleContainer}>
|
||||||
|
<FileText color="#1E3A8A" size={24} style={styles.docIcon} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Typography variant="body" weight="bold" style={styles.docTitle}>
|
||||||
|
{item.addition_info?.title || 'Untitled Document'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" style={styles.docDate}>
|
||||||
|
{new Date(item.created_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => handleDelete(item.id)} style={styles.deleteBtn}>
|
||||||
|
<Trash2 color="#EF4444" size={20} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{item.addition_info?.notes ? (
|
||||||
|
<Typography variant="caption" style={styles.docNotes}>
|
||||||
|
{item.addition_info.notes}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{item.properties?.url ? (
|
||||||
|
<Image source={{ uri: getImageUrl(item.properties.url) }} style={styles.previewImage} />
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container}>
|
||||||
|
<Header title="Professional Documents" showBack={true} />
|
||||||
|
<View style={styles.content}>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#1E3A8A" />
|
||||||
|
</View>
|
||||||
|
) : documents.length === 0 ? (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<FileText color="#D1D5DB" size={64} style={{ marginBottom: 16 }} />
|
||||||
|
<Typography variant="body" style={styles.emptyText}>
|
||||||
|
No professional documents found.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" style={styles.emptySubText}>
|
||||||
|
Upload your licenses, certificates, and other professional documents here.
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={documents}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={[styles.fabContainer, { bottom: Math.max(insets.bottom + 16, 16) }]}>
|
||||||
|
<Button
|
||||||
|
title="Upload Document"
|
||||||
|
onPress={() => navigation.navigate('UploadDocument')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#F9FAFB' },
|
||||||
|
content: { flex: 1, padding: 16 },
|
||||||
|
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
|
||||||
|
listContent: { paddingBottom: 100 },
|
||||||
|
documentCard: { marginBottom: 16, padding: 16 },
|
||||||
|
docHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 },
|
||||||
|
titleContainer: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
||||||
|
docIcon: { marginRight: 12 },
|
||||||
|
docTitle: { color: '#111827', marginBottom: 4 },
|
||||||
|
docDate: { color: '#6B7280' },
|
||||||
|
deleteBtn: { padding: 8, marginLeft: 8 },
|
||||||
|
docNotes: { color: '#4B5563', marginBottom: 12, backgroundColor: '#F3F4F6', padding: 8, borderRadius: 6 },
|
||||||
|
previewImage: { width: '100%', height: 150, borderRadius: 8, resizeMode: 'cover', marginTop: 8 },
|
||||||
|
emptyText: { color: '#4B5563', fontWeight: '500', marginBottom: 8, textAlign: 'center' },
|
||||||
|
emptySubText: { color: '#9CA3AF', textAlign: 'center' },
|
||||||
|
fabContainer: { position: 'absolute', left: 16, right: 16 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
FlatList,
|
||||||
|
TextInput,
|
||||||
|
StyleSheet,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Search, Check, X } from 'lucide-react-native';
|
||||||
|
import { Typography } from '../components/ui/Typography';
|
||||||
|
import { Header } from '../components/ui/Header';
|
||||||
|
import { THEME } from '../constants/theme';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { RouteProp } from '@react-navigation/native';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
|
||||||
|
type SelectionScreenNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Selection'>;
|
||||||
|
type SelectionScreenRouteProp = RouteProp<RootStackParamList, 'Selection'>;
|
||||||
|
|
||||||
|
interface SelectionScreenProps {
|
||||||
|
navigation: SelectionScreenNavigationProp;
|
||||||
|
route: SelectionScreenRouteProp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectionScreen: React.FC<SelectionScreenProps> = ({ navigation, route }) => {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
value,
|
||||||
|
isMulti,
|
||||||
|
searchable,
|
||||||
|
label,
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
onSelect,
|
||||||
|
} = route.params;
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!searchQuery) return data;
|
||||||
|
return data.filter((item: any) =>
|
||||||
|
item.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [data, searchQuery]);
|
||||||
|
|
||||||
|
const handleSelect = (item: any) => {
|
||||||
|
if (isMulti) {
|
||||||
|
const currentValue = (Array.isArray(value) ? value : []) as (string | number)[];
|
||||||
|
const isSelected = currentValue.includes(item.value);
|
||||||
|
|
||||||
|
let newValue;
|
||||||
|
if (isSelected) {
|
||||||
|
newValue = currentValue.filter((val) => val !== item.value);
|
||||||
|
} else {
|
||||||
|
newValue = [...currentValue, item.value];
|
||||||
|
}
|
||||||
|
// Instantly update route params for immediate visual feedback
|
||||||
|
navigation.setParams({ value: newValue });
|
||||||
|
onSelect(newValue);
|
||||||
|
} else {
|
||||||
|
onSelect(item.value);
|
||||||
|
navigation.goBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isItemSelected = (item: any) => {
|
||||||
|
if (isMulti) {
|
||||||
|
const currentValue = (Array.isArray(value) ? value : []) as (string | number)[];
|
||||||
|
return currentValue.includes(item.value);
|
||||||
|
}
|
||||||
|
return value === item.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||||
|
<Header title={label || 'Select'} showBack />
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
|
||||||
|
{searchable && (
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<Search color="#9CA3AF" size={20} style={styles.searchIcon} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
autoFocus={true}
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearchQuery('')}>
|
||||||
|
<X color="#9CA3AF" size={16} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={filteredData}
|
||||||
|
keyExtractor={(item, index) => item.value.toString() + index}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.listContainer}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const selected = isItemSelected(item);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.itemContainer,
|
||||||
|
selected && styles.itemSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => handleSelect(item)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.itemText, selected && styles.itemTextSelected]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{selected && <Check color={THEME.colors.primary} size={20} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={() => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Typography variant="body" style={styles.emptyText}>
|
||||||
|
No options found
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
searchIcon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#111827',
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
itemContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
itemSelected: {
|
||||||
|
backgroundColor: THEME.colors.primaryLighter,
|
||||||
|
borderColor: THEME.colors.primaryLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
itemText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
itemTextSelected: {
|
||||||
|
color: THEME.colors.primary,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
padding: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
|||||||
import { Typography } from '../components/ui/Typography';
|
import { Typography } from '../components/ui/Typography';
|
||||||
import { Header } from '../components/ui/Header';
|
import { Header } from '../components/ui/Header';
|
||||||
import { Card } from '../components/ui/Card';
|
import { Card } from '../components/ui/Card';
|
||||||
import { User, ChevronRight, LogOut, CreditCard, Briefcase } from 'lucide-react-native';
|
import { User, ChevronRight, LogOut, CreditCard } from 'lucide-react-native';
|
||||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
@@ -68,7 +68,7 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({ navigation }) =>
|
|||||||
</Card>
|
</Card>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity onPress={() => navigation.navigate('PaymentProfile')}>
|
<TouchableOpacity onPress={() => navigation.navigate('PaymentProfileList')}>
|
||||||
<Card noPadding style={styles.menuItem}>
|
<Card noPadding style={styles.menuItem}>
|
||||||
<View style={styles.menuIconContainer}>
|
<View style={styles.menuIconContainer}>
|
||||||
<CreditCard color="#4F46E5" size={24} />
|
<CreditCard color="#4F46E5" size={24} />
|
||||||
@@ -85,23 +85,6 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({ navigation }) =>
|
|||||||
</Card>
|
</Card>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity onPress={() => navigation.navigate('ManageServices')}>
|
|
||||||
<Card noPadding style={styles.menuItem}>
|
|
||||||
<View style={styles.menuIconContainer}>
|
|
||||||
<Briefcase color="#4F46E5" size={24} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.menuTextContainer}>
|
|
||||||
<Typography variant="body" weight="bold" style={styles.menuTitle}>
|
|
||||||
Manage Services
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" style={styles.menuSubtitle}>
|
|
||||||
Add or edit your offered services
|
|
||||||
</Typography>
|
|
||||||
</View>
|
|
||||||
<ChevronRight color="#9CA3AF" size={20} />
|
|
||||||
</Card>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={handleLogout}>
|
<TouchableOpacity onPress={handleLogout}>
|
||||||
<Card noPadding style={styles.menuItem}>
|
<Card noPadding style={styles.menuItem}>
|
||||||
<View style={[styles.menuIconContainer, { backgroundColor: '#FEE2E2' }]}>
|
<View style={[styles.menuIconContainer, { backgroundColor: '#FEE2E2' }]}>
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, StyleSheet, TouchableOpacity, Image, Alert, ScrollView, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
import { Header } from '../components/ui/Header';
|
||||||
|
import { Typography } from '../components/ui/Typography';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { documentService } from '../services/documentService';
|
||||||
|
import { launchImageLibrary, ImagePickerResponse, Asset } from 'react-native-image-picker';
|
||||||
|
import { Upload } from 'lucide-react-native';
|
||||||
|
import { useSnackbar } from '../context/SnackbarContext';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'UploadDocument'>;
|
||||||
|
|
||||||
|
export const UploadDocumentScreen: React.FC<Props> = ({ navigation }) => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [document, setDocument] = useState<Asset | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { showSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
const handleSelectDocument = () => {
|
||||||
|
launchImageLibrary(
|
||||||
|
{
|
||||||
|
mediaType: 'photo',
|
||||||
|
quality: 0.8,
|
||||||
|
},
|
||||||
|
(response: ImagePickerResponse) => {
|
||||||
|
if (response.didCancel) {
|
||||||
|
console.log('User cancelled image picker');
|
||||||
|
} else if (response.errorCode) {
|
||||||
|
console.log('ImagePicker Error: ', response.errorMessage);
|
||||||
|
Alert.alert('Error', response.errorMessage || 'Failed to pick image');
|
||||||
|
} else if (response.assets && response.assets.length > 0) {
|
||||||
|
setDocument(response.assets[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!title.trim() || !document) {
|
||||||
|
Alert.alert('Error', 'Please provide a title and select a document.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('title', title);
|
||||||
|
formData.append('notes', notes);
|
||||||
|
|
||||||
|
formData.append('document', {
|
||||||
|
uri: document.uri,
|
||||||
|
type: document.type || 'image/jpeg',
|
||||||
|
name: document.fileName || `document-${Date.now()}.jpg`,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await documentService.uploadDocument(formData);
|
||||||
|
showSnackbar('Document uploaded successfully');
|
||||||
|
navigation.goBack();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Upload Error', error);
|
||||||
|
Alert.alert('Error', error.response?.data?.message || 'Failed to upload document. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container}>
|
||||||
|
<Header title="Upload Document" showBack={true} />
|
||||||
|
<ScrollView style={styles.content} keyboardShouldPersistTaps="handled">
|
||||||
|
<Typography variant="body" weight="medium" style={styles.label}>
|
||||||
|
Document Title *
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Nursing License"
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="body" weight="medium" style={styles.label}>
|
||||||
|
Notes
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Registered Nurse License exp 2028"
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
style={styles.textArea}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="body" weight="medium" style={styles.label}>
|
||||||
|
Attachment *
|
||||||
|
</Typography>
|
||||||
|
<TouchableOpacity style={styles.uploadContainer} onPress={handleSelectDocument}>
|
||||||
|
{document ? (
|
||||||
|
<Image source={{ uri: document.uri }} style={styles.previewImage} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.uploadPlaceholder}>
|
||||||
|
<Upload color="#1E3A8A" size={32} />
|
||||||
|
<Typography variant="body" style={styles.uploadText}>
|
||||||
|
Tap to select a document
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
{document && (
|
||||||
|
<TouchableOpacity onPress={() => setDocument(null)} style={styles.removeBtn}>
|
||||||
|
<Typography variant="caption" style={styles.removeBtnText}>Remove</Typography>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom + 40, 40) }]}>
|
||||||
|
<Button
|
||||||
|
title={isSubmitting ? "Uploading..." : "Upload Document"}
|
||||||
|
onPress={handleUpload}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#F9FAFB' },
|
||||||
|
content: { flex: 1, padding: 16 },
|
||||||
|
label: { marginBottom: 8, marginTop: 16, color: '#374151' },
|
||||||
|
textArea: { height: 100, textAlignVertical: 'top' },
|
||||||
|
uploadContainer: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderRadius: 12,
|
||||||
|
height: 200,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
uploadPlaceholder: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
uploadText: { marginTop: 12, color: '#6B7280' },
|
||||||
|
previewImage: { width: '100%', height: '100%', resizeMode: 'cover' },
|
||||||
|
removeBtn: { alignSelf: 'flex-end', marginBottom: 24 },
|
||||||
|
removeBtnText: { color: '#EF4444' },
|
||||||
|
footer: { marginTop: 32 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, StyleSheet, ActivityIndicator, Alert, Image, ScrollView } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
|
import { Header } from '../components/ui/Header';
|
||||||
|
import { Typography } from '../components/ui/Typography';
|
||||||
|
import { documentService, ProfessionalDocument } from '../services/documentService';
|
||||||
|
import { FileText } from 'lucide-react-native';
|
||||||
|
import { getImageUrl } from '../utils/formatters';
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'ViewDocument'>;
|
||||||
|
|
||||||
|
export const ViewDocumentScreen: React.FC<Props> = ({ route, navigation }) => {
|
||||||
|
const { documentId } = route.params;
|
||||||
|
const [document, setDocument] = useState<ProfessionalDocument | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDocument = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await documentService.getDocumentById(documentId);
|
||||||
|
setDocument(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch document', error);
|
||||||
|
Alert.alert('Error', 'Failed to load document details');
|
||||||
|
navigation.goBack();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDocument();
|
||||||
|
}, [documentId, navigation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container}>
|
||||||
|
<Header title="View Document" showBack={true} />
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#1E3A8A" />
|
||||||
|
</View>
|
||||||
|
) : document ? (
|
||||||
|
<ScrollView style={styles.content} contentContainerStyle={{ paddingBottom: 40 }}>
|
||||||
|
<View style={styles.headerSection}>
|
||||||
|
<FileText color="#1E3A8A" size={32} style={styles.icon} />
|
||||||
|
<View style={styles.headerText}>
|
||||||
|
<Typography variant="h2" weight="bold">
|
||||||
|
{document.addition_info?.title || 'Untitled Document'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" style={styles.date}>
|
||||||
|
Uploaded on {new Date(document.created_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{document.addition_info?.notes ? (
|
||||||
|
<View style={styles.notesSection}>
|
||||||
|
<Typography variant="body" weight="medium" style={styles.sectionTitle}>
|
||||||
|
Notes
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body" style={styles.notesText}>
|
||||||
|
{document.addition_info.notes}
|
||||||
|
</Typography>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{document.properties?.url ? (
|
||||||
|
<View style={styles.imageSection}>
|
||||||
|
<Typography variant="body" weight="medium" style={styles.sectionTitle}>
|
||||||
|
Document Preview
|
||||||
|
</Typography>
|
||||||
|
<Image
|
||||||
|
source={{ uri: getImageUrl(document.properties.url) }}
|
||||||
|
style={styles.fullImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Typography variant="body" style={{color: '#6B7280'}}>No image preview available</Typography>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#F9FAFB' },
|
||||||
|
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
content: { flex: 1, padding: 16 },
|
||||||
|
headerSection: { flexDirection: 'row', alignItems: 'center', marginBottom: 24, paddingBottom: 16, borderBottomWidth: 1, borderBottomColor: '#E5E7EB' },
|
||||||
|
icon: { marginRight: 16 },
|
||||||
|
headerText: { flex: 1 },
|
||||||
|
date: { color: '#6B7280', marginTop: 4 },
|
||||||
|
notesSection: { marginBottom: 24, backgroundColor: '#F3F4F6', padding: 16, borderRadius: 8 },
|
||||||
|
sectionTitle: { marginBottom: 8, color: '#374151' },
|
||||||
|
notesText: { color: '#4B5563' },
|
||||||
|
imageSection: { flex: 1 },
|
||||||
|
fullImage: { width: '100%', height: 400, backgroundColor: '#E5E7EB', borderRadius: 8, marginTop: 8 },
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ export const apiClient = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { navigate } from '../navigation/navigationRef';
|
||||||
|
|
||||||
// Optionally, we can add interceptors here later to inject Bearer tokens
|
// Optionally, we can add interceptors here later to inject Bearer tokens
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
@@ -37,9 +38,10 @@ apiClient.interceptors.response.use(
|
|||||||
const originalRequest = error.config;
|
const originalRequest = error.config;
|
||||||
|
|
||||||
// Check if error is 401 and we have not already retried
|
// Check if error is 401 and we have not already retried
|
||||||
|
const errorCode = error.response?.data?.errors?.code;
|
||||||
if (
|
if (
|
||||||
error.response?.status === 401 &&
|
error.response?.status === 401 &&
|
||||||
error.response?.data?.errors?.code === 'TOKEN_EXPIRED' &&
|
(errorCode === 'TOKEN_EXPIRED' || errorCode === 'UNAUTHENTICATED') &&
|
||||||
!originalRequest._retry
|
!originalRequest._retry
|
||||||
) {
|
) {
|
||||||
originalRequest._retry = true; // Mark as retried to prevent infinite loops
|
originalRequest._retry = true; // Mark as retried to prevent infinite loops
|
||||||
@@ -53,7 +55,7 @@ apiClient.interceptors.response.use(
|
|||||||
|
|
||||||
// Call your refresh endpoint
|
// Call your refresh endpoint
|
||||||
const refreshResponse = await axios.post(`${ENV.API_BASE_URL}/v1/caregiver/auth/refresh`, {
|
const refreshResponse = await axios.post(`${ENV.API_BASE_URL}/v1/caregiver/auth/refresh`, {
|
||||||
refresh_token: refreshToken,
|
refreshToken: refreshToken,
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -63,9 +65,9 @@ apiClient.interceptors.response.use(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The exact path depends on your backend response structure
|
// The exact path depends on your backend response structure
|
||||||
// Usually it's refreshResponse.data.data.access_token
|
// Usually it's refreshResponse.data.data.accessToken
|
||||||
const newAccessToken = refreshResponse.data?.data?.access_token || refreshResponse.data?.access_token;
|
const newAccessToken = refreshResponse.data?.data?.accessToken || refreshResponse.data?.accessToken;
|
||||||
const newRefreshToken = refreshResponse.data?.data?.refresh_token || refreshResponse.data?.refresh_token;
|
const newRefreshToken = refreshResponse.data?.data?.refreshToken || refreshResponse.data?.refreshToken;
|
||||||
|
|
||||||
if (newAccessToken) {
|
if (newAccessToken) {
|
||||||
await AsyncStorage.setItem('token', newAccessToken); // Ensure 'token' key matches what you use
|
await AsyncStorage.setItem('token', newAccessToken); // Ensure 'token' key matches what you use
|
||||||
@@ -87,7 +89,8 @@ apiClient.interceptors.response.use(
|
|||||||
// Force user to log out by clearing tokens
|
// Force user to log out by clearing tokens
|
||||||
await AsyncStorage.removeItem('token');
|
await AsyncStorage.removeItem('token');
|
||||||
await AsyncStorage.removeItem('refresh_token');
|
await AsyncStorage.removeItem('refresh_token');
|
||||||
// If you are using Context or Redux, you may need to dispatch a logout action here
|
// Force navigate to Login screen
|
||||||
|
navigate('Login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { apiClient } from './apiClient';
|
||||||
|
|
||||||
|
export interface ProfessionalDocument {
|
||||||
|
id: number;
|
||||||
|
properties?: {
|
||||||
|
ext?: string;
|
||||||
|
url?: string;
|
||||||
|
name?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
};
|
||||||
|
addition_info?: {
|
||||||
|
title?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: ProfessionalDocument[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SingleDocumentResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: ProfessionalDocument;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const documentService = {
|
||||||
|
getDocuments: async () => {
|
||||||
|
const response = await apiClient.get<DocumentResponse>('/v1/resource/caregiver/professional-documents');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getDocumentById: async (id: number) => {
|
||||||
|
const response = await apiClient.get<SingleDocumentResponse>(`/v1/resource/caregiver/professional-documents/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadDocument: async (formData: FormData) => {
|
||||||
|
const response = await apiClient.post<SingleDocumentResponse>(
|
||||||
|
'/v1/resource/caregiver/professional-documents',
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDocument: async (id: number) => {
|
||||||
|
const response = await apiClient.delete(`/v1/resource/caregiver/professional-documents/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -37,6 +37,10 @@ export interface CaregiverProfileResponse {
|
|||||||
profile_image: string;
|
profile_image: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
profile?: {
|
||||||
|
id: number;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
errors: any;
|
errors: any;
|
||||||
meta: any;
|
meta: any;
|
||||||
@@ -149,6 +153,19 @@ export const profileService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeSkills: async (skills: string[]): Promise<any> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/v1/resource/caregiver/skills/remove', {
|
||||||
|
skills,
|
||||||
|
force: 'yes'
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing skills:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getAvailability: async (): Promise<CaregiverAvailability[]> => {
|
getAvailability: async (): Promise<CaregiverAvailability[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/v1/resource/caregiver/availability');
|
const response = await apiClient.get('/v1/resource/caregiver/availability');
|
||||||
|
|||||||
@@ -2,19 +2,33 @@ import { apiClient } from './apiClient';
|
|||||||
|
|
||||||
export interface Service {
|
export interface Service {
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
service_id?: number;
|
||||||
|
caregiver_id?: number;
|
||||||
|
service_name?: string;
|
||||||
|
name?: string; // Sometimes APIs map this interchangeably
|
||||||
care_type_slug?: string;
|
care_type_slug?: string;
|
||||||
care_sub_type_slug?: string;
|
care_sub_type_slug?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
description: string;
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
customer_price?: number | string;
|
customer_price?: number | string;
|
||||||
caregiver_rate?: number | string;
|
caregiver_rate?: number | string;
|
||||||
|
custom_rate?: number | string;
|
||||||
|
effective_rate?: number | string;
|
||||||
tax?: number | string;
|
tax?: number | string;
|
||||||
duration_minutes?: number | string;
|
duration_minutes?: number | string;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddServicePayload {
|
||||||
|
service_id: number;
|
||||||
|
caregiver_id: number;
|
||||||
|
custom_rate: number | string;
|
||||||
|
is_active: boolean;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const serviceService = {
|
export const serviceService = {
|
||||||
getServices: async (filters?: { skills?: string }): Promise<Service[]> => {
|
getServices: async (filters?: { skills?: string }): Promise<Service[]> => {
|
||||||
try {
|
try {
|
||||||
@@ -31,6 +45,16 @@ export const serviceService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getSkillBasedServices: async (): Promise<Service[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/v1/caregivers/services/my-skills');
|
||||||
|
return response.data?.data || response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching skill-based services:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getTaxes: async (): Promise<any[]> => {
|
getTaxes: async (): Promise<any[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/v1/resource/taxes');
|
const response = await apiClient.get('/v1/resource/taxes');
|
||||||
@@ -51,9 +75,11 @@ export const serviceService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addService: async (data: Service): Promise<any> => {
|
addService: async (data: AddServicePayload): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/v1/caregiver/services', data);
|
// According to backend routes, it can be POST /v1/resource/caregiver/services
|
||||||
|
// which delegates to httpAddOwnService
|
||||||
|
const response = await apiClient.post('/v1/resource/caregiver/services', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding service:', error);
|
console.error('Error adding service:', error);
|
||||||
@@ -61,9 +87,9 @@ export const serviceService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateService: async (id: number, data: Partial<Service>): Promise<any> => {
|
updateService: async (id: number, data: Partial<AddServicePayload>): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.patch(`/v1/caregiver/services/${id}`, data);
|
const response = await apiClient.patch(`/v1/resource/caregiver/services/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error updating service with id ${id}:`, error);
|
console.error(`Error updating service with id ${id}:`, error);
|
||||||
@@ -73,7 +99,7 @@ export const serviceService = {
|
|||||||
|
|
||||||
deleteService: async (id: number): Promise<any> => {
|
deleteService: async (id: number): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.delete(`/v1/caregiver/services/${id}`);
|
const response = await apiClient.delete(`/v1/resource/caregiver/services/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting service with id ${id}:`, error);
|
console.error(`Error deleting service with id ${id}:`, error);
|
||||||
|
|||||||
+11
-3
@@ -21,10 +21,18 @@ import { ENV } from '../config/env';
|
|||||||
*/
|
*/
|
||||||
export const getImageUrl = (path?: string | null): string | undefined => {
|
export const getImageUrl = (path?: string | null): string | undefined => {
|
||||||
if (!path) return undefined;
|
if (!path) return undefined;
|
||||||
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('file://') || path.startsWith('content://') || path.startsWith('data:')) {
|
|
||||||
return path;
|
let processedPath = path;
|
||||||
|
if (processedPath.startsWith('http://localhost')) {
|
||||||
|
const baseUrl = ENV.API_BASE_URL.endsWith('/') ? ENV.API_BASE_URL.slice(0, -1) : ENV.API_BASE_URL;
|
||||||
|
// Replace http://localhost with our API base url
|
||||||
|
processedPath = processedPath.replace('http://localhost', baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedPath.startsWith('http://') || processedPath.startsWith('https://') || processedPath.startsWith('file://') || processedPath.startsWith('content://') || processedPath.startsWith('data:')) {
|
||||||
|
return processedPath;
|
||||||
}
|
}
|
||||||
const baseUrl = ENV.API_BASE_URL.endsWith('/') ? ENV.API_BASE_URL.slice(0, -1) : ENV.API_BASE_URL;
|
const baseUrl = ENV.API_BASE_URL.endsWith('/') ? ENV.API_BASE_URL.slice(0, -1) : ENV.API_BASE_URL;
|
||||||
const imagePath = path.startsWith('/') ? path : `/${path}`;
|
const imagePath = processedPath.startsWith('/') ? processedPath : `/${processedPath}`;
|
||||||
return `${baseUrl}${imagePath}`;
|
return `${baseUrl}${imagePath}`;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user