- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
7.3 KiB
TypeScript
160 lines
7.3 KiB
TypeScript
import Link from "next/link";
|
|
import { Plus } from "lucide-react";
|
|
import { prisma } from "@/server/db";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { createTenant } from "@/server/actions/admin";
|
|
import { getMailStatus } from "@/server/actions/mail";
|
|
import { MailStatusPanel } from "@/components/mail-status-panel";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { TrialBadge } from "@/components/trial/trial-badge";
|
|
|
|
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
|
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
|
|
|
|
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string; plan?: string }> }) {
|
|
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
|
const params = await searchParams;
|
|
// L15 Testphase: Filter Test/Voll
|
|
const tt = await getTranslations("trial.platform");
|
|
const tp = await getTranslations("plans");
|
|
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
|
|
|
|
const [tenants, mailStatus] = await Promise.all([
|
|
prisma.tenant.findMany({
|
|
where: planFilter ? { plan: planFilter } : undefined,
|
|
include: { _count: { select: { users: true } }, modules: true },
|
|
orderBy: { createdAt: "asc" },
|
|
}),
|
|
// SEC1: Betriebszustand der Mail-Strecke + Testversand.
|
|
getMailStatus(),
|
|
]);
|
|
|
|
return (
|
|
<main className="flex-1 p-6">
|
|
<PageHead
|
|
crumb="Plattform-Betrieb"
|
|
title="Admin-Konsole — Mandantenverwaltung"
|
|
sub="Kunden (Mandanten) anlegen, provisionieren, Module & Lebenszyklus verwalten"
|
|
actions={
|
|
<span className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
|
|
<Button variant="outline" nativeButton={false} render={<Link href="/admin/trial" />}>{tt("newTrial")}</Button>
|
|
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
|
|
<Plus className="size-4" /> Neuer Kunde
|
|
</Button>
|
|
</span>
|
|
}
|
|
/>
|
|
|
|
{params.new && (
|
|
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
|
|
<p className="mb-3 font-heading text-sm font-semibold">Neuen Kunden anlegen</p>
|
|
<form action={createTenant} className="grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<Label htmlFor="name">Firmenname *</Label>
|
|
<Input id="name" name="name" required className="mt-1" placeholder="Musterbau GmbH" />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="short">Kurzname</Label>
|
|
<Input id="short" name="short" className="mt-1" placeholder="Muster" />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="slug">Kürzel/Slug (optional)</Label>
|
|
<Input id="slug" name="slug" className="mt-1" placeholder="wird aus Name erzeugt" />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="sector">Gewerk / Branche</Label>
|
|
<Input id="sector" name="sector" className="mt-1" placeholder="z. B. Sanitär, Heizung, Klima" />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="adminEmail">Admin-E-Mail *</Label>
|
|
<Input id="adminEmail" name="adminEmail" type="email" required className="mt-1" placeholder="admin@betrieb.example" />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="adminName">Admin-Name</Label>
|
|
<Input id="adminName" name="adminName" className="mt-1" placeholder="Vor- und Nachname" />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="adminPassword">Initial-Passwort * (min. 8)</Label>
|
|
<Input id="adminPassword" name="adminPassword" type="text" required className="mt-1" placeholder="wird dem Admin mitgeteilt" />
|
|
</div>
|
|
<div className="flex gap-2 md:col-span-2">
|
|
<Button type="submit">Kunde anlegen & provisionieren</Button>
|
|
<Button variant="outline" nativeButton={false} render={<Link href="/admin" />}>Abbrechen</Button>
|
|
</div>
|
|
</form>
|
|
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
|
Beim Anlegen werden automatisch ausgerollt: Standard-Rollen (Mandantenadministrator, Backoffice, Teamleiter, Monteur), erster Admin-User, alle Module und Mandanten-Einstellungen (idempotent, protokolliert).
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<nav aria-label={tt("filter")} className="mt-4 flex flex-wrap gap-2">
|
|
{[
|
|
{ key: null, href: "/admin", label: tt("filterAll") },
|
|
{ key: "TRIAL", href: "/admin?plan=trial", label: tt("filterTrial") },
|
|
{ key: "FULL", href: "/admin?plan=full", label: tt("filterFull") },
|
|
].map((f) => (
|
|
<Link
|
|
key={f.href}
|
|
href={f.href}
|
|
aria-current={planFilter === f.key ? "page" : undefined}
|
|
className={`inline-flex min-h-11 items-center rounded-full border px-4 text-[13px] font-semibold ${planFilter === f.key ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]" : "text-muted-foreground hover:bg-muted"}`}
|
|
>
|
|
{f.label}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Kunde</TableHead>
|
|
<TableHead>Kürzel</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>{tt("colPlan")}</TableHead>
|
|
<TableHead>{tp("colTier")}</TableHead>
|
|
<TableHead>Nutzer</TableHead>
|
|
<TableHead>Aktive Module</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{tenants.map((t) => {
|
|
const active = t.modules.filter((m) => m.enabled).length;
|
|
return (
|
|
<TableRow key={t.id}>
|
|
<TableCell>
|
|
<Link href={`/admin/${t.id}`} className="font-bold hover:underline">{t.name}</Link>
|
|
{t.sector && <div className="text-xs text-muted-foreground">{t.sector}</div>}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
|
|
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
|
|
<TableCell><TrialBadge tenant={t} /></TableCell>
|
|
{/* L17 Pakete: Stufe als Badge (Testphase läuft immer als Profi) */}
|
|
<TableCell><Pill tone={t.tier === "PROFI" ? "ok" : "mut"}>{tp(`tier.${t.tier}`)}</Pill></TableCell>
|
|
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
|
|
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<MailStatusPanel status={mailStatus} />
|
|
</main>
|
|
);
|
|
}
|