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 = { 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 = { HIGH: '#DC2626', NORMAL: '#0891B2' }; const PRIORITY_BG: Record = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' }; const TYPE_COLOR: Record = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' }; const TYPE_BG: Record = { 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 ( {label} {value} ); } // ─── Main Screen ────────────────────────────────────────────────────────────── export default function TicketDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const { user } = useAuthStore(); const qc = useQueryClient(); const scrollRef = useRef(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 ( ); } 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 ( {/* ── Header ── */} router.back()} style={{ marginBottom: 12 }} activeOpacity={0.7}> ← Back {ticket?.subject} {/* Type */} {ticket?.type} {/* Status — tappable to change */} setShowStatusPicker(true)} style={{ borderRadius: 20, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: statusStyle.bg }} activeOpacity={0.7} > {currentStatus.replace('_', ' ')} ▾ {/* Priority — only show HIGH */} {ticket?.priority === 'HIGH' && ( HIGH )} {ticket?.client && ( {ticket.client.firstName} {ticket.client.lastName} · {ticket.client.accountNumber} {ticket.assignedTo ? ` · Assigned: ${ticket.assignedTo.firstName} ${ticket.assignedTo.lastName}` : ' · Unassigned'} )} {/* ── Tabs ── */} {[ { key: 'details', label: 'Details' }, { key: 'comments', label: `Comments${messages.length > 0 ? ` (${messages.length})` : ''}` }, ].map(tab => ( 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} > {tab.label} ))} {/* ── DETAILS TAB ── */} {activeTab === 'details' && ( {/* Info card */} {/* Description */} {ticket?.description ? ( Description {ticket.description} ) : null} {/* ── ACTIVATION TICKET BANNER ── */} {isActivationTicket && !isDone && ( {blockResolve ? ( <> ⚠️ First Invoice Not Yet Paid This is a PREPAID account. The first month's invoice must be settled before this account can be activated. {'\n\n'}Go to the Collect screen to record the payment, then come back here to resolve this ticket. {firstInvoice && ( Invoice #{firstInvoice.invoiceNumber} Balance: ₱{Number(firstInvoice.balance ?? firstInvoice.total ?? 0).toLocaleString()} Status: {firstInvoice.status} )} ) : ( <> ✓ Ready to Activate {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.'} )} )} {isActivationTicket && isDone && ( ✅ Account Activated Subscription is now ACTIVE. )} {/* ── INSTALLATION SECTION ── */} {isInstallation && ( <> Installation {isDone ? ( /* ─ Already confirmed ─ */ Installation Complete {ticket?.resolvedAt && ( Confirmed on {formatDate(ticket.resolvedAt)} )} setActiveTab('comments')} style={{ marginTop: 12 }} activeOpacity={0.7} > View activity log → ) : ( /* ─ Confirm installation form ─ */ Confirm Installation {/* ── GPS Coordinates (required) ── */} 📍 Installation Location * {coords ? ( ✓ Location Captured {coords.lat.toFixed(6)}, {coords.lng.toFixed(6)} {locLoading ? '...' : 'Retake'} ) : ( {locLoading ? <>Getting GPS... : <>📍Capture Current Location } )} {/* ── Notes ── */} Notes / Remarks { 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 ? : ✓ Mark Installation Complete } )} )} )} {/* ── COMMENTS TAB ── */} {activeTab === 'comments' && ( {messages.length === 0 ? ( No comments yet Add a note or update below ) : ( 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 ( {m.body} {m.createdAt && ( {formatDate(m.createdAt)} )} ); } // User messages — chat bubbles return ( {!isMe && ( {m.senderName ?? m.sender?.firstName ?? 'Staff'} )} {m.body} {m.createdAt && ( {formatDate(m.createdAt)} )} ); }) )} {/* ── Comment input — ALWAYS visible ── */} {sendingComment ? : Send } )} {/* ── Status Picker Modal ── */} setShowStatusPicker(false)}> setShowStatusPicker(false)} > Update Status Current: {currentStatus.replace('_', ' ')} {STATUS_FLOW.map(s => { const style = STATUS_STYLE[s]; const isActive = s === currentStatus; return ( { 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} > {s.replace('_', ' ')} {isActive && ✓ Current} {updateStatus.isPending && !isActive && } ); })} setShowStatusPicker(false)} style={{ paddingVertical: 14, alignItems: 'center' }}> Cancel ); }