feat: major UI/UX overhaul + user management + ticket detail refactor

- 5-tab navigation (Home/Clients/Collect/Tickets/Profile)
- Inline styles throughout (17px min font, SafeAreaView)
- Dashboard fixed to match real API shape
- Ticket detail: 2 tabs (Details + Comments), always-visible comment input
- Installation confirmation: GPS coordinate capture + client location update
- User management screens (Admin only): list, create, detail + role/active toggle
- Tasks folder replaces tickets folder
- Remittance detail: inline styles
- Record payment: prefill from client, live button text
- Icon component with SVG icons
- Color system: primary #0891B2
This commit is contained in:
Nemo
2026-03-24 10:37:57 +08:00
parent baed6dc8d5
commit 4644a3194d
38 changed files with 4967 additions and 2984 deletions

548
app/(app)/tasks/[id].tsx Normal file
View File

@@ -0,0 +1,548 @@
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';
// ─── 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),
});
const updateStatus = useMutation({
mutationFn: async (status: TaskStatus) => {
await api.patch(`/api/v1/tickets/${id}`, { status });
// Log status change as a system comment
const who = user?.firstName ?? 'Staff';
await api.post(`/api/v1/tickets/${id}/messages`, {
message: `Status changed to ${status.replace('_', ' ')} by ${who}`,
}).catch(() => {});
},
onSuccess: () => {
setShowStatusPicker(false);
refetch();
qc.invalidateQueries({ queryKey: ['tasks'] });
},
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`, { message: note }).catch(() => {});
setInstNotes('');
setCoords(null);
Alert.alert('Installation Complete!', 'Ticket resolved and client location updated.');
refetch();
qc.invalidateQueries({ queryKey: ['tasks'] });
qc.invalidateQueries({ queryKey: ['client', ticket?.clientId] });
setActiveTab('comments');
} 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`, { message: 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 ?? [];
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}
{/* ── 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.message?.startsWith('Status changed') || m.message?.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.message}</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.message}
</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={() => !isActive && 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>
);
}

View File

@@ -0,0 +1,4 @@
import { Stack } from 'expo-router';
export default function TasksLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

142
app/(app)/tasks/index.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { useState } from 'react';
import { View, Text, FlatList, TextInput, TouchableOpacity, ActivityIndicator, RefreshControl, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery } from '@tanstack/react-query';
import { router } from 'expo-router';
import { api } from '../../../services/api';
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', NORMAL: '#0891B2', LOW: '#6B7280' };
const PRIORITY_BG: Record<string, string> = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF', LOW: '#F1F5F9' };
const STATUS_COLOR: Record<string, string> = { OPEN: '#0891B2', IN_PROGRESS: '#D97706', RESOLVED: '#16A34A', CLOSED: '#6B7280' };
const STATUS_BG: Record<string, string> = { OPEN: '#ECFEFF', IN_PROGRESS: '#FFFBEB', RESOLVED: '#F0FDF4', CLOSED: '#F1F5F9' };
const TYPE_COLOR: Record<string, string> = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' };
const TYPE_BG: Record<string, string> = { INSTALLATION: '#ECFEFF', SUPPORT: '#F5F3FF', BILLING: '#FFFBEB' };
const STATUS_FILTERS = ['All', 'OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
export default function TasksScreen() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('All');
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['tasks'],
queryFn: () => api.get('/api/v1/tickets?limit=100').then(r => r.data?.data ?? r.data ?? []),
});
const tasks = (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 (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 16, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<View>
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Tickets</Text>
<Text style={{ color: '#A5F3FC', fontSize: 14, marginTop: 2 }}>{tasks.length} showing</Text>
</View>
<TouchableOpacity
style={{ backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 10 }}
onPress={() => router.push('/(app)/tasks/new')}
activeOpacity={0.7}
>
<Text style={{ color: '#FFF', fontWeight: '700', fontSize: 15 }}>+ Ticket</Text>
</TouchableOpacity>
</View>
<View style={{ backgroundColor: '#FFF', paddingHorizontal: 16, paddingTop: 12, paddingBottom: 8, borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
<View style={{ backgroundColor: '#F8FAFC', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14 }}>
<TextInput
style={{ flex: 1, paddingVertical: 13, fontSize: 16, color: '#0F172A' }}
placeholder="Search tasks..."
placeholderTextColor="#94A3B8"
value={search}
onChangeText={setSearch}
/>
{search.length > 0 && (
<TouchableOpacity onPress={() => setSearch('')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: '#FFF', fontSize: 13, fontWeight: '800', lineHeight: 16 }}>×</Text>
</View>
</TouchableOpacity>
)}
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginTop: 10, marginBottom: 4 }}>
{STATUS_FILTERS.map(f => {
const isActive = statusFilter === f;
const color = f === 'All' ? '#0891B2' : STATUS_COLOR[f] ?? '#6B7280';
return (
<TouchableOpacity
key={f}
onPress={() => setStatusFilter(f)}
style={{ borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, marginRight: 8, backgroundColor: isActive ? color : '#F1F5F9' }}
activeOpacity={0.7}
>
<Text style={{ fontSize: 14, fontWeight: '700', color: isActive ? '#FFF' : '#64748B' }}>
{f === 'All' ? 'All' : f.replace('_', ' ')}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
{isLoading ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color="#0891B2" size="large" />
</View>
) : (
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#0891B2" />}
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
renderItem={({ item }) => {
const typeColor = TYPE_COLOR[item.type] ?? '#6B7280';
const typeBg = TYPE_BG[item.type] ?? '#F1F5F9';
return (
<TouchableOpacity
style={{ backgroundColor: '#FFF', borderRadius: 16, padding: 18, marginBottom: 12, borderWidth: 1, borderColor: '#F1F5F9', borderLeftWidth: 4, borderLeftColor: typeColor }}
onPress={() => router.push(`/(app)/tasks/${item.id}`)}
activeOpacity={0.7}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: typeBg }}>
<Text style={{ fontSize: 12, fontWeight: '700', color: typeColor }}>{item.type}</Text>
</View>
{item.priority === 'HIGH' && (
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: PRIORITY_BG.HIGH }}>
<Text style={{ fontSize: 12, fontWeight: '700', color: PRIORITY_COLOR.HIGH }}>HIGH</Text>
</View>
)}
</View>
<View style={{ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: STATUS_BG[item.status] ?? '#F1F5F9' }}>
<Text style={{ fontSize: 12, fontWeight: '700', color: STATUS_COLOR[item.status] ?? '#6B7280' }}>{item.status?.replace('_', ' ')}</Text>
</View>
</View>
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 6 }} numberOfLines={2}>{item.subject}</Text>
<Text style={{ fontSize: 14, color: '#64748B' }}>
{item.client?.firstName} {item.client?.lastName}
{item.assignedTo ? ` · ${item.assignedTo.firstName} ${item.assignedTo.lastName}` : ' · Unassigned'}
</Text>
{item._count?.messages > 0 && (
<Text style={{ fontSize: 13, color: '#94A3B8', marginTop: 4 }}>{item._count.messages} message{item._count.messages !== 1 ? 's' : ''}</Text>
)}
</TouchableOpacity>
);
}}
ListEmptyComponent={
<View style={{ alignItems: 'center', paddingVertical: 60 }}>
<Text style={{ fontSize: 17, color: '#94A3B8' }}>No tasks found</Text>
</View>
}
/>
)}
</View>
</SafeAreaView>
);
}

202
app/(app)/tasks/new.tsx Normal file
View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../../../services/api';
const PRIORITIES = ['NORMAL', 'HIGH'];
const PRIORITY_COLOR: Record<string, string> = { HIGH: '#DC2626', NORMAL: '#0891B2' };
const PRIORITY_BG: Record<string, string> = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' };
const TYPES = [
{ value: 'SUPPORT', label: 'Support' },
{ value: 'INSTALLATION', label: 'Installation' },
{ value: 'BILLING', label: 'Billing' },
];
export default function NewTaskScreen() {
const qc = useQueryClient();
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState('NORMAL');
const [type, setType] = useState('SUPPORT');
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', 'Please enter a subject.');
if (!client) return Alert.alert('Required', 'Please select a client.');
setLoading(true);
try {
await api.post('/api/v1/tickets', {
subject: subject.trim(),
description: description.trim() || undefined,
priority, type, clientId: client.id,
});
qc.invalidateQueries({ queryKey: ['tasks'] });
qc.invalidateQueries({ queryKey: ['dashboard'] });
Alert.alert('Task Created', subject, [{ text: 'OK', onPress: () => router.back() }]);
} catch (e: any) {
Alert.alert('Error', e?.response?.data?.message ?? 'Could not create task.');
} finally { setLoading(false); }
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 12, paddingBottom: 20 }}>
<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: 28, fontWeight: '800' }}>New Ticket</Text>
</View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
{/* Client Search */}
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
Client <Text style={{ color: '#DC2626' }}>*</Text>
</Text>
{client ? (
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#ECFEFF', borderRadius: 14, padding: 18, marginBottom: 20, borderWidth: 1, borderColor: '#A5F3FC' }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 17, fontWeight: '700', color: '#0E7490' }}>{client.firstName} {client.lastName}</Text>
<Text style={{ fontSize: 15, color: '#0891B2', marginTop: 2 }}>{client.accountNumber}</Text>
</View>
<TouchableOpacity onPress={() => { setClient(null); setSearch(''); setDebouncedSearch(''); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Text style={{ fontSize: 15, fontWeight: '700', color: '#0891B2' }}>Change</Text>
</TouchableOpacity>
</View>
) : (
<View style={{ marginBottom: 20 }}>
<View style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, marginBottom: 8 }}>
<TextInput
style={{ flex: 1, paddingVertical: 14, fontSize: 16, color: '#0F172A' }}
placeholder="Search by name or account #"
placeholderTextColor="#94A3B8"
value={search}
onChangeText={handleSearchChange}
autoCapitalize="none"
/>
{search.length > 0 && (
<TouchableOpacity onPress={() => { setSearch(''); setDebouncedSearch(''); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<View style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: '#CBD5E1', alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: '#FFF', fontSize: 13, fontWeight: '800', lineHeight: 16 }}>×</Text>
</View>
</TouchableOpacity>
)}
{searching && <ActivityIndicator size="small" color="#0891B2" style={{ marginLeft: 8 }} />}
</View>
{debouncedSearch.trim().length >= 2 && (
<View style={{ backgroundColor: '#FFF', borderRadius: 14, borderWidth: 1, borderColor: '#E2E8F0', overflow: 'hidden' }}>
{(searchResults ?? []).length === 0 && !searching ? (
<Text style={{ paddingHorizontal: 16, paddingVertical: 14, fontSize: 15, color: '#94A3B8' }}>No clients found</Text>
) : (
(searchResults ?? []).map((c: any, i: number, arr: any[]) => (
<TouchableOpacity
key={c.id}
style={{ paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: i < arr.length - 1 ? 1 : 0, borderBottomColor: '#F1F5F9' }}
onPress={() => { setClient(c); setSearch(''); setDebouncedSearch(''); }}
activeOpacity={0.7}
>
<Text style={{ fontSize: 16, fontWeight: '600', color: '#0F172A' }}>{c.firstName} {c.lastName}</Text>
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 2 }}>{c.accountNumber}</Text>
</TouchableOpacity>
))
)}
</View>
)}
</View>
)}
{/* Subject */}
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
Subject <Text style={{ color: '#DC2626' }}>*</Text>
</Text>
<TextInput
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 20 }}
placeholder="e.g. New installation - Barangay 5"
placeholderTextColor="#94A3B8"
value={subject}
onChangeText={setSubject}
/>
{/* Type */}
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Type</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginBottom: 20 }}>
{TYPES.map(t => (
<TouchableOpacity
key={t.value}
onPress={() => setType(t.value)}
style={{ borderRadius: 20, paddingHorizontal: 16, paddingVertical: 10, marginRight: 8, marginBottom: 8, backgroundColor: type === t.value ? '#0891B2' : '#FFF', borderWidth: 1.5, borderColor: type === t.value ? '#0891B2' : '#E2E8F0' }}
activeOpacity={0.7}
>
<Text style={{ fontSize: 14, fontWeight: '600', color: type === t.value ? '#FFF' : '#64748B' }}>{t.label}</Text>
</TouchableOpacity>
))}
</View>
{/* Priority */}
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>Priority</Text>
<View style={{ flexDirection: 'row', marginBottom: 20 }}>
{PRIORITIES.map(p => {
const isSelected = priority === p;
const isHigh = p === 'HIGH';
const selectedBg = isHigh ? '#DC2626' : '#0891B2';
const unselectedBg = isHigh ? '#FEF2F2' : '#F0F9FF';
const selectedText = '#FFF';
const unselectedText = isHigh ? '#DC2626' : '#0891B2';
const desc = isHigh ? 'Urgent, escalate' : 'Standard queue';
return (
<TouchableOpacity
key={p}
onPress={() => setPriority(p)}
style={{ flex: 1, borderRadius: 14, paddingVertical: 16, alignItems: 'center', marginHorizontal: 4, backgroundColor: isSelected ? selectedBg : unselectedBg, borderWidth: 1.5, borderColor: isSelected ? selectedBg : '#E2E8F0' }}
activeOpacity={0.7}
>
<Text style={{ fontSize: 16, fontWeight: isHigh ? '800' : '700', color: isSelected ? selectedText : unselectedText }}>{p}</Text>
<Text style={{ fontSize: 13, fontWeight: '500', color: isSelected ? 'rgba(255,255,255,0.8)' : '#94A3B8', marginTop: 3 }}>{desc}</Text>
</TouchableOpacity>
);
})}
</View>
{/* Description */}
<Text style={{ fontSize: 16, fontWeight: '700', color: '#0F172A', marginBottom: 10 }}>
Description <Text style={{ fontWeight: '400', color: '#94A3B8' }}>(optional)</Text>
</Text>
<TextInput
style={{ backgroundColor: '#FFF', borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 14, paddingHorizontal: 18, paddingVertical: 14, fontSize: 16, color: '#0F172A', marginBottom: 24, minHeight: 100, textAlignVertical: 'top' }}
placeholder="Describe the issue in detail..."
placeholderTextColor="#94A3B8"
value={description}
onChangeText={setDescription}
multiline
/>
<TouchableOpacity
style={{ backgroundColor: client && subject.trim() ? '#0891B2' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
onPress={submit}
disabled={loading || !client || !subject.trim()}
activeOpacity={0.8}
>
{loading ? <ActivityIndicator color="#FFF" /> : <Text style={{ color: '#FFF', fontSize: 17, fontWeight: '700' }}>Create Ticket</Text>}
</TouchableOpacity>
</ScrollView>
</View>
</SafeAreaView>
);
}