Files
fiberops-mobile/app/(app)/tasks/[id].tsx

687 lines
35 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef } from 'react';
import {
View, Text, ScrollView, TextInput, TouchableOpacity,
ActivityIndicator, Alert, Modal, KeyboardAvoidingView, Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, router } from 'expo-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as Location from 'expo-location';
import { api } from '../../../services/api';
import { useAuthStore } from '../../../stores/authStore';
import { SlideToConfirm } from '../../../components/SlideToConfirm';
// ─── Constants ────────────────────────────────────────────────────────────────
const STATUS_FLOW = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const;
type TaskStatus = typeof STATUS_FLOW[number];
const STATUS_STYLE: Record<string, { bg: string; color: string }> = {
OPEN: { bg: '#ECFEFF', color: '#0891B2' },
IN_PROGRESS: { bg: '#FFFBEB', color: '#D97706' },
RESOLVED: { bg: '#F0FDF4', color: '#16A34A' },
CLOSED: { bg: '#F1F5F9', color: '#6B7280' },
};
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', NORMAL: '#0891B2' };
const PRIORITY_BG: Record<string, string> = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' };
const TYPE_COLOR: Record<string, string> = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' };
const TYPE_BG: Record<string, string> = { INSTALLATION: '#ECFEFF', SUPPORT: '#F5F3FF', BILLING: '#FFFBEB' };
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>
);
}
// ─── Main Screen ──────────────────────────────────────────────────────────────
export default function TicketDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { user } = useAuthStore();
const qc = useQueryClient();
const scrollRef = useRef<ScrollView>(null);
const [activeTab, setActiveTab] = useState<'details' | 'comments'>('details');
const [showStatusPicker, setShowStatusPicker] = useState(false);
const [instNotes, setInstNotes] = useState('');
const [instConfirming, setInstConfirming] = useState(false);
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
const [locLoading, setLocLoading] = useState(false);
const [comment, setComment] = useState('');
const [sendingComment, setSendingComment] = useState(false);
const { data: ticket, isLoading, refetch } = useQuery({
queryKey: ['task', id],
queryFn: () => api.get(`/api/v1/tickets/${id}`).then(r => r.data),
staleTime: 0,
});
// For activation tickets — fetch client (subscription) + first invoice
const isActivationTicket = ticket?.type === 'BILLING' && ticket?.subject?.includes('Activation');
const { data: clientDetail, refetch: refetchClient } = useQuery({
queryKey: ['task-client', ticket?.clientId],
queryFn: () => api.get(`/api/v1/clients/${ticket.clientId}`).then(r => r.data),
enabled: !!ticket?.clientId && isActivationTicket,
staleTime: 0,
});
// Fetch invoices for this client (to check if first invoice is PAID)
const { data: clientInvoices, refetch: refetchInvoices } = useQuery({
queryKey: ['task-client-invoices', ticket?.clientId],
queryFn: () => api.get(`/api/v1/invoices?clientId=${ticket.clientId}&limit=5`).then(r => r.data?.data ?? r.data ?? []),
enabled: !!ticket?.clientId && isActivationTicket,
staleTime: 0,
});
const updateStatus = useMutation({
mutationFn: async ({ status, activationTicket, subId }: { status: string; activationTicket: boolean; subId?: string }) => {
// Return the patched ticket so onSuccess can set cache directly
const res = await api.patch(`/api/v1/tickets/${id}`, { status });
const updatedTicket = res.data;
// Activate subscription when resolving an activation ticket
if ((status === 'RESOLVED' || status === 'CLOSED') && activationTicket && subId) {
await api.patch(`/api/v1/subscriptions/${subId}`, { status: 'ACTIVE' }).catch(() => {});
}
const who = user?.firstName ?? 'Staff';
await api.post(`/api/v1/tickets/${id}/messages`, {
body: `Status changed to ${status.replace('_', ' ')} by ${who}`,
}).catch(() => {});
return updatedTicket;
},
onSuccess: (updatedTicket) => {
// Directly inject fresh data into cache — avoids race condition with refetch
if (updatedTicket) {
qc.setQueryData(['task', id], (old: any) => ({ ...(old ?? {}), ...updatedTicket }));
}
setShowStatusPicker(false);
// Invalidate list queries in background (non-blocking)
qc.invalidateQueries({ queryKey: ['tasks'] });
qc.invalidateQueries({ queryKey: ['clients'] });
qc.invalidateQueries({ queryKey: ['task-client', updatedTicket?.clientId] });
},
onError: (e: any) => {
const msg = e?.response?.data?.message ?? 'Could not update status.';
Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg);
},
});
const captureLocation = async () => {
setLocLoading(true);
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Location permission is required to record the installation site.');
return;
}
const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High });
setCoords({ lat: loc.coords.latitude, lng: loc.coords.longitude });
} catch {
Alert.alert('Error', 'Could not get location. Make sure GPS is enabled.');
} finally {
setLocLoading(false);
}
};
const confirmInstallation = async () => {
setInstConfirming(true);
try {
// 1. Resolve the ticket — use response to directly update cache
const patchRes = await api.patch(`/api/v1/tickets/${id}`, { status: 'RESOLVED' });
if (patchRes.data) {
qc.setQueryData(['task', id], (old: any) => ({ ...(old ?? {}), ...patchRes.data }));
}
// 2. Update client location with recorded coordinates (skip if GPS unavailable)
if (ticket?.clientId && coords) {
await api.patch(`/api/v1/clients/${ticket.clientId}`, {
lat: coords.lat,
lng: coords.lng,
}).catch(() => {});
}
// 3. Log activity comment
const coordStr = coords ? `${coords.lat.toFixed(6)}, ${coords.lng.toFixed(6)}` : 'Not captured';
const note = instNotes.trim()
? `Installation confirmed. Location recorded: ${coordStr}. Notes: ${instNotes.trim()}`
: `Installation confirmed. Location recorded: ${coordStr}`;
await api.post(`/api/v1/tickets/${id}/messages`, { body: note }).catch(() => {});
// 4. Generate first invoice so it appears in Collect screen
if (ticket?.clientId) {
await api.post(`/api/v1/invoices/generate/${ticket.clientId}`).catch(() => {});
}
// 5. Create follow-up activation ticket (non-fatal)
await api.post('/api/v1/tickets', {
clientId: ticket?.clientId,
subject: `Account Activation — ${ticket?.client?.firstName ?? ''} ${ticket?.client?.lastName ?? ''}`.trim(),
type: 'BILLING',
priority: 'NORMAL',
}).catch(() => {});
setInstNotes('');
setCoords(null);
// Invalidate list queries in background (cache already updated above)
qc.invalidateQueries({ queryKey: ['tasks'] });
qc.invalidateQueries({ queryKey: ['clients'] });
Alert.alert(
'Installation Complete! ✓',
'Ticket resolved, location recorded, and activation ticket created.',
[{ text: 'OK', onPress: () => router.back() }]
);
} catch {
Alert.alert('Error', 'Could not confirm installation. Please try again.');
} finally {
setInstConfirming(false);
}
};
const sendComment = async () => {
if (!comment.trim()) return;
setSendingComment(true);
const text = comment.trim();
setComment(''); // clear immediately for responsiveness
try {
await api.post(`/api/v1/tickets/${id}/messages`, { body: text });
refetch();
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 300);
} catch {
Alert.alert('Error', 'Could not send comment.');
setComment(text); // restore on failure
} finally {
setSendingComment(false);
}
};
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 currentStatus: string = ticket?.status ?? 'OPEN';
const statusStyle = STATUS_STYLE[currentStatus] ?? STATUS_STYLE.OPEN;
const isInstallation = ticket?.type === 'INSTALLATION';
const isDone = currentStatus === 'RESOLVED' || currentStatus === 'CLOSED';
const typeColor = TYPE_COLOR[ticket?.type] ?? '#6B7280';
const typeBg = TYPE_BG[ticket?.type] ?? '#F1F5F9';
const messages: any[] = ticket?.messages ?? [];
// Activation ticket gate — check first invoice paid
const sub = clientDetail?.subscriptions?.[0];
const isPrepaid = sub?.type === 'PREPAID';
const invoiceList: any[] = Array.isArray(clientInvoices) ? clientInvoices : [];
const firstInvoice = invoiceList[0] ?? null;
const firstInvoicePaid = firstInvoice?.status === 'PAID' || firstInvoice?.balance === 0;
const blockResolve = isActivationTicket && isPrepaid && !firstInvoicePaid;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<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>
<Text style={{ color: '#FFF', fontSize: 20, fontWeight: '800', marginBottom: 12 }} numberOfLines={2}>
{ticket?.subject}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
{/* Type */}
<View style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: typeBg }}>
<Text style={{ fontSize: 13, fontWeight: '700', color: typeColor }}>{ticket?.type}</Text>
</View>
{/* Status — tappable to change */}
<TouchableOpacity
onPress={() => setShowStatusPicker(true)}
style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: statusStyle.bg }}
activeOpacity={0.7}
>
<Text style={{ fontSize: 13, fontWeight: '700', color: statusStyle.color }}>
{currentStatus.replace('_', ' ')}
</Text>
</TouchableOpacity>
{/* Priority — only show HIGH */}
{ticket?.priority === 'HIGH' && (
<View style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: PRIORITY_BG.HIGH }}>
<Text style={{ fontSize: 13, fontWeight: '700', color: PRIORITY_COLOR.HIGH }}>HIGH</Text>
</View>
)}
</View>
{ticket?.client && (
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 10 }}>
{ticket.client.firstName} {ticket.client.lastName} · {ticket.client.accountNumber}
{ticket.assignedTo
? ` · Assigned: ${ticket.assignedTo.firstName} ${ticket.assignedTo.lastName}`
: ' · Unassigned'}
</Text>
)}
</View>
{/* ── Tabs ── */}
<View style={{ flexDirection: 'row', backgroundColor: '#FFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
{[
{ key: 'details', label: 'Details' },
{ key: 'comments', label: `Comments${messages.length > 0 ? ` (${messages.length})` : ''}` },
].map(tab => (
<TouchableOpacity
key={tab.key}
onPress={() => setActiveTab(tab.key as 'details' | 'comments')}
style={{
flex: 1, paddingVertical: 16, alignItems: 'center',
borderBottomWidth: 2.5,
borderBottomColor: activeTab === tab.key ? '#0891B2' : 'transparent',
}}
activeOpacity={0.7}
>
<Text style={{
fontSize: 15, fontWeight: '700',
color: activeTab === tab.key ? '#0891B2' : '#94A3B8',
}}>
{tab.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* ── DETAILS TAB ── */}
{activeTab === 'details' && (
<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="Type" value={ticket?.type} />
<InfoRow label="Client" value={ticket?.client ? `${ticket.client.firstName} ${ticket.client.lastName}` : null} />
<InfoRow label="Assigned to" value={ticket?.assignedTo ? `${ticket.assignedTo.firstName} ${ticket.assignedTo.lastName}` : 'Unassigned'} />
<InfoRow label="Created by" value={ticket?.createdBy ? `${ticket.createdBy.firstName} ${ticket.createdBy.lastName}` : null} />
<InfoRow label="Created" value={ticket?.createdAt ? formatDate(ticket.createdAt) : null} />
<InfoRow label="Resolved" value={ticket?.resolvedAt ? formatDate(ticket.resolvedAt) : null} isLast />
</View>
{/* Description */}
{ticket?.description ? (
<View style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#F1F5F9' }}>
<Text style={{ fontSize: 12, fontWeight: '700', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 8 }}>Description</Text>
<Text style={{ fontSize: 16, color: '#334155', lineHeight: 26 }}>{ticket.description}</Text>
</View>
) : null}
{/* ── ACTIVATION TICKET BANNER ── */}
{isActivationTicket && !isDone && (
<View style={{
borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1.5,
backgroundColor: blockResolve ? '#FFFBEB' : '#F0FDF4',
borderColor: blockResolve ? '#FDE68A' : '#86EFAC',
}}>
{blockResolve ? (
<>
<Text style={{ fontSize: 16, fontWeight: '800', color: '#92400E', marginBottom: 6 }}>
First Invoice Not Yet Paid
</Text>
<Text style={{ fontSize: 14, color: '#92400E', lineHeight: 20 }}>
This is a <Text style={{ fontWeight: '700' }}>PREPAID</Text> account. The first month's invoice must be settled before this account can be activated.
{'\n\n'}Go to the <Text style={{ fontWeight: '700', color: '#D97706' }}>Collect</Text> screen to record the payment, then come back here to resolve this ticket.
</Text>
{firstInvoice && (
<View style={{ marginTop: 12, backgroundColor: '#FEF3C7', borderRadius: 10, padding: 12 }}>
<Text style={{ fontSize: 13, color: '#92400E', fontWeight: '600' }}>
Invoice #{firstInvoice.invoiceNumber}
</Text>
<Text style={{ fontSize: 15, color: '#92400E', fontWeight: '800', marginTop: 2 }}>
Balance: ₱{Number(firstInvoice.balance ?? firstInvoice.total ?? 0).toLocaleString()}
</Text>
<Text style={{ fontSize: 12, color: '#B45309', marginTop: 2 }}>
Status: {firstInvoice.status}
</Text>
</View>
)}
</>
) : (
<>
<Text style={{ fontSize: 16, fontWeight: '800', color: '#166534', marginBottom: 4 }}>
✓ Ready to Activate
</Text>
<Text style={{ fontSize: 14, color: '#166534' }}>
{isPrepaid
? 'First invoice has been paid. Tap "Update Status" Resolved to activate this account.'
: 'Postpaid account is ready to activate. Tap "Update Status" Resolved to activate.'}
</Text>
</>
)}
</View>
)}
{isActivationTicket && isDone && (
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#86EFAC' }}>
<Text style={{ fontSize: 16, fontWeight: '800', color: '#166534' }}>✅ Account Activated</Text>
<Text style={{ fontSize: 14, color: '#16A34A', marginTop: 4 }}>
Subscription is now ACTIVE.
</Text>
</View>
)}
{/* ── INSTALLATION SECTION ── */}
{isInstallation && (
<>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 14 }}>
<View style={{ flex: 1, height: 1, backgroundColor: '#E2E8F0' }} />
<Text style={{ marginHorizontal: 12, fontSize: 13, fontWeight: '700', color: '#0891B2', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Installation
</Text>
<View style={{ flex: 1, height: 1, backgroundColor: '#E2E8F0' }} />
</View>
{isDone ? (
/* ─ Already confirmed ─ */
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#86EFAC', alignItems: 'center', marginBottom: 16 }}>
<Text style={{ fontSize: 28, marginBottom: 8 }}>✅</Text>
<Text style={{ fontSize: 18, fontWeight: '800', color: '#166534' }}>Installation Complete</Text>
{ticket?.resolvedAt && (
<Text style={{ fontSize: 14, color: '#16A34A', marginTop: 4 }}>
Confirmed on {formatDate(ticket.resolvedAt)}
</Text>
)}
<TouchableOpacity
onPress={() => setActiveTab('comments')}
style={{ marginTop: 12 }}
activeOpacity={0.7}
>
<Text style={{ fontSize: 15, fontWeight: '600', color: '#16A34A' }}>
View activity log →
</Text>
</TouchableOpacity>
</View>
) : (
/* ─ Confirm installation form ─ */
<View style={{ backgroundColor: '#FFFBEB', borderRadius: 16, padding: 18, borderWidth: 1.5, borderColor: '#FCD34D', marginBottom: 16 }}>
<Text style={{ fontSize: 17, fontWeight: '700', color: '#92400E', marginBottom: 16 }}>
Confirm Installation
</Text>
{/* ── GPS Coordinates (required) ── */}
<Text style={{ fontSize: 15, fontWeight: '700', color: '#78350F', marginBottom: 8 }}>
📍 Installation Location <Text style={{ color: '#DC2626' }}>*</Text>
</Text>
{coords ? (
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 12, padding: 14, marginBottom: 16, borderWidth: 1, borderColor: '#86EFAC', flexDirection: 'row', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 15, fontWeight: '700', color: '#166534' }}>✓ Location Captured</Text>
<Text style={{ fontSize: 13, color: '#16A34A', marginTop: 3, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace' }}>
{coords.lat.toFixed(6)}, {coords.lng.toFixed(6)}
</Text>
</View>
<TouchableOpacity onPress={captureLocation} disabled={locLoading} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Text style={{ fontSize: 13, fontWeight: '600', color: '#0891B2' }}>
{locLoading ? '...' : 'Retake'}
</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#FCD34D', borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginBottom: 16, flexDirection: 'row', justifyContent: 'center' }}
onPress={captureLocation}
disabled={locLoading}
activeOpacity={0.8}
>
{locLoading
? <><ActivityIndicator color="#D97706" size="small" style={{ marginRight: 10 }} /><Text style={{ fontSize: 16, fontWeight: '700', color: '#D97706' }}>Getting GPS...</Text></>
: <><Text style={{ fontSize: 18, marginRight: 8 }}>📍</Text><Text style={{ fontSize: 16, fontWeight: '700', color: '#D97706' }}>Capture Current Location</Text></>
}
</TouchableOpacity>
)}
{/* ── Notes ── */}
<Text style={{ fontSize: 15, fontWeight: '600', color: '#78350F', marginBottom: 8 }}>
Notes / Remarks
</Text>
<TextInput
style={{
backgroundColor: '#FFF',
borderWidth: 1.5, borderColor: '#FDE68A', borderRadius: 12,
paddingHorizontal: 16, paddingVertical: 12,
fontSize: 16, color: '#0F172A',
marginBottom: 16, minHeight: 88, textAlignVertical: 'top',
}}
placeholder="Equipment serial, router model, cable length, remarks..."
placeholderTextColor="#D97706"
value={instNotes}
onChangeText={setInstNotes}
multiline
/>
<TouchableOpacity
style={{
backgroundColor: '#059669',
borderRadius: 14, paddingVertical: 16, alignItems: 'center',
}}
onPress={() => {
if (!coords) {
Alert.alert(
'Location Not Captured',
'Location not captured are you sure you want to proceed without GPS coordinates?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Confirm Without Location', onPress: confirmInstallation },
]
);
} else {
Alert.alert(
'Confirm Installation',
`Mark this installation as complete?\n\nLocation: ${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}\n\nThis will update the client's location and resolve the ticket.`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Confirm', onPress: confirmInstallation },
]
);
}
}}
disabled={instConfirming}
activeOpacity={0.8}
>
{instConfirming
? <ActivityIndicator color="#FFF" />
: <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>
✓ Mark Installation Complete
</Text>
}
</TouchableOpacity>
</View>
)}
</>
)}
</ScrollView>
)}
{/* ── COMMENTS TAB ── */}
{activeTab === 'comments' && (
<View style={{ flex: 1 }}>
<ScrollView
ref={scrollRef}
style={{ flex: 1 }}
contentContainerStyle={{ padding: 16, paddingBottom: 8 }}
>
{messages.length === 0 ? (
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
<Text style={{ fontSize: 16, color: '#94A3B8', marginBottom: 6 }}>No comments yet</Text>
<Text style={{ fontSize: 14, color: '#CBD5E1', textAlign: 'center' }}>
Add a note or update below
</Text>
</View>
) : (
messages.map((m: any, i: number) => {
const isSystem = m.senderType === 'SYSTEM' || m.body?.startsWith('Status changed') || m.body?.startsWith('Installation confirmed');
const isMe = m.sender?.id === user?.id;
if (isSystem) {
// System messages — centered pill
return (
<View key={m.id ?? i} style={{ alignItems: 'center', marginBottom: 14 }}>
<View style={{ backgroundColor: '#F1F5F9', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6 }}>
<Text style={{ fontSize: 13, color: '#64748B', fontStyle: 'italic' }}>{m.body}</Text>
</View>
{m.createdAt && (
<Text style={{ fontSize: 11, color: '#CBD5E1', marginTop: 3 }}>
{formatDate(m.createdAt)}
</Text>
)}
</View>
);
}
// User messages — chat bubbles
return (
<View
key={m.id ?? i}
style={{ marginBottom: 14, maxWidth: '80%', alignSelf: isMe ? 'flex-end' : 'flex-start' }}
>
{!isMe && (
<Text style={{ fontSize: 12, fontWeight: '600', color: '#94A3B8', marginBottom: 4, marginLeft: 4 }}>
{m.senderName ?? m.sender?.firstName ?? 'Staff'}
</Text>
)}
<View style={{
borderRadius: 18,
paddingHorizontal: 16, paddingVertical: 12,
backgroundColor: isMe ? '#0891B2' : '#FFF',
borderWidth: isMe ? 0 : 1, borderColor: '#F1F5F9',
}}>
<Text style={{ fontSize: 16, color: isMe ? '#FFF' : '#0F172A', lineHeight: 22 }}>
{m.body}
</Text>
</View>
{m.createdAt && (
<Text style={{
fontSize: 11, color: '#CBD5E1', marginTop: 3,
alignSelf: isMe ? 'flex-end' : 'flex-start',
}}>
{formatDate(m.createdAt)}
</Text>
)}
</View>
);
})
)}
</ScrollView>
{/* ── Comment input — ALWAYS visible ── */}
<View style={{
flexDirection: 'row',
paddingHorizontal: 16, paddingVertical: 12,
backgroundColor: '#FFF',
borderTopWidth: 1, borderTopColor: '#F1F5F9',
}}>
<TextInput
style={{
flex: 1,
backgroundColor: '#F8FAFC',
borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14,
paddingHorizontal: 16, paddingVertical: 12,
fontSize: 16, color: '#0F172A', marginRight: 10,
maxHeight: 100,
}}
placeholder={isDone ? 'Add a follow-up note...' : 'Add a comment...'}
placeholderTextColor="#94A3B8"
value={comment}
onChangeText={setComment}
multiline
returnKeyType="send"
/>
<TouchableOpacity
style={{
backgroundColor: comment.trim() ? '#0891B2' : '#E2E8F0',
borderRadius: 14, paddingHorizontal: 18,
alignItems: 'center', justifyContent: 'center',
}}
onPress={sendComment}
disabled={sendingComment || !comment.trim()}
activeOpacity={0.8}
>
{sendingComment
? <ActivityIndicator color="#FFF" size="small" />
: <Text style={{ color: comment.trim() ? '#FFF' : '#94A3B8', fontWeight: '700', fontSize: 15 }}>
Send
</Text>
}
</TouchableOpacity>
</View>
</View>
)}
</View>
{/* ── Status Picker Modal ── */}
<Modal visible={showStatusPicker} transparent animationType="slide" onRequestClose={() => setShowStatusPicker(false)}>
<TouchableOpacity
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }}
activeOpacity={1}
onPress={() => setShowStatusPicker(false)}
>
<TouchableOpacity activeOpacity={1} style={{ backgroundColor: '#FFF', borderTopLeftRadius: 28, borderTopRightRadius: 28, padding: 24 }}>
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0F172A', marginBottom: 4 }}>Update Status</Text>
<Text style={{ fontSize: 15, color: '#94A3B8', marginBottom: 20 }}>
Current: <Text style={{ fontWeight: '700', color: '#0F172A' }}>{currentStatus.replace('_', ' ')}</Text>
</Text>
{STATUS_FLOW.map(s => {
const style = STATUS_STYLE[s];
const isActive = s === currentStatus;
return (
<TouchableOpacity
key={s}
onPress={() => {
if (isActive) return;
if (blockResolve && (s === 'RESOLVED' || s === 'CLOSED')) {
setShowStatusPicker(false);
Alert.alert(
'Invoice Not Yet Paid',
'This is a PREPAID account. The first month\'s invoice must be paid before activating the account.\n\nGo to Collect screen to record the payment first.',
[{ text: 'OK' }]
);
return;
}
updateStatus.mutate({
status: s,
activationTicket: isActivationTicket,
subId: clientDetail?.subscriptions?.[0]?.id,
});
}}
disabled={isActive || updateStatus.isPending}
style={{
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
padding: 18, borderRadius: 16, marginBottom: 10,
backgroundColor: style.bg, opacity: isActive ? 0.5 : 1,
}}
activeOpacity={0.7}
>
<Text style={{ fontSize: 17, fontWeight: '700', color: style.color }}>{s.replace('_', ' ')}</Text>
{isActive && <Text style={{ fontSize: 14, color: style.color, fontWeight: '600' }}>✓ Current</Text>}
{updateStatus.isPending && !isActive && <ActivityIndicator size="small" color={style.color} />}
</TouchableOpacity>
);
})}
<TouchableOpacity onPress={() => setShowStatusPicker(false)} style={{ paddingVertical: 14, alignItems: 'center' }}>
<Text style={{ fontSize: 16, color: '#94A3B8', fontWeight: '600' }}>Cancel</Text>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</KeyboardAvoidingView>
</SafeAreaView>
);
}