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
+7 -3
View File
@@ -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 (
+6 -4
View File
@@ -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");
+4 -2
View File
@@ -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}</>;
}
+5 -1
View File
@@ -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>
);
}
+4 -3
View File
@@ -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 (
+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>
+9 -1
View File
@@ -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}</>;
}
+7 -18
View File
@@ -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>
);
+12 -5
View File
@@ -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>
);
}
+12 -1
View File
@@ -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>
+4
View File
@@ -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>