- 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>
79 lines
3.4 KiB
TypeScript
79 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import { useActionState } from "react";
|
|
import Link from "next/link";
|
|
import { useTranslations } from "next-intl";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { TENANT_TIERS, type TenantTier } from "@/lib/plans";
|
|
import type { PlatformPlanState } from "@/server/actions/plans-platform";
|
|
|
|
/** L17 Pakete: operator form (tier, seats, hard limit) with mandatory confirmation checkbox. */
|
|
export function PlanForm({
|
|
action,
|
|
tier,
|
|
seats,
|
|
hardLimit,
|
|
maxSeats,
|
|
closeHref,
|
|
}: {
|
|
action: (prev: PlatformPlanState, fd: FormData) => Promise<PlatformPlanState>;
|
|
tier: TenantTier;
|
|
seats: number;
|
|
hardLimit: boolean;
|
|
maxSeats: number;
|
|
closeHref: string;
|
|
}) {
|
|
const t = useTranslations("plans.platform");
|
|
const tt = useTranslations("plans");
|
|
const [state, formAction, pending] = useActionState<PlatformPlanState, FormData>(action, { status: "idle" });
|
|
return (
|
|
<form action={formAction} className="space-y-4">
|
|
<fieldset>
|
|
<legend className="text-sm font-semibold">{t("tier")}</legend>
|
|
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
|
{TENANT_TIERS.map((v) => (
|
|
<label key={v} className="flex min-h-11 cursor-pointer items-start gap-2 rounded-lg border p-3 text-sm has-[:checked]:border-[var(--ui-primary)] has-[:checked]:font-semibold">
|
|
<input type="radio" name="tier" value={v} defaultChecked={tier === v} className="mt-0.5 size-4" />
|
|
<span>
|
|
<span className="block">{tt(`tier.${v}`)}</span>
|
|
<span className="block text-[12px] font-normal text-muted-foreground">{t(`tierHint.${v}`)}</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<p className="mt-2 text-[12px] text-muted-foreground">{t("downgradeHint")}</p>
|
|
</fieldset>
|
|
<div className="max-w-xs">
|
|
<Label htmlFor="plan-seats">{t("seatsInput")}</Label>
|
|
<Input id="plan-seats" name="lotseChatSeats" type="number" inputMode="numeric" min={0} max={maxSeats} step={1} required defaultValue={seats} className="mt-1 h-11" />
|
|
<p className="mt-1 text-[12px] text-muted-foreground">{t("seatsHint")}</p>
|
|
</div>
|
|
<label className="flex min-h-11 items-start gap-2 text-sm">
|
|
<input type="checkbox" name="lotseChatHardLimit" defaultChecked={hardLimit} className="mt-0.5 size-5" />
|
|
<span>
|
|
<span className="block font-semibold">{t("hardLimitInput")}</span>
|
|
<span className="block text-[12px] text-muted-foreground">{t("hardLimitHint")}</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex min-h-11 items-center gap-2 text-sm">
|
|
<input type="checkbox" name="confirm" className="size-5" required /> {t("confirm")}
|
|
</label>
|
|
{state.status === "error" && (
|
|
<p role="alert" className="rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
|
|
{t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.failed")}
|
|
</p>
|
|
)}
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="submit" className="h-11" disabled={pending}>
|
|
{pending ? t("submitting") : t("submit")}
|
|
</Button>
|
|
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href={closeHref} />}>
|
|
{t("close")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|