import { useState } from 'react'; import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking, Alert, TextInput, Modal } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, router } from 'expo-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import MapView, { Marker } from 'react-native-maps'; import { api } from '../../../services/api'; const TABS = ['Profile', 'Subscription', 'Invoices', 'Tickets'] as const; type Tab = typeof TABS[number]; const STATUS_COLOR: Record = { ACTIVE: '#166534', SUSPENDED: '#92400E', CANCELLED: '#991B1B', PENDING: '#475569' }; const STATUS_BG: Record = { ACTIVE: '#DCFCE7', SUSPENDED: '#FEF3C7', CANCELLED: '#FEE2E2', PENDING: '#F1F5F9' }; const TICKET_STATUS_COLOR: Record = { OPEN: '#0891B2', IN_PROGRESS: '#D97706', RESOLVED: '#16A34A', CLOSED: '#6B7280' }; const TYPE_COLOR: Record = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' }; const INV_STATUS: Record = { PAID: { label: 'Paid', color: '#166534', bg: '#DCFCE7' }, SENT: { label: 'Unpaid', color: '#92400E', bg: '#FEF3C7' }, OVERDUE: { label: 'Overdue', color: '#991B1B', bg: '#FEE2E2' }, PARTIAL: { label: 'Partial', color: '#0E7490', bg: '#CFFAFE' }, DRAFT: { label: 'Draft', color: '#6B7280', bg: '#F1F5F9' }, VOID: { label: 'Void', color: '#6B7280', bg: '#F1F5F9' }, }; function InfoRow({ label, value, isLast }: { label: string; value?: string | null; isLast?: boolean }) { return ( {label} {value ?? '—'} ); } // ── 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 ( Record Payment {invoice.invoiceNumber} · Balance: ₱{Number(invoice.balance).toLocaleString()} Amount (₱) Method {METHODS.map(m => ( setMethod(m)} style={{ flex: 1, borderRadius: 12, paddingVertical: 12, alignItems: 'center', marginHorizontal: 3, backgroundColor: method === m ? '#0891B2' : '#F1F5F9' }} activeOpacity={0.7} > {m} ))} Reference # (optional) 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }} onPress={submit} disabled={loading} activeOpacity={0.8} > {loading ? : Confirm Payment} ); } // ── Main Screen ─────────────────────────────────────────────────────────────── export default function ClientDetailScreen() { const params = useLocalSearchParams<{ id: string; tab?: string }>(); const id = params.id; const qc = useQueryClient(); const initialTab = (params.tab === 'invoices' ? 'Invoices' : 'Profile') as Tab; const [activeTab, setActiveTab] = useState(initialTab); const [payingInvoice, setPayingInvoice] = useState(null); const { data: client, isLoading } = useQuery({ queryKey: ['client', id], queryFn: () => api.get(`/api/v1/clients/${id}`).then(r => r.data), }); const { data: invoices } = useQuery({ queryKey: ['client-invoices', id], queryFn: async () => { const res = await api.get(`/api/v1/invoices?clientId=${id}&limit=50`); const items: any[] = res.data?.data ?? res.data ?? []; items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return items; }, enabled: activeTab === 'Invoices', }); const { data: tickets } = useQuery({ queryKey: ['client-tickets', id], queryFn: async () => { const res = await api.get(`/api/v1/tickets?clientId=${id}&limit=50`); return res.data?.data ?? res.data ?? []; }, enabled: activeTab === 'Tickets', }); const sub = client?.subscriptions?.[0]; const navigateToClient = () => { const lat = client?.lat; const lng = client?.lng; if (!lat || !lng) { Alert.alert('No Location', 'This client has no recorded location yet. Location is recorded during installation confirmation.'); return; } Alert.alert('Navigate to Client', `${client?.firstName} ${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' }, ]); }; if (isLoading) { return ( ); } const statusStyle = { color: STATUS_COLOR[client?.status] ?? '#475569', bg: STATUS_BG[client?.status] ?? '#F1F5F9', }; return ( {/* Header */} router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}> ← Back {client?.firstName} {client?.lastName} {client?.accountNumber} {client?.status} {/* Tabs */} {TABS.map(tab => ( setActiveTab(tab)} style={{ paddingHorizontal: 16, paddingVertical: 16, borderBottomWidth: 2.5, borderBottomColor: activeTab === tab ? '#0891B2' : 'transparent' }} activeOpacity={0.7}> {tab} ))} {/* ── PROFILE TAB ── */} {activeTab === 'Profile' && ( {/* Map + Navigate */} Location {client?.lat && client?.lng ? ( <> 📍 Navigate to Client ) : ( No location recorded Location is recorded when a technician confirms an installation )} )} {/* ── SUBSCRIPTION TAB ── */} {activeTab === 'Subscription' && ( {!sub ? ( No active subscription ) : ( )} )} {/* ── INVOICES TAB ── */} {activeTab === 'Invoices' && ( {!invoices ? ( ) : invoices.length === 0 ? ( No invoices found ) : ( invoices.map((inv: any) => { const st = INV_STATUS[inv.status] ?? { label: inv.status, color: '#6B7280', bg: '#F1F5F9' }; const isUnpaid = ['SENT', 'PARTIAL', 'OVERDUE'].includes(inv.status) && Number(inv.balance) > 0; const issued = inv.createdAt ? new Date(inv.createdAt).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; return ( {inv.invoiceNumber} Issued: {issued} {inv.dueDate && ( Due: {new Date(inv.dueDate).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })} )} {st.label} ₱{Number(inv.total).toLocaleString()} {isUnpaid && ( Balance: ₱{Number(inv.balance).toLocaleString()} )} {isUnpaid && ( setPayingInvoice(inv)} activeOpacity={0.8} > + Record Payment )} ); }) )} )} {/* ── TICKETS TAB ── */} {activeTab === 'Tickets' && ( router.push({ pathname: '/(app)/tasks/new', params: { prefillClientId: id, prefillName: `${client?.firstName} ${client?.lastName}` } })} activeOpacity={0.8} > + New Ticket {!tickets ? ( ) : tickets.length === 0 ? ( No tickets for this client ) : ( tickets.map((t: any) => { const typeColor = TYPE_COLOR[t.type] ?? '#6B7280'; const statusColor = TICKET_STATUS_COLOR[t.status] ?? '#6B7280'; return ( router.push(`/(app)/tasks/${t.id}`)} activeOpacity={0.8} style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 16, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9', borderLeftWidth: 4, borderLeftColor: typeColor }} > {t.type} {t.status?.replace('_', ' ')} {t.subject} {t.assignedTo ? `Assigned: ${t.assignedTo.firstName} ${t.assignedTo.lastName}` : 'Unassigned'} ); }) )} )} {/* Pay Invoice Modal */} {payingInvoice && ( setPayingInvoice(null)} onSuccess={() => { setPayingInvoice(null); qc.invalidateQueries({ queryKey: ['client-invoices', id] }); qc.invalidateQueries({ queryKey: ['unpaid-invoices'] }); }} /> )} ); }