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 {