feat: dashboard tickets+invoices, collect screen overhaul, client detail tickets tab + pay invoice + map
- Dashboard: active tickets (unassigned + assigned to me, top 10), top 10 unpaid invoices by due date, revenue hidden for TECHNICIAN/COLLECTOR - Collect screen: unpaid invoices sorted overdue-first, remittance button at top, navigate button (Google Maps/Waze) - Client Detail: added Tickets tab, removed Payments tab, Invoices tab has pay button per invoice + status tags + ordered by issuedDate - Client Profile tab: map view + navigate button using client lat/lng - Installed react-native-maps
This commit is contained in:
@@ -1,64 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking } from 'react-native';
|
||||
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking, Alert, TextInput, Modal } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import MapView, { Marker } from 'react-native-maps';
|
||||
import { api } from '../../../services/api';
|
||||
|
||||
const TABS = ['Profile', 'Subscription', 'Invoices', 'Payments'];
|
||||
const TABS = ['Profile', 'Subscription', 'Invoices', 'Tickets'] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = { ACTIVE: '#166534', SUSPENDED: '#92400E', CANCELLED: '#991B1B', PENDING: '#475569' };
|
||||
const STATUS_BG: Record<string, string> = { ACTIVE: '#DCFCE7', SUSPENDED: '#FEF3C7', CANCELLED: '#FEE2E2', PENDING: '#F1F5F9' };
|
||||
const TICKET_STATUS_COLOR: Record<string, string> = { OPEN: '#0891B2', IN_PROGRESS: '#D97706', RESOLVED: '#16A34A', CLOSED: '#6B7280' };
|
||||
const TYPE_COLOR: Record<string, string> = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' };
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
ACTIVE: '#166534', SUSPENDED: '#92400E', CANCELLED: '#991B1B', PENDING: '#475569',
|
||||
};
|
||||
const STATUS_BG: Record<string, string> = {
|
||||
ACTIVE: '#DCFCE7', SUSPENDED: '#FEF3C7', CANCELLED: '#FEE2E2', PENDING: '#F1F5F9',
|
||||
};
|
||||
const INV_STATUS: Record<string, { label: string; color: string; bg: string }> = {
|
||||
PAID: { label: 'Paid', color: '#166534', bg: '#DCFCE7' },
|
||||
UNPAID: { label: 'Unpaid', color: '#92400E', bg: '#FEF3C7' },
|
||||
SENT: { label: 'Unpaid', color: '#92400E', bg: '#FEF3C7' },
|
||||
OVERDUE: { label: 'Overdue', color: '#991B1B', bg: '#FEE2E2' },
|
||||
PARTIAL: { label: 'Partial', color: '#0E7490', bg: '#CFFAFE' },
|
||||
DRAFT: { label: 'Draft', color: '#6B7280', bg: '#F1F5F9' },
|
||||
VOID: { label: 'Void', color: '#6B7280', bg: '#F1F5F9' },
|
||||
};
|
||||
|
||||
function InfoRow({ label, value, onPress, isLast }: { label: string; value?: string | null; onPress?: () => void; isLast?: boolean }) {
|
||||
function InfoRow({ label, value, isLast }: { label: string; value?: string | null; isLast?: boolean }) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!onPress}
|
||||
onPress={onPress}
|
||||
style={{ paddingHorizontal: 20, paddingVertical: 16, borderBottomWidth: isLast ? 0 : 1, borderBottomColor: '#F1F5F9' }}
|
||||
activeOpacity={onPress ? 0.7 : 1}
|
||||
>
|
||||
<View style={{ paddingHorizontal: 20, paddingVertical: 16, borderBottomWidth: isLast ? 0 : 1, borderBottomColor: '#F1F5F9' }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 4 }}>{label}</Text>
|
||||
<Text style={{ fontSize: 17, fontWeight: '500', color: onPress ? '#0891B2' : '#0F172A' }}>{value ?? '—'}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={{ fontSize: 17, fontWeight: '500', color: value ? '#0F172A' : '#CBD5E1' }}>{value ?? '—'}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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: 4 }}>Record Payment</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: 24, 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Screen ───────────────────────────────────────────────────────────────
|
||||
export default function ClientDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const [tab, setTab] = useState('Profile');
|
||||
const params = useLocalSearchParams<{ id: string; tab?: string }>();
|
||||
const id = params.id;
|
||||
const qc = useQueryClient();
|
||||
|
||||
const initialTab = (params.tab === 'invoices' ? 'Invoices' : 'Profile') as Tab;
|
||||
const [activeTab, setActiveTab] = useState<Tab>(initialTab);
|
||||
const [payingInvoice, setPayingInvoice] = useState<any>(null);
|
||||
|
||||
const { data: client, isLoading } = useQuery({
|
||||
queryKey: ['client', id],
|
||||
queryFn: () => api.get(`/api/v1/clients/${id}`).then(r => r.data),
|
||||
});
|
||||
const { data: subData, isLoading: subLoading } = useQuery({
|
||||
queryKey: ['client-subscription', id],
|
||||
queryFn: () => api.get(`/api/v1/subscriptions?clientId=${id}&limit=1`).then(r => r.data?.data?.[0] ?? r.data?.[0] ?? null),
|
||||
enabled: tab === 'Subscription',
|
||||
});
|
||||
const { data: invoices, isLoading: invLoading } = useQuery({
|
||||
|
||||
const { data: invoices } = useQuery({
|
||||
queryKey: ['client-invoices', id],
|
||||
queryFn: () => api.get(`/api/v1/invoices?clientId=${id}&limit=20`).then(r => r.data?.data ?? r.data ?? []),
|
||||
enabled: tab === 'Invoices',
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/api/v1/invoices?clientId=${id}&limit=50`);
|
||||
const items: any[] = res.data?.data ?? res.data ?? [];
|
||||
items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
return items;
|
||||
},
|
||||
enabled: activeTab === 'Invoices',
|
||||
});
|
||||
const { data: payments, isLoading: payLoading } = useQuery({
|
||||
queryKey: ['client-payments', id],
|
||||
queryFn: () => api.get(`/api/v1/payments?clientId=${id}&limit=20`).then(r => r.data?.data ?? r.data ?? []),
|
||||
enabled: tab === 'Payments',
|
||||
|
||||
const { data: tickets } = useQuery({
|
||||
queryKey: ['client-tickets', id],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/api/v1/tickets?clientId=${id}&limit=50`);
|
||||
return res.data?.data ?? res.data ?? [];
|
||||
},
|
||||
enabled: activeTab === 'Tickets',
|
||||
});
|
||||
|
||||
const sub = client?.subscriptions?.[0];
|
||||
|
||||
const navigateToClient = () => {
|
||||
const lat = client?.lat;
|
||||
const lng = client?.lng;
|
||||
if (!lat || !lng) {
|
||||
Alert.alert('No Location', 'This client has no recorded location yet. Location is recorded during installation confirmation.');
|
||||
return;
|
||||
}
|
||||
Alert.alert('Navigate to Client', `${client?.firstName} ${client?.lastName}`, [
|
||||
{ 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: 'Cancel', style: 'cancel' },
|
||||
]);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||
@@ -69,154 +180,212 @@ export default function ClientDetailScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const statusColor = STATUS_COLOR[client?.status] ?? '#475569';
|
||||
const statusBg = STATUS_BG[client?.status] ?? '#F1F5F9';
|
||||
const statusStyle = {
|
||||
color: STATUS_COLOR[client?.status] ?? '#475569',
|
||||
bg: STATUS_BG[client?.status] ?? '#F1F5F9',
|
||||
};
|
||||
|
||||
return (
|
||||
<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={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }} activeOpacity={0.7} hitSlop={{ top: 10, bottom: 10, left: 0, right: 20 }}>
|
||||
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 18 }}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<View style={{ flex: 1, marginRight: 12 }}>
|
||||
<Text style={{ color: '#FFF', fontSize: 22, fontWeight: '800' }}>{client?.firstName} {client?.lastName}</Text>
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 15, marginTop: 3 }}>{client?.accountNumber}</Text>
|
||||
</View>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: statusBg }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '700', color: statusColor }}>{client?.status}</Text>
|
||||
<Text style={{ color: '#FFF', fontSize: 24, fontWeight: '800' }}>{client?.firstName} {client?.lastName}</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 8 }}>
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 14 }}>{client?.accountNumber}</Text>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 12, paddingVertical: 5, backgroundColor: statusStyle.bg }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '700', color: statusStyle.color }}>{client?.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={{ flexDirection: 'row', backgroundColor: '#FFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||
{TABS.map(t => (
|
||||
<TouchableOpacity
|
||||
key={t}
|
||||
onPress={() => setTab(t)}
|
||||
style={{ flex: 1, paddingVertical: 14, alignItems: 'center', borderBottomWidth: 2.5, borderBottomColor: tab === t ? '#0891B2' : 'transparent' }}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={{ fontSize: 14, fontWeight: '700', color: tab === t ? '#0891B2' : '#94A3B8' }}>{t}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ backgroundColor: '#FFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }} contentContainerStyle={{ paddingHorizontal: 8 }}>
|
||||
{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}>
|
||||
<Text style={{ fontSize: 15, fontWeight: '700', color: activeTab === tab ? '#0891B2' : '#94A3B8' }}>{tab}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||
{/* PROFILE */}
|
||||
{tab === 'Profile' && (
|
||||
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||
<InfoRow label="Account Number" value={client?.accountNumber} />
|
||||
<InfoRow label="Email" value={client?.email} />
|
||||
<InfoRow label="Phone" value={client?.phone} onPress={() => client?.phone && Linking.openURL(`tel:${client.phone}`)} />
|
||||
<InfoRow label="Address" value={client?.address} />
|
||||
<InfoRow label="Area" value={client?.area?.name} />
|
||||
<InfoRow label="Date Joined" value={client?.createdAt ? new Date(client.createdAt).toLocaleDateString('en-PH', { year: 'numeric', month: 'long', day: 'numeric' }) : undefined} isLast />
|
||||
{/* ── PROFILE TAB ── */}
|
||||
{activeTab === 'Profile' && (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 40 }}>
|
||||
<View style={{ backgroundColor: '#FFF', borderRadius: 20, margin: 16, borderWidth: 1, borderColor: '#F1F5F9', overflow: 'hidden' }}>
|
||||
<InfoRow label="Full Name" value={`${client?.firstName} ${client?.lastName}`} />
|
||||
<InfoRow label="Account #" value={client?.accountNumber} />
|
||||
<InfoRow label="Email" value={client?.email} />
|
||||
<InfoRow label="Phone" value={client?.phone} />
|
||||
<InfoRow label="Address" value={client?.address} />
|
||||
<InfoRow label="Area" value={client?.area?.name} />
|
||||
<InfoRow label="Zone" value={client?.zone?.name} isLast />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* SUBSCRIPTION */}
|
||||
{tab === 'Subscription' && (
|
||||
subLoading ? (
|
||||
<View style={{ paddingVertical: 60, alignItems: 'center' }}><ActivityIndicator color="#0891B2" size="large" /></View>
|
||||
) : !subData ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||||
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No active subscription</Text>
|
||||
{/* Map + Navigate */}
|
||||
<View style={{ marginHorizontal: 16, marginBottom: 16 }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Location</Text>
|
||||
{client?.lat && client?.lng ? (
|
||||
<>
|
||||
<View style={{ borderRadius: 16, overflow: 'hidden', height: 200, marginBottom: 10 }}>
|
||||
<MapView
|
||||
style={{ flex: 1 }}
|
||||
initialRegion={{ latitude: client.lat, longitude: client.lng, latitudeDelta: 0.005, longitudeDelta: 0.005 }}
|
||||
scrollEnabled={false}
|
||||
zoomEnabled={false}
|
||||
>
|
||||
<Marker coordinate={{ latitude: client.lat, longitude: client.lng }} title={`${client.firstName} ${client.lastName}`} description={client.address ?? ''} />
|
||||
</MapView>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: '#0891B2', borderRadius: 14, paddingVertical: 16, alignItems: 'center' }}
|
||||
onPress={navigateToClient}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={{ color: '#FFF', fontSize: 16, fontWeight: '700' }}>📍 Navigate to Client</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<View style={{ backgroundColor: '#F1F5F9', borderRadius: 16, padding: 24, alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 15, color: '#94A3B8', marginBottom: 6 }}>No location recorded</Text>
|
||||
<Text style={{ fontSize: 13, color: '#CBD5E1', textAlign: 'center' }}>Location is recorded when a technician confirms an installation</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* ── SUBSCRIPTION TAB ── */}
|
||||
{activeTab === 'Subscription' && (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||
{!sub ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
|
||||
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No active subscription</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', marginBottom: 12 }}>
|
||||
<InfoRow label="Plan" value={subData.plan?.name} />
|
||||
<InfoRow label="Speed" value={subData.plan?.speedMbps ? `${subData.plan.speedMbps} Mbps` : undefined} />
|
||||
<InfoRow label="Monthly Rate" value={subData.plan?.price ? `₱${Number(subData.plan.price).toLocaleString()}` : undefined} />
|
||||
<InfoRow label="Status" value={subData.status} />
|
||||
<InfoRow label="Start Date" value={subData.startDate ? new Date(subData.startDate).toLocaleDateString('en-PH', { year: 'numeric', month: 'long', day: 'numeric' }) : undefined} />
|
||||
<InfoRow label="Billing Day" value={subData.billingCycleDay ? `Day ${subData.billingCycleDay} of every month` : undefined} isLast />
|
||||
</View>
|
||||
{subData.nextBillingDate && (
|
||||
<View style={{ backgroundColor: '#ECFEFF', borderRadius: 14, padding: 16, borderWidth: 1, borderColor: '#A5F3FC' }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '600', color: '#0E7490' }}>Next billing date</Text>
|
||||
<Text style={{ fontSize: 17, fontWeight: '700', color: '#0E7490', marginTop: 2 }}>{new Date(subData.nextBillingDate).toLocaleDateString('en-PH', { year: 'numeric', month: 'long', day: 'numeric' })}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', overflow: 'hidden' }}>
|
||||
<InfoRow label="Plan" value={sub.plan?.name} />
|
||||
<InfoRow label="Speed" value={sub.plan?.speed ? `${sub.plan.speed} Mbps` : null} />
|
||||
<InfoRow label="Monthly" value={sub.plan?.price ? `₱${Number(sub.plan.price).toLocaleString()}` : null} />
|
||||
<InfoRow label="Status" value={sub.status} />
|
||||
<InfoRow label="Start Date" value={sub.startDate ? new Date(sub.startDate).toLocaleDateString('en-PH', { year: 'numeric', month: 'long', day: 'numeric' }) : null} />
|
||||
<InfoRow label="Billing Day" value={sub.billingDay ? `Day ${sub.billingDay}` : null} isLast />
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* INVOICES */}
|
||||
{tab === 'Invoices' && (
|
||||
invLoading ? (
|
||||
<View style={{ paddingVertical: 60, alignItems: 'center' }}><ActivityIndicator color="#0891B2" size="large" /></View>
|
||||
) : !invoices?.length ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||||
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No invoices yet</Text>
|
||||
{/* ── INVOICES TAB ── */}
|
||||
{activeTab === 'Invoices' && (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||
{!invoices ? (
|
||||
<ActivityIndicator color="#0891B2" style={{ marginTop: 40 }} />
|
||||
) : invoices.length === 0 ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
|
||||
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No invoices found</Text>
|
||||
</View>
|
||||
) : (
|
||||
invoices.map((inv: any) => {
|
||||
const st = INV_STATUS[inv.status] ?? { label: inv.status, color: '#6B7280', bg: '#F1F5F9' };
|
||||
const isUnpaid = ['SENT', 'PARTIAL', 'OVERDUE'].includes(inv.status) && Number(inv.balance) > 0;
|
||||
const issued = inv.createdAt ? new Date(inv.createdAt).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
return (
|
||||
<View key={inv.id} style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A' }}>{inv.invoiceNumber}</Text>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: st.bg }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '700', color: st.color }}>{st.label}</Text>
|
||||
<View key={inv.id} style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 16, marginBottom: 12, borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 10 }}>
|
||||
<View>
|
||||
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A' }}>{inv.invoiceNumber}</Text>
|
||||
<Text style={{ fontSize: 13, color: '#64748B', marginTop: 2 }}>Issued: {issued}</Text>
|
||||
{inv.dueDate && (
|
||||
<Text style={{ fontSize: 13, color: '#64748B' }}>Due: {new Date(inv.dueDate).toLocaleDateString('en-PH', { month: 'short', day: 'numeric', year: 'numeric' })}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 12, paddingVertical: 5, backgroundColor: st.bg }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '700', color: st.color }}>{st.label}</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 16, fontWeight: '800', color: '#0F172A', marginTop: 6 }}>₱{Number(inv.total).toLocaleString()}</Text>
|
||||
{isUnpaid && (
|
||||
<Text style={{ fontSize: 13, color: '#991B1B', fontWeight: '600' }}>Balance: ₱{Number(inv.balance).toLocaleString()}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 15, color: '#64748B' }}>Due: {inv.dueDate ? new Date(inv.dueDate).toLocaleDateString() : '—'}</Text>
|
||||
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>₱{Number(inv.amount ?? inv.totalAmount ?? 0).toLocaleString()}</Text>
|
||||
</View>
|
||||
{inv.balance > 0 && (
|
||||
<Text style={{ fontSize: 14, color: '#DC2626', marginTop: 6, fontWeight: '600' }}>Balance: ₱{Number(inv.balance).toLocaleString()}</Text>
|
||||
{isUnpaid && (
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: '#059669', borderRadius: 12, paddingVertical: 13, alignItems: 'center' }}
|
||||
onPress={() => setPayingInvoice(inv)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={{ color: '#FFF', fontSize: 15, fontWeight: '700' }}>+ Record Payment</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)
|
||||
)}
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* PAYMENTS */}
|
||||
{tab === 'Payments' && (
|
||||
payLoading ? (
|
||||
<View style={{ paddingVertical: 60, alignItems: 'center' }}><ActivityIndicator color="#0891B2" size="large" /></View>
|
||||
{/* ── TICKETS TAB ── */}
|
||||
{activeTab === 'Tickets' && (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: '#0891B2', borderRadius: 14, paddingVertical: 14, alignItems: 'center', marginBottom: 16 }}
|
||||
onPress={() => router.push({ pathname: '/(app)/tasks/new', params: { prefillClientId: id, prefillName: `${client?.firstName} ${client?.lastName}` } })}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={{ color: '#FFF', fontSize: 16, fontWeight: '700' }}>+ New Ticket</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{!tickets ? (
|
||||
<ActivityIndicator color="#0891B2" style={{ marginTop: 40 }} />
|
||||
) : tickets.length === 0 ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
|
||||
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No tickets for this client</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: '#059669', borderRadius: 14, paddingVertical: 16, alignItems: 'center', marginBottom: 16 }}
|
||||
onPress={() => router.push({ pathname: '/(app)/payments/record', params: { prefillClientId: id, prefillName: `${client?.firstName} ${client?.lastName}`, prefillAccountNumber: client?.accountNumber } })}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>+ Record Payment</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{!payments?.length ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 40 }}>
|
||||
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No payments recorded</Text>
|
||||
</View>
|
||||
) : (
|
||||
payments.map((p: any) => (
|
||||
<View key={p.id} style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A' }}>{p.paymentMethod}</Text>
|
||||
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 3 }}>
|
||||
{p.paymentDate ? new Date(p.paymentDate).toLocaleDateString('en-PH') : new Date(p.createdAt).toLocaleDateString('en-PH')}
|
||||
</Text>
|
||||
{p.referenceNumber && <Text style={{ fontSize: 13, color: '#94A3B8', marginTop: 2 }}>Ref: {p.referenceNumber}</Text>}
|
||||
</View>
|
||||
<Text style={{ fontSize: 18, fontWeight: '800', color: '#166534' }}>₱{Number(p.amount).toLocaleString()}</Text>
|
||||
tickets.map((t: any) => {
|
||||
const typeColor = TYPE_COLOR[t.type] ?? '#6B7280';
|
||||
const statusColor = TICKET_STATUS_COLOR[t.status] ?? '#6B7280';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={t.id}
|
||||
onPress={() => router.push(`/(app)/tasks/${t.id}`)}
|
||||
activeOpacity={0.8}
|
||||
style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 16, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9', borderLeftWidth: 4, borderLeftColor: typeColor }}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3, backgroundColor: `${typeColor}20` }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '700', color: typeColor }}>{t.type}</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 13, fontWeight: '600', color: statusColor }}>{t.status?.replace('_', ' ')}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</ScrollView>
|
||||
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A' }} numberOfLines={1}>{t.subject}</Text>
|
||||
<Text style={{ fontSize: 13, color: '#64748B', marginTop: 3 }}>
|
||||
{t.assignedTo ? `Assigned: ${t.assignedTo.firstName} ${t.assignedTo.lastName}` : 'Unassigned'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Pay Invoice Modal */}
|
||||
{payingInvoice && (
|
||||
<PayInvoiceModal
|
||||
invoice={payingInvoice}
|
||||
onClose={() => setPayingInvoice(null)}
|
||||
onSuccess={() => {
|
||||
setPayingInvoice(null);
|
||||
qc.invalidateQueries({ queryKey: ['client-invoices', id] });
|
||||
qc.invalidateQueries({ queryKey: ['unpaid-invoices'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user