From f8c5402b02522bcc19c2ac7f716570246d032509 Mon Sep 17 00:00:00 2001 From: Nemo Date: Tue, 24 Mar 2026 11:44:14 +0800 Subject: [PATCH] fix: 5 bugs - tab height, collect pay modal, invoice picker in record payment, comment body field, dashboard tickets label --- app/(app)/clients/[id].tsx | 8 +-- app/(app)/dashboard.tsx | 39 ++++++------- app/(app)/payments/index.tsx | 102 ++++++++++++++++++++++++++++++++-- app/(app)/payments/record.tsx | 97 +++++++++++++++++++++++++++++--- app/(app)/tasks/[id].tsx | 6 +- 5 files changed, 211 insertions(+), 41 deletions(-) diff --git a/app/(app)/clients/[id].tsx b/app/(app)/clients/[id].tsx index 2a9ae49..4c38346 100644 --- a/app/(app)/clients/[id].tsx +++ b/app/(app)/clients/[id].tsx @@ -204,13 +204,13 @@ export default function ClientDetailScreen() { {/* Tabs */} - + {TABS.map(tab => ( - setActiveTab(tab)} style={{ paddingHorizontal: 16, paddingVertical: 16, borderBottomWidth: 2.5, borderBottomColor: activeTab === tab ? '#0891B2' : 'transparent' }} activeOpacity={0.7}> - {tab} + setActiveTab(tab)} style={{ flex: 1, paddingVertical: 14, alignItems: 'center', borderBottomWidth: 2.5, borderBottomColor: activeTab === tab ? '#0891B2' : 'transparent' }} activeOpacity={0.7}> + {tab} ))} - + {/* ── PROFILE TAB ── */} {activeTab === 'Profile' && ( diff --git a/app/(app)/dashboard.tsx b/app/(app)/dashboard.tsx index f19bb51..2f9447d 100644 --- a/app/(app)/dashboard.tsx +++ b/app/(app)/dashboard.tsx @@ -143,8 +143,15 @@ export default function DashboardScreen() { const allActiveTickets: any[] = ticketsQ.data ?? []; const unpaidInvoices: any[] = invoicesQ.data ?? []; - const unassigned = allActiveTickets.filter((t: any) => !t.assignedToId).slice(0, 10); - const assignedToMe = allActiveTickets.filter((t: any) => t.assignedToId === user?.id).slice(0, 10); + const unassigned = allActiveTickets.filter((t: any) => !t.assignedToId); + const assignedToMe = allActiveTickets.filter((t: any) => t.assignedToId === user?.id); + // Merge: assigned-to-me first, then unassigned, deduped, max 10 + const seen = new Set(); + const mergedTickets = [...assignedToMe, ...unassigned].filter((t: any) => { + if (seen.has(t.id)) return false; + seen.add(t.id); + return true; + }).slice(0, 10); const byPrio = (a: any, b: any) => (a.priority === 'HIGH' ? -1 : 1) - (b.priority === 'HIGH' ? -1 : 1); const refetchAll = () => { summaryQ.refetch(); ticketsQ.refetch(); invoicesQ.refetch(); }; @@ -195,13 +202,13 @@ export default function DashboardScreen() { )} - {/* ── Unassigned Tickets ── */} + {/* ── Active Tickets ── */} - Unassigned Tickets - {unassigned.length > 0 && ( - - {unassigned.length} + Tickets + {mergedTickets.length > 0 && ( + + {mergedTickets.length} )} @@ -210,30 +217,18 @@ export default function DashboardScreen() { - {unassigned.length === 0 ? ( + {mergedTickets.length === 0 ? ( - No unassigned tickets 🎉 + No active tickets 🎉 ) : ( - {[...unassigned].sort(byPrio).map((t: any) => ( + {[...mergedTickets].sort(byPrio).map((t: any) => ( router.push(`/(app)/tasks/${t.id}`)} /> ))} )} - {/* ── Assigned to Me ── */} - {assignedToMe.length > 0 && ( - <> - Assigned to Me - - {[...assignedToMe].sort(byPrio).map((t: any) => ( - router.push(`/(app)/tasks/${t.id}`)} /> - ))} - - - )} - {/* ── Unpaid Invoices ── */} Unpaid Invoices diff --git a/app/(app)/payments/index.tsx b/app/(app)/payments/index.tsx index b01d5f2..16ac45f 100644 --- a/app/(app)/payments/index.tsx +++ b/app/(app)/payments/index.tsx @@ -1,12 +1,95 @@ -import { View, Text, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator, Linking, Alert, TextInput } from 'react-native'; +import { View, Text, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator, Linking, Alert, TextInput, Modal } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { api } from '../../../services/api'; +// ── Pay Invoice Modal ────────────────────────────────────────────────────────── +function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClose: () => void; onSuccess: () => void }) { + const [amount, setAmount] = useState(''); + const [method, setMethod] = useState('CASH'); + const [ref, setRef] = useState(''); + const [loading, setLoading] = useState(false); + const METHODS = ['CASH', 'GCASH', 'MAYA', 'BANK']; + + const submit = async () => { + const amt = Number(amount); + if (!amt || amt <= 0) return Alert.alert('Required', 'Enter a valid amount.'); + if (amt > Number(invoice.balance)) { + Alert.alert('Over Payment', `Amount exceeds balance of ₱${Number(invoice.balance).toLocaleString()}`); + return; + } + setLoading(true); + try { + await api.post('/api/v1/payments', { + clientId: invoice.clientId, + invoiceId: invoice.id, + amount: amt, + channel: method, + referenceNumber: ref.trim() || undefined, + paymentDate: new Date().toISOString(), + }); + Alert.alert('Payment Recorded! ✓', `₱${amt.toLocaleString()} applied to ${invoice.invoiceNumber}`); + onSuccess(); + } catch (e: any) { + const msg = e?.response?.data?.message; + Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg ?? 'Payment failed.'); + } finally { setLoading(false); } + }; + + return ( + + + + Record Payment + {invoice.client?.firstName} {invoice.client?.lastName} · {invoice.client?.accountNumber} + + {invoice.invoiceNumber} · Balance: ₱{Number(invoice.balance).toLocaleString()} + + + Amount (₱) + + + Method + + {METHODS.map(m => ( + setMethod(m)} + style={{ flex: 1, borderRadius: 12, paddingVertical: 12, alignItems: 'center', marginHorizontal: 3, backgroundColor: method === m ? '#0891B2' : '#F1F5F9' }} + activeOpacity={0.7} + > + {m} + + ))} + + + Reference # (optional) + + + 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }} + onPress={submit} disabled={loading} activeOpacity={0.8} + > + {loading ? : Confirm Payment} + + + + + ); +} + export default function CollectScreen() { const [search, setSearch] = useState(''); + const [payingInvoice, setPayingInvoice] = useState(null); + const qc = useQueryClient(); const { data: invoices, isLoading, isRefetching, refetch } = useQuery({ queryKey: ['unpaid-invoices'], @@ -148,7 +231,7 @@ export default function CollectScreen() { return ( router.push({ pathname: '/(app)/clients/[id]', params: { id: inv.clientId, tab: 'invoices' } })} + onPress={() => setPayingInvoice(inv)} activeOpacity={0.8} style={{ backgroundColor: '#FFF', @@ -194,7 +277,7 @@ export default function CollectScreen() { { e.stopPropagation?.(); navigate(inv); }} + onPress={() => navigate(inv)} style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: hasLocation ? '#ECFEFF' : '#F1F5F9', @@ -214,6 +297,17 @@ export default function CollectScreen() { )} + + {payingInvoice && ( + setPayingInvoice(null)} + onSuccess={() => { + setPayingInvoice(null); + qc.invalidateQueries({ queryKey: ['unpaid-invoices'] }); + }} + /> + )} ); } diff --git a/app/(app)/payments/record.tsx b/app/(app)/payments/record.tsx index fa07a84..37666f3 100644 --- a/app/(app)/payments/record.tsx +++ b/app/(app)/payments/record.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react'; -import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native'; +import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, Modal } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router, useLocalSearchParams } from 'expo-router'; +import { useQuery } from '@tanstack/react-query'; import { api } from '../../../services/api'; const METHODS = [ @@ -19,15 +20,26 @@ export default function RecordPaymentScreen() { prefillAccountNumber?: string; }>(); - const [search, setSearch] = useState(''); - const [client, setClient] = useState(null); - const [amount, setAmount] = useState(''); - const [method, setMethod] = useState('CASH'); + const [search, setSearch] = useState(''); + const [client, setClient] = useState(null); + const [invoice, setInvoice] = useState(null); + const [showInvoicePicker, setShowInvoicePicker] = useState(false); + const [amount, setAmount] = useState(''); + const [method, setMethod] = useState('CASH'); const [reference, setReference] = useState(''); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(false); const [searching, setSearching] = useState(false); - // Auto-fill client if navigated from client detail + const { data: clientInvoices } = useQuery({ + queryKey: ['record-payment-invoices', client?.id], + queryFn: async () => { + const res = await api.get(`/api/v1/invoices?clientId=${client.id}&limit=50`); + const all: any[] = res.data?.data ?? res.data ?? []; + return all.filter((inv: any) => ['SENT', 'PARTIAL', 'OVERDUE'].includes(inv.status) && Number(inv.balance) > 0); + }, + enabled: !!client?.id, + }); + useEffect(() => { if (params.prefillClientId && params.prefillName) { setClient({ @@ -39,6 +51,9 @@ export default function RecordPaymentScreen() { } }, []); + // When client changes, reset invoice selection + useEffect(() => { setInvoice(null); }, [client?.id]); + const searchClient = async () => { if (!search.trim()) return; setSearching(true); @@ -76,8 +91,9 @@ export default function RecordPaymentScreen() { try { await api.post('/api/v1/payments', { clientId: client.id, + invoiceId: invoice?.id ?? undefined, amount: amt, - channel: method, // API uses `channel` not `paymentMethod` + channel: method, referenceNumber: reference.trim() || undefined, paymentDate: new Date().toISOString(), }); @@ -153,6 +169,36 @@ export default function RecordPaymentScreen() { )} + {/* Invoice selection (shown after client selected) */} + {client && ( + <> + Invoice (optional) + {!clientInvoices ? ( + + ) : clientInvoices.length === 0 ? ( + + ✓ No outstanding invoices + + ) : ( + setShowInvoicePicker(true)} + activeOpacity={0.7} + > + + + {invoice ? invoice.invoiceNumber : 'Select invoice to apply payment'} + + {invoice && ( + Balance: ₱{Number(invoice.balance).toLocaleString()} + )} + + + + )} + + )} + {/* Amount */} Amount (₱) + + {/* Invoice Picker Modal */} + setShowInvoicePicker(false)}> + setShowInvoicePicker(false)}> + + Select Invoice + Choose which invoice to apply payment to + + { setInvoice(null); setShowInvoicePicker(false); }} + style={{ padding: 16, borderRadius: 14, marginBottom: 10, backgroundColor: !invoice ? '#ECFEFF' : '#F8FAFC', borderWidth: 1.5, borderColor: !invoice ? '#0891B2' : '#F1F5F9' }} + activeOpacity={0.7} + > + No specific invoice (general payment) + + + {(clientInvoices ?? []).map((inv: any) => ( + { setInvoice(inv); setAmount(String(inv.balance)); setShowInvoicePicker(false); }} + style={{ padding: 16, borderRadius: 14, marginBottom: 10, backgroundColor: invoice?.id === inv.id ? '#ECFEFF' : '#F8FAFC', borderWidth: 1.5, borderColor: invoice?.id === inv.id ? '#0891B2' : '#F1F5F9' }} + activeOpacity={0.7} + > + + {inv.invoiceNumber} + ₱{Number(inv.balance).toLocaleString()} + + + {inv.dueDate ? `Due: ${new Date(inv.dueDate).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })}` : 'No due date'} · {inv.status} + + + ))} + + + ); } diff --git a/app/(app)/tasks/[id].tsx b/app/(app)/tasks/[id].tsx index ac9c6c9..f65e25a 100644 --- a/app/(app)/tasks/[id].tsx +++ b/app/(app)/tasks/[id].tsx @@ -69,7 +69,7 @@ export default function TicketDetailScreen() { // Log status change as a system comment const who = user?.firstName ?? 'Staff'; await api.post(`/api/v1/tickets/${id}/messages`, { - message: `Status changed to ${status.replace('_', ' ')} by ${who}`, + body: `Status changed to ${status.replace('_', ' ')} by ${who}`, }).catch(() => {}); }, onSuccess: () => { @@ -123,7 +123,7 @@ export default function TicketDetailScreen() { const note = instNotes.trim() ? `Installation confirmed. Location recorded: ${coordStr}. Notes: ${instNotes.trim()}` : `Installation confirmed. Location recorded: ${coordStr}`; - await api.post(`/api/v1/tickets/${id}/messages`, { message: note }).catch(() => {}); + await api.post(`/api/v1/tickets/${id}/messages`, { body: note }).catch(() => {}); setInstNotes(''); setCoords(null); @@ -145,7 +145,7 @@ export default function TicketDetailScreen() { const text = comment.trim(); setComment(''); // clear immediately for responsiveness try { - await api.post(`/api/v1/tickets/${id}/messages`, { message: text }); + await api.post(`/api/v1/tickets/${id}/messages`, { body: text }); refetch(); setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 300); } catch {