diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx index 7b78568..34984b7 100644 --- a/app/(app)/_layout.tsx +++ b/app/(app)/_layout.tsx @@ -52,6 +52,7 @@ export default function AppLayout() { {/* Hidden — accessed programmatically */} + ); } diff --git a/app/(app)/dashboard.tsx b/app/(app)/dashboard.tsx index fd520af..15f8986 100644 --- a/app/(app)/dashboard.tsx +++ b/app/(app)/dashboard.tsx @@ -10,6 +10,14 @@ 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' }; @@ -157,7 +165,7 @@ export default function DashboardScreen() { } }; - const [summaryQ, ticketsQ, invoicesQ] = useQueries({ + const [summaryQ, ticketsQ, invoicesQ, leadsQ] = useQueries({ queries: [ { queryKey: ['dashboard'], queryFn: () => api.get('/api/v1/dashboard/summary').then(r => r.data) }, { @@ -182,6 +190,14 @@ export default function DashboardScreen() { return unpaid.slice(0, 10); }, }, + { + 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); + }, + }, ], }); @@ -190,6 +206,7 @@ export default function DashboardScreen() { 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); @@ -198,7 +215,7 @@ export default function DashboardScreen() { if (seen.has(t.id)) return false; seen.add(t.id); return true; }).slice(0, 10).sort((a: any, b: any) => (a.priority === 'HIGH' ? -1 : 1) - (b.priority === 'HIGH' ? -1 : 1)); - const refetchAll = () => { summaryQ.refetch(); ticketsQ.refetch(); invoicesQ.refetch(); }; + const refetchAll = () => { summaryQ.refetch(); ticketsQ.refetch(); invoicesQ.refetch(); leadsQ.refetch(); }; return ( @@ -282,6 +299,64 @@ export default function DashboardScreen() { ))} )} + {/* ── 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 + + + )} + )} diff --git a/app/(app)/leads/[id].tsx b/app/(app)/leads/[id].tsx new file mode 100644 index 0000000..f93b9e0 --- /dev/null +++ b/app/(app)/leads/[id].tsx @@ -0,0 +1,222 @@ +import { View, Text, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useLocalSearchParams, router } from 'expo-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '../../../services/api'; +import { Icon } from '../../../components/Icon'; + +const STATUS_CONFIG: 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 STATUS_FLOW = ['NEW', 'CONTACTED', 'INTERESTED', 'CONVERTED', 'LOST']; + +function InfoRow({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( + + {label} + {value} + + ); +} + +export default function LeadDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const qc = useQueryClient(); + + const { data: lead, isLoading, refetch } = useQuery({ + queryKey: ['lead', id], + queryFn: () => api.get(`/api/v1/leads/${id}`).then(r => r.data), + staleTime: 0, + }); + + const cfg = STATUS_CONFIG[lead?.status ?? 'NEW'] ?? STATUS_CONFIG.NEW; + const isConverted = lead?.status === 'CONVERTED'; + + const updateStatus = async (status: string) => { + try { + await api.patch(`/api/v1/leads/${id}`, { status }); + await refetch(); + qc.invalidateQueries({ queryKey: ['leads'] }); + } catch { + Alert.alert('Error', 'Could not update status.'); + } + }; + + const confirmConvert = () => { + Alert.alert( + 'Convert to Client?', + `This will start the onboarding process for ${lead?.firstName} ${lead?.lastName}. They will be added to Clients and an installation ticket will be created.`, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Convert & Onboard', style: 'default', onPress: convertLead }, + ] + ); + }; + + const convertLead = async () => { + try { + // Create client from lead + const clientRes = await api.post('/api/v1/clients', { + firstName: lead.firstName, + lastName: lead.lastName !== '—' ? lead.lastName : '', + phone: lead.phone, + address: lead.address ?? '—', + ...(lead.email ? { email: lead.email } : {}), + ...(lead.areaId ? { areaId: lead.areaId } : {}), + }); + const client = clientRes.data; + + // Create installation ticket + const ticketRes = await api.post('/api/v1/tickets', { + clientId: client.id, + subject: `New Installation — ${lead.firstName} ${lead.lastName !== '—' ? lead.lastName : ''}`.trim(), + type: 'INSTALLATION', + priority: 'NORMAL', + }); + const ticket = ticketRes.data; + + // Mark lead as CONVERTED + await api.patch(`/api/v1/leads/${id}`, { + status: 'CONVERTED', + convertedClientId: client.id, + }); + + qc.invalidateQueries({ queryKey: ['leads'] }); + qc.invalidateQueries({ queryKey: ['clients'] }); + qc.invalidateQueries({ queryKey: ['tasks'] }); + + Alert.alert( + 'Lead Converted! 🎉', + `${lead.firstName} is now a client with an installation ticket created.`, + [{ + text: 'View Installation Ticket', + onPress: () => { + router.replace('/(app)/tasks'); + setTimeout(() => router.push(`/(app)/tasks/${ticket.id}`), 300); + }, + }, { + text: 'Done', + onPress: () => router.back(), + }] + ); + } catch (e: any) { + const msg = e?.response?.data?.message ?? 'Could not convert lead.'; + Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg); + } + }; + + const confirmDelete = () => { + Alert.alert( + 'Delete Lead?', + `Are you sure you want to delete ${lead?.firstName} ${lead?.lastName}? This cannot be undone.`, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: deleteLead }, + ] + ); + }; + + const deleteLead = async () => { + try { + await api.delete(`/api/v1/leads/${id}`); + qc.invalidateQueries({ queryKey: ['leads'] }); + router.back(); + } catch { + Alert.alert('Error', 'Could not delete lead.'); + } + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + {/* Header */} + + router.back()} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} style={{ marginRight: 14 }}> + + + + + {lead?.firstName} {lead?.lastName !== '—' ? lead?.lastName : ''} + + Lead + + + {cfg.label} + + + + + {/* Info card */} + + + + + + + + + + {/* Status update */} + {!isConverted && ( + + Update Status + + {STATUS_FLOW.filter(s => s !== 'CONVERTED').map(s => { + const c = STATUS_CONFIG[s]; + const isActive = lead?.status === s; + return ( + !isActive && updateStatus(s)} + style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5, + borderColor: isActive ? c.color : '#E2E8F0', + backgroundColor: isActive ? c.bg : '#F8FAFC' }} + > + {c.label} + + ); + })} + + + )} + + {/* Action buttons */} + + {!isConverted && ( + + 🚀 Convert to Client & Schedule Install + + )} + + {isConverted && ( + + ✓ Already Converted to Client + + )} + + + 🗑 Delete Lead + + + + + ); +} diff --git a/app/(app)/leads/_layout.tsx b/app/(app)/leads/_layout.tsx new file mode 100644 index 0000000..5ff41c8 --- /dev/null +++ b/app/(app)/leads/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function LeadsLayout() { + return ; +} diff --git a/app/(app)/leads/index.tsx b/app/(app)/leads/index.tsx new file mode 100644 index 0000000..813147a --- /dev/null +++ b/app/(app)/leads/index.tsx @@ -0,0 +1,191 @@ +import { useState } from 'react'; +import { + View, Text, FlatList, TouchableOpacity, TextInput, + ActivityIndicator, RefreshControl, Alert, Modal +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { router } from 'expo-router'; +import { api } from '../../../services/api'; +import { useAuthStore } from '../../../stores/authStore'; + +const STATUS_CONFIG: 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' }, +}; + +export default function LeadsScreen() { + const { user } = useAuthStore(); + const qc = useQueryClient(); + const [search, setSearch] = useState(''); + const [showAdd, setShowAdd] = useState(false); + const [newName, setNewName] = useState(''); + const [newPhone, setNewPhone] = useState(''); + const [newNotes, setNewNotes] = useState(''); + const [saving, setSaving] = useState(false); + + const { data, isLoading, refetch, isRefetching } = useQuery({ + queryKey: ['leads'], + queryFn: () => api.get('/api/v1/leads?limit=100').then(r => r.data?.data ?? r.data ?? []), + }); + + const leads: any[] = (data ?? []).filter((l: any) => + `${l.firstName} ${l.lastName} ${l.phone}`.toLowerCase().includes(search.toLowerCase()) + ); + + const addLead = async () => { + const parts = newName.trim().split(' '); + const firstName = parts[0] ?? ''; + const lastName = parts.slice(1).join(' ') || '—'; + if (!firstName) { Alert.alert('Required', 'Enter the lead\'s name.'); return; } + if (!newPhone.trim()) { Alert.alert('Required', 'Enter a contact number.'); return; } + setSaving(true); + try { + await api.post('/api/v1/leads', { + firstName, lastName, + phone: newPhone.trim(), + ...(newNotes.trim() ? { notes: newNotes.trim() } : {}), + }); + setShowAdd(false); + setNewName(''); setNewPhone(''); setNewNotes(''); + qc.invalidateQueries({ queryKey: ['leads'] }); + refetch(); + } catch (e: any) { + Alert.alert('Error', e?.response?.data?.message ?? 'Could not save lead.'); + } finally { + setSaving(false); + } + }; + + const renderLead = ({ item: l }: { item: any }) => { + const cfg = STATUS_CONFIG[l.status] ?? STATUS_CONFIG.NEW; + return ( + router.push(`/(app)/leads/${l.id}`)} + activeOpacity={0.7} + style={{ backgroundColor: '#fff', borderRadius: 14, padding: 16, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9', flexDirection: 'row', alignItems: 'center' }} + > + {/* Avatar circle */} + + + {l.firstName?.[0]?.toUpperCase() ?? '?'} + + + + + {l.firstName} {l.lastName !== '—' ? l.lastName : ''} + + {l.phone} + {l.notes ? {l.notes} : null} + + + {cfg.label} + + + ); + }; + + return ( + + {/* Header */} + + + Leads + {leads.length} prospect{leads.length !== 1 ? 's' : ''} + + setShowAdd(true)} + style={{ backgroundColor: '#fff', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 8, flexDirection: 'row', alignItems: 'center', gap: 6 }} + > + + + Add Lead + + + + {/* Search */} + + + + {search.length > 0 && ( + setSearch('')}> + + × + + + )} + + + + {isLoading ? ( + + + + ) : ( + i.id} + renderItem={renderLead} + contentContainerStyle={{ padding: 16, paddingBottom: 40 }} + refreshControl={} + ListEmptyComponent={ + + 👥 + No leads yet + Tap + Add Lead to record a prospect + + } + /> + )} + + {/* Add Lead Modal */} + setShowAdd(false)}> + + setShowAdd(false)} /> + + + New Lead + + Full Name * + + + Contact Number * + + + Notes (optional) + + + + {saving ? : Save Lead} + + + + + + ); +}