feat: major UI/UX overhaul + user management + ticket detail refactor

- 5-tab navigation (Home/Clients/Collect/Tickets/Profile)
- Inline styles throughout (17px min font, SafeAreaView)
- Dashboard fixed to match real API shape
- Ticket detail: 2 tabs (Details + Comments), always-visible comment input
- Installation confirmation: GPS coordinate capture + client location update
- User management screens (Admin only): list, create, detail + role/active toggle
- Tasks folder replaces tickets folder
- Remittance detail: inline styles
- Record payment: prefill from client, live button text
- Icon component with SVG icons
- Color system: primary #0891B2
This commit is contained in:
Nemo
2026-03-24 10:37:57 +08:00
parent baed6dc8d5
commit 4644a3194d
38 changed files with 4967 additions and 2984 deletions

View File

@@ -0,0 +1,4 @@
import { Stack } from 'expo-router';
export default function PaymentsLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -1,87 +1,48 @@
import { useState } from 'react';
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { api } from '../../../services/api';
const METHOD_ICON: Record<string, string> = { CASH: '💵', GCASH: '📱', MAYA: '💙', BANK: '🏦' };
export default function PaymentsScreen() {
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['payments'],
queryFn: () => api.get('/api/v1/payments?limit=50').then(r => r.data?.data ?? r.data ?? []),
});
const payments: any[] = data ?? [];
// Calculate today's total
const today = new Date().toDateString();
const todayTotal = payments
.filter((p: any) => new Date(p.paymentDate ?? p.createdAt).toDateString() === today)
.reduce((sum: number, p: any) => sum + Number(p.amount), 0);
export default function CollectScreen() {
return (
<View className="flex-1 bg-gray-50">
{/* Header */}
<View className="px-4 pt-14 pb-4 bg-primary flex-row justify-between items-center">
<View>
<Text className="text-white text-xl font-bold">Payments</Text>
<Text className="text-white/70 text-xs mt-0.5">Today: {todayTotal.toLocaleString()}</Text>
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 24 }}>
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Collect</Text>
<Text style={{ color: '#A5F3FC', fontSize: 15, marginTop: 2 }}>Payments & remittances</Text>
</View>
<TouchableOpacity
className="bg-white/20 rounded-xl px-4 py-2"
onPress={() => router.push('/(app)/payments/record')}
>
<Text className="text-white font-semibold text-sm">+ Record</Text>
</TouchableOpacity>
</View>
{isLoading ? (
<View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>
) : (
<FlatList
data={payments}
keyExtractor={(item) => item.id}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
ListEmptyComponent={
<View className="items-center py-20">
<Text className="text-4xl mb-3">💳</Text>
<Text className="text-gray-400 text-base">No payments yet</Text>
<TouchableOpacity
className="mt-4 bg-primary rounded-xl px-6 py-3"
onPress={() => router.push('/(app)/payments/record')}
>
<Text className="text-white font-semibold">Record First Payment</Text>
</TouchableOpacity>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}>
<TouchableOpacity
style={{ backgroundColor: '#ECFEFF', borderRadius: 20, padding: 20, marginBottom: 14, borderWidth: 1.5, borderColor: '#67E8F9', flexDirection: 'row', alignItems: 'center' }}
onPress={() => router.push('/(app)/payments/record')}
activeOpacity={0.7}
>
<View style={{ width: 56, height: 56, borderRadius: 16, backgroundColor: '#0891B2', alignItems: 'center', justifyContent: 'center', marginRight: 16 }}>
<Text style={{ fontSize: 26 }}>💳</Text>
</View>
}
renderItem={({ item }) => (
<View className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
<View className="flex-row justify-between items-start">
<View className="flex-1">
<View className="flex-row items-center mb-1">
<Text className="text-base mr-2">{METHOD_ICON[item.paymentMethod] ?? '💳'}</Text>
<Text className="font-semibold text-gray-900">
{item.client?.firstName} {item.client?.lastName}
</Text>
</View>
<Text className="text-gray-500 text-sm">{item.client?.accountNumber}</Text>
<Text className="text-gray-400 text-xs mt-1">
{new Date(item.paymentDate ?? item.createdAt).toLocaleDateString()} · {item.paymentMethod}
</Text>
{item.referenceNumber && (
<Text className="text-gray-400 text-xs">Ref: {item.referenceNumber}</Text>
)}
</View>
<Text className="font-bold text-green-700 text-lg">
{Number(item.amount).toLocaleString()}
</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0F172A' }}>Record Payment</Text>
<Text style={{ fontSize: 15, color: '#64748B', marginTop: 3 }}>Cash, GCash, Maya, or bank</Text>
</View>
)}
/>
)}
</View>
<Text style={{ fontSize: 22, color: '#94A3B8' }}></Text>
</TouchableOpacity>
<TouchableOpacity
style={{ backgroundColor: '#F0FDF4', borderRadius: 20, padding: 20, borderWidth: 1.5, borderColor: '#86EFAC', flexDirection: 'row', alignItems: 'center' }}
onPress={() => router.push('/(app)/remittances')}
activeOpacity={0.7}
>
<View style={{ width: 56, height: 56, borderRadius: 16, backgroundColor: '#059669', alignItems: 'center', justifyContent: 'center', marginRight: 16 }}>
<Text style={{ fontSize: 26 }}>📋</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0F172A' }}>Remittances</Text>
<Text style={{ fontSize: 15, color: '#64748B', marginTop: 3 }}>Submit & track daily collections</Text>
</View>
<Text style={{ fontSize: 22, color: '#94A3B8' }}></Text>
</TouchableOpacity>
</ScrollView>
</View>
</SafeAreaView>
);
}

View File

@@ -1,189 +1,211 @@
import { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, FlatList, Modal } from 'react-native';
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../../../services/api';
const METHODS = ['CASH', 'GCASH', 'MAYA', 'BANK'];
const METHODS = [
{ id: 'CASH', label: 'Cash' },
{ id: 'GCASH', label: 'GCash' },
{ id: 'MAYA', label: 'Maya' },
{ id: 'BANK', label: 'Bank Transfer' },
];
export default function RecordPaymentScreen() {
const params = useLocalSearchParams<{ prefillClientId?: string; prefillName?: string; prefillAccountNumber?: string }>();
const qc = useQueryClient();
// Prefill params when navigated from client detail
const params = useLocalSearchParams<{
prefillClientId?: string;
prefillName?: string;
prefillAccountNumber?: string;
}>();
const [search, setSearch] = useState('');
const [showPicker, setShowPicker] = useState(false);
const [client, setClient] = useState<any>(
params.prefillClientId
? { id: params.prefillClientId, firstName: params.prefillName?.split(' ')[0], lastName: params.prefillName?.split(' ').slice(1).join(' '), accountNumber: params.prefillAccountNumber }
: null
);
const [amount, setAmount] = useState('');
const [method, setMethod] = useState('CASH');
const [search, setSearch] = useState('');
const [client, setClient] = useState<any>(null);
const [amount, setAmount] = useState('');
const [method, setMethod] = useState('CASH');
const [reference, setReference] = useState('');
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [searching, setSearching] = useState(false);
// Debounced client search
const [debouncedSearch, setDebouncedSearch] = useState('');
// Auto-fill client if navigated from client detail
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
if (params.prefillClientId && params.prefillName) {
setClient({
id: params.prefillClientId,
firstName: params.prefillName.split(' ')[0] ?? '',
lastName: params.prefillName.split(' ').slice(1).join(' ') ?? '',
accountNumber: params.prefillAccountNumber ?? '',
});
}
}, []);
const { data: searchResults, isFetching: searching } = useQuery({
queryKey: ['client-search', debouncedSearch],
queryFn: () => api.get(`/api/v1/clients?search=${debouncedSearch}&limit=8`).then(r => r.data?.data ?? r.data ?? []),
enabled: debouncedSearch.trim().length >= 2,
});
const searchClient = async () => {
if (!search.trim()) return;
setSearching(true);
try {
const res = await api.get(`/api/v1/clients?search=${encodeURIComponent(search.trim())}&limit=5`);
const found = res.data?.data ?? res.data ?? [];
if (Array.isArray(found) && found.length === 1) {
setClient(found[0]);
} else if (Array.isArray(found) && found.length > 1) {
// Show picker if multiple results
Alert.alert(
'Multiple clients found',
found.map((c: any, i: number) => `${i + 1}. ${c.firstName} ${c.lastName} (${c.accountNumber})`).join('\n'),
[
...found.slice(0, 5).map((c: any, i: number) => ({
text: `${i + 1}. ${c.firstName} ${c.lastName}`,
onPress: () => setClient(c),
})),
{ text: 'Cancel', style: 'cancel' as const },
]
);
} else {
Alert.alert('Not Found', 'No client found. Try account number or full name.');
}
} catch {
Alert.alert('Error', 'Search failed. Please try again.');
} finally { setSearching(false); }
};
const submit = async () => {
if (!client) return Alert.alert('Required', 'Select a client first.');
if (!amount || isNaN(Number(amount)) || Number(amount) <= 0)
return Alert.alert('Required', 'Enter a valid amount.');
if (!client) return Alert.alert('Required', 'Search and select a client first.');
const amt = Number(amount);
if (!amount || isNaN(amt) || amt <= 0) return Alert.alert('Required', 'Enter a valid amount.');
setLoading(true);
try {
await api.post('/api/v1/payments', {
clientId: client.id,
amount: Number(amount),
paymentMethod: method,
referenceNumber: reference || undefined,
notes: notes || undefined,
paymentDate: new Date().toISOString(),
clientId: client.id,
amount: amt,
channel: method, // API uses `channel` not `paymentMethod`
referenceNumber: reference.trim() || undefined,
paymentDate: new Date().toISOString(),
});
// Invalidate relevant queries
qc.invalidateQueries({ queryKey: ['payments'] });
qc.invalidateQueries({ queryKey: ['client-payments', client.id] });
qc.invalidateQueries({ queryKey: ['dashboard'] });
Alert.alert('✅ Payment Recorded', `${Number(amount).toLocaleString()} from ${client.firstName} ${client.lastName}`, [
Alert.alert('Payment Recorded!', `${amt.toLocaleString()} from ${client.firstName} ${client.lastName}`, [
{ text: 'Record Another', onPress: () => { setClient(null); setAmount(''); setSearch(''); setReference(''); } },
{ text: 'Done', onPress: () => router.back() },
{ text: 'Record Another', onPress: () => { setClient(null); setAmount(''); setReference(''); setNotes(''); setSearch(''); } },
]);
} catch (e: any) {
Alert.alert('Error', e?.response?.data?.message ?? 'Payment failed. Try again.');
} finally {
setLoading(false);
}
const msg = e?.response?.data?.message;
Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg ?? 'Payment failed.');
} finally { setLoading(false); }
};
const canSubmit = !!client && !!amount && Number(amount) > 0;
return (
<View className="flex-1 bg-gray-50">
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
<TouchableOpacity onPress={() => router.back()} className="mr-3 p-1">
<Text className="text-white text-lg"></Text>
</TouchableOpacity>
<Text className="text-white text-xl font-bold">Record Payment</Text>
</View>
<ScrollView className="flex-1 px-4 py-4" keyboardShouldPersistTaps="handled">
{/* Client selector */}
<Text className="font-semibold text-gray-700 mb-2">Client *</Text>
{client ? (
<View className="flex-row items-center bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
<View className="flex-1">
<Text className="font-bold text-blue-900">{client.firstName} {client.lastName}</Text>
<Text className="text-blue-600 text-sm">{client.accountNumber}</Text>
</View>
<TouchableOpacity onPress={() => { setClient(null); setSearch(''); }} className="p-2">
<Text className="text-blue-500 font-semibold">Change</Text>
</TouchableOpacity>
</View>
) : (
<View className="mb-4">
<View className="flex-row items-center bg-white border border-gray-200 rounded-xl px-4 mb-1">
<TextInput
className="flex-1 py-3 text-base"
placeholder="Search by name or account #"
value={search}
onChangeText={setSearch}
autoCapitalize="none"
/>
{searching && <ActivityIndicator size="small" color="#2563EB" />}
</View>
{debouncedSearch.trim().length >= 2 && (
<View className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{(searchResults ?? []).length === 0 && !searching && (
<Text className="px-4 py-3 text-gray-400">No clients found</Text>
)}
{(searchResults ?? []).map((c: any) => (
<TouchableOpacity
key={c.id}
className="px-4 py-3 border-b border-gray-100"
onPress={() => { setClient(c); setSearch(''); }}
>
<Text className="font-medium text-gray-900">{c.firstName} {c.lastName}</Text>
<Text className="text-gray-500 text-sm">{c.accountNumber}</Text>
</TouchableOpacity>
))}
</View>
)}
</View>
)}
{/* Amount */}
<Text className="font-semibold text-gray-700 mb-2">Amount () *</Text>
<TextInput
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-4"
placeholder="0.00"
value={amount}
onChangeText={setAmount}
keyboardType="decimal-pad"
/>
{/* Payment method */}
<Text className="font-semibold text-gray-700 mb-2">Payment Method *</Text>
<View className="flex-row flex-wrap mb-4">
{METHODS.map(m => (
<TouchableOpacity
key={m}
onPress={() => setMethod(m)}
className={`rounded-xl px-5 py-2.5 mr-2 mb-2 border ${method === m ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}
>
<Text className={method === m ? 'text-white font-semibold' : 'text-gray-700'}>{m}</Text>
</TouchableOpacity>
))}
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
{/* Header */}
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 20 }}>
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}> Back</Text>
</TouchableOpacity>
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Record Payment</Text>
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>Field collection</Text>
</View>
{/* Reference (for non-cash) */}
{method !== 'CASH' && (
<>
<Text className="font-semibold text-gray-700 mb-2">Reference # *</Text>
<TextInput
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-4"
placeholder={`${method} transaction reference`}
value={reference}
onChangeText={setReference}
autoCapitalize="none"
/>
</>
)}
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
{/* Client section */}
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Client</Text>
{/* Notes */}
<Text className="font-semibold text-gray-700 mb-2">Notes (optional)</Text>
<TextInput
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-8"
placeholder="Any remarks..."
value={notes}
onChangeText={setNotes}
multiline
numberOfLines={2}
/>
{client ? (
<View style={{ backgroundColor: '#ECFEFF', borderRadius: 16, padding: 18, marginBottom: 20, borderWidth: 1.5, borderColor: '#A5F3FC' }}>
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0E7490' }}>{client.firstName} {client.lastName}</Text>
<Text style={{ fontSize: 15, color: '#0891B2', marginTop: 3 }}>{client.accountNumber}</Text>
<TouchableOpacity onPress={() => { setClient(null); setSearch(''); }} style={{ marginTop: 10 }} hitSlop={{ top: 8, bottom: 8, left: 0, right: 8 }}>
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>× Change client</Text>
</TouchableOpacity>
</View>
) : (
<View style={{ marginBottom: 20 }}>
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1, backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, marginRight: 10 }}>
<TextInput
style={{ flex: 1, paddingVertical: 14, fontSize: 16, color: '#0F172A' }}
placeholder="Account # or name"
placeholderTextColor="#94A3B8"
value={search}
onChangeText={setSearch}
onSubmitEditing={searchClient}
returnKeyType="search"
/>
{search.length > 0 && (
<TouchableOpacity onPress={() => setSearch('')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: '#FFF', fontSize: 12, fontWeight: '800', lineHeight: 14 }}>×</Text>
</View>
</TouchableOpacity>
)}
</View>
<TouchableOpacity
style={{ backgroundColor: '#0891B2', borderRadius: 14, paddingHorizontal: 18, alignItems: 'center', justifyContent: 'center' }}
onPress={searchClient}
activeOpacity={0.8}
>
{searching
? <ActivityIndicator color="#FFF" size="small" />
: <Text style={{ color: '#FFF', fontWeight: '700', fontSize: 15 }}>Find</Text>
}
</TouchableOpacity>
</View>
<Text style={{ fontSize: 13, color: '#94A3B8', marginTop: 8 }}>Search by account number, first name, or last name</Text>
</View>
)}
{/* Submit */}
<TouchableOpacity
className={`rounded-xl py-4 items-center ${client && amount ? 'bg-primary' : 'bg-gray-300'}`}
onPress={submit}
disabled={loading || !client || !amount}
>
{loading
? <ActivityIndicator color="white" />
: <Text className="text-white font-bold text-base">
Submit Payment {amount ? `· ₱${Number(amount || 0).toLocaleString()}` : ''}
</Text>
}
</TouchableOpacity>
{/* Amount */}
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Amount ()</Text>
<TextInput
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 16, fontSize: 32, fontWeight: '800', color: '#0F172A', marginBottom: 20, textAlign: 'center' }}
placeholder="0.00"
placeholderTextColor="#CBD5E1"
value={amount}
onChangeText={setAmount}
keyboardType="decimal-pad"
/>
<View className="h-8" />
</ScrollView>
</View>
{/* Payment method */}
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Payment Method</Text>
<View style={{ flexDirection: 'row', marginBottom: 20 }}>
{METHODS.map(m => (
<TouchableOpacity
key={m.id}
onPress={() => setMethod(m.id)}
style={{ flex: 1, borderRadius: 14, paddingVertical: 14, alignItems: 'center', marginHorizontal: 4, backgroundColor: method === m.id ? '#0891B2' : '#FFF', borderWidth: 1.5, borderColor: method === m.id ? '#0891B2' : '#E2E8F0' }}
activeOpacity={0.7}
>
<Text style={{ fontSize: 13, fontWeight: '700', color: method === m.id ? '#FFF' : '#64748B' }}>{m.label}</Text>
</TouchableOpacity>
))}
</View>
{/* Reference */}
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>
Reference # <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text>
</Text>
<TextInput
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 28 }}
placeholder="GCash ref, receipt #, OR number..."
placeholderTextColor="#94A3B8"
value={reference}
onChangeText={setReference}
/>
<TouchableOpacity
style={{ backgroundColor: canSubmit ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
onPress={submit}
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>
</View>
</SafeAreaView>
);
}