feat: complete all screens - client tabs, payments list, remittance detail, new ticket, installations tab
- Client detail: Subscription/Invoices/Payments tabs now fully functional - Payments: proper list with today's total + live search prefill from client - Record payment: debounced live search, reference required for non-cash - Remittances: detail screen with included payments breakdown - Tickets: status filter chips + create button, new ticket with categories - Installations: tab now visible with list + confirm flow - Fix: remove duplicate @react-navigation/elements causing Metro asset error - Fix: metro.config.js asset resolution from node_modules
This commit is contained in:
11
app.json
11
app.json
@@ -23,12 +23,17 @@
|
|||||||
},
|
},
|
||||||
"package": "com.fiberops.mobile",
|
"package": "com.fiberops.mobile",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.RECORD_AUDIO",
|
||||||
|
"android.permission.ACCESS_COARSE_LOCATION",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION",
|
||||||
"android.permission.CAMERA",
|
"android.permission.CAMERA",
|
||||||
"android.permission.RECORD_AUDIO",
|
"android.permission.RECORD_AUDIO",
|
||||||
"android.permission.ACCESS_COARSE_LOCATION",
|
"android.permission.ACCESS_COARSE_LOCATION",
|
||||||
"android.permission.ACCESS_FINE_LOCATION"
|
"android.permission.ACCESS_FINE_LOCATION"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"platforms": ["android", "ios"],
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
@@ -61,6 +66,12 @@
|
|||||||
"eas": {
|
"eas": {
|
||||||
"projectId": "15d21320-57f5-4fb6-a116-82d222d914e2"
|
"projectId": "15d21320-57f5-4fb6-a116-82d222d914e2"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"url": "https://u.expo.dev/15d21320-57f5-4fb6-a116-82d222d914e2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,14 @@ export default function AppLayout() {
|
|||||||
name="tickets"
|
name="tickets"
|
||||||
options={{ title: 'Tickets', tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>🎫</Text> }}
|
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 }) => <Text style={{ color, fontSize: 20 }}>👤</Text> }}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen name="installations" options={{ href: null }} />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,35 @@ import { api } from '../../../services/api';
|
|||||||
|
|
||||||
const TABS = ['Profile', 'Subscription', 'Invoices', 'Payments'];
|
const TABS = ['Profile', 'Subscription', 'Invoices', 'Payments'];
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
ACTIVE: '#16A34A', SUSPENDED: '#D97706', CANCELLED: '#DC2626', PENDING: '#6B7280',
|
||||||
|
};
|
||||||
|
|
||||||
|
const INV_STATUS: Record<string, { label: string; color: string }> = {
|
||||||
|
PAID: { label: 'Paid', color: '#16A34A' },
|
||||||
|
UNPAID: { label: 'Unpaid', color: '#D97706' },
|
||||||
|
OVERDUE: { label: 'Overdue', color: '#DC2626' },
|
||||||
|
PARTIAL: { label: 'Partial', color: '#2563EB' },
|
||||||
|
VOID: { label: 'Void', color: '#6B7280' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYMENT_METHODS: Record<string, string> = {
|
||||||
|
CASH: '💵', GCASH: '📱', MAYA: '💙', BANK: '🏦',
|
||||||
|
};
|
||||||
|
|
||||||
|
function InfoRow({ label, value, onPress, isLast }: { label: string; value?: string | null; onPress?: () => void; isLast?: boolean }) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!onPress}
|
||||||
|
onPress={onPress}
|
||||||
|
className={`px-4 py-3 ${!isLast ? 'border-b border-gray-100' : ''}`}
|
||||||
|
>
|
||||||
|
<Text className="text-gray-500 text-xs">{label}</Text>
|
||||||
|
<Text className={`font-medium mt-0.5 ${onPress ? 'text-primary' : 'text-gray-900'}`}>{value ?? '—'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClientDetailScreen() {
|
export default function ClientDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const [tab, setTab] = useState('Profile');
|
const [tab, setTab] = useState('Profile');
|
||||||
@@ -15,54 +44,175 @@ export default function ClientDetailScreen() {
|
|||||||
queryFn: () => api.get(`/api/v1/clients/${id}`).then(r => r.data),
|
queryFn: () => api.get(`/api/v1/clients/${id}`).then(r => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>;
|
const { data: subData, isLoading: subLoading } = useQuery({
|
||||||
|
queryKey: ['client-subscription', id],
|
||||||
|
queryFn: () => api.get(`/api/v1/subscriptions?clientId=${id}&limit=1`).then(r => r.data?.data?.[0] ?? r.data?.[0] ?? null),
|
||||||
|
enabled: tab === 'Subscription',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: invoices, isLoading: invLoading } = useQuery({
|
||||||
|
queryKey: ['client-invoices', id],
|
||||||
|
queryFn: () => api.get(`/api/v1/invoices?clientId=${id}&limit=20`).then(r => r.data?.data ?? r.data ?? []),
|
||||||
|
enabled: tab === 'Invoices',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: payments, isLoading: payLoading } = useQuery({
|
||||||
|
queryKey: ['client-payments', id],
|
||||||
|
queryFn: () => api.get(`/api/v1/payments?clientId=${id}&limit=20`).then(r => r.data?.data ?? r.data ?? []),
|
||||||
|
enabled: tab === 'Payments',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-gray-50">
|
||||||
|
<ActivityIndicator color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
<TouchableOpacity onPress={() => router.back()} className="mr-3 p-1">
|
||||||
<Text className="text-white text-lg">←</Text>
|
<Text className="text-white text-lg">←</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View>
|
<View className="flex-1">
|
||||||
<Text className="text-white font-bold text-lg">{client?.firstName} {client?.lastName}</Text>
|
<Text className="text-white font-bold text-lg">{client?.firstName} {client?.lastName}</Text>
|
||||||
<Text className="text-white/70 text-sm">{client?.accountNumber}</Text>
|
<Text className="text-white/70 text-sm">{client?.accountNumber}</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
<View className="flex-row bg-white border-b border-gray-100">
|
<View className="flex-row bg-white border-b border-gray-100">
|
||||||
{TABS.map(t => (
|
{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'}`}>
|
<TouchableOpacity
|
||||||
<Text className={`text-sm font-medium ${tab === t ? 'text-primary' : 'text-gray-500'}`}>{t}</Text>
|
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>
|
</View>
|
||||||
|
|
||||||
<ScrollView className="flex-1 px-4 py-4">
|
<ScrollView className="flex-1 px-4 py-4">
|
||||||
|
{/* PROFILE TAB */}
|
||||||
{tab === 'Profile' && (
|
{tab === 'Profile' && (
|
||||||
<View className="bg-white rounded-2xl border border-gray-100">
|
<View className="bg-white rounded-2xl border border-gray-100">
|
||||||
{[
|
<InfoRow label="Account #" value={client?.accountNumber} />
|
||||||
{ label: 'Account #', value: client?.accountNumber },
|
<InfoRow label="Email" value={client?.email} />
|
||||||
{ label: 'Status', value: client?.status },
|
<InfoRow label="Phone" value={client?.phone} onPress={() => client?.phone && Linking.openURL(`tel:${client.phone}`)} />
|
||||||
{ label: 'Email', value: client?.email },
|
<InfoRow label="Address" value={client?.address} />
|
||||||
{ label: 'Phone', value: client?.phone, onPress: () => client?.phone && Linking.openURL(`tel:${client.phone}`) },
|
<InfoRow label="Area" value={client?.area?.name} />
|
||||||
{ label: 'Address', value: client?.address },
|
<InfoRow label="Joined" value={client?.createdAt ? new Date(client.createdAt).toLocaleDateString() : undefined} isLast />
|
||||||
{ label: 'Area', value: client?.area?.name },
|
|
||||||
].map((item, i) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={item.label}
|
|
||||||
disabled={!item.onPress}
|
|
||||||
onPress={item.onPress}
|
|
||||||
className={`px-4 py-3 ${i > 0 ? 'border-t border-gray-100' : ''}`}
|
|
||||||
>
|
|
||||||
<Text className="text-gray-500 text-xs">{item.label}</Text>
|
|
||||||
<Text className={`font-medium mt-0.5 ${item.onPress ? 'text-primary' : 'text-gray-900'}`}>{item.value ?? '—'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{tab === 'Subscription' && <Text className="text-gray-500 text-center py-10">Subscription details coming soon</Text>}
|
|
||||||
{tab === 'Invoices' && <Text className="text-gray-500 text-center py-10">Invoices coming soon</Text>}
|
{/* SUBSCRIPTION TAB */}
|
||||||
{tab === 'Payments' && <Text className="text-gray-500 text-center py-10">Payments coming soon</Text>}
|
{tab === 'Subscription' && (
|
||||||
|
subLoading ? (
|
||||||
|
<View className="py-16 items-center"><ActivityIndicator color="#2563EB" /></View>
|
||||||
|
) : !subData ? (
|
||||||
|
<View className="items-center py-16">
|
||||||
|
<Text className="text-4xl mb-3">📡</Text>
|
||||||
|
<Text className="text-gray-400">No active subscription</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View>
|
||||||
|
<View className="bg-white rounded-2xl border border-gray-100 mb-3">
|
||||||
|
<InfoRow label="Plan" value={subData.plan?.name} />
|
||||||
|
<InfoRow label="Speed" value={subData.plan?.speedMbps ? `${subData.plan.speedMbps} Mbps` : undefined} />
|
||||||
|
<InfoRow label="Monthly Rate" value={subData.plan?.price ? `₱${Number(subData.plan.price).toLocaleString()}` : undefined} />
|
||||||
|
<InfoRow label="Status" value={subData.status} />
|
||||||
|
<InfoRow label="Started" value={subData.startDate ? new Date(subData.startDate).toLocaleDateString() : undefined} />
|
||||||
|
<InfoRow label="Billing Cycle" value={subData.billingCycleDay ? `Day ${subData.billingCycleDay} of month` : undefined} isLast />
|
||||||
|
</View>
|
||||||
|
{subData.nextBillingDate && (
|
||||||
|
<View className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<Text className="text-blue-700 text-sm">
|
||||||
|
📅 Next billing: <Text className="font-bold">{new Date(subData.nextBillingDate).toLocaleDateString()}</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* INVOICES TAB */}
|
||||||
|
{tab === 'Invoices' && (
|
||||||
|
invLoading ? (
|
||||||
|
<View className="py-16 items-center"><ActivityIndicator color="#2563EB" /></View>
|
||||||
|
) : !invoices?.length ? (
|
||||||
|
<View className="items-center py-16">
|
||||||
|
<Text className="text-4xl mb-3">🧾</Text>
|
||||||
|
<Text className="text-gray-400">No invoices yet</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
invoices.map((inv: any) => {
|
||||||
|
const st = INV_STATUS[inv.status] ?? { label: inv.status, color: '#6B7280' };
|
||||||
|
return (
|
||||||
|
<View key={inv.id} className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
|
||||||
|
<View className="flex-row justify-between items-start mb-1">
|
||||||
|
<Text className="font-semibold text-gray-900">{inv.invoiceNumber}</Text>
|
||||||
|
<View className="rounded-full px-2 py-0.5" style={{ backgroundColor: `${st.color}20` }}>
|
||||||
|
<Text className="text-xs font-medium" style={{ color: st.color }}>{st.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-gray-500 text-sm">{inv.dueDate ? new Date(inv.dueDate).toLocaleDateString() : '—'}</Text>
|
||||||
|
<Text className="font-bold text-gray-900">₱{Number(inv.amount ?? inv.totalAmount).toLocaleString()}</Text>
|
||||||
|
</View>
|
||||||
|
{inv.balance > 0 && (
|
||||||
|
<Text className="text-red-500 text-xs mt-1">Balance: ₱{Number(inv.balance).toLocaleString()}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PAYMENTS TAB */}
|
||||||
|
{tab === 'Payments' && (
|
||||||
|
payLoading ? (
|
||||||
|
<View className="py-16 items-center"><ActivityIndicator color="#2563EB" /></View>
|
||||||
|
) : !payments?.length ? (
|
||||||
|
<View className="items-center py-16">
|
||||||
|
<Text className="text-4xl mb-3">💳</Text>
|
||||||
|
<Text className="text-gray-400">No payments recorded</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-primary rounded-xl py-3 items-center mb-4"
|
||||||
|
onPress={() => router.push({ pathname: '/(app)/payments/record', params: { prefillClientId: id, prefillName: `${client?.firstName} ${client?.lastName}`, prefillAccountNumber: client?.accountNumber } })}
|
||||||
|
>
|
||||||
|
<Text className="text-white font-semibold">+ Record Payment</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{payments.map((p: any) => (
|
||||||
|
<View key={p.id} className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
|
||||||
|
<View className="flex-row justify-between items-start">
|
||||||
|
<View>
|
||||||
|
<Text className="font-semibold text-gray-900">
|
||||||
|
{PAYMENT_METHODS[p.paymentMethod] ?? '💳'} {p.paymentMethod}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-500 text-sm mt-0.5">
|
||||||
|
{p.paymentDate ? new Date(p.paymentDate).toLocaleDateString() : new Date(p.createdAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
{p.referenceNumber && (
|
||||||
|
<Text className="text-gray-400 text-xs mt-0.5">Ref: {p.referenceNumber}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className="font-bold text-green-700 text-base">₱{Number(p.amount).toLocaleString()}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import { View, Text, ScrollView, RefreshControl, ActivityIndicator } from 'react-native';
|
import { View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
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';
|
||||||
|
|
||||||
function KpiCard({ label, value, color }: { label: string; value: string | number; color: string }) {
|
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#6B7280' };
|
||||||
|
const TICKET_STATUS_COLOR: Record<string, string> = { OPEN: '#2563EB', IN_PROGRESS: '#D97706', RESOLVED: '#16A34A', CLOSED: '#6B7280' };
|
||||||
|
|
||||||
|
function KpiCard({ label, value, color, onPress }: { label: string; value: string | number; color: string; onPress?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<View className="bg-white rounded-2xl p-4 flex-1 mx-1 shadow-sm border border-gray-100">
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={!onPress}
|
||||||
|
className="bg-white rounded-2xl p-4 flex-1 mx-1 shadow-sm border border-gray-100"
|
||||||
|
>
|
||||||
<Text className="text-gray-500 text-xs mb-1">{label}</Text>
|
<Text className="text-gray-500 text-xs mb-1">{label}</Text>
|
||||||
<Text className={`text-2xl font-bold`} style={{ color }}>{value}</Text>
|
<Text className="text-2xl font-bold" style={{ color }}>{value}</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickAction({ icon, label, onPress }: { icon: string; label: string; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity className="flex-1 items-center bg-white rounded-2xl py-4 mx-1 border border-gray-100" onPress={onPress}>
|
||||||
|
<Text className="text-2xl mb-1">{icon}</Text>
|
||||||
|
<Text className="text-xs text-gray-600 font-medium">{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,14 +36,23 @@ export default function DashboardScreen() {
|
|||||||
queryFn: () => api.get('/api/v1/dashboard/summary').then(r => r.data),
|
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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1 bg-gray-50"
|
className="flex-1 bg-gray-50"
|
||||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
||||||
>
|
>
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary">
|
{/* Header */}
|
||||||
<Text className="text-white text-sm opacity-80">Welcome back,</Text>
|
<View className="px-4 pt-14 pb-6 bg-primary">
|
||||||
<Text className="text-white text-xl font-bold">{user?.firstName ?? 'Field Staff'}</Text>
|
<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>
|
</View>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -35,27 +61,77 @@ export default function DashboardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="px-3 py-4">
|
<View className="px-3 py-4">
|
||||||
|
{/* KPIs */}
|
||||||
<Text className="text-gray-700 font-semibold mb-3 px-1">Overview</Text>
|
<Text className="text-gray-700 font-semibold mb-3 px-1">Overview</Text>
|
||||||
<View className="flex-row mb-3">
|
<View className="flex-row mb-2">
|
||||||
<KpiCard label="Total Clients" value={data?.totalClients ?? 0} color="#2563EB" />
|
<KpiCard
|
||||||
<KpiCard label="Active Subs" value={data?.activeSubscriptions ?? 0} color="#16A34A" />
|
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-6">
|
<View className="flex-row mb-5">
|
||||||
<KpiCard label="Overdue" value={data?.overdueInvoices ?? 0} color="#DC2626" />
|
<KpiCard
|
||||||
<KpiCard label="Today Collections" value={`₱${(data?.todayCollections ?? 0).toLocaleString()}`} color="#D97706" />
|
label="Overdue"
|
||||||
|
value={data?.overdueInvoices ?? 0}
|
||||||
|
color="#DC2626"
|
||||||
|
onPress={() => router.push('/(app)/clients')}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Today's Collections"
|
||||||
|
value={`₱${(data?.todayCollections ?? 0).toLocaleString()}`}
|
||||||
|
color="#D97706"
|
||||||
|
onPress={() => router.push('/(app)/payments')}
|
||||||
|
/>
|
||||||
</View>
|
</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>
|
<Text className="text-gray-700 font-semibold mb-3 px-1">Recent Tickets</Text>
|
||||||
{(data?.recentTickets ?? []).length === 0 ? (
|
{(data?.recentTickets ?? []).length === 0 ? (
|
||||||
<View className="bg-white rounded-2xl p-6 items-center border border-gray-100">
|
<View className="bg-white rounded-2xl p-6 items-center border border-gray-100">
|
||||||
<Text className="text-gray-400">No recent tickets</Text>
|
<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) => (
|
(data?.recentTickets ?? []).map((t: any) => (
|
||||||
<View key={t.id} className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
|
<TouchableOpacity
|
||||||
<Text className="font-medium text-gray-900">{t.subject}</Text>
|
key={t.id}
|
||||||
<Text className="text-gray-500 text-sm mt-1">{t.clientName} · {t.status}</Text>
|
className="bg-white rounded-xl p-4 mb-2 border border-gray-100"
|
||||||
</View>
|
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>
|
||||||
|
|||||||
110
app/(app)/installations/index.tsx
Normal file
110
app/(app)/installations/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,87 @@
|
|||||||
import { View, Text, TouchableOpacity } from 'react-native';
|
import { useState } from 'react';
|
||||||
|
import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native';
|
||||||
|
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() {
|
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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary">
|
{/* Header */}
|
||||||
<Text className="text-white text-xl font-bold">Payments</Text>
|
<View className="px-4 pt-14 pb-4 bg-primary flex-row justify-between items-center">
|
||||||
</View>
|
<View>
|
||||||
<View className="flex-1 items-center justify-center px-8">
|
<Text className="text-white text-xl font-bold">Payments</Text>
|
||||||
<Text className="text-6xl mb-4">💰</Text>
|
<Text className="text-white/70 text-xs mt-0.5">Today: ₱{todayTotal.toLocaleString()}</Text>
|
||||||
<Text className="text-xl font-bold text-gray-900 mb-2">Record a Payment</Text>
|
</View>
|
||||||
<Text className="text-gray-500 text-center mb-8">Collect payments from clients in the field</Text>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="bg-primary rounded-xl py-4 px-8 w-full items-center"
|
className="bg-white/20 rounded-xl px-4 py-2"
|
||||||
onPress={() => router.push('/(app)/payments/record')}
|
onPress={() => router.push('/(app)/payments/record')}
|
||||||
>
|
>
|
||||||
<Text className="text-white font-semibold text-base">Record Payment</Text>
|
<Text className="text-white font-semibold text-sm">+ Record</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={payments}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View className="bg-white rounded-xl p-4 mb-2 border border-gray-100">
|
||||||
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,45 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, FlatList, Modal } from 'react-native';
|
||||||
import { router } 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 = ['CASH', 'GCASH', 'MAYA', 'BANK'];
|
||||||
|
|
||||||
export default function RecordPaymentScreen() {
|
export default function RecordPaymentScreen() {
|
||||||
|
const params = useLocalSearchParams<{ prefillClientId?: string; prefillName?: string; prefillAccountNumber?: string }>();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [client, setClient] = useState<any>(null);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [client, setClient] = useState<any>(
|
||||||
|
params.prefillClientId
|
||||||
|
? { id: params.prefillClientId, firstName: params.prefillName?.split(' ')[0], lastName: params.prefillName?.split(' ').slice(1).join(' '), accountNumber: params.prefillAccountNumber }
|
||||||
|
: null
|
||||||
|
);
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [method, setMethod] = useState('CASH');
|
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);
|
|
||||||
|
|
||||||
const searchClient = async () => {
|
// Debounced client search
|
||||||
if (!search.trim()) return;
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
setSearching(true);
|
useEffect(() => {
|
||||||
try {
|
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||||
const res = await api.get(`/api/v1/clients?search=${search.trim()}&limit=1`);
|
return () => clearTimeout(t);
|
||||||
const found = res.data?.data?.[0] ?? res.data?.[0];
|
}, [search]);
|
||||||
if (found) setClient(found);
|
|
||||||
else Alert.alert('Not Found', 'No client found with that account number or name.');
|
const { data: searchResults, isFetching: searching } = useQuery({
|
||||||
} catch {
|
queryKey: ['client-search', debouncedSearch],
|
||||||
Alert.alert('Error', 'Search failed.');
|
queryFn: () => api.get(`/api/v1/clients?search=${debouncedSearch}&limit=8`).then(r => r.data?.data ?? r.data ?? []),
|
||||||
} finally {
|
enabled: debouncedSearch.trim().length >= 2,
|
||||||
setSearching(false);
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!client) return Alert.alert('Required', 'Search and select a client first.');
|
if (!client) return Alert.alert('Required', 'Select a client first.');
|
||||||
if (!amount || isNaN(Number(amount))) return Alert.alert('Required', 'Enter a valid amount.');
|
if (!amount || isNaN(Number(amount)) || Number(amount) <= 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', {
|
||||||
@@ -39,11 +47,19 @@ export default function RecordPaymentScreen() {
|
|||||||
amount: Number(amount),
|
amount: Number(amount),
|
||||||
paymentMethod: method,
|
paymentMethod: method,
|
||||||
referenceNumber: reference || undefined,
|
referenceNumber: reference || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
paymentDate: new Date().toISOString(),
|
paymentDate: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
Alert.alert('Success', 'Payment recorded!', [{ text: 'OK', onPress: () => router.back() }]);
|
// Invalidate relevant queries
|
||||||
|
qc.invalidateQueries({ queryKey: ['payments'] });
|
||||||
|
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: 'Record Another', onPress: () => { setClient(null); setAmount(''); setReference(''); setNotes(''); setSearch(''); } },
|
||||||
|
]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Error', e?.response?.data?.message ?? 'Payment failed.');
|
Alert.alert('Error', e?.response?.data?.message ?? 'Payment failed. Try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -52,69 +68,121 @@ export default function RecordPaymentScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
<TouchableOpacity onPress={() => router.back()} className="mr-3 p-1">
|
||||||
<Text className="text-white text-lg">←</Text>
|
<Text className="text-white text-lg">←</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="text-white text-xl font-bold">Record Payment</Text>
|
<Text className="text-white text-xl font-bold">Record Payment</Text>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView className="flex-1 px-4 py-4">
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Search Client</Text>
|
|
||||||
<View className="flex-row mb-4">
|
|
||||||
<TextInput
|
|
||||||
className="flex-1 bg-white border border-gray-200 rounded-xl px-4 py-3 mr-2"
|
|
||||||
placeholder="Account # or name"
|
|
||||||
value={search}
|
|
||||||
onChangeText={setSearch}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity className="bg-primary rounded-xl px-4 items-center justify-center" onPress={searchClient}>
|
|
||||||
{searching ? <ActivityIndicator color="white" /> : <Text className="text-white font-semibold">Find</Text>}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{client && (
|
<ScrollView className="flex-1 px-4 py-4" keyboardShouldPersistTaps="handled">
|
||||||
<View className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
|
{/* Client selector */}
|
||||||
<Text className="font-bold text-blue-900">{client.firstName} {client.lastName}</Text>
|
<Text className="font-semibold text-gray-700 mb-2">Client *</Text>
|
||||||
<Text className="text-blue-700 text-sm">{client.accountNumber}</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>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Amount (₱)</Text>
|
{/* Amount */}
|
||||||
|
<Text className="font-semibold text-gray-700 mb-2">Amount (₱) *</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 mb-4 text-base"
|
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-4"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChangeText={setAmount}
|
onChangeText={setAmount}
|
||||||
keyboardType="numeric"
|
keyboardType="decimal-pad"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Payment Method</Text>
|
{/* Payment method */}
|
||||||
|
<Text className="font-semibold text-gray-700 mb-2">Payment Method *</Text>
|
||||||
<View className="flex-row flex-wrap mb-4">
|
<View className="flex-row flex-wrap mb-4">
|
||||||
{METHODS.map(m => (
|
{METHODS.map(m => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={m}
|
key={m}
|
||||||
onPress={() => setMethod(m)}
|
onPress={() => setMethod(m)}
|
||||||
className={`rounded-xl px-4 py-2 mr-2 mb-2 border ${method === m ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}
|
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>
|
<Text className={method === m ? 'text-white font-semibold' : 'text-gray-700'}>{m}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="font-semibold text-gray-700 mb-2">Reference # (optional)</Text>
|
{/* Reference (for non-cash) */}
|
||||||
|
{method !== 'CASH' && (
|
||||||
|
<>
|
||||||
|
<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 */}
|
||||||
|
<Text className="font-semibold text-gray-700 mb-2">Notes (optional)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3 mb-8"
|
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-8"
|
||||||
placeholder="GCash ref, receipt #, etc."
|
placeholder="Any remarks..."
|
||||||
value={reference}
|
value={notes}
|
||||||
onChangeText={setReference}
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
numberOfLines={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="bg-primary rounded-xl py-4 items-center"
|
className={`rounded-xl py-4 items-center ${client && amount ? 'bg-primary' : 'bg-gray-300'}`}
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={loading}
|
disabled={loading || !client || !amount}
|
||||||
>
|
>
|
||||||
{loading ? <ActivityIndicator color="white" /> : <Text className="text-white font-bold text-base">Submit Payment</Text>}
|
{loading
|
||||||
|
? <ActivityIndicator color="white" />
|
||||||
|
: <Text className="text-white font-bold text-base">
|
||||||
|
Submit Payment {amount ? `· ₱${Number(amount || 0).toLocaleString()}` : ''}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View className="h-8" />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,38 +3,86 @@ 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 METHOD_ICON: Record<string, string> = { CASH: '💵', GCASH: '📱', MAYA: '💙', BANK: '🏦' };
|
||||||
|
|
||||||
export default function RemittanceDetailScreen() {
|
export default function RemittanceDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['remittance', id],
|
queryKey: ['remittance', id],
|
||||||
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 <View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>;
|
if (isLoading) return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-gray-50">
|
||||||
|
<ActivityIndicator color="#2563EB" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = data?.status ?? 'PENDING';
|
||||||
|
const statusColor = STATUS_COLOR[status] ?? '#6B7280';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
<TouchableOpacity onPress={() => router.back()} className="mr-3 p-1">
|
||||||
<Text className="text-white text-lg">←</Text>
|
<Text className="text-white text-lg">←</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="text-white text-xl font-bold">Remittance Detail</Text>
|
<View className="flex-1">
|
||||||
</View>
|
<Text className="text-white font-bold text-lg">Remittance</Text>
|
||||||
<ScrollView className="flex-1 px-4 py-4">
|
<Text className="text-white/70 text-xs">{data?.createdAt ? new Date(data.createdAt).toLocaleDateString() : ''}</Text>
|
||||||
<View className="bg-white rounded-2xl border border-gray-100 p-4">
|
|
||||||
<Text className="text-3xl font-bold text-gray-900 mb-1">₱{Number(data?.totalAmount ?? 0).toLocaleString()}</Text>
|
|
||||||
<Text className="text-gray-500 text-sm mb-4">{new Date(data?.createdAt).toLocaleDateString()}</Text>
|
|
||||||
<View className="border-t border-gray-100 pt-4">
|
|
||||||
<Text className="text-gray-500 text-xs mb-0.5">Status</Text>
|
|
||||||
<Text className="font-semibold text-gray-900">{data?.status}</Text>
|
|
||||||
</View>
|
|
||||||
{data?.notes && (
|
|
||||||
<View className="border-t border-gray-100 pt-4 mt-4">
|
|
||||||
<Text className="text-gray-500 text-xs mb-0.5">Notes</Text>
|
|
||||||
<Text className="text-gray-900">{data.notes}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
<View className="rounded-full px-3 py-1" style={{ backgroundColor: `${statusColor}30` }}>
|
||||||
|
<Text className="text-xs font-bold" style={{ color: statusColor }}>{status}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Included payments */}
|
||||||
|
{(data?.payments ?? []).length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text className="font-semibold text-gray-700 mb-2 px-1">Included Payments ({data.payments.length})</Text>
|
||||||
|
{data.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-medium text-gray-900">
|
||||||
|
{METHOD_ICON[p.paymentMethod] ?? '💳'} {p.client?.firstName} {p.client?.lastName}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-500 text-sm">{p.client?.accountNumber} · {p.paymentMethod}</Text>
|
||||||
|
{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" />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, ScrollView, TextInput, TouchableOpacity, ActivityIndicator, Alert } from 'react-native';
|
import {
|
||||||
|
View, Text, ScrollView, TextInput, TouchableOpacity,
|
||||||
|
ActivityIndicator, Alert, Modal,
|
||||||
|
} from 'react-native';
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../../../services/api';
|
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() {
|
export default function TicketDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const [reply, setReply] = useState('');
|
const [reply, setReply] = useState('');
|
||||||
|
const [showStatusPicker, setShowStatusPicker] = useState(false);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -16,49 +34,183 @@ export default function TicketDetailScreen() {
|
|||||||
|
|
||||||
const addReply = useMutation({
|
const addReply = useMutation({
|
||||||
mutationFn: () => api.post(`/api/v1/tickets/${id}/messages`, { message: reply }),
|
mutationFn: () => api.post(`/api/v1/tickets/${id}/messages`, { message: reply }),
|
||||||
onSuccess: () => { setReply(''); qc.invalidateQueries({ queryKey: ['ticket', id] }); },
|
onSuccess: () => {
|
||||||
|
setReply('');
|
||||||
|
qc.invalidateQueries({ queryKey: ['ticket', id] });
|
||||||
|
},
|
||||||
onError: () => Alert.alert('Error', 'Could not send reply.'),
|
onError: () => Alert.alert('Error', 'Could not send reply.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>;
|
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 (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
{/* Header */}
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
<View className="px-4 pt-14 pb-4 bg-blue-600">
|
||||||
<Text className="text-white text-lg">←</Text>
|
<View className="flex-row items-center mb-2">
|
||||||
</TouchableOpacity>
|
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
||||||
<View className="flex-1">
|
<Text className="text-white text-lg">←</Text>
|
||||||
<Text className="text-white font-bold" numberOfLines={1}>{data?.subject}</Text>
|
</TouchableOpacity>
|
||||||
<Text className="text-white/70 text-xs">{data?.status} · {data?.priority}</Text>
|
<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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
<ScrollView className="flex-1 px-4 py-4">
|
<ScrollView className="flex-1 px-4 py-4">
|
||||||
{(data?.messages ?? []).map((m: any) => (
|
{data?.description && (
|
||||||
<View key={m.id} className={`mb-3 max-w-xs ${m.senderType === 'AGENT' ? 'self-end items-end' : 'self-start items-start'}`}>
|
<View className="bg-white border border-gray-100 rounded-2xl p-4 mb-4">
|
||||||
<View className={`rounded-2xl px-4 py-3 ${m.senderType === 'AGENT' ? 'bg-primary' : 'bg-white border border-gray-100'}`}>
|
<Text className="text-xs text-gray-500 mb-1">Description</Text>
|
||||||
<Text className={m.senderType === 'AGENT' ? 'text-white' : 'text-gray-900'}>{m.message}</Text>
|
<Text className="text-gray-800">{data.description}</Text>
|
||||||
</View>
|
|
||||||
<Text className="text-gray-400 text-xs mt-1">{m.senderName}</Text>
|
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
<View className="flex-row px-4 py-3 bg-white border-t border-gray-100">
|
|
||||||
<TextInput
|
{/* Reply bar — hide if ticket is closed */}
|
||||||
className="flex-1 bg-gray-100 rounded-xl px-4 py-3 mr-2"
|
{currentStatus !== 'CLOSED' ? (
|
||||||
placeholder="Type a reply..."
|
<View className="flex-row px-4 py-3 bg-white border-t border-gray-100">
|
||||||
value={reply}
|
<TextInput
|
||||||
onChangeText={setReply}
|
className="flex-1 bg-gray-100 rounded-xl px-4 py-3 mr-2 text-gray-900"
|
||||||
multiline
|
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
|
<TouchableOpacity
|
||||||
className="bg-primary rounded-xl px-4 items-center justify-center"
|
className="flex-1 bg-black/50 justify-end"
|
||||||
onPress={() => reply.trim() && addReply.mutate()}
|
activeOpacity={1}
|
||||||
disabled={addReply.isPending}
|
onPress={() => setShowStatusPicker(false)}
|
||||||
>
|
>
|
||||||
{addReply.isPending ? <ActivityIndicator color="white" /> : <Text className="text-white font-semibold">Send</Text>}
|
<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>
|
</TouchableOpacity>
|
||||||
</View>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,64 @@
|
|||||||
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, ScrollView } from 'react-native';
|
||||||
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 PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#6B7280' };
|
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() {
|
export default function TicketsScreen() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||||
|
|
||||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||||
queryKey: ['tickets'],
|
queryKey: ['tickets'],
|
||||||
queryFn: () => api.get('/api/v1/tickets?limit=50').then(r => r.data?.data ?? r.data),
|
queryFn: () => api.get('/api/v1/tickets?limit=100').then(r => r.data?.data ?? r.data ?? []),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tickets = (data ?? []).filter((t: any) =>
|
const tickets = (data ?? []).filter((t: any) => {
|
||||||
`${t.subject} ${t.client?.firstName} ${t.client?.lastName}`.toLowerCase().includes(search.toLowerCase())
|
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 (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
<View className="px-4 pt-14 pb-4 bg-primary">
|
{/* Header */}
|
||||||
<Text className="text-white text-xl font-bold">Helpdesk Tickets</Text>
|
<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>
|
</View>
|
||||||
<View className="px-4 py-3">
|
|
||||||
|
{/* Search */}
|
||||||
|
<View className="px-4 pt-3 pb-2 bg-white border-b border-gray-100">
|
||||||
<TextInput
|
<TextInput
|
||||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3"
|
className="bg-gray-100 border border-gray-200 rounded-xl px-4 py-3 mb-2"
|
||||||
placeholder="Search tickets..."
|
placeholder="Search tickets..."
|
||||||
value={search}
|
value={search}
|
||||||
onChangeText={setSearch}
|
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>
|
</View>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>
|
<View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>
|
||||||
) : (
|
) : (
|
||||||
@@ -37,22 +66,38 @@ export default function TicketsScreen() {
|
|||||||
data={tickets}
|
data={tickets}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
||||||
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 24 }}
|
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12, paddingBottom: 32 }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="bg-white rounded-xl p-4 mb-2 border border-gray-100"
|
className="bg-white rounded-xl p-4 mb-2 border border-gray-100"
|
||||||
onPress={() => router.push(`/(app)/tickets/${item.id}`)}
|
onPress={() => router.push(`/(app)/tickets/${item.id}`)}
|
||||||
>
|
>
|
||||||
<View className="flex-row justify-between items-start">
|
<View className="flex-row justify-between items-start mb-1">
|
||||||
<Text className="font-semibold text-gray-900 flex-1 mr-2">{item.subject}</Text>
|
<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` }}>
|
<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>
|
<Text className="text-xs font-medium" style={{ color: PRIORITY_COLOR[item.priority] ?? '#6B7280' }}>{item.priority}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-gray-500 text-sm mt-1">{item.client?.firstName} {item.client?.lastName} · {item.status}</Text>
|
<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>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={<View className="items-center py-16"><Text className="text-gray-400">No tickets found</Text></View>}
|
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>
|
</View>
|
||||||
|
|||||||
182
app/(app)/tickets/new.tsx
Normal file
182
app/(app)/tickets/new.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@ module.exports = function (api) {
|
|||||||
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||||
'nativewind/babel',
|
'nativewind/babel',
|
||||||
],
|
],
|
||||||
|
plugins: ['react-native-reanimated/plugin'],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
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' });
|
||||||
|
|||||||
45
nativewind-env.d.ts
vendored
45
nativewind-env.d.ts
vendored
@@ -1,3 +1,44 @@
|
|||||||
/// <reference types="nativewind/types" />
|
// NativeWind v4 className augmentation
|
||||||
|
import 'react-native';
|
||||||
|
|
||||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
|
declare module 'react-native' {
|
||||||
|
interface ViewProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface TextProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface ImageProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface TextInputProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface TouchableOpacityProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface TouchableHighlightProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface TouchableWithoutFeedbackProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface ScrollViewProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface FlatListProps<ItemT> {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface SectionListProps<ItemT> {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface ModalProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface PressableProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
interface KeyboardAvoidingViewProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
990
package-lock.json
generated
990
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,11 +22,15 @@
|
|||||||
"expo-router": "^55.0.7",
|
"expo-router": "^55.0.7",
|
||||||
"expo-secure-store": "^55.0.9",
|
"expo-secure-store": "^55.0.9",
|
||||||
"expo-status-bar": "~55.0.4",
|
"expo-status-bar": "~55.0.4",
|
||||||
"nativewind": "^4.2.3",
|
"expo-updates": "~55.0.15",
|
||||||
|
"hermes-parser": "0.32.0",
|
||||||
|
"nativewind": "^4.1.23",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-native": "0.83.2",
|
"react-native": "0.83.2",
|
||||||
|
"react-native-reanimated": "4.2.1",
|
||||||
"react-native-safe-area-context": "^5.6.2",
|
"react-native-safe-area-context": "^5.6.2",
|
||||||
"react-native-screens": "^4.23.0",
|
"react-native-screens": "^4.23.0",
|
||||||
|
"react-native-worklets": "0.7.2",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -11,5 +11,13 @@
|
|||||||
"@/hooks/*": ["./hooks/*"],
|
"@/hooks/*": ["./hooks/*"],
|
||||||
"@/constants/*": ["./constants/*"]
|
"@/constants/*": ["./constants/*"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"nativewind-env.d.ts",
|
||||||
|
"types/**/*.d.ts",
|
||||||
|
".expo/types/**/*.d.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
39
types/expo-modules.d.ts
vendored
Normal file
39
types/expo-modules.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
declare module 'expo-image-picker' {
|
||||||
|
export function requestCameraPermissionsAsync(): Promise<{ status: string }>;
|
||||||
|
export function launchCameraAsync(options?: {
|
||||||
|
quality?: number;
|
||||||
|
base64?: boolean;
|
||||||
|
allowsEditing?: boolean;
|
||||||
|
}): Promise<{ canceled: boolean; assets: Array<{ uri: string; base64?: string }> }>;
|
||||||
|
export function launchImageLibraryAsync(options?: {
|
||||||
|
quality?: number;
|
||||||
|
base64?: boolean;
|
||||||
|
mediaTypes?: string;
|
||||||
|
}): Promise<{ canceled: boolean; assets: Array<{ uri: string; base64?: string }> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'expo-location' {
|
||||||
|
export enum Accuracy {
|
||||||
|
Lowest = 1,
|
||||||
|
Low = 2,
|
||||||
|
Balanced = 3,
|
||||||
|
High = 4,
|
||||||
|
Highest = 5,
|
||||||
|
BestForNavigation = 6,
|
||||||
|
}
|
||||||
|
export function requestForegroundPermissionsAsync(): Promise<{ status: string }>;
|
||||||
|
export function getCurrentPositionAsync(options?: {
|
||||||
|
accuracy?: Accuracy;
|
||||||
|
}): Promise<{
|
||||||
|
coords: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
altitude: number | null;
|
||||||
|
accuracy: number;
|
||||||
|
altitudeAccuracy: number | null;
|
||||||
|
heading: number | null;
|
||||||
|
speed: number | null;
|
||||||
|
};
|
||||||
|
timestamp: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user