feat: dashboard tickets+invoices, collect screen overhaul, client detail tickets tab + pay invoice + map
- 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
This commit is contained in:
@@ -1,47 +1,218 @@
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||
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' }}>
|
||||
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 24 }}>
|
||||
|
||||
{/* 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: 15, marginTop: 2 }}>Payments & remittances</Text>
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>Unpaid invoices · sorted by due date</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}>
|
||||
{/* Action Buttons */}
|
||||
<View style={{ flexDirection: 'row', padding: 16, gap: 10 }}>
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: '#ECFEFF', borderRadius: 20, padding: 20, marginBottom: 14, borderWidth: 1.5, borderColor: '#67E8F9', flexDirection: 'row', alignItems: 'center' }}
|
||||
style={{ flex: 1, backgroundColor: '#059669', borderRadius: 14, paddingVertical: 16, alignItems: 'center' }}
|
||||
onPress={() => router.push('/(app)/payments/record')}
|
||||
activeOpacity={0.7}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={{ width: 56, height: 56, borderRadius: 16, backgroundColor: '#0891B2', alignItems: 'center', justifyContent: 'center', marginRight: 16 }}>
|
||||
<Text style={{ fontSize: 26 }}>💳</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0F172A' }}>Record Payment</Text>
|
||||
<Text style={{ fontSize: 15, color: '#64748B', marginTop: 3 }}>Cash, GCash, Maya, or bank</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 22, color: '#94A3B8' }}>›</Text>
|
||||
<Text style={{ color: '#FFF', fontSize: 16, fontWeight: '700' }}>+ Record Payment</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: '#F0FDF4', borderRadius: 20, padding: 20, borderWidth: 1.5, borderColor: '#86EFAC', flexDirection: 'row', alignItems: 'center' }}
|
||||
style={{ flex: 1, backgroundColor: '#0891B2', borderRadius: 14, paddingVertical: 16, alignItems: 'center' }}
|
||||
onPress={() => router.push('/(app)/remittances')}
|
||||
activeOpacity={0.7}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={{ width: 56, height: 56, borderRadius: 16, backgroundColor: '#059669', alignItems: 'center', justifyContent: 'center', marginRight: 16 }}>
|
||||
<Text style={{ fontSize: 26 }}>📋</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0F172A' }}>Remittances</Text>
|
||||
<Text style={{ fontSize: 15, color: '#64748B', marginTop: 3 }}>Submit & track daily collections</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 22, color: '#94A3B8' }}>›</Text>
|
||||
<Text style={{ color: '#FFF', fontSize: 16, fontWeight: '700' }}>📋 Remittances</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user