diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 284c4ce..555790c 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,7 +1,7 @@ // app/(tabs)/_layout.tsx import { Tabs, router } from 'expo-router'; import { useEffect } from 'react'; -import { Home, Users, Map, Settings } from 'lucide-react-native'; +import { Home, Users, Map, Settings, MapPin } from 'lucide-react-native'; import { useUserStore } from '@/store/useUserStore'; export default function TabLayout() { @@ -23,6 +23,7 @@ export default function TabLayout() { > }} /> }} /> + }} /> }} /> }} /> diff --git a/app/(tabs)/contacts.tsx b/app/(tabs)/contacts.tsx index f7c104f..f13ba9d 100644 --- a/app/(tabs)/contacts.tsx +++ b/app/(tabs)/contacts.tsx @@ -13,6 +13,8 @@ export default function ContactsScreen() { const { contacts, setContacts } = useContactStore(); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState('All'); + const [territoryFilter, setTerritoryFilter] = useState('All'); + const [territories, setTerritories] = useState([]); const [showAdd, setShowAdd] = useState(false); const statusOptions = ['All', 'Active', 'Return Visit', 'Bible Study', 'Not Interested', 'Do Not Call']; @@ -23,6 +25,10 @@ export default function ContactsScreen() { 'SELECT * FROM contacts WHERE deleted_at IS NULL ORDER BY full_name ASC' ); setContacts(rows.map(dbToContact)); + + // Load territory codes for filter + const tRows = await db.getAllAsync('SELECT territory_code FROM territories ORDER BY territory_code ASC'); + setTerritories(tRows.map((r: any) => r.territory_code)); } useFocusEffect(useCallback(() => { loadContacts(); }, [])); @@ -31,7 +37,8 @@ export default function ContactsScreen() { const matchSearch = c.fullName.toLowerCase().includes(search.toLowerCase()) || (c.address?.toLowerCase().includes(search.toLowerCase()) ?? false); const matchStatus = statusFilter === 'All' || c.status === statusFilter; - return matchSearch && matchStatus; + const matchTerritory = territoryFilter === 'All' || c.territoryCode === territoryFilter; + return matchSearch && matchStatus && matchTerritory; }); return ( @@ -59,7 +66,7 @@ export default function ContactsScreen() { {/* Status Filter */} - + + {/* Territory Filter */} + {territories.length > 0 && ( + + `t-${item}`} + renderItem={({ item }) => ( + setTerritoryFilter(item)} + className={`px-3 py-1 rounded-full border ${territoryFilter === item ? 'bg-accent border-accent' : 'bg-white border-gray-200'}`} + > + {item === 'All' ? 'All Territories' : item} + + )} + /> + + )} + {/* List */} ([]); + const [search, setSearch] = useState(''); + const [showAdd, setShowAdd] = useState(false); + + async function loadTerritories() { + const db = await getDatabase(); + const rows = await db.getAllAsync('SELECT * FROM territories ORDER BY territory_code ASC'); + setTerritories(rows.map(dbToTerritory)); + } + + useFocusEffect(useCallback(() => { loadTerritories(); }, [])); + + const filtered = territories.filter((t) => + t.territoryCode.toLowerCase().includes(search.toLowerCase()) || + (t.barangay?.toLowerCase().includes(search.toLowerCase()) ?? false) || + (t.municipality?.toLowerCase().includes(search.toLowerCase()) ?? false) + ); + + return ( + + + + Territories + setShowAdd(true)} className="bg-white/20 rounded-full p-2"> + + + + + + + {search ? setSearch('')}> : null} + + + + item.id} + contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 100, paddingTop: 12, gap: 8 }} + renderItem={({ item }) => ( + router.push({ pathname: '/territory/[id]', params: { id: item.id } })} + className="bg-white rounded-2xl p-4 flex-row items-center shadow-sm" + > + + {item.territoryCode} + + + {item.territoryCode} + {item.barangay && {item.barangay}{item.municipality ? `, ${item.municipality}` : ''}} + {item.area && Area: {item.area}{item.block ? ` • Block: ${item.block}` : ''}} + + + + )} + ListEmptyComponent={() => ( + + {search ? 'No territories found' : 'No territories yet'} + {!search && Tap + to add your first territory} + + )} + /> + + setShowAdd(false)} onSaved={() => { setShowAdd(false); loadTerritories(); }} /> + + ); +} diff --git a/app/territory/[id].tsx b/app/territory/[id].tsx new file mode 100644 index 0000000..7445cdd --- /dev/null +++ b/app/territory/[id].tsx @@ -0,0 +1,112 @@ +import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native'; +import { useLocalSearchParams, router, useFocusEffect } from 'expo-router'; +import { useState, useCallback } from 'react'; +import { ArrowLeft, Edit2, Trash2, ChevronRight } from 'lucide-react-native'; +import { getDatabase } from '@/lib/database'; +import { Territory, dbToTerritory } from '@/lib/territoryHelpers'; +import { Contact } from '@/store/useContactStore'; +import { dbToContact } from '@/lib/contactHelpers'; +import { EditTerritorySheet } from '@/components/territories/EditTerritorySheet'; + +export default function TerritoryDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const [territory, setTerritory] = useState(null); + const [contacts, setContacts] = useState([]); + const [showEdit, setShowEdit] = useState(false); + + async function loadData() { + const db = await getDatabase(); + const row = await db.getFirstAsync('SELECT * FROM territories WHERE id = ?', [id]); + if (row) { + const t = dbToTerritory(row); + setTerritory(t); + const contactRows = await db.getAllAsync( + 'SELECT * FROM contacts WHERE territory_code = ? AND deleted_at IS NULL ORDER BY full_name ASC', + [t.territoryCode] + ); + setContacts(contactRows.map(dbToContact)); + } + } + + useFocusEffect(useCallback(() => { loadData(); }, [id])); + + async function handleDelete() { + Alert.alert('Delete Territory', `Delete territory ${territory?.territoryCode}? Contacts will keep their territory code but it won't link to a territory record.`, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: async () => { + const db = await getDatabase(); + await db.runAsync('DELETE FROM territories WHERE id = ?', [id]); + router.back(); + }} + ]); + } + + if (!territory) return ; + + return ( + + + + router.back()} className="bg-white/20 rounded-full p-2"> + + + + setShowEdit(true)} className="bg-white/20 rounded-full p-2"> + + + + + + + + + + {territory.territoryCode} + + {territory.territoryCode} + {territory.barangay && {territory.barangay}{territory.municipality ? `, ${territory.municipality}` : ''}} + + + + + + Details + {territory.municipality && } + {territory.barangay && } + {territory.area && } + {territory.block && } + + + + Contacts in this territory ({contacts.length}) + {contacts.length === 0 ? ( + No contacts assigned to this territory + ) : ( + contacts.map((c) => ( + router.push({ pathname: '/contact/[id]', params: { id: c.id } })} + className="flex-row items-center py-2 border-b border-gray-50" + > + {c.fullName} + {c.status} + + + )) + )} + + + + setShowEdit(false)} onSaved={() => { setShowEdit(false); loadData(); }} /> + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/components/contacts/AddContactSheet.tsx b/components/contacts/AddContactSheet.tsx index ffcd95b..1fc87f2 100644 --- a/components/contacts/AddContactSheet.tsx +++ b/components/contacts/AddContactSheet.tsx @@ -1,6 +1,7 @@ import { View, Text, Modal, ScrollView, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native'; import { useState } from 'react'; import { X } from 'lucide-react-native'; +import * as Location from 'expo-location'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { useUserStore } from '@/store/useUserStore'; @@ -23,12 +24,15 @@ export function AddContactSheet({ visible, onClose, onSaved }: Props) { const [status, setStatus] = useState('Active'); const [category, setCategory] = useState('Adult'); const [notes, setNotes] = useState(''); + const [territoryCode, setTerritoryCode] = useState(''); + const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null); const [loading, setLoading] = useState(false); const [errors, setErrors] = useState>({}); function reset() { setFullName(''); setAddress(''); setStatus('Active'); setCategory('Adult'); setNotes(''); setErrors({}); + setTerritoryCode(''); setCoords(null); } async function handleSave() { @@ -42,9 +46,9 @@ export function AddContactSheet({ visible, onClose, onSaved }: Props) { const now = Math.floor(Date.now() / 1000); const id = newContactId(); await db.runAsync( - `INSERT INTO contacts (id, owner_id, full_name, address, category, status, tags, notes, household_count, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, '[]', ?, 1, ?, ?)`, - [id, user?.id ?? 'unknown', fullName.trim(), address.trim() || null, category, status, notes.trim() || null, now, now] + `INSERT INTO contacts (id, owner_id, full_name, address, category, status, tags, notes, territory_code, latitude, longitude, household_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, '[]', ?, ?, ?, ?, 1, ?, ?)`, + [id, user?.id ?? 'unknown', fullName.trim(), address.trim() || null, category, status, notes.trim() || null, territoryCode.trim().toUpperCase() || null, coords?.lat ?? null, coords?.lng ?? null, now, now] ); reset(); onSaved(); @@ -92,6 +96,25 @@ export function AddContactSheet({ visible, onClose, onSaved }: Props) { ))} + {/* Territory Code */} + + + {/* GPS Tag */} + { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { alert('Location permission denied'); return; } + const loc = await Location.getCurrentPositionAsync({}); + setCoords({ lat: loc.coords.latitude, lng: loc.coords.longitude }); + }} + className={`flex-row items-center border rounded-xl px-4 py-3 mb-4 ${coords ? 'border-primary bg-primary/5' : 'border-gray-200 bg-white'}`} + > + 📍 + + {coords ? `GPS: ${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` : 'Tag GPS Location (optional)'} + + + diff --git a/components/contacts/EditContactSheet.tsx b/components/contacts/EditContactSheet.tsx index 07f9d70..049e358 100644 --- a/components/contacts/EditContactSheet.tsx +++ b/components/contacts/EditContactSheet.tsx @@ -22,6 +22,7 @@ export function EditContactSheet({ contact, visible, onClose, onSaved }: Props) const [status, setStatus] = useState(contact.status); const [category, setCategory] = useState(contact.category); const [notes, setNotes] = useState(contact.notes ?? ''); + const [territoryCode, setTerritoryCode] = useState(contact.territoryCode ?? ''); const [loading, setLoading] = useState(false); useEffect(() => { @@ -30,6 +31,7 @@ export function EditContactSheet({ contact, visible, onClose, onSaved }: Props) setStatus(contact.status); setCategory(contact.category); setNotes(contact.notes ?? ''); + setTerritoryCode(contact.territoryCode ?? ''); }, [contact]); async function handleSave() { @@ -39,8 +41,8 @@ export function EditContactSheet({ contact, visible, onClose, onSaved }: Props) const db = await getDatabase(); const now = Math.floor(Date.now() / 1000); await db.runAsync( - 'UPDATE contacts SET full_name=?, address=?, status=?, category=?, notes=?, updated_at=? WHERE id=?', - [fullName.trim(), address.trim() || null, status, category, notes.trim() || null, now, contact.id] + 'UPDATE contacts SET full_name=?, address=?, status=?, category=?, notes=?, territory_code=?, updated_at=? WHERE id=?', + [fullName.trim(), address.trim() || null, status, category, notes.trim() || null, territoryCode.trim().toUpperCase() || null, now, contact.id] ); onSaved(); } finally { @@ -76,6 +78,8 @@ export function EditContactSheet({ contact, visible, onClose, onSaved }: Props) ))} + {/* Territory Code */} + diff --git a/components/territories/AddTerritorySheet.tsx b/components/territories/AddTerritorySheet.tsx new file mode 100644 index 0000000..66007ac --- /dev/null +++ b/components/territories/AddTerritorySheet.tsx @@ -0,0 +1,72 @@ +import { View, Text, Modal, ScrollView, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native'; +import { useState } from 'react'; +import { X } from 'lucide-react-native'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { getDatabase } from '@/lib/database'; +import { newTerritoryId } from '@/lib/territoryHelpers'; + +interface Props { + visible: boolean; + onClose: () => void; + onSaved: () => void; +} + +export function AddTerritorySheet({ visible, onClose, onSaved }: Props) { + const [code, setCode] = useState(''); + const [municipality, setMunicipality] = useState(''); + const [barangay, setBarangay] = useState(''); + const [area, setArea] = useState(''); + const [block, setBlock] = useState(''); + const [errors, setErrors] = useState>({}); + const [loading, setLoading] = useState(false); + + function reset() { setCode(''); setMunicipality(''); setBarangay(''); setArea(''); setBlock(''); setErrors({}); } + + async function handleSave() { + const errs: Record = {}; + if (!code.trim()) errs.code = 'Territory code is required'; + if (Object.keys(errs).length) { setErrors(errs); return; } + + setLoading(true); + try { + const db = await getDatabase(); + const now = Math.floor(Date.now() / 1000); + await db.runAsync( + 'INSERT INTO territories (id, territory_code, municipality, barangay, area, block, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [newTerritoryId(), code.trim().toUpperCase(), municipality.trim() || null, barangay.trim() || null, area.trim() || null, block.trim() || null, now, now] + ); + reset(); + onSaved(); + } catch (e: any) { + if (e?.message?.includes('UNIQUE')) setErrors({ code: 'Territory code already exists' }); + } finally { + setLoading(false); + } + } + + return ( + { reset(); onClose(); }}> + + + + { reset(); onClose(); }}> + New Territory + + + + { setCode(t); setErrors((e) => ({ ...e, code: '' })); }} error={errors.code} autoCapitalize="characters" /> + + + + + + + +