feat: major UI/UX overhaul + user management + ticket detail refactor

- 5-tab navigation (Home/Clients/Collect/Tickets/Profile)
- Inline styles throughout (17px min font, SafeAreaView)
- Dashboard fixed to match real API shape
- Ticket detail: 2 tabs (Details + Comments), always-visible comment input
- Installation confirmation: GPS coordinate capture + client location update
- User management screens (Admin only): list, create, detail + role/active toggle
- Tasks folder replaces tickets folder
- Remittance detail: inline styles
- Record payment: prefill from client, live button text
- Icon component with SVG icons
- Color system: primary #0891B2
This commit is contained in:
Nemo
2026-03-24 10:37:57 +08:00
parent baed6dc8d5
commit 4644a3194d
38 changed files with 4967 additions and 2984 deletions

View File

@@ -1,25 +1,24 @@
import { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, router } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
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 STATUS_COLOR: Record<string, string> = {
ACTIVE: '#166534', SUSPENDED: '#92400E', CANCELLED: '#991B1B', PENDING: '#475569',
};
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 STATUS_BG: Record<string, string> = {
ACTIVE: '#DCFCE7', SUSPENDED: '#FEF3C7', CANCELLED: '#FEE2E2', PENDING: '#F1F5F9',
};
const PAYMENT_METHODS: Record<string, string> = {
CASH: '💵', GCASH: '📱', MAYA: '💙', BANK: '🏦',
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' },
OVERDUE: { label: 'Overdue', color: '#991B1B', bg: '#FEE2E2' },
PARTIAL: { label: 'Partial', color: '#0E7490', bg: '#CFFAFE' },
VOID: { label: 'Void', color: '#6B7280', bg: '#F1F5F9' },
};
function InfoRow({ label, value, onPress, isLast }: { label: string; value?: string | null; onPress?: () => void; isLast?: boolean }) {
@@ -27,10 +26,11 @@ function InfoRow({ label, value, onPress, isLast }: { label: string; value?: str
<TouchableOpacity
disabled={!onPress}
onPress={onPress}
className={`px-4 py-3 ${!isLast ? 'border-b border-gray-100' : ''}`}
style={{ paddingHorizontal: 20, paddingVertical: 16, borderBottomWidth: isLast ? 0 : 1, borderBottomColor: '#F1F5F9' }}
activeOpacity={onPress ? 0.7 : 1}
>
<Text className="text-gray-500 text-xs">{label}</Text>
<Text className={`font-medium mt-0.5 ${onPress ? 'text-primary' : 'text-gray-900'}`}>{value ?? '—'}</Text>
<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>
);
}
@@ -43,177 +43,180 @@ export default function ClientDetailScreen() {
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({
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>
);
if (isLoading) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
<View style={{ flex: 1, backgroundColor: '#F8FAFC', alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color="#0891B2" size="large" />
</View>
</SafeAreaView>
);
}
const statusColor = STATUS_COLOR[client?.status] ?? '#475569';
const statusBg = STATUS_BG[client?.status] ?? '#F1F5F9';
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 p-1">
<Text className="text-white text-lg"></Text>
</TouchableOpacity>
<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-xs font-medium ${tab === t ? 'text-primary' : 'text-gray-500'}`}>{t}</Text>
<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 }}>
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}> Back</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">
<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 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>
</View>
</View>
)}
</View>
{/* 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>
{/* 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>
</TouchableOpacity>
))}
</View>
<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 />
</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 />
)}
{/* 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>
</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 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>
)}
</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>
)
)}
{/* 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>
{/* 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>
</View>
) : (
invoices.map((inv: any) => {
const st = INV_STATUS[inv.status] ?? { label: inv.status, color: '#6B7280', bg: '#F1F5F9' };
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>
</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>
)}
</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>
{/* PAYMENTS */}
{tab === 'Payments' && (
payLoading ? (
<View style={{ paddingVertical: 60, alignItems: 'center' }}><ActivityIndicator color="#0891B2" size="large" /></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>
</View>
))}
</>
)
)}
</ScrollView>
</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>
</View>
</View>
))
)}
</>
)
)}
</ScrollView>
</View>
</SafeAreaView>
);
}