feat: Manual Tasks mobile screens (#24) - list, new, detail + profile nav
This commit is contained in:
237
app/(app)/manual-tasks/[id].tsx
Normal file
237
app/(app)/manual-tasks/[id].tsx
Normal file
@@ -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<string, string> = {
|
||||
SUSPEND_CLIENT: 'Suspend Client',
|
||||
REACTIVATE_CLIENT: 'Reactivate Client',
|
||||
ACTIVATE_CLIENT: 'Activate Client',
|
||||
};
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
SUSPEND_CLIENT: '#DC2626',
|
||||
REACTIVATE_CLIENT: '#059669',
|
||||
ACTIVATE_CLIENT: '#0891B2',
|
||||
};
|
||||
const TYPE_BG: Record<string, string> = {
|
||||
SUSPEND_CLIENT: '#FEE2E2',
|
||||
REACTIVATE_CLIENT: '#D1FAE5',
|
||||
ACTIVATE_CLIENT: '#ECFEFF',
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: '#D97706',
|
||||
DONE: '#059669',
|
||||
CANCELLED: '#6B7280',
|
||||
};
|
||||
const STATUS_BG: Record<string, string> = {
|
||||
PENDING: '#FFFBEB',
|
||||
DONE: '#D1FAE5',
|
||||
CANCELLED: '#F1F5F9',
|
||||
};
|
||||
const COMPLETE_CONFIRM: Record<string, string> = {
|
||||
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 (
|
||||
<View style={{ paddingHorizontal: 20, paddingVertical: 16, borderBottomWidth: isLast ? 0 : 1, borderBottomColor: '#F1F5F9' }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 4 }}>{label}</Text>
|
||||
<Text style={{ fontSize: 17, fontWeight: '500', color: '#0F172A' }}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function 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 (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||
<View style={{ flex: 1, backgroundColor: '#F8FAFC', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator color="#0891B2" size="large" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const 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 (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||
{/* Header */}
|
||||
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 18 }}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Type badge (large) */}
|
||||
<View style={{ alignSelf: 'flex-start', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, backgroundColor: typeBg, marginBottom: 10 }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: '800', color: typeColor }}>{typeLabel}</Text>
|
||||
</View>
|
||||
|
||||
{/* Status badge */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6, backgroundColor: statusBg }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: '700', color: statusColor }}>{task?.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{clientName && (
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 10 }}>
|
||||
{clientName} · {task?.client?.accountNumber}
|
||||
{' · '}{assignedName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }}>
|
||||
{/* Info Card */}
|
||||
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', marginBottom: 16, overflow: 'hidden' }}>
|
||||
<InfoRow label="Client" value={clientName} />
|
||||
<InfoRow label="Account #" value={task?.client?.accountNumber} />
|
||||
<InfoRow label="Assigned to" value={assignedName} />
|
||||
<InfoRow label="Notes" value={task?.notes} />
|
||||
<InfoRow label="Created" value={task?.createdAt ? formatDate(task.createdAt) : null} />
|
||||
<InfoRow label="Completed" value={task?.completedAt ? formatDate(task.completedAt) : null} isLast />
|
||||
</View>
|
||||
|
||||
{/* State banners */}
|
||||
{isDone && (
|
||||
<View style={{ backgroundColor: '#D1FAE5', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#86EFAC', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 28, marginBottom: 6 }}>✅</Text>
|
||||
<Text style={{ fontSize: 18, fontWeight: '800', color: '#166534' }}>Task Completed</Text>
|
||||
{task?.completedAt && (
|
||||
<Text style={{ fontSize: 14, color: '#16A34A', marginTop: 4 }}>{formatDate(task.completedAt)}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isCancelled && (
|
||||
<View style={{ backgroundColor: '#F1F5F9', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#CBD5E1', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 28, marginBottom: 6 }}>🚫</Text>
|
||||
<Text style={{ fontSize: 18, fontWeight: '800', color: '#6B7280' }}>Task Cancelled</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Actions — PENDING + ADMIN/STAFF */}
|
||||
{isPending && isAdminOrStaff && (
|
||||
<View style={{ gap: 12 }}>
|
||||
{/* Assign to me */}
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: '#FFF', borderRadius: 14, paddingVertical: 18, alignItems: 'center', borderWidth: 1.5, borderColor: '#0891B2' }}
|
||||
onPress={handleAssignToMe}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={{ color: '#0891B2', fontSize: 17, fontWeight: '700' }}>Assign to Me</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Complete Task */}
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: '#059669', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||||
onPress={handleComplete}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>✓ Complete Task</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Cancel */}
|
||||
<TouchableOpacity
|
||||
style={{ borderRadius: 14, paddingVertical: 18, alignItems: 'center', borderWidth: 1.5, borderColor: '#DC2626', backgroundColor: '#FFF' }}
|
||||
onPress={handleCancel}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={{ color: '#DC2626', fontSize: 17, fontWeight: '700' }}>Cancel Task</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
4
app/(app)/manual-tasks/_layout.tsx
Normal file
4
app/(app)/manual-tasks/_layout.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Stack } from 'expo-router';
|
||||
export default function ManualTasksLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
166
app/(app)/manual-tasks/index.tsx
Normal file
166
app/(app)/manual-tasks/index.tsx
Normal file
@@ -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<string, string> = {
|
||||
SUSPEND_CLIENT: 'SUSPEND',
|
||||
REACTIVATE_CLIENT: 'REACTIVATE',
|
||||
ACTIVATE_CLIENT: 'ACTIVATE',
|
||||
};
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
SUSPEND_CLIENT: '#DC2626',
|
||||
REACTIVATE_CLIENT: '#059669',
|
||||
ACTIVATE_CLIENT: '#0891B2',
|
||||
};
|
||||
const TYPE_BG: Record<string, string> = {
|
||||
SUSPEND_CLIENT: '#FEE2E2',
|
||||
REACTIVATE_CLIENT: '#D1FAE5',
|
||||
ACTIVATE_CLIENT: '#ECFEFF',
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: '#D97706',
|
||||
DONE: '#059669',
|
||||
CANCELLED: '#6B7280',
|
||||
};
|
||||
const STATUS_BG: Record<string, string> = {
|
||||
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 (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||
{/* Header */}
|
||||
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<View>
|
||||
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Manual Tasks</Text>
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>{tasks.length} showing</Text>
|
||||
</View>
|
||||
{(role === 'ADMIN' || role === 'STAFF') && (
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 10 }}
|
||||
onPress={() => router.push('/(app)/manual-tasks/new')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={{ color: '#FFF', fontWeight: '700', fontSize: 15 }}>+ New</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Filter chips */}
|
||||
<View style={{ backgroundColor: '#FFF', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
{FILTERS.map(f => {
|
||||
const isActive = filter === f;
|
||||
const color = f === 'ALL' ? '#0891B2' : STATUS_COLOR[f] ?? '#6B7280';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={f}
|
||||
onPress={() => setFilter(f)}
|
||||
style={{ borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, marginRight: 8, backgroundColor: isActive ? color : '#F1F5F9' }}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={{ fontSize: 14, fontWeight: '700', color: isActive ? '#FFF' : '#64748B' }}>
|
||||
{f === 'ALL' ? 'All' : f}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator color="#0891B2" size="large" />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={tasks}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#0891B2" />}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||
renderItem={({ item }) => {
|
||||
const typeColor = TYPE_COLOR[item.type] ?? '#6B7280';
|
||||
const typeBg = TYPE_BG[item.type] ?? '#F1F5F9';
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 12, borderWidth: 1, borderColor: '#F1F5F9', borderLeftWidth: 4, borderLeftColor: typeColor }}
|
||||
onPress={() => router.push(`/(app)/manual-tasks/${item.id}`)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* Badges row */}
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: typeBg }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '700', color: typeColor }}>{typeLabel}</Text>
|
||||
</View>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: statusBg }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '700', color: statusColor }}>{item.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Client */}
|
||||
{item.client && (
|
||||
<Text style={{ fontSize: 17, fontWeight: '700', color: '#0F172A', marginBottom: 4 }}>
|
||||
{item.client.firstName} {item.client.lastName}
|
||||
<Text style={{ fontWeight: '400', color: '#64748B' }}> · {item.client.accountNumber}</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Assigned / Date */}
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 }}>
|
||||
<Text style={{ fontSize: 14, color: '#64748B' }}>{assignedName}</Text>
|
||||
<Text style={{ fontSize: 13, color: '#94A3B8' }}>{item.createdAt ? formatDate(item.createdAt) : ''}</Text>
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
{!!item.notes && (
|
||||
<Text style={{ fontSize: 14, color: '#94A3B8', marginTop: 6 }} numberOfLines={2}>{item.notes}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={
|
||||
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
|
||||
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No manual tasks found</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
230
app/(app)/manual-tasks/new.tsx
Normal file
230
app/(app)/manual-tasks/new.tsx
Normal file
@@ -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<any>(null);
|
||||
const [clientSearch, setClientSearch] = useState('');
|
||||
const [debouncedClientSearch, setDebouncedClientSearch] = useState('');
|
||||
const [assignTo, setAssignTo] = useState<any>(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 (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||
{/* Header */}
|
||||
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 12, paddingBottom: 20 }}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}>
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 17, fontWeight: '600' }}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>New Manual Task</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
|
||||
|
||||
{/* Task Type */}
|
||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
|
||||
Task Type <Text style={{ color: '#DC2626' }}>*</Text>
|
||||
</Text>
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
{TASK_TYPES.map(t => (
|
||||
<TouchableOpacity
|
||||
key={t.value}
|
||||
onPress={() => 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}
|
||||
>
|
||||
<View style={{
|
||||
width: 20, height: 20, borderRadius: 10, borderWidth: 2,
|
||||
borderColor: t.color, alignItems: 'center', justifyContent: 'center', marginRight: 12,
|
||||
backgroundColor: taskType === t.value ? t.color : 'transparent',
|
||||
}}>
|
||||
{taskType === t.value && <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: '#FFF' }} />}
|
||||
</View>
|
||||
<View style={{ borderRadius: 20, paddingHorizontal: 12, paddingVertical: 5, backgroundColor: t.bg, marginRight: 10 }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '700', color: t.color }}>{t.label}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Client */}
|
||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
|
||||
Client <Text style={{ color: '#DC2626' }}>*</Text>
|
||||
</Text>
|
||||
{client ? (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#ECFEFF', borderRadius: 14, padding: 18, marginBottom: 20, borderWidth: 1, borderColor: '#A5F3FC' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 17, fontWeight: '700', color: '#0E7490' }}>{client.firstName} {client.lastName}</Text>
|
||||
<Text style={{ fontSize: 15, color: '#0891B2', marginTop: 2 }}>{client.accountNumber}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => { setClient(null); setClientSearch(''); setDebouncedClientSearch(''); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0891B2' }}>Change</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<View style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, marginBottom: 8 }}>
|
||||
<TextInput
|
||||
style={{ flex: 1, paddingVertical: 14, fontSize: 16, color: '#0F172A' }}
|
||||
placeholder="Search by name or account #"
|
||||
placeholderTextColor="#94A3B8"
|
||||
value={clientSearch}
|
||||
onChangeText={handleClientSearchChange}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{clientSearch.length > 0 && (
|
||||
<TouchableOpacity onPress={() => { setClientSearch(''); setDebouncedClientSearch(''); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<View style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={{ color: '#FFF', fontSize: 13, fontWeight: '800', lineHeight: 16 }}>×</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{searchingClients && <ActivityIndicator size="small" color="#0891B2" style={{ marginLeft: 8 }} />}
|
||||
</View>
|
||||
{debouncedClientSearch.trim().length >= 2 && (
|
||||
<View style={{ backgroundColor: '#FFF', borderRadius: 14, borderWidth: 1, borderColor: '#E2E8F0', overflow: 'hidden' }}>
|
||||
{(clientResults ?? []).length === 0 && !searchingClients ? (
|
||||
<Text style={{ paddingHorizontal: 16, paddingVertical: 14, fontSize: 15, color: '#94A3B8' }}>No clients found</Text>
|
||||
) : (
|
||||
(clientResults ?? []).map((c: any, i: number, arr: any[]) => (
|
||||
<TouchableOpacity
|
||||
key={c.id}
|
||||
style={{ paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: i < arr.length - 1 ? 1 : 0, borderBottomColor: '#F1F5F9' }}
|
||||
onPress={() => { setClient(c); setClientSearch(''); setDebouncedClientSearch(''); }}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontWeight: '600', color: '#0F172A' }}>{c.firstName} {c.lastName}</Text>
|
||||
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 2 }}>{c.accountNumber}</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Assign To */}
|
||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
|
||||
Assign To <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text>
|
||||
</Text>
|
||||
{assignTo ? (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#F0FDF4', borderRadius: 14, padding: 16, marginBottom: 20, borderWidth: 1, borderColor: '#86EFAC' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 17, fontWeight: '700', color: '#166534' }}>{assignTo.firstName} {assignTo.lastName}</Text>
|
||||
<Text style={{ fontSize: 14, color: '#16A34A', marginTop: 2 }}>{assignTo.roles?.[0] ?? assignTo.role ?? 'Staff'}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => setAssignTo(null)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Text style={{ fontSize: 15, fontWeight: '700', color: '#059669' }}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ backgroundColor: '#FFF', borderRadius: 14, borderWidth: 1, borderColor: '#E2E8F0', overflow: 'hidden', marginBottom: 20 }}>
|
||||
{users.length === 0 ? (
|
||||
<View style={{ padding: 16, alignItems: 'center' }}>
|
||||
<ActivityIndicator size="small" color="#0891B2" />
|
||||
</View>
|
||||
) : (
|
||||
users.map((u: any, i: number) => (
|
||||
<TouchableOpacity
|
||||
key={u.id}
|
||||
style={{ paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: i < users.length - 1 ? 1 : 0, borderBottomColor: '#F1F5F9', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
onPress={() => setAssignTo(u)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontWeight: '600', color: '#0F172A' }}>{u.firstName} {u.lastName}</Text>
|
||||
<Text style={{ fontSize: 13, color: '#94A3B8' }}>{u.roles?.[0] ?? u.role ?? 'Staff'}</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
|
||||
Notes <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 24, minHeight: 100, textAlignVertical: 'top' }}
|
||||
placeholder="Additional notes or instructions..."
|
||||
placeholderTextColor="#94A3B8"
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
multiline
|
||||
/>
|
||||
|
||||
{/* Submit */}
|
||||
<TouchableOpacity
|
||||
style={{ backgroundColor: client ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
|
||||
onPress={submit}
|
||||
disabled={loading || !client}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{loading ? <ActivityIndicator color="#FFF" /> : <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Create Task</Text>}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user