From 8a3f7564a659c2caca6bd69f23c6ab835b92079d Mon Sep 17 00:00:00 2001 From: Nemo Date: Tue, 24 Mar 2026 12:41:31 +0800 Subject: [PATCH] feat: slide-to-confirm on all payment screens; pay button on dashboard invoice rows --- app/(app)/clients/[id].tsx | 15 ++- app/(app)/dashboard.tsx | 225 ++++++++++++++++++++++++---------- app/(app)/payments/index.tsx | 13 +- app/(app)/payments/record.tsx | 18 +-- components/SlideToConfirm.tsx | 109 ++++++++++++++++ 5 files changed, 286 insertions(+), 94 deletions(-) create mode 100644 components/SlideToConfirm.tsx diff --git a/app/(app)/clients/[id].tsx b/app/(app)/clients/[id].tsx index ed649ac..065e767 100644 --- a/app/(app)/clients/[id].tsx +++ b/app/(app)/clients/[id].tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking, Alert, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native'; +import { SlideToConfirm } from '../../../components/SlideToConfirm'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, router } from 'expo-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -106,14 +107,12 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos onChangeText={setRef} /> - 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }} - onPress={submit} - disabled={loading} - activeOpacity={0.8} - > - {loading ? : Confirm Payment} - + diff --git a/app/(app)/dashboard.tsx b/app/(app)/dashboard.tsx index 2f9447d..f2fb9fc 100644 --- a/app/(app)/dashboard.tsx +++ b/app/(app)/dashboard.tsx @@ -1,15 +1,26 @@ -import { View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity, Linking, Alert } from 'react-native'; +import { useState } from 'react'; +import { + View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity, + Linking, Alert, Modal, TextInput, KeyboardAvoidingView, Platform +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useQueries } from '@tanstack/react-query'; +import { useQueries, useQueryClient } from '@tanstack/react-query'; import { router } from 'expo-router'; import { api } from '../../services/api'; import { useAuthStore } from '../../stores/authStore'; +import { SlideToConfirm } from '../../components/SlideToConfirm'; 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' }; +const METHODS = [ + { id: 'CASH', label: 'Cash' }, + { id: 'GCASH', label: 'GCash' }, + { id: 'MAYA', label: 'Maya' }, + { id: 'BANK_TRANSFER',label: 'Bank Transfer' }, +]; -function KpiCard({ label, value, color, bg }: { label: string; value: string | number; color: string; bg: string }) { +function KpiCard({ label, value, color, bg }: any) { return ( {label} @@ -21,9 +32,7 @@ function KpiCard({ label, value, color, bg }: { label: string; value: string | n function TicketRow({ task, onPress }: { task: any; onPress: () => void }) { const typeColor = TYPE_COLOR[task.type] ?? '#6B7280'; return ( - @@ -37,9 +46,7 @@ function TicketRow({ task, onPress }: { task: any; onPress: () => void }) { )} - - {task.status?.replace('_', ' ')} - + {task.status?.replace('_', ' ')} {task.subject} @@ -50,22 +57,18 @@ function TicketRow({ task, onPress }: { task: any; onPress: () => void }) { ); } -function InvoiceRow({ inv }: { inv: any }) { - const dueDate = inv.dueDate ? new Date(inv.dueDate) : null; - const today = new Date(); +function InvoiceRow({ inv, onPay }: { inv: any; onPay: () => void }) { + const dueDate = inv.dueDate ? new Date(inv.dueDate) : null; + const today = new Date(); const isOverdue = dueDate && dueDate < today; const daysLeft = dueDate ? Math.ceil((dueDate.getTime() - today.getTime()) / 86400000) : null; const navigate = () => { - const lat = inv.client?.lat; - const lng = inv.client?.lng; - if (!lat || !lng) { - Alert.alert('No Location', 'This client does not have a recorded location yet.'); - return; - } + const lat = inv.client?.lat, lng = inv.client?.lng; + if (!lat || !lng) { Alert.alert('No Location', 'No recorded location for this client.'); return; } Alert.alert('Navigate', `Open navigation to ${inv.client?.firstName} ${inv.client?.lastName}?`, [ { text: 'Google Maps', onPress: () => Linking.openURL(`https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`) }, - { text: 'Waze', onPress: () => Linking.openURL(`waze://?ll=${lat},${lng}&navigate=yes`) }, + { text: 'Waze', onPress: () => Linking.openURL(`waze://?ll=${lat},${lng}&navigate=yes`) }, { text: 'Cancel', style: 'cancel' }, ]); }; @@ -73,7 +76,7 @@ function InvoiceRow({ inv }: { inv: any }) { return ( - + {inv.client?.firstName} {inv.client?.lastName} @@ -81,16 +84,21 @@ function InvoiceRow({ inv }: { inv: any }) { {isOverdue ? `Overdue by ${Math.abs(daysLeft ?? 0)} day${Math.abs(daysLeft ?? 0) !== 1 ? 's' : ''}` - : daysLeft !== null - ? `Due in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}` - : 'No due date'} + : daysLeft !== null ? `Due in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}` : 'No due date'} - + โ‚ฑ{Number(inv.balance).toLocaleString()} - - ๐Ÿ“ Navigate - + + + ๐Ÿ“ Navigate + + + ๐Ÿ’ณ Pay + + @@ -99,18 +107,59 @@ function InvoiceRow({ inv }: { inv: any }) { export default function DashboardScreen() { const { user } = useAuthStore(); - const role = user?.roles?.[0] ?? user?.role ?? ''; + const qc = useQueryClient(); + const role = user?.roles?.[0] ?? user?.role ?? ''; const isAdminOrStaff = role === 'ADMIN' || role === 'STAFF'; - const hour = new Date().getHours(); const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; + // Payment modal state + const [payModal, setPayModal] = useState(false); + const [payInvoice, setPayInvoice] = useState(null); + const [payAmount, setPayAmount] = useState(''); + const [payMethod, setPayMethod] = useState('CASH'); + const [payNote, setPayNote] = useState(''); + const [paying, setPaying] = useState(false); + + const openPay = (inv: any) => { + setPayInvoice(inv); + setPayAmount(String(Number(inv.balance))); + setPayMethod('CASH'); + setPayNote(''); + setPaying(false); + setPayModal(true); + }; + const closePay = () => { if (!paying) setPayModal(false); }; + + const submitPayment = async () => { + if (!payInvoice) return; + const amt = parseFloat(payAmount); + if (!amt || amt <= 0) { Alert.alert('Invalid Amount', 'Please enter a valid amount.'); return; } + setPaying(true); + try { + await api.post('/api/v1/payments', { + clientId: payInvoice.clientId, + invoiceId: payInvoice.id, + amount: amt, + channel: payMethod, + ...(payNote.trim() ? { notes: payNote.trim() } : {}), + paymentDate: new Date().toISOString(), + }); + setPayModal(false); + qc.invalidateQueries({ queryKey: ['dashboard-invoices'] }); + qc.invalidateQueries({ queryKey: ['dashboard'] }); + Alert.alert('Payment Recorded โœ“', `โ‚ฑ${amt.toLocaleString()} payment recorded for ${payInvoice.client?.firstName} ${payInvoice.client?.lastName}.`); + } catch (e: any) { + const msg = e?.response?.data?.message ?? 'Could not record payment.'; + Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg); + } finally { + setPaying(false); + } + }; + const [summaryQ, ticketsQ, invoicesQ] = useQueries({ 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) }, { queryKey: ['dashboard-tickets'], queryFn: async () => { @@ -124,7 +173,7 @@ export default function DashboardScreen() { queryFn: async () => { const res = await api.get('/api/v1/invoices?limit=50'); const all: any[] = res.data?.data ?? res.data ?? []; - const unpaid = all.filter((inv: any) => ['SENT', 'PARTIAL', 'OVERDUE'].includes(inv.status) && Number(inv.balance) > 0); + const unpaid = all.filter((inv: any) => ['SENT','PARTIAL','OVERDUE'].includes(inv.status) && Number(inv.balance) > 0); unpaid.sort((a: any, b: any) => { const da = a.dueDate ? new Date(a.dueDate).getTime() : Infinity; const db = b.dueDate ? new Date(b.dueDate).getTime() : Infinity; @@ -136,30 +185,24 @@ export default function DashboardScreen() { ], }); - const isLoading = summaryQ.isLoading || ticketsQ.isLoading || invoicesQ.isLoading; + const isLoading = summaryQ.isLoading || ticketsQ.isLoading || invoicesQ.isLoading; const isRefetching = summaryQ.isRefetching || ticketsQ.isRefetching || invoicesQ.isRefetching; + const summary = summaryQ.data ?? {}; + const allActiveTickets = ticketsQ.data ?? []; + const unpaidInvoices = invoicesQ.data ?? []; - const summary = summaryQ.data ?? {}; - const allActiveTickets: any[] = ticketsQ.data ?? []; - const unpaidInvoices: any[] = invoicesQ.data ?? []; - - 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 unassigned = (allActiveTickets as any[]).filter((t: any) => !t.assignedToId); + const assignedToMe = (allActiveTickets as any[]).filter((t: any) => t.assignedToId === user?.id); 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); + 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(); }; return ( - } > {/* Header */} @@ -169,25 +212,18 @@ export default function DashboardScreen() { {isLoading ? ( - - - + ) : ( - - {/* KPI Row 1 */} - - + + - - {/* KPI Row 2 */} - + - {/* Revenue โ€” ADMIN/STAFF only */} {isAdminOrStaff && summary?.revenue?.thisMonth != null && ( @@ -202,7 +238,7 @@ export default function DashboardScreen() { )} - {/* โ”€โ”€ Active Tickets โ”€โ”€ */} + {/* Tickets */} Tickets @@ -216,43 +252,96 @@ export default function DashboardScreen() { View all - {mergedTickets.length === 0 ? ( No active tickets ๐ŸŽ‰ ) : ( - {[...mergedTickets].sort(byPrio).map((t: any) => ( + {mergedTickets.map((t: any) => ( router.push(`/(app)/tasks/${t.id}`)} /> ))} )} - {/* โ”€โ”€ Unpaid Invoices โ”€โ”€ */} + {/* Unpaid Invoices */} Unpaid Invoices router.push('/(app)/payments')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> View all - {unpaidInvoices.length === 0 ? ( No unpaid invoices ๐ŸŽ‰ ) : ( - {unpaidInvoices.map((inv: any) => ( - + {(unpaidInvoices as any[]).map((inv: any) => ( + openPay(inv)} /> ))} )} - )} + + {/* โ”€โ”€ Payment Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + + + + + {/* Handle */} + + + Record Payment + {payInvoice && ( + + {payInvoice.client?.firstName} {payInvoice.client?.lastName} ยท {payInvoice.invoiceNumber} + + )} + + {/* Amount */} + Amount (Balance: โ‚ฑ{Number(payInvoice?.balance ?? 0).toLocaleString()}) + + + {/* Method */} + Payment Method + + {METHODS.map(m => ( + setPayMethod(m.id)} + style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5, borderColor: payMethod === m.id ? '#0891B2' : '#E2E8F0', backgroundColor: payMethod === m.id ? '#ECFEFF' : '#F8FAFC' }} + > + {m.label} + + ))} + + + {/* Notes */} + Notes (optional) + + + + + + ); } diff --git a/app/(app)/payments/index.tsx b/app/(app)/payments/index.tsx index 8b3f999..f94ac4e 100644 --- a/app/(app)/payments/index.tsx +++ b/app/(app)/payments/index.tsx @@ -1,4 +1,5 @@ import { View, Text, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator, Linking, Alert, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native'; +import { SlideToConfirm } from '../../../components/SlideToConfirm'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -75,12 +76,12 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos value={ref} onChangeText={setRef} /> - 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }} - onPress={submit} disabled={loading} activeOpacity={0.8} - > - {loading ? : Confirm Payment} - + diff --git a/app/(app)/payments/record.tsx b/app/(app)/payments/record.tsx index 37666f3..917e18e 100644 --- a/app/(app)/payments/record.tsx +++ b/app/(app)/payments/record.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, Modal } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { SlideToConfirm } from '../../../components/SlideToConfirm'; import { router, useLocalSearchParams } from 'expo-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../../../services/api'; @@ -237,19 +238,12 @@ export default function RecordPaymentScreen() { onChangeText={setReference} /> - - {loading - ? - : - {canSubmit ? `Record โ‚ฑ${Number(amount || 0).toLocaleString()} Payment` : 'Record Payment'} - - } - + /> diff --git a/components/SlideToConfirm.tsx b/components/SlideToConfirm.tsx new file mode 100644 index 0000000..9cdeb50 --- /dev/null +++ b/components/SlideToConfirm.tsx @@ -0,0 +1,109 @@ +import { useRef, useState } from 'react'; +import { View, Text, PanResponder, Animated, StyleSheet } from 'react-native'; + +const TRACK_HEIGHT = 58; +const HANDLE_SIZE = 46; +const PADDING = 6; +const THRESHOLD = 0.85; // 85% of track = confirmed + +interface Props { + label?: string; + color?: string; + onConfirm: () => void; + disabled?: boolean; +} + +export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', onConfirm, disabled = false }: Props) { + const pan = useRef(new Animated.Value(0)).current; + const [done, setDone] = useState(false); + const [trackW, setTrackW] = useState(0); + + const maxX = trackW - HANDLE_SIZE - PADDING * 2; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => !disabled && !done, + onMoveShouldSetPanResponder: () => !disabled && !done, + onPanResponderMove: (_, gs) => { + const x = Math.max(0, Math.min(gs.dx, maxX)); + pan.setValue(x); + }, + onPanResponderRelease: (_, gs) => { + const x = Math.max(0, Math.min(gs.dx, maxX)); + if (maxX > 0 && x / maxX >= THRESHOLD) { + // Snap to end + confirm + Animated.timing(pan, { toValue: maxX, duration: 120, useNativeDriver: false }).start(() => { + setDone(true); + onConfirm(); + }); + } else { + // Snap back + Animated.spring(pan, { toValue: 0, useNativeDriver: false, speed: 20 }).start(); + } + }, + }) + ).current; + + // Interpolate opacity of the label as handle moves right + const labelOpacity = pan.interpolate({ + inputRange: [0, maxX * 0.5], + outputRange: [1, 0], + extrapolate: 'clamp', + }); + + return ( + setTrackW(e.nativeEvent.layout.width)} + style={[styles.track, { backgroundColor: done ? color : '#F1F5F9', borderColor: done ? color : '#E2E8F0' }]} + > + {/* Label */} + + {done ? 'โœ“ Confirmed!' : label} + + + {/* Handle */} + {!done && ( + + {'โ€บ'} + + )} + + ); +} + +const styles = StyleSheet.create({ + track: { + height: TRACK_HEIGHT, + borderRadius: TRACK_HEIGHT / 2, + borderWidth: 1.5, + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + position: 'relative', + }, + label: { + fontSize: 15, + fontWeight: '700', + letterSpacing: 0.3, + }, + handle: { + position: 'absolute', + top: PADDING, + width: HANDLE_SIZE, + height: HANDLE_SIZE, + borderRadius: HANDLE_SIZE / 2, + alignItems: 'center', + justifyContent: 'center', + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.15, + shadowRadius: 4, + }, +});