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:
@@ -6,6 +6,35 @@ import { api } from '../../../services/api';
|
||||
|
||||
const TABS = ['Profile', 'Subscription', 'Invoices', 'Payments'];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: '#16A34A', SUSPENDED: '#D97706', CANCELLED: '#DC2626', PENDING: '#6B7280',
|
||||
};
|
||||
|
||||
const INV_STATUS: Record<string, { label: string; color: string }> = {
|
||||
PAID: { label: 'Paid', color: '#16A34A' },
|
||||
UNPAID: { label: 'Unpaid', color: '#D97706' },
|
||||
OVERDUE: { label: 'Overdue', color: '#DC2626' },
|
||||
PARTIAL: { label: 'Partial', color: '#2563EB' },
|
||||
VOID: { label: 'Void', color: '#6B7280' },
|
||||
};
|
||||
|
||||
const PAYMENT_METHODS: Record<string, string> = {
|
||||
CASH: '💵', GCASH: '📱', MAYA: '💙', BANK: '🏦',
|
||||
};
|
||||
|
||||
function InfoRow({ label, value, onPress, isLast }: { label: string; value?: string | null; onPress?: () => void; isLast?: boolean }) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!onPress}
|
||||
onPress={onPress}
|
||||
className={`px-4 py-3 ${!isLast ? 'border-b border-gray-100' : ''}`}
|
||||
>
|
||||
<Text className="text-gray-500 text-xs">{label}</Text>
|
||||
<Text className={`font-medium mt-0.5 ${onPress ? 'text-primary' : 'text-gray-900'}`}>{value ?? '—'}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const [tab, setTab] = useState('Profile');
|
||||
@@ -15,54 +44,175 @@ export default function ClientDetailScreen() {
|
||||
queryFn: () => api.get(`/api/v1/clients/${id}`).then(r => r.data),
|
||||
});
|
||||
|
||||
if (isLoading) return <View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>;
|
||||
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({
|
||||
queryKey: ['client-invoices', id],
|
||||
queryFn: () => api.get(`/api/v1/invoices?clientId=${id}&limit=20`).then(r => r.data?.data ?? r.data ?? []),
|
||||
enabled: tab === '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',
|
||||
});
|
||||
|
||||
if (isLoading) return (
|
||||
<View className="flex-1 items-center justify-center bg-gray-50">
|
||||
<ActivityIndicator color="#2563EB" />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
{/* Header */}
|
||||
<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>
|
||||
<View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-bold text-lg">{client?.firstName} {client?.lastName}</Text>
|
||||
<Text className="text-white/70 text-sm">{client?.accountNumber}</Text>
|
||||
</View>
|
||||
<View className="rounded-full px-3 py-1" style={{ backgroundColor: `${STATUS_COLORS[client?.status] ?? '#6B7280'}30` }}>
|
||||
<Text className="text-xs font-semibold" style={{ color: STATUS_COLORS[client?.status] ?? '#fff' }}>
|
||||
{client?.status}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tabs */}
|
||||
<View className="flex-row bg-white border-b border-gray-100">
|
||||
{TABS.map(t => (
|
||||
<TouchableOpacity key={t} onPress={() => setTab(t)} className={`flex-1 py-3 items-center border-b-2 ${tab === t ? 'border-primary' : 'border-transparent'}`}>
|
||||
<Text className={`text-sm font-medium ${tab === t ? 'text-primary' : 'text-gray-500'}`}>{t}</Text>
|
||||
<TouchableOpacity
|
||||
key={t}
|
||||
onPress={() => setTab(t)}
|
||||
className={`flex-1 py-3 items-center border-b-2 ${tab === t ? 'border-primary' : 'border-transparent'}`}
|
||||
>
|
||||
<Text className={`text-xs font-medium ${tab === t ? 'text-primary' : 'text-gray-500'}`}>{t}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<ScrollView className="flex-1 px-4 py-4">
|
||||
{/* PROFILE TAB */}
|
||||
{tab === 'Profile' && (
|
||||
<View className="bg-white rounded-2xl border border-gray-100">
|
||||
{[
|
||||
{ label: 'Account #', value: client?.accountNumber },
|
||||
{ label: 'Status', value: client?.status },
|
||||
{ label: 'Email', value: client?.email },
|
||||
{ label: 'Phone', value: client?.phone, onPress: () => client?.phone && Linking.openURL(`tel:${client.phone}`) },
|
||||
{ label: 'Address', value: client?.address },
|
||||
{ label: 'Area', value: client?.area?.name },
|
||||
].map((item, i) => (
|
||||
<TouchableOpacity
|
||||
key={item.label}
|
||||
disabled={!item.onPress}
|
||||
onPress={item.onPress}
|
||||
className={`px-4 py-3 ${i > 0 ? 'border-t border-gray-100' : ''}`}
|
||||
>
|
||||
<Text className="text-gray-500 text-xs">{item.label}</Text>
|
||||
<Text className={`font-medium mt-0.5 ${item.onPress ? 'text-primary' : 'text-gray-900'}`}>{item.value ?? '—'}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<InfoRow label="Account #" 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="Joined" value={client?.createdAt ? new Date(client.createdAt).toLocaleDateString() : undefined} isLast />
|
||||
</View>
|
||||
)}
|
||||
{tab === 'Subscription' && <Text className="text-gray-500 text-center py-10">Subscription details coming soon</Text>}
|
||||
{tab === 'Invoices' && <Text className="text-gray-500 text-center py-10">Invoices coming soon</Text>}
|
||||
{tab === 'Payments' && <Text className="text-gray-500 text-center py-10">Payments coming soon</Text>}
|
||||
|
||||
{/* SUBSCRIPTION TAB */}
|
||||
{tab === 'Subscription' && (
|
||||
subLoading ? (
|
||||
<View className="py-16 items-center"><ActivityIndicator color="#2563EB" /></View>
|
||||
) : !subData ? (
|
||||
<View className="items-center py-16">
|
||||
<Text className="text-4xl mb-3">📡</Text>
|
||||
<Text className="text-gray-400">No active subscription</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<View className="bg-white rounded-2xl border border-gray-100 mb-3">
|
||||
<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="Started" value={subData.startDate ? new Date(subData.startDate).toLocaleDateString() : undefined} />
|
||||
<InfoRow label="Billing Cycle" value={subData.billingCycleDay ? `Day ${subData.billingCycleDay} of month` : undefined} isLast />
|
||||
</View>
|
||||
{subData.nextBillingDate && (
|
||||
<View className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<Text className="text-blue-700 text-sm">
|
||||
📅 Next billing: <Text className="font-bold">{new Date(subData.nextBillingDate).toLocaleDateString()}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* INVOICES TAB */}
|
||||
{tab === 'Invoices' && (
|
||||
invLoading ? (
|
||||
<View className="py-16 items-center"><ActivityIndicator color="#2563EB" /></View>
|
||||
) : !invoices?.length ? (
|
||||
<View className="items-center py-16">
|
||||
<Text className="text-4xl mb-3">🧾</Text>
|
||||
<Text className="text-gray-400">No invoices yet</Text>
|
||||
</View>
|
||||
) : (
|
||||
invoices.map((inv: any) => {
|
||||
const st = INV_STATUS[inv.status] ?? { label: inv.status, color: '#6B7280' };
|
||||
return (
|
||||
<View key={inv.id} className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
|
||||
<View className="flex-row justify-between items-start mb-1">
|
||||
<Text className="font-semibold text-gray-900">{inv.invoiceNumber}</Text>
|
||||
<View className="rounded-full px-2 py-0.5" style={{ backgroundColor: `${st.color}20` }}>
|
||||
<Text className="text-xs font-medium" style={{ color: st.color }}>{st.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="text-gray-500 text-sm">{inv.dueDate ? new Date(inv.dueDate).toLocaleDateString() : '—'}</Text>
|
||||
<Text className="font-bold text-gray-900">₱{Number(inv.amount ?? inv.totalAmount).toLocaleString()}</Text>
|
||||
</View>
|
||||
{inv.balance > 0 && (
|
||||
<Text className="text-red-500 text-xs mt-1">Balance: ₱{Number(inv.balance).toLocaleString()}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)
|
||||
)}
|
||||
|
||||
{/* PAYMENTS TAB */}
|
||||
{tab === 'Payments' && (
|
||||
payLoading ? (
|
||||
<View className="py-16 items-center"><ActivityIndicator color="#2563EB" /></View>
|
||||
) : !payments?.length ? (
|
||||
<View className="items-center py-16">
|
||||
<Text className="text-4xl mb-3">💳</Text>
|
||||
<Text className="text-gray-400">No payments recorded</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
className="bg-primary rounded-xl py-3 items-center mb-4"
|
||||
onPress={() => router.push({ pathname: '/(app)/payments/record', params: { prefillClientId: id, prefillName: `${client?.firstName} ${client?.lastName}`, prefillAccountNumber: client?.accountNumber } })}
|
||||
>
|
||||
<Text className="text-white font-semibold">+ Record Payment</Text>
|
||||
</TouchableOpacity>
|
||||
{payments.map((p: any) => (
|
||||
<View key={p.id} className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View>
|
||||
<Text className="font-semibold text-gray-900">
|
||||
{PAYMENT_METHODS[p.paymentMethod] ?? '💳'} {p.paymentMethod}
|
||||
</Text>
|
||||
<Text className="text-gray-500 text-sm mt-0.5">
|
||||
{p.paymentDate ? new Date(p.paymentDate).toLocaleDateString() : new Date(p.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
{p.referenceNumber && (
|
||||
<Text className="text-gray-400 text-xs mt-0.5">Ref: {p.referenceNumber}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text className="font-bold text-green-700 text-base">₱{Number(p.amount).toLocaleString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user