- 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
217 lines
8.3 KiB
TypeScript
217 lines
8.3 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
View, Text, ScrollView, TextInput, TouchableOpacity,
|
|
ActivityIndicator, Alert, Modal,
|
|
} from 'react-native';
|
|
import { useLocalSearchParams, router } from 'expo-router';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
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() {
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const [reply, setReply] = useState('');
|
|
const [showStatusPicker, setShowStatusPicker] = useState(false);
|
|
const qc = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['ticket', id],
|
|
queryFn: () => api.get(`/api/v1/tickets/${id}`).then(r => r.data),
|
|
});
|
|
|
|
const addReply = useMutation({
|
|
mutationFn: () => api.post(`/api/v1/tickets/${id}/messages`, { message: reply }),
|
|
onSuccess: () => {
|
|
setReply('');
|
|
qc.invalidateQueries({ queryKey: ['ticket', id] });
|
|
},
|
|
onError: () => Alert.alert('Error', 'Could not send reply.'),
|
|
});
|
|
|
|
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 (
|
|
<View className="flex-1 bg-gray-50">
|
|
{/* Header */}
|
|
<View className="px-4 pt-14 pb-4 bg-blue-600">
|
|
<View className="flex-row items-center mb-2">
|
|
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
|
<Text className="text-white text-lg">←</Text>
|
|
</TouchableOpacity>
|
|
<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>
|
|
|
|
{/* Messages */}
|
|
<ScrollView className="flex-1 px-4 py-4">
|
|
{data?.description && (
|
|
<View className="bg-white border border-gray-100 rounded-2xl p-4 mb-4">
|
|
<Text className="text-xs text-gray-500 mb-1">Description</Text>
|
|
<Text className="text-gray-800">{data.description}</Text>
|
|
</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>
|
|
|
|
{/* Reply bar — hide if ticket is closed */}
|
|
{currentStatus !== 'CLOSED' ? (
|
|
<View className="flex-row px-4 py-3 bg-white border-t border-gray-100">
|
|
<TextInput
|
|
className="flex-1 bg-gray-100 rounded-xl px-4 py-3 mr-2 text-gray-900"
|
|
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
|
|
className="flex-1 bg-black/50 justify-end"
|
|
activeOpacity={1}
|
|
onPress={() => setShowStatusPicker(false)}
|
|
>
|
|
<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>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
}
|