feat: Manual Tasks mobile screens (#24) - list, new, detail + profile nav

This commit is contained in:
Nemo
2026-03-25 08:21:02 +08:00
parent ca6236da04
commit 95d2510b23
5 changed files with 652 additions and 0 deletions

View 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>
);
}