293 lines
15 KiB
TypeScript
293 lines
15 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, Modal } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { router, useLocalSearchParams } from 'expo-router';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
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 [invoice, setInvoice] = useState<any>(null);
|
||
const [showInvoicePicker, setShowInvoicePicker] = useState(false);
|
||
const [amount, setAmount] = useState('');
|
||
const [method, setMethod] = useState('CASH');
|
||
const [reference, setReference] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [searching, setSearching] = useState(false);
|
||
|
||
const { data: clientInvoices } = useQuery({
|
||
queryKey: ['record-payment-invoices', client?.id],
|
||
queryFn: async () => {
|
||
const res = await api.get(`/api/v1/invoices?clientId=${client.id}&limit=50`);
|
||
const all: any[] = res.data?.data ?? res.data ?? [];
|
||
return all.filter((inv: any) => ['SENT', 'PARTIAL', 'OVERDUE'].includes(inv.status) && Number(inv.balance) > 0);
|
||
},
|
||
enabled: !!client?.id,
|
||
});
|
||
|
||
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 ?? '',
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
// When client changes, reset invoice selection
|
||
useEffect(() => { setInvoice(null); }, [client?.id]);
|
||
|
||
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,
|
||
invoiceId: invoice?.id ?? undefined,
|
||
amount: amt,
|
||
channel: method,
|
||
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>
|
||
)}
|
||
|
||
{/* Invoice selection (shown after client selected) */}
|
||
{client && (
|
||
<>
|
||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Invoice <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text></Text>
|
||
{!clientInvoices ? (
|
||
<ActivityIndicator color="#0891B2" style={{ marginBottom: 16 }} />
|
||
) : clientInvoices.length === 0 ? (
|
||
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 14, padding: 14, marginBottom: 16 }}>
|
||
<Text style={{ fontSize: 15, color: '#166534', fontWeight: '600' }}>✓ No outstanding invoices</Text>
|
||
</View>
|
||
) : (
|
||
<TouchableOpacity
|
||
style={{ backgroundColor: invoice ? '#ECFEFF' : '#FFF', borderWidth: 1.5, borderColor: invoice ? '#0891B2' : '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14, marginBottom: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}
|
||
onPress={() => setShowInvoicePicker(true)}
|
||
activeOpacity={0.7}
|
||
>
|
||
<View>
|
||
<Text style={{ fontSize: 16, fontWeight: invoice ? '700' : '400', color: invoice ? '#0891B2' : '#94A3B8' }}>
|
||
{invoice ? invoice.invoiceNumber : 'Select invoice to apply payment'}
|
||
</Text>
|
||
{invoice && (
|
||
<Text style={{ fontSize: 13, color: '#0891B2', marginTop: 2 }}>Balance: ₱{Number(invoice.balance).toLocaleString()}</Text>
|
||
)}
|
||
</View>
|
||
<Text style={{ color: '#94A3B8', fontSize: 18 }}>▾</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 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>
|
||
|
||
{/* Invoice Picker Modal */}
|
||
<Modal visible={showInvoicePicker} transparent animationType="slide" onRequestClose={() => setShowInvoicePicker(false)}>
|
||
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={() => setShowInvoicePicker(false)}>
|
||
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24, maxHeight: '70%' }}>
|
||
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 4 }}>Select Invoice</Text>
|
||
<Text style={{ fontSize: 15, color: '#64748B', marginBottom: 20 }}>Choose which invoice to apply payment to</Text>
|
||
|
||
<TouchableOpacity
|
||
onPress={() => { setInvoice(null); setShowInvoicePicker(false); }}
|
||
style={{ padding: 16, borderRadius: 14, marginBottom: 10, backgroundColor: !invoice ? '#ECFEFF' : '#F8FAFC', borderWidth: 1.5, borderColor: !invoice ? '#0891B2' : '#F1F5F9' }}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Text style={{ fontSize: 16, fontWeight: '600', color: !invoice ? '#0891B2' : '#64748B' }}>No specific invoice (general payment)</Text>
|
||
</TouchableOpacity>
|
||
|
||
{(clientInvoices ?? []).map((inv: any) => (
|
||
<TouchableOpacity
|
||
key={inv.id}
|
||
onPress={() => { setInvoice(inv); setAmount(String(inv.balance)); setShowInvoicePicker(false); }}
|
||
style={{ padding: 16, borderRadius: 14, marginBottom: 10, backgroundColor: invoice?.id === inv.id ? '#ECFEFF' : '#F8FAFC', borderWidth: 1.5, borderColor: invoice?.id === inv.id ? '#0891B2' : '#F1F5F9' }}
|
||
activeOpacity={0.7}
|
||
>
|
||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A' }}>{inv.invoiceNumber}</Text>
|
||
<Text style={{ fontSize: 16, fontWeight: '800', color: '#991B1B' }}>₱{Number(inv.balance).toLocaleString()}</Text>
|
||
</View>
|
||
<Text style={{ fontSize: 13, color: '#64748B', marginTop: 3 }}>
|
||
{inv.dueDate ? `Due: ${new Date(inv.dueDate).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })}` : 'No due date'} · {inv.status}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</Modal>
|
||
</SafeAreaView>
|
||
);
|
||
}
|