Files
fiberops-mobile/app/(app)/payments/index.tsx
Nemo 346e41445b 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
2026-03-24 11:27:18 +08:00

220 lines
11 KiB
TypeScript
Raw 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 } 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>
);
}