feat: new client onboarding wizard (3-step); fix ticket status refresh after update
This commit is contained in:
@@ -35,9 +35,18 @@ export default function ClientsScreen() {
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||
<View style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||
{/* Header */}
|
||||
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 20 }}>
|
||||
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Clients</Text>
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 15, marginTop: 2 }}>{data?.length ?? 0} subscribers</Text>
|
||||
<View style={{ backgroundColor: '#0891B2', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 20, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||
<View>
|
||||
<Text style={{ color: '#FFF', fontSize: 28, fontWeight: '800' }}>Clients</Text>
|
||||
<Text style={{ color: '#A5F3FC', fontSize: 15, marginTop: 2 }}>{data?.length ?? 0} subscribers</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/(app)/clients/new')}
|
||||
style={{ backgroundColor: '#fff', borderRadius: 20, paddingHorizontal: 14, paddingVertical: 8, flexDirection: 'row', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<Text style={{ fontSize: 18, color: '#0891B2', fontWeight: '800', lineHeight: 20 }}>+</Text>
|
||||
<Text style={{ fontSize: 14, fontWeight: '700', color: '#0891B2' }}>New Client</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Search */}
|
||||
|
||||
389
app/(app)/clients/new.tsx
Normal file
389
app/(app)/clients/new.tsx
Normal file
@@ -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 (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, paddingVertical: 16 }}>
|
||||
{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 (
|
||||
<View key={i} style={{ flex: 1, alignItems: 'center' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', width: '100%' }}>
|
||||
{i > 0 && (
|
||||
<View style={{ flex: 1, height: 2, backgroundColor: done ? '#0891B2' : '#E2E8F0', marginRight: 4 }} />
|
||||
)}
|
||||
<View style={{
|
||||
width: 28, height: 28, borderRadius: 14,
|
||||
backgroundColor: done || active ? '#0891B2' : '#F1F5F9',
|
||||
borderWidth: 2, borderColor: circleColor,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{done
|
||||
? <Text style={{ color: '#fff', fontSize: 14, fontWeight: '700' }}>✓</Text>
|
||||
: <Text style={{ color: active ? '#fff' : '#94A3B8', fontSize: 13, fontWeight: '700' }}>{i + 1}</Text>
|
||||
}
|
||||
</View>
|
||||
{i < STEP_LABELS.length - 1 && (
|
||||
<View style={{ flex: 1, height: 2, backgroundColor: done ? '#0891B2' : '#E2E8F0', marginLeft: 4 }} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ fontSize: 11, fontWeight: '600', color: textColor, marginTop: 4 }}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Field ──────────────────────────────────────────────────────────────────────
|
||||
function Field({
|
||||
label, value, onChangeText, placeholder, required = false,
|
||||
keyboardType = 'default', autoCapitalize = 'words',
|
||||
}: any) {
|
||||
return (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '600', color: '#64748B', marginBottom: 6 }}>
|
||||
{label}{required && <Text style={{ color: '#DC2626' }}> *</Text>}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#94A3B8"
|
||||
keyboardType={keyboardType}
|
||||
autoCapitalize={autoCapitalize}
|
||||
style={{
|
||||
borderWidth: 1, borderColor: '#E2E8F0', borderRadius: 10,
|
||||
paddingHorizontal: 14, paddingVertical: 13,
|
||||
fontSize: 16, color: '#1E293B', backgroundColor: '#fff',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#F8FAFC' }}>
|
||||
{/* Header */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' }}>
|
||||
<TouchableOpacity onPress={back} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} style={{ marginRight: 12 }}>
|
||||
<Icon name="arrow-left" size={22} color="#0891B2" />
|
||||
</TouchableOpacity>
|
||||
<Text style={{ fontSize: 18, fontWeight: '700', color: '#1E293B', flex: 1 }}>New Client Onboarding</Text>
|
||||
</View>
|
||||
|
||||
<StepBar step={step} />
|
||||
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
|
||||
|
||||
{/* ── Step 1: Client Info ─────────────────────────────────────────── */}
|
||||
{step === 0 && (
|
||||
<View>
|
||||
<Text style={{ fontSize: 20, fontWeight: '700', color: '#1E293B', marginBottom: 4 }}>Client Information</Text>
|
||||
<Text style={{ fontSize: 15, color: '#64748B', marginBottom: 20 }}>Fill in the new subscriber's details.</Text>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Field label="First Name" value={firstName} onChangeText={setFirstName} placeholder="Juan" required />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Field label="Last Name" value={lastName} onChangeText={setLastName} placeholder="Dela Cruz" required />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Field
|
||||
label="Contact Number" value={phone} onChangeText={setPhone}
|
||||
placeholder="09XXXXXXXXX" required keyboardType="phone-pad" autoCapitalize="none"
|
||||
/>
|
||||
<Field
|
||||
label="Email Address" value={email} onChangeText={setEmail}
|
||||
placeholder="optional" keyboardType="email-address" autoCapitalize="none"
|
||||
/>
|
||||
<Field
|
||||
label="Address" value={address} onChangeText={setAddress}
|
||||
placeholder="House / Barangay / Street (optional)" autoCapitalize="sentences"
|
||||
/>
|
||||
|
||||
{/* Area picker */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '600', color: '#64748B', marginBottom: 8 }}>Area / Zone</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||
{(areas as any[]).map((a: any) => (
|
||||
<TouchableOpacity
|
||||
key={a.id}
|
||||
onPress={() => 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',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 14, fontWeight: '600', color: areaId === a.id ? '#fff' : '#475569' }}>
|
||||
{a.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Plan Selection ──────────────────────────────────────── */}
|
||||
{step === 1 && (
|
||||
<View>
|
||||
<Text style={{ fontSize: 20, fontWeight: '700', color: '#1E293B', marginBottom: 4 }}>Select a Plan</Text>
|
||||
<Text style={{ fontSize: 15, color: '#64748B', marginBottom: 20 }}>Choose the internet plan for this subscriber.</Text>
|
||||
|
||||
{plansLoading
|
||||
? <ActivityIndicator color="#0891B2" />
|
||||
: (plans as any[]).filter((p: any) => p.isActive !== false).map((p: any) => {
|
||||
const selected = planId === p.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={p.id}
|
||||
onPress={() => setPlanId(p.id)}
|
||||
style={{
|
||||
backgroundColor: selected ? '#ECFEFF' : '#fff',
|
||||
borderWidth: 2, borderColor: selected ? '#0891B2' : '#E2E8F0',
|
||||
borderRadius: 14, padding: 18, marginBottom: 12,
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 17, fontWeight: '700', color: '#1E293B' }}>{p.name}</Text>
|
||||
<Text style={{ fontSize: 14, color: '#64748B', marginTop: 2 }}>
|
||||
↓ {p.speedDownMbps} Mbps ↑ {p.speedUpMbps} Mbps
|
||||
</Text>
|
||||
{p.description ? (
|
||||
<Text style={{ fontSize: 13, color: '#94A3B8', marginTop: 2 }}>{p.description}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end', marginLeft: 12 }}>
|
||||
<Text style={{ fontSize: 20, fontWeight: '800', color: '#0891B2' }}>
|
||||
₱{Number(p.monthlyPrice).toLocaleString()}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: '#94A3B8' }}>/month</Text>
|
||||
</View>
|
||||
{selected && (
|
||||
<View style={{
|
||||
position: 'absolute', top: 10, right: 10,
|
||||
width: 22, height: 22, borderRadius: 11,
|
||||
backgroundColor: '#0891B2', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Text style={{ color: '#fff', fontSize: 13, fontWeight: '700' }}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})
|
||||
}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Confirm ─────────────────────────────────────────────── */}
|
||||
{step === 2 && (
|
||||
<View>
|
||||
<Text style={{ fontSize: 20, fontWeight: '700', color: '#1E293B', marginBottom: 4 }}>Confirm & Submit</Text>
|
||||
<Text style={{ fontSize: 15, color: '#64748B', marginBottom: 20 }}>Review the details before creating the account.</Text>
|
||||
|
||||
{/* Client card */}
|
||||
<View style={{ backgroundColor: '#fff', borderRadius: 14, padding: 18, marginBottom: 12, borderWidth: 1, borderColor: '#E2E8F0' }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '700', color: '#94A3B8', marginBottom: 12, letterSpacing: 0.5 }}>CLIENT</Text>
|
||||
<Row label="Name" value={`${firstName} ${lastName}`} />
|
||||
<Row label="Phone" value={phone} />
|
||||
{email ? <Row label="Email" value={email} /> : null}
|
||||
{address ? <Row label="Address" value={address} /> : null}
|
||||
{areaId ? <Row label="Area" value={(areas as any[]).find((a:any) => a.id === areaId)?.name ?? ''} /> : null}
|
||||
</View>
|
||||
|
||||
{/* Plan card */}
|
||||
<View style={{ backgroundColor: '#fff', borderRadius: 14, padding: 18, marginBottom: 12, borderWidth: 1, borderColor: '#E2E8F0' }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: '700', color: '#94A3B8', marginBottom: 12, letterSpacing: 0.5 }}>PLAN</Text>
|
||||
<Row label="Plan" value={selectedPlan?.name ?? ''} />
|
||||
<Row label="Speed" value={`↓ ${selectedPlan?.speedDownMbps} Mbps ↑ ${selectedPlan?.speedUpMbps} Mbps`} />
|
||||
<Row label="Price" value={`₱${Number(selectedPlan?.monthlyPrice ?? 0).toLocaleString()}/month`} isLast />
|
||||
</View>
|
||||
|
||||
{/* What will happen */}
|
||||
<View style={{ backgroundColor: '#ECFEFF', borderRadius: 12, padding: 16, borderWidth: 1, borderColor: '#A5F3FC' }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: '700', color: '#0E7490', marginBottom: 8 }}>What happens next</Text>
|
||||
{['Client record will be created', 'Subscription set to Pending (activates after install)', 'Installation ticket created automatically'].map((s, i) => (
|
||||
<View key={i} style={{ flexDirection: 'row', alignItems: 'flex-start', marginBottom: 4 }}>
|
||||
<Text style={{ fontSize: 14, color: '#0891B2', marginRight: 8, lineHeight: 20 }}>•</Text>
|
||||
<Text style={{ fontSize: 14, color: '#0E7490', flex: 1, lineHeight: 20 }}>{s}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<View style={{ padding: 20, backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#F1F5F9', flexDirection: 'row', gap: 12 }}>
|
||||
{step > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={back}
|
||||
style={{ flex: 1, paddingVertical: 16, borderRadius: 12, borderWidth: 1.5, borderColor: '#CBD5E1', alignItems: 'center' }}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#64748B' }}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{step < 2
|
||||
? (
|
||||
<TouchableOpacity
|
||||
onPress={next}
|
||||
style={{ flex: 2, paddingVertical: 16, borderRadius: 12, backgroundColor: '#0891B2', alignItems: 'center' }}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontWeight: '700', color: '#fff' }}>
|
||||
{step === 0 ? 'Next: Select Plan →' : 'Next: Review →'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={submit}
|
||||
disabled={submitting}
|
||||
style={{ flex: 2, paddingVertical: 16, borderRadius: 12, backgroundColor: submitting ? '#94A3B8' : '#059669', alignItems: 'center' }}
|
||||
>
|
||||
{submitting
|
||||
? <ActivityIndicator color="#fff" />
|
||||
: <Text style={{ fontSize: 16, fontWeight: '700', color: '#fff' }}>✓ Create & Schedule Install</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, isLast = false }: { label: string; value: string; isLast?: boolean }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: isLast ? 0 : 1, borderBottomColor: '#F1F5F9' }}>
|
||||
<Text style={{ fontSize: 14, color: '#64748B', fontWeight: '500' }}>{label}</Text>
|
||||
<Text style={{ fontSize: 14, color: '#1E293B', fontWeight: '600', maxWidth: '60%', textAlign: 'right' }}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user