- 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
183 lines
7.6 KiB
TypeScript
183 lines
7.6 KiB
TypeScript
import { useState } from 'react';
|
|
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
|
import { router } from 'expo-router';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { api } from '../../../services/api';
|
|
|
|
const PRIORITIES = ['LOW', 'MEDIUM', 'HIGH'];
|
|
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#16A34A' };
|
|
|
|
const CATEGORIES: { value: string; label: string }[] = [
|
|
{ value: 'NO_SIGNAL', label: 'No Signal' },
|
|
{ value: 'SLOW_CONNECTION', label: 'Slow Connection' },
|
|
{ value: 'BILLING', label: 'Billing' },
|
|
{ value: 'INSTALLATION', label: 'Installation' },
|
|
{ value: 'RELOCATION', label: 'Relocation' },
|
|
{ value: 'OTHER', label: 'Other' },
|
|
];
|
|
|
|
export default function NewTicketScreen() {
|
|
const qc = useQueryClient();
|
|
const [subject, setSubject] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [priority, setPriority] = useState('MEDIUM');
|
|
const [category, setCategory] = useState('NO_SIGNAL');
|
|
const [search, setSearch] = useState('');
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
const [client, setClient] = useState<any>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const { data: searchResults, isFetching: searching } = useQuery({
|
|
queryKey: ['client-search', debouncedSearch],
|
|
queryFn: () => api.get(`/api/v1/clients?search=${debouncedSearch}&limit=8`).then(r => r.data?.data ?? r.data ?? []),
|
|
enabled: debouncedSearch.trim().length >= 2,
|
|
});
|
|
|
|
const handleSearchChange = (v: string) => {
|
|
setSearch(v);
|
|
setTimeout(() => setDebouncedSearch(v), 400);
|
|
};
|
|
|
|
const submit = async () => {
|
|
if (!subject.trim()) return Alert.alert('Required', 'Enter a subject.');
|
|
if (!client) return Alert.alert('Required', 'Select a client.');
|
|
setLoading(true);
|
|
try {
|
|
await api.post('/api/v1/tickets', {
|
|
subject: subject.trim(),
|
|
description: description.trim() || undefined,
|
|
priority,
|
|
category,
|
|
clientId: client.id,
|
|
});
|
|
qc.invalidateQueries({ queryKey: ['tickets'] });
|
|
qc.invalidateQueries({ queryKey: ['dashboard'] });
|
|
Alert.alert('✅ Ticket Created', subject, [{ text: 'OK', onPress: () => router.back() }]);
|
|
} catch (e: any) {
|
|
Alert.alert('Error', e?.response?.data?.message ?? 'Could not create ticket.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View className="flex-1 bg-gray-50">
|
|
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
|
<TouchableOpacity onPress={() => router.back()} className="mr-3 p-1">
|
|
<Text className="text-white text-lg">←</Text>
|
|
</TouchableOpacity>
|
|
<Text className="text-white text-xl font-bold">New Ticket</Text>
|
|
</View>
|
|
|
|
<ScrollView className="flex-1 px-4 py-4" keyboardShouldPersistTaps="handled">
|
|
{/* Client */}
|
|
<Text className="font-semibold text-gray-700 mb-2">Client *</Text>
|
|
{client ? (
|
|
<View className="flex-row items-center bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
|
|
<View className="flex-1">
|
|
<Text className="font-bold text-blue-900">{client.firstName} {client.lastName}</Text>
|
|
<Text className="text-blue-600 text-sm">{client.accountNumber}</Text>
|
|
</View>
|
|
<TouchableOpacity onPress={() => { setClient(null); setSearch(''); setDebouncedSearch(''); }} className="p-2">
|
|
<Text className="text-blue-500 font-semibold">Change</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : (
|
|
<View className="mb-4">
|
|
<View className="flex-row items-center bg-white border border-gray-200 rounded-xl px-4 mb-1">
|
|
<TextInput
|
|
className="flex-1 py-3 text-base"
|
|
placeholder="Search client by name or account #"
|
|
value={search}
|
|
onChangeText={handleSearchChange}
|
|
autoCapitalize="none"
|
|
/>
|
|
{searching && <ActivityIndicator size="small" color="#2563EB" />}
|
|
</View>
|
|
{debouncedSearch.trim().length >= 2 && (
|
|
<View className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
|
{(searchResults ?? []).length === 0 && !searching && (
|
|
<Text className="px-4 py-3 text-gray-400">No clients found</Text>
|
|
)}
|
|
{(searchResults ?? []).map((c: any) => (
|
|
<TouchableOpacity
|
|
key={c.id}
|
|
className="px-4 py-3 border-b border-gray-100"
|
|
onPress={() => { setClient(c); setSearch(''); setDebouncedSearch(''); }}
|
|
>
|
|
<Text className="font-medium text-gray-900">{c.firstName} {c.lastName}</Text>
|
|
<Text className="text-gray-500 text-sm">{c.accountNumber}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* Subject */}
|
|
<Text className="font-semibold text-gray-700 mb-2">Subject *</Text>
|
|
<TextInput
|
|
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-4"
|
|
placeholder="e.g. No internet connection"
|
|
value={subject}
|
|
onChangeText={setSubject}
|
|
/>
|
|
|
|
{/* Category */}
|
|
<Text className="font-semibold text-gray-700 mb-2">Category</Text>
|
|
<View className="flex-row flex-wrap mb-4">
|
|
{CATEGORIES.map(c => (
|
|
<TouchableOpacity
|
|
key={c.value}
|
|
onPress={() => setCategory(c.value)}
|
|
className={`rounded-xl px-3 py-2 mr-2 mb-2 border ${category === c.value ? 'bg-primary border-primary' : 'bg-white border-gray-200'}`}
|
|
>
|
|
<Text className={`text-sm font-medium ${category === c.value ? 'text-white' : 'text-gray-700'}`}>
|
|
{c.label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
{/* Priority */}
|
|
<Text className="font-semibold text-gray-700 mb-2">Priority</Text>
|
|
<View className="flex-row mb-4">
|
|
{PRIORITIES.map(p => (
|
|
<TouchableOpacity
|
|
key={p}
|
|
onPress={() => setPriority(p)}
|
|
className={`flex-1 rounded-xl py-2.5 items-center mx-1 border ${priority === p ? 'border-transparent' : 'bg-white border-gray-200'}`}
|
|
style={priority === p ? { backgroundColor: PRIORITY_COLOR[p] } : {}}
|
|
>
|
|
<Text className={`font-semibold text-sm ${priority === p ? 'text-white' : 'text-gray-600'}`}>{p}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
{/* Description */}
|
|
<Text className="font-semibold text-gray-700 mb-2">Description <Text className="text-gray-400 font-normal">(optional)</Text></Text>
|
|
<TextInput
|
|
className="bg-white border border-gray-200 rounded-xl px-4 py-3 text-base mb-8"
|
|
placeholder="Describe the issue in more detail..."
|
|
value={description}
|
|
onChangeText={setDescription}
|
|
multiline
|
|
numberOfLines={4}
|
|
textAlignVertical="top"
|
|
style={{ minHeight: 96 }}
|
|
/>
|
|
|
|
<TouchableOpacity
|
|
className={`rounded-xl py-4 items-center ${client && subject ? 'bg-primary' : 'bg-gray-300'}`}
|
|
onPress={submit}
|
|
disabled={loading || !client || !subject}
|
|
>
|
|
{loading ? <ActivityIndicator color="white" /> : <Text className="text-white font-bold text-base">Create Ticket</Text>}
|
|
</TouchableOpacity>
|
|
|
|
<View className="h-8" />
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|