- Dashboard: active tickets (unassigned + assigned to me, top 10), top 10 unpaid invoices by due date, revenue hidden for TECHNICIAN/COLLECTOR - Collect screen: unpaid invoices sorted overdue-first, remittance button at top, navigate button (Google Maps/Waze) - Client Detail: added Tickets tab, removed Payments tab, Invoices tab has pay button per invoice + status tags + ordered by issuedDate - Client Profile tab: map view + navigate button using client lat/lng - Installed react-native-maps
220 lines
11 KiB
TypeScript
220 lines
11 KiB
TypeScript
import { View, Text, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator, Linking, Alert, TextInput } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { router } from 'expo-router';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useState } from 'react';
|
||
import { api } from '../../../services/api';
|
||
|
||
export default function CollectScreen() {
|
||
const [search, setSearch] = useState('');
|
||
|
||
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={() => router.push({ pathname: '/(app)/clients/[id]', params: { id: inv.clientId, tab: 'invoices' } })}
|
||
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={(e) => { e.stopPropagation?.(); 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>
|
||
</SafeAreaView>
|
||
);
|
||
}
|