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

@@ -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',