fix: activation flow - generate invoice on install, block prepaid if unpaid, activate sub on resolve; sub type/status badges
This commit is contained in:
@@ -268,11 +268,33 @@ export default function ClientDetailScreen() {
|
||||
<Text style={{ fontSize: 15, color: '#94A3B8' }}>No active subscription</Text>
|
||||
</View>
|
||||
) : (
|
||||
{/* Plan type badge */}
|
||||
{sub.type && (
|
||||
<View style={{ flexDirection: 'row', marginBottom: 12, gap: 8 }}>
|
||||
<View style={{
|
||||
borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6,
|
||||
backgroundColor: sub.type === 'PREPAID' ? '#FEF3C7' : '#ECFEFF',
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 13, fontWeight: '800',
|
||||
color: sub.type === 'PREPAID' ? '#D97706' : '#0891B2',
|
||||
}}>{sub.type}</Text>
|
||||
</View>
|
||||
<View style={{
|
||||
borderRadius: 20, paddingHorizontal: 14, paddingVertical: 6,
|
||||
backgroundColor: sub.status === 'ACTIVE' ? '#DCFCE7' : sub.status === 'SUSPENDED' ? '#FEF3C7' : sub.status === 'PENDING' ? '#F0F9FF' : '#F1F5F9',
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 13, fontWeight: '700',
|
||||
color: sub.status === 'ACTIVE' ? '#166534' : sub.status === 'SUSPENDED' ? '#D97706' : sub.status === 'PENDING' ? '#0891B2' : '#6B7280',
|
||||
}}>{sub.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ backgroundColor: '#FFF', borderRadius: 20, borderWidth: 1, borderColor: '#F1F5F9', overflow: 'hidden' }}>
|
||||
<InfoRow label="Plan" value={sub.plan?.name} />
|
||||
<InfoRow label="Speed" value={sub.plan?.speed ? `${sub.plan.speed} Mbps` : null} />
|
||||
<InfoRow label="Monthly" value={sub.plan?.price ? `₱${Number(sub.plan.price).toLocaleString()}` : null} />
|
||||
<InfoRow label="Status" value={sub.status} />
|
||||
<InfoRow label="Speed" value={sub.plan?.speedDownMbps ? `${sub.plan.speedDownMbps} Mbps` : null} />
|
||||
<InfoRow label="Monthly" value={sub.monthlyPrice ? `₱${Number(sub.monthlyPrice).toLocaleString()}` : null} />
|
||||
<InfoRow label="Start Date" value={sub.startDate ? new Date(sub.startDate).toLocaleDateString('en-PH', { year: 'numeric', month: 'long', day: 'numeric' }) : null} />
|
||||
<InfoRow label="Billing Day" value={sub.billingDay ? `Day ${sub.billingDay}` : null} isLast />
|
||||
</View>
|
||||
|
||||
@@ -59,35 +59,40 @@ 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),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Fetch client with subscriptions when ticket loads (for prepaid gate)
|
||||
const { data: clientDetail } = useQuery({
|
||||
// For activation tickets — fetch client (subscription) + first invoice
|
||||
const isActivationTicket = ticket?.type === 'BILLING' && ticket?.subject?.includes('Activation');
|
||||
|
||||
const { data: clientDetail, refetch: refetchClient } = useQuery({
|
||||
queryKey: ['task-client', ticket?.clientId],
|
||||
queryFn: () => api.get(`/api/v1/clients/${ticket.clientId}`).then(r => r.data),
|
||||
enabled: !!ticket?.clientId && ticket?.type === 'BILLING',
|
||||
enabled: !!ticket?.clientId && isActivationTicket,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// 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',
|
||||
// Fetch invoices for this client (to check if first invoice is PAID)
|
||||
const { data: clientInvoices, refetch: refetchInvoices } = useQuery({
|
||||
queryKey: ['task-client-invoices', ticket?.clientId],
|
||||
queryFn: () => api.get(`/api/v1/invoices?clientId=${ticket.clientId}&limit=5`).then(r => r.data?.data ?? r.data ?? []),
|
||||
enabled: !!ticket?.clientId && isActivationTicket,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: async (status: TaskStatus) => {
|
||||
await api.patch(`/api/v1/tickets/${id}`, { status });
|
||||
// Log status change as a system comment
|
||||
// If this is an activation ticket being RESOLVED → activate subscription
|
||||
if ((status === 'RESOLVED' || status === 'CLOSED') && isActivationTicket) {
|
||||
const sub = clientDetail?.subscriptions?.[0];
|
||||
if (sub?.id && sub?.status !== 'ACTIVE') {
|
||||
await api.patch(`/api/v1/subscriptions/${sub.id}`, { status: 'ACTIVE' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
const who = user?.firstName ?? 'Staff';
|
||||
await api.post(`/api/v1/tickets/${id}/messages`, {
|
||||
body: `Status changed to ${status.replace('_', ' ')} by ${who}`,
|
||||
@@ -97,7 +102,10 @@ export default function TicketDetailScreen() {
|
||||
setShowStatusPicker(false);
|
||||
await qc.invalidateQueries({ queryKey: ['task', id] });
|
||||
await qc.invalidateQueries({ queryKey: ['tasks'] });
|
||||
await qc.invalidateQueries({ queryKey: ['clients'] });
|
||||
await refetch();
|
||||
await refetchClient().catch(() => {});
|
||||
await refetchInvoices().catch(() => {});
|
||||
},
|
||||
onError: () => Alert.alert('Error', 'Could not update status.'),
|
||||
});
|
||||
@@ -147,7 +155,12 @@ export default function TicketDetailScreen() {
|
||||
: `Installation confirmed. Location recorded: ${coordStr}`;
|
||||
await api.post(`/api/v1/tickets/${id}/messages`, { body: note }).catch(() => {});
|
||||
|
||||
// 4. Create follow-up activation ticket (non-fatal if fails for role reasons)
|
||||
// 4. Generate first invoice so it appears in Collect screen
|
||||
if (ticket?.clientId) {
|
||||
await api.post(`/api/v1/invoices/generate/${ticket.clientId}`).catch(() => {});
|
||||
}
|
||||
|
||||
// 5. Create follow-up activation ticket (non-fatal)
|
||||
await api.post('/api/v1/tickets', {
|
||||
clientId: ticket?.clientId,
|
||||
subject: `Account Activation — ${ticket?.client?.firstName ?? ''} ${ticket?.client?.lastName ?? ''}`.trim(),
|
||||
@@ -210,60 +223,13 @@ export default function TicketDetailScreen() {
|
||||
const typeBg = TYPE_BG[ticket?.type] ?? '#F1F5F9';
|
||||
const messages: any[] = ticket?.messages ?? [];
|
||||
|
||||
// Prepaid activation gate
|
||||
// Activation ticket gate — check first invoice paid
|
||||
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 effectiveAmount = prepaidPayAmount || String(planPrice);
|
||||
const amt = parseFloat(effectiveAmount);
|
||||
if (!amt || amt <= 0) { Alert.alert('Invalid Amount', 'Enter a valid amount.'); return; }
|
||||
setPrepaidPaying(true);
|
||||
try {
|
||||
// 1. Generate first invoice for client
|
||||
let invoiceId: string | undefined;
|
||||
try {
|
||||
const invRes = await api.post(`/api/v1/invoices/generate/${ticket.clientId}`);
|
||||
invoiceId = invRes.data?.id;
|
||||
} catch {}
|
||||
|
||||
// 2. Record payment (link to invoice if available)
|
||||
await api.post('/api/v1/payments', {
|
||||
clientId: ticket.clientId,
|
||||
amount: amt,
|
||||
channel: prepaidPayMethod,
|
||||
paymentDate: new Date().toISOString(),
|
||||
notes: 'First prepaid payment — account activation',
|
||||
...(invoiceId ? { invoiceId } : {}),
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
const isPrepaid = sub?.type === 'PREPAID';
|
||||
const invoiceList: any[] = Array.isArray(clientInvoices) ? clientInvoices : [];
|
||||
const firstInvoice = invoiceList[0] ?? null;
|
||||
const firstInvoicePaid = firstInvoice?.status === 'PAID' || firstInvoice?.balance === 0;
|
||||
const blockResolve = isActivationTicket && isPrepaid && !firstInvoicePaid;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#0891B2' }} edges={['top']}>
|
||||
@@ -357,55 +323,60 @@ 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 && (
|
||||
{/* ── ACTIVATION TICKET BANNER ── */}
|
||||
{isActivationTicket && !isDone && (
|
||||
<View style={{
|
||||
borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1.5,
|
||||
backgroundColor: blockResolve ? '#FFFBEB' : '#F0FDF4',
|
||||
borderColor: blockResolve ? '#FDE68A' : '#86EFAC',
|
||||
}}>
|
||||
{blockResolve ? (
|
||||
<>
|
||||
{/* Amount */}
|
||||
<Text style={{ fontSize: 13, fontWeight: '600', color: '#92400E', marginBottom: 6 }}>Amount</Text>
|
||||
<TextInput
|
||||
value={prepaidPayAmount}
|
||||
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={planPrice ? String(planPrice) : '0.00'}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
<Text style={{ fontSize: 16, fontWeight: '800', color: '#92400E', marginBottom: 6 }}>
|
||||
⚠️ First Invoice Not Yet Paid
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: '#92400E', lineHeight: 20 }}>
|
||||
This is a <Text style={{ fontWeight: '700' }}>PREPAID</Text> account. The first month's invoice must be settled before this account can be activated.
|
||||
{'\n\n'}Go to the <Text style={{ fontWeight: '700', color: '#D97706' }}>Collect</Text> screen to record the payment, then come back here to resolve this ticket.
|
||||
</Text>
|
||||
{firstInvoice && (
|
||||
<View style={{ marginTop: 12, backgroundColor: '#FEF3C7', borderRadius: 10, padding: 12 }}>
|
||||
<Text style={{ fontSize: 13, color: '#92400E', fontWeight: '600' }}>
|
||||
Invoice #{firstInvoice.invoiceNumber}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 15, color: '#92400E', fontWeight: '800', marginTop: 2 }}>
|
||||
Balance: ₱{Number(firstInvoice.balance ?? firstInvoice.total ?? 0).toLocaleString()}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: '#B45309', marginTop: 2 }}>
|
||||
Status: {firstInvoice.status}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SlideToConfirm
|
||||
label={`Slide to collect ₱${parseFloat(prepaidPayAmount || String(planPrice) || '0').toLocaleString()} & activate`}
|
||||
color="#D97706"
|
||||
onConfirm={submitPrepaidPayment}
|
||||
disabled={prepaidPaying}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ fontSize: 16, fontWeight: '800', color: '#166534', marginBottom: 4 }}>
|
||||
✓ Ready to Activate
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: '#166534' }}>
|
||||
{isPrepaid
|
||||
? 'First invoice has been paid. Tap "Update Status" → Resolved to activate this account.'
|
||||
: 'Postpaid account is ready to activate. Tap "Update Status" → Resolved to activate.'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isActivationTicket && isDone && (
|
||||
<View style={{ backgroundColor: '#F0FDF4', borderRadius: 16, padding: 18, marginBottom: 16, borderWidth: 1, borderColor: '#86EFAC' }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: '800', color: '#166534' }}>✅ Account Activated</Text>
|
||||
<Text style={{ fontSize: 14, color: '#16A34A', marginTop: 4 }}>
|
||||
Subscription is now ACTIVE.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── INSTALLATION SECTION ── */}
|
||||
{isInstallation && (
|
||||
<>
|
||||
@@ -663,9 +634,13 @@ export default function TicketDetailScreen() {
|
||||
key={s}
|
||||
onPress={() => {
|
||||
if (isActive) return;
|
||||
if (isPrepaidActivation && !hasFirstPayment && (s === 'RESOLVED' || s === 'CLOSED')) {
|
||||
if (blockResolve && (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.');
|
||||
Alert.alert(
|
||||
'Invoice Not Yet Paid',
|
||||
'This is a PREPAID account. The first month\'s invoice must be paid before activating the account.\n\nGo to Collect screen to record the payment first.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
updateStatus.mutate(s);
|
||||
|
||||
Reference in New Issue
Block a user