Add invoice payment management.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileText, Search, User, Wallet, CreditCard, XCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import api from '@/lib/api';
|
||||
import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types';
|
||||
|
||||
const statusLabels: Record<InvoiceStatus, string> = {
|
||||
draft: 'Draft',
|
||||
issued: 'Unpaid',
|
||||
partially_paid: 'Partially paid',
|
||||
paid: 'Paid',
|
||||
void: 'Void',
|
||||
failed: 'Failed',
|
||||
};
|
||||
|
||||
const statusClasses: Record<InvoiceStatus, string> = {
|
||||
draft: 'badge-gray',
|
||||
issued: 'badge-yellow',
|
||||
partially_paid: 'badge-blue',
|
||||
paid: 'badge-green',
|
||||
void: 'badge-gray',
|
||||
failed: 'badge-red',
|
||||
};
|
||||
|
||||
export default function AdminInvoicesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState<'all' | InvoiceStatus>('all');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'all' | PaymentMethod>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [statusReason, setStatusReason] = useState('');
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||
queryKey: ['admin-invoices', status, paymentMethod, search],
|
||||
queryFn: () => {
|
||||
const params: Record<string, string> = { limit: '200' };
|
||||
if (status !== 'all') params.status = status;
|
||||
if (paymentMethod !== 'all') params.paymentMethod = paymentMethod;
|
||||
if (search.trim()) params.search = search.trim();
|
||||
return api.get('/billing/admin/invoices', { params }).then((r) => r.data);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: selectedInvoice } = useQuery<Invoice>({
|
||||
queryKey: ['admin-invoice', selectedId],
|
||||
queryFn: () => api.get(`/billing/admin/invoices/${selectedId}`).then((r) => r.data),
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: ({ invoiceId, nextStatus, reason }: { invoiceId: string; nextStatus: InvoiceStatus; reason: string }) =>
|
||||
api.patch(`/billing/admin/invoices/${invoiceId}/status`, { status: nextStatus, reason }).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
toast.success('Invoice status updated');
|
||||
setStatusReason('');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-invoice', selectedId] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update invoice status'),
|
||||
});
|
||||
|
||||
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
|
||||
const formatDate = (value?: string) => value ? new Date(value).toLocaleString('en-US') : '-';
|
||||
|
||||
const handleStatusUpdate = (nextStatus: InvoiceStatus) => {
|
||||
if (!selectedInvoice) return;
|
||||
if (!statusReason.trim()) {
|
||||
toast.error('Reason is required for manual status changes');
|
||||
return;
|
||||
}
|
||||
updateStatusMutation.mutate({
|
||||
invoiceId: selectedInvoice.id,
|
||||
nextStatus,
|
||||
reason: statusReason.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" /> Invoice Management
|
||||
</h1>
|
||||
<p className="page-subtitle">Track all user invoices, payments, gateway refs, and wallet transactions.</p>
|
||||
</div>
|
||||
|
||||
<div className="card grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div className="relative md:col-span-2">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search invoice, email, or application"
|
||||
className="input-field pl-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value as any)} className="input-field">
|
||||
<option value="all">All statuses</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value as any)} className="input-field">
|
||||
<option value="all">All methods</option>
|
||||
<option value="wallet">Wallet</option>
|
||||
<option value="gateway">Gateway</option>
|
||||
<option value="mixed">Mixed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2 card p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-100">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Invoice</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">User</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Method</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase">Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">Loading...</td></tr>
|
||||
) : invoices.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">No invoices found</td></tr>
|
||||
) : invoices.map((invoice) => (
|
||||
<tr
|
||||
key={invoice.id}
|
||||
onClick={() => setSelectedId(invoice.id)}
|
||||
className={`cursor-pointer hover:bg-gray-50 ${selectedId === invoice.id ? 'bg-primary-50' : ''}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<p className="text-xs text-gray-500">{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-900">{invoice.user?.email || invoice.userId}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={statusClasses[invoice.status]}>{statusLabels[invoice.status]}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{invoice.paymentMethod || '-'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<p className="text-sm font-bold text-gray-900">{formatPrice(invoice.dueAmount)} T</p>
|
||||
<p className="text-xs text-gray-400">Total {formatPrice(invoice.total)} T</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{!selectedInvoice ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<FileText className="w-10 h-10 mx-auto mb-3" />
|
||||
Select an invoice
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
|
||||
</div>
|
||||
<span className={statusClasses[selectedInvoice.status]}>{statusLabels[selectedInvoice.status]}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-gray-50 rounded-xl p-3">
|
||||
<p className="text-xs text-gray-500">Total</p>
|
||||
<p className="font-bold text-gray-900">{formatPrice(selectedInvoice.total)} T</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-3">
|
||||
<p className="text-xs text-gray-500">Due</p>
|
||||
<p className="font-bold text-primary-700">{formatPrice(selectedInvoice.dueAmount)} T</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
{selectedInvoice.user?.email || selectedInvoice.userId}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<CreditCard className="w-4 h-4 text-gray-400" />
|
||||
Tracking: {selectedInvoice.gatewayTrackingCode || '-'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<Wallet className="w-4 h-4 text-gray-400" />
|
||||
Method: {selectedInvoice.paymentMethod || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Line items</h3>
|
||||
<div className="space-y-2">
|
||||
{(selectedInvoice.lines || []).map((line) => (
|
||||
<div key={line.id} className="border border-gray-100 rounded-xl p-3 flex justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{line.label}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
|
||||
</div>
|
||||
<span className="text-sm font-bold">{formatPrice(line.amount)} T</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Transactions</h3>
|
||||
{(selectedInvoice.transactions || []).length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No linked transactions yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(selectedInvoice.transactions || []).map((tx) => (
|
||||
<div key={tx.id} className="border border-gray-100 rounded-xl p-3">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-sm text-gray-700">{tx.description || tx.type}</span>
|
||||
<span className="text-sm font-bold">{formatPrice(tx.amount)} T</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{tx.type} · {formatDate(tx.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Manual status change</h3>
|
||||
<textarea
|
||||
value={statusReason}
|
||||
onChange={(e) => setStatusReason(e.target.value)}
|
||||
className="input-field w-full min-h-[80px]"
|
||||
placeholder="Reason is required for audit visibility"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStatusUpdate('failed')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="btn-secondary text-red-600 flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-4 h-4" /> Failed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStatusUpdate('void')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="btn-secondary disabled:opacity-50"
|
||||
>
|
||||
Void
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, Invoice } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import NextLink from 'next/link';
|
||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react';
|
||||
@@ -259,6 +259,20 @@ export default function AppDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const createRenewalInvoiceMutation = useMutation({
|
||||
mutationFn: (cycle: string) =>
|
||||
api.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data),
|
||||
onSuccess: (invoice) => {
|
||||
toast.success('Invoice created. Choose how you want to pay.');
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
setShowRenewalModal(false);
|
||||
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to create renewal invoice');
|
||||
},
|
||||
});
|
||||
|
||||
// Check if app needs renewal (expired or suspended)
|
||||
const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
|
||||
const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
@@ -631,6 +645,19 @@ export default function AppDetailPage() {
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to calculate upgrade cost'),
|
||||
});
|
||||
|
||||
const createUpgradeInvoiceMutation = useMutation({
|
||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||
api.post<Invoice>(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data),
|
||||
onSuccess: (invoice) => {
|
||||
toast.success('Invoice created. Choose how you want to pay.');
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create upgrade invoice'),
|
||||
});
|
||||
|
||||
// Handler: app uses billing upgrade path when subscribed; other workloads patch directly.
|
||||
const handleScaleResources = () => {
|
||||
if (scaleWorkload !== 'app') {
|
||||
@@ -1110,11 +1137,10 @@ export default function AppDetailPage() {
|
||||
: renewalCostData.costs.yearly;
|
||||
if (walletData.balance < cost) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-red-700">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-amber-700">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Insufficient balance. Please charge your wallet first.
|
||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
||||
Wallet is short by {(cost - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1132,18 +1158,14 @@ export default function AppDetailPage() {
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => renewMutation.mutate(selectedCycle)}
|
||||
disabled={renewMutation.isPending || !renewalCostData?.costs || (walletData && renewalCostData?.costs && (
|
||||
(selectedCycle === 'hourly' && walletData.balance < renewalCostData.costs.hourly) ||
|
||||
(selectedCycle === 'monthly' && walletData.balance < renewalCostData.costs.monthly) ||
|
||||
(selectedCycle === 'yearly' && walletData.balance < renewalCostData.costs.yearly)
|
||||
))}
|
||||
onClick={() => createRenewalInvoiceMutation.mutate(selectedCycle)}
|
||||
disabled={createRenewalInvoiceMutation.isPending || !renewalCostData?.costs}
|
||||
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{renewMutation.isPending ? (
|
||||
{createRenewalInvoiceMutation.isPending ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Processing...</>
|
||||
) : (
|
||||
<><CreditCard className="w-4 h-4" /> Pay & Renew</>
|
||||
<><CreditCard className="w-4 h-4" /> Create Invoice & Pay</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1203,11 +1225,10 @@ export default function AppDetailPage() {
|
||||
|
||||
{/* Insufficient Balance Warning */}
|
||||
{walletData && upgradeCostData.proratedAmount > walletData.balance && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-red-700">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-amber-700">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Insufficient balance. Please charge your wallet first.
|
||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
||||
Wallet is short by {(upgradeCostData.proratedAmount - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1224,14 +1245,20 @@ export default function AppDetailPage() {
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scaleMutation.mutate(resourceForm)}
|
||||
disabled={scaleMutation.isPending || (walletData && upgradeCostData.proratedAmount > walletData.balance)}
|
||||
onClick={() => {
|
||||
if (upgradeCostData.proratedAmount > 0) {
|
||||
createUpgradeInvoiceMutation.mutate(resourceForm);
|
||||
} else {
|
||||
scaleMutation.mutate(resourceForm);
|
||||
}
|
||||
}}
|
||||
disabled={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
|
||||
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{scaleMutation.isPending ? (
|
||||
{scaleMutation.isPending || createUpgradeInvoiceMutation.isPending ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Applying...</>
|
||||
) : upgradeCostData.proratedAmount > 0 ? (
|
||||
<><CreditCard className="w-4 h-4" /> Pay & Upgrade</>
|
||||
<><CreditCard className="w-4 h-4" /> Create Invoice & Pay</>
|
||||
) : (
|
||||
<><CheckCircle className="w-4 h-4" /> Apply Changes</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, ExternalLink } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import api from '@/lib/api';
|
||||
import type { Invoice, InvoiceStatus } from '@/types';
|
||||
|
||||
const statusLabels: Record<InvoiceStatus, string> = {
|
||||
draft: 'Draft',
|
||||
issued: 'Unpaid',
|
||||
partially_paid: 'Partially paid',
|
||||
paid: 'Paid',
|
||||
void: 'Void',
|
||||
failed: 'Failed',
|
||||
};
|
||||
|
||||
const statusClasses: Record<InvoiceStatus, string> = {
|
||||
draft: 'badge-gray',
|
||||
issued: 'badge-yellow',
|
||||
partially_paid: 'badge-blue',
|
||||
paid: 'badge-green',
|
||||
void: 'badge-gray',
|
||||
failed: 'badge-red',
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'unpaid' | InvoiceStatus>('all');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(searchParams.get('invoice'));
|
||||
|
||||
useEffect(() => {
|
||||
const invoiceId = searchParams.get('invoice');
|
||||
if (invoiceId) setSelectedId(invoiceId);
|
||||
}, [searchParams]);
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||
queryKey: ['invoices', statusFilter],
|
||||
queryFn: () => {
|
||||
const params: Record<string, string> = {};
|
||||
if (statusFilter !== 'all' && statusFilter !== 'unpaid') params.status = statusFilter;
|
||||
return api.get('/billing/invoices', { params }).then((r) => r.data);
|
||||
},
|
||||
});
|
||||
|
||||
const visibleInvoices = invoices.filter((invoice) => {
|
||||
if (statusFilter === 'unpaid') return invoice.status === 'issued' || invoice.status === 'partially_paid';
|
||||
return true;
|
||||
});
|
||||
|
||||
const { data: selectedInvoice } = useQuery<Invoice>({
|
||||
queryKey: ['invoice', selectedId],
|
||||
queryFn: () => api.get(`/billing/invoices/${selectedId}`).then((r) => r.data),
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoice', selectedId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
};
|
||||
|
||||
const walletPayMutation = useMutation({
|
||||
mutationFn: (invoiceId: string) => api.post(`/billing/invoices/${invoiceId}/pay/wallet`).then((r) => r.data),
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.effect ? 'Invoice paid and service updated' : 'Invoice paid');
|
||||
refresh();
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Wallet payment failed'),
|
||||
});
|
||||
|
||||
const gatewayPayMutation = useMutation({
|
||||
mutationFn: async ({ invoiceId, mixed }: { invoiceId: string; mixed: boolean }) => {
|
||||
const callbackUrl = `${window.location.origin}/dashboard/invoices`;
|
||||
const { data } = await api.post(
|
||||
`/billing/invoices/${invoiceId}/pay/${mixed ? 'mixed' : 'gateway'}`,
|
||||
{ callbackUrl },
|
||||
);
|
||||
if (data.gatewayAmount === 0) return data;
|
||||
const amount = data.gatewayAmount ?? data.amount;
|
||||
const trackingCode = data.trackingCode;
|
||||
if (!trackingCode || !amount) return data;
|
||||
const verified = await api.post(`/billing/invoices/${invoiceId}/gateway/verify`, {
|
||||
trackingCode,
|
||||
amount,
|
||||
});
|
||||
return verified.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.effect ? 'Payment complete and service updated' : 'Payment complete');
|
||||
refresh();
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Gateway payment failed'),
|
||||
});
|
||||
|
||||
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
|
||||
const formatDate = (value?: string) => value ? new Date(value).toLocaleString('en-US') : '-';
|
||||
const dueAmount = Number(selectedInvoice?.dueAmount || 0);
|
||||
const walletBalance = Number(walletData?.balance || 0);
|
||||
const isPayable = selectedInvoice?.status === 'issued' || selectedInvoice?.status === 'partially_paid';
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" /> Invoices
|
||||
</h1>
|
||||
<p className="page-subtitle">Review what each payment was for and pay open invoices.</p>
|
||||
</div>
|
||||
<div className="card py-3 px-4 flex items-center gap-3">
|
||||
<Wallet className="w-5 h-5 text-primary-600" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Wallet balance</p>
|
||||
<p className="font-bold text-gray-900">{formatPrice(walletBalance)} Toman</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['all', 'unpaid', 'paid', 'failed', 'void'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border ${
|
||||
statusFilter === status
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{status === 'all' ? 'All' : status === 'unpaid' ? 'Unpaid' : statusLabels[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
<div className="lg:col-span-3 card p-0 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-400">Loading invoices...</div>
|
||||
) : visibleInvoices.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">No invoices found</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{visibleInvoices.map((invoice) => (
|
||||
<button
|
||||
key={invoice.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(invoice.id)}
|
||||
className={`w-full text-left p-4 hover:bg-gray-50 transition-colors ${
|
||||
selectedId === invoice.id ? 'bg-primary-50' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={statusClasses[invoice.status]}>{statusLabels[invoice.status]}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">Total {formatPrice(invoice.total)} Toman</span>
|
||||
<span className="font-semibold text-gray-900">Due {formatPrice(invoice.dueAmount)} Toman</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 card">
|
||||
{!selectedInvoice ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<FileText className="w-10 h-10 mx-auto mb-3" />
|
||||
Select an invoice to view details
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
|
||||
</div>
|
||||
<span className={statusClasses[selectedInvoice.status]}>{statusLabels[selectedInvoice.status]}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Total</span><span className="font-semibold">{formatPrice(selectedInvoice.total)} Toman</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Paid</span><span className="font-semibold text-green-600">{formatPrice(selectedInvoice.paidAmount)} Toman</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Due</span><span className="font-semibold text-primary-700">{formatPrice(selectedInvoice.dueAmount)} Toman</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Method</span><span>{selectedInvoice.paymentMethod || '-'}</span></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Line items</h3>
|
||||
<div className="space-y-2">
|
||||
{(selectedInvoice.lines || []).map((line) => (
|
||||
<div key={line.id} className="border border-gray-100 rounded-xl p-3">
|
||||
<div className="flex justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{line.label}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">{formatPrice(line.amount)} T</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPayable && (
|
||||
<div className="space-y-3 border-t border-gray-100 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Payment options</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => walletPayMutation.mutate(selectedInvoice.id)}
|
||||
disabled={walletPayMutation.isPending || walletBalance < dueAmount}
|
||||
className="w-full btn-primary flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Wallet className="w-4 h-4" />
|
||||
Pay from wallet
|
||||
</button>
|
||||
{walletBalance < dueAmount && (
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-2">
|
||||
Wallet is short by {formatPrice(dueAmount - walletBalance)} Toman. Use mixed payment to cover the delta by gateway.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => gatewayPayMutation.mutate({ invoiceId: selectedInvoice.id, mixed: false })}
|
||||
disabled={gatewayPayMutation.isPending}
|
||||
className="w-full btn-secondary flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Pay directly by gateway
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => gatewayPayMutation.mutate({ invoiceId: selectedInvoice.id, mixed: true })}
|
||||
disabled={gatewayPayMutation.isPending}
|
||||
className="w-full btn-secondary flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Use wallet + gateway delta
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedInvoice.status === 'paid' && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-100 rounded-xl p-3">
|
||||
<CheckCircle className="w-4 h-4" /> Paid on {formatDate(selectedInvoice.paidAt)}
|
||||
</div>
|
||||
)}
|
||||
{selectedInvoice.status === 'failed' && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-700 bg-red-50 border border-red-100 rounded-xl p-3">
|
||||
<XCircle className="w-4 h-4" /> {selectedInvoice.statusReason || 'Payment failed'}
|
||||
</div>
|
||||
)}
|
||||
{selectedInvoice.status === 'partially_paid' && (
|
||||
<div className="flex items-center gap-2 text-sm text-blue-700 bg-blue-50 border border-blue-100 rounded-xl p-3">
|
||||
<Clock className="w-4 h-4" /> Waiting for the remaining payment.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Wallet,
|
||||
CreditCard,
|
||||
ScrollText,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
|
||||
type NavItem = { href: string; label: string; icon: ReactNode };
|
||||
@@ -35,6 +36,7 @@ const userNavItems: NavItem[] = [
|
||||
{ href: '/dashboard/logs', label: 'Logs', icon: <ScrollText className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/wallet', label: 'Wallet', icon: <Wallet className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/invoices', label: 'Invoices', icon: <FileText className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/tickets', label: 'Tickets', icon: <Ticket className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
@@ -42,6 +44,7 @@ const adminNavItems: NavItem[] = [
|
||||
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/billing', label: 'Billing Plans', icon: <CreditCard className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/invoices', label: 'Invoices', icon: <FileText className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: <ClipboardList className="w-4 h-4" /> },
|
||||
|
||||
@@ -5,24 +5,28 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { WalletTransaction, TransactionType } from '@/types';
|
||||
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock, CreditCard } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock, CreditCard, FileText } from 'lucide-react';
|
||||
|
||||
const txTypeLabels: Record<TransactionType, string> = {
|
||||
charge: 'Deposit',
|
||||
deduction: 'Payment',
|
||||
refund: 'Refund',
|
||||
gateway_payment: 'Gateway payment',
|
||||
};
|
||||
|
||||
const txTypeColors: Record<TransactionType, string> = {
|
||||
charge: 'text-green-600',
|
||||
deduction: 'text-red-600',
|
||||
refund: 'text-blue-600',
|
||||
gateway_payment: 'text-purple-600',
|
||||
};
|
||||
|
||||
const txTypeIcons: Record<TransactionType, React.ReactNode> = {
|
||||
charge: <ArrowDownCircle className="w-4 h-4 text-green-500" />,
|
||||
deduction: <ArrowUpCircle className="w-4 h-4 text-red-500" />,
|
||||
refund: <RotateCcw className="w-4 h-4 text-blue-500" />,
|
||||
gateway_payment: <CreditCard className="w-4 h-4 text-purple-500" />,
|
||||
};
|
||||
|
||||
export default function WalletPage() {
|
||||
@@ -194,11 +198,20 @@ export default function WalletPage() {
|
||||
{tx.description && <span className="text-gray-500 font-normal"> — {tx.description}</span>}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
|
||||
{tx.invoiceId && (
|
||||
<Link
|
||||
href={`/dashboard/invoices?invoice=${tx.invoiceId}`}
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
Invoice {tx.invoice?.invoiceNumber || ''}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
|
||||
{tx.type === 'deduction' ? '−' : '+'}{formatPrice(tx.amount)} T
|
||||
{tx.type === 'deduction' ? '−' : tx.type === 'gateway_payment' ? '' : '+'}{formatPrice(tx.amount)} T
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Balance: {formatPrice(tx.balanceAfter)} T</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user