fix: tickets freeze (useFocusEffect), comment body render, client status from subscription, activation ticket on install, keyboard avoid payment modal
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking, Alert, TextInput, Modal } from 'react-native';
|
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking, Alert, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -67,6 +67,7 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible animationType="slide" transparent onRequestClose={onClose}>
|
<Modal visible animationType="slide" transparent onRequestClose={onClose}>
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
|
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
|
||||||
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24 }}>
|
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24 }}>
|
||||||
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 4 }}>Record Payment</Text>
|
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 4 }}>Record Payment</Text>
|
||||||
@@ -115,6 +116,7 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,16 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string;
|
|||||||
SUSPENDED: { label: 'Suspended', color: '#92400E', bg: '#FEF3C7', accent: '#D97706' },
|
SUSPENDED: { label: 'Suspended', color: '#92400E', bg: '#FEF3C7', accent: '#D97706' },
|
||||||
CANCELLED: { label: 'Cancelled', color: '#991B1B', bg: '#FEE2E2', accent: '#DC2626' },
|
CANCELLED: { label: 'Cancelled', color: '#991B1B', bg: '#FEE2E2', accent: '#DC2626' },
|
||||||
PENDING: { label: 'Pending', color: '#475569', bg: '#F1F5F9', accent: '#94A3B8' },
|
PENDING: { label: 'Pending', color: '#475569', bg: '#F1F5F9', accent: '#94A3B8' },
|
||||||
|
NO_SUB: { label: 'No Sub', color: '#6B7280', bg: '#F1F5F9', accent: '#CBD5E1' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Client.status is null in API — derive from subscription status instead
|
||||||
|
function getClientStatus(client: any) {
|
||||||
|
const sub = client?.subscriptions?.[0];
|
||||||
|
if (!sub) return 'NO_SUB';
|
||||||
|
return sub.status ?? 'PENDING';
|
||||||
|
}
|
||||||
|
|
||||||
export default function ClientsScreen() {
|
export default function ClientsScreen() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||||
@@ -63,7 +71,8 @@ export default function ClientsScreen() {
|
|||||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#0891B2" />}
|
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#0891B2" />}
|
||||||
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
const st = STATUS_CONFIG[item.status] ?? { label: item.status, color: '#475569', bg: '#F1F5F9', accent: '#94A3B8' };
|
const statusKey = getClientStatus(item);
|
||||||
|
const st = STATUS_CONFIG[statusKey] ?? { label: statusKey, color: '#475569', bg: '#F1F5F9', accent: '#94A3B8' };
|
||||||
const plan = item.subscriptions?.[0]?.plan?.name;
|
const plan = item.subscriptions?.[0]?.plan?.name;
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { View, Text, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator, Linking, Alert, TextInput, Modal } from 'react-native';
|
import { View, Text, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator, Linking, Alert, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -40,6 +40,7 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible animationType="slide" transparent onRequestClose={onClose}>
|
<Modal visible animationType="slide" transparent onRequestClose={onClose}>
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
|
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
|
||||||
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24 }}>
|
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24 }}>
|
||||||
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 2 }}>Record Payment</Text>
|
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 2 }}>Record Payment</Text>
|
||||||
@@ -82,6 +83,7 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,12 +125,22 @@ export default function TicketDetailScreen() {
|
|||||||
: `Installation confirmed. Location recorded: ${coordStr}`;
|
: `Installation confirmed. Location recorded: ${coordStr}`;
|
||||||
await api.post(`/api/v1/tickets/${id}/messages`, { body: note }).catch(() => {});
|
await api.post(`/api/v1/tickets/${id}/messages`, { body: note }).catch(() => {});
|
||||||
|
|
||||||
|
// 4. Create follow-up activation ticket
|
||||||
|
await api.post('/api/v1/tickets', {
|
||||||
|
clientId: ticket?.clientId,
|
||||||
|
subject: `Account Activation — ${ticket?.client?.firstName ?? ''} ${ticket?.client?.lastName ?? ''}`.trim(),
|
||||||
|
type: 'BILLING',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
description: `Follow-up after installation confirmed. Please activate the client account and generate the first invoice.\n\nInstallation ref: ${id}\nLocation: ${coordStr}`,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
setInstNotes('');
|
setInstNotes('');
|
||||||
setCoords(null);
|
setCoords(null);
|
||||||
Alert.alert('Installation Complete!', 'Ticket resolved and client location updated.');
|
Alert.alert('Installation Complete! ✓', 'Ticket resolved, location updated, and activation ticket created.');
|
||||||
refetch();
|
refetch();
|
||||||
qc.invalidateQueries({ queryKey: ['tasks'] });
|
qc.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
qc.invalidateQueries({ queryKey: ['client', ticket?.clientId] });
|
qc.invalidateQueries({ queryKey: ['client', ticket?.clientId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['client-tickets', ticket?.clientId] });
|
||||||
setActiveTab('comments');
|
setActiveTab('comments');
|
||||||
} catch {
|
} catch {
|
||||||
Alert.alert('Error', 'Could not confirm installation. Please try again.');
|
Alert.alert('Error', 'Could not confirm installation. Please try again.');
|
||||||
@@ -404,7 +414,7 @@ export default function TicketDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
messages.map((m: any, i: number) => {
|
messages.map((m: any, i: number) => {
|
||||||
const isSystem = m.senderType === 'SYSTEM' || m.message?.startsWith('Status changed') || m.message?.startsWith('Installation confirmed');
|
const isSystem = m.senderType === 'SYSTEM' || m.body?.startsWith('Status changed') || m.body?.startsWith('Installation confirmed');
|
||||||
const isMe = m.sender?.id === user?.id;
|
const isMe = m.sender?.id === user?.id;
|
||||||
|
|
||||||
if (isSystem) {
|
if (isSystem) {
|
||||||
@@ -412,7 +422,7 @@ export default function TicketDetailScreen() {
|
|||||||
return (
|
return (
|
||||||
<View key={m.id ?? i} style={{ alignItems: 'center', marginBottom: 14 }}>
|
<View key={m.id ?? i} style={{ alignItems: 'center', marginBottom: 14 }}>
|
||||||
<View style={{ backgroundColor: '#F1F5F9', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6 }}>
|
<View style={{ backgroundColor: '#F1F5F9', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6 }}>
|
||||||
<Text style={{ fontSize: 13, color: '#64748B', fontStyle: 'italic' }}>{m.message}</Text>
|
<Text style={{ fontSize: 13, color: '#64748B', fontStyle: 'italic' }}>{m.body}</Text>
|
||||||
</View>
|
</View>
|
||||||
{m.createdAt && (
|
{m.createdAt && (
|
||||||
<Text style={{ fontSize: 11, color: '#CBD5E1', marginTop: 3 }}>
|
<Text style={{ fontSize: 11, color: '#CBD5E1', marginTop: 3 }}>
|
||||||
@@ -441,7 +451,7 @@ export default function TicketDetailScreen() {
|
|||||||
borderWidth: isMe ? 0 : 1, borderColor: '#F1F5F9',
|
borderWidth: isMe ? 0 : 1, borderColor: '#F1F5F9',
|
||||||
}}>
|
}}>
|
||||||
<Text style={{ fontSize: 16, color: isMe ? '#FFF' : '#0F172A', lineHeight: 22 }}>
|
<Text style={{ fontSize: 16, color: isMe ? '#FFF' : '#0F172A', lineHeight: 22 }}>
|
||||||
{m.message}
|
{m.body}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{m.createdAt && (
|
{m.createdAt && (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState } from 'react';
|
|||||||
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl, ScrollView } from 'react-native';
|
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl, ScrollView } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useFocusEffect } from 'expo-router';
|
||||||
|
import { useCallback } from 'react';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { api } from '../../../services/api';
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
@@ -23,6 +25,9 @@ export default function TasksScreen() {
|
|||||||
queryFn: () => api.get('/api/v1/tickets?limit=100').then(r => r.data?.data ?? r.data ?? []),
|
queryFn: () => api.get('/api/v1/tickets?limit=100').then(r => r.data?.data ?? r.data ?? []),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refetch list every time this screen comes into focus (fixes stale list after navigating back)
|
||||||
|
useFocusEffect(useCallback(() => { refetch(); }, []));
|
||||||
|
|
||||||
const tasks = (data ?? []).filter((t: any) => {
|
const tasks = (data ?? []).filter((t: any) => {
|
||||||
const matchSearch = `${t.subject} ${t.client?.firstName ?? ''} ${t.client?.lastName ?? ''}`.toLowerCase().includes(search.toLowerCase());
|
const matchSearch = `${t.subject} ${t.client?.firstName ?? ''} ${t.client?.lastName ?? ''}`.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchStatus = statusFilter === 'All' || t.status === statusFilter;
|
const matchStatus = statusFilter === 'All' || t.status === statusFilter;
|
||||||
|
|||||||
Reference in New Issue
Block a user