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}
+
+
+
+
+
+ );
+}