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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user