diff --git a/app/(app)/manual-tasks/[id].tsx b/app/(app)/manual-tasks/[id].tsx new file mode 100644 index 0000000..a54770e --- /dev/null +++ b/app/(app)/manual-tasks/[id].tsx @@ -0,0 +1,237 @@ +import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Alert } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useLocalSearchParams, router } from 'expo-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '../../../services/api'; +import { useAuthStore } from '../../../stores/authStore'; + +const TYPE_LABEL: Record = { + SUSPEND_CLIENT: 'Suspend Client', + REACTIVATE_CLIENT: 'Reactivate Client', + ACTIVATE_CLIENT: 'Activate Client', +}; +const TYPE_COLOR: Record = { + SUSPEND_CLIENT: '#DC2626', + REACTIVATE_CLIENT: '#059669', + ACTIVATE_CLIENT: '#0891B2', +}; +const TYPE_BG: Record = { + SUSPEND_CLIENT: '#FEE2E2', + REACTIVATE_CLIENT: '#D1FAE5', + ACTIVATE_CLIENT: '#ECFEFF', +}; +const STATUS_COLOR: Record = { + PENDING: '#D97706', + DONE: '#059669', + CANCELLED: '#6B7280', +}; +const STATUS_BG: Record = { + PENDING: '#FFFBEB', + DONE: '#D1FAE5', + CANCELLED: '#F1F5F9', +}; +const COMPLETE_CONFIRM: Record = { + SUSPEND_CLIENT: 'This will suspend the client and all their active subscriptions. Continue?', + REACTIVATE_CLIENT: 'This will reactivate the client and their suspended subscriptions. Continue?', + ACTIVATE_CLIENT: 'This will activate the client account and their pending subscription. Continue?', +}; + +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 ( + + {label} + {value} + + ); +} + +export default function ManualTaskDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { user } = useAuthStore(); + const qc = useQueryClient(); + const role = user?.roles?.[0] ?? user?.role ?? ''; + + const { data: task, isLoading, refetch } = useQuery({ + queryKey: ['manual-task', id], + queryFn: () => api.get(`/api/v1/manual-tasks/${id}`).then(r => r.data), + staleTime: 0, + }); + + const isAdminOrStaff = role === 'ADMIN' || role === 'STAFF'; + const isPending = task?.status === 'PENDING'; + const isDone = task?.status === 'DONE'; + const isCancelled = task?.status === 'CANCELLED'; + + const handleAssignToMe = async () => { + try { + const res = await api.patch(`/api/v1/manual-tasks/${id}/assign`, { assignedToId: user?.sub ?? user?.id }); + qc.setQueryData(['manual-task', id], (old: any) => ({ ...(old ?? {}), ...res.data })); + qc.invalidateQueries({ queryKey: ['manual-tasks'] }); + } catch (e: any) { + Alert.alert('Error', e?.response?.data?.message ?? 'Could not assign task.'); + } + }; + + const handleComplete = () => { + const confirmMsg = COMPLETE_CONFIRM[task?.type] ?? 'Complete this task?'; + Alert.alert('Complete Task', confirmMsg, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Complete', + onPress: async () => { + try { + const res = await api.patch(`/api/v1/manual-tasks/${id}/complete`, { clientId: task?.clientId ?? task?.client?.id }); + qc.setQueryData(['manual-task', id], (old: any) => ({ ...(old ?? {}), ...res.data })); + qc.invalidateQueries({ queryKey: ['manual-tasks'] }); + Alert.alert('Done', 'Task completed successfully.'); + } catch (e: any) { + Alert.alert('Error', e?.response?.data?.message ?? 'Could not complete task.'); + } + }, + }, + ]); + }; + + const handleCancel = () => { + Alert.alert('Cancel Task', 'Are you sure you want to cancel this task?', [ + { text: 'No', style: 'cancel' }, + { + text: 'Yes, Cancel', + style: 'destructive', + onPress: async () => { + try { + const res = await api.patch(`/api/v1/manual-tasks/${id}/cancel`); + qc.setQueryData(['manual-task', id], (old: any) => ({ ...(old ?? {}), ...res.data })); + qc.invalidateQueries({ queryKey: ['manual-tasks'] }); + } catch (e: any) { + Alert.alert('Error', e?.response?.data?.message ?? 'Could not cancel task.'); + } + }, + }, + ]); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + const typeLabel = TYPE_LABEL[task?.type] ?? task?.type ?? ''; + const typeColor = TYPE_COLOR[task?.type] ?? '#6B7280'; + const typeBg = TYPE_BG[task?.type] ?? '#F1F5F9'; + const statusColor = STATUS_COLOR[task?.status] ?? '#6B7280'; + const statusBg = STATUS_BG[task?.status] ?? '#F1F5F9'; + const assignedName = task?.assignedTo + ? `${task.assignedTo.firstName} ${task.assignedTo.lastName}` + : 'Unassigned'; + const clientName = task?.client + ? `${task.client.firstName} ${task.client.lastName}` + : null; + + return ( + + + {/* Header */} + + router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}> + ← Back + + + {/* Type badge (large) */} + + {typeLabel} + + + {/* Status badge */} + + + {task?.status} + + + + {clientName && ( + + {clientName} · {task?.client?.accountNumber} + {' · '}{assignedName} + + )} + + + + {/* Info Card */} + + + + + + + + + + {/* State banners */} + {isDone && ( + + + Task Completed + {task?.completedAt && ( + {formatDate(task.completedAt)} + )} + + )} + + {isCancelled && ( + + 🚫 + Task Cancelled + + )} + + {/* Actions — PENDING + ADMIN/STAFF */} + {isPending && isAdminOrStaff && ( + + {/* Assign to me */} + + Assign to Me + + + {/* Complete Task */} + + ✓ Complete Task + + + {/* Cancel */} + + Cancel Task + + + )} + + + + ); +} diff --git a/app/(app)/manual-tasks/_layout.tsx b/app/(app)/manual-tasks/_layout.tsx new file mode 100644 index 0000000..bef1634 --- /dev/null +++ b/app/(app)/manual-tasks/_layout.tsx @@ -0,0 +1,4 @@ +import { Stack } from 'expo-router'; +export default function ManualTasksLayout() { + return ; +} diff --git a/app/(app)/manual-tasks/index.tsx b/app/(app)/manual-tasks/index.tsx new file mode 100644 index 0000000..2209dd7 --- /dev/null +++ b/app/(app)/manual-tasks/index.tsx @@ -0,0 +1,166 @@ +import { useState, useCallback } from 'react'; +import { View, Text, FlatList, TouchableOpacity, ActivityIndicator, RefreshControl, ScrollView } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useQuery } from '@tanstack/react-query'; +import { useFocusEffect, router } from 'expo-router'; +import { api } from '../../../services/api'; +import { useAuthStore } from '../../../stores/authStore'; + +const TYPE_LABEL: Record = { + SUSPEND_CLIENT: 'SUSPEND', + REACTIVATE_CLIENT: 'REACTIVATE', + ACTIVATE_CLIENT: 'ACTIVATE', +}; +const TYPE_COLOR: Record = { + SUSPEND_CLIENT: '#DC2626', + REACTIVATE_CLIENT: '#059669', + ACTIVATE_CLIENT: '#0891B2', +}; +const TYPE_BG: Record = { + SUSPEND_CLIENT: '#FEE2E2', + REACTIVATE_CLIENT: '#D1FAE5', + ACTIVATE_CLIENT: '#ECFEFF', +}; +const STATUS_COLOR: Record = { + PENDING: '#D97706', + DONE: '#059669', + CANCELLED: '#6B7280', +}; +const STATUS_BG: Record = { + PENDING: '#FFFBEB', + DONE: '#D1FAE5', + CANCELLED: '#F1F5F9', +}; + +const FILTERS = ['ALL', 'PENDING', 'DONE', 'CANCELLED']; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString('en-PH', { + month: 'short', day: 'numeric', year: 'numeric', + }); +} + +export default function ManualTasksScreen() { + const { user } = useAuthStore(); + const [filter, setFilter] = useState('ALL'); + const role = user?.roles?.[0] ?? user?.role ?? ''; + + const queryParams = filter === 'ALL' ? '' : `?status=${filter}`; + const { data, isLoading, refetch, isRefetching } = useQuery({ + queryKey: ['manual-tasks', filter], + queryFn: () => api.get(`/api/v1/manual-tasks${queryParams}`).then(r => r.data?.data ?? r.data ?? []), + }); + + useFocusEffect(useCallback(() => { refetch(); }, [filter])); + + const tasks: any[] = data ?? []; + + return ( + + + {/* Header */} + + + Manual Tasks + {tasks.length} showing + + {(role === 'ADMIN' || role === 'STAFF') && ( + router.push('/(app)/manual-tasks/new')} + activeOpacity={0.7} + > + + New + + )} + + + {/* Filter chips */} + + + {FILTERS.map(f => { + const isActive = filter === f; + const color = f === 'ALL' ? '#0891B2' : STATUS_COLOR[f] ?? '#6B7280'; + return ( + setFilter(f)} + style={{ borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, marginRight: 8, backgroundColor: isActive ? color : '#F1F5F9' }} + activeOpacity={0.7} + > + + {f === 'ALL' ? 'All' : f} + + + ); + })} + + + + {isLoading ? ( + + + + ) : ( + item.id} + refreshControl={} + contentContainerStyle={{ padding: 16, paddingBottom: 32 }} + renderItem={({ item }) => { + const typeColor = TYPE_COLOR[item.type] ?? '#6B7280'; + const typeBg = TYPE_BG[item.type] ?? '#F1F5F9'; + const typeLabel = TYPE_LABEL[item.type] ?? item.type; + const statusColor = STATUS_COLOR[item.status] ?? '#6B7280'; + const statusBg = STATUS_BG[item.status] ?? '#F1F5F9'; + const assignedName = item.assignedTo + ? `${item.assignedTo.firstName} ${item.assignedTo.lastName}` + : 'Unassigned'; + return ( + router.push(`/(app)/manual-tasks/${item.id}`)} + activeOpacity={0.7} + > + {/* Badges row */} + + + {typeLabel} + + + {item.status} + + + + {/* Client */} + {item.client && ( + + {item.client.firstName} {item.client.lastName} + · {item.client.accountNumber} + + )} + + {/* Assigned / Date */} + + {assignedName} + {item.createdAt ? formatDate(item.createdAt) : ''} + + + {/* Notes */} + {!!item.notes && ( + {item.notes} + )} + + ); + }} + ListEmptyComponent={ + + No manual tasks found + + } + /> + )} + + + ); +} diff --git a/app/(app)/manual-tasks/new.tsx b/app/(app)/manual-tasks/new.tsx new file mode 100644 index 0000000..182d725 --- /dev/null +++ b/app/(app)/manual-tasks/new.tsx @@ -0,0 +1,230 @@ +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'; +import { useAuthStore } from '../../../stores/authStore'; + +const TASK_TYPES = [ + { value: 'SUSPEND_CLIENT', label: 'Suspend Client', color: '#DC2626', bg: '#FEE2E2' }, + { value: 'REACTIVATE_CLIENT', label: 'Reactivate Client', color: '#059669', bg: '#D1FAE5' }, + { value: 'ACTIVATE_CLIENT', label: 'Activate Client', color: '#0891B2', bg: '#ECFEFF' }, +]; + +export default function NewManualTaskScreen() { + const qc = useQueryClient(); + const { user } = useAuthStore(); + + const [taskType, setTaskType] = useState('SUSPEND_CLIENT'); + const [client, setClient] = useState(null); + const [clientSearch, setClientSearch] = useState(''); + const [debouncedClientSearch, setDebouncedClientSearch] = useState(''); + const [assignTo, setAssignTo] = useState(null); + const [showUserPicker, setShowUserPicker] = useState(false); + const [notes, setNotes] = useState(''); + const [loading, setLoading] = useState(false); + + // Client search + const { data: clientResults, isFetching: searchingClients } = useQuery({ + queryKey: ['manual-task-clients', debouncedClientSearch], + queryFn: () => api.get(`/api/v1/clients?search=${debouncedClientSearch}&limit=8`).then(r => r.data?.data ?? r.data ?? []), + enabled: debouncedClientSearch.trim().length >= 2, + }); + + // Users list + const { data: usersData } = useQuery({ + queryKey: ['manual-task-users'], + queryFn: () => api.get('/api/v1/users').then(r => r.data?.data ?? r.data ?? []), + }); + + const handleClientSearchChange = (v: string) => { + setClientSearch(v); + setTimeout(() => setDebouncedClientSearch(v), 400); + }; + + const submit = async () => { + if (!client) return Alert.alert('Required', 'Please select a client.'); + setLoading(true); + try { + await api.post('/api/v1/manual-tasks', { + type: taskType, + clientId: client.id, + assignedToId: assignTo?.id ?? undefined, + notes: notes.trim() || undefined, + }); + qc.invalidateQueries({ queryKey: ['manual-tasks'] }); + Alert.alert('Task created', '', [{ text: 'OK', onPress: () => router.back() }]); + } catch (e: any) { + Alert.alert('Error', e?.response?.data?.message ?? 'Could not create task.'); + } finally { + setLoading(false); + } + }; + + const users: any[] = usersData ?? []; + + return ( + + + {/* Header */} + + router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}> + ← Back + + New Manual Task + + + + + {/* Task Type */} + + Task Type * + + + {TASK_TYPES.map(t => ( + setTaskType(t.value)} + style={{ + flexDirection: 'row', alignItems: 'center', + borderRadius: 14, padding: 16, marginBottom: 8, + backgroundColor: taskType === t.value ? t.bg : '#FFF', + borderWidth: 1.5, + borderColor: taskType === t.value ? t.color : '#E2E8F0', + }} + activeOpacity={0.7} + > + + {taskType === t.value && } + + + {t.label} + + + ))} + + + {/* Client */} + + Client * + + {client ? ( + + + {client.firstName} {client.lastName} + {client.accountNumber} + + { setClient(null); setClientSearch(''); setDebouncedClientSearch(''); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + Change + + + ) : ( + + + + {clientSearch.length > 0 && ( + { setClientSearch(''); setDebouncedClientSearch(''); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + + × + + + )} + {searchingClients && } + + {debouncedClientSearch.trim().length >= 2 && ( + + {(clientResults ?? []).length === 0 && !searchingClients ? ( + No clients found + ) : ( + (clientResults ?? []).map((c: any, i: number, arr: any[]) => ( + { setClient(c); setClientSearch(''); setDebouncedClientSearch(''); }} + activeOpacity={0.7} + > + {c.firstName} {c.lastName} + {c.accountNumber} + + )) + )} + + )} + + )} + + {/* Assign To */} + + Assign To (optional) + + {assignTo ? ( + + + {assignTo.firstName} {assignTo.lastName} + {assignTo.roles?.[0] ?? assignTo.role ?? 'Staff'} + + setAssignTo(null)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + Clear + + + ) : ( + + {users.length === 0 ? ( + + + + ) : ( + users.map((u: any, i: number) => ( + setAssignTo(u)} + activeOpacity={0.7} + > + {u.firstName} {u.lastName} + {u.roles?.[0] ?? u.role ?? 'Staff'} + + )) + )} + + )} + + {/* Notes */} + + Notes (optional) + + + + {/* Submit */} + + {loading ? : Create Task} + + + + + ); +} diff --git a/app/(app)/profile.tsx b/app/(app)/profile.tsx index 5bf137b..21c7dc3 100644 --- a/app/(app)/profile.tsx +++ b/app/(app)/profile.tsx @@ -60,6 +60,21 @@ export default function ProfileScreen() { )} + {/* Manual Tasks — admin only */} + {(user?.roles?.includes('ADMIN') || user?.role === 'ADMIN' || role === 'ADMIN') && ( + router.push('/(app)/manual-tasks')} + activeOpacity={0.7} + > + + Manual Tasks + Suspend, reactivate & activate clients + + + + )} + {/* App version */} App