billing
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
||||
FiUsers, FiUser, FiHardDrive,
|
||||
FiUsers, FiUser, FiHardDrive, FiDollarSign,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import Login from './components/Login';
|
||||
@@ -16,6 +16,7 @@ import AuditLogModal from './components/AuditLogModal';
|
||||
import AdminUsersModal from './components/AdminUsersModal';
|
||||
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
||||
import DomainQuotaModal from './components/DomainQuotaModal';
|
||||
import BillingModal from './components/BillingModal';
|
||||
|
||||
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
|
||||
|
||||
@@ -38,6 +39,7 @@ function App() {
|
||||
const [showAdmins, setShowAdmins] = useState(false);
|
||||
const [showChangePw, setShowChangePw] = useState(false);
|
||||
const [showDomainQuota, setShowDomainQuota] = useState(false);
|
||||
const [showBilling, setShowBilling] = useState(false);
|
||||
|
||||
const showToast = useCallback((message, type = 'success') => {
|
||||
setToast({ message, type });
|
||||
@@ -193,9 +195,12 @@ function App() {
|
||||
<FiUser className="w-4 h-4 mr-2" />
|
||||
My Password
|
||||
</button>
|
||||
{/* Audit log: super_admin only. */}
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<button onClick={() => setShowBilling(true)} className="btn-secondary">
|
||||
<FiDollarSign className="w-4 h-4 mr-2" />
|
||||
Billing
|
||||
</button>
|
||||
<button onClick={() => setShowAudit(true)} className="btn-secondary">
|
||||
<FiList className="w-4 h-4 mr-2" />
|
||||
Audit Log
|
||||
@@ -274,7 +279,6 @@ function App() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row flex-nowrap gap-2">
|
||||
{/* Set domain-wide quota: super_admin only. */}
|
||||
{isSuperAdmin && (
|
||||
<button
|
||||
onClick={() => setShowDomainQuota(true)}
|
||||
@@ -429,6 +433,14 @@ function App() {
|
||||
onToast={showToast}
|
||||
/>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<BillingModal
|
||||
open={showBilling}
|
||||
domains={domains}
|
||||
onClose={() => setShowBilling(false)}
|
||||
onToast={showToast}
|
||||
/>
|
||||
)}
|
||||
<ChangeMyPasswordModal
|
||||
open={showChangePw}
|
||||
onClose={() => setShowChangePw(false)}
|
||||
|
||||
274
frontend/src/components/BillingModal.jsx
Normal file
274
frontend/src/components/BillingModal.jsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { FiDollarSign, FiList, FiPlus, FiMinus, FiInbox } from 'react-icons/fi';
|
||||
import Modal from './Modal';
|
||||
import LoadingOverlay from './LoadingOverlay';
|
||||
import { billingAPI } from '../services/api';
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
|
||||
const TABS = [
|
||||
{ id: 'summary', label: 'Monthly summary', icon: FiDollarSign },
|
||||
{ id: 'events', label: 'Event log', icon: FiList },
|
||||
];
|
||||
|
||||
const BillingModal = ({ open, domains, onClose, onToast }) => {
|
||||
const [activeTab, setActiveTab] = useState('summary');
|
||||
const [domainFilter, setDomainFilter] = useState(''); // '' = all
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const sortedDomains = useMemo(
|
||||
() => [...domains].map((d) => d.domain).sort(),
|
||||
[domains]
|
||||
);
|
||||
|
||||
const reload = async () => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const filter = domainFilter || undefined;
|
||||
if (activeTab === 'summary') {
|
||||
const data = await billingAPI.summary(filter);
|
||||
setSummary(data);
|
||||
} else {
|
||||
const data = await billingAPI.events({ domain: filter, limit: 500 });
|
||||
setEvents(data);
|
||||
}
|
||||
} catch (err) {
|
||||
onToast?.(`Failed to load billing data: ${err.message}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab('summary');
|
||||
setDomainFilter('');
|
||||
setSummary(null);
|
||||
setEvents([]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, activeTab, domainFilter]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Inbox billing"
|
||||
subtitle="$5 per active inbox per month"
|
||||
size="lg"
|
||||
>
|
||||
<div className="relative min-h-[400px]">
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-4">
|
||||
<div className="flex gap-1">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const isActive = activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
|
||||
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain filter */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<label className="text-sm font-semibold text-gray-700">Domain:</label>
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={(e) => setDomainFilter(e.target.value)}
|
||||
className="input-field max-w-xs py-2"
|
||||
>
|
||||
<option value="">All domains</option>
|
||||
{sortedDomains.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading && <LoadingOverlay message="Loading..." />}
|
||||
|
||||
{!loading && activeTab === 'summary' && (
|
||||
<SummaryView summary={summary} />
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'events' && (
|
||||
<EventsView events={events} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Sub: Summary
|
||||
// ============================================================
|
||||
const SummaryView = ({ summary }) => {
|
||||
if (!summary) return null;
|
||||
|
||||
const { months, price_per_inbox } = summary;
|
||||
|
||||
if (!months || months.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500 text-center py-12">
|
||||
No billable activity yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by year-month for the section headers, then list domains within.
|
||||
const byYm = new Map();
|
||||
for (const row of months) {
|
||||
if (!byYm.has(row.ym)) byYm.set(row.ym, []);
|
||||
byYm.get(row.ym).push(row);
|
||||
}
|
||||
|
||||
// Total across the most recent month for the headline number.
|
||||
const sortedYms = [...byYm.keys()].sort().reverse();
|
||||
const latestYm = sortedYms[0];
|
||||
const latestRows = byYm.get(latestYm) || [];
|
||||
const latestTotalInboxes = latestRows.reduce((s, r) => s + r.inbox_count, 0);
|
||||
const latestTotalUsd = latestRows.reduce((s, r) => s + r.amount_usd, 0);
|
||||
const [latestY, latestM] = latestYm ? latestYm.split('-').map(Number) : [null, null];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Headline */}
|
||||
{latestYm && (
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
|
||||
{MONTH_NAMES[latestM - 1]} {latestY}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-3">
|
||||
<span className="text-3xl font-bold text-gray-900">${latestTotalUsd}</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
({latestTotalInboxes} inbox{latestTotalInboxes !== 1 ? 'es' : ''} × ${price_per_inbox})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per month / per domain */}
|
||||
<div className="space-y-5">
|
||||
{sortedYms.map((ym) => {
|
||||
const rows = byYm.get(ym);
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
const monthTotalInboxes = rows.reduce((s, r) => s + r.inbox_count, 0);
|
||||
const monthTotalUsd = rows.reduce((s, r) => s + r.amount_usd, 0);
|
||||
|
||||
return (
|
||||
<div key={ym}>
|
||||
<div className="flex items-baseline justify-between mb-2 border-b border-gray-200 pb-1">
|
||||
<h3 className="text-sm font-bold text-gray-800">
|
||||
{MONTH_NAMES[m - 1]} {y}
|
||||
</h3>
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="font-mono">{monthTotalInboxes}</span> inboxes ·{' '}
|
||||
<span className="font-bold text-gray-900">${monthTotalUsd}</span>
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{rows
|
||||
.sort((a, b) => b.amount_usd - a.amount_usd || a.domain.localeCompare(b.domain))
|
||||
.map((r) => (
|
||||
<tr key={`${r.ym}-${r.domain}`} className="hover:bg-gray-50">
|
||||
<td className="py-2 pr-3">
|
||||
<span className="font-medium text-gray-900">{r.domain}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-gray-600 text-xs whitespace-nowrap">
|
||||
<FiInbox className="inline w-3 h-3 mr-1 mb-0.5" />
|
||||
{r.inbox_count} inbox{r.inbox_count !== 1 ? 'es' : ''}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right font-mono font-semibold text-gray-900 whitespace-nowrap">
|
||||
${r.amount_usd}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Sub: Events
|
||||
// ============================================================
|
||||
const EventsView = ({ events }) => {
|
||||
if (!events || events.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500 text-center py-12">
|
||||
No events yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200 sticky top-0 bg-white">
|
||||
<tr>
|
||||
<th className="py-2 pr-4 font-semibold">When</th>
|
||||
<th className="py-2 pr-4 font-semibold">Action</th>
|
||||
<th className="py-2 pr-4 font-semibold">Mailbox</th>
|
||||
<th className="py-2 pr-4 font-semibold">Domain</th>
|
||||
<th className="py-2 pr-4 font-semibold">Actor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{events.map((ev) => (
|
||||
<tr key={ev.id} className="hover:bg-gray-50 align-top">
|
||||
<td className="py-2 pr-4 text-gray-500 whitespace-nowrap">
|
||||
{new Date(ev.occurred_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
{ev.action === 'created' ? (
|
||||
<span className="inline-flex items-center gap-1 pill-success">
|
||||
<FiPlus className="w-3 h-3" /> created
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 pill-warn">
|
||||
<FiMinus className="w-3 h-3" /> deleted
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-gray-800">{ev.email}</td>
|
||||
<td className="py-2 pr-4 text-gray-700">{ev.domain}</td>
|
||||
<td className="py-2 pr-4 text-gray-500 text-xs">
|
||||
{ev.actor_email || (ev.notes ? <em>{ev.notes}</em> : '—')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingModal;
|
||||
@@ -50,7 +50,6 @@ export const mailboxesAPI = {
|
||||
`/api/mailboxes/${encodeURIComponent(email)}/password`,
|
||||
{ password }
|
||||
)).data,
|
||||
// quota_gb: integer, e.g. 5, 10, 15...
|
||||
setQuota: async (email, quota_gb) =>
|
||||
(await api.post(
|
||||
`/api/mailboxes/${encodeURIComponent(email)}/quota`,
|
||||
@@ -85,3 +84,21 @@ export const adminsAPI = {
|
||||
remove: async (email) =>
|
||||
(await api.delete(`/api/admins/${encodeURIComponent(email)}`)).data,
|
||||
};
|
||||
|
||||
export const billingAPI = {
|
||||
summary: async (domain) => {
|
||||
const params = new URLSearchParams();
|
||||
if (domain) params.set('domain', domain);
|
||||
const qs = params.toString();
|
||||
return (await api.get(`/api/billing/summary${qs ? '?' + qs : ''}`)).data;
|
||||
},
|
||||
events: async ({ domain, from, to, limit } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (domain) params.set('domain', domain);
|
||||
if (from) params.set('from', from);
|
||||
if (to) params.set('to', to);
|
||||
if (limit) params.set('limit', String(limit));
|
||||
const qs = params.toString();
|
||||
return (await api.get(`/api/billing/events${qs ? '?' + qs : ''}`)).data;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user