feat: implement service management module including UI, services, and navigation screens
This commit is contained in:
@@ -13,6 +13,8 @@ export interface InputProps extends TextInputProps {
|
||||
onChangeText?: (text: string) => void;
|
||||
style?: any;
|
||||
labelStyle?: any;
|
||||
multiline?: boolean;
|
||||
numberOfLines?: number;
|
||||
}
|
||||
|
||||
export function Input({ label, error, style, labelStyle, ...props }: InputProps) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const THEME = {
|
||||
colors: {
|
||||
primary: '#1E3A8A',
|
||||
primaryDark: '#172554',
|
||||
primaryLight: '#E0E7FF',
|
||||
primaryLighter: '#EEF2FF',
|
||||
background: '#F9FAFB',
|
||||
@@ -12,6 +13,7 @@ export const THEME = {
|
||||
},
|
||||
border: '#F3F4F6',
|
||||
warning: '#EAB308',
|
||||
error: '#EF4444',
|
||||
},
|
||||
spacing: {
|
||||
xs: 4,
|
||||
|
||||
@@ -16,6 +16,9 @@ import { AvailabilityListScreen } from '../screens/AvailabilityListScreen';
|
||||
import { AddAvailabilityScreen } from '../screens/AddAvailabilityScreen';
|
||||
import { EditAvailabilityScreen } from '../screens/EditAvailabilityScreen';
|
||||
import { PaymentProfileScreen } from '../screens/PaymentProfileScreen';
|
||||
import { ManageServicesScreen } from '../screens/ManageServicesScreen';
|
||||
import { AddEditServiceScreen } from '../screens/AddEditServiceScreen';
|
||||
import { Service } from '../services/serviceService';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Login: undefined;
|
||||
@@ -32,6 +35,8 @@ export type RootStackParamList = {
|
||||
end_time: string;
|
||||
};
|
||||
PaymentProfile: undefined;
|
||||
ManageServices: undefined;
|
||||
AddEditService: { service?: Service };
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
@@ -120,6 +125,8 @@ export const AppNavigator = () => {
|
||||
<Stack.Screen name="AddAvailability" component={AddAvailabilityScreen} />
|
||||
<Stack.Screen name="EditAvailability" component={EditAvailabilityScreen} />
|
||||
<Stack.Screen name="PaymentProfile" component={PaymentProfileScreen} />
|
||||
<Stack.Screen name="ManageServices" component={ManageServicesScreen} />
|
||||
<Stack.Screen name="AddEditService" component={AddEditServiceScreen} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, ScrollView, Alert, KeyboardAvoidingView, Platform, Switch, TouchableOpacity } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { Header } from '../components/ui/Header';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { THEME } from '../constants/theme';
|
||||
import { serviceService, Service } from '../services/serviceService';
|
||||
import { profileService } from '../services/profileService';
|
||||
import { Typography } from '../components/ui/Typography';
|
||||
import { ChevronDown, Check } from 'lucide-react-native';
|
||||
|
||||
const UNIT_OPTIONS = [
|
||||
'Visit',
|
||||
'Hour',
|
||||
'Day',
|
||||
'Week',
|
||||
'Month'
|
||||
];
|
||||
|
||||
interface AddEditServiceScreenProps {
|
||||
navigation: NativeStackNavigationProp<RootStackParamList, 'AddEditService'>;
|
||||
route: RouteProp<RootStackParamList, 'AddEditService'>;
|
||||
}
|
||||
|
||||
export const AddEditServiceScreen: React.FC<AddEditServiceScreenProps> = ({ navigation, route }) => {
|
||||
const existingService = route.params?.service;
|
||||
const isEditing = !!existingService;
|
||||
|
||||
const [form, setForm] = useState<Partial<Service>>({
|
||||
name: existingService?.name || '',
|
||||
description: existingService?.description || '',
|
||||
care_type_slug: existingService?.care_type_slug || '',
|
||||
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,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [careTypes, setCareTypes] = useState<any[]>([]);
|
||||
const [careSubTypes, setCareSubTypes] = useState<any[]>([]);
|
||||
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(() => {
|
||||
fetchCareTypes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.care_type_slug) {
|
||||
fetchCareSubTypes(form.care_type_slug);
|
||||
}
|
||||
}, [form.care_type_slug]);
|
||||
|
||||
const fetchCareTypes = async () => {
|
||||
try {
|
||||
const [ownSkillsResponse, data, taxesData] = await Promise.all([
|
||||
profileService.getOwnSkills(),
|
||||
profileService.getCareTypes(),
|
||||
serviceService.getTaxes()
|
||||
]);
|
||||
|
||||
setCareTypes(data);
|
||||
setTaxes(taxesData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching master types', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCareSubTypes = async (slug: string) => {
|
||||
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 }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIsActive = () => {
|
||||
setForm(prev => ({ ...prev, is_active: !prev.is_active }));
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.name?.trim()) newErrors.name = 'Service name is required';
|
||||
if (!form.description?.trim()) newErrors.description = 'Description is required';
|
||||
if (!form.customer_price) newErrors.customer_price = 'Customer Price is required';
|
||||
if (!form.caregiver_rate) newErrors.caregiver_rate = 'Caregiver Rate is required';
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
await serviceService.updateService(existingService.id, payload);
|
||||
Alert.alert('Success', 'Service updated successfully.');
|
||||
} else {
|
||||
await serviceService.addService(payload);
|
||||
Alert.alert('Success', 'Service added successfully.');
|
||||
}
|
||||
navigation.goBack();
|
||||
} catch (error: any) {
|
||||
console.error('Error saving service:', error);
|
||||
Alert.alert('Error', error?.response?.data?.message || 'Failed to save service.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top, paddingLeft: insets.left, paddingRight: insets.right }]}>
|
||||
<Header title={isEditing ? "Edit Service" : "Add Service"} showBack />
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
|
||||
<Typography variant="body" style={styles.helperText}>
|
||||
{isEditing ? 'Update the details of your service below.' : 'Add a new service that you offer to clients.'}
|
||||
</Typography>
|
||||
|
||||
<View style={styles.formContainer}>
|
||||
<Input
|
||||
label="Service Name *"
|
||||
placeholder="e.g. Elderly Care, Physiotherapy"
|
||||
value={form.name}
|
||||
onChangeText={(val) => handleChange('name', val)}
|
||||
error={errors.name}
|
||||
/>
|
||||
<Input
|
||||
label="Description *"
|
||||
placeholder="Describe what the service includes..."
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
value={form.description}
|
||||
onChangeText={(val) => handleChange('description', val)}
|
||||
error={errors.description}
|
||||
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}>
|
||||
<Typography variant="body" weight="medium">Is Active</Typography>
|
||||
<Switch
|
||||
value={form.is_active}
|
||||
onValueChange={toggleIsActive}
|
||||
trackColor={{ false: '#D1D5DB', true: THEME.colors.primary }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title={isEditing ? "Update Service" : "Save Service"}
|
||||
onPress={handleSave}
|
||||
loading={isSaving}
|
||||
style={styles.saveButton}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: THEME.colors.background,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 24,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
helperText: {
|
||||
color: THEME.colors.text.secondary,
|
||||
marginBottom: 24,
|
||||
},
|
||||
formContainer: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 100,
|
||||
textAlignVertical: 'top', // for Android
|
||||
},
|
||||
switchContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E5E7EB',
|
||||
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: {
|
||||
backgroundColor: THEME.colors.primary,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, TouchableOpacity, Alert } from 'react-native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { Typography } from '../components/ui/Typography';
|
||||
@@ -23,6 +23,20 @@ export const LoginScreen: React.FC<LoginScreenProps> = ({ navigation }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const token = await AsyncStorage.getItem('token');
|
||||
if (token) {
|
||||
navigation.replace('MainTabs');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking auth status:', e);
|
||||
}
|
||||
};
|
||||
checkAuthStatus();
|
||||
}, [navigation]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (step === 'PHONE') {
|
||||
if (!phone || phone.length < 10) {
|
||||
@@ -34,9 +48,14 @@ export const LoginScreen: React.FC<LoginScreenProps> = ({ navigation }) => {
|
||||
try {
|
||||
const response = await authService.login(phone);
|
||||
console.log('OTP sent:', response);
|
||||
if (response.success === false) {
|
||||
Alert.alert('API Error', response.message || 'Unknown error');
|
||||
return;
|
||||
}
|
||||
setStep('OTP');
|
||||
} catch (err: any) {
|
||||
console.error('Login failed:', err);
|
||||
Alert.alert('Frontend Error', err?.response?.data?.message || err.message || 'Failed to connect');
|
||||
setError('Failed to send OTP. Please check your number and try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -51,9 +70,14 @@ export const LoginScreen: React.FC<LoginScreenProps> = ({ navigation }) => {
|
||||
try {
|
||||
const response = await authService.verifyOtp(phone, otp);
|
||||
console.log('OTP verified successfully:', response);
|
||||
// Save the token
|
||||
if (response.data && response.data.accessToken) {
|
||||
await AsyncStorage.setItem('token', response.data.accessToken);
|
||||
// Save the token and refresh token
|
||||
if (response.data) {
|
||||
if (response.data.accessToken) {
|
||||
await AsyncStorage.setItem('token', response.data.accessToken);
|
||||
}
|
||||
if (response.data.refreshToken) {
|
||||
await AsyncStorage.setItem('refresh_token', response.data.refreshToken);
|
||||
}
|
||||
} else if (response.token) {
|
||||
await AsyncStorage.setItem('token', response.token);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, FlatList, Alert, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { Header } from '../components/ui/Header';
|
||||
import { Typography } from '../components/ui/Typography';
|
||||
import { THEME } from '../constants/theme';
|
||||
import { serviceService, Service } from '../services/serviceService';
|
||||
import { Edit2, Trash2, Plus } from 'lucide-react-native';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
|
||||
interface ManageServicesScreenProps {
|
||||
navigation: NativeStackNavigationProp<RootStackParamList, 'ManageServices'>;
|
||||
}
|
||||
|
||||
export const ManageServicesScreen: React.FC<ManageServicesScreenProps> = ({ navigation }) => {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
fetchServices();
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await serviceService.getServices();
|
||||
// Ensure data is array
|
||||
setServices(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching services:', error);
|
||||
Alert.alert('Error', 'Failed to load services. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id?: number) => {
|
||||
if (!id) return;
|
||||
|
||||
Alert.alert(
|
||||
'Delete Service',
|
||||
'Are you sure you want to delete this service?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await serviceService.deleteService(id);
|
||||
Alert.alert('Success', 'Service deleted successfully.');
|
||||
fetchServices();
|
||||
} catch (error) {
|
||||
console.error('Error deleting service:', error);
|
||||
Alert.alert('Error', 'Failed to delete service.');
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Service }) => (
|
||||
<View style={styles.serviceCard}>
|
||||
<View style={styles.serviceInfo}>
|
||||
<Typography variant="h3" weight="bold" style={styles.serviceName}>{item.name}</Typography>
|
||||
<Typography variant="body" style={styles.serviceDescription}>{item.description}</Typography>
|
||||
|
||||
<View style={styles.metaContainer}>
|
||||
<Typography variant="caption" weight="bold" style={styles.price}>
|
||||
Price: ${item.customer_price}
|
||||
</Typography>
|
||||
{item.duration_minutes && (
|
||||
<Typography variant="caption" style={styles.duration}>
|
||||
• Duration: {item.duration_minutes} mins
|
||||
</Typography>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.skillsContainer}>
|
||||
{item.category && (
|
||||
<View style={styles.skillBadge}>
|
||||
<Typography variant="caption" style={styles.skillText}>{item.category}</Typography>
|
||||
</View>
|
||||
)}
|
||||
{item.unit && (
|
||||
<View style={styles.skillBadge}>
|
||||
<Typography variant="caption" style={styles.skillText}>{item.unit}</Typography>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.skillBadge, { backgroundColor: item.is_active ? THEME.colors.primaryLighter : '#FEE2E2' }]}>
|
||||
<Typography variant="caption" style={[styles.skillText, { color: item.is_active ? THEME.colors.primaryDark : '#EF4444' }]}>
|
||||
{item.is_active ? 'Active' : 'Inactive'}
|
||||
</Typography>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionBtn}
|
||||
onPress={() => navigation.navigate('AddEditService', { service: item })}
|
||||
>
|
||||
<Edit2 color={THEME.colors.primary} size={20} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.actionBtn}
|
||||
onPress={() => handleDelete(item.id)}
|
||||
>
|
||||
<Trash2 color={THEME.colors.error} size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
// @ts-ignore - TS types for SafeAreaView in this version incorrectly omit the style prop
|
||||
<SafeAreaView edges={['top', 'left', 'right']} style={styles.container as any}>
|
||||
<Header title="Manage Services" showBack />
|
||||
|
||||
{isLoading ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" color={THEME.colors.primary} />
|
||||
</View>
|
||||
) : services.length === 0 ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<Typography style={styles.emptyText}>No services found. Add one to get started.</Typography>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={services}
|
||||
keyExtractor={(item, index) => item.id?.toString() || index.toString()}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.fab}
|
||||
onPress={() => navigation.navigate('AddEditService', {})}
|
||||
>
|
||||
<Plus color="#FFF" size={24} />
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: THEME.colors.background,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
emptyText: {
|
||||
color: THEME.colors.text.secondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 80, // for FAB
|
||||
},
|
||||
serviceCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
serviceInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
serviceName: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
serviceDescription: {
|
||||
color: THEME.colors.text.secondary,
|
||||
marginBottom: 8,
|
||||
},
|
||||
metaContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
price: {
|
||||
color: THEME.colors.primary,
|
||||
},
|
||||
duration: {
|
||||
color: THEME.colors.text.secondary,
|
||||
marginLeft: 8,
|
||||
},
|
||||
skillsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
skillBadge: {
|
||||
backgroundColor: THEME.colors.primaryLighter,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 16,
|
||||
},
|
||||
skillText: {
|
||||
color: THEME.colors.primaryDark,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
marginLeft: 16,
|
||||
},
|
||||
actionBtn: {
|
||||
padding: 8,
|
||||
marginLeft: 4,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 8,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Typography } from '../components/ui/Typography';
|
||||
import { Header } from '../components/ui/Header';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { User, ChevronRight, LogOut, CreditCard } from 'lucide-react-native';
|
||||
import { User, ChevronRight, LogOut, CreditCard, Briefcase } from 'lucide-react-native';
|
||||
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
@@ -85,6 +85,23 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({ navigation }) =>
|
||||
</Card>
|
||||
</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}>
|
||||
<Card noPadding style={styles.menuItem}>
|
||||
<View style={[styles.menuIconContainer, { backgroundColor: '#FEE2E2' }]}>
|
||||
|
||||
@@ -137,6 +137,8 @@ export const profileService = {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
addSkills: async (skills: string[]): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.post('/v1/resource/caregiver/skills/add', { skills });
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { apiClient } from './apiClient';
|
||||
|
||||
export interface Service {
|
||||
id?: number;
|
||||
name: string;
|
||||
care_type_slug?: string;
|
||||
care_sub_type_slug?: string;
|
||||
category?: string;
|
||||
description: string;
|
||||
unit?: string;
|
||||
customer_price?: number | string;
|
||||
caregiver_rate?: number | string;
|
||||
tax?: number | string;
|
||||
duration_minutes?: number | string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export const serviceService = {
|
||||
getServices: async (filters?: { skills?: string }): Promise<Service[]> => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.skills) {
|
||||
params.append('skills', filters.skills);
|
||||
}
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await apiClient.get(`/v1/caregiver/services${queryString}`);
|
||||
return response.data?.data || response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching services:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getTaxes: async (): Promise<any[]> => {
|
||||
try {
|
||||
const response = await apiClient.get('/v1/resource/taxes');
|
||||
return response.data?.data?.standard_tax_rates || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching taxes:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getServiceById: async (id: number): Promise<Service> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/v1/caregiver/services/${id}`);
|
||||
return response.data?.data || response.data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching service with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addService: async (data: Service): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.post('/v1/caregiver/services', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error adding service:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateService: async (id: number, data: Partial<Service>): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.patch(`/v1/caregiver/services/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`Error updating service with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteService: async (id: number): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/v1/caregiver/services/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting service with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user