feat: slide-to-confirm on all payment screens; pay button on dashboard invoice rows

This commit is contained in:
Nemo
2026-03-24 12:41:31 +08:00
parent 58468fb986
commit 8a3f7564a6
5 changed files with 286 additions and 94 deletions

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking, Alert, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native'; 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 { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, router } from 'expo-router'; import { useLocalSearchParams, router } from 'expo-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -106,14 +107,12 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos
onChangeText={setRef} onChangeText={setRef}
/> />
<TouchableOpacity <SlideToConfirm
style={{ backgroundColor: amount && Number(amount) > 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }} label={`Slide to record ₱${parseFloat(amount || '0').toLocaleString()} payment`}
onPress={submit} color="#059669"
disabled={loading} onConfirm={submit}
activeOpacity={0.8} disabled={loading || !amount || Number(amount) <= 0}
> />
{loading ? <ActivityIndicator color="#FFF" /> : <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Confirm Payment</Text>}
</TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
</KeyboardAvoidingView> </KeyboardAvoidingView>

View File

@@ -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 { 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 { router } from 'expo-router';
import { api } from '../../services/api'; import { api } from '../../services/api';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { SlideToConfirm } from '../../components/SlideToConfirm';
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' };
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 ( return (
<View style={{ flex: 1, marginHorizontal: 5, borderRadius: 16, padding: 16, backgroundColor: bg }}> <View style={{ flex: 1, marginHorizontal: 5, borderRadius: 16, padding: 16, backgroundColor: bg }}>
<Text style={{ fontSize: 11, fontWeight: '700', color, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</Text> <Text style={{ fontSize: 11, fontWeight: '700', color, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</Text>
@@ -21,9 +32,7 @@ function KpiCard({ label, value, color, bg }: { label: string; value: string | n
function TicketRow({ task, onPress }: { task: any; onPress: () => void }) { function TicketRow({ task, onPress }: { task: any; onPress: () => void }) {
const typeColor = TYPE_COLOR[task.type] ?? '#6B7280'; const typeColor = TYPE_COLOR[task.type] ?? '#6B7280';
return ( return (
<TouchableOpacity <TouchableOpacity onPress={onPress} activeOpacity={0.7}
onPress={onPress}
activeOpacity={0.7}
style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 16, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9', borderLeftWidth: 4, borderLeftColor: typeColor }} style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 16, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9', borderLeftWidth: 4, borderLeftColor: typeColor }}
> >
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
@@ -37,9 +46,7 @@ function TicketRow({ task, onPress }: { task: any; onPress: () => void }) {
</View> </View>
)} )}
</View> </View>
<Text style={{ fontSize: 12, fontWeight: '600', color: '#64748B' }}> <Text style={{ fontSize: 12, fontWeight: '600', color: '#64748B' }}>{task.status?.replace('_', ' ')}</Text>
{task.status?.replace('_', ' ')}
</Text>
</View> </View>
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A' }} numberOfLines={1}>{task.subject}</Text> <Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A' }} numberOfLines={1}>{task.subject}</Text>
<Text style={{ fontSize: 13, color: '#64748B', marginTop: 3 }}> <Text style={{ fontSize: 13, color: '#64748B', marginTop: 3 }}>
@@ -50,19 +57,15 @@ function TicketRow({ task, onPress }: { task: any; onPress: () => void }) {
); );
} }
function InvoiceRow({ inv }: { inv: any }) { function InvoiceRow({ inv, onPay }: { inv: any; onPay: () => void }) {
const dueDate = inv.dueDate ? new Date(inv.dueDate) : null; const dueDate = inv.dueDate ? new Date(inv.dueDate) : null;
const today = new Date(); const today = new Date();
const isOverdue = dueDate && dueDate < today; const isOverdue = dueDate && dueDate < today;
const daysLeft = dueDate ? Math.ceil((dueDate.getTime() - today.getTime()) / 86400000) : null; const daysLeft = dueDate ? Math.ceil((dueDate.getTime() - today.getTime()) / 86400000) : null;
const navigate = () => { const navigate = () => {
const lat = inv.client?.lat; const lat = inv.client?.lat, lng = inv.client?.lng;
const lng = inv.client?.lng; if (!lat || !lng) { Alert.alert('No Location', 'No recorded location for this client.'); return; }
if (!lat || !lng) {
Alert.alert('No Location', 'This client does not have a recorded location yet.');
return;
}
Alert.alert('Navigate', `Open navigation to ${inv.client?.firstName} ${inv.client?.lastName}?`, [ 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: '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`) },
@@ -73,7 +76,7 @@ function InvoiceRow({ inv }: { inv: any }) {
return ( return (
<View style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 16, marginBottom: 10, borderWidth: 1, borderColor: isOverdue ? '#FCA5A5' : '#F1F5F9', borderLeftWidth: 4, borderLeftColor: isOverdue ? '#DC2626' : '#F59E0B' }}> <View style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 16, marginBottom: 10, borderWidth: 1, borderColor: isOverdue ? '#FCA5A5' : '#F1F5F9', borderLeftWidth: 4, borderLeftColor: isOverdue ? '#DC2626' : '#F59E0B' }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<View style={{ flex: 1 }}> <View style={{ flex: 1, marginRight: 10 }}>
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A' }}> <Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A' }}>
{inv.client?.firstName} {inv.client?.lastName} {inv.client?.firstName} {inv.client?.lastName}
</Text> </Text>
@@ -81,16 +84,21 @@ function InvoiceRow({ inv }: { inv: any }) {
<Text style={{ fontSize: 13, color: isOverdue ? '#DC2626' : '#D97706', fontWeight: '600', marginTop: 3 }}> <Text style={{ fontSize: 13, color: isOverdue ? '#DC2626' : '#D97706', fontWeight: '600', marginTop: 3 }}>
{isOverdue {isOverdue
? `Overdue by ${Math.abs(daysLeft ?? 0)} day${Math.abs(daysLeft ?? 0) !== 1 ? 's' : ''}` ? `Overdue by ${Math.abs(daysLeft ?? 0)} day${Math.abs(daysLeft ?? 0) !== 1 ? 's' : ''}`
: daysLeft !== null : daysLeft !== null ? `Due in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}` : 'No due date'}
? `Due in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}`
: 'No due date'}
</Text> </Text>
</View> </View>
<View style={{ alignItems: 'flex-end' }}> <View style={{ alignItems: 'flex-end', gap: 6 }}>
<Text style={{ fontSize: 16, fontWeight: '800', color: '#991B1B' }}>{Number(inv.balance).toLocaleString()}</Text> <Text style={{ fontSize: 16, fontWeight: '800', color: '#991B1B' }}>{Number(inv.balance).toLocaleString()}</Text>
<TouchableOpacity onPress={navigate} style={{ marginTop: 8, backgroundColor: '#ECFEFF', borderRadius: 10, paddingHorizontal: 10, paddingVertical: 6 }} activeOpacity={0.7}> <View style={{ flexDirection: 'row', gap: 6 }}>
<TouchableOpacity onPress={navigate}
style={{ backgroundColor: '#ECFEFF', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 }}>
<Text style={{ fontSize: 12, fontWeight: '700', color: '#0891B2' }}>📍 Navigate</Text> <Text style={{ fontSize: 12, fontWeight: '700', color: '#0891B2' }}>📍 Navigate</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={onPay}
style={{ backgroundColor: '#059669', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 }}>
<Text style={{ fontSize: 12, fontWeight: '700', color: '#fff' }}>💳 Pay</Text>
</TouchableOpacity>
</View>
</View> </View>
</View> </View>
</View> </View>
@@ -99,18 +107,59 @@ function InvoiceRow({ inv }: { inv: any }) {
export default function DashboardScreen() { export default function DashboardScreen() {
const { user } = useAuthStore(); const { user } = useAuthStore();
const qc = useQueryClient();
const role = user?.roles?.[0] ?? user?.role ?? ''; const role = user?.roles?.[0] ?? user?.role ?? '';
const isAdminOrStaff = role === 'ADMIN' || role === 'STAFF'; const isAdminOrStaff = role === 'ADMIN' || role === 'STAFF';
const hour = new Date().getHours(); const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
// Payment modal state
const [payModal, setPayModal] = useState(false);
const [payInvoice, setPayInvoice] = useState<any>(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({ const [summaryQ, ticketsQ, invoicesQ] = 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),
},
{ {
queryKey: ['dashboard-tickets'], queryKey: ['dashboard-tickets'],
queryFn: async () => { queryFn: async () => {
@@ -138,28 +187,22 @@ 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 isRefetching = summaryQ.isRefetching || ticketsQ.isRefetching || invoicesQ.isRefetching;
const summary = summaryQ.data ?? {}; const summary = summaryQ.data ?? {};
const allActiveTickets: any[] = ticketsQ.data ?? []; const allActiveTickets = ticketsQ.data ?? [];
const unpaidInvoices: any[] = invoicesQ.data ?? []; const unpaidInvoices = invoicesQ.data ?? [];
const unassigned = allActiveTickets.filter((t: any) => !t.assignedToId); const unassigned = (allActiveTickets as any[]).filter((t: any) => !t.assignedToId);
const assignedToMe = allActiveTickets.filter((t: any) => t.assignedToId === user?.id); const assignedToMe = (allActiveTickets as any[]).filter((t: any) => t.assignedToId === user?.id);
// Merge: assigned-to-me first, then unassigned, deduped, max 10
const seen = new Set<string>(); const seen = new Set<string>();
const mergedTickets = [...assignedToMe, ...unassigned].filter((t: any) => { const mergedTickets = [...assignedToMe, ...unassigned].filter((t: any) => {
if (seen.has(t.id)) return false; if (seen.has(t.id)) return false; seen.add(t.id); return true;
seen.add(t.id); }).slice(0, 10).sort((a: any, b: any) => (a.priority === 'HIGH' ? -1 : 1) - (b.priority === 'HIGH' ? -1 : 1));
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(); }; const refetchAll = () => { summaryQ.refetch(); ticketsQ.refetch(); invoicesQ.refetch(); };
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}> <SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
<ScrollView <ScrollView style={{ flex: 1, backgroundColor: '#F8FAFC' }}
style={{ flex: 1, backgroundColor: '#F8FAFC' }}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetchAll} tintColor="#0891B2" />} refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetchAll} tintColor="#0891B2" />}
> >
{/* Header */} {/* Header */}
@@ -169,25 +212,18 @@ export default function DashboardScreen() {
</View> </View>
{isLoading ? ( {isLoading ? (
<View style={{ paddingVertical: 80, alignItems: 'center' }}> <View style={{ paddingVertical: 80, alignItems: 'center' }}><ActivityIndicator color="#0891B2" size="large" /></View>
<ActivityIndicator color="#0891B2" size="large" />
</View>
) : ( ) : (
<View style={{ padding: 16 }}> <View style={{ padding: 16 }}>
{/* KPI Row 1 */}
<View style={{ flexDirection: 'row', marginBottom: 10 }}> <View style={{ flexDirection: 'row', marginBottom: 10 }}>
<KpiCard label="Subscribers" value={summary?.subscribers?.total ?? '—'} color="#0E7490" bg="#ECFEFF" /> <KpiCard label="Subscribers" value={summary?.subscribers?.total ?? '—'} color="#0E7490" bg="#ECFEFF" />
<KpiCard label="Active" value={summary?.subscribers?.active ?? '—'} color="#166534" bg="#F0FDF4" /> <KpiCard label="Active" value={summary?.subscribers?.active ?? '—'} color="#166534" bg="#F0FDF4" />
</View> </View>
{/* KPI Row 2 */}
<View style={{ flexDirection: 'row', marginBottom: 16 }}> <View style={{ flexDirection: 'row', marginBottom: 16 }}>
<KpiCard label="Unpaid Invoices" value={summary?.billing?.unpaidInvoices ?? '—'} color="#991B1B" bg="#FEF2F2" /> <KpiCard label="Unpaid Invoices" value={summary?.billing?.unpaidInvoices ?? '—'} color="#991B1B" bg="#FEF2F2" />
<KpiCard label="Open Tickets" value={summary?.support?.openTickets ?? '—'} color="#92400E" bg="#FFFBEB" /> <KpiCard label="Open Tickets" value={summary?.support?.openTickets ?? '—'} color="#92400E" bg="#FFFBEB" />
</View> </View>
{/* Revenue — ADMIN/STAFF only */}
{isAdminOrStaff && summary?.revenue?.thisMonth != null && ( {isAdminOrStaff && summary?.revenue?.thisMonth != null && (
<View style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#F1F5F9', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}> <View style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#F1F5F9', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View> <View>
@@ -202,7 +238,7 @@ export default function DashboardScreen() {
</View> </View>
)} )}
{/* ── Active Tickets ── */} {/* Tickets */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>Tickets</Text> <Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>Tickets</Text>
@@ -216,43 +252,96 @@ export default function DashboardScreen() {
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>View all</Text> <Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>View all</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{mergedTickets.length === 0 ? ( {mergedTickets.length === 0 ? (
<View style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 20, alignItems: 'center', marginBottom: 20, borderWidth: 1, borderColor: '#F1F5F9' }}> <View style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 20, alignItems: 'center', marginBottom: 20, borderWidth: 1, borderColor: '#F1F5F9' }}>
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No active tickets 🎉</Text> <Text style={{ fontSize: 15, color: '#94A3B8' }}>No active tickets 🎉</Text>
</View> </View>
) : ( ) : (
<View style={{ marginBottom: 20 }}> <View style={{ marginBottom: 20 }}>
{[...mergedTickets].sort(byPrio).map((t: any) => ( {mergedTickets.map((t: any) => (
<TicketRow key={t.id} task={t} onPress={() => router.push(`/(app)/tasks/${t.id}`)} /> <TicketRow key={t.id} task={t} onPress={() => router.push(`/(app)/tasks/${t.id}`)} />
))} ))}
</View> </View>
)} )}
{/* ── Unpaid Invoices ── */} {/* Unpaid Invoices */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>Unpaid Invoices</Text> <Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>Unpaid Invoices</Text>
<TouchableOpacity onPress={() => router.push('/(app)/payments')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> <TouchableOpacity onPress={() => router.push('/(app)/payments')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>View all</Text> <Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>View all</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{unpaidInvoices.length === 0 ? ( {unpaidInvoices.length === 0 ? (
<View style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 20, alignItems: 'center', marginBottom: 20, borderWidth: 1, borderColor: '#F1F5F9' }}> <View style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 20, alignItems: 'center', marginBottom: 20, borderWidth: 1, borderColor: '#F1F5F9' }}>
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No unpaid invoices 🎉</Text> <Text style={{ fontSize: 15, color: '#94A3B8' }}>No unpaid invoices 🎉</Text>
</View> </View>
) : ( ) : (
<View style={{ marginBottom: 20 }}> <View style={{ marginBottom: 20 }}>
{unpaidInvoices.map((inv: any) => ( {(unpaidInvoices as any[]).map((inv: any) => (
<InvoiceRow key={inv.id} inv={inv} /> <InvoiceRow key={inv.id} inv={inv} onPay={() => openPay(inv)} />
))} ))}
</View> </View>
)} )}
<View style={{ height: 24 }} /> <View style={{ height: 24 }} />
</View> </View>
)} )}
</ScrollView> </ScrollView>
{/* ── Payment Modal ──────────────────────────────────────────────────── */}
<Modal visible={payModal} transparent animationType="slide" onRequestClose={closePay}>
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.4)' }} activeOpacity={1} onPress={closePay} />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={{ backgroundColor: '#fff', borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40 }}>
{/* Handle */}
<View style={{ width: 40, height: 4, backgroundColor: '#E2E8F0', borderRadius: 2, alignSelf: 'center', marginBottom: 20 }} />
<Text style={{ fontSize: 18, fontWeight: '800', color: '#1E293B', marginBottom: 2 }}>Record Payment</Text>
{payInvoice && (
<Text style={{ fontSize: 14, color: '#64748B', marginBottom: 20 }}>
{payInvoice.client?.firstName} {payInvoice.client?.lastName} · {payInvoice.invoiceNumber}
</Text>
)}
{/* Amount */}
<Text style={{ fontSize: 13, fontWeight: '600', color: '#64748B', marginBottom: 6 }}>Amount (Balance: ₱{Number(payInvoice?.balance ?? 0).toLocaleString()})</Text>
<TextInput
value={payAmount}
onChangeText={setPayAmount}
keyboardType="decimal-pad"
style={{ borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12, paddingHorizontal: 14, paddingVertical: 13, fontSize: 20, fontWeight: '700', color: '#059669', marginBottom: 16, backgroundColor: '#F8FAFC' }}
placeholder="0.00"
/>
{/* Method */}
<Text style={{ fontSize: 13, fontWeight: '600', color: '#64748B', marginBottom: 8 }}>Payment Method</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
{METHODS.map(m => (
<TouchableOpacity key={m.id} onPress={() => 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' }}
>
<Text style={{ fontSize: 14, fontWeight: '600', color: payMethod === m.id ? '#0891B2' : '#64748B' }}>{m.label}</Text>
</TouchableOpacity>
))}
</View>
{/* Notes */}
<Text style={{ fontSize: 13, fontWeight: '600', color: '#64748B', marginBottom: 6 }}>Notes (optional)</Text>
<TextInput
value={payNote} onChangeText={setPayNote}
placeholder="Reference number, remarks..."
placeholderTextColor="#94A3B8"
style={{ borderWidth: 1, borderColor: '#E2E8F0', borderRadius: 10, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, color: '#1E293B', marginBottom: 24, backgroundColor: '#F8FAFC' }}
/>
<SlideToConfirm
label={`Slide to record ₱${parseFloat(payAmount || '0').toLocaleString()} payment`}
color="#059669"
onConfirm={submitPayment}
disabled={paying || !payAmount || parseFloat(payAmount) <= 0}
/>
</View>
</KeyboardAvoidingView>
</Modal>
</SafeAreaView> </SafeAreaView>
); );
} }

View File

@@ -1,4 +1,5 @@
import { View, Text, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator, Linking, Alert, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native'; 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 { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -75,12 +76,12 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos
value={ref} onChangeText={setRef} value={ref} onChangeText={setRef}
/> />
<TouchableOpacity <SlideToConfirm
style={{ backgroundColor: amount && Number(amount) > 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }} label={`Slide to record ₱${parseFloat(amount || '0').toLocaleString()} payment`}
onPress={submit} disabled={loading} activeOpacity={0.8} color="#059669"
> onConfirm={submit}
{loading ? <ActivityIndicator color="#FFF" /> : <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Confirm Payment</Text>} disabled={loading || !amount || Number(amount) <= 0}
</TouchableOpacity> />
</TouchableOpacity> </TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
</KeyboardAvoidingView> </KeyboardAvoidingView>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, Modal } from 'react-native'; import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, Modal } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { SlideToConfirm } from '../../../components/SlideToConfirm';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../../../services/api'; import { api } from '../../../services/api';
@@ -237,19 +238,12 @@ export default function RecordPaymentScreen() {
onChangeText={setReference} onChangeText={setReference}
/> />
<TouchableOpacity <SlideToConfirm
style={{ backgroundColor: canSubmit ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }} label={canSubmit ? `Slide to record ₱${Number(amount || 0).toLocaleString()} payment` : 'Slide to record payment'}
onPress={submit} color="#059669"
onConfirm={submit}
disabled={loading || !canSubmit} disabled={loading || !canSubmit}
activeOpacity={0.8} />
>
{loading
? <ActivityIndicator color="#FFF" />
: <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>
{canSubmit ? `Record ₱${Number(amount || 0).toLocaleString()} Payment` : 'Record Payment'}
</Text>
}
</TouchableOpacity>
</ScrollView> </ScrollView>
</View> </View>

View File

@@ -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 (
<View
onLayout={e => setTrackW(e.nativeEvent.layout.width)}
style={[styles.track, { backgroundColor: done ? color : '#F1F5F9', borderColor: done ? color : '#E2E8F0' }]}
>
{/* Label */}
<Animated.Text style={[styles.label, { opacity: disabled ? 0.4 : labelOpacity, color: done ? '#fff' : '#64748B' }]}>
{done ? '✓ Confirmed!' : label}
</Animated.Text>
{/* Handle */}
{!done && (
<Animated.View
{...(disabled ? {} : panResponder.panHandlers)}
style={[
styles.handle,
{ backgroundColor: disabled ? '#CBD5E1' : color, left: PADDING, transform: [{ translateX: pan }] },
]}
>
<Text style={{ color: '#fff', fontSize: 20, fontWeight: '700' }}>{''}</Text>
</Animated.View>
)}
</View>
);
}
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,
},
});