feat: prepaid/postpaid onboarding; prepaid payment gate on activation ticket; slide-to-confirm on all payments; pay button on dashboard invoices

This commit is contained in:
Nemo
2026-03-24 12:44:55 +08:00
parent 8a3f7564a6
commit 705e375667
2 changed files with 165 additions and 11 deletions

View File

@@ -89,7 +89,8 @@ export default function NewClientScreen() {
const [areaId, setAreaId] = useState('');
// Step 2
const [planId, setPlanId] = useState('');
const [planId, setPlanId] = useState('');
const [subType, setSubType] = useState<'PREPAID' | 'POSTPAID'>('POSTPAID');
const { data: areas = [] } = useQuery({
queryKey: ['areas'],
@@ -142,12 +143,12 @@ export default function NewClientScreen() {
// 2. Create subscription (PENDING until installation confirmed)
await api.post('/api/v1/subscriptions', {
clientId: client.id,
clientId: client.id,
planId,
type: 'POSTPAID',
status: 'PENDING',
billingDay: 5,
startDate: new Date().toISOString(),
type: subType,
status: 'PENDING',
billingDay: 5,
startDate: new Date().toISOString(),
});
// 3. Create installation ticket
@@ -156,6 +157,8 @@ export default function NewClientScreen() {
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;
@@ -252,7 +255,27 @@ export default function NewClientScreen() {
{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>
<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" />
@@ -319,11 +342,19 @@ export default function NewClientScreen() {
{/* 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 />
<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>

View File

@@ -9,6 +9,7 @@ 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;
@@ -58,11 +59,30 @@ export default function TicketDetailScreen() {
const [comment, setComment] = useState('');
const [sendingComment, setSendingComment] = useState(false);
// Prepaid activation payment state
const [prepaidPayAmount, setPrepaidPayAmount] = useState('');
const [prepaidPayMethod, setPrepaidPayMethod] = useState('CASH');
const [prepaidPaying, setPrepaidPaying] = useState(false);
const { data: ticket, isLoading, refetch } = useQuery({
queryKey: ['task', id],
queryFn: () => api.get(`/api/v1/tickets/${id}`).then(r => r.data),
});
// Fetch client with subscriptions when ticket loads (for prepaid gate)
const { data: clientDetail } = useQuery({
queryKey: ['task-client', ticket?.clientId],
queryFn: () => api.get(`/api/v1/clients/${ticket.clientId}`).then(r => r.data),
enabled: !!ticket?.clientId && ticket?.type === 'BILLING',
});
// Fetch existing payments for this client (to check if first payment done)
const { data: clientPayments, refetch: refetchPayments } = useQuery({
queryKey: ['task-client-payments', ticket?.clientId],
queryFn: () => api.get(`/api/v1/payments?clientId=${ticket.clientId}&limit=5`).then(r => r.data?.data ?? r.data ?? []),
enabled: !!ticket?.clientId && ticket?.type === 'BILLING',
});
const updateStatus = useMutation({
mutationFn: async (status: TaskStatus) => {
await api.patch(`/api/v1/tickets/${id}`, { status });
@@ -190,6 +210,52 @@ export default function TicketDetailScreen() {
const typeBg = TYPE_BG[ticket?.type] ?? '#F1F5F9';
const messages: any[] = ticket?.messages ?? [];
// Prepaid activation gate
const sub = clientDetail?.subscriptions?.[0];
const isPrepaidActivation =
ticket?.type === 'BILLING' &&
ticket?.subject?.includes('Activation') &&
sub?.type === 'PREPAID' &&
sub?.status === 'PENDING';
const hasFirstPayment = Array.isArray(clientPayments) && clientPayments.length > 0;
const planPrice = Number(sub?.monthlyPrice ?? 0);
const submitPrepaidPayment = async () => {
const amt = parseFloat(prepaidPayAmount);
if (!amt || amt <= 0) { Alert.alert('Invalid Amount', 'Enter a valid amount.'); return; }
setPrepaidPaying(true);
try {
// 1. Record payment
await api.post('/api/v1/payments', {
clientId: ticket.clientId,
amount: amt,
channel: prepaidPayMethod,
paymentDate: new Date().toISOString(),
notes: 'First prepaid payment — account activation',
});
// 2. Activate subscription
if (sub?.id) {
await api.patch(`/api/v1/subscriptions/${sub.id}`, { status: 'ACTIVE' }).catch(() => {});
}
// 3. Log comment + resolve ticket
await api.post(`/api/v1/tickets/${id}/messages`, {
body: `First payment of ₱${amt.toLocaleString()} recorded via ${prepaidPayMethod}. Account activated.`,
}).catch(() => {});
await api.patch(`/api/v1/tickets/${id}`, { status: 'RESOLVED' });
await refetch();
await refetchPayments();
qc.invalidateQueries({ queryKey: ['tasks'] });
qc.invalidateQueries({ queryKey: ['clients'] });
Alert.alert('Account Activated! ✓', `Payment of ₱${amt.toLocaleString()} recorded and account is now ACTIVE.`);
} catch (e: any) {
const msg = e?.response?.data?.message ?? 'Could not process payment.';
Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg);
} finally {
setPrepaidPaying(false);
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
@@ -282,6 +348,55 @@ export default function TicketDetailScreen() {
</View>
) : null}
{/* ── PREPAID ACTIVATION GATE ── */}
{isPrepaidActivation && (
<View style={{ backgroundColor: '#FFFBEB', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1.5, borderColor: '#FDE68A' }}>
<Text style={{ fontSize: 16, fontWeight: '800', color: '#92400E', marginBottom: 4 }}>
{hasFirstPayment ? '✓ Payment Received — Ready to Activate' : '⚠️ Prepaid — Payment Required'}
</Text>
<Text style={{ fontSize: 14, color: '#92400E', marginBottom: 16 }}>
{hasFirstPayment
? 'First payment has been recorded. Account is now active.'
: `Collect ₱${planPrice.toLocaleString()} first payment before activating this account.`}
</Text>
{!hasFirstPayment && !isDone && (
<>
{/* Amount */}
<Text style={{ fontSize: 13, fontWeight: '600', color: '#92400E', marginBottom: 6 }}>Amount</Text>
<TextInput
value={prepaidPayAmount || String(planPrice)}
onChangeText={setPrepaidPayAmount}
keyboardType="decimal-pad"
style={{ borderWidth: 1.5, borderColor: '#FDE68A', borderRadius: 10, paddingHorizontal: 14, paddingVertical: 12, fontSize: 20, fontWeight: '700', color: '#92400E', marginBottom: 12, backgroundColor: '#fff' }}
placeholder={String(planPrice)}
/>
{/* Method */}
<Text style={{ fontSize: 13, fontWeight: '600', color: '#92400E', marginBottom: 8 }}>Payment Method</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
{[['CASH','Cash'],['GCASH','GCash'],['MAYA','Maya'],['BANK_TRANSFER','Bank']].map(([id, label]) => (
<TouchableOpacity key={id} onPress={() => setPrepaidPayMethod(id)}
style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5,
borderColor: prepaidPayMethod === id ? '#D97706' : '#FDE68A',
backgroundColor: prepaidPayMethod === id ? '#FEF3C7' : '#fff' }}
>
<Text style={{ fontSize: 13, fontWeight: '700', color: prepaidPayMethod === id ? '#92400E' : '#B45309' }}>{label}</Text>
</TouchableOpacity>
))}
</View>
<SlideToConfirm
label={`Slide to collect ₱${parseFloat(prepaidPayAmount || String(planPrice)).toLocaleString()} & activate`}
color="#D97706"
onConfirm={submitPrepaidPayment}
disabled={prepaidPaying}
/>
</>
)}
</View>
)}
{/* ── INSTALLATION SECTION ── */}
{isInstallation && (
<>
@@ -537,7 +652,15 @@ export default function TicketDetailScreen() {
return (
<TouchableOpacity
key={s}
onPress={() => !isActive && updateStatus.mutate(s)}
onPress={() => {
if (isActive) return;
if (isPrepaidActivation && !hasFirstPayment && (s === 'RESOLVED' || s === 'CLOSED')) {
setShowStatusPicker(false);
Alert.alert('Payment Required', 'This is a PREPAID account. Please collect and record the first payment before resolving this ticket.');
return;
}
updateStatus.mutate(s);
}}
disabled={isActive || updateStatus.isPending}
style={{
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',