feat: leads module - list, detail, add, convert to client, delete; leads section on dashboard
This commit is contained in:
@@ -52,6 +52,7 @@ export default function AppLayout() {
|
|||||||
{/* Hidden — accessed programmatically */}
|
{/* Hidden — accessed programmatically */}
|
||||||
<Tabs.Screen name="remittances" options={{ href: null }} />
|
<Tabs.Screen name="remittances" options={{ href: null }} />
|
||||||
<Tabs.Screen name="users" options={{ href: null }} />
|
<Tabs.Screen name="users" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="leads" options={{ href: null }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import { api } from '../../services/api';
|
|||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { SlideToConfirm } from '../../components/SlideToConfirm';
|
import { SlideToConfirm } from '../../components/SlideToConfirm';
|
||||||
|
|
||||||
|
const LEAD_STATUS: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
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<string, string> = { HIGH: '#DC2626', NORMAL: '#0891B2' };
|
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', NORMAL: '#0891B2' };
|
||||||
const PRIORITY_BG: Record<string, string> = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' };
|
const PRIORITY_BG: Record<string, string> = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' };
|
||||||
const TYPE_COLOR: Record<string, string> = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' };
|
const TYPE_COLOR: Record<string, string> = { 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: [
|
queries: [
|
||||||
{ queryKey: ['dashboard'], queryFn: () => api.get('/api/v1/dashboard/summary').then(r => r.data) },
|
{ 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);
|
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 summary = summaryQ.data ?? {};
|
||||||
const allActiveTickets = ticketsQ.data ?? [];
|
const allActiveTickets = ticketsQ.data ?? [];
|
||||||
const unpaidInvoices = invoicesQ.data ?? [];
|
const unpaidInvoices = invoicesQ.data ?? [];
|
||||||
|
const activeLeads = leadsQ.data ?? [];
|
||||||
|
|
||||||
const unassigned = (allActiveTickets as any[]).filter((t: any) => !t.assignedToId);
|
const unassigned = (allActiveTickets as any[]).filter((t: any) => !t.assignedToId);
|
||||||
const assignedToMe = (allActiveTickets as any[]).filter((t: any) => t.assignedToId === user?.id);
|
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;
|
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));
|
}).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 (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
@@ -282,6 +299,64 @@ export default function DashboardScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{/* ── Leads ── */}
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>Leads</Text>
|
||||||
|
{(activeLeads as any[]).length > 0 && (
|
||||||
|
<View style={{ backgroundColor: '#F5F3FF', borderRadius: 20, paddingHorizontal: 8, paddingVertical: 2, marginLeft: 8 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: '#7C3AED' }}>{(activeLeads as any[]).length}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/(app)/leads')}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>View all</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{(activeLeads as any[]).length === 0 ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/(app)/leads')}
|
||||||
|
style={{ backgroundColor: '#fff', borderRadius: 14, padding: 20, alignItems: 'center', marginBottom: 20, borderWidth: 1, borderColor: '#F1F5F9', borderStyle: 'dashed' }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No active leads — <Text style={{ color: '#0891B2', fontWeight: '700' }}>+ Add one</Text></Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={{ marginBottom: 20 }}>
|
||||||
|
{(activeLeads as any[]).map((l: any) => {
|
||||||
|
const cfg = LEAD_STATUS[l.status] ?? LEAD_STATUS.NEW;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={l.id}
|
||||||
|
onPress={() => 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' }}
|
||||||
|
>
|
||||||
|
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: '#F5F3FF', alignItems: 'center', justifyContent: 'center', marginRight: 12 }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '800', color: '#7C3AED' }}>{l.firstName?.[0]?.toUpperCase()}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A' }}>{l.firstName} {l.lastName !== '—' ? l.lastName : ''}</Text>
|
||||||
|
<Text style={{ fontSize: 13, color: '#64748B', marginTop: 1 }}>{l.phone}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ backgroundColor: cfg.bg, borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: cfg.color }}>{cfg.label}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/(app)/leads')}
|
||||||
|
style={{ alignItems: 'center', paddingVertical: 10 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>+ Add New Lead</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={{ height: 24 }} />
|
<View style={{ height: 24 }} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
222
app/(app)/leads/[id].tsx
Normal file
222
app/(app)/leads/[id].tsx
Normal file
@@ -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<string, { label: string; color: string; bg: string }> = {
|
||||||
|
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 (
|
||||||
|
<View style={{ paddingVertical: 13, paddingHorizontal: 18, borderBottomWidth: 1, borderBottomColor: '#F1F5F9', flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B', fontWeight: '500' }}>{label}</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#1E293B', fontWeight: '600', maxWidth: '60%', textAlign: 'right' }}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#F8FAFC', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ActivityIndicator color="#0891B2" size="large" />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#F8FAFC' }} edges={['top']}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingVertical: 14, flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} style={{ marginRight: 14 }}>
|
||||||
|
<Icon name="arrow-left" size={22} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: '800', color: '#fff' }}>
|
||||||
|
{lead?.firstName} {lead?.lastName !== '—' ? lead?.lastName : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#A5F3FC', marginTop: 1 }}>Lead</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ backgroundColor: cfg.bg, borderRadius: 20, paddingHorizontal: 12, paddingVertical: 5 }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: cfg.color }}>{cfg.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||||
|
{/* Info card */}
|
||||||
|
<View style={{ backgroundColor: '#fff', borderRadius: 16, marginBottom: 16, borderWidth: 1, borderColor: '#F1F5F9', overflow: 'hidden' }}>
|
||||||
|
<InfoRow label="Phone" value={lead?.phone} />
|
||||||
|
<InfoRow label="Email" value={lead?.email} />
|
||||||
|
<InfoRow label="Address" value={lead?.address} />
|
||||||
|
<InfoRow label="Area" value={lead?.area?.name} />
|
||||||
|
<InfoRow label="Notes" value={lead?.notes} />
|
||||||
|
<InfoRow label="Added" value={lead?.createdAt ? new Date(lead.createdAt).toLocaleDateString('en-PH', { dateStyle: 'medium' }) : null} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status update */}
|
||||||
|
{!isConverted && (
|
||||||
|
<View style={{ backgroundColor: '#fff', borderRadius: 16, marginBottom: 16, padding: 16, borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: '#94A3B8', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 0.5 }}>Update Status</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{STATUS_FLOW.filter(s => s !== 'CONVERTED').map(s => {
|
||||||
|
const c = STATUS_CONFIG[s];
|
||||||
|
const isActive = lead?.status === s;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={s} onPress={() => !isActive && updateStatus(s)}
|
||||||
|
style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5,
|
||||||
|
borderColor: isActive ? c.color : '#E2E8F0',
|
||||||
|
backgroundColor: isActive ? c.bg : '#F8FAFC' }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: isActive ? c.color : '#94A3B8' }}>{c.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
{!isConverted && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={confirmConvert}
|
||||||
|
style={{ backgroundColor: '#059669', borderRadius: 14, paddingVertical: 18, alignItems: 'center', flexDirection: 'row', justifyContent: 'center', gap: 8 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '700', color: '#fff' }}>🚀 Convert to Client & Schedule Install</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isConverted && (
|
||||||
|
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 14, paddingVertical: 18, alignItems: 'center', borderWidth: 1.5, borderColor: '#86EFAC' }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#166534' }}>✓ Already Converted to Client</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={confirmDelete}
|
||||||
|
style={{ backgroundColor: '#FEF2F2', borderRadius: 14, paddingVertical: 18, alignItems: 'center', borderWidth: 1.5, borderColor: '#FCA5A5' }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#DC2626' }}>🗑 Delete Lead</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/leads/_layout.tsx
Normal file
5
app/(app)/leads/_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function LeadsLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
191
app/(app)/leads/index.tsx
Normal file
191
app/(app)/leads/index.tsx
Normal file
@@ -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<string, { label: string; color: string; bg: string }> = {
|
||||||
|
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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => 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 */}
|
||||||
|
<View style={{ width: 44, height: 44, borderRadius: 22, backgroundColor: '#ECFEFF', alignItems: 'center', justifyContent: 'center', marginRight: 14 }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0891B2' }}>
|
||||||
|
{l.firstName?.[0]?.toUpperCase() ?? '?'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A' }}>
|
||||||
|
{l.firstName} {l.lastName !== '—' ? l.lastName : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 2 }}>{l.phone}</Text>
|
||||||
|
{l.notes ? <Text style={{ fontSize: 13, color: '#94A3B8', marginTop: 2 }} numberOfLines={1}>{l.notes}</Text> : null}
|
||||||
|
</View>
|
||||||
|
<View style={{ backgroundColor: cfg.bg, borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: cfg.color }}>{cfg.label}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 20, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||||
|
<View>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Leads</Text>
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 15, marginTop: 2 }}>{leads.length} prospect{leads.length !== 1 ? 's' : ''}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowAdd(true)}
|
||||||
|
style={{ backgroundColor: '#fff', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 8, flexDirection: 'row', alignItems: 'center', gap: 6 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 18, color: '#0891B2', fontWeight: '800', lineHeight: 20 }}>+</Text>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: '700', color: '#0891B2' }}>Add Lead</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<View style={{ backgroundColor: '#fff', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||||
|
<View style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14 }}>
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1, paddingVertical: 13, fontSize: 16, color: '#0F172A' }}
|
||||||
|
placeholder="Search by name or phone"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={search} onChangeText={setSearch}
|
||||||
|
/>
|
||||||
|
{search.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearch('')}>
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={leads}
|
||||||
|
keyExtractor={i => i.id}
|
||||||
|
renderItem={renderLead}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 40 }}
|
||||||
|
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#0891B2" />}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{ alignItems: 'center', paddingTop: 60 }}>
|
||||||
|
<Text style={{ fontSize: 40, marginBottom: 12 }}>👥</Text>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '700', color: '#475569' }}>No leads yet</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#94A3B8', marginTop: 4 }}>Tap + Add Lead to record a prospect</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Lead Modal */}
|
||||||
|
<Modal visible={showAdd} transparent animationType="slide" onRequestClose={() => setShowAdd(false)}>
|
||||||
|
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' }}>
|
||||||
|
<TouchableOpacity style={{ flex: 1 }} onPress={() => setShowAdd(false)} />
|
||||||
|
<View style={{ backgroundColor: '#fff', borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40 }}>
|
||||||
|
<View style={{ width: 40, height: 4, backgroundColor: '#E2E8F0', borderRadius: 2, alignSelf: 'center', marginBottom: 20 }} />
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: '800', color: '#1E293B', marginBottom: 20 }}>New Lead</Text>
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '600', color: '#64748B', marginBottom: 6 }}>Full Name <Text style={{ color: '#DC2626' }}>*</Text></Text>
|
||||||
|
<TextInput
|
||||||
|
value={newName} onChangeText={setNewName}
|
||||||
|
placeholder="Juan Dela Cruz"
|
||||||
|
autoCapitalize="words"
|
||||||
|
style={{ borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12, paddingHorizontal: 14, paddingVertical: 13, fontSize: 16, color: '#1E293B', marginBottom: 14 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '600', color: '#64748B', marginBottom: 6 }}>Contact Number <Text style={{ color: '#DC2626' }}>*</Text></Text>
|
||||||
|
<TextInput
|
||||||
|
value={newPhone} onChangeText={setNewPhone}
|
||||||
|
placeholder="09XXXXXXXXX"
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
style={{ borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12, paddingHorizontal: 14, paddingVertical: 13, fontSize: 16, color: '#1E293B', marginBottom: 14 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '600', color: '#64748B', marginBottom: 6 }}>Notes (optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={newNotes} onChangeText={setNewNotes}
|
||||||
|
placeholder="Location, interest, remarks..."
|
||||||
|
multiline
|
||||||
|
style={{ borderWidth: 1, borderColor: '#E2E8F0', borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, fontSize: 15, color: '#1E293B', marginBottom: 20, minHeight: 72, textAlignVertical: 'top' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={addLead} disabled={saving}
|
||||||
|
style={{ backgroundColor: saving ? '#94A3B8' : '#0891B2', borderRadius: 14, paddingVertical: 17, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{saving ? <ActivityIndicator color="#fff" /> : <Text style={{ fontSize: 16, fontWeight: '700', color: '#fff' }}>Save Lead</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user