From d876fb7a7a70b2d4285af8f28cb0a9b323b028b5 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Thu, 28 May 2026 14:55:39 +0530 Subject: [PATCH 01/13] feat(admin): confirm tenant activate/deactivate + drop redundant tenant chip Activate/deactivate now goes through a styled ConfirmDialog (important, blast- radius-y operation) instead of firing immediately. Removes the topbar TENANT pill since the user dropdown already shows the active tenant. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/src/components/layout/topbar.tsx | 28 ------- .../src/components/ui/confirm-dialog.tsx | 81 +++++++++++++++++++ clients/admin/src/pages/tenants/detail.tsx | 29 ++++++- 3 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 clients/admin/src/components/ui/confirm-dialog.tsx diff --git a/clients/admin/src/components/layout/topbar.tsx b/clients/admin/src/components/layout/topbar.tsx index 31359e5db0..1dff6c00d0 100644 --- a/clients/admin/src/components/layout/topbar.tsx +++ b/clients/admin/src/components/layout/topbar.tsx @@ -101,31 +101,6 @@ function SimpleMenuItem({ ); } -// ───────────────────────────────────────────────────────────────────────────── -// TenantChip — tenant indicator in the topbar right zone. -// ───────────────────────────────────────────────────────────────────────────── - -function TenantChip({ tenant }: { tenant?: string }) { - return ( -
- - - tenant - - - {tenant ?? "—"} - -
- ); -} - // ───────────────────────────────────────────────────────────────────────────── // Topbar // ───────────────────────────────────────────────────────────────────────────── @@ -155,9 +130,6 @@ export function Topbar() { {/* Spacer pushes right-side actions to the trailing edge */}
- {/* Tenant chip */} - - {/* Notification bell */} diff --git a/clients/admin/src/components/ui/confirm-dialog.tsx b/clients/admin/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000000..0b7bfad0c6 --- /dev/null +++ b/clients/admin/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/cn"; + +/** + * A reusable confirmation dialog for important / irreversible actions. Replaces ad-hoc + * window.confirm calls with a styled, accessible Radix dialog. The confirm button shows a pending + * state while the action runs and the dialog stays open until the caller closes it. + */ +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + onConfirm, + destructive = false, + pending = false, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + destructive?: boolean; + pending?: boolean; +}) { + return ( + (pending ? undefined : onOpenChange(o))}> + + +
+ + + + {title} +
+
+ + + {description} + + + + + + +
+
+ ); +} diff --git a/clients/admin/src/pages/tenants/detail.tsx b/clients/admin/src/pages/tenants/detail.tsx index fdccd60670..e885ea9216 100644 --- a/clients/admin/src/pages/tenants/detail.tsx +++ b/clients/admin/src/pages/tenants/detail.tsx @@ -24,6 +24,7 @@ import { ImpersonateDialog } from "@/components/impersonation/impersonate-dialog import { ActiveGrantsCard } from "@/components/impersonation/active-grants-card"; import { TenantBrandingCard } from "@/components/tenants/tenant-branding-card"; import { RenewTenantDialog } from "@/components/tenants/renew-tenant-dialog"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { IdentityPermissions } from "@/lib/permissions"; import { changeTenantActivation, @@ -51,6 +52,7 @@ export function TenantDetailPage() { const { user: currentUser } = useAuth(); const [impersonateOpen, setImpersonateOpen] = useState(false); const [renewOpen, setRenewOpen] = useState(false); + const [activationConfirmOpen, setActivationConfirmOpen] = useState(false); const canImpersonate = (currentUser?.permissions ?? []).includes( IdentityPermissions.Users.Impersonate, ); @@ -85,6 +87,7 @@ export function TenantDetailPage() { mutationFn: (isActive: boolean) => changeTenantActivation(id, isActive), onSuccess: (result) => { toast.success(result.isActive ? "Tenant activated" : "Tenant deactivated"); + setActivationConfirmOpen(false); queryClient.invalidateQueries({ queryKey: ["tenant", id] }); queryClient.invalidateQueries({ queryKey: ["tenants"] }); }, @@ -199,7 +202,7 @@ export function TenantDetailPage() { - diff --git a/clients/admin/src/components/tenants/adjust-validity-dialog.tsx b/clients/admin/src/components/tenants/adjust-validity-dialog.tsx new file mode 100644 index 0000000000..af982a91a7 --- /dev/null +++ b/clients/admin/src/components/tenants/adjust-validity-dialog.tsx @@ -0,0 +1,163 @@ +import { useEffect } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CalendarCog } from "lucide-react"; +import { toast } from "sonner"; +import { adjustTenantValidity } from "@/api/tenants"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field } from "@/components/list"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ApiRequestError } from "@/lib/api-client"; + +// A `type="date"` input yields a `YYYY-MM-DD` string. zod validates the shape +// and that it parses to a real calendar date. +const schema = z.object({ + validUpto: z + .string() + .min(1, "Pick a date.") + .refine((v) => !Number.isNaN(new Date(v).getTime()), "Enter a valid date."), +}); + +type FormValues = z.infer; + +function describe(err: unknown, fallback: string): string { + if (err instanceof ApiRequestError) return err.problem?.detail ?? err.problem?.title ?? err.message; + if (err instanceof Error) return err.message; + return fallback; +} + +function formatDate(value?: string | null): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString(); +} + +/** `YYYY-MM-DD` (the native date input value) for an ISO/date string, for prefill. */ +function toDateInputValue(value?: string | null): string { + if (!value) return ""; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return ""; + return d.toISOString().slice(0, 10); +} + +/** + * Operator override that sets a tenant's ValidUpto directly with NO invoice — + * a comp/correction, distinct from Renew (which issues a term invoice). + * Backdating is permitted server-side. Root-operator only. + */ +export function AdjustValidityDialog({ + open, + onOpenChange, + tenantId, + validUpto, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + tenantId: string; + validUpto?: string; +}) { + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { validUpto: "" }, + }); + + // Prefill with the tenant's current validity each time the dialog opens. + useEffect(() => { + if (open) reset({ validUpto: toDateInputValue(validUpto) }); + }, [open, validUpto, reset]); + + const mutation = useMutation({ + // Pass the date via mutate(arg) — never close over form state at submit time. + mutationFn: (value: string) => adjustTenantValidity(tenantId, new Date(value).toISOString()), + onSuccess: (result) => { + toast.success("Validity adjusted", { + description: `Valid until ${formatDate(result.validUpto)}. No invoice was issued.`, + }); + queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); + queryClient.invalidateQueries({ queryKey: ["tenants"] }); + handleClose(); + }, + onError: (err) => toast.error("Adjust failed", { description: describe(err, "Could not adjust validity.") }), + }); + + function handleClose() { + reset({ validUpto: "" }); + onOpenChange(false); + } + + const onSubmit = handleSubmit((values) => mutation.mutate(values.validUpto)); + const submitting = isSubmitting || mutation.isPending; + + return ( + { + if (!o) handleClose(); + else onOpenChange(true); + }} + > + + +
+ + + + Adjust validity +
+ + Set this tenant's expiry date directly — an operator override with{" "} + no invoice. Use for comps or + corrections; renewals that should bill belong in Renew. Currently valid until{" "} + {formatDate(validUpto)}. + +
+ +
+ + + + + + + + + + +
+
+
+ ); +} diff --git a/clients/admin/src/pages/billing/invoice-detail.tsx b/clients/admin/src/pages/billing/invoice-detail.tsx index 32b46c3ffc..93851479ae 100644 --- a/clients/admin/src/pages/billing/invoice-detail.tsx +++ b/clients/admin/src/pages/billing/invoice-detail.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Ban, CheckCircle2, FileText, Send } from "lucide-react"; +import { ArrowLeft, Ban, CheckCircle2, Download, FileText, Send } from "lucide-react"; import { toast } from "sonner"; import { + downloadInvoicePdf, getInvoice, issueInvoice, markInvoicePaid, @@ -89,6 +90,13 @@ export function InvoiceDetailPage() { const [dueAt, setDueAt] = useState(""); const [voidReason, setVoidReason] = useState(""); + // Pass id + number via mutate(arg) — never close over invoice state, which + // could be stale if the query refetched between render and click. + const downloadMutation = useMutation({ + mutationFn: ({ id, number }: { id: string; number: string }) => downloadInvoicePdf(id, number), + onError: (err) => toast.error("Download failed", { description: describe(err, "Could not download the invoice PDF.") }), + }); + const issueMutation = useMutation({ mutationFn: () => issueInvoice(invoiceId, dueAt ? new Date(dueAt).toISOString() : null), onSuccess: () => { @@ -170,7 +178,20 @@ export function InvoiceDetailPage() { } - /> + > + + ) : null}
diff --git a/clients/admin/src/pages/tenants/detail.tsx b/clients/admin/src/pages/tenants/detail.tsx index e885ea9216..731cacc11a 100644 --- a/clients/admin/src/pages/tenants/detail.tsx +++ b/clients/admin/src/pages/tenants/detail.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, Building2, CalendarClock, + CalendarCog, CheckCircle2, CircleDashed, ClipboardList, @@ -24,8 +25,9 @@ import { ImpersonateDialog } from "@/components/impersonation/impersonate-dialog import { ActiveGrantsCard } from "@/components/impersonation/active-grants-card"; import { TenantBrandingCard } from "@/components/tenants/tenant-branding-card"; import { RenewTenantDialog } from "@/components/tenants/renew-tenant-dialog"; +import { AdjustValidityDialog } from "@/components/tenants/adjust-validity-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; -import { IdentityPermissions } from "@/lib/permissions"; +import { IdentityPermissions, MultitenancyPermissions } from "@/lib/permissions"; import { changeTenantActivation, getTenantProvisioningStatus, @@ -52,9 +54,13 @@ export function TenantDetailPage() { const { user: currentUser } = useAuth(); const [impersonateOpen, setImpersonateOpen] = useState(false); const [renewOpen, setRenewOpen] = useState(false); + const [adjustOpen, setAdjustOpen] = useState(false); const [activationConfirmOpen, setActivationConfirmOpen] = useState(false); - const canImpersonate = (currentUser?.permissions ?? []).includes( - IdentityPermissions.Users.Impersonate, + const permissions = currentUser?.permissions ?? []; + const canImpersonate = permissions.includes(IdentityPermissions.Users.Impersonate); + // Same gate as Renew — adjusting validity is a root-operator subscription action. + const canManageSubscription = permissions.includes( + MultitenancyPermissions.Tenants.UpgradeSubscription, ); const tenantQuery = useQuery({ @@ -200,6 +206,17 @@ export function TenantDetailPage() { Renew / change plan + {canManageSubscription && ( + + )} + + ); +} diff --git a/clients/dashboard/src/components/layout/nav-data.ts b/clients/dashboard/src/components/layout/nav-data.ts index 5b64372977..6b5f7f7290 100644 --- a/clients/dashboard/src/components/layout/nav-data.ts +++ b/clients/dashboard/src/components/layout/nav-data.ts @@ -1,5 +1,6 @@ import { Activity, + CreditCard, FolderOpen, FolderTree, HeartPulse, @@ -60,6 +61,7 @@ export const sections: NavSection[] = [ icon: Activity, items: [ { to: "/activity", label: "Live activity", icon: Activity }, + { to: "/subscription", label: "Subscription", icon: CreditCard }, { to: "/invoices", label: "Invoices", icon: Receipt }, ], }, diff --git a/clients/dashboard/src/pages/invoice-detail.tsx b/clients/dashboard/src/pages/invoice-detail.tsx new file mode 100644 index 0000000000..e9a2b63627 --- /dev/null +++ b/clients/dashboard/src/pages/invoice-detail.tsx @@ -0,0 +1,396 @@ +import { useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Download, FileText, Receipt } from "lucide-react"; +import { + downloadInvoicePdf, + getMyInvoice, + type InvoiceDto, + type InvoiceLineItemDto, + type InvoiceStatus, +} from "@/api/billing"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + EntityDetailBack, + EntityDetailSection, + EntityStatusBadge, + ErrorBand, + type EntityStatusTone, +} from "@/components/list"; +import { describe, formatDate, formatMoney } from "@/lib/list-helpers"; + +// ──────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────── + +function statusTone(status: InvoiceStatus): EntityStatusTone { + switch (status) { + case "Paid": + return "success"; + case "Issued": + return "info"; + case "Void": + return "danger"; + default: + return "default"; + } +} + +function formatPeriod(year: number, month: number) { + return `${year}-${String(month).padStart(2, "0")}`; +} + +// ──────────────────────────────────────────────────────────────────── +// Page +// ──────────────────────────────────────────────────────────────────── + +export function InvoiceDetailPage() { + const { id = "" } = useParams<{ id: string }>(); + + const query = useQuery({ + queryKey: ["billing", "invoices", id], + queryFn: () => getMyInvoice(id), + enabled: !!id, + }); + + const invoice = query.data; + + return ( +
+ + + {query.isError && ( +
+ +
+ )} + + {query.isLoading ? ( + + ) : invoice ? ( + + ) : ( + + )} +
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Body +// ──────────────────────────────────────────────────────────────────── + +function InvoiceBody({ invoice }: { invoice: InvoiceDto }) { + return ( +
+ + +
+ {/* Left: line items + totals */} + + + + + {/* Right: meta + dates + notes */} + +
+
+ ); +} + +function InvoiceHeader({ invoice }: { invoice: InvoiceDto }) { + const [downloading, setDownloading] = useState(false); + + const onDownload = async () => { + if (downloading) return; + setDownloading(true); + try { + await downloadInvoicePdf(invoice.id, invoice.invoiceNumber); + } catch (err) { + toast.error("Download failed", { description: describe(err) }); + } finally { + setDownloading(false); + } + }; + + return ( +
+
+
+
+ + + +
+
+

+ {invoice.invoiceNumber} +

+ + {invoice.status} + + {invoice.purpose && ( + {invoice.purpose} + )} +
+

+ Period {formatPeriod(invoice.periodYear, invoice.periodMonth)} ·{" "} + + {formatMoney(invoice.subtotalAmount, invoice.currency)} + +

+
+
+ +
+ +
+
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Line items table +// ──────────────────────────────────────────────────────────────────── + +const LINE_GRID = "grid-cols-[1fr_80px_110px_110px] sm:grid-cols-[1fr_100px_130px_130px]"; + +function LineItemsTable({ invoice }: { invoice: InvoiceDto }) { + const items = invoice.lineItems ?? []; + + if (items.length === 0) { + return ( +
+

+ No line items +

+

+ This invoice has no itemized charges. +

+
+ ); + } + + return ( +
+
+ Description + Qty + Unit price + Amount +
+ + {items.map((item, i) => ( + + ))} + + {/* Total */} +
+ + Total + + + + + {formatMoney(invoice.subtotalAmount, invoice.currency)} + +
+
+ ); +} + +function LineItemRow({ + item, + currency, + isLast, +}: { + item: InvoiceLineItemDto; + currency: string; + isLast: boolean; +}) { + return ( +
+
+
+ + {item.description} + + {item.kind} +
+ {item.resource && ( + + {item.resource} + + )} +
+ + {item.quantity} + + + {formatMoney(item.unitPrice, currency)} + + + {formatMoney(item.amount, currency)} + +
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Details / dates +// ──────────────────────────────────────────────────────────────────── + +function DetailsBody({ invoice }: { invoice: InvoiceDto }) { + return ( +
+ + + {invoice.status} + + + {invoice.currency} + {formatPeriod(invoice.periodYear, invoice.periodMonth)} + {formatDate(invoice.createdAtUtc)} + {invoice.issuedAtUtc && {formatDate(invoice.issuedAtUtc)}} + {invoice.dueAtUtc && ( + + {formatDate(invoice.dueAtUtc)} + + )} + {invoice.paidAtUtc && ( + + {formatDate(invoice.paidAtUtc)} + + )} + {invoice.voidedAtUtc && ( + + {formatDate(invoice.voidedAtUtc)} + + )} + {invoice.periodStartUtc && ( + {formatDate(invoice.periodStartUtc)} + )} + {invoice.periodEndUtc && ( + {formatDate(invoice.periodEndUtc)} + )} +
+ ); +} + +function Row({ + label, + children, + tone, +}: { + label: string; + children: React.ReactNode; + tone?: "warning" | "success" | "danger"; +}) { + const toneColor = + tone === "warning" + ? "text-[var(--color-warning)]" + : tone === "success" + ? "text-[var(--color-success)]" + : tone === "danger" + ? "text-[var(--color-destructive)]" + : "text-[var(--color-foreground)]"; + return ( +
+
{label}
+
{children}
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Loading + not found +// ──────────────────────────────────────────────────────────────────── + +function DetailSkeleton() { + return ( +
+
+ +
+
+ +
+ + +
+
+
+
+
+ + +
+
+ ); +} + +function NotFoundPanel() { + return ( +
+
+ +
+

+ Invoice not found +

+

+ It may not belong to your tenant, or the link may be wrong. +

+ +
+ ); +} diff --git a/clients/dashboard/src/pages/invoices.tsx b/clients/dashboard/src/pages/invoices.tsx index 3f24caeab5..7fe81f3362 100644 --- a/clients/dashboard/src/pages/invoices.tsx +++ b/clients/dashboard/src/pages/invoices.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Receipt } from "lucide-react"; import { @@ -67,8 +68,8 @@ const DESKTOP_GRID = export function InvoicesPage() { const { user } = useAuth(); const query = useQuery({ - queryKey: ["billing", "invoices", "me"], - queryFn: getMyInvoices, + queryKey: ["billing", "invoices", "me", { pageNumber: 1, pageSize: 100 }], + queryFn: () => getMyInvoices({ pageNumber: 1, pageSize: 100 }), staleTime: 30_000, }); @@ -78,7 +79,7 @@ export function InvoicesPage() { const [search, setSearch] = useState(""); - const invoices = useMemo(() => query.data ?? [], [query.data]); + const invoices = useMemo(() => query.data?.items ?? [], [query.data]); const sorted = useMemo( () => @@ -115,7 +116,7 @@ export function InvoicesPage() { @@ -196,7 +197,9 @@ export function InvoicesPage() { function MobileCard({ invoice }: { invoice: InvoiceDto }) { return ( -
+
@@ -225,7 +228,7 @@ function MobileCard({ invoice }: { invoice: InvoiceDto }) { )}
-
+ ); } @@ -236,8 +239,13 @@ function DesktopRow({ invoice: InvoiceDto; isLast: boolean; }) { + const navigate = useNavigate(); return ( - + navigate(`/invoices/${invoice.id}`)} + > {/* Invoice number + icon */}
diff --git a/clients/dashboard/src/pages/overview.tsx b/clients/dashboard/src/pages/overview.tsx index 4f05a82b2a..eff3fbaa43 100644 --- a/clients/dashboard/src/pages/overview.tsx +++ b/clients/dashboard/src/pages/overview.tsx @@ -113,8 +113,8 @@ function relativeTime(iso: string, now: number = Date.now()): string { function subscriptionTone(status: SubscriptionDto["status"] | undefined) { if (status === "Active") return "success" as const; - if (status === "Canceled") return "warning" as const; - if (status === "Expired") return "danger" as const; + if (status === "Suspended") return "warning" as const; + if (status === "Cancelled") return "danger" as const; return "default" as const; } @@ -329,7 +329,7 @@ function SubscriptionBody({

); @@ -611,9 +611,9 @@ const QUICK_ACTIONS: QuickAction[] = [ tone: "success", }, { - to: "/invoices", + to: "/subscription", title: "Subscription", - description: "Plans, invoices, usage.", + description: "Plan, usage, invoices.", icon: CreditCard, tone: "primary", }, diff --git a/clients/dashboard/src/pages/subscription.tsx b/clients/dashboard/src/pages/subscription.tsx new file mode 100644 index 0000000000..040772e714 --- /dev/null +++ b/clients/dashboard/src/pages/subscription.tsx @@ -0,0 +1,540 @@ +import { useMemo } from "react"; +import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowUpRight, + CalendarClock, + CreditCard, + Gauge, + Receipt, +} from "lucide-react"; +import { + getMyInvoices, + getMyStatus, + getMySubscription, + getUsageSnapshots, + type InvoiceDto, + type InvoiceStatus, + type SubscriptionDto, + type TenantExpiryState, + type TenantStatusDto, + type UsageSnapshotDto, +} from "@/api/billing"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + EntityDetailSection, + EntityPageHeader, + EntityStatusBadge, + ErrorBand, + type EntityStatusTone, +} from "@/components/list"; +import { describe, formatDate, formatMoney } from "@/lib/list-helpers"; +import { cn } from "@/lib/cn"; + +// ──────────────────────────────────────────────────────────────────── +// Pure view helpers — module scope. +// ──────────────────────────────────────────────────────────────────── + +const numberFmt = new Intl.NumberFormat("en-US"); +const formatNumber = (n: number) => numberFmt.format(n); + +type UsageRowVm = { + resource: string; + used: number; + limit: number; + overage: number; + utilization: number; +}; + +function toUsageRows(snapshots: UsageSnapshotDto[]): UsageRowVm[] { + const now = new Date(); + const cy = now.getUTCFullYear(); + const cm = now.getUTCMonth() + 1; + return snapshots + .filter((s) => s.periodYear === cy && s.periodMonth === cm) + .map((s) => ({ + resource: String(s.resource), + used: s.usedUnits, + limit: s.limitUnits, + overage: s.overage, + utilization: s.limitUnits > 0 ? Math.min(100, (s.usedUnits / s.limitUnits) * 100) : 0, + })) + .sort((a, b) => b.utilization - a.utilization); +} + +function expiryTone(state: TenantExpiryState | undefined): { + tone: EntityStatusTone; + label: string; +} { + switch (state) { + case "InGrace": + return { tone: "warning", label: "In grace" }; + case "Expired": + return { tone: "danger", label: "Expired" }; + case "Active": + return { tone: "success", label: "Active" }; + default: + return { tone: "default", label: "Unknown" }; + } +} + +function subscriptionTone(status: SubscriptionDto["status"] | undefined): EntityStatusTone { + if (status === "Active") return "success"; + if (status === "Suspended") return "warning"; + if (status === "Cancelled") return "danger"; + return "default"; +} + +function invoiceStatusTone(status: InvoiceStatus): EntityStatusTone { + switch (status) { + case "Paid": + return "success"; + case "Issued": + return "info"; + case "Void": + return "danger"; + default: + return "default"; + } +} + +function formatPeriod(year: number, month: number) { + return `${year}-${String(month).padStart(2, "0")}`; +} + +// ──────────────────────────────────────────────────────────────────── +// Page +// ──────────────────────────────────────────────────────────────────── + +export function SubscriptionPage() { + const status = useQuery({ + queryKey: ["tenant", "me", "status"], + queryFn: () => getMyStatus(), + staleTime: 60_000, + }); + + const subscription = useQuery({ + queryKey: ["billing", "subscriptions", "me"], + queryFn: () => getMySubscription(), + staleTime: 60_000, + }); + + const usage = useQuery({ + queryKey: ["billing", "usage"], + queryFn: () => getUsageSnapshots(), + staleTime: 60_000, + }); + + const invoices = useQuery({ + queryKey: ["billing", "invoices", "me", { pageNumber: 1, pageSize: 5 }], + queryFn: () => getMyInvoices({ pageNumber: 1, pageSize: 5 }), + staleTime: 60_000, + }); + + const usageRows = useMemo( + () => (usage.data ? toUsageRows(usage.data) : []), + [usage.data], + ); + + const recentInvoices = useMemo(() => { + const items = invoices.data?.items ?? []; + return [...items].sort( + (a, b) => new Date(b.createdAtUtc).getTime() - new Date(a.createdAtUtc).getTime(), + ); + }, [invoices.data]); + + const errorMessage = status.error + ? describe(status.error) + : subscription.error + ? describe(subscription.error) + : null; + + const planName = + status.data?.plan ?? subscription.data?.planKey ?? null; + + return ( +
+ + + {errorMessage && } + +
+ {/* Left rail — plan + validity */} + + + {/* Right column — usage + invoices */} +
+ + + + + + See all + + } + > + + +
+
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Plan body +// ──────────────────────────────────────────────────────────────────── + +function PlanBody({ + planName, + subscription, + loading, +}: { + planName: string | null; + subscription: SubscriptionDto | null | undefined; + loading: boolean; +}) { + if (loading) { + return ( +
+ + + +
+ ); + } + + if (!planName && !subscription) { + return ( +
+
+ No active subscription +
+

+ Your tenant has no plan assigned. Contact your operator to enable + billing, quotas, and overage tracking. +

+
+ ); + } + + return ( +
+
+ + {planName ?? "—"} + + {subscription && ( + + {subscription.status} + + )} +
+ + {subscription && ( +
+
+
Started
+
+ {formatDate(subscription.startUtc)} +
+
+
+
Ends
+
+ {subscription.endUtc ? formatDate(subscription.endUtc) : "open-ended"} +
+
+
+ )} + +

+ Plan changes are operator-driven. Contact your operator to upgrade, + renew, or cancel. +

+
+ ); +} + +/** Map a status tone to the matching Badge variant. */ +function badgeVariantFor( + tone: EntityStatusTone, +): "default" | "success" | "warning" | "danger" | "info" { + return tone === "default" ? "default" : tone; +} + +// ──────────────────────────────────────────────────────────────────── +// Validity body +// ──────────────────────────────────────────────────────────────────── + +function ValidityBody({ + status, + loading, +}: { + status: TenantStatusDto | undefined; + loading: boolean; +}) { + if (loading) { + return ( +
+ + +
+ ); + } + + if (!status) { + return ( +

+ Tenant status is unavailable right now. +

+ ); + } + + const { tone, label } = expiryTone(status.expiryState); + + return ( +
+
+ {label} + {!status.isActive && ( + Inactive + )} +
+ +
+
+
Valid until
+
+ {formatDate(status.validUpto)} +
+
+ {status.expiryState === "InGrace" && ( +
+
Grace ends
+
+ {formatDate(status.graceEndsUtc)} +
+
+ )} +
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────── +// Usage body +// ──────────────────────────────────────────────────────────────────── + +function UsageBody({ + rows, + loading, + isError, +}: { + rows: UsageRowVm[]; + loading: boolean; + isError: boolean; +}) { + if (loading) { + return ( +
    + {[0, 1, 2].map((i) => ( +
  • +
    + + +
    + +
  • + ))} +
+ ); + } + + if (isError) { + return ( +

+ Couldn't load usage. Try refreshing. +

+ ); + } + + if (rows.length === 0) { + return ( +
+ + + +
+ No usage captured yet +
+

+ Consumption will appear here as snapshots are recorded for this period. +

+
+ ); + } + + return ( +
    + {rows.map((row) => ( + + ))} +
+ ); +} + +function UsageRow({ row }: { row: UsageRowVm }) { + const overUtilized = row.utilization >= 80; + const overage = row.overage > 0; + return ( +
  • +
    + + {row.resource} + + {overage && +{formatNumber(row.overage)}} +
    + +
    + + {formatNumber(row.used)} + + + / {formatNumber(row.limit)} + + + {row.utilization.toFixed(0)}% + +
    + +
    +
    +
    +
    +
    +
  • + ); +} + +// ──────────────────────────────────────────────────────────────────── +// Recent invoices body +// ──────────────────────────────────────────────────────────────────── + +function RecentInvoicesBody({ + invoices, + loading, +}: { + invoices: InvoiceDto[]; + loading: boolean; +}) { + if (loading) { + return ( +
      + {[0, 1, 2].map((i) => ( +
    • + + + +
    • + ))} +
    + ); + } + + if (invoices.length === 0) { + return ( +
    + +
    + No invoices yet +
    +

    + Once your tenant has been billed for a period, invoices will appear here. +

    +
    + ); + } + + return ( +
      + {invoices.map((invoice) => ( +
    • + + + + +
      +
      + + {invoice.invoiceNumber} + + + {invoice.status} + +
      +
      + period {formatPeriod(invoice.periodYear, invoice.periodMonth)} +
      +
      + + {formatMoney(invoice.subtotalAmount, invoice.currency)} + + +
    • + ))} +
    + ); +} diff --git a/clients/dashboard/src/routes.tsx b/clients/dashboard/src/routes.tsx index 7b0612deec..487e3f85e0 100644 --- a/clients/dashboard/src/routes.tsx +++ b/clients/dashboard/src/routes.tsx @@ -44,6 +44,14 @@ const ConfirmEmailPage = lazyNamed( const OverviewPage = lazyNamed(() => import("@/pages/overview"), "OverviewPage"); const ActivityPage = lazyNamed(() => import("@/pages/activity"), "ActivityPage"); const InvoicesPage = lazyNamed(() => import("@/pages/invoices"), "InvoicesPage"); +const InvoiceDetailPage = lazyNamed( + () => import("@/pages/invoice-detail"), + "InvoiceDetailPage", +); +const SubscriptionPage = lazyNamed( + () => import("@/pages/subscription"), + "SubscriptionPage", +); const BrandsPage = lazyNamed(() => import("@/pages/catalog/brands"), "BrandsPage"); const CategoriesPage = lazyNamed(() => import("@/pages/catalog/categories"), "CategoriesPage"); const ProductsPage = lazyNamed(() => import("@/pages/catalog/products"), "ProductsPage"); @@ -160,7 +168,9 @@ export const router = createBrowserRouter([ children: [ { index: true, element: withSuspense() }, { path: "activity", element: withSuspense() }, + { path: "subscription", element: withSuspense() }, { path: "invoices", element: withSuspense() }, + { path: "invoices/:id", element: withSuspense() }, { path: "system/health", element: withSuspense() }, { path: "system/audits", element: withSuspense() }, { path: "system/trash", element: withSuspense() }, diff --git a/clients/dashboard/tests/billing/subscription.spec.ts b/clients/dashboard/tests/billing/subscription.spec.ts new file mode 100644 index 0000000000..24830c1713 --- /dev/null +++ b/clients/dashboard/tests/billing/subscription.spec.ts @@ -0,0 +1,256 @@ +import { expect, test } from "@playwright/test"; +import { mockJsonResponse } from "../helpers/api-mocks"; +import { installShellMocks, paged } from "../helpers/shell-mocks"; +import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; + +// ── Fixtures ───────────────────────────────────────────────────────── + +const now = new Date(); +const PERIOD_YEAR = now.getUTCFullYear(); +const PERIOD_MONTH = now.getUTCMonth() + 1; + +const SUBSCRIPTION = { + id: "sub-1", + tenantId: "acme", + planId: "plan-scale", + planKey: "Scale", + startUtc: new Date(Date.UTC(2026, 0, 1)).toISOString(), + endUtc: null, + status: "Active", +}; + +/** Status with plenty of runway — banner stays hidden. */ +const HEALTHY_STATUS = { + id: "acme", + name: "Acme Corp", + isActive: true, + validUpto: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + hasConnectionString: false, + adminEmail: "admin@acme.com", + issuer: null, + plan: "Scale", + expiryState: "Active", + graceEndsUtc: new Date(Date.now() + 97 * 24 * 60 * 60 * 1000).toISOString(), +}; + +/** Active but within the 7-day window — soft info bar should appear. */ +const NEARING_STATUS = { + ...HEALTHY_STATUS, + validUpto: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), + expiryState: "Active", +}; + +/** Expired, still inside the grace window — warning bar should appear. */ +const GRACE_STATUS = { + ...HEALTHY_STATUS, + validUpto: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + expiryState: "InGrace", + graceEndsUtc: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), +}; + +const USAGE = [ + { + id: "use-1", + tenantId: "acme", + periodYear: PERIOD_YEAR, + periodMonth: PERIOD_MONTH, + resource: "ApiCalls", + usedUnits: 4200, + limitUnits: 10000, + overage: 0, + capturedAtUtc: now.toISOString(), + }, + { + id: "use-2", + tenantId: "acme", + periodYear: PERIOD_YEAR, + periodMonth: PERIOD_MONTH, + resource: "StorageBytes", + usedUnits: 900, + limitUnits: 1000, + overage: 120, + capturedAtUtc: now.toISOString(), + }, +]; + +const INVOICE = { + id: "inv-1", + tenantId: "acme", + invoiceNumber: "INV-2026-05", + periodYear: 2026, + periodMonth: 5, + currency: "USD", + subtotalAmount: 149, + status: "Issued", + createdAtUtc: "2026-05-01T00:00:00Z", + issuedAtUtc: "2026-05-01T00:00:00Z", + dueAtUtc: "2026-05-15T00:00:00Z", + paidAtUtc: null, + voidedAtUtc: null, + notes: null, + lineItems: [], + purpose: "Subscription", +}; + +const INVOICE_DETAIL = { + ...INVOICE, + notes: "Thanks for your business.", + lineItems: [ + { + id: "li-1", + kind: "BaseFee", + resource: null, + description: "Scale plan — monthly base fee", + quantity: 1, + unitPrice: 99, + amount: 99, + }, + { + id: "li-2", + kind: "Overage", + resource: "StorageBytes", + description: "Storage overage", + quantity: 50, + unitPrice: 1, + amount: 50, + }, + ], +}; + +// ── Subscription page ──────────────────────────────────────────────── + +test.describe("subscription (/subscription)", () => { + test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, TEST_USER); + await installShellMocks(page); + await mockJsonResponse(page, "**/api/v1/tenants/me/status**", HEALTHY_STATUS); + await mockJsonResponse(page, "**/api/v1/billing/subscriptions/me**", SUBSCRIPTION); + await mockJsonResponse(page, "**/api/v1/billing/usage**", USAGE); + await mockJsonResponse(page, "**/api/v1/billing/invoices/me**", paged([INVOICE])); + }); + + test("renders plan, usage rows, and a recent invoice link", async ({ page }) => { + await page.goto("/subscription"); + + await expect(page.getByRole("heading", { name: /subscription/i })).toBeVisible(); + + // Plan name + status. + await expect(page.getByText("Scale").first()).toBeVisible(); + + // Usage section + both resources. + await expect(page.getByText("Usage by resource", { exact: true })).toBeVisible(); + await expect(page.getByText("ApiCalls", { exact: true })).toBeVisible(); + await expect(page.getByText("StorageBytes", { exact: true })).toBeVisible(); + + // Recent invoices section + a clickable invoice. + await expect(page.getByText("Recent invoices", { exact: true })).toBeVisible(); + const invoiceLink = page.getByRole("link", { name: /INV-2026-05/i }); + await expect(invoiceLink).toBeVisible(); + await expect(invoiceLink).toHaveAttribute("href", "/invoices/inv-1"); + }); + + test("shows the no-subscription empty state", async ({ page }) => { + await mockJsonResponse(page, "**/api/v1/billing/subscriptions/me**", null); + await mockJsonResponse(page, "**/api/v1/tenants/me/status**", { + ...HEALTHY_STATUS, + plan: null, + }); + await page.goto("/subscription"); + await expect(page.getByText(/no active subscription/i)).toBeVisible(); + }); +}); + +// ── Expiry banner ────────────────────────────────────────────────────── + +test.describe("expiry banner", () => { + test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, TEST_USER); + await installShellMocks(page); + await mockJsonResponse(page, "**/api/v1/billing/subscriptions/me**", SUBSCRIPTION); + await mockJsonResponse(page, "**/api/v1/billing/usage**", []); + await mockJsonResponse(page, "**/api/v1/audits**", paged([])); + }); + + test("warns while in grace", async ({ page }) => { + await mockJsonResponse(page, "**/api/v1/tenants/me/status**", GRACE_STATUS); + await page.goto("/"); + await expect(page.getByText(/your subscription expired/i)).toBeVisible(); + await expect(page.getByText(/grace left/i)).toBeVisible(); + }); + + test("informs when nearing expiry", async ({ page }) => { + await mockJsonResponse(page, "**/api/v1/tenants/me/status**", NEARING_STATUS); + await page.goto("/"); + await expect(page.getByText(/your subscription expires in/i)).toBeVisible(); + }); + + test("is absent when healthy", async ({ page }) => { + await mockJsonResponse(page, "**/api/v1/tenants/me/status**", HEALTHY_STATUS); + await page.goto("/"); + // Wait for the page to settle, then assert the bar never showed. + await expect(page.getByRole("heading", { name: /good (morning|afternoon|evening)/i })).toBeVisible(); + await expect(page.getByText(/your subscription expire/i)).toHaveCount(0); + }); + + test("can be dismissed for the session", async ({ page }) => { + await mockJsonResponse(page, "**/api/v1/tenants/me/status**", GRACE_STATUS); + await page.goto("/"); + await expect(page.getByText(/your subscription expired/i)).toBeVisible(); + await page.getByRole("button", { name: /dismiss subscription notice/i }).click(); + await expect(page.getByText(/your subscription expired/i)).toHaveCount(0); + }); +}); + +// ── Invoice detail ─────────────────────────────────────────────────── + +test.describe("invoice detail (/invoices/:id)", () => { + test.beforeEach(async ({ page }) => { + await seedAuthedSession(page, TEST_USER); + await installShellMocks(page); + await mockJsonResponse(page, "**/api/v1/tenants/me/status**", HEALTHY_STATUS); + }); + + test("renders line items and totals", async ({ page }) => { + await mockJsonResponse(page, "**/api/v1/billing/invoices/inv-1", INVOICE_DETAIL); + await page.goto("/invoices/inv-1"); + + await expect(page.getByRole("heading", { name: /INV-2026-05/i })).toBeVisible(); + await expect(page.getByText("Scale plan — monthly base fee")).toBeVisible(); + await expect(page.getByText("Storage overage")).toBeVisible(); + await expect(page.getByText("Total", { exact: true })).toBeVisible(); + await expect(page.getByText("Thanks for your business.")).toBeVisible(); + }); + + test("Download PDF button issues the /pdf request", async ({ page }) => { + await mockJsonResponse(page, "**/api/v1/billing/invoices/inv-1", INVOICE_DETAIL); + + // Intercept the PDF stream and fulfil with a tiny binary body so the + // blob/anchor download path runs without a real backend. + let pdfRequested = false; + await page.route("**/api/v1/billing/invoices/inv-1/pdf", async (route) => { + pdfRequested = true; + await route.fulfill({ + status: 200, + headers: { "Content-Type": "application/pdf" }, + body: "%PDF-1.4 mock", + }); + }); + + await page.goto("/invoices/inv-1"); + await page.getByRole("button", { name: /download pdf/i }).click(); + + await expect.poll(() => pdfRequested).toBe(true); + }); + + test("shows a not-found panel on 404", async ({ page }) => { + await page.route("**/api/v1/billing/invoices/missing", (route) => + route.fulfill({ + status: 404, + headers: { "Content-Type": "application/problem+json" }, + body: JSON.stringify({ status: 404, title: "Not Found" }), + }), + ); + await page.goto("/invoices/missing"); + await expect(page.getByText(/invoice not found/i)).toBeVisible(); + }); +}); diff --git a/clients/dashboard/tests/helpers/shell-mocks.ts b/clients/dashboard/tests/helpers/shell-mocks.ts index 2bf06445b4..05bc0538d7 100644 --- a/clients/dashboard/tests/helpers/shell-mocks.ts +++ b/clients/dashboard/tests/helpers/shell-mocks.ts @@ -46,6 +46,22 @@ export async function installShellMocks(page: Page): Promise { // Defensive: profile + permissions (harmless if a page re-reads them). await mockJsonResponse(page, "**/api/v1/identity/profile", DEFAULT_PROFILE); await mockJsonResponse(page, "**/api/v1/identity/permissions", []); + + // Tenant status drives the global expiry/grace banner mounted in the + // AppShell. Default to a healthy, far-future tenant so the banner stays + // hidden; specs that exercise the banner override this after the call. + await mockJsonResponse(page, "**/api/v1/tenants/me/status**", { + id: "acme", + name: "Acme Corp", + isActive: true, + validUpto: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + hasConnectionString: false, + adminEmail: "admin@acme.com", + issuer: null, + plan: "Scale", + expiryState: "Active", + graceEndsUtc: new Date(Date.now() + 372 * 24 * 60 * 60 * 1000).toISOString(), + }); } /** Build a Playwright-shaped paged response body. */ diff --git a/clients/dashboard/tests/overview/overview.spec.ts b/clients/dashboard/tests/overview/overview.spec.ts index f99090d12b..ca7e6c4c0c 100644 --- a/clients/dashboard/tests/overview/overview.spec.ts +++ b/clients/dashboard/tests/overview/overview.spec.ts @@ -78,7 +78,7 @@ test.describe("invoices (/invoices)", () => { }); test("renders an invoice row from the API", async ({ page }) => { - await mockJsonResponse(page, "**/api/v1/billing/invoices/me**", [INVOICE]); + await mockJsonResponse(page, "**/api/v1/billing/invoices/me**", paged([INVOICE])); await page.goto("/invoices"); await expect(page.getByRole("heading", { name: /invoices/i })).toBeVisible(); // Invoice number + status render in both a (hidden) mobile card and the @@ -88,13 +88,13 @@ test.describe("invoices (/invoices)", () => { }); test("shows the empty state when there are no invoices", async ({ page }) => { - await mockJsonResponse(page, "**/api/v1/billing/invoices/me**", []); + await mockJsonResponse(page, "**/api/v1/billing/invoices/me**", paged([])); await page.goto("/invoices"); await expect(page.getByText(/no invoices yet/i)).toBeVisible(); }); test("filters by search term", async ({ page }) => { - await mockJsonResponse(page, "**/api/v1/billing/invoices/me**", [INVOICE]); + await mockJsonResponse(page, "**/api/v1/billing/invoices/me**", paged([INVOICE])); await page.goto("/invoices"); await expect(page.getByText("INV-2026-05").last()).toBeVisible(); await page.getByPlaceholder(/search by invoice number/i).fill("nomatch-xyz");