feat: complete all screens - client tabs, payments list, remittance detail, new ticket, installations tab
- Client detail: Subscription/Invoices/Payments tabs now fully functional - Payments: proper list with today's total + live search prefill from client - Record payment: debounced live search, reference required for non-cash - Remittances: detail screen with included payments breakdown - Tickets: status filter chips + create button, new ticket with categories - Installations: tab now visible with list + confirm flow - Fix: remove duplicate @react-navigation/elements causing Metro asset error - Fix: metro.config.js asset resolution from node_modules
This commit is contained in:
@@ -1,37 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, FlatList, Modal } from 'react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../../services/api';
|
||||
|
||||
const METHODS = ['CASH', 'GCASH', 'MAYA', 'BANK'];
|
||||
|
||||
export default function RecordPaymentScreen() {
|
||||
const params = useLocalSearchParams<{ prefillClientId?: string; prefillName?: string; prefillAccountNumber?: string }>();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [client, setClient] = useState<any>(null);
|
||||
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 [reference, setReference] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const searchClient = async () => {
|
||||
if (!search.trim()) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await api.get(`/api/v1/clients?search=${search.trim()}&limit=1`);
|
||||
const found = res.data?.data?.[0] ?? res.data?.[0];
|
||||
if (found) setClient(found);
|
||||
else Alert.alert('Not Found', 'No client found with that account number or name.');
|
||||
} catch {
|
||||
Alert.alert('Error', 'Search failed.');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
// Debounced client search
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
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 submit = async () => {
|
||||
if (!client) return Alert.alert('Required', 'Search and select a client first.');
|
||||
if (!amount || isNaN(Number(amount))) return Alert.alert('Required', 'Enter a valid amount.');
|
||||
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.');
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post('/api/v1/payments', {
|
||||
@@ -39,11 +47,19 @@ export default function RecordPaymentScreen() {
|
||||
amount: Number(amount),
|
||||
paymentMethod: method,
|
||||
referenceNumber: reference || undefined,
|
||||
notes: notes || undefined,
|
||||
paymentDate: new Date().toISOString(),
|
||||
});
|
||||
Alert.alert('Success', 'Payment recorded!', [{ text: 'OK', onPress: () => router.back() }]);
|
||||
// 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}`, [
|
||||
{ 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.');
|
||||
Alert.alert('Error', e?.response?.data?.message ?? 'Payment failed. Try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -52,69 +68,121 @@ export default function RecordPaymentScreen() {
|
||||
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">
|
||||
<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">
|
||||
<Text className="font-semibold text-gray-700 mb-2">Search Client</Text>
|
||||
<View className="flex-row mb-4">
|
||||
<TextInput
|
||||
className="flex-1 bg-white border border-gray-200 rounded-xl px-4 py-3 mr-2"
|
||||
placeholder="Account # or name"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
/>
|
||||
<TouchableOpacity className="bg-primary rounded-xl px-4 items-center justify-center" onPress={searchClient}>
|
||||
{searching ? <ActivityIndicator color="white" /> : <Text className="text-white font-semibold">Find</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{client && (
|
||||
<View className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
|
||||
<Text className="font-bold text-blue-900">{client.firstName} {client.lastName}</Text>
|
||||
<Text className="text-blue-700 text-sm">{client.accountNumber}</Text>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Text className="font-semibold text-gray-700 mb-2">Amount (₱)</Text>
|
||||
{/* 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 mb-4 text-base"
|
||||
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="numeric"
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
|
||||
<Text className="font-semibold text-gray-700 mb-2">Payment Method</Text>
|
||||
{/* 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-4 py-2 mr-2 mb-2 border ${method === m ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}
|
||||
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>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text className="font-semibold text-gray-700 mb-2">Reference # (optional)</Text>
|
||||
{/* 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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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 mb-8"
|
||||
placeholder="GCash ref, receipt #, etc."
|
||||
value={reference}
|
||||
onChangeText={setReference}
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* Submit */}
|
||||
<TouchableOpacity
|
||||
className="bg-primary rounded-xl py-4 items-center"
|
||||
className={`rounded-xl py-4 items-center ${client && amount ? 'bg-primary' : 'bg-gray-300'}`}
|
||||
onPress={submit}
|
||||
disabled={loading}
|
||||
disabled={loading || !client || !amount}
|
||||
>
|
||||
{loading ? <ActivityIndicator color="white" /> : <Text className="text-white font-bold text-base">Submit Payment</Text>}
|
||||
{loading
|
||||
? <ActivityIndicator color="white" />
|
||||
: <Text className="text-white font-bold text-base">
|
||||
Submit Payment {amount ? `· ₱${Number(amount || 0).toLocaleString()}` : ''}
|
||||
</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="h-8" />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user