fix: 5 bugs - tab height, collect pay modal, invoice picker in record payment, comment body field, dashboard tickets label
This commit is contained in:
@@ -204,13 +204,13 @@ export default function ClientDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ backgroundColor: '#FFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }} contentContainerStyle={{ paddingHorizontal: 8 }}>
|
<View style={{ flexDirection: 'row', backgroundColor: '#FFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||||
{TABS.map(tab => (
|
{TABS.map(tab => (
|
||||||
<TouchableOpacity key={tab} onPress={() => setActiveTab(tab)} style={{ paddingHorizontal: 16, paddingVertical: 16, borderBottomWidth: 2.5, borderBottomColor: activeTab === tab ? '#0891B2' : 'transparent' }} activeOpacity={0.7}>
|
<TouchableOpacity key={tab} onPress={() => setActiveTab(tab)} style={{ flex: 1, paddingVertical: 14, alignItems: 'center', borderBottomWidth: 2.5, borderBottomColor: activeTab === tab ? '#0891B2' : 'transparent' }} activeOpacity={0.7}>
|
||||||
<Text style={{ fontSize: 15, fontWeight: '700', color: activeTab === tab ? '#0891B2' : '#94A3B8' }}>{tab}</Text>
|
<Text style={{ fontSize: 14, fontWeight: '700', color: activeTab === tab ? '#0891B2' : '#94A3B8' }}>{tab}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</View>
|
||||||
|
|
||||||
{/* ── PROFILE TAB ── */}
|
{/* ── PROFILE TAB ── */}
|
||||||
{activeTab === 'Profile' && (
|
{activeTab === 'Profile' && (
|
||||||
|
|||||||
@@ -143,8 +143,15 @@ export default function DashboardScreen() {
|
|||||||
const allActiveTickets: any[] = ticketsQ.data ?? [];
|
const allActiveTickets: any[] = ticketsQ.data ?? [];
|
||||||
const unpaidInvoices: any[] = invoicesQ.data ?? [];
|
const unpaidInvoices: any[] = invoicesQ.data ?? [];
|
||||||
|
|
||||||
const unassigned = allActiveTickets.filter((t: any) => !t.assignedToId).slice(0, 10);
|
const unassigned = allActiveTickets.filter((t: any) => !t.assignedToId);
|
||||||
const assignedToMe = allActiveTickets.filter((t: any) => t.assignedToId === user?.id).slice(0, 10);
|
const assignedToMe = allActiveTickets.filter((t: any) => t.assignedToId === user?.id);
|
||||||
|
// Merge: assigned-to-me first, then unassigned, deduped, max 10
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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 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(); };
|
||||||
@@ -195,13 +202,13 @@ export default function DashboardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Unassigned Tickets ── */}
|
{/* ── Active 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' }}>Unassigned Tickets</Text>
|
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>Tickets</Text>
|
||||||
{unassigned.length > 0 && (
|
{mergedTickets.length > 0 && (
|
||||||
<View style={{ backgroundColor: '#FEE2E2', borderRadius: 20, paddingHorizontal: 8, paddingVertical: 2, marginLeft: 8 }}>
|
<View style={{ backgroundColor: '#ECFEFF', borderRadius: 20, paddingHorizontal: 8, paddingVertical: 2, marginLeft: 8 }}>
|
||||||
<Text style={{ fontSize: 12, fontWeight: '700', color: '#DC2626' }}>{unassigned.length}</Text>
|
<Text style={{ fontSize: 12, fontWeight: '700', color: '#0891B2' }}>{mergedTickets.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -210,30 +217,18 @@ export default function DashboardScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{unassigned.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 unassigned tickets 🎉</Text>
|
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No active tickets 🎉</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ marginBottom: 20 }}>
|
<View style={{ marginBottom: 20 }}>
|
||||||
{[...unassigned].sort(byPrio).map((t: any) => (
|
{[...mergedTickets].sort(byPrio).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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Assigned to Me ── */}
|
|
||||||
{assignedToMe.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A', marginBottom: 10 }}>Assigned to Me</Text>
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
|
||||||
{[...assignedToMe].sort(byPrio).map((t: any) => (
|
|
||||||
<TicketRow key={t.id} task={t} onPress={() => router.push(`/(app)/tasks/${t.id}`)} />
|
|
||||||
))}
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { api } from '../../../services/api';
|
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 (
|
||||||
|
<Modal visible animationType="slide" transparent onRequestClose={onClose}>
|
||||||
|
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24 }}>
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 2 }}>Record Payment</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B', marginBottom: 4 }}>{invoice.client?.firstName} {invoice.client?.lastName} · {invoice.client?.accountNumber}</Text>
|
||||||
|
<Text style={{ fontSize: 15, color: '#64748B', marginBottom: 20 }}>
|
||||||
|
{invoice.invoiceNumber} · Balance: <Text style={{ fontWeight: '700', color: '#991B1B' }}>₱{Number(invoice.balance).toLocaleString()}</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>Amount (₱)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14, fontSize: 28, fontWeight: '800', color: '#0F172A', textAlign: 'center', marginBottom: 16 }}
|
||||||
|
placeholder="0.00" placeholderTextColor="#CBD5E1"
|
||||||
|
keyboardType="decimal-pad" value={amount} onChangeText={setAmount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Method</Text>
|
||||||
|
<View style={{ flexDirection: 'row', marginBottom: 16 }}>
|
||||||
|
{METHODS.map(m => (
|
||||||
|
<TouchableOpacity key={m} onPress={() => setMethod(m)}
|
||||||
|
style={{ flex: 1, borderRadius: 12, paddingVertical: 12, alignItems: 'center', marginHorizontal: 3, backgroundColor: method === m ? '#0891B2' : '#F1F5F9' }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: method === m ? '#FFF' : '#64748B' }}>{m}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>Reference # <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text></Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 12, fontSize: 16, color: '#0F172A', marginBottom: 20 }}
|
||||||
|
placeholder="GCash ref, OR number..." placeholderTextColor="#94A3B8"
|
||||||
|
value={ref} onChangeText={setRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: amount && Number(amount) > 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||||||
|
onPress={submit} disabled={loading} activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{loading ? <ActivityIndicator color="#FFF" /> : <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Confirm Payment</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CollectScreen() {
|
export default function CollectScreen() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [payingInvoice, setPayingInvoice] = useState<any>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const { data: invoices, isLoading, isRefetching, refetch } = useQuery({
|
const { data: invoices, isLoading, isRefetching, refetch } = useQuery({
|
||||||
queryKey: ['unpaid-invoices'],
|
queryKey: ['unpaid-invoices'],
|
||||||
@@ -148,7 +231,7 @@ export default function CollectScreen() {
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
onPress={() => router.push({ pathname: '/(app)/clients/[id]', params: { id: inv.clientId, tab: 'invoices' } })}
|
onPress={() => setPayingInvoice(inv)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#FFF',
|
backgroundColor: '#FFF',
|
||||||
@@ -194,7 +277,7 @@ export default function CollectScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={(e) => { e.stopPropagation?.(); navigate(inv); }}
|
onPress={() => navigate(inv)}
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row', alignItems: 'center',
|
flexDirection: 'row', alignItems: 'center',
|
||||||
backgroundColor: hasLocation ? '#ECFEFF' : '#F1F5F9',
|
backgroundColor: hasLocation ? '#ECFEFF' : '#F1F5F9',
|
||||||
@@ -214,6 +297,17 @@ export default function CollectScreen() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{payingInvoice && (
|
||||||
|
<PayInvoiceModal
|
||||||
|
invoice={payingInvoice}
|
||||||
|
onClose={() => setPayingInvoice(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setPayingInvoice(null);
|
||||||
|
qc.invalidateQueries({ queryKey: ['unpaid-invoices'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../../../services/api';
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
const METHODS = [
|
const METHODS = [
|
||||||
@@ -19,15 +20,26 @@ export default function RecordPaymentScreen() {
|
|||||||
prefillAccountNumber?: string;
|
prefillAccountNumber?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [client, setClient] = useState<any>(null);
|
const [client, setClient] = useState<any>(null);
|
||||||
const [amount, setAmount] = useState('');
|
const [invoice, setInvoice] = useState<any>(null);
|
||||||
const [method, setMethod] = useState('CASH');
|
const [showInvoicePicker, setShowInvoicePicker] = useState(false);
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [method, setMethod] = useState('CASH');
|
||||||
const [reference, setReference] = useState('');
|
const [reference, setReference] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searching, setSearching] = 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(() => {
|
useEffect(() => {
|
||||||
if (params.prefillClientId && params.prefillName) {
|
if (params.prefillClientId && params.prefillName) {
|
||||||
setClient({
|
setClient({
|
||||||
@@ -39,6 +51,9 @@ export default function RecordPaymentScreen() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// When client changes, reset invoice selection
|
||||||
|
useEffect(() => { setInvoice(null); }, [client?.id]);
|
||||||
|
|
||||||
const searchClient = async () => {
|
const searchClient = async () => {
|
||||||
if (!search.trim()) return;
|
if (!search.trim()) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
@@ -76,8 +91,9 @@ export default function RecordPaymentScreen() {
|
|||||||
try {
|
try {
|
||||||
await api.post('/api/v1/payments', {
|
await api.post('/api/v1/payments', {
|
||||||
clientId: client.id,
|
clientId: client.id,
|
||||||
|
invoiceId: invoice?.id ?? undefined,
|
||||||
amount: amt,
|
amount: amt,
|
||||||
channel: method, // API uses `channel` not `paymentMethod`
|
channel: method,
|
||||||
referenceNumber: reference.trim() || undefined,
|
referenceNumber: reference.trim() || undefined,
|
||||||
paymentDate: new Date().toISOString(),
|
paymentDate: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
@@ -153,6 +169,36 @@ export default function RecordPaymentScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Invoice selection (shown after client selected) */}
|
||||||
|
{client && (
|
||||||
|
<>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Invoice <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text></Text>
|
||||||
|
{!clientInvoices ? (
|
||||||
|
<ActivityIndicator color="#0891B2" style={{ marginBottom: 16 }} />
|
||||||
|
) : clientInvoices.length === 0 ? (
|
||||||
|
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 14, padding: 14, marginBottom: 16 }}>
|
||||||
|
<Text style={{ fontSize: 15, color: '#166534', fontWeight: '600' }}>✓ No outstanding invoices</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: invoice ? '#ECFEFF' : '#FFF', borderWidth: 1.5, borderColor: invoice ? '#0891B2' : '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14, marginBottom: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}
|
||||||
|
onPress={() => setShowInvoicePicker(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: invoice ? '700' : '400', color: invoice ? '#0891B2' : '#94A3B8' }}>
|
||||||
|
{invoice ? invoice.invoiceNumber : 'Select invoice to apply payment'}
|
||||||
|
</Text>
|
||||||
|
{invoice && (
|
||||||
|
<Text style={{ fontSize: 13, color: '#0891B2', marginTop: 2 }}>Balance: ₱{Number(invoice.balance).toLocaleString()}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#94A3B8', fontSize: 18 }}>▾</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Amount */}
|
{/* Amount */}
|
||||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Amount (₱)</Text>
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Amount (₱)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -206,6 +252,41 @@ export default function RecordPaymentScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Invoice Picker Modal */}
|
||||||
|
<Modal visible={showInvoicePicker} transparent animationType="slide" onRequestClose={() => setShowInvoicePicker(false)}>
|
||||||
|
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={() => setShowInvoicePicker(false)}>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24, maxHeight: '70%' }}>
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 4 }}>Select Invoice</Text>
|
||||||
|
<Text style={{ fontSize: 15, color: '#64748B', marginBottom: 20 }}>Choose which invoice to apply payment to</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => { 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}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '600', color: !invoice ? '#0891B2' : '#64748B' }}>No specific invoice (general payment)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{(clientInvoices ?? []).map((inv: any) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={inv.id}
|
||||||
|
onPress={() => { 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}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A' }}>{inv.invoiceNumber}</Text>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '800', color: '#991B1B' }}>₱{Number(inv.balance).toLocaleString()}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 13, color: '#64748B', marginTop: 3 }}>
|
||||||
|
{inv.dueDate ? `Due: ${new Date(inv.dueDate).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })}` : 'No due date'} · {inv.status}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function TicketDetailScreen() {
|
|||||||
// Log status change as a system comment
|
// Log status change as a system comment
|
||||||
const who = user?.firstName ?? 'Staff';
|
const who = user?.firstName ?? 'Staff';
|
||||||
await api.post(`/api/v1/tickets/${id}/messages`, {
|
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(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -123,7 +123,7 @@ export default function TicketDetailScreen() {
|
|||||||
const note = instNotes.trim()
|
const note = instNotes.trim()
|
||||||
? `Installation confirmed. Location recorded: ${coordStr}. Notes: ${instNotes.trim()}`
|
? `Installation confirmed. Location recorded: ${coordStr}. Notes: ${instNotes.trim()}`
|
||||||
: `Installation confirmed. Location recorded: ${coordStr}`;
|
: `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('');
|
setInstNotes('');
|
||||||
setCoords(null);
|
setCoords(null);
|
||||||
@@ -145,7 +145,7 @@ export default function TicketDetailScreen() {
|
|||||||
const text = comment.trim();
|
const text = comment.trim();
|
||||||
setComment(''); // clear immediately for responsiveness
|
setComment(''); // clear immediately for responsiveness
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/v1/tickets/${id}/messages`, { message: text });
|
await api.post(`/api/v1/tickets/${id}/messages`, { body: text });
|
||||||
refetch();
|
refetch();
|
||||||
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 300);
|
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 300);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user