Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s
Some checks failed
Build and Push / build (push) Failing after 28s
This commit is contained in:
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal file
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { GenerateForm } from "@/components/admin/billing/generate-form";
|
||||
|
||||
/**
|
||||
* /admin/billing/generate — testing tool to compute & commit an
|
||||
* invoice for a given (org, period).
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Admin picks org + year/month + locale (default auto-detected
|
||||
* from country).
|
||||
* 2. "Preview" runs computeInvoiceDraft (dryRun) — shows lines,
|
||||
* totals, warnings.
|
||||
* 3. "Commit" persists + renders the PDF.
|
||||
*
|
||||
* The org dropdown is hydrated server-side here so the page loads
|
||||
* with the list pre-populated. Per-org billing status (address
|
||||
* present / open balance) is fetched on demand from /api/admin/
|
||||
* billing/orgs since it can change as admin edits.
|
||||
*/
|
||||
export default async function AdminBillingGeneratePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Build initial org list from tenant labels.
|
||||
const tenants = await listTenants();
|
||||
const orgMap = new Map<string, string[]>();
|
||||
for (const t of tenants) {
|
||||
const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||
if (!oid) continue;
|
||||
if (!orgMap.has(oid)) orgMap.set(oid, []);
|
||||
orgMap.get(oid)!.push(t.metadata.name);
|
||||
}
|
||||
// Hydrate company name + country in parallel.
|
||||
const orgList = await Promise.all(
|
||||
[...orgMap.entries()].map(async ([orgId, tenantNames]) => {
|
||||
const billing = await getOrgBilling(orgId).catch(() => null);
|
||||
return {
|
||||
zitadelOrgId: orgId,
|
||||
tenantNames,
|
||||
companyName: billing?.companyName ?? null,
|
||||
country: billing?.country ?? null,
|
||||
hasBillingAddress: !!billing,
|
||||
};
|
||||
})
|
||||
);
|
||||
orgList.sort((a, b) =>
|
||||
(a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||
b.companyName ?? b.zitadelOrgId
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("generateTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("generatePageDesc")}</p>
|
||||
</div>
|
||||
<GenerateForm orgs={orgList} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
35
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
35
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getInvoiceDetail } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||
*
|
||||
* Server-renders the static body (header, lines, totals, billing
|
||||
* snapshot); the action bar (mark-paid, delete, PDF download) is
|
||||
* a client component for the interactive bits.
|
||||
*/
|
||||
export default async function AdminInvoiceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const { id } = await params;
|
||||
const detail = await getInvoiceDetail(id);
|
||||
if (!detail) notFound();
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||
<InvoiceDetailView detail={detail} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal file
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { InvoicesTable } from "@/components/admin/billing/invoices-table";
|
||||
|
||||
/**
|
||||
* /admin/billing/invoices — list of all issued invoices, filterable
|
||||
* by status and month. Click a row to drill into detail.
|
||||
*
|
||||
* Server-renders the initial table with no filters applied (showing
|
||||
* the most recent 200). Client filters trigger a fetch with query
|
||||
* params and re-render in place.
|
||||
*/
|
||||
export default async function AdminInvoicesListPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
const invoices = await listInvoices({ limit: 200 });
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("invoicesTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("invoicesPageDesc")}</p>
|
||||
</div>
|
||||
<InvoicesTable initialInvoices={invoices} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
128
src/app/[locale]/admin/billing/page.tsx
Normal file
128
src/app/[locale]/admin/billing/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgOpenBalances, syncOverdueInvoices } from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* /admin/billing — landing page with sub-section links and a
|
||||
* quick overview of orgs in arrears.
|
||||
*
|
||||
* Sub-pages:
|
||||
* - /admin/billing/pricing — platform + skill prices
|
||||
* - /admin/billing/generate — manual invoice generator (testing)
|
||||
* - /admin/billing/invoices — invoice list/detail
|
||||
*
|
||||
* The Phase 2 customer-side /billing landing page is added in
|
||||
* Phase 3.
|
||||
*/
|
||||
export default async function AdminBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
// Sweep open invoices past due → 'overdue' so the counters below
|
||||
// reflect reality without needing a cron.
|
||||
await syncOverdueInvoices().catch((e) =>
|
||||
console.error("syncOverdueInvoices failed:", e)
|
||||
);
|
||||
const balances = await getOrgOpenBalances().catch(() => []);
|
||||
const totalOpen = balances.reduce((acc, b) => acc + b.totalOpenChf, 0);
|
||||
const totalOverdue = balances.reduce((acc, b) => acc + b.overdueCount, 0);
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats strip */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-1">
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("totalOpenBalance")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">
|
||||
CHF {totalOpen.toFixed(2)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("orgsWithBalance")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">{balances.length}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-xs text-text-muted">{t("overdueInvoices")}</div>
|
||||
<div className="text-2xl font-semibold mt-1">
|
||||
{totalOverdue > 0 ? (
|
||||
<span className="text-error">{totalOverdue}</span>
|
||||
) : (
|
||||
totalOverdue
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sub-tool cards */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||
<Link href="/admin/billing/pricing">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("pricingDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/generate">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("generateTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("generateDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/admin/billing/invoices">
|
||||
<Card interactive>
|
||||
<div className="font-semibold mb-1">{t("invoicesTitle")}</div>
|
||||
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Orgs with open balance */}
|
||||
{balances.length > 0 && (
|
||||
<div className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2">{t("orgIdCol")}</th>
|
||||
<th className="pb-2 text-right">{t("openCountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("overdueCountCol")}</th>
|
||||
<th className="pb-2 text-right">{t("totalOpenCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{balances.map((b) => (
|
||||
<tr key={b.zitadelOrgId} className="border-t border-border">
|
||||
<td className="py-2 font-mono text-xs">{b.zitadelOrgId}</td>
|
||||
<td className="py-2 text-right">{b.openCount}</td>
|
||||
<td className="py-2 text-right">
|
||||
{b.overdueCount > 0 ? (
|
||||
<span className="text-error">{b.overdueCount}</span>
|
||||
) : (
|
||||
<span className="text-text-muted">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
CHF {b.totalOpenChf.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal file
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getPlatformPricing, listSkillPricing } from "@/lib/db";
|
||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { PricingEditor } from "@/components/admin/billing/pricing-editor";
|
||||
|
||||
/**
|
||||
* /admin/billing/pricing — edit platform-wide pricing config
|
||||
* (monthly fee, setup fee, Threema per-message, VAT rate for
|
||||
* CH/LI) and per-skill daily prices.
|
||||
*
|
||||
* Single-row platform_pricing semantics: one global pricing
|
||||
* config applies to every tenant. No per-tenant overrides in
|
||||
* v1.
|
||||
*/
|
||||
export default async function AdminBillingPricingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user.isPlatform) redirect("/dashboard");
|
||||
const t = await getTranslations("adminBilling");
|
||||
|
||||
const [pricing, skillPricing] = await Promise.all([
|
||||
getPlatformPricing(),
|
||||
listSkillPricing(),
|
||||
]);
|
||||
|
||||
// Surface every package in the catalog so admin can price any of
|
||||
// them — UI defaults the picker to skill-kind entries but doesn't
|
||||
// hard-block other kinds (a future scenario where a non-skill
|
||||
// package gets a per-day price shouldn't need a code change).
|
||||
const catalog = Object.values(PACKAGE_CATALOG).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
kind: p.kind,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("pricingTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("pricingPageDesc")}</p>
|
||||
</div>
|
||||
<PricingEditor
|
||||
initialPricing={pricing}
|
||||
initialSkillPricing={skillPricing}
|
||||
catalog={catalog}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -32,12 +32,20 @@ export default async function AdminPage() {
|
||||
{/* Sub-tools: links to other admin pages. Plain links rather
|
||||
than nav-shell entries — these are platform-team utilities,
|
||||
not main navigation. */}
|
||||
<a
|
||||
href="/admin/openclaw"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("openclawTool")}
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/admin/billing"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("billingTool")}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/openclaw"
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||
>
|
||||
{t("openclawTool")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="animate-in animate-in-delay-1">
|
||||
|
||||
Reference in New Issue
Block a user