317 lines
16 KiB
TypeScript
317 lines
16 KiB
TypeScript
import { View, Text, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator, Linking, Alert, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native';
|
||
import { SlideToConfirm } from '../../../components/SlideToConfirm';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { router } from 'expo-router';
|
||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { useState } from 'react';
|
||
import { api } from '../../../services/api';
|
||
|
||
// ── Pay Invoice Modal ──────────────────────────────────────────────────────────
|
||
function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClose: () => void; onSuccess: () => void }) {
|
||
const [amount, setAmount] = useState('');
|
||
const [method, setMethod] = useState('CASH');
|
||
const [ref, setRef] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const METHODS = ['CASH', 'GCASH', 'MAYA', 'BANK'];
|
||
|
||
const submit = async () => {
|
||
const amt = Number(amount);
|
||
if (!amt || amt <= 0) return Alert.alert('Required', 'Enter a valid amount.');
|
||
if (amt > Number(invoice.balance)) {
|
||
Alert.alert('Over Payment', `Amount exceeds balance of ₱${Number(invoice.balance).toLocaleString()}`);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
await api.post('/api/v1/payments', {
|
||
clientId: invoice.clientId,
|
||
invoiceId: invoice.id,
|
||
amount: amt,
|
||
channel: method,
|
||
referenceNumber: ref.trim() || undefined,
|
||
paymentDate: new Date().toISOString(),
|
||
});
|
||
Alert.alert('Payment Recorded! ✓', `₱${amt.toLocaleString()} applied to ${invoice.invoiceNumber}`);
|
||
onSuccess();
|
||
} catch (e: any) {
|
||
const msg = e?.response?.data?.message;
|
||
Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg ?? 'Payment failed.');
|
||
} finally { setLoading(false); }
|
||
};
|
||
|
||
return (
|
||
<Modal visible animationType="slide" transparent onRequestClose={onClose}>
|
||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
|
||
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24 }}>
|
||
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 2 }}>Record Payment</Text>
|
||
<Text style={{ fontSize: 14, color: '#64748B', marginBottom: 4 }}>{invoice.client?.firstName} {invoice.client?.lastName} · {invoice.client?.accountNumber}</Text>
|
||
<Text style={{ fontSize: 15, color: '#64748B', marginBottom: 20 }}>
|
||
{invoice.invoiceNumber} · Balance: <Text style={{ fontWeight: '700', color: '#991B1B' }}>₱{Number(invoice.balance).toLocaleString()}</Text>
|
||
</Text>
|
||
|
||
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>Amount (₱)</Text>
|
||
<TextInput
|
||
style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14, fontSize: 28, fontWeight: '800', color: '#0F172A', textAlign: 'center', marginBottom: 16 }}
|
||
placeholder="0.00" placeholderTextColor="#CBD5E1"
|
||
keyboardType="decimal-pad" value={amount} onChangeText={setAmount}
|
||
/>
|
||
|
||
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Method</Text>
|
||
<View style={{ flexDirection: 'row', marginBottom: 16 }}>
|
||
{METHODS.map(m => (
|
||
<TouchableOpacity key={m} onPress={() => setMethod(m)}
|
||
style={{ flex: 1, borderRadius: 12, paddingVertical: 12, alignItems: 'center', marginHorizontal: 3, backgroundColor: method === m ? '#0891B2' : '#F1F5F9' }}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Text style={{ fontSize: 13, fontWeight: '700', color: method === m ? '#FFF' : '#64748B' }}>{m}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
|
||
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>Reference # <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text></Text>
|
||
<TextInput
|
||
style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 12, fontSize: 16, color: '#0F172A', marginBottom: 20 }}
|
||
placeholder="GCash ref, OR number..." placeholderTextColor="#94A3B8"
|
||
value={ref} onChangeText={setRef}
|
||
/>
|
||
|
||
<SlideToConfirm
|
||
label={`Slide to record ₱${parseFloat(amount || '0').toLocaleString()} payment`}
|
||
color="#059669"
|
||
onConfirm={submit}
|
||
disabled={loading || !amount || Number(amount) <= 0}
|
||
/>
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</KeyboardAvoidingView>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export default function CollectScreen() {
|
||
const [search, setSearch] = useState('');
|
||
const [payingInvoice, setPayingInvoice] = useState<any>(null);
|
||
const qc = useQueryClient();
|
||
|
||
const { data: invoices, isLoading, isRefetching, refetch } = useQuery({
|
||
queryKey: ['unpaid-invoices'],
|
||
queryFn: async () => {
|
||
const res = await api.get('/api/v1/invoices?limit=100');
|
||
const all: any[] = res.data?.data ?? res.data ?? [];
|
||
const unpaid = all.filter((inv: any) =>
|
||
['SENT', 'PARTIAL', 'OVERDUE'].includes(inv.status) && Number(inv.balance) > 0
|
||
);
|
||
// Sort: overdue first, then by due date ascending
|
||
unpaid.sort((a: any, b: any) => {
|
||
const today = new Date().getTime();
|
||
const da = a.dueDate ? new Date(a.dueDate).getTime() : Infinity;
|
||
const db = b.dueDate ? new Date(b.dueDate).getTime() : Infinity;
|
||
const aOverdue = da < today;
|
||
const bOverdue = db < today;
|
||
if (aOverdue && !bOverdue) return -1;
|
||
if (!aOverdue && bOverdue) return 1;
|
||
return da - db;
|
||
});
|
||
return unpaid;
|
||
},
|
||
});
|
||
|
||
const filtered = (invoices ?? []).filter((inv: any) => {
|
||
if (!search.trim()) return true;
|
||
const q = search.toLowerCase();
|
||
const name = `${inv.client?.firstName ?? ''} ${inv.client?.lastName ?? ''}`.toLowerCase();
|
||
const acct = inv.client?.accountNumber?.toLowerCase() ?? '';
|
||
const num = inv.invoiceNumber?.toLowerCase() ?? '';
|
||
return name.includes(q) || acct.includes(q) || num.includes(q);
|
||
});
|
||
|
||
const today = new Date();
|
||
|
||
const navigate = (inv: any) => {
|
||
const lat = inv.client?.lat;
|
||
const lng = inv.client?.lng;
|
||
if (!lat || !lng) {
|
||
Alert.alert('No Location', `${inv.client?.firstName} ${inv.client?.lastName} has no recorded location yet.\n\nLocation is set during installation confirmation.`);
|
||
return;
|
||
}
|
||
const name = encodeURIComponent(`${inv.client?.firstName} ${inv.client?.lastName}`);
|
||
Alert.alert(
|
||
'📍 Navigate to Client',
|
||
`${inv.client?.firstName} ${inv.client?.lastName}\n${inv.client?.address ?? ''}`,
|
||
[
|
||
{ text: 'Google Maps', onPress: () => Linking.openURL(`https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}&destination_place_id=${name}`) },
|
||
{ text: 'Waze', onPress: () => Linking.openURL(`waze://?ll=${lat},${lng}&navigate=yes`) },
|
||
{ text: 'Cancel', style: 'cancel' },
|
||
]
|
||
);
|
||
};
|
||
|
||
const totalUnremitted = filtered.reduce((s: number, inv: any) => s + Number(inv.balance ?? 0), 0);
|
||
|
||
return (
|
||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||
|
||
{/* Header */}
|
||
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingTop: 16, paddingBottom: 20 }}>
|
||
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Collect</Text>
|
||
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>Unpaid invoices · sorted by due date</Text>
|
||
</View>
|
||
|
||
{/* Action Buttons */}
|
||
<View style={{ flexDirection: 'row', padding: 16, gap: 10 }}>
|
||
<TouchableOpacity
|
||
style={{ flex: 1, backgroundColor: '#059669', borderRadius: 14, paddingVertical: 16, alignItems: 'center' }}
|
||
onPress={() => router.push('/(app)/payments/record')}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Text style={{ color: '#FFF', fontSize: 16, fontWeight: '700' }}>+ Record Payment</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={{ flex: 1, backgroundColor: '#0891B2', borderRadius: 14, paddingVertical: 16, alignItems: 'center' }}
|
||
onPress={() => router.push('/(app)/remittances')}
|
||
activeOpacity={0.8}
|
||
>
|
||
<Text style={{ color: '#FFF', fontSize: 16, fontWeight: '700' }}>📋 Remittances</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* Search */}
|
||
<View style={{ paddingHorizontal: 16, marginBottom: 8 }}>
|
||
<View style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14 }}>
|
||
<TextInput
|
||
style={{ flex: 1, paddingVertical: 12, fontSize: 16, color: '#0F172A' }}
|
||
placeholder="Search by name, account, invoice #"
|
||
placeholderTextColor="#94A3B8"
|
||
value={search}
|
||
onChangeText={setSearch}
|
||
/>
|
||
{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' }}>×</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
{isLoading ? (
|
||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||
<ActivityIndicator color="#0891B2" size="large" />
|
||
</View>
|
||
) : (
|
||
<ScrollView
|
||
style={{ flex: 1 }}
|
||
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}
|
||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#0891B2" />}
|
||
>
|
||
{/* Summary banner */}
|
||
{filtered.length > 0 && (
|
||
<View style={{ backgroundColor: '#FEF2F2', borderRadius: 14, padding: 14, marginBottom: 14, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderWidth: 1, borderColor: '#FCA5A5' }}>
|
||
<View>
|
||
<Text style={{ fontSize: 13, fontWeight: '600', color: '#991B1B' }}>{filtered.length} unpaid invoice{filtered.length !== 1 ? 's' : ''}</Text>
|
||
<Text style={{ fontSize: 11, color: '#DC2626', marginTop: 2 }}>Total outstanding</Text>
|
||
</View>
|
||
<Text style={{ fontSize: 20, fontWeight: '800', color: '#991B1B' }}>₱{totalUnremitted.toLocaleString()}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{filtered.length === 0 ? (
|
||
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||
<Text style={{ fontSize: 16, color: '#94A3B8' }}>
|
||
{search ? 'No results found' : 'All invoices paid! 🎉'}
|
||
</Text>
|
||
</View>
|
||
) : (
|
||
filtered.map((inv: any) => {
|
||
const dueDate = inv.dueDate ? new Date(inv.dueDate) : null;
|
||
const isOverdue = dueDate && dueDate < today;
|
||
const daysLeft = dueDate ? Math.ceil((dueDate.getTime() - today.getTime()) / 86400000) : null;
|
||
const hasLocation = !!(inv.client?.lat && inv.client?.lng);
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
key={inv.id}
|
||
onPress={() => setPayingInvoice(inv)}
|
||
activeOpacity={0.8}
|
||
style={{
|
||
backgroundColor: '#FFF',
|
||
borderRadius: 16,
|
||
padding: 16,
|
||
marginBottom: 12,
|
||
borderWidth: 1,
|
||
borderColor: isOverdue ? '#FCA5A5' : '#F1F5F9',
|
||
borderLeftWidth: 4,
|
||
borderLeftColor: isOverdue ? '#DC2626' : '#F59E0B',
|
||
}}
|
||
>
|
||
{/* Client + amount */}
|
||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A' }}>
|
||
{inv.client?.firstName} {inv.client?.lastName}
|
||
</Text>
|
||
<Text style={{ fontSize: 13, color: '#64748B', marginTop: 2 }}>
|
||
{inv.client?.accountNumber} · {inv.invoiceNumber}
|
||
</Text>
|
||
</View>
|
||
<View style={{ alignItems: 'flex-end' }}>
|
||
<Text style={{ fontSize: 18, fontWeight: '800', color: '#991B1B' }}>
|
||
₱{Number(inv.balance).toLocaleString()}
|
||
</Text>
|
||
{inv.status === 'PARTIAL' && (
|
||
<Text style={{ fontSize: 11, color: '#D97706', fontWeight: '600', marginTop: 2 }}>PARTIAL</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
{/* Due date + navigate */}
|
||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 10 }}>
|
||
<View style={{ backgroundColor: isOverdue ? '#FEE2E2' : '#FFFBEB', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4 }}>
|
||
<Text style={{ fontSize: 13, fontWeight: '600', color: isOverdue ? '#DC2626' : '#D97706' }}>
|
||
{isOverdue
|
||
? `⚠️ Overdue ${Math.abs(daysLeft ?? 0)}d`
|
||
: daysLeft !== null
|
||
? `Due in ${daysLeft}d`
|
||
: 'No due date'}
|
||
</Text>
|
||
</View>
|
||
|
||
<TouchableOpacity
|
||
onPress={() => navigate(inv)}
|
||
style={{
|
||
flexDirection: 'row', alignItems: 'center',
|
||
backgroundColor: hasLocation ? '#ECFEFF' : '#F1F5F9',
|
||
borderRadius: 10, paddingHorizontal: 12, paddingVertical: 7,
|
||
}}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Text style={{ fontSize: 13, fontWeight: '700', color: hasLocation ? '#0891B2' : '#94A3B8' }}>
|
||
{hasLocation ? '📍 Navigate' : '📍 No location'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
})
|
||
)}
|
||
</ScrollView>
|
||
)}
|
||
</View>
|
||
|
||
{payingInvoice && (
|
||
<PayInvoiceModal
|
||
invoice={payingInvoice}
|
||
onClose={() => setPayingInvoice(null)}
|
||
onSuccess={() => {
|
||
setPayingInvoice(null);
|
||
qc.invalidateQueries({ queryKey: ['unpaid-invoices'] });
|
||
}}
|
||
/>
|
||
)}
|
||
</SafeAreaView>
|
||
);
|
||
}
|