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,17 +1,14 @@
|
||||
import { View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||
import { View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity, Linking, Alert } from 'react-native';
|
||||
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';
|
||||
|
||||
// ─── 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 }}>
|
||||
@@ -21,11 +18,8 @@ function KpiCard({ label, value, color, bg }: { label: string; value: string | n
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ticket Row ───────────────────────────────────────────────────────────────
|
||||
function TaskRow({ task, onPress }: { task: any; onPress: () => void }) {
|
||||
function TicketRow({ task, onPress }: { task: any; onPress: () => void }) {
|
||||
const typeColor = TYPE_COLOR[task.type] ?? '#6B7280';
|
||||
const isHigh = task.priority === 'HIGH';
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
@@ -34,74 +28,126 @@ function TaskRow({ task, onPress }: { task: any; onPress: () => void }) {
|
||||
>
|
||||
<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` }}>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3, backgroundColor: `${typeColor}20` }}>
|
||||
<Text style={{ fontSize: 11, fontWeight: '700', color: typeColor }}>{task.type}</Text>
|
||||
</View>
|
||||
{isHigh && (
|
||||
{task.priority === 'HIGH' && (
|
||||
<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>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: STATUS_COLOR[task.status] ?? '#6B7280' }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: '#64748B' }}>
|
||||
{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'}
|
||||
{task.assignedTo ? ` · ${task.assignedTo.firstName}` : ' · Unassigned'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Screen ──────────────────────────────────────────────────────────────
|
||||
function InvoiceRow({ inv }: { inv: any }) {
|
||||
const dueDate = inv.dueDate ? new Date(inv.dueDate) : null;
|
||||
const today = new Date();
|
||||
const isOverdue = dueDate && dueDate < today;
|
||||
const daysLeft = dueDate ? Math.ceil((dueDate.getTime() - today.getTime()) / 86400000) : null;
|
||||
|
||||
const navigate = () => {
|
||||
const lat = inv.client?.lat;
|
||||
const lng = inv.client?.lng;
|
||||
if (!lat || !lng) {
|
||||
Alert.alert('No Location', 'This client does not have a recorded location yet.');
|
||||
return;
|
||||
}
|
||||
Alert.alert('Navigate', `Open navigation to ${inv.client?.firstName} ${inv.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' },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 16, marginBottom: 10, borderWidth: 1, borderColor: isOverdue ? '#FCA5A5' : '#F1F5F9', borderLeftWidth: 4, borderLeftColor: isOverdue ? '#DC2626' : '#F59E0B' }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A' }}>
|
||||
{inv.client?.firstName} {inv.client?.lastName}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: '#64748B', marginTop: 2 }}>{inv.invoiceNumber}</Text>
|
||||
<Text style={{ fontSize: 13, color: isOverdue ? '#DC2626' : '#D97706', fontWeight: '600', marginTop: 3 }}>
|
||||
{isOverdue
|
||||
? `Overdue by ${Math.abs(daysLeft ?? 0)} day${Math.abs(daysLeft ?? 0) !== 1 ? 's' : ''}`
|
||||
: daysLeft !== null
|
||||
? `Due in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}`
|
||||
: 'No due date'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: '800', color: '#991B1B' }}>₱{Number(inv.balance).toLocaleString()}</Text>
|
||||
<TouchableOpacity onPress={navigate} style={{ marginTop: 8, backgroundColor: '#ECFEFF', borderRadius: 10, paddingHorizontal: 10, paddingVertical: 6 }} activeOpacity={0.7}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '700', color: '#0891B2' }}>📍 Navigate</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { user } = useAuthStore();
|
||||
const role = user?.roles?.[0] ?? user?.role ?? '';
|
||||
const isAdminOrStaff = role === 'ADMIN' || role === 'STAFF';
|
||||
|
||||
const hour = new Date().getHours();
|
||||
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||
|
||||
const [summaryQ, tasksQ] = useQueries({
|
||||
const [summaryQ, ticketsQ, invoicesQ] = 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 ?? []),
|
||||
queryKey: ['dashboard-tickets'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/v1/tickets?limit=50');
|
||||
const all: any[] = res.data?.data ?? res.data ?? [];
|
||||
return all.filter((t: any) => t.status === 'OPEN' || t.status === 'IN_PROGRESS');
|
||||
},
|
||||
},
|
||||
{
|
||||
queryKey: ['dashboard-invoices'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/api/v1/invoices?limit=50');
|
||||
const all: any[] = res.data?.data ?? res.data ?? [];
|
||||
const unpaid = all.filter((inv: any) => ['SENT', 'PARTIAL', 'OVERDUE'].includes(inv.status) && Number(inv.balance) > 0);
|
||||
unpaid.sort((a: any, b: any) => {
|
||||
const da = a.dueDate ? new Date(a.dueDate).getTime() : Infinity;
|
||||
const db = b.dueDate ? new Date(b.dueDate).getTime() : Infinity;
|
||||
return da - db;
|
||||
});
|
||||
return unpaid.slice(0, 10);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const isLoading = summaryQ.isLoading || tasksQ.isLoading;
|
||||
const isRefetching = summaryQ.isRefetching || tasksQ.isRefetching;
|
||||
const summary = summaryQ.data;
|
||||
const isLoading = summaryQ.isLoading || ticketsQ.isLoading || invoicesQ.isLoading;
|
||||
const isRefetching = summaryQ.isRefetching || ticketsQ.isRefetching || invoicesQ.isRefetching;
|
||||
|
||||
// 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 summary = summaryQ.data ?? {};
|
||||
const allActiveTickets: any[] = ticketsQ.data ?? [];
|
||||
const unpaidInvoices: any[] = invoicesQ.data ?? [];
|
||||
|
||||
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 unassigned = allActiveTickets.filter((t: any) => !t.assignedToId).slice(0, 10);
|
||||
const assignedToMe = allActiveTickets.filter((t: any) => t.assignedToId === user?.id).slice(0, 10);
|
||||
const byPrio = (a: any, b: any) => (a.priority === 'HIGH' ? -1 : 1) - (b.priority === 'HIGH' ? -1 : 1);
|
||||
|
||||
const refetchAll = () => { summaryQ.refetch(); tasksQ.refetch(); };
|
||||
const refetchAll = () => { summaryQ.refetch(); ticketsQ.refetch(); invoicesQ.refetch(); };
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||
@@ -121,26 +167,27 @@ export default function DashboardScreen() {
|
||||
</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" />
|
||||
<KpiCard label="Subscribers" value={summary?.subscribers?.total ?? '—'} color="#0E7490" bg="#ECFEFF" />
|
||||
<KpiCard label="Active" value={summary?.subscribers?.active ?? '—'} 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 style={{ flexDirection: 'row', marginBottom: 16 }}>
|
||||
<KpiCard label="Unpaid Invoices" value={summary?.billing?.unpaidInvoices ?? '—'} color="#991B1B" bg="#FEF2F2" />
|
||||
<KpiCard label="Open Tickets" value={summary?.support?.openTickets ?? '—'} color="#92400E" bg="#FFFBEB" />
|
||||
</View>
|
||||
|
||||
{/* Revenue card */}
|
||||
{thisMonthRevenue !== null && (
|
||||
{/* Revenue — ADMIN/STAFF only */}
|
||||
{isAdminOrStaff && summary?.revenue?.thisMonth != 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>
|
||||
<Text style={{ fontSize: 24, fontWeight: '800', color: '#166534' }}>₱{Number(summary.revenue.thisMonth).toLocaleString()}</Text>
|
||||
</View>
|
||||
{summary?.revenue?.growth !== undefined && (
|
||||
{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>
|
||||
@@ -148,10 +195,10 @@ export default function DashboardScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Unassigned Tasks */}
|
||||
{/* ── Unassigned Tickets ── */}
|
||||
<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>
|
||||
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>Unassigned Tickets</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>
|
||||
@@ -165,26 +212,48 @@ export default function DashboardScreen() {
|
||||
|
||||
{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>
|
||||
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No unassigned tickets 🎉</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}`)} />
|
||||
{[...unassigned].sort(byPrio).map((t: any) => (
|
||||
<TicketRow key={t.id} task={t} onPress={() => router.push(`/(app)/tasks/${t.id}`)} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Assigned Tasks */}
|
||||
{assigned.length > 0 && (
|
||||
{/* ── Assigned to Me ── */}
|
||||
{assignedToMe.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}`)} />
|
||||
))}
|
||||
<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 ── */}
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text style={{ fontSize: 17, fontWeight: '800', color: '#0F172A' }}>Unpaid Invoices</Text>
|
||||
<TouchableOpacity onPress={() => router.push('/(app)/payments')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>View all</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{unpaidInvoices.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 unpaid invoices 🎉</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
{unpaidInvoices.map((inv: any) => (
|
||||
<InvoiceRow key={inv.id} inv={inv} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ height: 24 }} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user