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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user