feat: complete all screens - client tabs, payments list, remittance detail, new ticket, installations tab
- 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
This commit is contained in:
@@ -1,12 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, ScrollView, TextInput, TouchableOpacity, ActivityIndicator, Alert } from 'react-native';
|
||||
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({
|
||||
@@ -16,49 +34,183 @@ export default function TicketDetailScreen() {
|
||||
|
||||
const addReply = useMutation({
|
||||
mutationFn: () => api.post(`/api/v1/tickets/${id}/messages`, { message: reply }),
|
||||
onSuccess: () => { setReply(''); qc.invalidateQueries({ queryKey: ['ticket', id] }); },
|
||||
onSuccess: () => {
|
||||
setReply('');
|
||||
qc.invalidateQueries({ queryKey: ['ticket', id] });
|
||||
},
|
||||
onError: () => Alert.alert('Error', 'Could not send reply.'),
|
||||
});
|
||||
|
||||
if (isLoading) return <View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>;
|
||||
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">
|
||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row items-center">
|
||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
||||
<Text className="text-white text-lg">←</Text>
|
||||
</TouchableOpacity>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-bold" numberOfLines={1}>{data?.subject}</Text>
|
||||
<Text className="text-white/70 text-xs">{data?.status} · {data?.priority}</Text>
|
||||
{/* 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?.messages ?? []).map((m: any) => (
|
||||
<View key={m.id} className={`mb-3 max-w-xs ${m.senderType === 'AGENT' ? 'self-end items-end' : 'self-start items-start'}`}>
|
||||
<View className={`rounded-2xl px-4 py-3 ${m.senderType === 'AGENT' ? 'bg-primary' : 'bg-white border border-gray-100'}`}>
|
||||
<Text className={m.senderType === 'AGENT' ? 'text-white' : 'text-gray-900'}>{m.message}</Text>
|
||||
</View>
|
||||
<Text className="text-gray-400 text-xs mt-1">{m.senderName}</Text>
|
||||
{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>
|
||||
<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"
|
||||
placeholder="Type a reply..."
|
||||
value={reply}
|
||||
onChangeText={setReply}
|
||||
multiline
|
||||
/>
|
||||
|
||||
{/* 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="bg-primary rounded-xl px-4 items-center justify-center"
|
||||
onPress={() => reply.trim() && addReply.mutate()}
|
||||
disabled={addReply.isPending}
|
||||
className="flex-1 bg-black/50 justify-end"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowStatusPicker(false)}
|
||||
>
|
||||
{addReply.isPending ? <ActivityIndicator color="white" /> : <Text className="text-white font-semibold">Send</Text>}
|
||||
<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>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl } from 'react-native';
|
||||
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl, ScrollView } from 'react-native';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
import { api } from '../../../services/api';
|
||||
|
||||
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#6B7280' };
|
||||
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', MEDIUM: '#D97706', LOW: '#16A34A' };
|
||||
const STATUS_FILTERS = ['ALL', 'OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
|
||||
|
||||
export default function TicketsScreen() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||
|
||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['tickets'],
|
||||
queryFn: () => api.get('/api/v1/tickets?limit=50').then(r => r.data?.data ?? r.data),
|
||||
queryFn: () => api.get('/api/v1/tickets?limit=100').then(r => r.data?.data ?? r.data ?? []),
|
||||
});
|
||||
|
||||
const tickets = (data ?? []).filter((t: any) =>
|
||||
`${t.subject} ${t.client?.firstName} ${t.client?.lastName}`.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const tickets = (data ?? []).filter((t: any) => {
|
||||
const matchSearch = `${t.subject} ${t.client?.firstName} ${t.client?.lastName}`.toLowerCase().includes(search.toLowerCase());
|
||||
const matchStatus = statusFilter === 'ALL' || t.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
<View className="px-4 pt-14 pb-4 bg-primary">
|
||||
<Text className="text-white text-xl font-bold">Helpdesk Tickets</Text>
|
||||
{/* Header */}
|
||||
<View className="px-4 pt-14 pb-4 bg-primary flex-row justify-between items-center">
|
||||
<Text className="text-white text-xl font-bold">Tickets</Text>
|
||||
<TouchableOpacity
|
||||
className="bg-white/20 rounded-xl px-4 py-2"
|
||||
onPress={() => router.push('/(app)/tickets/new')}
|
||||
>
|
||||
<Text className="text-white font-semibold text-sm">+ New</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="px-4 py-3">
|
||||
|
||||
{/* Search */}
|
||||
<View className="px-4 pt-3 pb-2 bg-white border-b border-gray-100">
|
||||
<TextInput
|
||||
className="bg-white border border-gray-200 rounded-xl px-4 py-3"
|
||||
className="bg-gray-100 border border-gray-200 rounded-xl px-4 py-3 mb-2"
|
||||
placeholder="Search tickets..."
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
/>
|
||||
{/* Status filter chips */}
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="pb-1">
|
||||
{STATUS_FILTERS.map(s => (
|
||||
<TouchableOpacity
|
||||
key={s}
|
||||
onPress={() => setStatusFilter(s)}
|
||||
className={`rounded-full px-3 py-1.5 mr-2 ${statusFilter === s ? 'bg-primary' : 'bg-gray-100'}`}
|
||||
>
|
||||
<Text className={`text-xs font-semibold ${statusFilter === s ? 'text-white' : 'text-gray-600'}`}>
|
||||
{s.replace('_', ' ')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center"><ActivityIndicator color="#2563EB" /></View>
|
||||
) : (
|
||||
@@ -37,22 +66,38 @@ export default function TicketsScreen() {
|
||||
data={tickets}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 24 }}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12, paddingBottom: 32 }}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
className="bg-white rounded-xl p-4 mb-2 border border-gray-100"
|
||||
onPress={() => router.push(`/(app)/tickets/${item.id}`)}
|
||||
>
|
||||
<View className="flex-row justify-between items-start">
|
||||
<Text className="font-semibold text-gray-900 flex-1 mr-2">{item.subject}</Text>
|
||||
<View className="flex-row justify-between items-start mb-1">
|
||||
<Text className="font-semibold text-gray-900 flex-1 mr-2" numberOfLines={2}>{item.subject}</Text>
|
||||
<View className="rounded-full px-2 py-0.5" style={{ backgroundColor: `${PRIORITY_COLOR[item.priority] ?? '#6B7280'}20` }}>
|
||||
<Text className="text-xs font-medium" style={{ color: PRIORITY_COLOR[item.priority] ?? '#6B7280' }}>{item.priority}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-gray-500 text-sm mt-1">{item.client?.firstName} {item.client?.lastName} · {item.status}</Text>
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text className="text-gray-500 text-sm">
|
||||
{item.client?.firstName} {item.client?.lastName}
|
||||
</Text>
|
||||
<Text className="text-gray-400 text-xs">{item.status?.replace('_', ' ')}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={<View className="items-center py-16"><Text className="text-gray-400">No tickets found</Text></View>}
|
||||
ListEmptyComponent={
|
||||
<View className="items-center py-20">
|
||||
<Text className="text-4xl mb-3">🎫</Text>
|
||||
<Text className="text-gray-400 text-base">No tickets found</Text>
|
||||
<TouchableOpacity
|
||||
className="mt-4 bg-primary rounded-xl px-6 py-3"
|
||||
onPress={() => router.push('/(app)/tickets/new')}
|
||||
>
|
||||
<Text className="text-white font-semibold">Create First Ticket</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
182
app/(app)/tickets/new.tsx
Normal file
182
app/(app)/tickets/new.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user