From 58468fb98609c0bf26979888e684abb74de41b65 Mon Sep 17 00:00:00 2001 From: Nemo Date: Tue, 24 Mar 2026 12:36:19 +0800 Subject: [PATCH] feat: new client onboarding wizard (3-step); fix ticket status refresh after update --- app/(app)/clients/index.tsx | 15 +- app/(app)/clients/new.tsx | 389 ++++++++++++++++++++++++++++++++++++ app/(app)/tasks/[id].tsx | 11 +- 3 files changed, 407 insertions(+), 8 deletions(-) create mode 100644 app/(app)/clients/new.tsx diff --git a/app/(app)/clients/index.tsx b/app/(app)/clients/index.tsx index 71dfb31..811812e 100644 --- a/app/(app)/clients/index.tsx +++ b/app/(app)/clients/index.tsx @@ -35,9 +35,18 @@ export default function ClientsScreen() { {/* Header */} - - Clients - {data?.length ?? 0} subscribers + + + Clients + {data?.length ?? 0} subscribers + + router.push('/(app)/clients/new')} + style={{ backgroundColor: '#fff', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 8, flexDirection: 'row', alignItems: 'center', gap: 6 }} + > + + + New Client + {/* Search */} diff --git a/app/(app)/clients/new.tsx b/app/(app)/clients/new.tsx new file mode 100644 index 0000000..835acf5 --- /dev/null +++ b/app/(app)/clients/new.tsx @@ -0,0 +1,389 @@ +import { useState } from 'react'; +import { + View, Text, TextInput, TouchableOpacity, ScrollView, + ActivityIndicator, Alert, KeyboardAvoidingView, Platform +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../../services/api'; +import { Icon } from '../../../components/Icon'; + +const STEP_LABELS = ['Client Info', 'Plan', 'Confirm']; + +// ── Step indicator ───────────────────────────────────────────────────────────── +function StepBar({ step }: { step: number }) { + return ( + + {STEP_LABELS.map((label, i) => { + const active = i === step; + const done = i < step; + const circleColor = done || active ? '#0891B2' : '#CBD5E1'; + const textColor = done || active ? '#0891B2' : '#94A3B8'; + return ( + + + {i > 0 && ( + + )} + + {done + ? + : {i + 1} + } + + {i < STEP_LABELS.length - 1 && ( + + )} + + {label} + + ); + })} + + ); +} + +// ── Field ────────────────────────────────────────────────────────────────────── +function Field({ + label, value, onChangeText, placeholder, required = false, + keyboardType = 'default', autoCapitalize = 'words', +}: any) { + return ( + + + {label}{required && *} + + + + ); +} + +export default function NewClientScreen() { + const [step, setStep] = useState(0); + const [submitting, setSubmitting] = useState(false); + + // Step 1 fields + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [phone, setPhone] = useState(''); + const [email, setEmail] = useState(''); + const [address, setAddress] = useState(''); + const [areaId, setAreaId] = useState(''); + + // Step 2 + const [planId, setPlanId] = useState(''); + + const { data: areas = [] } = useQuery({ + queryKey: ['areas'], + queryFn: () => api.get('/api/v1/areas').then(r => r.data?.data ?? r.data ?? []), + }); + + const { data: plans = [], isLoading: plansLoading } = useQuery({ + queryKey: ['plans'], + queryFn: () => api.get('/api/v1/plans').then(r => r.data?.data ?? r.data ?? []), + }); + + const selectedPlan = (plans as any[]).find((p: any) => p.id === planId); + + // ── Validation ───────────────────────────────────────────────────────────── + const validateStep1 = () => { + if (!firstName.trim()) { Alert.alert('Required', 'First name is required.'); return false; } + if (!lastName.trim()) { Alert.alert('Required', 'Last name is required.'); return false; } + if (!phone.trim()) { Alert.alert('Required', 'Contact number is required.'); return false; } + return true; + }; + const validateStep2 = () => { + if (!planId) { Alert.alert('Required', 'Please select a plan.'); return false; } + return true; + }; + + const next = () => { + if (step === 0 && !validateStep1()) return; + if (step === 1 && !validateStep2()) return; + setStep(s => s + 1); + }; + const back = () => { + if (step === 0) router.back(); + else setStep(s => s - 1); + }; + + // ── Submit ───────────────────────────────────────────────────────────────── + const submit = async () => { + setSubmitting(true); + try { + // 1. Create client + const clientRes = await api.post('/api/v1/clients', { + firstName: firstName.trim(), + lastName: lastName.trim(), + phone: phone.trim(), + ...(email.trim() ? { email: email.trim() } : {}), + ...(address.trim() ? { address: address.trim() } : {}), + ...(areaId ? { areaId } : {}), + }); + const client = clientRes.data; + + // 2. Create subscription (PENDING until installation confirmed) + await api.post('/api/v1/subscriptions', { + clientId: client.id, + planId, + type: 'POSTPAID', + status: 'PENDING', + billingDay: 5, + startDate: new Date().toISOString(), + }); + + // 3. Create installation ticket + const ticketRes = await api.post('/api/v1/tickets', { + clientId: client.id, + subject: `New Installation — ${firstName.trim()} ${lastName.trim()}`, + type: 'INSTALLATION', + priority: 'NORMAL', + }); + const ticket = ticketRes.data; + + Alert.alert( + 'Client Onboarded! 🎉', + `${firstName} ${lastName} has been registered.\n\nAn installation ticket has been created.`, + [{ + text: 'View Ticket', + onPress: () => { + router.replace('/(app)/clients'); + setTimeout(() => router.push(`/(app)/tasks/${ticket.id}`), 300); + }, + }, { + text: 'Done', + onPress: () => router.replace('/(app)/clients'), + }] + ); + } catch (e: any) { + const msg = e?.response?.data?.message ?? 'Something went wrong. Please try again.'; + Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg); + } finally { + setSubmitting(false); + } + }; + + return ( + + {/* Header */} + + + + + New Client Onboarding + + + + + + + + {/* ── Step 1: Client Info ─────────────────────────────────────────── */} + {step === 0 && ( + + Client Information + Fill in the new subscriber's details. + + + + + + + + + + + + + + + {/* Area picker */} + + Area / Zone + + {(areas as any[]).map((a: any) => ( + setAreaId(areaId === a.id ? '' : a.id)} + style={{ + paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, + backgroundColor: areaId === a.id ? '#0891B2' : '#F1F5F9', + borderWidth: 1, borderColor: areaId === a.id ? '#0891B2' : '#E2E8F0', + }} + > + + {a.name} + + + ))} + + + + )} + + {/* ── Step 2: Plan Selection ──────────────────────────────────────── */} + {step === 1 && ( + + Select a Plan + Choose the internet plan for this subscriber. + + {plansLoading + ? + : (plans as any[]).filter((p: any) => p.isActive !== false).map((p: any) => { + const selected = planId === p.id; + return ( + setPlanId(p.id)} + style={{ + backgroundColor: selected ? '#ECFEFF' : '#fff', + borderWidth: 2, borderColor: selected ? '#0891B2' : '#E2E8F0', + borderRadius: 14, padding: 18, marginBottom: 12, + flexDirection: 'row', alignItems: 'center', + }} + > + + {p.name} + + ↓ {p.speedDownMbps} Mbps ↑ {p.speedUpMbps} Mbps + + {p.description ? ( + {p.description} + ) : null} + + + + ₱{Number(p.monthlyPrice).toLocaleString()} + + /month + + {selected && ( + + + + )} + + ); + }) + } + + )} + + {/* ── Step 3: Confirm ─────────────────────────────────────────────── */} + {step === 2 && ( + + Confirm & Submit + Review the details before creating the account. + + {/* Client card */} + + CLIENT + + + {email ? : null} + {address ? : null} + {areaId ? a.id === areaId)?.name ?? ''} /> : null} + + + {/* Plan card */} + + PLAN + + + + + + {/* What will happen */} + + What happens next + {['Client record will be created', 'Subscription set to Pending (activates after install)', 'Installation ticket created automatically'].map((s, i) => ( + + + {s} + + ))} + + + )} + + + + + {/* Footer buttons */} + + {step > 0 && ( + + Back + + )} + {step < 2 + ? ( + + + {step === 0 ? 'Next: Select Plan →' : 'Next: Review →'} + + + ) : ( + + {submitting + ? + : ✓ Create & Schedule Install + } + + ) + } + + + ); +} + +function Row({ label, value, isLast = false }: { label: string; value: string; isLast?: boolean }) { + if (!value) return null; + return ( + + {label} + {value} + + ); +} diff --git a/app/(app)/tasks/[id].tsx b/app/(app)/tasks/[id].tsx index 1c0a3d1..49be9c1 100644 --- a/app/(app)/tasks/[id].tsx +++ b/app/(app)/tasks/[id].tsx @@ -72,11 +72,11 @@ export default function TicketDetailScreen() { body: `Status changed to ${status.replace('_', ' ')} by ${who}`, }).catch(() => {}); }, - onSuccess: () => { + onSuccess: async () => { setShowStatusPicker(false); - qc.invalidateQueries({ queryKey: ['task', id] }); - qc.invalidateQueries({ queryKey: ['tasks'] }); - refetch(); + await qc.invalidateQueries({ queryKey: ['task', id] }); + await qc.invalidateQueries({ queryKey: ['tasks'] }); + await refetch(); }, onError: () => Alert.alert('Error', 'Could not update status.'), }); @@ -137,9 +137,10 @@ export default function TicketDetailScreen() { setInstNotes(''); setCoords(null); - // Invalidate all caches then go back to list — avoids stale UI on current screen + // 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(