import { useState } from 'react'; import { View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity, Linking, Alert, Modal, TextInput, KeyboardAvoidingView, Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useQueries, useQueryClient } from '@tanstack/react-query'; import { router } from 'expo-router'; import { api } from '../../services/api'; import { useAuthStore } from '../../stores/authStore'; import { SlideToConfirm } from '../../components/SlideToConfirm'; const LEAD_STATUS: Record = { NEW: { label: 'New', color: '#0891B2', bg: '#ECFEFF' }, CONTACTED: { label: 'Contacted', color: '#D97706', bg: '#FEF3C7' }, INTERESTED: { label: 'Interested', color: '#7C3AED', bg: '#F5F3FF' }, CONVERTED: { label: 'Converted', color: '#166534', bg: '#DCFCE7' }, LOST: { label: 'Lost', color: '#6B7280', bg: '#F1F5F9' }, }; const PRIORITY_COLOR: Record = { HIGH: '#DC2626', NORMAL: '#0891B2' }; const PRIORITY_BG: Record = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' }; const TYPE_COLOR: Record = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' }; const METHODS = [ { id: 'CASH', label: 'Cash' }, { id: 'GCASH', label: 'GCash' }, { id: 'MAYA', label: 'Maya' }, { id: 'BANK_TRANSFER',label: 'Bank Transfer' }, ]; function KpiCard({ label, value, color, bg }: any) { return ( {label} {value} ); } function TicketRow({ task, onPress }: { task: any; onPress: () => void }) { const typeColor = TYPE_COLOR[task.type] ?? '#6B7280'; return ( {task.type} {task.priority === 'HIGH' && ( HIGH )} {task.status?.replace('_', ' ')} {task.subject} {task.client?.firstName} {task.client?.lastName} {task.assignedTo ? ` ยท ${task.assignedTo.firstName}` : ' ยท Unassigned'} ); } function InvoiceRow({ inv, onPay }: { inv: any; onPay: () => void }) { const dueDate = inv.dueDate ? new Date(inv.dueDate) : null; const today = new Date(); const isOverdue = dueDate && dueDate < today; const daysLeft = dueDate ? Math.ceil((dueDate.getTime() - today.getTime()) / 86400000) : null; const navigate = () => { const lat = inv.client?.lat, lng = inv.client?.lng; if (!lat || !lng) { Alert.alert('No Location', 'No recorded location for this client.'); return; } Alert.alert('Navigate', `Open navigation to ${inv.client?.firstName} ${inv.client?.lastName}?`, [ { text: 'Google Maps', onPress: () => Linking.openURL(`https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`) }, { text: 'Waze', onPress: () => Linking.openURL(`waze://?ll=${lat},${lng}&navigate=yes`) }, { text: 'Cancel', style: 'cancel' }, ]); }; return ( {inv.client?.firstName} {inv.client?.lastName} {inv.invoiceNumber} {isOverdue ? `Overdue by ${Math.abs(daysLeft ?? 0)} day${Math.abs(daysLeft ?? 0) !== 1 ? 's' : ''}` : daysLeft !== null ? `Due in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}` : 'No due date'} โ‚ฑ{Number(inv.balance).toLocaleString()} ๐Ÿ“ Navigate ๐Ÿ’ณ Pay ); } export default function DashboardScreen() { const { user } = useAuthStore(); const qc = useQueryClient(); const role = user?.roles?.[0] ?? user?.role ?? ''; const isAdminOrStaff = role === 'ADMIN' || role === 'STAFF'; const hour = new Date().getHours(); const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; // Payment modal state const [payModal, setPayModal] = useState(false); const [payInvoice, setPayInvoice] = useState(null); const [payAmount, setPayAmount] = useState(''); const [payMethod, setPayMethod] = useState('CASH'); const [payNote, setPayNote] = useState(''); const [paying, setPaying] = useState(false); const openPay = (inv: any) => { setPayInvoice(inv); setPayAmount(String(Number(inv.balance))); setPayMethod('CASH'); setPayNote(''); setPaying(false); setPayModal(true); }; const closePay = () => { if (!paying) setPayModal(false); }; const submitPayment = async () => { if (!payInvoice) return; const amt = parseFloat(payAmount); if (!amt || amt <= 0) { Alert.alert('Invalid Amount', 'Please enter a valid amount.'); return; } setPaying(true); try { await api.post('/api/v1/payments', { clientId: payInvoice.clientId, invoiceId: payInvoice.id, amount: amt, channel: payMethod, ...(payNote.trim() ? { notes: payNote.trim() } : {}), paymentDate: new Date().toISOString(), }); setPayModal(false); qc.invalidateQueries({ queryKey: ['dashboard-invoices'] }); qc.invalidateQueries({ queryKey: ['dashboard'] }); Alert.alert('Payment Recorded โœ“', `โ‚ฑ${amt.toLocaleString()} payment recorded for ${payInvoice.client?.firstName} ${payInvoice.client?.lastName}.`); } catch (e: any) { const msg = e?.response?.data?.message ?? 'Could not record payment.'; Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg); } finally { setPaying(false); } }; const [summaryQ, ticketsQ, invoicesQ, leadsQ] = useQueries({ queries: [ { queryKey: ['dashboard'], queryFn: () => api.get('/api/v1/dashboard/summary').then(r => r.data) }, { queryKey: ['dashboard-tickets'], queryFn: async () => { const res = await api.get('/api/v1/tickets?limit=20'); const all: any[] = res.data?.data ?? res.data ?? []; return all.filter((t: any) => t.status === 'OPEN' || t.status === 'IN_PROGRESS'); }, }, { queryKey: ['dashboard-invoices'], queryFn: async () => { const res = await api.get('/api/v1/invoices?limit=20'); const all: any[] = res.data?.data ?? res.data ?? []; const unpaid = all.filter((inv: any) => ['SENT','PARTIAL','OVERDUE'].includes(inv.status) && Number(inv.balance) > 0); unpaid.sort((a: any, b: any) => { const da = a.dueDate ? new Date(a.dueDate).getTime() : Infinity; const db = b.dueDate ? new Date(b.dueDate).getTime() : Infinity; return da - db; }); return unpaid.slice(0, 5); }, }, { queryKey: ['dashboard-leads'], queryFn: async () => { const res = await api.get('/api/v1/leads?limit=20'); const all: any[] = res.data?.data ?? res.data ?? []; return all.filter((l: any) => l.status !== 'CONVERTED' && l.status !== 'LOST').slice(0, 5); }, }, ], }); const isLoading = summaryQ.isLoading || ticketsQ.isLoading || invoicesQ.isLoading; const isRefetching = summaryQ.isRefetching || ticketsQ.isRefetching || invoicesQ.isRefetching; const summary = summaryQ.data ?? {}; const allActiveTickets = ticketsQ.data ?? []; const unpaidInvoices = invoicesQ.data ?? []; const activeLeads = leadsQ.data ?? []; const unassigned = (allActiveTickets as any[]).filter((t: any) => !t.assignedToId); const assignedToMe = (allActiveTickets as any[]).filter((t: any) => t.assignedToId === user?.id); const seen = new Set(); const mergedTickets = [...assignedToMe, ...unassigned].filter((t: any) => { if (seen.has(t.id)) return false; seen.add(t.id); return true; }).slice(0, 5).sort((a: any, b: any) => (a.priority === 'HIGH' ? -1 : 1) - (b.priority === 'HIGH' ? -1 : 1)); const refetchAll = () => { summaryQ.refetch(); ticketsQ.refetch(); invoicesQ.refetch(); leadsQ.refetch(); }; return ( } > {/* Header */} {greeting}, {user?.firstName ?? 'Field Staff'} {isLoading ? ( ) : ( {isAdminOrStaff && summary?.revenue?.thisMonth != null && ( This Month's Revenue โ‚ฑ{Number(summary.revenue.thisMonth).toLocaleString()} {summary.revenue.growth !== undefined && ( +{summary.revenue.growth}% )} )} {/* Tickets */} Tickets {mergedTickets.length > 0 && ( {mergedTickets.length} )} router.push('/(app)/tasks')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> View all {mergedTickets.length === 0 ? ( No active tickets ๐ŸŽ‰ ) : ( {mergedTickets.map((t: any) => ( router.push(`/(app)/tasks/${t.id}`)} /> ))} router.push('/(app)/tasks')} style={{ alignItems: 'center', paddingVertical: 10, marginBottom: 12 }}> View all tickets โ†’ )} {/* Unpaid Invoices */} Unpaid Invoices router.push('/(app)/payments')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> View all {unpaidInvoices.length === 0 ? ( No unpaid invoices ๐ŸŽ‰ ) : ( {(unpaidInvoices as any[]).map((inv: any) => ( openPay(inv)} /> ))} router.push('/(app)/payments')} style={{ alignItems: 'center', paddingVertical: 10, marginBottom: 12 }}> View all unpaid invoices โ†’ )} {/* โ”€โ”€ Leads โ”€โ”€ */} Leads {(activeLeads as any[]).length > 0 && ( {(activeLeads as any[]).length} )} router.push('/(app)/leads')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > View all {(activeLeads as any[]).length === 0 ? ( router.push('/(app)/leads')} style={{ backgroundColor: '#fff', borderRadius: 14, padding: 20, alignItems: 'center', marginBottom: 20, borderWidth: 1, borderColor: '#F1F5F9', borderStyle: 'dashed' }} > No active leads โ€” + Add one ) : ( {(activeLeads as any[]).map((l: any) => { const cfg = LEAD_STATUS[l.status] ?? LEAD_STATUS.NEW; return ( router.push(`/(app)/leads/${l.id}`)} activeOpacity={0.7} style={{ backgroundColor: '#fff', borderRadius: 14, padding: 14, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9', flexDirection: 'row', alignItems: 'center' }} > {l.firstName?.[0]?.toUpperCase()} {l.firstName} {l.lastName !== 'โ€”' ? l.lastName : ''} {l.phone} {cfg.label} ); })} router.push('/(app)/leads')} style={{ alignItems: 'center', paddingVertical: 10 }} > + Add New Lead )} )} {/* โ”€โ”€ Payment Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} {/* Tap outside to dismiss โ€” only on the dark area above the sheet */} {/* Handle */} Record Payment {payInvoice && ( {payInvoice.client?.firstName} {payInvoice.client?.lastName} ยท {payInvoice.invoiceNumber} )} {/* Amount */} Amount (Balance: โ‚ฑ{Number(payInvoice?.balance ?? 0).toLocaleString()}) {/* Method */} Payment Method {METHODS.map(m => ( setPayMethod(m.id)} style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5, borderColor: payMethod === m.id ? '#0891B2' : '#E2E8F0', backgroundColor: payMethod === m.id ? '#ECFEFF' : '#F8FAFC' }} > {m.label} ))} {/* Notes */} Notes (optional) ); }