L17 Pakete: Stufen Basis/Profi, Lotse-Chat-Plätze und Kontingent

- 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>
This commit is contained in:
2026-09-21 10:13:27 +02:00
co-authored by Claude Opus 5
parent 4d5be72ebb
commit 06d320908f
60 changed files with 2076 additions and 91 deletions
+4 -5
View File
@@ -29,7 +29,7 @@ import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { reasonRequired } from "@/server/services/work-orders/transition";
import { billingModuleActive, reasonRequired } from "@/server/services/work-orders/transition";
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
type Tab = (typeof TABS)[number];
@@ -103,8 +103,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
]);
// L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button
const billingModule = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } });
const billingOverviewActive = !billingModule || billingModule.enabled;
const billingOverviewActive = await billingModuleActive(ctx); // L17: incl. package tier
const billingTransitions = transitions.filter(
(to) =>
(to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) ||
@@ -188,7 +187,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
<div className="mt-4">
<LinkTabs
label={t("detail.tabs.overview")}
items={TABS.map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
items={TABS.filter((k) => k !== "billing" || billingOverviewActive).map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
/>
</div>
@@ -211,7 +210,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
}
/>
)}
{tab === "billing" && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
{tab === "billing" && billingOverviewActive && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
{tab === "history" && <HistoryTab {...tabProps} />}
</div>