Files
fiberops-mobile/app/(app)/clients/new.tsx

421 lines
21 KiB
TypeScript

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 [subType, setSubType] = useState<'PREPAID' | 'POSTPAID'>('POSTPAID');
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: subType,
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',
// Pass subType so activation ticket knows to gate on payment for PREPAID
...(subType === 'PREPAID' ? { priority: 'HIGH' } : {}),
});
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 and billing type.</Text>
{/* Subscription type toggle */}
<View style={{ marginBottom: 20 }}>
<Text style={{ fontSize: 13, fontWeight: '600', color: '#64748B', marginBottom: 8 }}>Billing Type <Text style={{ color: '#DC2626' }}>*</Text></Text>
<View style={{ flexDirection: 'row', backgroundColor: '#F1F5F9', borderRadius: 12, padding: 4 }}>
{(['POSTPAID', 'PREPAID'] as const).map(t => (
<TouchableOpacity key={t} onPress={() => setSubType(t)} style={{ flex: 1, paddingVertical: 10, borderRadius: 10, alignItems: 'center', backgroundColor: subType === t ? '#fff' : 'transparent' }}>
<Text style={{ fontSize: 14, fontWeight: '700', color: subType === t ? '#0891B2' : '#94A3B8' }}>{t}</Text>
<Text style={{ fontSize: 11, color: subType === t ? '#64748B' : '#CBD5E1', marginTop: 1 }}>
{t === 'POSTPAID' ? 'Pay after billing day' : 'Pay before activation'}
</Text>
</TouchableOpacity>
))}
</View>
{subType === 'PREPAID' && (
<View style={{ backgroundColor: '#FFFBEB', borderRadius: 10, padding: 10, marginTop: 8, borderWidth: 1, borderColor: '#FDE68A' }}>
<Text style={{ fontSize: 13, color: '#92400E' }}>⚠️ Prepaid clients must settle their first payment before the account can be activated.</Text>
</View>
)}
</View>
{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`} />
<Row label="Billing" value={subType} isLast />
</View>
{subType === 'PREPAID' && (
<View style={{ backgroundColor: '#FFFBEB', borderRadius: 12, padding: 14, marginBottom: 12, borderWidth: 1, borderColor: '#FDE68A' }}>
<Text style={{ fontSize: 14, fontWeight: '700', color: '#92400E', marginBottom: 4 }}>⚠️ Prepaid — Payment Required Before Activation</Text>
<Text style={{ fontSize: 13, color: '#92400E' }}>After installation, a payment of ₱{Number(selectedPlan?.monthlyPrice ?? 0).toLocaleString()} must be collected before the account goes active.</Text>
</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>
);
}