diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 68f7810..c0ae696 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,10 +1,178 @@ -import { View, Text } from 'react-native'; +import { View, Text, ScrollView, TouchableOpacity, Modal, FlatList } from 'react-native'; +import { useState, useCallback } from 'react'; +import { useFocusEffect, router } from 'expo-router'; +import { Plus, Users, Calendar, BookOpen, ChevronRight, X } from 'lucide-react-native'; +import { useUserStore } from '@/store/useUserStore'; +import { getDatabase } from '@/lib/database'; +import { Contact } from '@/store/useContactStore'; +import { dbToContact } from '@/lib/contactHelpers'; +import { formatDate } from '@/lib/visitHelpers'; + +interface Stats { + total: number; + returnVisits: number; + bibleStudies: number; + visitsDue: number; +} + +interface ReturnVisit { + contactId: string; + contactName: string; + nextVisitDate: number; +} export default function HomeScreen() { + const user = useUserStore((s) => s.user); + const [stats, setStats] = useState({ total: 0, returnVisits: 0, bibleStudies: 0, visitsDue: 0 }); + const [dueVisits, setDueVisits] = useState([]); + const [showFAB, setShowFAB] = useState(false); + + async function loadDashboard() { + const db = await getDatabase(); + const now = Math.floor(Date.now() / 1000); + const todayEnd = now + 86400 * 3; // next 3 days + + const contacts = await db.getAllAsync('SELECT status FROM contacts WHERE deleted_at IS NULL'); + const total = contacts.length; + const returnVisits = contacts.filter((c) => c.status === 'Return Visit').length; + const bibleStudies = contacts.filter((c) => c.status === 'Bible Study').length; + + const due = await db.getAllAsync( + `SELECT v.contact_id, c.full_name, MAX(v.next_visit_date) as next_visit_date + FROM visits v JOIN contacts c ON c.id = v.contact_id + WHERE v.next_visit_date IS NOT NULL AND v.next_visit_date <= ? AND c.deleted_at IS NULL + GROUP BY v.contact_id ORDER BY next_visit_date ASC`, + [todayEnd] + ); + + setStats({ total, returnVisits, bibleStudies, visitsDue: due.length }); + setDueVisits(due.map((d) => ({ contactId: d.contact_id, contactName: d.full_name, nextVisitDate: d.next_visit_date }))); + } + + useFocusEffect(useCallback(() => { loadDashboard(); }, [])); + + const greeting = () => { + const h = new Date().getHours(); + if (h < 12) return 'Good morning'; + if (h < 18) return 'Good afternoon'; + return 'Good evening'; + }; + return ( - - TerritoryLog - Dashboard coming in Sprint 2 + + {/* Header */} + + {greeting()}, + {user?.displayName ?? 'Friend'} + + {new Date().toLocaleDateString('en-PH', { weekday: 'long', month: 'long', day: 'numeric' })} + + + + + {/* Stats Grid */} + + + + + + + 0 ? "bg-orange-500" : "bg-gray-400"} icon="⏰" /> + + + {/* Return Visits Due */} + {dueVisits.length > 0 && ( + + ⏰ Visits Due Soon + {dueVisits.slice(0, 5).map((v) => ( + router.push({ pathname: '/contact/[id]', params: { id: v.contactId } })} + className="flex-row items-center justify-between py-2 border-b border-gray-50" + > + {v.contactName} + {formatDate(v.nextVisitDate)} + + ))} + {dueVisits.length > 5 && ( + +{dueVisits.length - 5} more + )} + + )} + + {/* Quick actions */} + + Quick Actions + router.push('/(tabs)/contacts')} /> + router.push('/(tabs)/map')} /> + router.push('/(tabs)/settings')} /> + + + + {/* FAB */} + setShowFAB(true)} + className="absolute bottom-8 right-6 w-14 h-14 bg-primary rounded-full items-center justify-center shadow-lg" + style={{ elevation: 8 }} + > + + + + {/* FAB Action Sheet */} + + setShowFAB(false)}> + + Quick Add + { + setShowFAB(false); + router.push({ pathname: '/(tabs)/contacts', params: { openAdd: '1' } }); + }} + /> + { setShowFAB(false); router.push('/(tabs)/contacts'); }} + /> + + + ); } + +function StatCard({ label, value, color, icon }: { label: string; value: number; color: string; icon: string }) { + return ( + + {icon} + {value} + {label} + + ); +} + +function QuickAction({ icon, label, onPress }: { icon: string; label: string; onPress: () => void }) { + return ( + + {icon} + {label} + + + ); +} + +function FABAction({ icon, label, sub, onPress }: { icon: string; label: string; sub: string; onPress: () => void }) { + return ( + + {icon} + + {label} + {sub} + + + ); +} diff --git a/app/contact/[id].tsx b/app/contact/[id].tsx index 796ddee..40ea03d 100644 --- a/app/contact/[id].tsx +++ b/app/contact/[id].tsx @@ -1,12 +1,13 @@ import { View, Text, ScrollView, TouchableOpacity, Alert } from 'react-native'; -import { useLocalSearchParams, router } from 'expo-router'; +import { useLocalSearchParams, router, useFocusEffect } from 'expo-router'; import { useState, useCallback } from 'react'; -import { useFocusEffect } from 'expo-router'; -import { ArrowLeft, Edit2, Trash2, MapPin, Phone } from 'lucide-react-native'; +import { ArrowLeft, Edit2, Trash2, MapPin } from 'lucide-react-native'; import { getDatabase } from '@/lib/database'; import { dbToContact } from '@/lib/contactHelpers'; import { Contact } from '@/store/useContactStore'; import { EditContactSheet } from '@/components/contacts/EditContactSheet'; +import { LogVisitSheet } from '@/components/visits/LogVisitSheet'; +import { Visit, dbToVisit, formatDate } from '@/lib/visitHelpers'; const statusColors: Record = { 'Active': '#4CAF7D', @@ -19,15 +20,19 @@ const statusColors: Record = { export default function ContactDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const [contact, setContact] = useState(null); + const [visits, setVisits] = useState([]); const [showEdit, setShowEdit] = useState(false); + const [showLogVisit, setShowLogVisit] = useState(false); - async function loadContact() { + async function loadData() { const db = await getDatabase(); const row = await db.getFirstAsync('SELECT * FROM contacts WHERE id = ?', [id]); if (row) setContact(dbToContact(row)); + const visitRows = await db.getAllAsync('SELECT * FROM visits WHERE contact_id = ? ORDER BY visit_date DESC', [id]); + setVisits(visitRows.map(dbToVisit)); } - useFocusEffect(useCallback(() => { loadContact(); }, [id])); + useFocusEffect(useCallback(() => { loadData(); }, [id])); async function handleDelete() { Alert.alert('Delete Contact', `Are you sure you want to delete ${contact?.fullName}?`, [ @@ -36,8 +41,7 @@ export default function ContactDetailScreen() { text: 'Delete', style: 'destructive', onPress: async () => { const db = await getDatabase(); - const now = Math.floor(Date.now() / 1000); - await db.runAsync('UPDATE contacts SET deleted_at = ? WHERE id = ?', [now, id]); + await db.runAsync('UPDATE contacts SET deleted_at = ? WHERE id = ?', [Math.floor(Date.now() / 1000), id]); router.back(); } } @@ -50,7 +54,6 @@ export default function ContactDetailScreen() { return ( - {/* Header */} router.back()} className="bg-white/20 rounded-full p-2"> @@ -79,12 +82,11 @@ export default function ContactDetailScreen() { - {/* Info Card */} Information - - {contact.address && } />} - {contact.territoryCode && } + + {contact.address && } + {contact.territoryCode && } {contact.notes && ( @@ -93,21 +95,47 @@ export default function ContactDetailScreen() { {contact.notes} )} + + + + Visit History ({visits.length}) + setShowLogVisit(true)} className="bg-primary rounded-lg px-3 py-1.5"> + + Log Visit + + + {visits.length === 0 ? ( + No visits recorded yet + ) : ( + visits.map((v) => ( + + {formatDate(v.visitDate)} + {v.topic && πŸ“– {v.topic}} + {v.response && πŸ’¬ {v.response}} + {v.remarks && {v.remarks}} + {v.nextVisitDate && Next: {formatDate(v.nextVisitDate)}} + + )) + )} + - setShowEdit(false)} onSaved={() => { setShowEdit(false); loadContact(); }} /> + setShowEdit(false)} onSaved={() => { setShowEdit(false); loadData(); }} /> + setShowLogVisit(false)} + onSaved={() => { setShowLogVisit(false); loadData(); }} + /> ); } -function Row({ label, value, icon }: { label: string; value: string; icon?: React.ReactNode }) { +function InfoRow({ label, value }: { label: string; value: string }) { return ( {label} - - {icon} - {value} - + {value} ); } diff --git a/components/ui/DatePicker.tsx b/components/ui/DatePicker.tsx new file mode 100644 index 0000000..a2bd7d8 --- /dev/null +++ b/components/ui/DatePicker.tsx @@ -0,0 +1,99 @@ +import { View, Text, TouchableOpacity, Modal, Platform } from 'react-native'; +import { useState } from 'react'; +import { Calendar } from 'lucide-react-native'; + +interface Props { + label?: string; + value: number | null; + onChange: (timestamp: number) => void; +} + +export function DatePicker({ label, value, onChange }: Props) { + const [show, setShow] = useState(false); + + // Build a simple date selector (year/month/day dropdowns) + const selected = value ? new Date(value * 1000) : new Date(); + const today = new Date(); + + const [year, setYear] = useState(selected.getFullYear()); + const [month, setMonth] = useState(selected.getMonth()); + const [day, setDay] = useState(selected.getDate()); + + const years = Array.from({ length: 3 }, (_, i) => today.getFullYear() - 1 + i); + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const days = Array.from({ length: daysInMonth }, (_, i) => i + 1); + + function handleConfirm() { + const d = new Date(year, month, day, 12, 0, 0); + onChange(Math.floor(d.getTime() / 1000)); + setShow(false); + } + + const displayDate = value + ? new Date(value * 1000).toLocaleDateString('en-PH', { year: 'numeric', month: 'short', day: 'numeric' }) + : 'Select date'; + + return ( + + {label && {label}} + setShow(true)} + className="flex-row items-center border border-gray-200 rounded-xl px-4 py-3 bg-white" + > + + {displayDate} + + + + setShow(false)}> + + Select Date + + + {/* Month */} + + Month + + {months.map((m, i) => ( + setMonth(i)} className={`py-2 px-3 ${month === i ? 'bg-primary' : ''}`}> + {m} + + ))} + + + + {/* Day */} + + Day + + {days.filter((_, i) => i < 10 || Math.abs(i - (day - 1)) < 3).map((d) => ( + setDay(d)} className={`py-2 ${day === d ? 'bg-primary' : ''}`}> + {d} + + ))} + + + + {/* Year */} + + Year + + {years.map((y) => ( + setYear(y)} className={`py-3 ${year === y ? 'bg-primary' : ''}`}> + {y} + + ))} + + + + + + Confirm + + + + + + ); +} diff --git a/components/visits/LogVisitSheet.tsx b/components/visits/LogVisitSheet.tsx new file mode 100644 index 0000000..251d8c5 --- /dev/null +++ b/components/visits/LogVisitSheet.tsx @@ -0,0 +1,99 @@ +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 { DatePicker } from '@/components/ui/DatePicker'; +import { TopicSelector } from '@/components/visits/TopicSelector'; +import { useUserStore } from '@/store/useUserStore'; +import { getDatabase } from '@/lib/database'; +import { newVisitId } from '@/lib/visitHelpers'; + +interface Props { + contactId: string; + contactName: string; + visible: boolean; + onClose: () => void; + onSaved: () => void; +} + +const RESPONSE_OPTIONS = ['Interested', 'Not Interested', 'Busy', 'No Answer', 'Do Not Call']; + +export function LogVisitSheet({ contactId, contactName, visible, onClose, onSaved }: Props) { + const user = useUserStore((s) => s.user); + const [visitDate, setVisitDate] = useState(Math.floor(Date.now() / 1000)); + const [topic, setTopic] = useState(''); + const [response, setResponse] = useState(''); + const [remarks, setRemarks] = useState(''); + const [nextVisitDate, setNextVisitDate] = useState(null); + const [loading, setLoading] = useState(false); + + function reset() { + setVisitDate(Math.floor(Date.now() / 1000)); + setTopic(''); setResponse(''); setRemarks(''); setNextVisitDate(null); + } + + async function handleSave() { + setLoading(true); + try { + const db = await getDatabase(); + const now = Math.floor(Date.now() / 1000); + const id = newVisitId(); + await db.runAsync( + `INSERT INTO visits (id, contact_id, visited_by_name, visited_by_id, visit_date, topic, response, remarks, next_visit_date, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, contactId, user?.displayName ?? 'Unknown', user?.id ?? 'unknown', + visitDate, topic || null, response || null, remarks.trim() || null, + nextVisitDate, now, now] + ); + // Update contact status if response is Do Not Call + if (response === 'Do Not Call') { + await db.runAsync('UPDATE contacts SET status=?, updated_at=? WHERE id=?', ['Do Not Call', now, contactId]); + } else if (response === 'Interested') { + await db.runAsync('UPDATE contacts SET status=?, updated_at=? WHERE id=?', ['Return Visit', now, contactId]); + } + reset(); + onSaved(); + } finally { + setLoading(false); + } + } + + return ( + { reset(); onClose(); }}> + + + + { reset(); onClose(); }}> + Log Visit + + + + + Contact: {contactName} + + + + + Response + + {RESPONSE_OPTIONS.map((r) => ( + setResponse(r === response ? '' : r)} className={`px-3 py-1.5 rounded-full border ${response === r ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}> + {r} + + ))} + + + + + + + + +