Files

318 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }}>
<TouchableOpacity style={{ flex: 1 }} activeOpacity={1} onPress={onClose} />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View 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}
/>
</View>
</KeyboardAvoidingView>
</View>
</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>
);
}