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,141 +1,194 @@
import { View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQueries } from '@tanstack/react-query';
import { router } from 'expo-router';
import { api } from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#6B7280' };
const TICKET_STATUS_COLOR: Record<string, string> = { OPEN: '#2563EB', IN_PROGRESS: '#D97706', RESOLVED: '#16A34A', CLOSED: '#6B7280' };
// ─── Constants ────────────────────────────────────────────────────────────────
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', NORMAL: '#0891B2' };
const PRIORITY_BG: Record<string, string> = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' };
const 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' };
// ─── KPI Card ────────────────────────────────────────────────────────────────
function KpiCard({ label, value, color, bg }: { label: string; value: string | number; color: string; bg: string }) {
return (
<View style={{ flex: 1, marginHorizontal: 5, borderRadius: 16, padding: 16, backgroundColor: bg }}>
<Text style={{ fontSize: 11, fontWeight: '700', color, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</Text>
<Text style={{ fontSize: 26, fontWeight: '800', color }}>{value}</Text>
</View>
);
}
// ─── Ticket Row ───────────────────────────────────────────────────────────────
function TaskRow({ task, onPress }: { task: any; onPress: () => void }) {
const typeColor = TYPE_COLOR[task.type] ?? '#6B7280';
const isHigh = task.priority === 'HIGH';
function KpiCard({ label, value, color, onPress }: { label: string; value: string | number; color: string; onPress?: () => void }) {
return (
<TouchableOpacity
onPress={onPress}
disabled={!onPress}
className="bg-white rounded-2xl p-4 flex-1 mx-1 shadow-sm border border-gray-100"
activeOpacity={0.7}
style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 16, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9', borderLeftWidth: 4, borderLeftColor: typeColor }}
>
<Text className="text-gray-500 text-xs mb-1">{label}</Text>
<Text className="text-2xl font-bold" style={{ color }}>{value}</Text>
</TouchableOpacity>
);
}
function QuickAction({ icon, label, onPress }: { icon: string; label: string; onPress: () => void }) {
return (
<TouchableOpacity className="flex-1 items-center bg-white rounded-2xl py-4 mx-1 border border-gray-100" onPress={onPress}>
<Text className="text-2xl mb-1">{icon}</Text>
<Text className="text-xs text-gray-600 font-medium">{label}</Text>
</TouchableOpacity>
);
}
export default function DashboardScreen() {
const { user } = useAuthStore();
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['dashboard'],
queryFn: () => api.get('/api/v1/dashboard/summary').then(r => r.data),
});
const greeting = () => {
const h = new Date().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
};
return (
<ScrollView
className="flex-1 bg-gray-50"
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
>
{/* Header */}
<View className="px-4 pt-14 pb-6 bg-primary">
<Text className="text-white/70 text-sm">{greeting()},</Text>
<Text className="text-white text-2xl font-bold">{user?.firstName ?? 'Field Staff'} 👋</Text>
<Text className="text-white/50 text-xs mt-1">{new Date().toLocaleDateString('en-PH', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</Text>
</View>
{isLoading ? (
<View className="flex-1 items-center justify-center py-20">
<ActivityIndicator color="#2563EB" />
</View>
) : (
<View className="px-3 py-4">
{/* KPIs */}
<Text className="text-gray-700 font-semibold mb-3 px-1">Overview</Text>
<View className="flex-row mb-2">
<KpiCard
label="Total Clients"
value={data?.totalClients ?? 0}
color="#2563EB"
onPress={() => router.push('/(app)/clients')}
/>
<KpiCard
label="Active Subs"
value={data?.activeSubscriptions ?? 0}
color="#16A34A"
/>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<View style={{ flexDirection: 'row', gap: 6, alignItems: 'center' }}>
<View style={{ borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3, backgroundColor: `${typeColor}15` }}>
<Text style={{ fontSize: 11, fontWeight: '700', color: typeColor }}>{task.type}</Text>
</View>
<View className="flex-row mb-5">
<KpiCard
label="Overdue"
value={data?.overdueInvoices ?? 0}
color="#DC2626"
onPress={() => router.push('/(app)/clients')}
/>
<KpiCard
label="Today's Collections"
value={`${(data?.todayCollections ?? 0).toLocaleString()}`}
color="#D97706"
onPress={() => router.push('/(app)/payments')}
/>
</View>
{/* Quick Actions */}
<Text className="text-gray-700 font-semibold mb-3 px-1">Quick Actions</Text>
<View className="flex-row mb-5">
<QuickAction icon="💰" label="Collect" onPress={() => router.push('/(app)/payments/record')} />
<QuickAction icon="🎫" label="New Ticket" onPress={() => router.push('/(app)/tickets/create')} />
<QuickAction icon="🔌" label="Install" onPress={() => router.push('/(app)/installations')} />
<QuickAction icon="📋" label="Remit" onPress={() => router.push('/(app)/remittances/submit')} />
</View>
{/* Recent Tickets */}
<Text className="text-gray-700 font-semibold mb-3 px-1">Recent Tickets</Text>
{(data?.recentTickets ?? []).length === 0 ? (
<View className="bg-white rounded-2xl p-6 items-center border border-gray-100">
<Text className="text-gray-400 mb-3">No recent tickets</Text>
<TouchableOpacity
className="bg-primary rounded-xl px-5 py-2"
onPress={() => router.push('/(app)/tickets/create')}
>
<Text className="text-white text-sm font-semibold">Create Ticket</Text>
</TouchableOpacity>
{isHigh && (
<View style={{ borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3, backgroundColor: PRIORITY_BG.HIGH }}>
<Text style={{ fontSize: 11, fontWeight: '700', color: PRIORITY_COLOR.HIGH }}>HIGH</Text>
</View>
) : (
(data?.recentTickets ?? []).map((t: any) => (
<TouchableOpacity
key={t.id}
className="bg-white rounded-xl p-4 mb-2 border border-gray-100"
onPress={() => router.push(`/(app)/tickets/${t.id}`)}
>
<View className="flex-row justify-between items-start mb-1">
<Text className="font-medium text-gray-900 flex-1 mr-2" numberOfLines={1}>{t.subject}</Text>
<View className="rounded-full px-2 py-0.5" style={{ backgroundColor: `${PRIORITY_COLOR[t.priority] ?? '#6B7280'}20` }}>
<Text className="text-xs font-medium" style={{ color: PRIORITY_COLOR[t.priority] ?? '#6B7280' }}>{t.priority}</Text>
</View>
</View>
<View className="flex-row justify-between items-center mt-0.5">
<Text className="text-gray-500 text-sm">{t.clientName ?? 'No client'}</Text>
<View className="rounded-full px-2 py-0.5" style={{ backgroundColor: `${TICKET_STATUS_COLOR[t.status] ?? '#6B7280'}20` }}>
<Text className="text-xs" style={{ color: TICKET_STATUS_COLOR[t.status] ?? '#6B7280' }}>{t.status}</Text>
</View>
</View>
</TouchableOpacity>
))
)}
</View>
)}
</ScrollView>
<Text style={{ fontSize: 12, fontWeight: '600', color: STATUS_COLOR[task.status] ?? '#6B7280' }}>
{task.status?.replace('_', ' ')}
</Text>
</View>
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A' }} numberOfLines={1}>{task.subject}</Text>
<Text style={{ fontSize: 13, color: '#64748B', marginTop: 3 }}>
{task.client?.firstName} {task.client?.lastName}
{task.assignedTo
? ` · ${task.assignedTo.firstName} ${task.assignedTo.lastName}`
: ' · Unassigned'}
</Text>
</TouchableOpacity>
);
}
// ─── Main Screen ──────────────────────────────────────────────────────────────
export default function DashboardScreen() {
const { user } = useAuthStore();
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
const [summaryQ, tasksQ] = useQueries({
queries: [
{
queryKey: ['dashboard'],
queryFn: () => api.get('/api/v1/dashboard/summary').then(r => r.data),
},
{
queryKey: ['dashboard-tasks'],
queryFn: () =>
api.get('/api/v1/tickets?status=OPEN&status=IN_PROGRESS&limit=20')
.then(r => r.data?.data ?? r.data ?? []),
},
],
});
const isLoading = summaryQ.isLoading || tasksQ.isLoading;
const isRefetching = summaryQ.isRefetching || tasksQ.isRefetching;
const summary = summaryQ.data;
// Real dashboard API shape:
// { subscribers: { total, active, pending, suspended },
// billing: { unpaidInvoices, overdueInvoices },
// support: { openTickets, inProgressTickets },
// tasks: { pending },
// revenue: { thisMonth, lastMonth, growth } }
const totalClients = summary?.subscribers?.total ?? '—';
const activeSubscribers = summary?.subscribers?.active ?? '—';
const unpaidInvoices = summary?.billing?.unpaidInvoices ?? '—';
const openTickets = summary?.support?.openTickets ?? '—';
const thisMonthRevenue = summary?.revenue?.thisMonth ?? null;
const allTasks: any[] = tasksQ.data ?? [];
const unassigned = allTasks.filter((t: any) => !t.assignedToId);
const assigned = allTasks.filter((t: any) => !!t.assignedToId);
const prioOrder: Record<string, number> = { HIGH: 0, NORMAL: 1 };
const byPrio = (a: any, b: any) => (prioOrder[a.priority] ?? 2) - (prioOrder[b.priority] ?? 2);
const refetchAll = () => { summaryQ.refetch(); tasksQ.refetch(); };
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
<ScrollView
style={{ flex: 1, backgroundColor: '#F8FAFC' }}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetchAll} tintColor="#0891B2" />}
>
{/* Header */}
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 24 }}>
<Text style={{ color: '#A5F3FC', fontSize: 15, fontWeight: '500' }}>{greeting},</Text>
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800', marginTop: 2 }}>{user?.firstName ?? 'Field Staff'}</Text>
</View>
{isLoading ? (
<View style={{ paddingVertical: 80, alignItems: 'center' }}>
<ActivityIndicator color="#0891B2" size="large" />
</View>
) : (
<View style={{ padding: 16 }}>
{/* KPI Row 1 */}
<View style={{ flexDirection: 'row', marginBottom: 10 }}>
<KpiCard label="Subscribers" value={totalClients} color="#0E7490" bg="#ECFEFF" />
<KpiCard label="Active" value={activeSubscribers} color="#166534" bg="#F0FDF4" />
</View>
{/* KPI Row 2 */}
<View style={{ flexDirection: 'row', marginBottom: 10 }}>
<KpiCard label="Unpaid Invoices" value={unpaidInvoices} color="#991B1B" bg="#FEF2F2" />
<KpiCard label="Open Tasks" value={openTickets} color="#92400E" bg="#FFFBEB" />
</View>
{/* Revenue card */}
{thisMonthRevenue !== null && (
<View style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#F1F5F9', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View>
<Text style={{ fontSize: 12, fontWeight: '700', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 4 }}>This Month's Revenue</Text>
<Text style={{ fontSize: 24, fontWeight: '800', color: '#166534' }}>₱{Number(thisMonthRevenue).toLocaleString()}</Text>
</View>
{summary?.revenue?.growth !== undefined && (
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 12, paddingHorizontal: 12, paddingVertical: 6 }}>
<Text style={{ fontSize: 15, fontWeight: '800', color: '#16A34A' }}>+{summary.revenue.growth}%</Text>
</View>
)}
</View>
)}
{/* Unassigned Tasks */}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>Unassigned</Text>
{unassigned.length > 0 && (
<View style={{ backgroundColor: '#FEE2E2', borderRadius: 20, paddingHorizontal: 8, paddingVertical: 2, marginLeft: 8 }}>
<Text style={{ fontSize: 12, fontWeight: '700', color: '#DC2626' }}>{unassigned.length}</Text>
</View>
)}
</View>
<TouchableOpacity onPress={() => router.push('/(app)/tasks')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>View all</Text>
</TouchableOpacity>
</View>
{unassigned.length === 0 ? (
<View style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 20, alignItems: 'center', marginBottom: 20, borderWidth: 1, borderColor: '#F1F5F9' }}>
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No unassigned tasks</Text>
</View>
) : (
<View style={{ marginBottom: 20 }}>
{[...unassigned].sort(byPrio).slice(0, 5).map((t: any) => (
<TaskRow key={t.id} task={t} onPress={() => router.push(`/(app)/tasks/${t.id}`)} />
))}
</View>
)}
{/* Assigned Tasks */}
{assigned.length > 0 && (
<>
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A', marginBottom: 10 }}>Assigned Tasks</Text>
{[...assigned].sort(byPrio).slice(0, 5).map((t: any) => (
<TaskRow key={t.id} task={t} onPress={() => router.push(`/(app)/tasks/${t.id}`)} />
))}
</>
)}
<View style={{ height: 24 }} />
</View>
)}
</ScrollView>
</SafeAreaView>
);
}