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:
@@ -27,6 +27,8 @@ import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
|
||||
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
import { GettingStarted } from "@/components/trial/getting-started";
|
||||
import { ProfiHint } from "@/components/plans/profi-hint";
|
||||
import { effectiveModules } from "@/server/plan";
|
||||
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
|
||||
@@ -54,8 +56,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
||||
const t = await getTranslations("dashboard");
|
||||
const tw = await getTranslations("workOrders");
|
||||
|
||||
const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } });
|
||||
const moduleEnabled = !moduleRow || moduleRow.enabled;
|
||||
// L17 Pakete: effektive Module (Schalter + Paketstufe) zentral
|
||||
const modules = await effectiveModules(ctx.tenantId);
|
||||
const moduleEnabled = !modules.inactive.has("work_orders");
|
||||
|
||||
const f = parseListParams(sp);
|
||||
const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority };
|
||||
@@ -72,6 +75,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
||||
{t("moduleDisabled")}
|
||||
</p>
|
||||
)}
|
||||
{sp.module === "profi" && <ProfiHint />} {/* L17 Pakete: Funktion nicht in der Paketstufe */}
|
||||
|
||||
{!tiles ? (
|
||||
<p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p>
|
||||
@@ -148,7 +152,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
||||
</details>
|
||||
|
||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || can("billing:read"))).map(({ key, icon: Icon, tone }) => {
|
||||
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || (can("billing:read") && !modules.inactive.has("billing"))) && (tile.key !== "emergency_new" || !modules.inactive.has("emergency"))).map(({ key, icon: Icon, tone }) => {
|
||||
const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
|
||||
const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { signOut } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requireAppAccess } from "@/server/app-access";
|
||||
import { effectiveModules } from "@/server/plan";
|
||||
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
|
||||
import { resolveTenantBranding } from "@/lib/brand";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -30,17 +31,18 @@ export default async function AppLayout({
|
||||
|
||||
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
|
||||
// Seiten/Actions prüfen serverseitig selbst).
|
||||
const [moduleRows, brandingSettings] = await Promise.all([
|
||||
dbForTenant(session.user.tenantId).tenantModule.findMany(),
|
||||
// L17 Pakete: effektive Module (TenantModule + Paketstufe) zentral aus src/server/plan.ts
|
||||
const [modules, brandingSettings] = await Promise.all([
|
||||
effectiveModules(session.user.tenantId),
|
||||
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
|
||||
where: { tenantId: session.user.tenantId },
|
||||
select: { accent: true },
|
||||
}),
|
||||
]);
|
||||
const branding = resolveTenantBranding(brandingSettings);
|
||||
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
||||
const navItems = visibleNavItems(NAV_ITEMS, {
|
||||
disabledModules,
|
||||
disabledModules: modules.inactive,
|
||||
lockedFeatures: modules.lockedFeatures,
|
||||
permissions: session.user.permissions ?? [],
|
||||
});
|
||||
const mainNav = navItems.filter((i) => i.section === "main");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
import { requirePlanFeature } from "@/server/plan";
|
||||
|
||||
/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */
|
||||
/** Modul-Gate „work_orders" + Paket-Feature „planning" (L17) für Plantafel und Live-Lage (L13 Planung). */
|
||||
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
await requireModule("work_orders");
|
||||
const session = await requireModule("work_orders");
|
||||
await requirePlanFeature(session.user.tenantId, "planning"); // L17 Pakete: Planung nur in Profi
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { LotseMark } from "@/components/lotse/lotse-mark";
|
||||
import { LotseSeatAdmin } from "@/components/lotse/chat/seat-admin";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { saveLotseSettings } from "@/server/actions/lotse-settings";
|
||||
@@ -24,7 +25,7 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off
|
||||
* /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on
|
||||
* which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling).
|
||||
*/
|
||||
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) {
|
||||
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string; seat?: string; seatError?: string }> }) {
|
||||
const ctx = await readCtx();
|
||||
if (!can(ctx, "tenant:manage")) redirect("/dashboard");
|
||||
const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]);
|
||||
@@ -159,6 +160,9 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* L17 Pakete: Paket (nur lesend), Lotse-Chat-Plätze, Monatsverbrauch */}
|
||||
<LotseSeatAdmin ctx={ctx} seatSaved={sp.seat === "saved"} seatError={sp.seatError} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BRAND } from "@/lib/brand";
|
||||
import { updateTenantSettings } from "@/server/actions/tenant-settings";
|
||||
import { ShieldCheck, History } from "lucide-react";
|
||||
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
||||
import { effectiveModules } from "@/server/plan";
|
||||
|
||||
// TODO(i18n): Texte dieser Seite in messages/<locale>/settings.json überführen
|
||||
// (aus dem Fundament übernommen; nicht Teil des Rückbaus).
|
||||
@@ -28,9 +29,9 @@ export default async function SettingsPage({
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const sp = await searchParams;
|
||||
|
||||
const [s, moduleRows] = await Promise.all([
|
||||
const [s, modules] = await Promise.all([
|
||||
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
|
||||
db.tenantModule.findMany(),
|
||||
effectiveModules(session.user.tenantId), // L17 Pakete: Schalter + Paketstufe
|
||||
]);
|
||||
|
||||
// Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten-
|
||||
@@ -50,7 +51,7 @@ export default async function SettingsPage({
|
||||
}
|
||||
}
|
||||
// Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an).
|
||||
const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
||||
const disabled = modules.inactive;
|
||||
const v = (x?: string | null) => x ?? "";
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { requireModule } from "@/server/modules";
|
||||
import { moduleState } from "@/server/plan";
|
||||
import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
|
||||
|
||||
/** Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx. */
|
||||
/**
|
||||
* Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx.
|
||||
* L17 Pakete: nicht in der Paketstufe (Basis) → freundlicher Hinweis statt Weiterleitung aufs Dashboard.
|
||||
*/
|
||||
export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const session = await requireSession();
|
||||
if ((await moduleState(session.user.tenantId, "lotse")) === "not_in_plan") return <LotseChatUnavailable reason="not_in_plan" />;
|
||||
await requireModule("lotse");
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ChevronLeft, CircleSlash } from "lucide-react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import type { ChatView } from "@/lib/lotse/chat";
|
||||
import { isAiConfigured } from "@/server/ai/client";
|
||||
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { fieldPageContext } from "@/server/services/field/page-context";
|
||||
import { isChatQuotaBlocked } from "@/server/services/lotse/chat/access";
|
||||
import { getChatView } from "@/server/services/lotse/chat/conversations";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { LotseMark } from "@/components/lotse/lotse-mark";
|
||||
import { LotseChat } from "@/components/lotse/chat/lotse-chat";
|
||||
import { card } from "@/components/field/ui";
|
||||
import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
|
||||
|
||||
/**
|
||||
* `/m/lotse` — Lotse chat for technicians (lane L16). `?order=<id>` opens the chat with order context
|
||||
@@ -27,24 +28,11 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
|
||||
if (!(err instanceof ServiceError)) throw err;
|
||||
if (err.code === "not_found") notFound();
|
||||
const reason = (err.details as { reason?: string } | undefined)?.reason;
|
||||
const code = reason === "disabled" || reason === "chat_disabled" ? reason : err.code === "forbidden" ? "forbidden" : "generic";
|
||||
return (
|
||||
<main className="space-y-3 p-4">
|
||||
<section className={card}>
|
||||
<h1 className="flex items-center gap-2 text-[20px]">
|
||||
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
|
||||
{t("chat.unavailable.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px]">{t(`chat.errors.${code}`)}</p>
|
||||
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
|
||||
{t("chat.unavailable.back")}
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
const code = reason && ["disabled", "chat_disabled", "not_in_plan", "no_seat"].includes(reason) ? reason : err.code === "forbidden" ? "forbidden" : "generic";
|
||||
return <LotseChatUnavailable reason={code} />;
|
||||
}
|
||||
|
||||
const timeZone = await tenantTimezone(ctx);
|
||||
const [timeZone, quotaExhausted] = await Promise.all([tenantTimezone(ctx), isChatQuotaBlocked(ctx)]); // L17: hard limit → hint
|
||||
return (
|
||||
<main className="space-y-3 p-4">
|
||||
{view.workOrder && (
|
||||
@@ -64,6 +52,7 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
|
||||
transcription={transcriptionConfig().configured}
|
||||
locale={locale}
|
||||
timeZone={timeZone}
|
||||
quotaExhausted={quotaExhausted}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fieldPageContext } from "@/server/services/field/page-context";
|
||||
import { getMyActiveSession } from "@/server/services/field/sessions";
|
||||
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
|
||||
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
|
||||
import { isModuleActive } from "@/server/plan";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||
@@ -16,20 +17,26 @@ import { OfflineRuntime } from "@/components/offline/offline-runtime";
|
||||
import { TrialBanner } from "@/components/trial/trial-banner";
|
||||
|
||||
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
|
||||
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> {
|
||||
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean; emergency: boolean }> {
|
||||
try {
|
||||
const ctx = await fieldPageContext();
|
||||
const [session, approvals, lotseChat] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx), canUseLotseChat(ctx)]);
|
||||
const [session, approvals, lotseChat, emergency] = await Promise.all([
|
||||
can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null),
|
||||
countPendingTimeEntries(ctx),
|
||||
canUseLotseChat(ctx),
|
||||
isModuleActive(ctx.tenantId, "emergency"), // L17 Pakete: Notdienst nur in Profi
|
||||
]);
|
||||
return {
|
||||
clock: session
|
||||
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
|
||||
: null,
|
||||
approvals,
|
||||
lotseChat, // L16
|
||||
lotseChat, // L16 (L17: inkl. Chat-Platz)
|
||||
emergency,
|
||||
};
|
||||
} catch {
|
||||
// module "field" disabled or no field permissions: shell without clock/badge
|
||||
return { clock: null, approvals: 0, lotseChat: false };
|
||||
return { clock: null, approvals: 0, lotseChat: false, emergency: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +67,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
|
||||
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
|
||||
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
|
||||
<RunningClockBar initial={time.clock} />
|
||||
<BottomNav approvals={time.approvals} lotse={time.lotseChat} />
|
||||
<BottomNav approvals={time.approvals} lotse={time.lotseChat} emergency={time.emergency} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { platformAuth } from "@/server/platform-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { MODULES } from "@/lib/modules";
|
||||
import { effectiveTier, isModuleInTier } from "@/lib/plans";
|
||||
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
|
||||
import { resolveMfaRequired } from "@/lib/mfa-policy";
|
||||
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
|
||||
@@ -20,6 +21,7 @@ import { UserCreateForm, UserEditForm } from "@/components/user-forms";
|
||||
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
||||
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
|
||||
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
|
||||
import { PlanAdminCard } from "@/components/plans/plan-admin-card";
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
|
||||
@@ -28,7 +30,7 @@ export default async function AdminTenantPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string }>;
|
||||
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string; plan?: string; planDone?: string }>;
|
||||
}) {
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const { id } = await params;
|
||||
@@ -58,6 +60,9 @@ export default async function AdminTenantPage({
|
||||
const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null;
|
||||
const base = `/admin/${tenant.id}`;
|
||||
|
||||
// L17 Pakete: Module außerhalb der Paketstufe wirken trotz Schalter wie deaktiviert
|
||||
const tierNow = effectiveTier(tenant.tier, tenant.plan);
|
||||
const tp = await getTranslations("plans");
|
||||
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
|
||||
const isOn = (key: string) => moduleState.get(key) ?? true;
|
||||
|
||||
@@ -226,6 +231,11 @@ export default async function AdminTenantPage({
|
||||
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
|
||||
/>
|
||||
|
||||
{/* L17 Pakete: Stufe, Lotse-Chat-Plätze, hartes Limit, Verbrauch (Grundlage der Rechnung) */}
|
||||
{platformSession?.user?.id && (
|
||||
<PlanAdminCard tenantId={tenant.id} platformAdminId={platformSession.user.id} isFullAdmin={isFullAdmin} base={base} edit={sp.plan === "edit"} done={!!sp.planDone} />
|
||||
)}
|
||||
|
||||
{/* Lebenszyklus */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
|
||||
@@ -342,6 +352,7 @@ export default async function AdminTenantPage({
|
||||
<p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isModuleInTier(tierNow, m.key) && <Pill tone="warn">{tp("notInTier")}</Pill>}
|
||||
<Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill>
|
||||
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
|
||||
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
|
||||
|
||||
@@ -27,6 +27,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
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([
|
||||
@@ -124,6 +125,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
<TableHead>Kürzel</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>{tt("colPlan")}</TableHead>
|
||||
<TableHead>{tp("colTier")}</TableHead>
|
||||
<TableHead>Nutzer</TableHead>
|
||||
<TableHead>Aktive Module</TableHead>
|
||||
</TableRow>
|
||||
@@ -140,6 +142,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user