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:
@@ -1,44 +1,51 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import { Text } from 'react-native';
|
import { Icon } from '../../components/Icon';
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarActiveTintColor: '#2563EB',
|
tabBarActiveTintColor: '#0891B2',
|
||||||
tabBarInactiveTintColor: '#6B7280',
|
tabBarInactiveTintColor: '#94A3B8',
|
||||||
tabBarStyle: { paddingBottom: 4 },
|
tabBarStyle: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopColor: '#F1F5F9',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
height: 64,
|
||||||
|
},
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="dashboard"
|
name="dashboard"
|
||||||
options={{ title: 'Home', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>🏠</Text> }}
|
options={{ title: 'Home', tabBarIcon: ({ color }) => <Icon name="home" size={22} color={color} /> }}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="clients"
|
name="clients"
|
||||||
options={{ title: 'Clients', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>👥</Text> }}
|
options={{ title: 'Clients', tabBarIcon: ({ color }) => <Icon name="users" size={22} color={color} /> }}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="payments"
|
name="payments"
|
||||||
options={{ title: 'Payments', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>💰</Text> }}
|
options={{ title: 'Collect', tabBarIcon: ({ color }) => <Icon name="collect" size={22} color={color} /> }}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="remittances"
|
name="tasks"
|
||||||
options={{ title: 'Remit', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>📋</Text> }}
|
options={{ title: 'Tickets', tabBarIcon: ({ color }) => <Icon name="ticket" size={22} color={color} /> }}
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="tickets"
|
|
||||||
options={{ title: 'Tickets', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>🎫</Text> }}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="installations"
|
|
||||||
options={{ title: 'Install', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>🔌</Text> }}
|
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="profile"
|
name="profile"
|
||||||
options={{ title: 'Profile', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>👤</Text> }}
|
options={{ title: 'Profile', tabBarIcon: ({ color }) => <Icon name="user" size={22} color={color} /> }}
|
||||||
/>
|
/>
|
||||||
|
{/* Hidden — accessed programmatically */}
|
||||||
|
<Tabs.Screen name="remittances" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="users" options={{ href: null }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking } from 'react-native';
|
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 { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../../../services/api';
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
const TABS = ['Profile', 'Subscription', 'Invoices', 'Payments'];
|
const TABS = ['Profile', 'Subscription', 'Invoices', 'Payments'];
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
ACTIVE: '#16A34A', SUSPENDED: '#D97706', CANCELLED: '#DC2626', PENDING: '#6B7280',
|
ACTIVE: '#166534', SUSPENDED: '#92400E', CANCELLED: '#991B1B', PENDING: '#475569',
|
||||||
};
|
};
|
||||||
|
const STATUS_BG: Record<string, string> = {
|
||||||
const INV_STATUS: Record<string, { label: string; color: string }> = {
|
ACTIVE: '#DCFCE7', SUSPENDED: '#FEF3C7', CANCELLED: '#FEE2E2', PENDING: '#F1F5F9',
|
||||||
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 INV_STATUS: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
const PAYMENT_METHODS: Record<string, string> = {
|
PAID: { label: 'Paid', color: '#166534', bg: '#DCFCE7' },
|
||||||
CASH: '💵', GCASH: '📱', MAYA: '💙', BANK: '🏦',
|
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 }) {
|
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
|
<TouchableOpacity
|
||||||
disabled={!onPress}
|
disabled={!onPress}
|
||||||
onPress={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 style={{ fontSize: 12, fontWeight: '600', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 4 }}>{label}</Text>
|
||||||
<Text className={`font-medium mt-0.5 ${onPress ? 'text-primary' : 'text-gray-900'}`}>{value ?? '—'}</Text>
|
<Text style={{ fontSize: 17, fontWeight: '500', color: onPress ? '#0891B2' : '#0F172A' }}>{value ?? '—'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,177 +43,180 @@ export default function ClientDetailScreen() {
|
|||||||
queryKey: ['client', id],
|
queryKey: ['client', id],
|
||||||
queryFn: () => api.get(`/api/v1/clients/${id}`).then(r => r.data),
|
queryFn: () => api.get(`/api/v1/clients/${id}`).then(r => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: subData, isLoading: subLoading } = useQuery({
|
const { data: subData, isLoading: subLoading } = useQuery({
|
||||||
queryKey: ['client-subscription', id],
|
queryKey: ['client-subscription', id],
|
||||||
queryFn: () => api.get(`/api/v1/subscriptions?clientId=${id}&limit=1`).then(r => r.data?.data?.[0] ?? r.data?.[0] ?? null),
|
queryFn: () => api.get(`/api/v1/subscriptions?clientId=${id}&limit=1`).then(r => r.data?.data?.[0] ?? r.data?.[0] ?? null),
|
||||||
enabled: tab === 'Subscription',
|
enabled: tab === 'Subscription',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: invoices, isLoading: invLoading } = useQuery({
|
const { data: invoices, isLoading: invLoading } = useQuery({
|
||||||
queryKey: ['client-invoices', id],
|
queryKey: ['client-invoices', id],
|
||||||
queryFn: () => api.get(`/api/v1/invoices?clientId=${id}&limit=20`).then(r => r.data?.data ?? r.data ?? []),
|
queryFn: () => api.get(`/api/v1/invoices?clientId=${id}&limit=20`).then(r => r.data?.data ?? r.data ?? []),
|
||||||
enabled: tab === 'Invoices',
|
enabled: tab === 'Invoices',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: payments, isLoading: payLoading } = useQuery({
|
const { data: payments, isLoading: payLoading } = useQuery({
|
||||||
queryKey: ['client-payments', id],
|
queryKey: ['client-payments', id],
|
||||||
queryFn: () => api.get(`/api/v1/payments?clientId=${id}&limit=20`).then(r => r.data?.data ?? r.data ?? []),
|
queryFn: () => api.get(`/api/v1/payments?clientId=${id}&limit=20`).then(r => r.data?.data ?? r.data ?? []),
|
||||||
enabled: tab === 'Payments',
|
enabled: tab === 'Payments',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return (
|
if (isLoading) {
|
||||||
<View className="flex-1 items-center justify-center bg-gray-50">
|
return (
|
||||||
<ActivityIndicator color="#2563EB" />
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
</View>
|
<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 (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
{/* Header */}
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
{/* Header */}
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3 p-1">
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 20 }}>
|
||||||
<Text className="text-white text-lg">←</Text>
|
<TouchableOpacity onPress={() => router.back()} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }} activeOpacity={0.7} hitSlop={{ top: 10, bottom: 10, left: 0, right: 20 }}>
|
||||||
</TouchableOpacity>
|
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||||
<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>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
</View>
|
<View style={{ flex: 1, marginRight: 12 }}>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 22, fontWeight: '800' }}>{client?.firstName} {client?.lastName}</Text>
|
||||||
<ScrollView className="flex-1 px-4 py-4">
|
<Text style={{ color: '#A5F3FC', fontSize: 15, marginTop: 3 }}>{client?.accountNumber}</Text>
|
||||||
{/* PROFILE TAB */}
|
</View>
|
||||||
{tab === 'Profile' && (
|
<View style={{ borderRadius: 20, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: statusBg }}>
|
||||||
<View className="bg-white rounded-2xl border border-gray-100">
|
<Text style={{ fontSize: 13, fontWeight: '700', color: statusColor }}>{client?.status}</Text>
|
||||||
<InfoRow label="Account #" value={client?.accountNumber} />
|
</View>
|
||||||
<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>
|
</View>
|
||||||
)}
|
</View>
|
||||||
|
|
||||||
{/* SUBSCRIPTION TAB */}
|
{/* Tabs */}
|
||||||
{tab === 'Subscription' && (
|
<View style={{ flexDirection: 'row', backgroundColor: '#FFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||||
subLoading ? (
|
{TABS.map(t => (
|
||||||
<View className="py-16 items-center"><ActivityIndicator color="#2563EB" /></View>
|
<TouchableOpacity
|
||||||
) : !subData ? (
|
key={t}
|
||||||
<View className="items-center py-16">
|
onPress={() => setTab(t)}
|
||||||
<Text className="text-4xl mb-3">📡</Text>
|
style={{ flex: 1, paddingVertical: 14, alignItems: 'center', borderBottomWidth: 2.5, borderBottomColor: tab === t ? '#0891B2' : 'transparent' }}
|
||||||
<Text className="text-gray-400">No active subscription</Text>
|
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>
|
|
||||||
<View className="bg-white rounded-2xl border border-gray-100 mb-3">
|
{/* SUBSCRIPTION */}
|
||||||
<InfoRow label="Plan" value={subData.plan?.name} />
|
{tab === 'Subscription' && (
|
||||||
<InfoRow label="Speed" value={subData.plan?.speedMbps ? `${subData.plan.speedMbps} Mbps` : undefined} />
|
subLoading ? (
|
||||||
<InfoRow label="Monthly Rate" value={subData.plan?.price ? `₱${Number(subData.plan.price).toLocaleString()}` : undefined} />
|
<View style={{ paddingVertical: 60, alignItems: 'center' }}><ActivityIndicator color="#0891B2" size="large" /></View>
|
||||||
<InfoRow label="Status" value={subData.status} />
|
) : !subData ? (
|
||||||
<InfoRow label="Started" value={subData.startDate ? new Date(subData.startDate).toLocaleDateString() : undefined} />
|
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||||||
<InfoRow label="Billing Cycle" value={subData.billingCycleDay ? `Day ${subData.billingCycleDay} of month` : undefined} isLast />
|
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No active subscription</Text>
|
||||||
</View>
|
</View>
|
||||||
{subData.nextBillingDate && (
|
) : (
|
||||||
<View className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
<View>
|
||||||
<Text className="text-blue-700 text-sm">
|
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', marginBottom: 12 }}>
|
||||||
📅 Next billing: <Text className="font-bold">{new Date(subData.nextBillingDate).toLocaleDateString()}</Text>
|
<InfoRow label="Plan" value={subData.plan?.name} />
|
||||||
</Text>
|
<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>
|
<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 */}
|
{/* INVOICES */}
|
||||||
{tab === 'Invoices' && (
|
{tab === 'Invoices' && (
|
||||||
invLoading ? (
|
invLoading ? (
|
||||||
<View className="py-16 items-center"><ActivityIndicator color="#2563EB" /></View>
|
<View style={{ paddingVertical: 60, alignItems: 'center' }}><ActivityIndicator color="#0891B2" size="large" /></View>
|
||||||
) : !invoices?.length ? (
|
) : !invoices?.length ? (
|
||||||
<View className="items-center py-16">
|
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||||||
<Text className="text-4xl mb-3">🧾</Text>
|
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No invoices yet</Text>
|
||||||
<Text className="text-gray-400">No invoices yet</Text>
|
</View>
|
||||||
</View>
|
) : (
|
||||||
) : (
|
invoices.map((inv: any) => {
|
||||||
invoices.map((inv: any) => {
|
const st = INV_STATUS[inv.status] ?? { label: inv.status, color: '#6B7280', bg: '#F1F5F9' };
|
||||||
const st = INV_STATUS[inv.status] ?? { label: inv.status, color: '#6B7280' };
|
return (
|
||||||
return (
|
<View key={inv.id} style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 10, borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||||
<View key={inv.id} className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
<View className="flex-row justify-between items-start mb-1">
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A' }}>{inv.invoiceNumber}</Text>
|
||||||
<Text className="font-semibold text-gray-900">{inv.invoiceNumber}</Text>
|
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: st.bg }}>
|
||||||
<View className="rounded-full px-2 py-0.5" style={{ backgroundColor: `${st.color}20` }}>
|
<Text style={{ fontSize: 12, fontWeight: '700', color: st.color }}>{st.label}</Text>
|
||||||
<Text className="text-xs font-medium" style={{ color: st.color }}>{st.label}</Text>
|
</View>
|
||||||
</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>
|
||||||
<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 */}
|
{/* PAYMENTS */}
|
||||||
{tab === 'Payments' && (
|
{tab === 'Payments' && (
|
||||||
payLoading ? (
|
payLoading ? (
|
||||||
<View className="py-16 items-center"><ActivityIndicator color="#2563EB" /></View>
|
<View style={{ paddingVertical: 60, alignItems: 'center' }}><ActivityIndicator color="#0891B2" size="large" /></View>
|
||||||
) : !payments?.length ? (
|
) : (
|
||||||
<View className="items-center py-16">
|
<>
|
||||||
<Text className="text-4xl mb-3">💳</Text>
|
<TouchableOpacity
|
||||||
<Text className="text-gray-400">No payments recorded</Text>
|
style={{ backgroundColor: '#059669', borderRadius: 14, paddingVertical: 16, alignItems: 'center', marginBottom: 16 }}
|
||||||
</View>
|
onPress={() => router.push({ pathname: '/(app)/payments/record', params: { prefillClientId: id, prefillName: `${client?.firstName} ${client?.lastName}`, prefillAccountNumber: client?.accountNumber } })}
|
||||||
) : (
|
activeOpacity={0.8}
|
||||||
<>
|
>
|
||||||
<TouchableOpacity
|
<Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>+ Record Payment</Text>
|
||||||
className="bg-primary rounded-xl py-3 items-center mb-4"
|
</TouchableOpacity>
|
||||||
onPress={() => router.push({ pathname: '/(app)/payments/record', params: { prefillClientId: id, prefillName: `${client?.firstName} ${client?.lastName}`, prefillAccountNumber: client?.accountNumber } })}
|
|
||||||
>
|
{!payments?.length ? (
|
||||||
<Text className="text-white font-semibold">+ Record Payment</Text>
|
<View style={{ alignItems: 'center', paddingVertical: 40 }}>
|
||||||
</TouchableOpacity>
|
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No payments recorded</Text>
|
||||||
{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>
|
||||||
</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 }}>
|
||||||
</ScrollView>
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A' }}>{p.paymentMethod}</Text>
|
||||||
</View>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
4
app/(app)/clients/_layout.tsx
Normal file
4
app/(app)/clients/_layout.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
export default function ClientsLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native';
|
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { api } from '../../../services/api';
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
ACTIVE: '#16A34A', SUSPENDED: '#D97706', CANCELLED: '#DC2626', PENDING: '#6B7280',
|
ACTIVE: { label: 'Active', color: '#166534', bg: '#DCFCE7' },
|
||||||
|
SUSPENDED: { label: 'Suspended', color: '#92400E', bg: '#FEF3C7' },
|
||||||
|
CANCELLED: { label: 'Cancelled', color: '#991B1B', bg: '#FEE2E2' },
|
||||||
|
PENDING: { label: 'Pending', color: '#475569', bg: '#F1F5F9' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClientsScreen() {
|
export default function ClientsScreen() {
|
||||||
@@ -20,54 +24,73 @@ export default function ClientsScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary">
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
<Text className="text-white text-xl font-bold">Clients</Text>
|
{/* Header */}
|
||||||
</View>
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 20 }}>
|
||||||
<View className="px-4 py-3">
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Clients</Text>
|
||||||
<TextInput
|
<Text style={{ color: '#A5F3FC', fontSize: 15, marginTop: 2 }}>{data?.length ?? 0} subscribers</Text>
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base"
|
|
||||||
placeholder="Search name or account #"
|
|
||||||
value={search}
|
|
||||||
onChangeText={setSearch}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{isLoading ? (
|
|
||||||
<View className="flex-1 items-center justify-center">
|
|
||||||
<ActivityIndicator color="#2563EB" />
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
|
||||||
<FlatList
|
{/* Search */}
|
||||||
data={clients}
|
<View style={{ backgroundColor: '#FFF', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||||
keyExtractor={(item) => item.id}
|
<View style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14 }}>
|
||||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
<TextInput
|
||||||
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 24 }}
|
style={{ flex: 1, paddingVertical: 13, fontSize: 16, color: '#0F172A' }}
|
||||||
renderItem={({ item }) => (
|
placeholder="Search by name or account #"
|
||||||
<TouchableOpacity
|
placeholderTextColor="#94A3B8"
|
||||||
className="bg-white rounded-xl p-4 mb-2 border border-gray-100"
|
value={search}
|
||||||
onPress={() => router.push(`/(app)/clients/${item.id}`)}
|
onChangeText={setSearch}
|
||||||
>
|
/>
|
||||||
<View className="flex-row justify-between items-start">
|
{search.length > 0 && (
|
||||||
<View className="flex-1">
|
<TouchableOpacity onPress={() => setSearch('')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
<Text className="font-semibold text-gray-900">{item.firstName} {item.lastName}</Text>
|
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Text className="text-gray-500 text-sm">{item.accountNumber}</Text>
|
<Text style={{ color: '#FFF', fontSize: 12, fontWeight: '800', lineHeight: 14 }}>×</Text>
|
||||||
{item.phone && <Text className="text-gray-500 text-sm">{item.phone}</Text>}
|
|
||||||
</View>
|
|
||||||
<View className="rounded-full px-2 py-0.5" style={{ backgroundColor: `${STATUS_COLORS[item.status] ?? '#6B7280'}20` }}>
|
|
||||||
<Text className="text-xs font-medium" style={{ color: STATUS_COLORS[item.status] ?? '#6B7280' }}>
|
|
||||||
{item.status}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ActivityIndicator color="#0891B2" size="large" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={clients}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#0891B2" />}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const st = STATUS_CONFIG[item.status] ?? { label: item.status, color: '#475569', bg: '#F1F5F9' };
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 12, borderWidth: 1, borderColor: '#F1F5F9' }}
|
||||||
|
onPress={() => router.push(`/(app)/clients/${item.id}`)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<View style={{ flex: 1, marginRight: 12 }}>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '700', color: '#0F172A' }}>{item.firstName} {item.lastName}</Text>
|
||||||
|
<Text style={{ fontSize: 15, color: '#64748B', marginTop: 3 }}>{item.accountNumber}</Text>
|
||||||
|
{item.phone && <Text style={{ fontSize: 15, color: '#94A3B8', marginTop: 2 }}>{item.phone}</Text>}
|
||||||
|
</View>
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 12, paddingVertical: 5, backgroundColor: st.bg }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: st.color }}>{st.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||||||
|
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No clients found</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
}
|
||||||
)}
|
/>
|
||||||
ListEmptyComponent={
|
)}
|
||||||
<View className="items-center py-16">
|
</View>
|
||||||
<Text className="text-gray-400 text-base">No clients found</Text>
|
</SafeAreaView>
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,194 @@
|
|||||||
import { View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity } from 'react-native';
|
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 { router } from 'expo-router';
|
||||||
import { api } from '../../services/api';
|
import { api } from '../../services/api';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#6B7280' };
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
const TICKET_STATUS_COLOR: Record<string, string> = { OPEN: '#2563EB', IN_PROGRESS: '#D97706', RESOLVED: '#16A34A', CLOSED: '#6B7280' };
|
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 (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={!onPress}
|
activeOpacity={0.7}
|
||||||
className="bg-white rounded-2xl p-4 flex-1 mx-1 shadow-sm border border-gray-100"
|
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>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
<Text className="text-2xl font-bold" style={{ color }}>{value}</Text>
|
<View style={{ flexDirection: 'row', gap: 6, alignItems: 'center' }}>
|
||||||
</TouchableOpacity>
|
<View style={{ borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3, backgroundColor: `${typeColor}15` }}>
|
||||||
);
|
<Text style={{ fontSize: 11, fontWeight: '700', color: typeColor }}>{task.type}</Text>
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</View>
|
||||||
<View className="flex-row mb-5">
|
{isHigh && (
|
||||||
<KpiCard
|
<View style={{ borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3, backgroundColor: PRIORITY_BG.HIGH }}>
|
||||||
label="Overdue"
|
<Text style={{ fontSize: 11, fontWeight: '700', color: PRIORITY_COLOR.HIGH }}>HIGH</Text>
|
||||||
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>
|
|
||||||
</View>
|
</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>
|
</View>
|
||||||
)}
|
<Text style={{ fontSize: 12, fontWeight: '600', color: STATUS_COLOR[task.status] ?? '#6B7280' }}>
|
||||||
</ScrollView>
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { View, Text, TouchableOpacity, ScrollView, Alert, Image, ActivityIndicator } from 'react-native';
|
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
|
||||||
import * as Location from 'expo-location';
|
|
||||||
import { api } from '../../../services/api';
|
|
||||||
|
|
||||||
export default function InstallationConfirmScreen() {
|
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
|
||||||
const [photo, setPhoto] = useState<string | null>(null);
|
|
||||||
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [gpsLoading, setGpsLoading] = useState(false);
|
|
||||||
|
|
||||||
const capturePhoto = async () => {
|
|
||||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
|
||||||
if (status !== 'granted') return Alert.alert('Permission denied', 'Camera access is required.');
|
|
||||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.7, base64: false });
|
|
||||||
if (!result.canceled) setPhoto(result.assets[0].uri);
|
|
||||||
};
|
|
||||||
|
|
||||||
const captureGPS = async () => {
|
|
||||||
setGpsLoading(true);
|
|
||||||
try {
|
|
||||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
|
||||||
if (status !== 'granted') { Alert.alert('Permission denied', 'Location access is required.'); return; }
|
|
||||||
const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High });
|
|
||||||
setCoords({ lat: loc.coords.latitude, lng: loc.coords.longitude });
|
|
||||||
} catch { Alert.alert('Error', 'Could not get location.'); }
|
|
||||||
finally { setGpsLoading(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirm = async () => {
|
|
||||||
if (!coords) return Alert.alert('Required', 'Capture GPS location first.');
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await api.patch(`/api/v1/tickets/${id}/confirm-installation`, {
|
|
||||||
latitude: coords.lat,
|
|
||||||
longitude: coords.lng,
|
|
||||||
photoUrl: photo,
|
|
||||||
});
|
|
||||||
Alert.alert('Done!', 'Installation confirmed.', [{ text: 'OK', onPress: () => router.back() }]);
|
|
||||||
} catch (e: any) {
|
|
||||||
Alert.alert('Error', e?.response?.data?.message ?? 'Confirmation failed.');
|
|
||||||
} finally { setLoading(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex-1 bg-gray-50">
|
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
|
||||||
<Text className="text-white text-lg">←</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="text-white text-xl font-bold">Installation Confirmation</Text>
|
|
||||||
</View>
|
|
||||||
<ScrollView className="flex-1 px-4 py-6">
|
|
||||||
<View className="bg-white rounded-2xl border border-gray-100 p-4 mb-4">
|
|
||||||
<Text className="font-semibold text-gray-700 mb-3">📍 GPS Location</Text>
|
|
||||||
{coords ? (
|
|
||||||
<Text className="text-green-700 font-medium">✓ {coords.lat.toFixed(6)}, {coords.lng.toFixed(6)}</Text>
|
|
||||||
) : (
|
|
||||||
<Text className="text-gray-400 mb-3">No location captured yet</Text>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity className="bg-primary rounded-xl py-3 items-center mt-3" onPress={captureGPS} disabled={gpsLoading}>
|
|
||||||
{gpsLoading ? <ActivityIndicator color="white" /> : <Text className="text-white font-semibold">Capture GPS</Text>}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="bg-white rounded-2xl border border-gray-100 p-4 mb-8">
|
|
||||||
<Text className="font-semibold text-gray-700 mb-3">📷 Photo Proof</Text>
|
|
||||||
{photo && <Image source={{ uri: photo }} className="w-full h-48 rounded-xl mb-3" resizeMode="cover" />}
|
|
||||||
<TouchableOpacity className="border border-primary rounded-xl py-3 items-center" onPress={capturePhoto}>
|
|
||||||
<Text className="text-primary font-semibold">{photo ? 'Retake Photo' : 'Take Photo'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity className="bg-green-600 rounded-xl py-4 items-center" onPress={confirm} disabled={loading}>
|
|
||||||
{loading ? <ActivityIndicator color="white" /> : <Text className="text-white font-bold text-base">✓ Confirm Installation</Text>}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import { api } from '../../../services/api';
|
|
||||||
|
|
||||||
// Installations are tickets of type INSTALLATION (or filtered by subject prefix)
|
|
||||||
// We query tickets with type=INSTALLATION if the API supports it, fallback to all open tickets
|
|
||||||
async function fetchInstallations() {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/api/v1/tickets?type=INSTALLATION&limit=50');
|
|
||||||
return res.data?.data ?? res.data ?? [];
|
|
||||||
} catch {
|
|
||||||
// Fallback: all OPEN tickets
|
|
||||||
const res = await api.get('/api/v1/tickets?status=OPEN&limit=50');
|
|
||||||
return res.data?.data ?? res.data ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_STYLE: Record<string, { bg: string; text: string }> = {
|
|
||||||
OPEN: { bg: '#EFF6FF', text: '#2563EB' },
|
|
||||||
IN_PROGRESS: { bg: '#FFFBEB', text: '#D97706' },
|
|
||||||
RESOLVED: { bg: '#F0FDF4', text: '#16A34A' },
|
|
||||||
CLOSED: { bg: '#F3F4F6', text: '#6B7280' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function InstallationsScreen() {
|
|
||||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
|
||||||
queryKey: ['installations'],
|
|
||||||
queryFn: fetchInstallations,
|
|
||||||
});
|
|
||||||
|
|
||||||
const installations: any[] = data ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex-1 bg-gray-50">
|
|
||||||
{/* Header */}
|
|
||||||
<View className="px-4 pt-14 pb-4 bg-blue-600">
|
|
||||||
<Text className="text-white text-xl font-bold">Installations</Text>
|
|
||||||
<Text className="text-white/70 text-xs mt-0.5">
|
|
||||||
{installations.length} pending
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<View className="flex-1 items-center justify-center">
|
|
||||||
<ActivityIndicator color="#2563EB" />
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={installations}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
|
||||||
contentContainerStyle={{ padding: 16 }}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
const statusStyle = STATUS_STYLE[item.status] ?? { bg: '#F3F4F6', text: '#6B7280' };
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
className="bg-white rounded-xl p-4 mb-3 border border-gray-100"
|
|
||||||
onPress={() => router.push(`/(app)/installations/${item.id}`)}
|
|
||||||
>
|
|
||||||
<View className="flex-row justify-between items-start mb-2">
|
|
||||||
<Text className="font-semibold text-gray-900 flex-1 mr-2" numberOfLines={2}>
|
|
||||||
{item.subject}
|
|
||||||
</Text>
|
|
||||||
<View className="rounded-full px-2.5 py-1" style={{ backgroundColor: statusStyle.bg }}>
|
|
||||||
<Text className="text-xs font-semibold" style={{ color: statusStyle.text }}>
|
|
||||||
{item.status?.replace('_', ' ')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-row items-center">
|
|
||||||
<Text className="text-gray-500 text-sm flex-1">
|
|
||||||
{item.client?.firstName} {item.client?.lastName}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-gray-400 text-xs">
|
|
||||||
{item.createdAt ? new Date(item.createdAt).toLocaleDateString() : ''}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{item.client?.address && (
|
|
||||||
<Text className="text-gray-400 text-xs mt-1 ml-0" numberOfLines={1}>
|
|
||||||
📍 {item.client.address}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirm button if not yet resolved */}
|
|
||||||
{item.status !== 'RESOLVED' && item.status !== 'CLOSED' && (
|
|
||||||
<TouchableOpacity
|
|
||||||
className="mt-3 bg-green-600 rounded-xl py-2.5 items-center"
|
|
||||||
onPress={() => router.push(`/(app)/installations/${item.id}`)}
|
|
||||||
>
|
|
||||||
<Text className="text-white font-semibold text-sm">📷 Confirm Installation</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className="items-center py-16">
|
|
||||||
<Text className="text-5xl mb-4">🔌</Text>
|
|
||||||
<Text className="text-gray-700 font-semibold text-base">No installations pending</Text>
|
|
||||||
<Text className="text-gray-400 text-sm mt-1">All caught up!</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
4
app/(app)/payments/_layout.tsx
Normal file
4
app/(app)/payments/_layout.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
export default function PaymentsLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
@@ -1,87 +1,48 @@
|
|||||||
import { useState } from 'react';
|
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||||
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { api } from '../../../services/api';
|
|
||||||
|
|
||||||
const METHOD_ICON: Record<string, string> = { CASH: '💵', GCASH: '📱', MAYA: '💙', BANK: '🏦' };
|
|
||||||
|
|
||||||
export default function PaymentsScreen() {
|
|
||||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
|
||||||
queryKey: ['payments'],
|
|
||||||
queryFn: () => api.get('/api/v1/payments?limit=50').then(r => r.data?.data ?? r.data ?? []),
|
|
||||||
});
|
|
||||||
|
|
||||||
const payments: any[] = data ?? [];
|
|
||||||
|
|
||||||
// Calculate today's total
|
|
||||||
const today = new Date().toDateString();
|
|
||||||
const todayTotal = payments
|
|
||||||
.filter((p: any) => new Date(p.paymentDate ?? p.createdAt).toDateString() === today)
|
|
||||||
.reduce((sum: number, p: any) => sum + Number(p.amount), 0);
|
|
||||||
|
|
||||||
|
export default function CollectScreen() {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
{/* Header */}
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row justify-between items-center">
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 24 }}>
|
||||||
<View>
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Collect</Text>
|
||||||
<Text className="text-white text-xl font-bold">Payments</Text>
|
<Text style={{ color: '#A5F3FC', fontSize: 15, marginTop: 2 }}>Payments & remittances</Text>
|
||||||
<Text className="text-white/70 text-xs mt-0.5">Today: ₱{todayTotal.toLocaleString()}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
|
||||||
className="bg-white/20 rounded-xl px-4 py-2"
|
|
||||||
onPress={() => router.push('/(app)/payments/record')}
|
|
||||||
>
|
|
||||||
<Text className="text-white font-semibold text-sm">+ Record</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isLoading ? (
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}>
|
||||||
<View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>
|
<TouchableOpacity
|
||||||
) : (
|
style={{ backgroundColor: '#ECFEFF', borderRadius: 20, padding: 20, marginBottom: 14, borderWidth: 1.5, borderColor: '#67E8F9', flexDirection: 'row', alignItems: 'center' }}
|
||||||
<FlatList
|
onPress={() => router.push('/(app)/payments/record')}
|
||||||
data={payments}
|
activeOpacity={0.7}
|
||||||
keyExtractor={(item) => item.id}
|
>
|
||||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
<View style={{ width: 56, height: 56, borderRadius: 16, backgroundColor: '#0891B2', alignItems: 'center', justifyContent: 'center', marginRight: 16 }}>
|
||||||
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
<Text style={{ fontSize: 26 }}>💳</Text>
|
||||||
ListEmptyComponent={
|
|
||||||
<View className="items-center py-20">
|
|
||||||
<Text className="text-4xl mb-3">💳</Text>
|
|
||||||
<Text className="text-gray-400 text-base">No payments yet</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="mt-4 bg-primary rounded-xl px-6 py-3"
|
|
||||||
onPress={() => router.push('/(app)/payments/record')}
|
|
||||||
>
|
|
||||||
<Text className="text-white font-semibold">Record First Payment</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
<View style={{ flex: 1 }}>
|
||||||
renderItem={({ item }) => (
|
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0F172A' }}>Record Payment</Text>
|
||||||
<View className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
|
<Text style={{ fontSize: 15, color: '#64748B', marginTop: 3 }}>Cash, GCash, Maya, or bank</Text>
|
||||||
<View className="flex-row justify-between items-start">
|
|
||||||
<View className="flex-1">
|
|
||||||
<View className="flex-row items-center mb-1">
|
|
||||||
<Text className="text-base mr-2">{METHOD_ICON[item.paymentMethod] ?? '💳'}</Text>
|
|
||||||
<Text className="font-semibold text-gray-900">
|
|
||||||
{item.client?.firstName} {item.client?.lastName}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-gray-500 text-sm">{item.client?.accountNumber}</Text>
|
|
||||||
<Text className="text-gray-400 text-xs mt-1">
|
|
||||||
{new Date(item.paymentDate ?? item.createdAt).toLocaleDateString()} · {item.paymentMethod}
|
|
||||||
</Text>
|
|
||||||
{item.referenceNumber && (
|
|
||||||
<Text className="text-gray-400 text-xs">Ref: {item.referenceNumber}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Text className="font-bold text-green-700 text-lg">
|
|
||||||
₱{Number(item.amount).toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
<Text style={{ fontSize: 22, color: '#94A3B8' }}>›</Text>
|
||||||
/>
|
</TouchableOpacity>
|
||||||
)}
|
|
||||||
</View>
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#F0FDF4', borderRadius: 20, padding: 20, borderWidth: 1.5, borderColor: '#86EFAC', flexDirection: 'row', alignItems: 'center' }}
|
||||||
|
onPress={() => router.push('/(app)/remittances')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={{ width: 56, height: 56, borderRadius: 16, backgroundColor: '#059669', alignItems: 'center', justifyContent: 'center', marginRight: 16 }}>
|
||||||
|
<Text style={{ fontSize: 26 }}>📋</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0F172A' }}>Remittances</Text>
|
||||||
|
<Text style={{ fontSize: 15, color: '#64748B', marginTop: 3 }}>Submit & track daily collections</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 22, color: '#94A3B8' }}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,189 +1,211 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, FlatList, Modal } from 'react-native';
|
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../../../services/api';
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
const METHODS = ['CASH', 'GCASH', 'MAYA', 'BANK'];
|
const METHODS = [
|
||||||
|
{ id: 'CASH', label: 'Cash' },
|
||||||
|
{ id: 'GCASH', label: 'GCash' },
|
||||||
|
{ id: 'MAYA', label: 'Maya' },
|
||||||
|
{ id: 'BANK', label: 'Bank Transfer' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function RecordPaymentScreen() {
|
export default function RecordPaymentScreen() {
|
||||||
const params = useLocalSearchParams<{ prefillClientId?: string; prefillName?: string; prefillAccountNumber?: string }>();
|
// Prefill params when navigated from client detail
|
||||||
const qc = useQueryClient();
|
const params = useLocalSearchParams<{
|
||||||
|
prefillClientId?: string;
|
||||||
|
prefillName?: string;
|
||||||
|
prefillAccountNumber?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [client, setClient] = useState<any>(null);
|
||||||
const [client, setClient] = useState<any>(
|
const [amount, setAmount] = useState('');
|
||||||
params.prefillClientId
|
const [method, setMethod] = useState('CASH');
|
||||||
? { id: params.prefillClientId, firstName: params.prefillName?.split(' ')[0], lastName: params.prefillName?.split(' ').slice(1).join(' '), accountNumber: params.prefillAccountNumber }
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
const [amount, setAmount] = useState('');
|
|
||||||
const [method, setMethod] = useState('CASH');
|
|
||||||
const [reference, setReference] = useState('');
|
const [reference, setReference] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [loading, setLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
||||||
// Debounced client search
|
// Auto-fill client if navigated from client detail
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
if (params.prefillClientId && params.prefillName) {
|
||||||
return () => clearTimeout(t);
|
setClient({
|
||||||
}, [search]);
|
id: params.prefillClientId,
|
||||||
|
firstName: params.prefillName.split(' ')[0] ?? '',
|
||||||
|
lastName: params.prefillName.split(' ').slice(1).join(' ') ?? '',
|
||||||
|
accountNumber: params.prefillAccountNumber ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: searchResults, isFetching: searching } = useQuery({
|
const searchClient = async () => {
|
||||||
queryKey: ['client-search', debouncedSearch],
|
if (!search.trim()) return;
|
||||||
queryFn: () => api.get(`/api/v1/clients?search=${debouncedSearch}&limit=8`).then(r => r.data?.data ?? r.data ?? []),
|
setSearching(true);
|
||||||
enabled: debouncedSearch.trim().length >= 2,
|
try {
|
||||||
});
|
const res = await api.get(`/api/v1/clients?search=${encodeURIComponent(search.trim())}&limit=5`);
|
||||||
|
const found = res.data?.data ?? res.data ?? [];
|
||||||
|
if (Array.isArray(found) && found.length === 1) {
|
||||||
|
setClient(found[0]);
|
||||||
|
} else if (Array.isArray(found) && found.length > 1) {
|
||||||
|
// Show picker if multiple results
|
||||||
|
Alert.alert(
|
||||||
|
'Multiple clients found',
|
||||||
|
found.map((c: any, i: number) => `${i + 1}. ${c.firstName} ${c.lastName} (${c.accountNumber})`).join('\n'),
|
||||||
|
[
|
||||||
|
...found.slice(0, 5).map((c: any, i: number) => ({
|
||||||
|
text: `${i + 1}. ${c.firstName} ${c.lastName}`,
|
||||||
|
onPress: () => setClient(c),
|
||||||
|
})),
|
||||||
|
{ text: 'Cancel', style: 'cancel' as const },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Not Found', 'No client found. Try account number or full name.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Error', 'Search failed. Please try again.');
|
||||||
|
} finally { setSearching(false); }
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!client) return Alert.alert('Required', 'Select a client first.');
|
if (!client) return Alert.alert('Required', 'Search and select a client first.');
|
||||||
if (!amount || isNaN(Number(amount)) || Number(amount) <= 0)
|
const amt = Number(amount);
|
||||||
return Alert.alert('Required', 'Enter a valid amount.');
|
if (!amount || isNaN(amt) || amt <= 0) return Alert.alert('Required', 'Enter a valid amount.');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/api/v1/payments', {
|
await api.post('/api/v1/payments', {
|
||||||
clientId: client.id,
|
clientId: client.id,
|
||||||
amount: Number(amount),
|
amount: amt,
|
||||||
paymentMethod: method,
|
channel: method, // API uses `channel` not `paymentMethod`
|
||||||
referenceNumber: reference || undefined,
|
referenceNumber: reference.trim() || undefined,
|
||||||
notes: notes || undefined,
|
paymentDate: new Date().toISOString(),
|
||||||
paymentDate: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
// Invalidate relevant queries
|
Alert.alert('Payment Recorded!', `₱${amt.toLocaleString()} from ${client.firstName} ${client.lastName}`, [
|
||||||
qc.invalidateQueries({ queryKey: ['payments'] });
|
{ text: 'Record Another', onPress: () => { setClient(null); setAmount(''); setSearch(''); setReference(''); } },
|
||||||
qc.invalidateQueries({ queryKey: ['client-payments', client.id] });
|
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard'] });
|
|
||||||
Alert.alert('✅ Payment Recorded', `₱${Number(amount).toLocaleString()} from ${client.firstName} ${client.lastName}`, [
|
|
||||||
{ text: 'Done', onPress: () => router.back() },
|
{ text: 'Done', onPress: () => router.back() },
|
||||||
{ text: 'Record Another', onPress: () => { setClient(null); setAmount(''); setReference(''); setNotes(''); setSearch(''); } },
|
|
||||||
]);
|
]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Error', e?.response?.data?.message ?? 'Payment failed. Try again.');
|
const msg = e?.response?.data?.message;
|
||||||
} finally {
|
Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg ?? 'Payment failed.');
|
||||||
setLoading(false);
|
} finally { setLoading(false); }
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canSubmit = !!client && !!amount && Number(amount) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3 p-1">
|
{/* Header */}
|
||||||
<Text className="text-white text-lg">←</Text>
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 20 }}>
|
||||||
</TouchableOpacity>
|
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||||||
<Text className="text-white text-xl font-bold">Record Payment</Text>
|
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Record Payment</Text>
|
||||||
<ScrollView className="flex-1 px-4 py-4" keyboardShouldPersistTaps="handled">
|
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>Field collection</Text>
|
||||||
{/* Client selector */}
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Client *</Text>
|
|
||||||
{client ? (
|
|
||||||
<View className="flex-row items-center bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="font-bold text-blue-900">{client.firstName} {client.lastName}</Text>
|
|
||||||
<Text className="text-blue-600 text-sm">{client.accountNumber}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity onPress={() => { setClient(null); setSearch(''); }} className="p-2">
|
|
||||||
<Text className="text-blue-500 font-semibold">Change</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex-row items-center bg-white border border-gray-200 rounded-xl px-4 mb-1">
|
|
||||||
<TextInput
|
|
||||||
className="flex-1 py-3 text-base"
|
|
||||||
placeholder="Search by name or account #"
|
|
||||||
value={search}
|
|
||||||
onChangeText={setSearch}
|
|
||||||
autoCapitalize="none"
|
|
||||||
/>
|
|
||||||
{searching && <ActivityIndicator size="small" color="#2563EB" />}
|
|
||||||
</View>
|
|
||||||
{debouncedSearch.trim().length >= 2 && (
|
|
||||||
<View className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
|
||||||
{(searchResults ?? []).length === 0 && !searching && (
|
|
||||||
<Text className="px-4 py-3 text-gray-400">No clients found</Text>
|
|
||||||
)}
|
|
||||||
{(searchResults ?? []).map((c: any) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={c.id}
|
|
||||||
className="px-4 py-3 border-b border-gray-100"
|
|
||||||
onPress={() => { setClient(c); setSearch(''); }}
|
|
||||||
>
|
|
||||||
<Text className="font-medium text-gray-900">{c.firstName} {c.lastName}</Text>
|
|
||||||
<Text className="text-gray-500 text-sm">{c.accountNumber}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Amount */}
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Amount (₱) *</Text>
|
|
||||||
<TextInput
|
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-4"
|
|
||||||
placeholder="0.00"
|
|
||||||
value={amount}
|
|
||||||
onChangeText={setAmount}
|
|
||||||
keyboardType="decimal-pad"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Payment method */}
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Payment Method *</Text>
|
|
||||||
<View className="flex-row flex-wrap mb-4">
|
|
||||||
{METHODS.map(m => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={m}
|
|
||||||
onPress={() => setMethod(m)}
|
|
||||||
className={`rounded-xl px-5 py-2.5 mr-2 mb-2 border ${method === m ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}
|
|
||||||
>
|
|
||||||
<Text className={method === m ? 'text-white font-semibold' : 'text-gray-700'}>{m}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Reference (for non-cash) */}
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
|
||||||
{method !== 'CASH' && (
|
{/* Client section */}
|
||||||
<>
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Client</Text>
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Reference # *</Text>
|
|
||||||
<TextInput
|
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-4"
|
|
||||||
placeholder={`${method} transaction reference`}
|
|
||||||
value={reference}
|
|
||||||
onChangeText={setReference}
|
|
||||||
autoCapitalize="none"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes */}
|
{client ? (
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Notes (optional)</Text>
|
<View style={{ backgroundColor: '#ECFEFF', borderRadius: 16, padding: 18, marginBottom: 20, borderWidth: 1.5, borderColor: '#A5F3FC' }}>
|
||||||
<TextInput
|
<Text style={{ fontSize: 18, fontWeight: '800', color: '#0E7490' }}>{client.firstName} {client.lastName}</Text>
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-8"
|
<Text style={{ fontSize: 15, color: '#0891B2', marginTop: 3 }}>{client.accountNumber}</Text>
|
||||||
placeholder="Any remarks..."
|
<TouchableOpacity onPress={() => { setClient(null); setSearch(''); }} style={{ marginTop: 10 }} hitSlop={{ top: 8, bottom: 8, left: 0, right: 8 }}>
|
||||||
value={notes}
|
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>× Change client</Text>
|
||||||
onChangeText={setNotes}
|
</TouchableOpacity>
|
||||||
multiline
|
</View>
|
||||||
numberOfLines={2}
|
) : (
|
||||||
/>
|
<View style={{ marginBottom: 20 }}>
|
||||||
|
<View style={{ flexDirection: 'row' }}>
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, marginRight: 10 }}>
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1, paddingVertical: 14, fontSize: 16, color: '#0F172A' }}
|
||||||
|
placeholder="Account # or name"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
onSubmitEditing={searchClient}
|
||||||
|
returnKeyType="search"
|
||||||
|
/>
|
||||||
|
{search.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearch('')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 12, fontWeight: '800', lineHeight: 14 }}>×</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#0891B2', borderRadius: 14, paddingHorizontal: 18, alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onPress={searchClient}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{searching
|
||||||
|
? <ActivityIndicator color="#FFF" size="small" />
|
||||||
|
: <Text style={{ color: '#FFF', fontWeight: '700', fontSize: 15 }}>Find</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 13, color: '#94A3B8', marginTop: 8 }}>Search by account number, first name, or last name</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Amount */}
|
||||||
<TouchableOpacity
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Amount (₱)</Text>
|
||||||
className={`rounded-xl py-4 items-center ${client && amount ? 'bg-primary' : 'bg-gray-300'}`}
|
<TextInput
|
||||||
onPress={submit}
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 16, fontSize: 32, fontWeight: '800', color: '#0F172A', marginBottom: 20, textAlign: 'center' }}
|
||||||
disabled={loading || !client || !amount}
|
placeholder="0.00"
|
||||||
>
|
placeholderTextColor="#CBD5E1"
|
||||||
{loading
|
value={amount}
|
||||||
? <ActivityIndicator color="white" />
|
onChangeText={setAmount}
|
||||||
: <Text className="text-white font-bold text-base">
|
keyboardType="decimal-pad"
|
||||||
Submit Payment {amount ? `· ₱${Number(amount || 0).toLocaleString()}` : ''}
|
/>
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<View className="h-8" />
|
{/* Payment method */}
|
||||||
</ScrollView>
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Payment Method</Text>
|
||||||
</View>
|
<View style={{ flexDirection: 'row', marginBottom: 20 }}>
|
||||||
|
{METHODS.map(m => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={m.id}
|
||||||
|
onPress={() => setMethod(m.id)}
|
||||||
|
style={{ flex: 1, borderRadius: 14, paddingVertical: 14, alignItems: 'center', marginHorizontal: 4, backgroundColor: method === m.id ? '#0891B2' : '#FFF', borderWidth: 1.5, borderColor: method === m.id ? '#0891B2' : '#E2E8F0' }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: method === m.id ? '#FFF' : '#64748B' }}>{m.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Reference */}
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>
|
||||||
|
Reference # <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 28 }}
|
||||||
|
placeholder="GCash ref, receipt #, OR number..."
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={reference}
|
||||||
|
onChangeText={setReference}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: canSubmit ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||||||
|
onPress={submit}
|
||||||
|
disabled={loading || !canSubmit}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator color="#FFF" />
|
||||||
|
: <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>
|
||||||
|
{canSubmit ? `Record ₱${Number(amount || 0).toLocaleString()} Payment` : 'Record Payment'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,81 @@
|
|||||||
import { View, Text, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
import { View, Text, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const { user, logout, tenantSlug } = useAuthStore();
|
const { user, logout, tenantSlug } = useAuthStore();
|
||||||
|
const initials = `${user?.firstName?.[0] ?? ''}${user?.lastName?.[0] ?? ''}`.toUpperCase() || 'U';
|
||||||
|
const fullName = `${user?.firstName ?? ''} ${user?.lastName ?? ''}`.trim();
|
||||||
|
const role = user?.roles?.[0] ?? user?.role ?? 'Staff';
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
|
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
{
|
{ text: 'Sign Out', style: 'destructive', onPress: async () => { await logout(); router.replace('/(auth)/company-code'); } },
|
||||||
text: 'Sign Out', style: 'destructive',
|
|
||||||
onPress: async () => {
|
|
||||||
await logout();
|
|
||||||
router.replace('/(auth)/company-code');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-gray-50">
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
<View className="px-4 pt-14 pb-6 bg-primary">
|
<ScrollView style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
<View className="w-16 h-16 rounded-full bg-white/20 items-center justify-center mb-3">
|
{/* Header */}
|
||||||
<Text className="text-white text-2xl font-bold">
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 32, alignItems: 'center' }}>
|
||||||
{user?.firstName?.[0]?.toUpperCase() ?? 'U'}
|
<View style={{ width: 80, height: 80, borderRadius: 40, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center', marginBottom: 12 }}>
|
||||||
</Text>
|
<Text style={{ color: '#FFF', fontSize: 32, fontWeight: '800' }}>{initials}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-white text-xl font-bold">{user?.firstName} {user?.lastName}</Text>
|
<Text style={{ color: '#FFF', fontSize: 24, fontWeight: '800' }}>{fullName}</Text>
|
||||||
<Text className="text-white/70 text-sm">{user?.role} · {tenantSlug}</Text>
|
<View style={{ backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 5, marginTop: 8 }}>
|
||||||
</View>
|
<Text style={{ color: '#E0F2FE', fontSize: 14, fontWeight: '600' }}>{role}</Text>
|
||||||
|
</View>
|
||||||
<View className="px-4 py-6">
|
|
||||||
<View className="bg-white rounded-2xl border border-gray-100 mb-4">
|
|
||||||
{[
|
|
||||||
{ label: 'Username', value: user?.username },
|
|
||||||
{ label: 'Email', value: user?.email },
|
|
||||||
{ label: 'Role', value: user?.role },
|
|
||||||
{ label: 'Company', value: tenantSlug },
|
|
||||||
].map((item, i) => (
|
|
||||||
<View key={item.label} className={`px-4 py-3 ${i > 0 ? 'border-t border-gray-100' : ''}`}>
|
|
||||||
<Text className="text-gray-500 text-xs mb-0.5">{item.label}</Text>
|
|
||||||
<Text className="text-gray-900 font-medium">{item.value ?? '—'}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<View style={{ padding: 16 }}>
|
||||||
className="bg-red-50 border border-red-200 rounded-2xl py-4 items-center"
|
{/* Info */}
|
||||||
onPress={handleLogout}
|
<View style={{ backgroundColor: '#FFF', borderRadius: 20, marginBottom: 16, borderWidth: 1, borderColor: '#F1F5F9', overflow: 'hidden' }}>
|
||||||
>
|
{[
|
||||||
<Text className="text-red-600 font-semibold">Sign Out</Text>
|
{ label: 'Email', value: user?.email },
|
||||||
</TouchableOpacity>
|
{ label: 'Company', value: tenantSlug },
|
||||||
</View>
|
{ label: 'Role', value: role },
|
||||||
</ScrollView>
|
].map((row, i, arr) => (
|
||||||
|
<View key={row.label} style={{ paddingHorizontal: 20, paddingVertical: 16, borderBottomWidth: i < arr.length - 1 ? 1 : 0, borderBottomColor: '#F1F5F9' }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '600', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 4 }}>{row.label}</Text>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '600', color: '#0F172A' }}>{row.value ?? '—'}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* User Management — admin only */}
|
||||||
|
{(user?.roles?.includes('ADMIN') || user?.role === 'ADMIN' || role === 'ADMIN') && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', paddingHorizontal: 20, paddingVertical: 18, marginBottom: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}
|
||||||
|
onPress={() => router.push('/(app)/users')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '700', color: '#0F172A' }}>User Management</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 2 }}>Add & manage team members</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 20, color: '#94A3B8' }}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* App version */}
|
||||||
|
<View style={{ backgroundColor: '#FFF', borderRadius: 20, marginBottom: 16, borderWidth: 1, borderColor: '#F1F5F9', paddingHorizontal: 20, paddingVertical: 16 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '600', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 4 }}>App</Text>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '600', color: '#0F172A' }}>FiberOps Mobile v1.0.0</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sign out */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#FFF', borderRadius: 20, paddingVertical: 18, alignItems: 'center', borderWidth: 1.5, borderColor: '#FECACA' }}
|
||||||
|
onPress={handleLogout}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#DC2626', fontSize: 17, fontWeight: '700' }}>Sign Out</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator } from 'react-native';
|
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../../../services/api';
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = { PENDING: '#D97706', CONFIRMED: '#16A34A', DISPUTED: '#DC2626' };
|
const STATUS_CONFIG: Record<string, { color: string; bg: string }> = {
|
||||||
const METHOD_ICON: Record<string, string> = { CASH: '💵', GCASH: '📱', MAYA: '💙', BANK: '🏦' };
|
PENDING: { color: '#92400E', bg: '#FEF3C7' },
|
||||||
|
CONFIRMED: { color: '#166534', bg: '#DCFCE7' },
|
||||||
|
DISPUTED: { color: '#991B1B', bg: '#FEE2E2' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function InfoRow({ label, value, isLast }: { label: string; value?: string | null; isLast?: boolean }) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<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: '#0F172A' }}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RemittanceDetailScreen() {
|
export default function RemittanceDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
@@ -14,76 +28,97 @@ export default function RemittanceDetailScreen() {
|
|||||||
queryFn: () => api.get(`/api/v1/remittances/${id}`).then(r => r.data),
|
queryFn: () => api.get(`/api/v1/remittances/${id}`).then(r => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return (
|
if (isLoading) {
|
||||||
<View className="flex-1 items-center justify-center bg-gray-50">
|
return (
|
||||||
<ActivityIndicator color="#2563EB" />
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
</View>
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
);
|
<ActivityIndicator color="#0891B2" size="large" />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const status = data?.status ?? 'PENDING';
|
const status = data?.status ?? 'PENDING';
|
||||||
const statusColor = STATUS_COLOR[status] ?? '#6B7280';
|
const st = STATUS_CONFIG[status] ?? { color: '#6B7280', bg: '#F1F5F9' };
|
||||||
|
const payments: any[] = data?.payments ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3 p-1">
|
{/* Header */}
|
||||||
<Text className="text-white text-lg">←</Text>
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 20 }}>
|
||||||
</TouchableOpacity>
|
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||||||
<View className="flex-1">
|
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||||
<Text className="text-white font-bold text-lg">Remittance</Text>
|
</TouchableOpacity>
|
||||||
<Text className="text-white/70 text-xs">{data?.createdAt ? new Date(data.createdAt).toLocaleDateString() : ''}</Text>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
</View>
|
<View>
|
||||||
<View className="rounded-full px-3 py-1" style={{ backgroundColor: `${statusColor}30` }}>
|
<Text style={{ color: '#FFF', fontSize: 22, fontWeight: '800' }}>Remittance</Text>
|
||||||
<Text className="text-xs font-bold" style={{ color: statusColor }}>{status}</Text>
|
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>
|
||||||
</View>
|
{data?.createdAt ? new Date(data.createdAt).toLocaleDateString('en-PH', { year: 'numeric', month: 'long', day: 'numeric' }) : ''}
|
||||||
</View>
|
</Text>
|
||||||
|
|
||||||
<ScrollView className="flex-1 px-4 py-4">
|
|
||||||
{/* Summary card */}
|
|
||||||
<View className="bg-white rounded-2xl border border-gray-100 p-5 mb-4 items-center">
|
|
||||||
<Text className="text-gray-500 text-sm mb-1">Total Amount</Text>
|
|
||||||
<Text className="text-4xl font-bold text-gray-900">₱{Number(data?.totalAmount ?? 0).toLocaleString()}</Text>
|
|
||||||
{data?.notes && <Text className="text-gray-500 text-sm mt-3 text-center">{data.notes}</Text>}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Details */}
|
|
||||||
<View className="bg-white rounded-2xl border border-gray-100 mb-4">
|
|
||||||
{[
|
|
||||||
{ label: 'Submitted by', value: data?.collector?.firstName ? `${data.collector.firstName} ${data.collector.lastName}` : undefined },
|
|
||||||
{ label: 'Submitted on', value: data?.createdAt ? new Date(data.createdAt).toLocaleString() : undefined },
|
|
||||||
{ label: 'Confirmed on', value: data?.confirmedAt ? new Date(data.confirmedAt).toLocaleString() : undefined },
|
|
||||||
{ label: 'Confirmed by', value: data?.confirmedBy?.firstName ? `${data.confirmedBy.firstName} ${data.confirmedBy.lastName}` : undefined },
|
|
||||||
].filter(r => r.value).map((row, i, arr) => (
|
|
||||||
<View key={row.label} className={`px-4 py-3 ${i < arr.length - 1 ? 'border-b border-gray-100' : ''}`}>
|
|
||||||
<Text className="text-gray-500 text-xs">{row.label}</Text>
|
|
||||||
<Text className="text-gray-900 font-medium mt-0.5">{row.value}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
<View style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: st.bg }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: st.color }}>{status}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Included payments */}
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||||
{(data?.payments ?? []).length > 0 && (
|
{/* Total amount card */}
|
||||||
<>
|
<View style={{ backgroundColor: '#FFF', borderRadius: 20, padding: 24, marginBottom: 16, alignItems: 'center', borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||||
<Text className="font-semibold text-gray-700 mb-2 px-1">Included Payments ({data.payments.length})</Text>
|
<Text style={{ fontSize: 13, fontWeight: '600', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>Total Amount</Text>
|
||||||
{data.payments.map((p: any) => (
|
<Text style={{ fontSize: 38, fontWeight: '800', color: '#0F172A' }}>₱{Number(data?.totalAmount ?? 0).toLocaleString()}</Text>
|
||||||
<View key={p.id} className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
|
{payments.length > 0 && (
|
||||||
<View className="flex-row justify-between items-start">
|
<Text style={{ fontSize: 15, color: '#64748B', marginTop: 6 }}>{payments.length} payment{payments.length !== 1 ? 's' : ''} included</Text>
|
||||||
<View>
|
)}
|
||||||
<Text className="font-medium text-gray-900">
|
{data?.notes && (
|
||||||
{METHOD_ICON[p.paymentMethod] ?? '💳'} {p.client?.firstName} {p.client?.lastName}
|
<Text style={{ fontSize: 15, color: '#64748B', marginTop: 8, textAlign: 'center', fontStyle: 'italic' }}>"{data.notes}"</Text>
|
||||||
</Text>
|
)}
|
||||||
<Text className="text-gray-500 text-sm">{p.client?.accountNumber} · {p.paymentMethod}</Text>
|
</View>
|
||||||
{p.referenceNumber && <Text className="text-gray-400 text-xs">Ref: {p.referenceNumber}</Text>}
|
|
||||||
</View>
|
|
||||||
<Text className="font-bold text-green-700">₱{Number(p.amount).toLocaleString()}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View className="h-8" />
|
{/* Details */}
|
||||||
</ScrollView>
|
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', marginBottom: 16, overflow: 'hidden' }}>
|
||||||
</View>
|
<InfoRow label="Submitted by"
|
||||||
|
value={data?.collector?.firstName ? `${data.collector.firstName} ${data.collector.lastName}` : undefined} />
|
||||||
|
<InfoRow label="Submitted on"
|
||||||
|
value={data?.createdAt ? new Date(data.createdAt).toLocaleString('en-PH') : undefined} />
|
||||||
|
<InfoRow label="Confirmed on"
|
||||||
|
value={data?.confirmedAt ? new Date(data.confirmedAt).toLocaleString('en-PH') : undefined} />
|
||||||
|
<InfoRow label="Confirmed by"
|
||||||
|
value={data?.confirmedBy?.firstName ? `${data.confirmedBy.firstName} ${data.confirmedBy.lastName}` : undefined}
|
||||||
|
isLast />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Payments breakdown */}
|
||||||
|
{payments.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
|
||||||
|
Payments ({payments.length})
|
||||||
|
</Text>
|
||||||
|
{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.client?.firstName} {p.client?.lastName}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 3 }}>
|
||||||
|
{p.client?.accountNumber} · {p.channel ?? p.paymentMethod}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
4
app/(app)/remittances/_layout.tsx
Normal file
4
app/(app)/remittances/_layout.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
export default function RemittancesLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
@@ -1,49 +1,120 @@
|
|||||||
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native';
|
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, RefreshControl } 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 { router } from 'expo-router';
|
||||||
import { api } from '../../../services/api';
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = { PENDING: '#D97706', CONFIRMED: '#16A34A', DISPUTED: '#DC2626' };
|
const STATUS_CONFIG: Record<string, { color: string; bg: string }> = {
|
||||||
|
PENDING: { color: '#92400E', bg: '#FEF3C7' },
|
||||||
|
CONFIRMED: { color: '#166534', bg: '#DCFCE7' },
|
||||||
|
DISPUTED: { color: '#991B1B', bg: '#FEE2E2' },
|
||||||
|
};
|
||||||
|
|
||||||
export default function RemittancesScreen() {
|
export default function RemittancesScreen() {
|
||||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
const [remittancesQ, unremittedQ] = useQueries({
|
||||||
queryKey: ['remittances'],
|
queries: [
|
||||||
queryFn: () => api.get('/api/v1/remittances?limit=50').then(r => r.data?.data ?? r.data),
|
{ queryKey: ['remittances'], queryFn: () => api.get('/api/v1/remittances?limit=50').then(r => r.data?.data ?? r.data) },
|
||||||
|
{ queryKey: ['unremitted'], queryFn: () => api.get('/api/v1/payments?unremitted=true').then(r => r.data).catch(() => null) },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const data = remittancesQ.data ?? [];
|
||||||
|
const isLoading = remittancesQ.isLoading;
|
||||||
|
const isRefetching = remittancesQ.isRefetching || unremittedQ.isRefetching;
|
||||||
|
const refetchAll = () => { remittancesQ.refetch(); unremittedQ.refetch(); };
|
||||||
|
|
||||||
|
// Compute unremitted total from raw payments or summary
|
||||||
|
const unremittedPayments: any[] = Array.isArray(unremittedQ.data?.data)
|
||||||
|
? unremittedQ.data.data
|
||||||
|
: Array.isArray(unremittedQ.data)
|
||||||
|
? unremittedQ.data
|
||||||
|
: [];
|
||||||
|
const unremittedTotal = unremittedQ.data?.totalUnremitted
|
||||||
|
?? unremittedPayments.reduce((s: number, p: any) => s + Number(p.amount ?? 0), 0);
|
||||||
|
const unremittedCount = unremittedQ.data?.count ?? unremittedPayments.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row justify-between items-center">
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
<Text className="text-white text-xl font-bold">Remittances</Text>
|
{/* Header */}
|
||||||
<TouchableOpacity className="bg-white/20 rounded-lg px-3 py-1.5" onPress={() => router.push('/(app)/remittances/submit')}>
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 12, paddingBottom: 20 }}>
|
||||||
<Text className="text-white text-sm font-semibold">+ Submit</Text>
|
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||||||
</TouchableOpacity>
|
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
{isLoading ? (
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||||
<View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>
|
<View>
|
||||||
) : (
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Remittances</Text>
|
||||||
<FlatList
|
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>{data.length} submissions</Text>
|
||||||
data={data ?? []}
|
</View>
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
|
||||||
contentContainerStyle={{ padding: 16 }}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="bg-white rounded-xl p-4 mb-2 border border-gray-100"
|
style={{ backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 10 }}
|
||||||
onPress={() => router.push(`/(app)/remittances/${item.id}`)}
|
onPress={() => router.push('/(app)/remittances/submit')}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View className="flex-row justify-between">
|
<Text style={{ color: '#FFF', fontWeight: '700', fontSize: 15 }}>+ Submit</Text>
|
||||||
<Text className="font-semibold text-gray-900">₱{Number(item.totalAmount).toLocaleString()}</Text>
|
|
||||||
<View className="rounded-full px-2 py-0.5" style={{ backgroundColor: `${STATUS_COLOR[item.status] ?? '#6B7280'}20` }}>
|
|
||||||
<Text className="text-xs font-medium" style={{ color: STATUS_COLOR[item.status] ?? '#6B7280' }}>{item.status}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text className="text-gray-500 text-sm mt-1">{new Date(item.createdAt).toLocaleDateString()}</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
</View>
|
||||||
ListEmptyComponent={<View className="items-center py-16"><Text className="text-gray-400">No remittances yet</Text></View>}
|
</View>
|
||||||
/>
|
|
||||||
)}
|
{isLoading ? (
|
||||||
</View>
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ActivityIndicator color="#0891B2" size="large" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={data}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetchAll} tintColor="#0891B2" />}
|
||||||
|
ListHeaderComponent={
|
||||||
|
unremittedTotal > 0 ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/(app)/remittances/submit')}
|
||||||
|
style={{ backgroundColor: '#FFF7ED', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1.5, borderColor: '#FED7AA' }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: '#92400E', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 4 }}>
|
||||||
|
Unremitted Amount
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 28, fontWeight: '800', color: '#9A3412' }}>
|
||||||
|
₱{Number(unremittedTotal).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
{unremittedCount > 0 && (
|
||||||
|
<Text style={{ fontSize: 14, color: '#C2410C', marginTop: 4 }}>{unremittedCount} payment{unremittedCount !== 1 ? 's' : ''} pending remittance</Text>
|
||||||
|
)}
|
||||||
|
<Text style={{ fontSize: 14, color: '#EA580C', marginTop: 8, fontWeight: '600' }}>Tap to submit →</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const st = STATUS_CONFIG[item.status] ?? { color: '#6B7280', bg: '#F1F5F9' };
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 12, borderWidth: 1, borderColor: '#F1F5F9' }}
|
||||||
|
onPress={() => router.push(`/(app)/remittances/${item.id}`)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A' }}>₱{Number(item.totalAmount).toLocaleString()}</Text>
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 12, paddingVertical: 5, backgroundColor: st.bg }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: st.color }}>{item.status}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 15, color: '#64748B' }}>{new Date(item.createdAt).toLocaleDateString('en-PH', { year: 'numeric', month: 'long', day: 'numeric' })}</Text>
|
||||||
|
{item.payments?.length > 0 && (
|
||||||
|
<Text style={{ fontSize: 14, color: '#94A3B8', marginTop: 3 }}>{item.payments.length} payment{item.payments.length !== 1 ? 's' : ''}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||||||
|
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No remittances yet</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,112 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../../../services/api';
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
export default function SubmitRemittanceScreen() {
|
export default function SubmitRemittanceScreen() {
|
||||||
const [amount, setAmount] = useState('');
|
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Fetch unremitted payments to auto-fill amount
|
||||||
|
const { data: unremittedData, isLoading: loadingUnremitted } = useQuery({
|
||||||
|
queryKey: ['unremitted'],
|
||||||
|
queryFn: () => api.get('/api/v1/payments?unremitted=true').then(r => r.data).catch(() => null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const unremittedPayments: any[] = Array.isArray(unremittedData?.data)
|
||||||
|
? unremittedData.data
|
||||||
|
: Array.isArray(unremittedData)
|
||||||
|
? unremittedData
|
||||||
|
: [];
|
||||||
|
const totalAmount = unremittedData?.totalUnremitted
|
||||||
|
?? unremittedPayments.reduce((s: number, p: any) => s + Number(p.amount ?? 0), 0);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!amount || isNaN(Number(amount))) return Alert.alert('Required', 'Enter a valid amount.');
|
if (totalAmount <= 0) return Alert.alert('Nothing to Submit', 'You have no unremitted payments to submit.');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post('/api/v1/remittances', { totalAmount: Number(amount), notes });
|
await api.post('/api/v1/remittances', { totalAmount: Number(totalAmount), notes: notes.trim() || undefined });
|
||||||
Alert.alert('Submitted', 'Remittance submitted successfully.', [{ text: 'OK', onPress: () => router.back() }]);
|
Alert.alert('Submitted!', `₱${Number(totalAmount).toLocaleString()} remittance submitted.`, [
|
||||||
|
{ text: 'OK', onPress: () => router.back() },
|
||||||
|
]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Error', e?.response?.data?.message ?? 'Submission failed.');
|
Alert.alert('Error', e?.response?.data?.message ?? 'Submission failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 12, paddingBottom: 20 }}>
|
||||||
<Text className="text-white text-lg">←</Text>
|
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||||||
</TouchableOpacity>
|
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||||
<Text className="text-white text-xl font-bold">Submit Remittance</Text>
|
</TouchableOpacity>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Submit Remittance</Text>
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>End-of-day collection</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }} keyboardShouldPersistTaps="handled">
|
||||||
|
{/* Total amount summary card */}
|
||||||
|
<View style={{ backgroundColor: '#FFF', borderRadius: 20, padding: 20, marginBottom: 20, alignItems: 'center', borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: '600', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 8 }}>Total to Remit</Text>
|
||||||
|
{loadingUnremitted ? (
|
||||||
|
<ActivityIndicator color="#0891B2" size="large" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={{ fontSize: 36, fontWeight: '800', color: totalAmount > 0 ? '#059669' : '#94A3B8' }}>
|
||||||
|
₱{Number(totalAmount).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
{unremittedPayments.length > 0 && (
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 6 }}>
|
||||||
|
From {unremittedPayments.length} collection{unremittedPayments.length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Breakdown of payments */}
|
||||||
|
{unremittedPayments.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 20 }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Breakdown</Text>
|
||||||
|
{unremittedPayments.map((p: any) => (
|
||||||
|
<View key={p.id} style={{ backgroundColor: '#FFF', borderRadius: 14, padding: 16, marginBottom: 8, flexDirection: 'row', justifyContent: 'space-between', borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '600', color: '#0F172A' }}>{p.client?.firstName} {p.client?.lastName}</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 2 }}>{p.paymentMethod} · {p.client?.accountNumber}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '800', color: '#166534' }}>₱{Number(p.amount).toLocaleString()}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>Notes <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text></Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 24, minHeight: 80, textAlignVertical: 'top' }}
|
||||||
|
placeholder="Any remarks or notes for admin..."
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: totalAmount > 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||||||
|
onPress={submit}
|
||||||
|
disabled={loading || loadingUnremitted || totalAmount <= 0}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{loading ? <ActivityIndicator color="#FFF" /> : <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Submit ₱{Number(totalAmount).toLocaleString()}</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{ height: 32 }} />
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView className="flex-1 px-4 py-6">
|
</SafeAreaView>
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Total Collection (₱)</Text>
|
|
||||||
<TextInput
|
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-4"
|
|
||||||
placeholder="0.00"
|
|
||||||
value={amount}
|
|
||||||
onChangeText={setAmount}
|
|
||||||
keyboardType="numeric"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Notes (optional)</Text>
|
|
||||||
<TextInput
|
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-8"
|
|
||||||
placeholder="Any remarks..."
|
|
||||||
value={notes}
|
|
||||||
onChangeText={setNotes}
|
|
||||||
multiline
|
|
||||||
numberOfLines={3}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity className="bg-primary rounded-xl py-4 items-center" onPress={submit} disabled={loading}>
|
|
||||||
{loading ? <ActivityIndicator color="white" /> : <Text className="text-white font-bold text-base">Submit Remittance</Text>}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
548
app/(app)/tasks/[id].tsx
Normal file
548
app/(app)/tasks/[id].tsx
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, ScrollView, TextInput, TouchableOpacity,
|
||||||
|
ActivityIndicator, Alert, Modal, KeyboardAvoidingView, Platform,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as Location from 'expo-location';
|
||||||
|
import { api } from '../../../services/api';
|
||||||
|
import { useAuthStore } from '../../../stores/authStore';
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
const STATUS_FLOW = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const;
|
||||||
|
type TaskStatus = typeof STATUS_FLOW[number];
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<string, { bg: string; color: string }> = {
|
||||||
|
OPEN: { bg: '#ECFEFF', color: '#0891B2' },
|
||||||
|
IN_PROGRESS: { bg: '#FFFBEB', color: '#D97706' },
|
||||||
|
RESOLVED: { bg: '#F0FDF4', color: '#16A34A' },
|
||||||
|
CLOSED: { bg: '#F1F5F9', color: '#6B7280' },
|
||||||
|
};
|
||||||
|
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', NORMAL: '#0891B2' };
|
||||||
|
const PRIORITY_BG: Record<string, string> = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' };
|
||||||
|
const TYPE_COLOR: Record<string, string> = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' };
|
||||||
|
const TYPE_BG: Record<string, string> = { INSTALLATION: '#ECFEFF', SUPPORT: '#F5F3FF', BILLING: '#FFFBEB' };
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('en-PH', {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value, isLast }: { label: string; value?: string | null; isLast?: boolean }) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<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: '#0F172A' }}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Screen ──────────────────────────────────────────────────────────────
|
||||||
|
export default function TicketDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'details' | 'comments'>('details');
|
||||||
|
const [showStatusPicker, setShowStatusPicker] = useState(false);
|
||||||
|
const [instNotes, setInstNotes] = useState('');
|
||||||
|
const [instConfirming, setInstConfirming] = useState(false);
|
||||||
|
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
const [locLoading, setLocLoading] = useState(false);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [sendingComment, setSendingComment] = useState(false);
|
||||||
|
|
||||||
|
const { data: ticket, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['task', id],
|
||||||
|
queryFn: () => api.get(`/api/v1/tickets/${id}`).then(r => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStatus = useMutation({
|
||||||
|
mutationFn: async (status: TaskStatus) => {
|
||||||
|
await api.patch(`/api/v1/tickets/${id}`, { status });
|
||||||
|
// Log status change as a system comment
|
||||||
|
const who = user?.firstName ?? 'Staff';
|
||||||
|
await api.post(`/api/v1/tickets/${id}/messages`, {
|
||||||
|
message: `Status changed to ${status.replace('_', ' ')} by ${who}`,
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowStatusPicker(false);
|
||||||
|
refetch();
|
||||||
|
qc.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
},
|
||||||
|
onError: () => Alert.alert('Error', 'Could not update status.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const captureLocation = async () => {
|
||||||
|
setLocLoading(true);
|
||||||
|
try {
|
||||||
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('Permission Denied', 'Location permission is required to record the installation site.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High });
|
||||||
|
setCoords({ lat: loc.coords.latitude, lng: loc.coords.longitude });
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Error', 'Could not get location. Make sure GPS is enabled.');
|
||||||
|
} finally {
|
||||||
|
setLocLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmInstallation = async () => {
|
||||||
|
if (!coords) {
|
||||||
|
Alert.alert('Location Required', 'Please capture the installation coordinates before confirming.', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Capture Now', onPress: captureLocation },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInstConfirming(true);
|
||||||
|
try {
|
||||||
|
// 1. Resolve the ticket
|
||||||
|
await api.patch(`/api/v1/tickets/${id}`, { status: 'RESOLVED' });
|
||||||
|
|
||||||
|
// 2. Update client location with recorded coordinates
|
||||||
|
if (ticket?.clientId) {
|
||||||
|
await api.patch(`/api/v1/clients/${ticket.clientId}`, {
|
||||||
|
lat: coords.lat,
|
||||||
|
lng: coords.lng,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Log activity comment
|
||||||
|
const coordStr = `${coords.lat.toFixed(6)}, ${coords.lng.toFixed(6)}`;
|
||||||
|
const note = instNotes.trim()
|
||||||
|
? `Installation confirmed. Location recorded: ${coordStr}. Notes: ${instNotes.trim()}`
|
||||||
|
: `Installation confirmed. Location recorded: ${coordStr}`;
|
||||||
|
await api.post(`/api/v1/tickets/${id}/messages`, { message: note }).catch(() => {});
|
||||||
|
|
||||||
|
setInstNotes('');
|
||||||
|
setCoords(null);
|
||||||
|
Alert.alert('Installation Complete!', 'Ticket resolved and client location updated.');
|
||||||
|
refetch();
|
||||||
|
qc.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['client', ticket?.clientId] });
|
||||||
|
setActiveTab('comments');
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Error', 'Could not confirm installation. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setInstConfirming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendComment = async () => {
|
||||||
|
if (!comment.trim()) return;
|
||||||
|
setSendingComment(true);
|
||||||
|
const text = comment.trim();
|
||||||
|
setComment(''); // clear immediately for responsiveness
|
||||||
|
try {
|
||||||
|
await api.post(`/api/v1/tickets/${id}/messages`, { message: text });
|
||||||
|
refetch();
|
||||||
|
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 300);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Error', 'Could not send comment.');
|
||||||
|
setComment(text); // restore on failure
|
||||||
|
} finally {
|
||||||
|
setSendingComment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 currentStatus: string = ticket?.status ?? 'OPEN';
|
||||||
|
const statusStyle = STATUS_STYLE[currentStatus] ?? STATUS_STYLE.OPEN;
|
||||||
|
const isInstallation = ticket?.type === 'INSTALLATION';
|
||||||
|
const isDone = currentStatus === 'RESOLVED' || currentStatus === 'CLOSED';
|
||||||
|
const typeColor = TYPE_COLOR[ticket?.type] ?? '#6B7280';
|
||||||
|
const typeBg = TYPE_BG[ticket?.type] ?? '#F1F5F9';
|
||||||
|
const messages: any[] = ticket?.messages ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<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>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 20, fontWeight: '800', marginBottom: 12 }} numberOfLines={2}>
|
||||||
|
{ticket?.subject}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{/* Type */}
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: typeBg }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: typeColor }}>{ticket?.type}</Text>
|
||||||
|
</View>
|
||||||
|
{/* Status — tappable to change */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowStatusPicker(true)}
|
||||||
|
style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: statusStyle.bg }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: statusStyle.color }}>
|
||||||
|
{currentStatus.replace('_', ' ')} ▾
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{/* Priority — only show HIGH */}
|
||||||
|
{ticket?.priority === 'HIGH' && (
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: PRIORITY_BG.HIGH }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: PRIORITY_COLOR.HIGH }}>HIGH</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{ticket?.client && (
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 10 }}>
|
||||||
|
{ticket.client.firstName} {ticket.client.lastName} · {ticket.client.accountNumber}
|
||||||
|
{ticket.assignedTo
|
||||||
|
? ` · Assigned: ${ticket.assignedTo.firstName} ${ticket.assignedTo.lastName}`
|
||||||
|
: ' · Unassigned'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Tabs ── */}
|
||||||
|
<View style={{ flexDirection: 'row', backgroundColor: '#FFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||||
|
{[
|
||||||
|
{ key: 'details', label: 'Details' },
|
||||||
|
{ key: 'comments', label: `Comments${messages.length > 0 ? ` (${messages.length})` : ''}` },
|
||||||
|
].map(tab => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tab.key}
|
||||||
|
onPress={() => setActiveTab(tab.key as 'details' | 'comments')}
|
||||||
|
style={{
|
||||||
|
flex: 1, paddingVertical: 16, alignItems: 'center',
|
||||||
|
borderBottomWidth: 2.5,
|
||||||
|
borderBottomColor: activeTab === tab.key ? '#0891B2' : 'transparent',
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{
|
||||||
|
fontSize: 15, fontWeight: '700',
|
||||||
|
color: activeTab === tab.key ? '#0891B2' : '#94A3B8',
|
||||||
|
}}>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── DETAILS TAB ── */}
|
||||||
|
{activeTab === 'details' && (
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||||
|
{/* Info card */}
|
||||||
|
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', marginBottom: 16, overflow: 'hidden' }}>
|
||||||
|
<InfoRow label="Type" value={ticket?.type} />
|
||||||
|
<InfoRow label="Client" value={ticket?.client ? `${ticket.client.firstName} ${ticket.client.lastName}` : null} />
|
||||||
|
<InfoRow label="Assigned to" value={ticket?.assignedTo ? `${ticket.assignedTo.firstName} ${ticket.assignedTo.lastName}` : 'Unassigned'} />
|
||||||
|
<InfoRow label="Created by" value={ticket?.createdBy ? `${ticket.createdBy.firstName} ${ticket.createdBy.lastName}` : null} />
|
||||||
|
<InfoRow label="Created" value={ticket?.createdAt ? formatDate(ticket.createdAt) : null} />
|
||||||
|
<InfoRow label="Resolved" value={ticket?.resolvedAt ? formatDate(ticket.resolvedAt) : null} isLast />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{ticket?.description ? (
|
||||||
|
<View style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#F1F5F9' }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 8 }}>Description</Text>
|
||||||
|
<Text style={{ fontSize: 16, color: '#334155', lineHeight: 26 }}>{ticket.description}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── INSTALLATION SECTION ── */}
|
||||||
|
{isInstallation && (
|
||||||
|
<>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<View style={{ flex: 1, height: 1, backgroundColor: '#E2E8F0' }} />
|
||||||
|
<Text style={{ marginHorizontal: 12, fontSize: 13, fontWeight: '700', color: '#0891B2', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Installation
|
||||||
|
</Text>
|
||||||
|
<View style={{ flex: 1, height: 1, backgroundColor: '#E2E8F0' }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isDone ? (
|
||||||
|
/* ─ Already confirmed ─ */
|
||||||
|
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#86EFAC', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<Text style={{ fontSize: 28, marginBottom: 8 }}>✅</Text>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: '800', color: '#166534' }}>Installation Complete</Text>
|
||||||
|
{ticket?.resolvedAt && (
|
||||||
|
<Text style={{ fontSize: 14, color: '#16A34A', marginTop: 4 }}>
|
||||||
|
Confirmed on {formatDate(ticket.resolvedAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setActiveTab('comments')}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '600', color: '#16A34A' }}>
|
||||||
|
View activity log →
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
/* ─ Confirm installation form ─ */
|
||||||
|
<View style={{ backgroundColor: '#FFFBEB', borderRadius: 16, padding: 18, borderWidth: 1.5, borderColor: '#FCD34D', marginBottom: 16 }}>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '700', color: '#92400E', marginBottom: 16 }}>
|
||||||
|
Confirm Installation
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* ── GPS Coordinates (required) ── */}
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#78350F', marginBottom: 8 }}>
|
||||||
|
📍 Installation Location <Text style={{ color: '#DC2626' }}>*</Text>
|
||||||
|
</Text>
|
||||||
|
{coords ? (
|
||||||
|
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 12, padding: 14, marginBottom: 16, borderWidth: 1, borderColor: '#86EFAC', flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#166534' }}>✓ Location Captured</Text>
|
||||||
|
<Text style={{ fontSize: 13, color: '#16A34A', marginTop: 3, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace' }}>
|
||||||
|
{coords.lat.toFixed(6)}, {coords.lng.toFixed(6)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={captureLocation} disabled={locLoading} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '600', color: '#0891B2' }}>
|
||||||
|
{locLoading ? '...' : 'Retake'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#FCD34D', borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginBottom: 16, flexDirection: 'row', justifyContent: 'center' }}
|
||||||
|
onPress={captureLocation}
|
||||||
|
disabled={locLoading}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{locLoading
|
||||||
|
? <><ActivityIndicator color="#D97706" size="small" style={{ marginRight: 10 }} /><Text style={{ fontSize: 16, fontWeight: '700', color: '#D97706' }}>Getting GPS...</Text></>
|
||||||
|
: <><Text style={{ fontSize: 18, marginRight: 8 }}>📍</Text><Text style={{ fontSize: 16, fontWeight: '700', color: '#D97706' }}>Capture Current Location</Text></>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Notes ── */}
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '600', color: '#78350F', marginBottom: 8 }}>
|
||||||
|
Notes / Remarks
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
borderWidth: 1.5, borderColor: '#FDE68A', borderRadius: 12,
|
||||||
|
paddingHorizontal: 16, paddingVertical: 12,
|
||||||
|
fontSize: 16, color: '#0F172A',
|
||||||
|
marginBottom: 16, minHeight: 88, textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
placeholder="Equipment serial, router model, cable length, remarks..."
|
||||||
|
placeholderTextColor="#D97706"
|
||||||
|
value={instNotes}
|
||||||
|
onChangeText={setInstNotes}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
backgroundColor: coords ? '#059669' : '#94A3B8',
|
||||||
|
borderRadius: 14, paddingVertical: 16, alignItems: 'center',
|
||||||
|
}}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert(
|
||||||
|
'Confirm Installation',
|
||||||
|
`Mark this installation as complete?\n\nLocation: ${coords ? `${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` : 'Not captured'}\n\nThis will update the client's location and resolve the ticket.`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Confirm', onPress: confirmInstallation },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={instConfirming || !coords}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{instConfirming
|
||||||
|
? <ActivityIndicator color="#FFF" />
|
||||||
|
: <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>
|
||||||
|
{coords ? '✓ Mark Installation Complete' : 'Capture Location First'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── COMMENTS TAB ── */}
|
||||||
|
{activeTab === 'comments' && (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 8 }}
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
|
||||||
|
<Text style={{ fontSize: 16, color: '#94A3B8', marginBottom: 6 }}>No comments yet</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#CBD5E1', textAlign: 'center' }}>
|
||||||
|
Add a note or update below
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
messages.map((m: any, i: number) => {
|
||||||
|
const isSystem = m.senderType === 'SYSTEM' || m.message?.startsWith('Status changed') || m.message?.startsWith('Installation confirmed');
|
||||||
|
const isMe = m.sender?.id === user?.id;
|
||||||
|
|
||||||
|
if (isSystem) {
|
||||||
|
// System messages — centered pill
|
||||||
|
return (
|
||||||
|
<View key={m.id ?? i} style={{ alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<View style={{ backgroundColor: '#F1F5F9', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6 }}>
|
||||||
|
<Text style={{ fontSize: 13, color: '#64748B', fontStyle: 'italic' }}>{m.message}</Text>
|
||||||
|
</View>
|
||||||
|
{m.createdAt && (
|
||||||
|
<Text style={{ fontSize: 11, color: '#CBD5E1', marginTop: 3 }}>
|
||||||
|
{formatDate(m.createdAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User messages — chat bubbles
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={m.id ?? i}
|
||||||
|
style={{ marginBottom: 14, maxWidth: '80%', alignSelf: isMe ? 'flex-end' : 'flex-start' }}
|
||||||
|
>
|
||||||
|
{!isMe && (
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '600', color: '#94A3B8', marginBottom: 4, marginLeft: 4 }}>
|
||||||
|
{m.senderName ?? m.sender?.firstName ?? 'Staff'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={{
|
||||||
|
borderRadius: 18,
|
||||||
|
paddingHorizontal: 16, paddingVertical: 12,
|
||||||
|
backgroundColor: isMe ? '#0891B2' : '#FFF',
|
||||||
|
borderWidth: isMe ? 0 : 1, borderColor: '#F1F5F9',
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontSize: 16, color: isMe ? '#FFF' : '#0F172A', lineHeight: 22 }}>
|
||||||
|
{m.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{m.createdAt && (
|
||||||
|
<Text style={{
|
||||||
|
fontSize: 11, color: '#CBD5E1', marginTop: 3,
|
||||||
|
alignSelf: isMe ? 'flex-end' : 'flex-start',
|
||||||
|
}}>
|
||||||
|
{formatDate(m.createdAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* ── Comment input — ALWAYS visible ── */}
|
||||||
|
<View style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: 16, paddingVertical: 12,
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
borderTopWidth: 1, borderTopColor: '#F1F5F9',
|
||||||
|
}}>
|
||||||
|
<TextInput
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14,
|
||||||
|
paddingHorizontal: 16, paddingVertical: 12,
|
||||||
|
fontSize: 16, color: '#0F172A', marginRight: 10,
|
||||||
|
maxHeight: 100,
|
||||||
|
}}
|
||||||
|
placeholder={isDone ? 'Add a follow-up note...' : 'Add a comment...'}
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={comment}
|
||||||
|
onChangeText={setComment}
|
||||||
|
multiline
|
||||||
|
returnKeyType="send"
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
backgroundColor: comment.trim() ? '#0891B2' : '#E2E8F0',
|
||||||
|
borderRadius: 14, paddingHorizontal: 18,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onPress={sendComment}
|
||||||
|
disabled={sendingComment || !comment.trim()}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{sendingComment
|
||||||
|
? <ActivityIndicator color="#FFF" size="small" />
|
||||||
|
: <Text style={{ color: comment.trim() ? '#FFF' : '#94A3B8', fontWeight: '700', fontSize: 15 }}>
|
||||||
|
Send
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Status Picker Modal ── */}
|
||||||
|
<Modal visible={showStatusPicker} transparent animationType="slide" onRequestClose={() => setShowStatusPicker(false)}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowStatusPicker(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24 }}>
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 4 }}>Update Status</Text>
|
||||||
|
<Text style={{ fontSize: 15, color: '#94A3B8', marginBottom: 20 }}>
|
||||||
|
Current: <Text style={{ fontWeight: '700', color: '#0F172A' }}>{currentStatus.replace('_', ' ')}</Text>
|
||||||
|
</Text>
|
||||||
|
{STATUS_FLOW.map(s => {
|
||||||
|
const style = STATUS_STYLE[s];
|
||||||
|
const isActive = s === currentStatus;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={s}
|
||||||
|
onPress={() => !isActive && updateStatus.mutate(s)}
|
||||||
|
disabled={isActive || updateStatus.isPending}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: 18, borderRadius: 16, marginBottom: 10,
|
||||||
|
backgroundColor: style.bg, opacity: isActive ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '700', color: style.color }}>{s.replace('_', ' ')}</Text>
|
||||||
|
{isActive && <Text style={{ fontSize: 14, color: style.color, fontWeight: '600' }}>✓ Current</Text>}
|
||||||
|
{updateStatus.isPending && !isActive && <ActivityIndicator size="small" color={style.color} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TouchableOpacity onPress={() => setShowStatusPicker(false)} style={{ paddingVertical: 14, alignItems: 'center' }}>
|
||||||
|
<Text style={{ fontSize: 16, color: '#94A3B8', fontWeight: '600' }}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
app/(app)/tasks/_layout.tsx
Normal file
4
app/(app)/tasks/_layout.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
export default function TasksLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
142
app/(app)/tasks/index.tsx
Normal file
142
app/(app)/tasks/index.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl, ScrollView } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
|
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', NORMAL: '#0891B2', LOW: '#6B7280' };
|
||||||
|
const PRIORITY_BG: Record<string, string> = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF', LOW: '#F1F5F9' };
|
||||||
|
const STATUS_COLOR: Record<string, string> = { OPEN: '#0891B2', IN_PROGRESS: '#D97706', RESOLVED: '#16A34A', CLOSED: '#6B7280' };
|
||||||
|
const STATUS_BG: Record<string, string> = { OPEN: '#ECFEFF', IN_PROGRESS: '#FFFBEB', RESOLVED: '#F0FDF4', CLOSED: '#F1F5F9' };
|
||||||
|
const TYPE_COLOR: Record<string, string> = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' };
|
||||||
|
const TYPE_BG: Record<string, string> = { INSTALLATION: '#ECFEFF', SUPPORT: '#F5F3FF', BILLING: '#FFFBEB' };
|
||||||
|
|
||||||
|
const STATUS_FILTERS = ['All', 'OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
|
||||||
|
|
||||||
|
export default function TasksScreen() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('All');
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||||
|
queryKey: ['tasks'],
|
||||||
|
queryFn: () => api.get('/api/v1/tickets?limit=100').then(r => r.data?.data ?? r.data ?? []),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = (data ?? []).filter((t: any) => {
|
||||||
|
const matchSearch = `${t.subject} ${t.client?.firstName ?? ''} ${t.client?.lastName ?? ''}`.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchStatus = statusFilter === 'All' || t.status === statusFilter;
|
||||||
|
return matchSearch && matchStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||||
|
<View>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Tickets</Text>
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>{tasks.length} showing</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 10 }}
|
||||||
|
onPress={() => router.push('/(app)/tasks/new')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#FFF', fontWeight: '700', fontSize: 15 }}>+ Ticket</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ backgroundColor: '#FFF', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 8, borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||||
|
<View style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14 }}>
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1, paddingVertical: 13, fontSize: 16, color: '#0F172A' }}
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
/>
|
||||||
|
{search.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearch('')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 13, fontWeight: '800', lineHeight: 16 }}>×</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginTop: 10, marginBottom: 4 }}>
|
||||||
|
{STATUS_FILTERS.map(f => {
|
||||||
|
const isActive = statusFilter === f;
|
||||||
|
const color = f === 'All' ? '#0891B2' : STATUS_COLOR[f] ?? '#6B7280';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f}
|
||||||
|
onPress={() => setStatusFilter(f)}
|
||||||
|
style={{ borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, marginRight: 8, backgroundColor: isActive ? color : '#F1F5F9' }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: '700', color: isActive ? '#FFF' : '#64748B' }}>
|
||||||
|
{f === 'All' ? 'All' : f.replace('_', ' ')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ActivityIndicator color="#0891B2" size="large" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={tasks}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#0891B2" />}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const typeColor = TYPE_COLOR[item.type] ?? '#6B7280';
|
||||||
|
const typeBg = TYPE_BG[item.type] ?? '#F1F5F9';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 12, borderWidth: 1, borderColor: '#F1F5F9', borderLeftWidth: 4, borderLeftColor: typeColor }}
|
||||||
|
onPress={() => router.push(`/(app)/tasks/${item.id}`)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: typeBg }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: typeColor }}>{item.type}</Text>
|
||||||
|
</View>
|
||||||
|
{item.priority === 'HIGH' && (
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: PRIORITY_BG.HIGH }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: PRIORITY_COLOR.HIGH }}>HIGH</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: STATUS_BG[item.status] ?? '#F1F5F9' }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: STATUS_COLOR[item.status] ?? '#6B7280' }}>{item.status?.replace('_', ' ')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 6 }} numberOfLines={2}>{item.subject}</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B' }}>
|
||||||
|
{item.client?.firstName} {item.client?.lastName}
|
||||||
|
{item.assignedTo ? ` · ${item.assignedTo.firstName} ${item.assignedTo.lastName}` : ' · Unassigned'}
|
||||||
|
</Text>
|
||||||
|
{item._count?.messages > 0 && (
|
||||||
|
<Text style={{ fontSize: 13, color: '#94A3B8', marginTop: 4 }}>{item._count.messages} message{item._count.messages !== 1 ? 's' : ''}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||||||
|
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No tasks found</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
app/(app)/tasks/new.tsx
Normal file
202
app/(app)/tasks/new.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
|
const PRIORITIES = ['NORMAL', 'HIGH'];
|
||||||
|
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', NORMAL: '#0891B2' };
|
||||||
|
const PRIORITY_BG: Record<string, string> = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' };
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{ value: 'SUPPORT', label: 'Support' },
|
||||||
|
{ value: 'INSTALLATION', label: 'Installation' },
|
||||||
|
{ value: 'BILLING', label: 'Billing' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NewTaskScreen() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [priority, setPriority] = useState('NORMAL');
|
||||||
|
const [type, setType] = useState('SUPPORT');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const [client, setClient] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { data: searchResults, isFetching: searching } = useQuery({
|
||||||
|
queryKey: ['client-search', debouncedSearch],
|
||||||
|
queryFn: () => api.get(`/api/v1/clients?search=${debouncedSearch}&limit=8`).then(r => r.data?.data ?? r.data ?? []),
|
||||||
|
enabled: debouncedSearch.trim().length >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearchChange = (v: string) => {
|
||||||
|
setSearch(v);
|
||||||
|
setTimeout(() => setDebouncedSearch(v), 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!subject.trim()) return Alert.alert('Required', 'Please enter a subject.');
|
||||||
|
if (!client) return Alert.alert('Required', 'Please select a client.');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/api/v1/tickets', {
|
||||||
|
subject: subject.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
priority, type, clientId: client.id,
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
Alert.alert('Task Created', subject, [{ text: 'OK', onPress: () => router.back() }]);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Error', e?.response?.data?.message ?? 'Could not create task.');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 12, paddingBottom: 20 }}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>New Ticket</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
|
||||||
|
{/* Client Search */}
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
|
||||||
|
Client <Text style={{ color: '#DC2626' }}>*</Text>
|
||||||
|
</Text>
|
||||||
|
{client ? (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#ECFEFF', borderRadius: 14, padding: 18, marginBottom: 20, borderWidth: 1, borderColor: '#A5F3FC' }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '700', color: '#0E7490' }}>{client.firstName} {client.lastName}</Text>
|
||||||
|
<Text style={{ fontSize: 15, color: '#0891B2', marginTop: 2 }}>{client.accountNumber}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => { setClient(null); setSearch(''); setDebouncedSearch(''); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0891B2' }}>Change</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{ marginBottom: 20 }}>
|
||||||
|
<View style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, marginBottom: 8 }}>
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1, paddingVertical: 14, fontSize: 16, color: '#0F172A' }}
|
||||||
|
placeholder="Search by name or account #"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={search}
|
||||||
|
onChangeText={handleSearchChange}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
{search.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => { setSearch(''); setDebouncedSearch(''); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<View style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 13, fontWeight: '800', lineHeight: 16 }}>×</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{searching && <ActivityIndicator size="small" color="#0891B2" style={{ marginLeft: 8 }} />}
|
||||||
|
</View>
|
||||||
|
{debouncedSearch.trim().length >= 2 && (
|
||||||
|
<View style={{ backgroundColor: '#FFF', borderRadius: 14, borderWidth: 1, borderColor: '#E2E8F0', overflow: 'hidden' }}>
|
||||||
|
{(searchResults ?? []).length === 0 && !searching ? (
|
||||||
|
<Text style={{ paddingHorizontal: 16, paddingVertical: 14, fontSize: 15, color: '#94A3B8' }}>No clients found</Text>
|
||||||
|
) : (
|
||||||
|
(searchResults ?? []).map((c: any, i: number, arr: any[]) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={c.id}
|
||||||
|
style={{ paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: i < arr.length - 1 ? 1 : 0, borderBottomColor: '#F1F5F9' }}
|
||||||
|
onPress={() => { setClient(c); setSearch(''); setDebouncedSearch(''); }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '600', color: '#0F172A' }}>{c.firstName} {c.lastName}</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 2 }}>{c.accountNumber}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subject */}
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
|
||||||
|
Subject <Text style={{ color: '#DC2626' }}>*</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 20 }}
|
||||||
|
placeholder="e.g. New installation - Barangay 5"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={subject}
|
||||||
|
onChangeText={setSubject}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Type</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginBottom: 20 }}>
|
||||||
|
{TYPES.map(t => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t.value}
|
||||||
|
onPress={() => setType(t.value)}
|
||||||
|
style={{ borderRadius: 20, paddingHorizontal: 16, paddingVertical: 10, marginRight: 8, marginBottom: 8, backgroundColor: type === t.value ? '#0891B2' : '#FFF', borderWidth: 1.5, borderColor: type === t.value ? '#0891B2' : '#E2E8F0' }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: '600', color: type === t.value ? '#FFF' : '#64748B' }}>{t.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Priority</Text>
|
||||||
|
<View style={{ flexDirection: 'row', marginBottom: 20 }}>
|
||||||
|
{PRIORITIES.map(p => {
|
||||||
|
const isSelected = priority === p;
|
||||||
|
const isHigh = p === 'HIGH';
|
||||||
|
const selectedBg = isHigh ? '#DC2626' : '#0891B2';
|
||||||
|
const unselectedBg = isHigh ? '#FEF2F2' : '#F0F9FF';
|
||||||
|
const selectedText = '#FFF';
|
||||||
|
const unselectedText = isHigh ? '#DC2626' : '#0891B2';
|
||||||
|
const desc = isHigh ? 'Urgent, escalate' : 'Standard queue';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={p}
|
||||||
|
onPress={() => setPriority(p)}
|
||||||
|
style={{ flex: 1, borderRadius: 14, paddingVertical: 16, alignItems: 'center', marginHorizontal: 4, backgroundColor: isSelected ? selectedBg : unselectedBg, borderWidth: 1.5, borderColor: isSelected ? selectedBg : '#E2E8F0' }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: isHigh ? '800' : '700', color: isSelected ? selectedText : unselectedText }}>{p}</Text>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '500', color: isSelected ? 'rgba(255,255,255,0.8)' : '#94A3B8', marginTop: 3 }}>{desc}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
|
||||||
|
Description <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 24, minHeight: 100, textAlignVertical: 'top' }}
|
||||||
|
placeholder="Describe the issue in detail..."
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: client && subject.trim() ? '#0891B2' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||||||
|
onPress={submit}
|
||||||
|
disabled={loading || !client || !subject.trim()}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{loading ? <ActivityIndicator color="#FFF" /> : <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Create Ticket</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View, Text, ScrollView, TextInput, TouchableOpacity,
|
|
||||||
ActivityIndicator, Alert, Modal,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../../../services/api';
|
|
||||||
|
|
||||||
const STATUS_FLOW = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const;
|
|
||||||
type TicketStatus = typeof STATUS_FLOW[number];
|
|
||||||
|
|
||||||
const STATUS_STYLE: Record<string, { bg: string; text: string }> = {
|
|
||||||
OPEN: { bg: '#EFF6FF', text: '#2563EB' },
|
|
||||||
IN_PROGRESS: { bg: '#FFFBEB', text: '#D97706' },
|
|
||||||
RESOLVED: { bg: '#F0FDF4', text: '#16A34A' },
|
|
||||||
CLOSED: { bg: '#F3F4F6', text: '#6B7280' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<string, string> = {
|
|
||||||
HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#6B7280',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TicketDetailScreen() {
|
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
|
||||||
const [reply, setReply] = useState('');
|
|
||||||
const [showStatusPicker, setShowStatusPicker] = useState(false);
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['ticket', id],
|
|
||||||
queryFn: () => api.get(`/api/v1/tickets/${id}`).then(r => r.data),
|
|
||||||
});
|
|
||||||
|
|
||||||
const addReply = useMutation({
|
|
||||||
mutationFn: () => api.post(`/api/v1/tickets/${id}/messages`, { message: reply }),
|
|
||||||
onSuccess: () => {
|
|
||||||
setReply('');
|
|
||||||
qc.invalidateQueries({ queryKey: ['ticket', id] });
|
|
||||||
},
|
|
||||||
onError: () => Alert.alert('Error', 'Could not send reply.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateStatus = useMutation({
|
|
||||||
mutationFn: (status: TicketStatus) =>
|
|
||||||
api.patch(`/api/v1/tickets/${id}`, { status }),
|
|
||||||
onSuccess: () => {
|
|
||||||
setShowStatusPicker(false);
|
|
||||||
qc.invalidateQueries({ queryKey: ['ticket', id] });
|
|
||||||
qc.invalidateQueries({ queryKey: ['tickets'] });
|
|
||||||
},
|
|
||||||
onError: () => Alert.alert('Error', 'Could not update status.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View className="flex-1 items-center justify-center bg-gray-50">
|
|
||||||
<ActivityIndicator color="#2563EB" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentStatus: string = data?.status ?? 'OPEN';
|
|
||||||
const statusStyle = STATUS_STYLE[currentStatus] ?? { bg: '#F3F4F6', text: '#6B7280' };
|
|
||||||
const priorityColor = PRIORITY_COLOR[data?.priority] ?? '#6B7280';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex-1 bg-gray-50">
|
|
||||||
{/* Header */}
|
|
||||||
<View className="px-4 pt-14 pb-4 bg-blue-600">
|
|
||||||
<View className="flex-row items-center mb-2">
|
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
|
||||||
<Text className="text-white text-lg">←</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="text-white font-bold flex-1" numberOfLines={2}>
|
|
||||||
{data?.subject}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row items-center gap-2 ml-7">
|
|
||||||
{/* Status badge - tappable */}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setShowStatusPicker(true)}
|
|
||||||
className="rounded-full px-3 py-1 flex-row items-center"
|
|
||||||
style={{ backgroundColor: statusStyle.bg }}
|
|
||||||
>
|
|
||||||
<Text className="text-xs font-semibold mr-1" style={{ color: statusStyle.text }}>
|
|
||||||
{currentStatus.replace('_', ' ')}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs" style={{ color: statusStyle.text }}>▾</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{/* Priority */}
|
|
||||||
<View className="rounded-full px-3 py-1" style={{ backgroundColor: `${priorityColor}20` }}>
|
|
||||||
<Text className="text-xs font-semibold" style={{ color: priorityColor }}>
|
|
||||||
{data?.priority}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{/* Client name */}
|
|
||||||
{data?.client && (
|
|
||||||
<Text className="text-white/70 text-xs flex-1" numberOfLines={1}>
|
|
||||||
{data.client.firstName} {data.client.lastName}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<ScrollView className="flex-1 px-4 py-4">
|
|
||||||
{data?.description && (
|
|
||||||
<View className="bg-white border border-gray-100 rounded-2xl p-4 mb-4">
|
|
||||||
<Text className="text-xs text-gray-500 mb-1">Description</Text>
|
|
||||||
<Text className="text-gray-800">{data.description}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(data?.messages ?? []).length === 0 && !data?.description && (
|
|
||||||
<View className="items-center py-10">
|
|
||||||
<Text className="text-gray-400">No messages yet. Send the first reply.</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(data?.messages ?? []).map((m: any) => {
|
|
||||||
const isAgent = m.senderType === 'AGENT' || m.senderType === 'STAFF';
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={m.id}
|
|
||||||
className={`mb-3 max-w-[80%] ${isAgent ? 'self-end items-end ml-auto' : 'self-start items-start'}`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={`rounded-2xl px-4 py-3 ${isAgent ? 'bg-blue-600' : 'bg-white border border-gray-100'}`}
|
|
||||||
>
|
|
||||||
<Text className={isAgent ? 'text-white' : 'text-gray-900'}>{m.message}</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-gray-400 text-xs mt-1">
|
|
||||||
{m.senderName ?? m.sender?.name ?? 'System'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Reply bar — hide if ticket is closed */}
|
|
||||||
{currentStatus !== 'CLOSED' ? (
|
|
||||||
<View className="flex-row px-4 py-3 bg-white border-t border-gray-100">
|
|
||||||
<TextInput
|
|
||||||
className="flex-1 bg-gray-100 rounded-xl px-4 py-3 mr-2 text-gray-900"
|
|
||||||
placeholder="Type a reply..."
|
|
||||||
value={reply}
|
|
||||||
onChangeText={setReply}
|
|
||||||
multiline
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="bg-blue-600 rounded-xl px-4 items-center justify-center"
|
|
||||||
onPress={() => reply.trim() && addReply.mutate()}
|
|
||||||
disabled={addReply.isPending || !reply.trim()}
|
|
||||||
style={{ opacity: !reply.trim() ? 0.5 : 1 }}
|
|
||||||
>
|
|
||||||
{addReply.isPending
|
|
||||||
? <ActivityIndicator color="white" />
|
|
||||||
: <Text className="text-white font-semibold">Send</Text>
|
|
||||||
}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="px-4 py-3 bg-gray-100 border-t border-gray-200 items-center">
|
|
||||||
<Text className="text-gray-400 text-sm">This ticket is closed</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status picker modal */}
|
|
||||||
<Modal
|
|
||||||
visible={showStatusPicker}
|
|
||||||
transparent
|
|
||||||
animationType="slide"
|
|
||||||
onRequestClose={() => setShowStatusPicker(false)}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="flex-1 bg-black/50 justify-end"
|
|
||||||
activeOpacity={1}
|
|
||||||
onPress={() => setShowStatusPicker(false)}
|
|
||||||
>
|
|
||||||
<TouchableOpacity activeOpacity={1} className="bg-white rounded-t-3xl p-6">
|
|
||||||
<Text className="text-lg font-bold text-gray-900 mb-1">Update Status</Text>
|
|
||||||
<Text className="text-gray-500 text-sm mb-5">
|
|
||||||
Current: <Text className="font-semibold">{currentStatus.replace('_', ' ')}</Text>
|
|
||||||
</Text>
|
|
||||||
{STATUS_FLOW.map((s) => {
|
|
||||||
const style = STATUS_STYLE[s] ?? { bg: '#F3F4F6', text: '#6B7280' };
|
|
||||||
const isActive = s === currentStatus;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={s}
|
|
||||||
onPress={() => !isActive && updateStatus.mutate(s)}
|
|
||||||
disabled={isActive || updateStatus.isPending}
|
|
||||||
className={`flex-row items-center justify-between p-4 rounded-xl mb-2 ${isActive ? 'opacity-40' : ''}`}
|
|
||||||
style={{ backgroundColor: style.bg }}
|
|
||||||
>
|
|
||||||
<Text className="font-semibold" style={{ color: style.text }}>
|
|
||||||
{s.replace('_', ' ')}
|
|
||||||
</Text>
|
|
||||||
{isActive && <Text style={{ color: style.text }}>✓ Current</Text>}
|
|
||||||
{updateStatus.isPending && !isActive && <ActivityIndicator size="small" color={style.text} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<TouchableOpacity
|
|
||||||
className="mt-2 py-3 items-center"
|
|
||||||
onPress={() => setShowStatusPicker(false)}
|
|
||||||
>
|
|
||||||
<Text className="text-gray-500">Cancel</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl, ScrollView } from 'react-native';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import { api } from '../../../services/api';
|
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#16A34A' };
|
|
||||||
const STATUS_FILTERS = ['ALL', 'OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
|
|
||||||
|
|
||||||
export default function TicketsScreen() {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('ALL');
|
|
||||||
|
|
||||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
|
||||||
queryKey: ['tickets'],
|
|
||||||
queryFn: () => api.get('/api/v1/tickets?limit=100').then(r => r.data?.data ?? r.data ?? []),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tickets = (data ?? []).filter((t: any) => {
|
|
||||||
const matchSearch = `${t.subject} ${t.client?.firstName} ${t.client?.lastName}`.toLowerCase().includes(search.toLowerCase());
|
|
||||||
const matchStatus = statusFilter === 'ALL' || t.status === statusFilter;
|
|
||||||
return matchSearch && matchStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex-1 bg-gray-50">
|
|
||||||
{/* Header */}
|
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row justify-between items-center">
|
|
||||||
<Text className="text-white text-xl font-bold">Tickets</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="bg-white/20 rounded-xl px-4 py-2"
|
|
||||||
onPress={() => router.push('/(app)/tickets/new')}
|
|
||||||
>
|
|
||||||
<Text className="text-white font-semibold text-sm">+ New</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<View className="px-4 pt-3 pb-2 bg-white border-b border-gray-100">
|
|
||||||
<TextInput
|
|
||||||
className="bg-gray-100 border border-gray-200 rounded-xl px-4 py-3 mb-2"
|
|
||||||
placeholder="Search tickets..."
|
|
||||||
value={search}
|
|
||||||
onChangeText={setSearch}
|
|
||||||
/>
|
|
||||||
{/* Status filter chips */}
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="pb-1">
|
|
||||||
{STATUS_FILTERS.map(s => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={s}
|
|
||||||
onPress={() => setStatusFilter(s)}
|
|
||||||
className={`rounded-full px-3 py-1.5 mr-2 ${statusFilter === s ? 'bg-primary' : 'bg-gray-100'}`}
|
|
||||||
>
|
|
||||||
<Text className={`text-xs font-semibold ${statusFilter === s ? 'text-white' : 'text-gray-600'}`}>
|
|
||||||
{s.replace('_', ' ')}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={tickets}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12, paddingBottom: 32 }}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
className="bg-white rounded-xl p-4 mb-2 border border-gray-100"
|
|
||||||
onPress={() => router.push(`/(app)/tickets/${item.id}`)}
|
|
||||||
>
|
|
||||||
<View className="flex-row justify-between items-start mb-1">
|
|
||||||
<Text className="font-semibold text-gray-900 flex-1 mr-2" numberOfLines={2}>{item.subject}</Text>
|
|
||||||
<View className="rounded-full px-2 py-0.5" style={{ backgroundColor: `${PRIORITY_COLOR[item.priority] ?? '#6B7280'}20` }}>
|
|
||||||
<Text className="text-xs font-medium" style={{ color: PRIORITY_COLOR[item.priority] ?? '#6B7280' }}>{item.priority}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row justify-between items-center">
|
|
||||||
<Text className="text-gray-500 text-sm">
|
|
||||||
{item.client?.firstName} {item.client?.lastName}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-gray-400 text-xs">{item.status?.replace('_', ' ')}</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className="items-center py-20">
|
|
||||||
<Text className="text-4xl mb-3">🎫</Text>
|
|
||||||
<Text className="text-gray-400 text-base">No tickets found</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="mt-4 bg-primary rounded-xl px-6 py-3"
|
|
||||||
onPress={() => router.push('/(app)/tickets/new')}
|
|
||||||
>
|
|
||||||
<Text className="text-white font-semibold">Create First Ticket</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { api } from '../../../services/api';
|
|
||||||
|
|
||||||
const PRIORITIES = ['LOW', 'MEDIUM', 'HIGH'];
|
|
||||||
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#16A34A' };
|
|
||||||
|
|
||||||
const CATEGORIES: { value: string; label: string }[] = [
|
|
||||||
{ value: 'NO_SIGNAL', label: 'No Signal' },
|
|
||||||
{ value: 'SLOW_CONNECTION', label: 'Slow Connection' },
|
|
||||||
{ value: 'BILLING', label: 'Billing' },
|
|
||||||
{ value: 'INSTALLATION', label: 'Installation' },
|
|
||||||
{ value: 'RELOCATION', label: 'Relocation' },
|
|
||||||
{ value: 'OTHER', label: 'Other' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function NewTicketScreen() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [subject, setSubject] = useState('');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [priority, setPriority] = useState('MEDIUM');
|
|
||||||
const [category, setCategory] = useState('NO_SIGNAL');
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
const [client, setClient] = useState<any>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const { data: searchResults, isFetching: searching } = useQuery({
|
|
||||||
queryKey: ['client-search', debouncedSearch],
|
|
||||||
queryFn: () => api.get(`/api/v1/clients?search=${debouncedSearch}&limit=8`).then(r => r.data?.data ?? r.data ?? []),
|
|
||||||
enabled: debouncedSearch.trim().length >= 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSearchChange = (v: string) => {
|
|
||||||
setSearch(v);
|
|
||||||
setTimeout(() => setDebouncedSearch(v), 400);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!subject.trim()) return Alert.alert('Required', 'Enter a subject.');
|
|
||||||
if (!client) return Alert.alert('Required', 'Select a client.');
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await api.post('/api/v1/tickets', {
|
|
||||||
subject: subject.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
priority,
|
|
||||||
category,
|
|
||||||
clientId: client.id,
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: ['tickets'] });
|
|
||||||
qc.invalidateQueries({ queryKey: ['dashboard'] });
|
|
||||||
Alert.alert('✅ Ticket Created', subject, [{ text: 'OK', onPress: () => router.back() }]);
|
|
||||||
} catch (e: any) {
|
|
||||||
Alert.alert('Error', e?.response?.data?.message ?? 'Could not create ticket.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex-1 bg-gray-50">
|
|
||||||
<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>
|
|
||||||
<Text className="text-white text-xl font-bold">New Ticket</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView className="flex-1 px-4 py-4" keyboardShouldPersistTaps="handled">
|
|
||||||
{/* Client */}
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Client *</Text>
|
|
||||||
{client ? (
|
|
||||||
<View className="flex-row items-center bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
|
|
||||||
<View className="flex-1">
|
|
||||||
<Text className="font-bold text-blue-900">{client.firstName} {client.lastName}</Text>
|
|
||||||
<Text className="text-blue-600 text-sm">{client.accountNumber}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity onPress={() => { setClient(null); setSearch(''); setDebouncedSearch(''); }} className="p-2">
|
|
||||||
<Text className="text-blue-500 font-semibold">Change</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex-row items-center bg-white border border-gray-200 rounded-xl px-4 mb-1">
|
|
||||||
<TextInput
|
|
||||||
className="flex-1 py-3 text-base"
|
|
||||||
placeholder="Search client by name or account #"
|
|
||||||
value={search}
|
|
||||||
onChangeText={handleSearchChange}
|
|
||||||
autoCapitalize="none"
|
|
||||||
/>
|
|
||||||
{searching && <ActivityIndicator size="small" color="#2563EB" />}
|
|
||||||
</View>
|
|
||||||
{debouncedSearch.trim().length >= 2 && (
|
|
||||||
<View className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
|
||||||
{(searchResults ?? []).length === 0 && !searching && (
|
|
||||||
<Text className="px-4 py-3 text-gray-400">No clients found</Text>
|
|
||||||
)}
|
|
||||||
{(searchResults ?? []).map((c: any) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={c.id}
|
|
||||||
className="px-4 py-3 border-b border-gray-100"
|
|
||||||
onPress={() => { setClient(c); setSearch(''); setDebouncedSearch(''); }}
|
|
||||||
>
|
|
||||||
<Text className="font-medium text-gray-900">{c.firstName} {c.lastName}</Text>
|
|
||||||
<Text className="text-gray-500 text-sm">{c.accountNumber}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subject */}
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Subject *</Text>
|
|
||||||
<TextInput
|
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-4"
|
|
||||||
placeholder="e.g. No internet connection"
|
|
||||||
value={subject}
|
|
||||||
onChangeText={setSubject}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Category */}
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Category</Text>
|
|
||||||
<View className="flex-row flex-wrap mb-4">
|
|
||||||
{CATEGORIES.map(c => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={c.value}
|
|
||||||
onPress={() => setCategory(c.value)}
|
|
||||||
className={`rounded-xl px-3 py-2 mr-2 mb-2 border ${category === c.value ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}
|
|
||||||
>
|
|
||||||
<Text className={`text-sm font-medium ${category === c.value ? 'text-white' : 'text-gray-700'}`}>
|
|
||||||
{c.label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Priority */}
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Priority</Text>
|
|
||||||
<View className="flex-row mb-4">
|
|
||||||
{PRIORITIES.map(p => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={p}
|
|
||||||
onPress={() => setPriority(p)}
|
|
||||||
className={`flex-1 rounded-xl py-2.5 items-center mx-1 border ${priority === p ? 'border-transparent' : 'bg-white border-gray-200'}`}
|
|
||||||
style={priority === p ? { backgroundColor: PRIORITY_COLOR[p] } : {}}
|
|
||||||
>
|
|
||||||
<Text className={`font-semibold text-sm ${priority === p ? 'text-white' : 'text-gray-600'}`}>{p}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Description <Text className="text-gray-400 font-normal">(optional)</Text></Text>
|
|
||||||
<TextInput
|
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-8"
|
|
||||||
placeholder="Describe the issue in more detail..."
|
|
||||||
value={description}
|
|
||||||
onChangeText={setDescription}
|
|
||||||
multiline
|
|
||||||
numberOfLines={4}
|
|
||||||
textAlignVertical="top"
|
|
||||||
style={{ minHeight: 96 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
className={`rounded-xl py-4 items-center ${client && subject ? 'bg-primary' : 'bg-gray-300'}`}
|
|
||||||
onPress={submit}
|
|
||||||
disabled={loading || !client || !subject}
|
|
||||||
>
|
|
||||||
{loading ? <ActivityIndicator color="white" /> : <Text className="text-white font-bold text-base">Create Ticket</Text>}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<View className="h-8" />
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
183
app/(app)/users/[id].tsx
Normal file
183
app/(app)/users/[id].tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, ScrollView, Alert, ActivityIndicator, Switch } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
|
const ROLE_COLOR: Record<string, string> = { ADMIN: '#7C3AED', STAFF: '#0891B2', TECHNICIAN: '#059669', COLLECTOR: '#D97706' };
|
||||||
|
const ROLE_BG: Record<string, string> = { ADMIN: '#F5F3FF', STAFF: '#ECFEFF', TECHNICIAN: '#F0FDF4', COLLECTOR: '#FFFBEB' };
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ value: 'TECHNICIAN', label: 'Technician', desc: 'Field work & installations' },
|
||||||
|
{ value: 'COLLECTOR', label: 'Collector', desc: 'Payments & remittances' },
|
||||||
|
{ value: 'STAFF', label: 'Staff', desc: 'General access' },
|
||||||
|
{ value: 'ADMIN', label: 'Admin', desc: 'Full access + user mgmt' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
|
return (
|
||||||
|
<View style={{ paddingHorizontal: 20, paddingVertical: 16, borderBottomWidth: 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: '#0F172A' }}>{value ?? '—'}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [editingRole, setEditingRole] = useState(false);
|
||||||
|
const [newRole, setNewRole] = useState('');
|
||||||
|
|
||||||
|
const { data: user, isLoading } = useQuery({
|
||||||
|
queryKey: ['user', id],
|
||||||
|
queryFn: () => api.get(`/api/v1/users/${id}`).then(r => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActive = useMutation({
|
||||||
|
mutationFn: (isActive: boolean) => api.patch(`/api/v1/users/${id}`, { isActive }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }),
|
||||||
|
onError: () => Alert.alert('Error', 'Could not update user status.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeRole = useMutation({
|
||||||
|
mutationFn: (role: string) => api.patch(`/api/v1/users/${id}`, { role }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditingRole(false);
|
||||||
|
qc.invalidateQueries({ queryKey: ['user', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
},
|
||||||
|
onError: () => Alert.alert('Error', 'Could not update role.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 role = user?.roleAssignments?.[0]?.role ?? 'STAFF';
|
||||||
|
const roleColor = ROLE_COLOR[role] ?? '#6B7280';
|
||||||
|
const roleBg = ROLE_BG[role] ?? '#F1F5F9';
|
||||||
|
const initials = `${user?.firstName?.[0] ?? ''}${user?.lastName?.[0] ?? ''}`.toUpperCase();
|
||||||
|
const isActive = user?.isActive ?? true;
|
||||||
|
const lastLogin = user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString('en-PH', { month: 'long', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Never';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
|
<ScrollView style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 24, alignItems: 'center' }}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={{ alignSelf: 'flex-start', marginBottom: 16 }} activeOpacity={0.7}>
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: roleBg, alignItems: 'center', justifyContent: 'center', marginBottom: 12 }}>
|
||||||
|
<Text style={{ fontSize: 26, fontWeight: '800', color: roleColor }}>{initials}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 22, fontWeight: '800' }}>{user?.firstName} {user?.lastName}</Text>
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6, backgroundColor: roleBg, marginTop: 8 }}>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: '700', color: roleColor }}>{role}</Text>
|
||||||
|
</View>
|
||||||
|
{!isActive && (
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6, backgroundColor: '#FEE2E2', marginTop: 6 }}>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: '700', color: '#DC2626' }}>Inactive Account</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ padding: 16 }}>
|
||||||
|
{/* Info Card */}
|
||||||
|
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', marginBottom: 16, overflow: 'hidden' }}>
|
||||||
|
<InfoRow label="Email" value={user?.email} />
|
||||||
|
<InfoRow label="Phone" value={user?.phone} />
|
||||||
|
<InfoRow label="Last Login" value={lastLogin} />
|
||||||
|
<View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '600', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 12 }}>Account Status</Text>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 17, fontWeight: '600', color: isActive ? '#166534' : '#DC2626' }}>
|
||||||
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13, color: '#94A3B8', marginTop: 2 }}>
|
||||||
|
{isActive ? 'User can log in' : 'Login is blocked'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={isActive}
|
||||||
|
onValueChange={(val) => Alert.alert(
|
||||||
|
val ? 'Activate User' : 'Deactivate User',
|
||||||
|
val ? `Allow ${user?.firstName} to log in?` : `Block ${user?.firstName} from logging in?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: val ? 'Activate' : 'Deactivate', onPress: () => toggleActive.mutate(val), style: val ? 'default' : 'destructive' },
|
||||||
|
]
|
||||||
|
)}
|
||||||
|
trackColor={{ false: '#E2E8F0', true: '#0891B2' }}
|
||||||
|
thumbColor="#FFF"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Role Change */}
|
||||||
|
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', marginBottom: 16, overflow: 'hidden' }}>
|
||||||
|
<View style={{ paddingHorizontal: 20, paddingVertical: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: editingRole ? 1 : 0, borderBottomColor: '#F1F5F9' }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A' }}>Role</Text>
|
||||||
|
<TouchableOpacity onPress={() => { setEditingRole(!editingRole); setNewRole(role); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '600', color: '#0891B2' }}>{editingRole ? 'Cancel' : 'Change'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{editingRole && (
|
||||||
|
<View style={{ padding: 16 }}>
|
||||||
|
{ROLES.map(r => {
|
||||||
|
const isSelected = (newRole || role) === r.value;
|
||||||
|
const rc = ROLE_COLOR[r.value] ?? '#6B7280';
|
||||||
|
const rb = ROLE_BG[r.value] ?? '#F1F5F9';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={r.value}
|
||||||
|
onPress={() => setNewRole(r.value)}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', borderRadius: 14, padding: 14, marginBottom: 8, borderWidth: 2, borderColor: isSelected ? rc : '#E2E8F0', backgroundColor: isSelected ? rb : '#FFF' }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: isSelected ? rc : '#0F172A' }}>{r.label}</Text>
|
||||||
|
<Text style={{ fontSize: 13, color: '#64748B' }}>{r.desc}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: isSelected ? rc : '#CBD5E1', backgroundColor: isSelected ? rc : 'transparent', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{isSelected && <View style={{ width: 7, height: 7, borderRadius: 4, backgroundColor: '#FFF' }} />}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: newRole && newRole !== role ? '#0891B2' : '#CBD5E1', borderRadius: 14, paddingVertical: 16, alignItems: 'center', marginTop: 4 }}
|
||||||
|
onPress={() => newRole && newRole !== role && Alert.alert(
|
||||||
|
'Change Role',
|
||||||
|
`Change ${user?.firstName}'s role to ${newRole}?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Change', onPress: () => changeRole.mutate(newRole) },
|
||||||
|
]
|
||||||
|
)}
|
||||||
|
disabled={changeRole.isPending || !newRole || newRole === role}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{changeRole.isPending
|
||||||
|
? <ActivityIndicator color="#FFF" />
|
||||||
|
: <Text style={{ color: '#FFF', fontSize: 16, fontWeight: '700' }}>Apply Role Change</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
app/(app)/users/_layout.tsx
Normal file
4
app/(app)/users/_layout.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
export default function UsersLayout() {
|
||||||
|
return <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
}
|
||||||
141
app/(app)/users/index.tsx
Normal file
141
app/(app)/users/index.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl, Alert } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
|
const ROLE_COLOR: Record<string, string> = {
|
||||||
|
ADMIN: '#7C3AED',
|
||||||
|
STAFF: '#0891B2',
|
||||||
|
TECHNICIAN: '#059669',
|
||||||
|
COLLECTOR: '#D97706',
|
||||||
|
};
|
||||||
|
const ROLE_BG: Record<string, string> = {
|
||||||
|
ADMIN: '#F5F3FF',
|
||||||
|
STAFF: '#ECFEFF',
|
||||||
|
TECHNICIAN: '#F0FDF4',
|
||||||
|
COLLECTOR: '#FFFBEB',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UsersScreen() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: () => api.get('/api/v1/users').then(r => Array.isArray(r.data) ? r.data : r.data?.data ?? []),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActive = useMutation({
|
||||||
|
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||||
|
api.patch(`/api/v1/users/${id}`, { isActive }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||||
|
onError: () => Alert.alert('Error', 'Could not update user.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = (data ?? []).filter((u: any) =>
|
||||||
|
`${u.firstName} ${u.lastName} ${u.email}`.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||||
|
<View>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} activeOpacity={0.7} style={{ marginBottom: 8 }}>
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 15, fontWeight: '600' }}>← Back</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Users</Text>
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>{users.length} members</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 10 }}
|
||||||
|
onPress={() => router.push('/(app)/users/new')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#FFF', fontWeight: '700', fontSize: 15 }}>+ Add User</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<View style={{ backgroundColor: '#FFF', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||||
|
<View style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14 }}>
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1, paddingVertical: 13, fontSize: 16, color: '#0F172A' }}
|
||||||
|
placeholder="Search users..."
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
/>
|
||||||
|
{search.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearch('')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 12, fontWeight: '800', lineHeight: 14 }}>×</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ActivityIndicator color="#0891B2" size="large" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={users}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#0891B2" />}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const role = item.roleAssignments?.[0]?.role ?? 'STAFF';
|
||||||
|
const roleColor = ROLE_COLOR[role] ?? '#6B7280';
|
||||||
|
const roleBg = ROLE_BG[role] ?? '#F1F5F9';
|
||||||
|
const initials = `${item.firstName?.[0] ?? ''}${item.lastName?.[0] ?? ''}`.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 12, borderWidth: 1, borderColor: '#F1F5F9', flexDirection: 'row', alignItems: 'center', opacity: item.isActive ? 1 : 0.5 }}
|
||||||
|
onPress={() => router.push(`/(app)/users/${item.id}`)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<View style={{ width: 48, height: 48, borderRadius: 24, backgroundColor: roleBg, alignItems: 'center', justifyContent: 'center', marginRight: 14 }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '800', color: roleColor }}>{initials}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 3 }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginRight: 8 }}>
|
||||||
|
{item.firstName} {item.lastName}
|
||||||
|
</Text>
|
||||||
|
{!item.isActive && (
|
||||||
|
<View style={{ borderRadius: 10, paddingHorizontal: 8, paddingVertical: 2, backgroundColor: '#FEE2E2' }}>
|
||||||
|
<Text style={{ fontSize: 11, fontWeight: '700', color: '#DC2626' }}>Inactive</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B' }}>{item.email}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Role badge */}
|
||||||
|
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 5, backgroundColor: roleBg }}>
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: '700', color: roleColor }}>{role}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||||||
|
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No users found</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
app/(app)/users/new.tsx
Normal file
175
app/(app)/users/new.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ value: 'TECHNICIAN', label: 'Technician', desc: 'Field work & installations', color: '#059669', bg: '#F0FDF4' },
|
||||||
|
{ value: 'COLLECTOR', label: 'Collector', desc: 'Payments & remittances', color: '#D97706', bg: '#FFFBEB' },
|
||||||
|
{ value: 'STAFF', label: 'Staff', desc: 'General access', color: '#0891B2', bg: '#ECFEFF' },
|
||||||
|
{ value: 'ADMIN', label: 'Admin', desc: 'Full access + user mgmt', color: '#7C3AED', bg: '#F5F3FF' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NewUserScreen() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPass, setShowPass] = useState(false);
|
||||||
|
const [role, setRole] = useState('TECHNICIAN');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isValid = firstName.trim() && lastName.trim() && email.trim() && password.length >= 8;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!isValid) return Alert.alert('Required', 'Please fill all required fields. Password must be at least 8 characters.');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post('/api/v1/users', {
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
phone: phone.trim() || undefined,
|
||||||
|
password,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
Alert.alert('User Created!', `${firstName} ${lastName} can now log in with ${email.trim().toLowerCase()}`, [
|
||||||
|
{ text: 'Add Another', onPress: () => { setFirstName(''); setLastName(''); setEmail(''); setPhone(''); setPassword(''); } },
|
||||||
|
{ text: 'Done', onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.message;
|
||||||
|
Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg ?? 'Could not create user.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 12, paddingBottom: 20 }}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Add User</Text>
|
||||||
|
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>Create a new team member</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
|
||||||
|
{/* Name row */}
|
||||||
|
<View style={{ flexDirection: 'row', marginBottom: 16 }}>
|
||||||
|
<View style={{ flex: 1, marginRight: 8 }}>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>First Name <Text style={{ color: '#DC2626' }}>*</Text></Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: '#0F172A' }}
|
||||||
|
placeholder="Juan"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={firstName}
|
||||||
|
onChangeText={setFirstName}
|
||||||
|
autoCapitalize="words"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, marginLeft: 8 }}>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>Last Name <Text style={{ color: '#DC2626' }}>*</Text></Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: '#0F172A' }}
|
||||||
|
placeholder="Dela Cruz"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={lastName}
|
||||||
|
onChangeText={setLastName}
|
||||||
|
autoCapitalize="words"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>Email <Text style={{ color: '#DC2626' }}>*</Text></Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 16 }}
|
||||||
|
placeholder="juan@yourisp.com"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>
|
||||||
|
Phone <Text style={{ fontSize: 14, fontWeight: '400', color: '#94A3B8' }}>(optional)</Text>
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 16 }}
|
||||||
|
placeholder="09171234567"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={phone}
|
||||||
|
onChangeText={setPhone}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 8 }}>Password <Text style={{ color: '#DC2626' }}>*</Text></Text>
|
||||||
|
<View style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, marginBottom: 6 }}>
|
||||||
|
<TextInput
|
||||||
|
style={{ flex: 1, paddingVertical: 14, fontSize: 16, color: '#0F172A' }}
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry={!showPass}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={() => setShowPass(!showPass)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: '600', color: '#0891B2' }}>{showPass ? 'Hide' : 'Show'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 13, color: '#94A3B8', marginBottom: 20 }}>They can change this after first login.</Text>
|
||||||
|
|
||||||
|
{/* Role */}
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0F172A', marginBottom: 12 }}>Role <Text style={{ color: '#DC2626' }}>*</Text></Text>
|
||||||
|
{ROLES.map(r => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={r.value}
|
||||||
|
onPress={() => setRole(r.value)}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: role === r.value ? r.bg : '#FFF', borderRadius: 16, padding: 18, marginBottom: 10, borderWidth: 2, borderColor: role === r.value ? r.color : '#E2E8F0' }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={{ width: 44, height: 44, borderRadius: 22, backgroundColor: role === r.value ? r.color : '#F1F5F9', alignItems: 'center', justifyContent: 'center', marginRight: 14 }}>
|
||||||
|
<Text style={{ fontSize: 11, fontWeight: '800', color: role === r.value ? '#FFF' : '#94A3B8' }}>{r.value.slice(0,4)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: '700', color: role === r.value ? r.color : '#0F172A' }}>{r.label}</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 2 }}>{r.desc}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ width: 22, height: 22, borderRadius: 11, borderWidth: 2, borderColor: role === r.value ? r.color : '#CBD5E1', backgroundColor: role === r.value ? r.color : 'transparent', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{role === r.value && <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: '#FFF' }} />}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={{ height: 16 }} />
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: isValid ? '#0891B2' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||||||
|
onPress={submit}
|
||||||
|
disabled={loading || !isValid}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator color="#FFF" />
|
||||||
|
: <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Create User</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { api } from '../../services/api';
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
@@ -18,51 +19,51 @@ export default function CompanyCodeScreen() {
|
|||||||
Alert.alert('Not Found', 'Company code not found. Please check and try again.');
|
Alert.alert('Not Found', 'Company code not found. Please check and try again.');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Alert.alert('Error', 'Could not verify company code. Please try again.');
|
Alert.alert('Error', 'Could not verify. Please try again.');
|
||||||
} finally {
|
} finally { setLoading(false); }
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#FFF' }}>
|
||||||
className="flex-1 bg-white"
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
<View style={{ flex: 1, justifyContent: 'center', paddingHorizontal: 24 }}>
|
||||||
>
|
{/* Logo */}
|
||||||
<View className="flex-1 justify-center px-8">
|
<View style={{ alignItems: 'center', marginBottom: 48 }}>
|
||||||
<View className="mb-10 items-center">
|
<View style={{ width: 84, height: 84, borderRadius: 24, backgroundColor: '#0891B2', alignItems: 'center', justifyContent: 'center', marginBottom: 16 }}>
|
||||||
<View className="w-16 h-16 rounded-2xl bg-primary items-center justify-center mb-4">
|
<Text style={{ color: '#FFF', fontSize: 40, fontWeight: '900' }}>F</Text>
|
||||||
<Text className="text-white text-3xl font-bold">F</Text>
|
</View>
|
||||||
|
<Text style={{ fontSize: 32, fontWeight: '900', color: '#0F172A', letterSpacing: -0.5 }}>FiberOps</Text>
|
||||||
|
<Text style={{ fontSize: 16, color: '#94A3B8', marginTop: 4 }}>Field Operations Platform</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-3xl font-bold text-gray-900">FiberOps</Text>
|
|
||||||
<Text className="text-gray-500 mt-1">Field Operations</Text>
|
<Text style={{ fontSize: 22, fontWeight: '800', color: '#0F172A', marginBottom: 6 }}>Enter Company Code</Text>
|
||||||
|
<Text style={{ fontSize: 16, color: '#64748B', marginBottom: 20, lineHeight: 22 }}>Ask your admin for your company's unique code.</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 16, fontSize: 17, color: '#0F172A', marginBottom: 16 }}
|
||||||
|
placeholder="e.g. demo-isp"
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
value={slug}
|
||||||
|
onChangeText={setSlug}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoFocus
|
||||||
|
onSubmitEditing={handleContinue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{ backgroundColor: '#0891B2', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||||||
|
onPress={handleContinue}
|
||||||
|
disabled={loading}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator color="#FFF" />
|
||||||
|
: <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Continue →</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
<Text className="text-xl font-semibold text-gray-900 mb-2">Enter Company Code</Text>
|
</SafeAreaView>
|
||||||
<Text className="text-gray-500 mb-6">Ask your admin for your company's unique code.</Text>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
className="border border-gray-300 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
|
||||||
placeholder="e.g. mybusiness"
|
|
||||||
value={slug}
|
|
||||||
onChangeText={setSlug}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
className="bg-primary rounded-xl py-4 items-center"
|
|
||||||
onPress={handleContinue}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<ActivityIndicator color="white" />
|
|
||||||
) : (
|
|
||||||
<Text className="text-white font-semibold text-base">Continue</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, Alert, KeyboardAvoidingView, Platform } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const { tenantSlug } = useLocalSearchParams<{ tenantSlug: string }>();
|
const { tenantSlug } = useLocalSearchParams<{ tenantSlug: string }>();
|
||||||
const [username, setUsername] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const { login, isLoading } = useAuthStore();
|
const { login, isLoading } = useAuthStore();
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!username.trim() || !password) return Alert.alert('Required', 'Please enter username and password.');
|
if (!email.trim() || !password) return Alert.alert('Required', 'Please enter email and password.');
|
||||||
try {
|
try {
|
||||||
await login(tenantSlug, username.trim(), password);
|
await login(tenantSlug, email.trim(), password);
|
||||||
router.replace('/(app)/dashboard');
|
router.replace('/(app)/dashboard');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.response?.data?.message ?? 'Login failed. Check your credentials.';
|
const msg = e?.response?.data?.message ?? 'Login failed. Check your credentials.';
|
||||||
@@ -21,52 +23,61 @@ export default function LoginScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#FFF' }}>
|
||||||
className="flex-1 bg-white"
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
<View style={{ flex: 1, justifyContent: 'center', paddingHorizontal: 24 }}>
|
||||||
>
|
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 32 }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
<View className="flex-1 justify-center px-8">
|
<Text style={{ fontSize: 17, fontWeight: '600', color: '#0891B2' }}>← Back</Text>
|
||||||
<TouchableOpacity className="mb-8" onPress={() => router.back()}>
|
</TouchableOpacity>
|
||||||
<Text className="text-primary text-base">← Back</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<Text className="text-2xl font-bold text-gray-900 mb-1">Welcome back</Text>
|
<Text style={{ fontSize: 32, fontWeight: '900', color: '#0F172A', letterSpacing: -0.5, marginBottom: 6 }}>Welcome back</Text>
|
||||||
<Text className="text-gray-500 mb-8">
|
<Text style={{ fontSize: 16, color: '#64748B', marginBottom: 32 }}>
|
||||||
Signing in to <Text className="font-semibold text-gray-700">{tenantSlug}</Text>
|
Signing in to <Text style={{ fontWeight: '700', color: '#0F172A' }}>{tenantSlug}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-sm font-medium text-gray-700 mb-1">Username</Text>
|
{/* Email */}
|
||||||
<TextInput
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#374151', marginBottom: 8 }}>Email</Text>
|
||||||
className="border border-gray-300 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
<TextInput
|
||||||
placeholder="Enter username"
|
style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 16, fontSize: 17, color: '#0F172A', marginBottom: 16 }}
|
||||||
value={username}
|
placeholder="Enter your email"
|
||||||
onChangeText={setUsername}
|
placeholderTextColor="#94A3B8"
|
||||||
autoCapitalize="none"
|
value={email}
|
||||||
autoCorrect={false}
|
onChangeText={setEmail}
|
||||||
autoFocus
|
autoCapitalize="none"
|
||||||
/>
|
keyboardType="email-address"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
<Text className="text-sm font-medium text-gray-700 mb-1">Password</Text>
|
{/* Password */}
|
||||||
<TextInput
|
<Text style={{ fontSize: 15, fontWeight: '700', color: '#374151', marginBottom: 8 }}>Password</Text>
|
||||||
className="border border-gray-300 rounded-xl px-4 py-3 text-base text-gray-900 mb-6"
|
<View style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 18, marginBottom: 28 }}>
|
||||||
placeholder="Enter password"
|
<TextInput
|
||||||
value={password}
|
style={{ flex: 1, paddingVertical: 16, fontSize: 17, color: '#0F172A' }}
|
||||||
onChangeText={setPassword}
|
placeholder="Enter your password"
|
||||||
secureTextEntry
|
placeholderTextColor="#94A3B8"
|
||||||
/>
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
onSubmitEditing={handleLogin}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={() => setShowPassword(!showPassword)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<Text style={{ fontSize: 15, fontWeight: '600', color: '#0891B2' }}>{showPassword ? 'Hide' : 'Show'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="bg-primary rounded-xl py-4 items-center"
|
style={{ backgroundColor: '#0891B2', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
activeOpacity={0.8}
|
||||||
{isLoading ? (
|
>
|
||||||
<ActivityIndicator color="white" />
|
{isLoading
|
||||||
) : (
|
? <ActivityIndicator color="#FFF" />
|
||||||
<Text className="text-white font-semibold text-base">Sign In</Text>
|
: <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Sign In</Text>
|
||||||
)}
|
}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/adaptive-icon.png
Normal file
BIN
assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
44
components/Icon.tsx
Normal file
44
components/Icon.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Svg, Path, Circle, Rect } from 'react-native-svg';
|
||||||
|
|
||||||
|
type IconName = 'home' | 'users' | 'collect' | 'ticket' | 'user' | 'arrow-left' | 'phone' | 'refresh' | 'send' | 'plus' | 'check' | 'location' | 'camera';
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
name: IconName;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({ name, size = 24, color = '#111827' }: IconProps) {
|
||||||
|
const props = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none' };
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'home':
|
||||||
|
return <Svg {...props}><Path d="M3 12L12 3l9 9" stroke={color} strokeWidth="2" strokeLinecap="round"/><Path d="M9 21V12h6v9" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/><Path d="M5 10v11h14V10" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></Svg>;
|
||||||
|
case 'users':
|
||||||
|
return <Svg {...props}><Circle cx="9" cy="7" r="4" stroke={color} strokeWidth="2"/><Path d="M3 21v-2a4 4 0 014-4h4a4 4 0 014 4v2" stroke={color} strokeWidth="2" strokeLinecap="round"/><Path d="M16 3.13a4 4 0 010 7.75M21 21v-2a4 4 0 00-3-3.87" stroke={color} strokeWidth="2" strokeLinecap="round"/></Svg>;
|
||||||
|
case 'collect':
|
||||||
|
return <Svg {...props}><Rect x="2" y="5" width="20" height="14" rx="2" stroke={color} strokeWidth="2"/><Path d="M2 10h20" stroke={color} strokeWidth="2"/></Svg>;
|
||||||
|
case 'ticket':
|
||||||
|
return <Svg {...props}><Path d="M2 9a3 3 0 110 6V9zM22 9v6a3 3 0 110-6v0" stroke={color} strokeWidth="2"/><Rect x="2" y="6" width="20" height="12" rx="2" stroke={color} strokeWidth="2"/><Path d="M9 12h6" stroke={color} strokeWidth="2" strokeLinecap="round"/></Svg>;
|
||||||
|
case 'user':
|
||||||
|
return <Svg {...props}><Circle cx="12" cy="8" r="4" stroke={color} strokeWidth="2"/><Path d="M4 20v-1a8 8 0 0116 0v1" stroke={color} strokeWidth="2" strokeLinecap="round"/></Svg>;
|
||||||
|
case 'arrow-left':
|
||||||
|
return <Svg {...props}><Path d="M19 12H5M12 19l-7-7 7-7" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></Svg>;
|
||||||
|
case 'phone':
|
||||||
|
return <Svg {...props}><Path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.8 19.79 19.79 0 01.06 1.18 2 2 0 012.03 0h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.09 7.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 14.92v2z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></Svg>;
|
||||||
|
case 'refresh':
|
||||||
|
return <Svg {...props}><Path d="M23 4v6h-6M1 20v-6h6" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/><Path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></Svg>;
|
||||||
|
case 'send':
|
||||||
|
return <Svg {...props}><Path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></Svg>;
|
||||||
|
case 'plus':
|
||||||
|
return <Svg {...props}><Path d="M12 5v14M5 12h14" stroke={color} strokeWidth="2" strokeLinecap="round"/></Svg>;
|
||||||
|
case 'check':
|
||||||
|
return <Svg {...props}><Path d="M20 6L9 17l-5-5" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></Svg>;
|
||||||
|
case 'location':
|
||||||
|
return <Svg {...props}><Path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke={color} strokeWidth="2"/><Circle cx="12" cy="9" r="2.5" stroke={color} strokeWidth="2"/></Svg>;
|
||||||
|
case 'camera':
|
||||||
|
return <Svg {...props}><Path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/><Circle cx="12" cy="13" r="4" stroke={color} strokeWidth="2"/></Svg>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://192.168.1.167:3001';
|
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://192.168.1.167:3001';
|
||||||
|
|
||||||
export const COLORS = {
|
export const COLORS = {
|
||||||
primary: '#2563EB',
|
primary: '#0891B2',
|
||||||
primaryDark: '#1D4ED8',
|
primaryDark: '#0E7490',
|
||||||
danger: '#DC2626',
|
danger: '#DC2626',
|
||||||
success: '#16A34A',
|
success: '#16A34A',
|
||||||
warning: '#D97706',
|
warning: '#D97706',
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
const { getDefaultConfig } = require('expo/metro-config');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
const { withNativeWind } = require('nativewind/metro');
|
const { withNativeWind } = require('nativewind/metro');
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
// Allow Metro to resolve assets (png/jpg) from inside node_modules
|
|
||||||
config.resolver.assetExts.push('png', 'jpg', 'jpeg', 'gif', 'webp');
|
|
||||||
|
|
||||||
module.exports = withNativeWind(config, { input: './global.css' });
|
module.exports = withNativeWind(config, { input: './global.css' });
|
||||||
|
|||||||
3727
package-lock.json
generated
3727
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -9,33 +9,34 @@
|
|||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@tanstack/react-query": "^5.95.0",
|
"@tanstack/react-query": "^5.95.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"expo": "~55.0.8",
|
"expo": "~54.0.33",
|
||||||
"expo-camera": "^55.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "^55.0.9",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-image-picker": "^55.0.13",
|
"expo-dev-client": "^55.0.18",
|
||||||
"expo-linking": "^55.0.8",
|
"expo-image-picker": "~17.0.10",
|
||||||
"expo-location": "^55.1.4",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-notifications": "^55.0.13",
|
"expo-location": "~19.0.8",
|
||||||
"expo-router": "^55.0.7",
|
"expo-notifications": "~0.32.16",
|
||||||
"expo-secure-store": "^55.0.9",
|
"expo-router": "~6.0.23",
|
||||||
"expo-status-bar": "~55.0.4",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-updates": "~55.0.15",
|
"expo-status-bar": "~3.0.9",
|
||||||
"hermes-parser": "0.32.0",
|
"expo-updates": "~29.0.16",
|
||||||
"nativewind": "^4.1.23",
|
"nativewind": "^4.1.23",
|
||||||
"react": "19.2.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.83.2",
|
"react-native": "0.81.5",
|
||||||
"react-native-reanimated": "4.2.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "^5.6.2",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "^4.23.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-svg": "^15.15.4",
|
||||||
|
"react-native-worklets": "^0.8.1",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@expo/ngrok": "^4.1.3",
|
"@expo/ngrok": "^4.1.3",
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.1.10",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const api = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use(async (config) => {
|
api.interceptors.request.use(async (config) => {
|
||||||
const token = await SecureStore.getItemAsync(STORAGE_KEYS.TOKEN);
|
const token = await SecureStore.getItemAsync('fiberops_token');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,8 @@ api.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
await SecureStore.deleteItemAsync(STORAGE_KEYS.TOKEN);
|
await SecureStore.deleteItemAsync('fiberops_token');
|
||||||
await SecureStore.deleteItemAsync(STORAGE_KEYS.USER);
|
await SecureStore.deleteItemAsync('fiberops_tenant');
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { create } from 'zustand';
|
|||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'fiberops_token';
|
||||||
|
const TENANT_KEY = 'fiberops_tenant';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
tenantSlug: string | null;
|
tenantSlug: string | null;
|
||||||
user: any | null;
|
user: any | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (tenantSlug: string, username: string, password: string) => Promise<void>;
|
login: (tenantSlug: string, email: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -20,8 +23,8 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
|
|
||||||
hydrate: async () => {
|
hydrate: async () => {
|
||||||
try {
|
try {
|
||||||
const token = await SecureStore.getItemAsync('auth_token');
|
const token = await SecureStore.getItemAsync(TOKEN_KEY);
|
||||||
const tenantSlug = await SecureStore.getItemAsync('tenant_slug');
|
const tenantSlug = await SecureStore.getItemAsync(TENANT_KEY);
|
||||||
if (token && tenantSlug) {
|
if (token && tenantSlug) {
|
||||||
const res = await api.get('/api/v1/auth/me');
|
const res = await api.get('/api/v1/auth/me');
|
||||||
set({ token, tenantSlug, user: res.data, isLoading: false });
|
set({ token, tenantSlug, user: res.data, isLoading: false });
|
||||||
@@ -29,23 +32,23 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await SecureStore.deleteItemAsync('auth_token');
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||||
await SecureStore.deleteItemAsync('tenant_slug');
|
await SecureStore.deleteItemAsync(TENANT_KEY);
|
||||||
set({ token: null, tenantSlug: null, user: null, isLoading: false });
|
set({ token: null, tenantSlug: null, user: null, isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
login: async (tenantSlug, username, password) => {
|
login: async (tenantSlug, email, password) => {
|
||||||
const res = await api.post('/api/v1/auth/login', { tenantSlug, username, password });
|
const res = await api.post('/api/v1/auth/login', { tenantSlug, email, password });
|
||||||
const { token, user } = res.data;
|
const { accessToken, user } = res.data;
|
||||||
await SecureStore.setItemAsync('auth_token', token);
|
await SecureStore.setItemAsync(TOKEN_KEY, accessToken);
|
||||||
await SecureStore.setItemAsync('tenant_slug', tenantSlug);
|
await SecureStore.setItemAsync(TENANT_KEY, tenantSlug);
|
||||||
set({ token, tenantSlug, user });
|
set({ token: accessToken, tenantSlug, user });
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
await SecureStore.deleteItemAsync('auth_token');
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||||
await SecureStore.deleteItemAsync('tenant_slug');
|
await SecureStore.deleteItemAsync(TENANT_KEY);
|
||||||
set({ token: null, tenantSlug: null, user: null });
|
set({ token: null, tenantSlug: null, user: null });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: '#2563EB',
|
primary: '#0891B2',
|
||||||
'primary-dark': '#1D4ED8',
|
'primary-dark': '#1D4ED8',
|
||||||
danger: '#DC2626',
|
danger: '#DC2626',
|
||||||
success: '#16A34A',
|
success: '#16A34A',
|
||||||
|
|||||||
@@ -4,12 +4,24 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"],
|
"@/*": [
|
||||||
"@/components/*": ["./components/*"],
|
"./*"
|
||||||
"@/stores/*": ["./stores/*"],
|
],
|
||||||
"@/services/*": ["./services/*"],
|
"@/components/*": [
|
||||||
"@/hooks/*": ["./hooks/*"],
|
"./components/*"
|
||||||
"@/constants/*": ["./constants/*"]
|
],
|
||||||
|
"@/stores/*": [
|
||||||
|
"./stores/*"
|
||||||
|
],
|
||||||
|
"@/services/*": [
|
||||||
|
"./services/*"
|
||||||
|
],
|
||||||
|
"@/hooks/*": [
|
||||||
|
"./hooks/*"
|
||||||
|
],
|
||||||
|
"@/constants/*": [
|
||||||
|
"./constants/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -17,7 +29,6 @@
|
|||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
"nativewind-env.d.ts",
|
"nativewind-env.d.ts",
|
||||||
"types/**/*.d.ts",
|
"types/**/*.d.ts",
|
||||||
".expo/types/**/*.d.ts",
|
".expo/types/**/*.d.ts"
|
||||||
"expo-env.d.ts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user