672 lines
34 KiB
TypeScript
672 lines
34 KiB
TypeScript
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: TaskStatus) => {
|
||
await api.patch(`/api/v1/tickets/${id}`, { status });
|
||
// If this is an activation ticket being RESOLVED → activate subscription
|
||
if ((status === 'RESOLVED' || status === 'CLOSED') && isActivationTicket) {
|
||
const sub = clientDetail?.subscriptions?.[0];
|
||
if (sub?.id && sub?.status !== 'ACTIVE') {
|
||
await api.patch(`/api/v1/subscriptions/${sub.id}`, { 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(() => {});
|
||
},
|
||
onSuccess: async () => {
|
||
setShowStatusPicker(false);
|
||
await qc.invalidateQueries({ queryKey: ['task', id] });
|
||
await qc.invalidateQueries({ queryKey: ['tasks'] });
|
||
await qc.invalidateQueries({ queryKey: ['clients'] });
|
||
await refetch();
|
||
await refetchClient().catch(() => {});
|
||
await refetchInvoices().catch(() => {});
|
||
},
|
||
onError: () => Alert.alert('Error', 'Could not update status.'),
|
||
});
|
||
|
||
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 () => {
|
||
if (!coords) {
|
||
Alert.alert('Location Required', 'Please capture the installation coordinates before confirming.', [
|
||
{ text: 'Cancel', style: 'cancel' },
|
||
{ text: 'Capture Now', onPress: captureLocation },
|
||
]);
|
||
return;
|
||
}
|
||
setInstConfirming(true);
|
||
try {
|
||
// 1. Resolve the ticket
|
||
await api.patch(`/api/v1/tickets/${id}`, { status: 'RESOLVED' });
|
||
|
||
// 2. Update client location with recorded coordinates
|
||
if (ticket?.clientId) {
|
||
await api.patch(`/api/v1/clients/${ticket.clientId}`, {
|
||
lat: coords.lat,
|
||
lng: coords.lng,
|
||
}).catch(() => {});
|
||
}
|
||
|
||
// 3. Log activity comment
|
||
const coordStr = `${coords.lat.toFixed(6)}, ${coords.lng.toFixed(6)}`;
|
||
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 + force refetch so the status shows RESOLVED immediately
|
||
await qc.invalidateQueries({ queryKey: ['tasks'] });
|
||
await qc.invalidateQueries({ queryKey: ['task', id] });
|
||
await refetch();
|
||
await qc.invalidateQueries({ queryKey: ['client', ticket?.clientId] });
|
||
await qc.invalidateQueries({ queryKey: ['client-tickets', ticket?.clientId] });
|
||
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: coords ? '#059669' : '#94A3B8',
|
||
borderRadius: 14, paddingVertical: 16, alignItems: 'center',
|
||
}}
|
||
onPress={() =>
|
||
Alert.alert(
|
||
'Confirm Installation',
|
||
`Mark this installation as complete?\n\nLocation: ${coords ? `${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` : 'Not captured'}\n\nThis will update the client's location and resolve the ticket.`,
|
||
[
|
||
{ text: 'Cancel', style: 'cancel' },
|
||
{ text: 'Confirm', onPress: confirmInstallation },
|
||
]
|
||
)
|
||
}
|
||
disabled={instConfirming || !coords}
|
||
activeOpacity={0.8}
|
||
>
|
||
{instConfirming
|
||
? <ActivityIndicator color="#FFF" />
|
||
: <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>
|
||
{coords ? '✓ Mark Installation Complete' : 'Capture Location First'}
|
||
</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(s);
|
||
}}
|
||
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>
|
||
);
|
||
}
|