- manual-tasks/[id].tsx: use r.data?.data ?? r.data for detail query to handle API response wrapper envelope, matching list screen pattern - remittances/[id].tsx: same fix — detail query now unwraps correctly - services/api.ts: use STORAGE_KEYS.TOKEN constant instead of hardcoded string literals in SecureStore calls
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
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?.data ?? 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>
|
|
);
|
|
}
|