- 5-tab navigation (Home/Clients/Collect/Tickets/Profile) - Inline styles throughout (17px min font, SafeAreaView) - Dashboard fixed to match real API shape - Ticket detail: 2 tabs (Details + Comments), always-visible comment input - Installation confirmation: GPS coordinate capture + client location update - User management screens (Admin only): list, create, detail + role/active toggle - Tasks folder replaces tickets folder - Remittance detail: inline styles - Record payment: prefill from client, live button text - Icon component with SVG icons - Color system: primary #0891B2
212 lines
10 KiB
TypeScript
212 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { router, useLocalSearchParams } from 'expo-router';
|
||
import { api } from '../../../services/api';
|
||
|
||
const METHODS = [
|
||
{ id: 'CASH', label: 'Cash' },
|
||
{ id: 'GCASH', label: 'GCash' },
|
||
{ id: 'MAYA', label: 'Maya' },
|
||
{ id: 'BANK', label: 'Bank Transfer' },
|
||
];
|
||
|
||
export default function RecordPaymentScreen() {
|
||
// Prefill params when navigated from client detail
|
||
const params = useLocalSearchParams<{
|
||
prefillClientId?: string;
|
||
prefillName?: string;
|
||
prefillAccountNumber?: string;
|
||
}>();
|
||
|
||
const [search, setSearch] = useState('');
|
||
const [client, setClient] = useState<any>(null);
|
||
const [amount, setAmount] = useState('');
|
||
const [method, setMethod] = useState('CASH');
|
||
const [reference, setReference] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [searching, setSearching] = useState(false);
|
||
|
||
// Auto-fill client if navigated from client detail
|
||
useEffect(() => {
|
||
if (params.prefillClientId && params.prefillName) {
|
||
setClient({
|
||
id: params.prefillClientId,
|
||
firstName: params.prefillName.split(' ')[0] ?? '',
|
||
lastName: params.prefillName.split(' ').slice(1).join(' ') ?? '',
|
||
accountNumber: params.prefillAccountNumber ?? '',
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
const searchClient = async () => {
|
||
if (!search.trim()) return;
|
||
setSearching(true);
|
||
try {
|
||
const res = await api.get(`/api/v1/clients?search=${encodeURIComponent(search.trim())}&limit=5`);
|
||
const found = res.data?.data ?? res.data ?? [];
|
||
if (Array.isArray(found) && found.length === 1) {
|
||
setClient(found[0]);
|
||
} else if (Array.isArray(found) && found.length > 1) {
|
||
// Show picker if multiple results
|
||
Alert.alert(
|
||
'Multiple clients found',
|
||
found.map((c: any, i: number) => `${i + 1}. ${c.firstName} ${c.lastName} (${c.accountNumber})`).join('\n'),
|
||
[
|
||
...found.slice(0, 5).map((c: any, i: number) => ({
|
||
text: `${i + 1}. ${c.firstName} ${c.lastName}`,
|
||
onPress: () => setClient(c),
|
||
})),
|
||
{ text: 'Cancel', style: 'cancel' as const },
|
||
]
|
||
);
|
||
} else {
|
||
Alert.alert('Not Found', 'No client found. Try account number or full name.');
|
||
}
|
||
} catch {
|
||
Alert.alert('Error', 'Search failed. Please try again.');
|
||
} finally { setSearching(false); }
|
||
};
|
||
|
||
const submit = async () => {
|
||
if (!client) return Alert.alert('Required', 'Search and select a client first.');
|
||
const amt = Number(amount);
|
||
if (!amount || isNaN(amt) || amt <= 0) return Alert.alert('Required', 'Enter a valid amount.');
|
||
setLoading(true);
|
||
try {
|
||
await api.post('/api/v1/payments', {
|
||
clientId: client.id,
|
||
amount: amt,
|
||
channel: method, // API uses `channel` not `paymentMethod`
|
||
referenceNumber: reference.trim() || undefined,
|
||
paymentDate: new Date().toISOString(),
|
||
});
|
||
Alert.alert('Payment Recorded!', `₱${amt.toLocaleString()} from ${client.firstName} ${client.lastName}`, [
|
||
{ text: 'Record Another', onPress: () => { setClient(null); setAmount(''); setSearch(''); setReference(''); } },
|
||
{ text: 'Done', onPress: () => router.back() },
|
||
]);
|
||
} catch (e: any) {
|
||
const msg = e?.response?.data?.message;
|
||
Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg ?? 'Payment failed.');
|
||
} finally { setLoading(false); }
|
||
};
|
||
|
||
const canSubmit = !!client && !!amount && Number(amount) > 0;
|
||
|
||
return (
|
||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||
{/* Header */}
|
||
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 20 }}>
|
||
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||
</TouchableOpacity>
|
||
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Record Payment</Text>
|
||
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>Field collection</Text>
|
||
</View>
|
||
|
||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
|
||
{/* Client section */}
|
||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Client</Text>
|
||
|
||
{client ? (
|
||
<View style={{ backgroundColor: '#ECFEFF', borderRadius: 16, padding: 18, marginBottom: 20, borderWidth: 1.5, borderColor: '#A5F3FC' }}>
|
||
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0E7490' }}>{client.firstName} {client.lastName}</Text>
|
||
<Text style={{ fontSize: 15, color: '#0891B2', marginTop: 3 }}>{client.accountNumber}</Text>
|
||
<TouchableOpacity onPress={() => { setClient(null); setSearch(''); }} style={{ marginTop: 10 }} hitSlop={{ top: 8, bottom: 8, left: 0, right: 8 }}>
|
||
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>× Change client</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
) : (
|
||
<View style={{ marginBottom: 20 }}>
|
||
<View style={{ flexDirection: 'row' }}>
|
||
<View style={{ flex: 1, backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, marginRight: 10 }}>
|
||
<TextInput
|
||
style={{ flex: 1, paddingVertical: 14, fontSize: 16, color: '#0F172A' }}
|
||
placeholder="Account # or name"
|
||
placeholderTextColor="#94A3B8"
|
||
value={search}
|
||
onChangeText={setSearch}
|
||
onSubmitEditing={searchClient}
|
||
returnKeyType="search"
|
||
/>
|
||
{search.length > 0 && (
|
||
<TouchableOpacity onPress={() => setSearch('')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Text style={{ color: '#FFF', fontSize: 12, fontWeight: '800', lineHeight: 14 }}>×</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
<TouchableOpacity
|
||
style={{ backgroundColor: '#0891B2', borderRadius: 14, paddingHorizontal: 18, alignItems: 'center', justifyContent: 'center' }}
|
||
onPress={searchClient}
|
||
activeOpacity={0.8}
|
||
>
|
||
{searching
|
||
? <ActivityIndicator color="#FFF" size="small" />
|
||
: <Text style={{ color: '#FFF', fontWeight: '700', fontSize: 15 }}>Find</Text>
|
||
}
|
||
</TouchableOpacity>
|
||
</View>
|
||
<Text style={{ fontSize: 13, color: '#94A3B8', marginTop: 8 }}>Search by account number, first name, or last name</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* Amount */}
|
||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Amount (₱)</Text>
|
||
<TextInput
|
||
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 16, fontSize: 32, fontWeight: '800', color: '#0F172A', marginBottom: 20, textAlign: 'center' }}
|
||
placeholder="0.00"
|
||
placeholderTextColor="#CBD5E1"
|
||
value={amount}
|
||
onChangeText={setAmount}
|
||
keyboardType="decimal-pad"
|
||
/>
|
||
|
||
{/* Payment method */}
|
||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Payment Method</Text>
|
||
<View style={{ flexDirection: 'row', marginBottom: 20 }}>
|
||
{METHODS.map(m => (
|
||
<TouchableOpacity
|
||
key={m.id}
|
||
onPress={() => setMethod(m.id)}
|
||
style={{ flex: 1, borderRadius: 14, paddingVertical: 14, alignItems: 'center', marginHorizontal: 4, backgroundColor: method === m.id ? '#0891B2' : '#FFF', borderWidth: 1.5, borderColor: method === m.id ? '#0891B2' : '#E2E8F0' }}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Text style={{ fontSize: 13, fontWeight: '700', color: method === m.id ? '#FFF' : '#64748B' }}>{m.label}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
{/* Reference */}
|
||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>
|
||
Reference # <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text>
|
||
</Text>
|
||
<TextInput
|
||
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 28 }}
|
||
placeholder="GCash ref, receipt #, OR number..."
|
||
placeholderTextColor="#94A3B8"
|
||
value={reference}
|
||
onChangeText={setReference}
|
||
/>
|
||
|
||
<TouchableOpacity
|
||
style={{ backgroundColor: canSubmit ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||
onPress={submit}
|
||
disabled={loading || !canSubmit}
|
||
activeOpacity={0.8}
|
||
>
|
||
{loading
|
||
? <ActivityIndicator color="#FFF" />
|
||
: <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>
|
||
{canSubmit ? `Record ₱${Number(amount || 0).toLocaleString()} Payment` : 'Record Payment'}
|
||
</Text>
|
||
}
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|