153 lines
7.7 KiB
React
153 lines
7.7 KiB
React
import React, { useMemo } from 'react';
|
|
import { AlertCircle, CalendarClock, FileText, Lock, Loader2 } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { toast } from 'sonner';
|
|
import { SpotlightCard } from '../../../components/SpotlightCard';
|
|
import { formatAmount } from '../data/invoices';
|
|
import { stripeConfig } from '../config/stripeConfig';
|
|
import { startCheckout } from '../services/checkoutService';
|
|
|
|
/**
|
|
* Outstanding tab — summarizes everything the customer still owes: a headline
|
|
* total-due figure plus the list of unpaid invoices, with overdue ones flagged.
|
|
* Reads the same invoice/paid data as the "Make a Payment" tab so the two views
|
|
* never disagree.
|
|
*/
|
|
const OutstandingPanel = ({ invoices, paidIds = [], customerEmail }) => {
|
|
const navigate = useNavigate();
|
|
const [redirectingId, setRedirectingId] = useState(null);
|
|
|
|
const due = useMemo(
|
|
() =>
|
|
invoices
|
|
.filter((i) => !(i.status === 'paid' || paidIds.includes(i.id)))
|
|
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)),
|
|
[invoices, paidIds]
|
|
);
|
|
|
|
// Total owed, assuming all unpaid invoices share a currency (they do here).
|
|
const totalCents = due.reduce((sum, i) => sum + (i.amount || 0), 0);
|
|
const currency = due[0]?.currency || 'usd';
|
|
|
|
// Midnight today, so an invoice due today is not counted as overdue.
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const isOverdue = (inv) => new Date(inv.dueDate) < today;
|
|
const overdueCount = due.filter(isOverdue).length;
|
|
|
|
const handlePay = async (invoice) => {
|
|
if (redirectingId) return;
|
|
setRedirectingId(invoice.id);
|
|
|
|
if (!stripeConfig.isConfigured) {
|
|
navigate(`/payments/demo-checkout?invoice=${encodeURIComponent(invoice.id)}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await startCheckout({ invoiceId: invoice.id, customerEmail });
|
|
} catch (err) {
|
|
toast.error('Unable to start checkout', { description: err.message });
|
|
setRedirectingId(null);
|
|
}
|
|
};
|
|
|
|
if (due.length === 0) {
|
|
return (
|
|
<div className="p-10 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
|
|
You have no outstanding balance — everything is paid up. 🎉
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Total due summary */}
|
|
<SpotlightCard className="p-6 md:p-8">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
|
Total Outstanding
|
|
</p>
|
|
<p className="text-4xl md:text-5xl font-black text-zinc-900 dark:text-white mt-1">
|
|
{formatAmount(totalCents, currency, stripeConfig.locale)}
|
|
</p>
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-2">
|
|
Across {due.length} invoice{due.length === 1 ? '' : 's'}
|
|
{overdueCount > 0 && (
|
|
<span className="text-red-500 dark:text-red-400 font-semibold">
|
|
{' '}· {overdueCount} overdue
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="w-16 h-16 rounded-full bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center text-amber-500 border border-amber-100 dark:border-amber-500/20 shrink-0">
|
|
<CalendarClock size={30} />
|
|
</div>
|
|
</div>
|
|
</SpotlightCard>
|
|
|
|
{/* Outstanding invoice list */}
|
|
<div className="grid gap-4">
|
|
{due.map((inv) => {
|
|
const busy = redirectingId === inv.id;
|
|
const overdue = isOverdue(inv);
|
|
return (
|
|
<SpotlightCard key={inv.id} className="p-5 md:p-6">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div className="flex items-start gap-4 min-w-0">
|
|
<div className="w-11 h-11 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-500/20 shrink-0">
|
|
<FileText size={20} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="font-bold truncate">{inv.title}</h4>
|
|
<span className="text-[10px] font-mono text-zinc-400 shrink-0">
|
|
{inv.id}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1 line-clamp-2">
|
|
{inv.description}
|
|
</p>
|
|
<p
|
|
className={`text-xs mt-1 flex items-center gap-1 ${
|
|
overdue
|
|
? 'text-red-500 dark:text-red-400 font-semibold'
|
|
: 'text-zinc-400'
|
|
}`}
|
|
>
|
|
{overdue && <AlertCircle size={12} />}
|
|
{overdue ? 'Overdue · ' : 'Due '}
|
|
{new Date(inv.dueDate).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between md:flex-col md:items-end gap-3 md:gap-2 shrink-0">
|
|
<span className="text-xl font-black text-zinc-900 dark:text-white">
|
|
{formatAmount(inv.amount, inv.currency, stripeConfig.locale)}
|
|
</span>
|
|
<button
|
|
onClick={() => handlePay(inv)}
|
|
disabled={busy}
|
|
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
|
|
>
|
|
{busy ? (
|
|
<><Loader2 className="animate-spin" size={15} /> Redirecting…</>
|
|
) : (
|
|
<><Lock size={14} /> Pay Now</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</SpotlightCard>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default OutstandingPanel;
|