feat(portal): M10.2 — restyle Products + Org + Team + Billing + Audit + SSO
Six existing customer-area shells under [slug]/* rebuilt against the handoff design (sections §2/§4/§5/§6/§7/§8). Every screen reuses the new Panel / Monogram / Sev primitives and the ledger-table token system so the visual contract stays single-source-of-truth in globals.css. * `[slug]/settings` (Organization, IT_ADMIN) — legal entity dl, primary contact card, plan & seats meter, products subscribed kv-list (ENTITLED green dot / TRIALING amber dot). * `[slug]/settings/users` (Team, IT_ADMIN) — bracketed member ledger with role chips, last-active mono dim, active/invited dot status. Invite affordance present, modal wiring deferred. * `[slug]/billing` (Billing, CXO + FINANCE + IT_ADMIN) — current plan card with monthly net + 19% VAT, seats + evidence-storage meters, payment method block that swaps to "Payment failed → Re-activate" when tenant.status is frozen, full invoices ledger with paid/due dot. * `[slug]/audit` (Audit log, LEGAL + IT_ADMIN) — filter bar (search + event-type chip toggles + product select), ledger table with denied red dot, footer count + retention note. * `[slug]/settings/integrations` (SSO, IT_ADMIN) — read-only OIDC summary pulling from KEYCLOAK_ISSUER / KEYCLOAK_CLIENT_ID, IdP-group→ role mapping table. * `[slug]/products` (Products index, USER+) — 2x2 product grid with live cards (entitled + trialing chips) and "Coming soon" dashed placeholders, plus a cross-product findings table with filter chips. Plus a new `NotAllowed` 403 surface in the same ledger language that replaces the inline "NotAuthorized" message used by the old shells, so forbidden routes still look like the rest of the portal. Every page goes through `getPortalSession()` so `BP_DEV_FIXTURE` still swaps between admin / user / trial / frozen / archived without Keycloak. Every screen returns 200 against `BP_DEV_FIXTURE=admin-acme pnpm dev`. Still to come on this branch: * Workflows editor (palette + canvas + inspector + drag-wiring) * ⌘K command palette + toasts * Product launch detail (per-product page) * Login redesign (mock SSO picker + violet gradient panel) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
f3c95123fa
commit
91a655b6df
+150
-21
@@ -1,31 +1,160 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
|
||||
import type { SessionWithExtras } from "@/lib/session";
|
||||
import { canSee } from "@/lib/session";
|
||||
import { getPortalSession } from "@/lib/get-session";
|
||||
import { loadTenantForShell } from "@/lib/portal-data";
|
||||
import { Panel } from "@/components/portal/Panel";
|
||||
import { NotAllowed } from "@/components/portal/NotAllowed";
|
||||
import { CreditCard, AlertTriangle } from "lucide-react";
|
||||
|
||||
export default async function Page({
|
||||
const EUR = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
|
||||
|
||||
export default async function BillingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const session = (await auth()) as SessionWithExtras | null;
|
||||
if (!canSee(session, "billing")) return <NotAuthorized />;
|
||||
const session = await getPortalSession();
|
||||
if (!canSee(session, "billing")) return <NotAllowed need="CXO / FINANCE / IT_ADMIN" />;
|
||||
const t = await loadTenantForShell(slug);
|
||||
if (!t) return null;
|
||||
|
||||
const monthlyVAT = Math.round(t.monthly * 0.19);
|
||||
const monthlyGross = t.monthly + monthlyVAT;
|
||||
const seatsPct = t.seats.total > 0 ? (t.seats.used / t.seats.total) * 100 : 0;
|
||||
const evidenceStorageUsed = t.metrics.evidence;
|
||||
const evidenceStorageMax = 1000;
|
||||
const evidencePct = (evidenceStorageUsed / evidenceStorageMax) * 100;
|
||||
const frozen = t.status === "frozen";
|
||||
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Billing"
|
||||
description="Plan, seats, invoices. Polar Checkout opens here for plan changes."
|
||||
milestone="M8.3"
|
||||
details="Polar.sh is the Merchant of Record — it handles EU VAT MOSS so we don't have to. Invoices mirror to ERPNext (Customer + Sales Invoice) so accounting stays in one place."
|
||||
cta={
|
||||
<Link
|
||||
href={`/${slug}/catalog`}
|
||||
style={{ color: "#0070f3", fontSize: 14, textDecoration: "underline" }}
|
||||
>
|
||||
Browse the catalog →
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="content-inner" style={{ maxWidth: 1080 }}>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<div className="page-title">Billing</div>
|
||||
<div className="page-sub">Plan, usage, payment method and invoice history</div>
|
||||
</div>
|
||||
<div className="ph-actions">
|
||||
<button type="button" className="btn">Download all (PDF)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-12" style={{ marginBottom: 14 }}>
|
||||
<div className="span-7 col" style={{ gap: 12 }}>
|
||||
<Panel title="Current plan" bracket>
|
||||
<div className="row between" style={{ alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<div className="eyebrow">PLAN</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, marginTop: 3 }}>{t.plan}</div>
|
||||
<div className="mono muted" style={{ fontSize: 11 }}>{t.planCode}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="eyebrow">MONTHLY</div>
|
||||
<div className="mono" style={{ fontSize: 22, fontWeight: 500, marginTop: 3 }}>
|
||||
{EUR.format(t.monthly)}
|
||||
</div>
|
||||
<div className="mono muted" style={{ fontSize: 10 }}>
|
||||
+ 19% VAT = {EUR.format(monthlyGross)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" style={{ margin: "14px 0" }} />
|
||||
<div className="row between" style={{ marginBottom: 6 }}>
|
||||
<span className="label-micro">SEATS</span>
|
||||
<span className="mono" style={{ fontSize: 12 }}>{t.seats.used} / {t.seats.total}</span>
|
||||
</div>
|
||||
<div className="meter"><span style={{ width: `${seatsPct}%` }} /></div>
|
||||
<div className="row between" style={{ margin: "12px 0 6px" }}>
|
||||
<span className="label-micro">EVIDENCE STORAGE</span>
|
||||
<span className="mono" style={{ fontSize: 12 }}>
|
||||
{evidenceStorageUsed} / {evidenceStorageMax} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className={`meter ${evidencePct > 90 ? "warn" : ""}`}>
|
||||
<span style={{ width: `${evidencePct}%` }} />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Payment method">
|
||||
{frozen ? (
|
||||
<div className="row" style={{ gap: 11 }}>
|
||||
<span className="row" style={{ gap: 8, color: "var(--danger)", fontSize: 13, fontWeight: 600 }}>
|
||||
<AlertTriangle size={16} /> Payment failed
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn btn-accent">Re-activate to continue</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row" style={{ gap: 11 }}>
|
||||
<span className="brand-mark" style={{ background: "var(--paper-2)", color: "var(--ink-2)" }}>
|
||||
<CreditCard size={14} />
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>SEPA Direct Debit</div>
|
||||
<div className="mono muted" style={{ fontSize: 11 }}>
|
||||
DE89 •••• •••• 7421 · {t.contact}
|
||||
</div>
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn btn-sm">Replace</button>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="span-5 col" style={{ gap: 12 }}>
|
||||
<Panel title="Renewal">
|
||||
<dl className="dl">
|
||||
<dt>Renews on</dt>
|
||||
<dd className="mono" style={{ color: t.renewal === "overdue" ? "var(--danger)" : "inherit" }}>
|
||||
{t.renewal}
|
||||
</dd>
|
||||
<dt>Billing email</dt><dd className="mono">{t.contactEmail}</dd>
|
||||
<dt>Currency</dt><dd className="mono">EUR</dd>
|
||||
<dt>VAT applied</dt><dd className="mono">19% (DE)</dd>
|
||||
</dl>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel title="Invoices" tail={<span className="label-micro">{t.invoices.length} entries</span>} pad={false}>
|
||||
<table className="ltable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice</th>
|
||||
<th>Period</th>
|
||||
<th>Issued</th>
|
||||
<th className="r">Seats</th>
|
||||
<th className="r">Net</th>
|
||||
<th className="r">VAT</th>
|
||||
<th className="r">Total</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{t.invoices.map((inv) => (
|
||||
<tr key={inv.id} className="clickable">
|
||||
<td className="t-id">{inv.id}</td>
|
||||
<td>{inv.period}</td>
|
||||
<td className="mono t-dim">{inv.issued}</td>
|
||||
<td className="r mono">{inv.seats}</td>
|
||||
<td className="r mono">{EUR.format(inv.net)}</td>
|
||||
<td className="r mono t-dim">{EUR.format(inv.vat)}</td>
|
||||
<td className="r mono" style={{ fontWeight: 600 }}>{EUR.format(inv.total)}</td>
|
||||
<td>
|
||||
<span className="row" style={{ gap: 6, fontSize: 12 }}>
|
||||
<span className={`dot ${inv.status === "due" ? "warn" : "ok"}`} />
|
||||
{inv.status === "due" ? "Due" : "Paid"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="r">
|
||||
<button type="button" className="btn btn-sm btn-ghost">PDF</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user