Skip to content

03 — Platform Admin Specification

Route root: /[locale]/platform-admin/* · Access: RestaurantOS internal staff only (super-admin RBAC) · Status: ✅ UI fully built on mock/localStorage data

🟢 বাংলা ভূমিকা: এই ডকুমেন্টে Platform Admin কনসোলের প্রতিটি মডিউল বিস্তারিতভাবে বর্ণনা করা হয়েছে — Tenants, Subscriptions/Plan Builder, Feature Flags, VIP Overrides, Automated Invoicing, Impersonation, Audit Log, Users, Support, Roadmap।


1. Module Map

ModuleRoutePrimary component
Overview dashboard/platform-adminoverview-dashboard.tsx
Tenants/platform-admin/tenantstenants-table.tsx
Tenant detail/platform-admin/tenants/[tenantSlug]tenant-detail.tsx
Subscriptions & Plan Builder/platform-admin/subscriptionsplan-builder.tsx + subscriptions-invoices.tsx
Feature flags/platform-admin/feature-flagsfeature-flags-board.tsx
Platform users/platform-admin/usersusers-board.tsx
Audit log/platform-admin/audit-logaudit-log-center.tsx
Notifications/platform-admin/notificationsnotification-center.tsx
Support inbox/platform-admin/supportsupport-inbox.tsx
Roadmap showcase/platform-admin/roadmaproadmap-showcase.tsx
Settings/platform-admin/settingsplatform-settings-panel.tsx
Global search(all pages, shell-level)platform-search.tsx

🟢 বাংলা সারাংশ: Platform Admin কনসোলে মোট ১১টি মূল মডিউল আছে — প্রতিটির নিজস্ব রুট এবং প্রধান কম্পোনেন্ট আছে, যা উপরের টেবিলে লিস্ট করা হয়েছে।


2. Tenants Management

2.1 Tenants list (tenants-table.tsx)

  • Search across name, cuisine, slug, custom domain (debounced 220 ms).
  • Status filter tabs: All / Active / Trial / Suspended / Churned, each showing a live count badge.
  • Responsive: a data table on desktop, card list on mobile (useIsMobile).
  • "Add tenant" modal — mock onboarding form (name, cuisine, domain, plan). On submit:
    1. Calls useBillingOverrides().generateInvoice({ tenant, planId, amount, reason: "registration" }), which auto-marks the invoice paid and stamps a mock emailSentAt.
    2. Logs a "Registered tenant" entry via usePlatformAuditLog.
    3. Shows a toast confirming the plan + generated invoice ID.
  • "View as" quick action — every row (desktop hover button + mobile card button) has an Eye icon that immediately opens /{locale}/tenants/{slug}/dashboard?impersonate=1 in the current tab (see §6 Impersonation).

🟢 বাংলা সারাংশ: Tenants লিস্ট পেজে সার্চ, স্ট্যাটাস ফিল্টার, এবং "Add tenant" মডাল আছে — নতুন tenant যোগ করলে স্বয়ংক্রিয়ভাবে একটা registration invoice তৈরি হয় ও অডিট লগে এন্ট্রি হয়। প্রতিটি সারিতে একটা "View as" বাটন আছে যা এক ক্লিকে সেই রেস্টুরেন্টের ড্যাশবোর্ড impersonation মোডে খোলে।

2.2 Tenant detail (tenant-detail.tsx)

Sections rendered top to bottom:

  1. Header — name, logo, status badge, plan badge, VIP badge (if any override active), "Feature access" quick-link (/platform-admin/feature-flags?tenant={slug}), "View as restaurant" button.
  2. Plan card — current effective plan (seed plan or Plan-Builder custom plan), VIP-adjusted price (strikethrough original if a discount/free-lifetime override is active), a plan-change <Select> populated from usePlanBuilder().getAllPlans(seedPlans).
  3. VIP override panel — Grant/Edit/Remove buttons opening a modal to set type: "free-lifetime" | "percent-discount" | "fixed-price", an optional numeric value, and a note.
  4. Invoices list — every invoice for this tenant (seed + auto-generated), each row with Download-PDF and Email/"Emailed" badge actions.
  5. Activity/metrics — orders today, tables, rating, MRR contribution.

Data model dependency:

interface Tenant {
  id: string; slug: string; name: string; cuisine: string;
  plan: TenantPlan;              // "starter" | "growth" | "scale" | "enterprise" — the ORIGINAL seed plan
  status: TenantStatus;          // "active" | "trial" | "suspended" | "churned"
  customDomain?: string;
  branding: TenantBranding;       // logoToken, logo?, accent, coverToken, coverImage?, tagline, layoutWidth
  address: TenantAddress;
  currency: string; currencySymbol: string; timezone: string;
  createdAt: string; mrr: number; tablesCount: number; ordersToday: number; rating: number;
  branches?: Branch[];
}

The tenant's effective plan (which may be a custom Plan-Builder plan id, not part of the TenantPlan union) is tracked separately from Tenant.plan via useBillingOverrides().planOverrides — the seed field always represents "what they originally signed up as," while the override represents "what they're on right now." This mirrors how VIP pricing, invoice status, and feature overrides are all layered on top of static seed data rather than mutating it in place.

🟢 বাংলা সারাংশ: Tenant Detail পেজে প্ল্যান পরিবর্তন, VIP override (ফ্রি লাইফটাইম/ছাড়/কাস্টম প্রাইস), ইনভয়েস তালিকা (PDF ডাউনলোড + ইমেইল), এবং impersonation বাটন আছে। গুরুত্বপূর্ণ ডিজাইন সিদ্ধান্ত: Tenant.plan ফিল্ডটা কখনো পরিবর্তন হয় না (এটা তাদের original signup প্ল্যান রেকর্ড রাখে) — বর্তমান কার্যকর প্ল্যান আলাদাভাবে একটা override ম্যাপে রাখা হয়, ঠিক যেমন VIP প্রাইসিং বা ফিচার override আলাদা লেয়ারে থাকে।


3. Dynamic Pricing Plan Builder

Component: plan-builder.tsx · Hook: use-plan-builder.ts

3.1 Data model

interface SubscriptionPlanDef {
  id: string;                 // seed plans reuse "starter"/"growth"/"scale"/"enterprise"; custom plans get "plan_<nanoid>"
  name: string;
  price: number;               // 0 = "Custom" / contact-sales pricing
  billingCycle: "monthly" | "yearly";
  description: string;
  features: string[];          // free-form marketing copy lines shown on the pricing card
  isPopular?: boolean;
  maxTables: number;           // 999 = unlimited
  maxStaff: number;            // 999 = unlimited
  isCustom?: boolean;          // true for Plan-Builder-created plans
  featureFlagIds?: string[];   // FeatureFlag.id[] picked from the catalog — see §4.3 for whitelist semantics
}

3.2 CRUD flow

  • Create — createPlan(payload) generates a plan_* id, stores it in a customPlans localStorage array.
  • Edit — updatePlan(id, patch) writes to a separate edits: Record<id, Partial<SubscriptionPlanDef>> overlay for seed plans (so the original 4 tiers stay in the codebase untouched, editable only via this overlay), and also patches customPlans directly for plans created via the builder (a harmless redundant merge — getAllPlans re-applies edits onto both seed and custom lists regardless of origin).
  • Archive (soft-delete) — deletePlan(id) adds the id to a deletedIds array; getAllPlans filters these out. Tenants already on an archived plan are unaffected — archiving only hides it from new assignments.
  • Restore — restorePlan(id) reverses the above.
  • Merge function — getAllPlans(seed) = [...seed, ...customPlans].map(applyEdits).filter(notDeleted).

3.3 Feature list: catalog picker + manual entry

The "Feature list" editor inside the create/edit modal offers two complementary ways to populate a plan's features:

  1. "Add from catalog" — a dropdown (DropdownMenu + DropdownMenuCheckboxItem), grouped by FeatureFlag.category (Ordering, Growth, Communications, Operations, Analytics, Platform, Security — the same categories used on the Feature Flags board), pulling from the single source-of-truth list in src/lib/mock-data/feature-flags.ts. An "All features" master checkbox at the top selects/deselects every catalog item in one click. Checking an item appends both its display label (to features[], for the pricing card copy) and its id (to featureFlagIds[], for enforcement — see §4.3).
  2. "Manual entry" — a free-text row for bespoke marketing copy (e.g. "Dedicated success manager") that doesn't correspond to any real toggleable flag and therefore doesn't add anything to featureFlagIds.

The trap this creates, and the guard against it: the two columns do different jobs — features[] is what the pricing card says, featureFlagIds[] is what the gate reads. Typing "Inventory Management" by hand puts the promise on the card and grants nothing, so the plan sells a section the restaurant then cannot open. A manual line whose text exactly matches a catalog flag's label now shows an inline warning — "This matches the catalog feature X but doesn't grant it — it is copy only" — with a one-click "Grant it" that adds the id. Exact-label matching does not catch everything (someone can write "Inventory tracking" and hit the same wall), but it catches what a hurried admin actually produces, because the label they type is the label they just read in the catalog.

Both approaches populate the same features: string[] array shown on the pricing card; only catalog picks additionally populate featureFlagIds and therefore participate in plan-based feature restriction.

🟢 বাংলা সারাংশ: প্রতিটি প্ল্যানের ফিচার লিস্ট দুইভাবে বানানো যায় — (১) "Add from catalog": প্ল্যাটফর্মের পুরো ফিচার ক্যাটালগ থেকে ক্যাটাগরি অনুযায়ী চেকবক্স দিয়ে বাছাই (একটা "All features" বাটন দিয়ে সব একসাথেও নেওয়া যায়), অথবা (২) "Manual entry": নিজের মতো কাস্টম টেক্সট লাইন টাইপ করে। Catalog থেকে বাছাই করা ফিচারগুলোর id আলাদাভাবে সেভ থাকে যা পরে ব্যবহার হয় tenant-level ফিচার এনফোর্সমেন্টে (নিচে দেখুন)।


4. Feature Flags (Global + Tenant-Scoped + Plan Whitelist)

Component: feature-flags-board.tsx · Hooks: use-tenant-feature-overrides.ts, use-plan-builder.ts, use-billing-overrides.ts

4.1 Data model

type FeatureFlagPhase = "MVP" | "P2" | "P3" | "P4";

interface FeatureFlag {
  id: string; label: string; description: string;
  category: string;             // "Ordering" | "Growth" | "Communications" | "Operations" | "Analytics" | "Platform" | "Security"
  enabled: boolean;              // GLOBAL default
  rolloutPhase: FeatureFlagPhase;
}

/** Record<tenantSlug, Record<flagId, boolean>> — explicit per-tenant overrides */
type TenantFeatureOverrides = Record<string, Record<string, boolean>>;

4.2 Scope selector

A <Select> at the top of the board switches between:

  • "Global (all tenants)" — toggling here changes flag.enabled for the whole platform (in local component state today; a real UPDATE feature_flags SET enabled = ... once wired to Postgres).
  • A specific tenant (deep-linkable via ?tenant={slug}, e.g. from Tenant Detail's "Feature access" button) — toggling here writes to TenantFeatureOverrides[slug][flagId] via useTenantFeatureOverrides().setOverride, independent of the global default.

4.3 Resolution order (the whitelist rule)

User requirement codified in this system: "Whatever features I assign to a pricing plan, a restaurant only ever gets exactly those features — never more."

For a tenant-scoped view, a flag's effective on/off state is computed as:

function getTenantEffective(flag: FeatureFlag, tenant, scope): boolean {
  if (isOverridden(scope, flag.id)) {
    // 1. An explicit, admin-set per-tenant override always wins (support/VIP escape hatch).
    return getEffective(scope, flag.id, flag.enabled);
  }
  // 2. Otherwise: global default, narrowed to the tenant's current plan's catalog whitelist.
  const plan = getAllPlans(seedPlans).find(p => p.id === getEffectivePlan(tenant.slug, tenant.plan));
  const planIsUnrestricted = !plan?.featureFlagIds || plan.featureFlagIds.length === 0;
  const isIncludedInPlan = planIsUnrestricted || plan.featureFlagIds!.includes(flag.id);
  return flag.enabled && isIncludedInPlan;
}
  • If the tenant's current plan has a non-empty featureFlagIds (i.e. it was built/edited using the catalog picker), that list acts as a hard whitelist — a flag outside it renders off and shows a "Not in plan" badge, even if the flag is globally enabled.
  • If the plan's featureFlagIds is empty/undefined (true for the four original seed tiers, which predate the catalog feature and only carry free-text marketing copy), there is no restriction — every globally-enabled flag applies, preserving backward compatibility.
  • A disabled flag has to be declared. enabled: false is the global kill switch and it is invisible: it removes a feature from every restaurant and looks identical whether that was intended or forgotten. Three flags drifted into it while the work behind them was finished and shipping — ff-7 (delivery), ff-13 (analytics suite), ff-11 (food costing) — each hiding a working screen from every tenant, each found only because somebody happened to look. A prose warning after the first one did not stop the next two. So UNBUILT_FEATURE_IDS in feature-flags.ts now lists the flags deliberately switched off, and a test asserts it matches the disabled set exactly, in both directions. It cannot tell whether a feature is built — nothing can — but it makes the answer a sentence someone wrote: switching a flag off requires saying so, finishing the feature requires deleting the entry, and silence stops being an option. The list is currently empty.
  • What the restaurant sees when a section is gated. FeatureGate names the plans that would unlock it and links to pricing, instead of the dead end it used to be ("talk to us about upgrading", with nothing to click). Those plan names come from plansUnlocking() in src/lib/feature-resolution.ts, which filters the plan list through the very same resolveFeature the gate itself uses — so the screen can never recommend a plan that then fails to unlock the feature, and a plan edited in the Plan Builder changes the message without anyone remembering to. Two further cases are distinguished, because treating them alike misleads the owner: if the flag is globally off, no plan is named at all (no amount of money enables an unbuilt feature); and if the plan already includes it and the lock came from a per-tenant admin override, the screen says it was switched off for this restaurant and points at support rather than selling an upgrade that would change nothing.
  • Impersonation ("View as restaurant") resolves gating through the tenant's own plan, so an admin in view-as mode sees exactly the locked screens the owner sees — verified in the browser, since a "view as" that quietly showed more than the owner has would be worse than useless for support.
  • An admin can still manually flip a flag on for one tenant beyond their plan's whitelist (e.g. a support workaround); doing so is recorded as an explicit override, surfaced with an "Overridden" badge, a confirmation toast that explicitly warns "this feature isn't part of {tenant}'s current plan — this is a manual admin override," and is reversible via a "Reset" link that deletes the override so the tenant snaps back to their plan-derived default.

🟢 বাংলা সারাংশ: যখন কোনো নির্দিষ্ট রেস্টুরেন্ট সিলেক্ট করা হয়, সিস্টেম প্রথমে চেক করে সেই tenant-এর জন্য কোনো ম্যানুয়াল override আছে কিনা — থাকলে সেটাই চূড়ান্ত। না থাকলে, তাদের বর্তমান প্ল্যানে (Plan Builder-এ ক্যাটালগ থেকে বাছাই করা featureFlagIds) সেই ফিচার আছে কিনা চেক হয় — না থাকলে global-এ enabled থাকলেও সেটা বন্ধ দেখাবে এবং "Not in plan" ব্যাজ দেখাবে। পুরনো ৪টা সিড প্ল্যানের কোনো featureFlagIds নেই বলে তাদের ক্ষেত্রে কোনো restriction প্রযোজ্য না (backward compatibility)। অ্যাডমিন চাইলে এখনও ম্যানুয়ালি override করতে পারবে, কিন্তু সেটা স্পষ্টভাবে চিহ্নিত ও রিসেটযোগ্য থাকবে। কোনো সেকশন lock থাকলে রেস্টুরেন্টকে এখন বলা হয় কোন প্ল্যানে সেটা আছে এবং pricing-এর লিংক দেওয়া হয় — নামগুলো একই resolveFeature দিয়ে বের করা, তাই ভুল প্ল্যান সুপারিশ হওয়া অসম্ভব। ফিচারটি globally বন্ধ থাকলে কোনো প্ল্যানের নাম দেখানো হয় না, আর lock যদি admin override থেকে আসে তাহলে upgrade নয়, support-এর কথা বলা হয়। এছাড়া কোনো flag enabled: false রাখতে হলে এখন UNBUILT_FEATURE_IDS-এ নাম লিখতে হয় — তিনবার (ff-7, ff-13, ff-11) সম্পূর্ণ বানানো ফিচার চুপচাপ সবার কাছ থেকে লুকিয়ে ছিল বলেই এই নিয়ম; একটা টেস্ট দুই দিক থেকেই মিল যাচাই করে।


5. VIP Billing Overrides

Hook: useBillingOverrides — vipOverrides, setVipOverride, clearVipOverride, getEffectivePrice

interface VipOverride {
  type: "free-lifetime" | "percent-discount" | "fixed-price";
  value?: number;   // percentage (0–100) for percent-discount, or absolute BDT amount for fixed-price — ignored for free-lifetime
  note?: string;    // internal reason, shown only to platform admins
  grantedAt: string;
}

getEffectivePrice(tenantSlug, basePrice) applies, in order: free-lifetime → 0; percent-discount → round(basePrice * (1 - value/100)); fixed-price → round(value ?? basePrice). The Tenant Detail plan card renders the original price struck through next to the discounted price whenever an override is active, plus a Grant/Edit/Remove control set gated behind a confirmation modal.

🟢 বাংলা সারাংশ: VIP override দিয়ে যেকোনো একটা tenant-কে বিশেষ বিলিং সুবিধা দেওয়া যায় — সম্পূর্ণ ফ্রি (লাইফটাইম), শতাংশ ছাড়, অথবা একটা নির্দিষ্ট কাস্টম প্রাইস। এটা প্রয়োগ হওয়ার পর ট্যাব-এ আগের দাম কেটে (strikethrough) নতুন দাম দেখানো হয়।


6. "View As Restaurant" (Impersonation)

Components: tenant-detail.tsx (entry button), impersonation-banner.tsx (persistent banner rendered inside the tenant dashboard shell)

Flow:

  1. Platform admin clicks "View as restaurant" (on Tenant Detail) or the row-level "View as" quick action (on the Tenants table).
  2. usePlatformAuditLog().logAction("Started impersonation", tenant.name, ...) records the action.
  3. Navigation goes to /{locale}/tenants/{slug}/dashboard?impersonate=1.
  4. impersonation-banner.tsx reads the impersonate search param via useSearchParams(), persists an "impersonating" flag to sessionStorage (so it survives client-side navigation within the dashboard even after the query param is gone from later URLs), and renders a persistent, dismiss-resistant top banner: "You are viewing {tenant.name}'s dashboard as their owner — [Exit impersonation]".
  5. Clicking Exit impersonation clears the sessionStorage flag and routes back to /platform-admin/tenants/{slug}.

This is fully implemented and does not require further work — every future backend integration must preserve the same UX contract (persistent banner, one-click exit, an audit trail entry on both entry and exit).

🟢 বাংলা সারাংশ: Impersonation ফিচারটি সম্পূর্ণভাবে তৈরি — প্ল্যাটফর্ম অ্যাডমিন এক ক্লিকে যেকোনো রেস্টুরেন্টের ড্যাশবোর্ডে মালিক হিসেবে ঢুকতে পারে, উপরে একটা স্থায়ী ব্যানার দেখায় যে এটা impersonation মোড, এবং "Exit impersonation" লিংক দিয়ে বেরিয়ে আসা যায়। প্রতিটি প্রবেশ ও প্রস্থান অডিট লগে রেকর্ড হয়।


7. Automated Invoicing

Hook: useBillingOverrides — generateInvoice, applyGeneratedInvoices, markInvoiceEmailed · PDF: src/lib/invoice-pdf.ts (downloadInvoicePdf, built on jsPDF)

interface Invoice {
  id: string; tenantId: string; tenantName: string; amount: number;
  status: "paid" | "pending" | "overdue" | "failed";
  issuedAt: string; paidAt?: string;
  plan: string;                                     // free-form — supports custom Plan Builder plan ids
  emailSentAt?: string;
  reason?: "registration" | "renewal" | "manual";
}

Triggers:

  • Registration — Tenants table's "Add tenant" flow calls generateInvoice({ reason: "registration" }) immediately after the mock signup.
  • Renewal — Tenant Detail's plan-change dropdown calls generateInvoice({ reason: "renewal" }) right after changePlan(), with a toast confirming both the plan switch and the new invoice.
  • Manual — reserved for a future "generate invoice now" admin action.

Every generated invoice is auto-marked paid and stamped with an emailSentAt (simulating an automatic email send — the recipient address is resolved via getTenantOwnerEmail(slug) in src/lib/mock-data/staff.ts, which looks up the staff record with role: "owner" for that tenant, falling back to a synthetic owner@{slug}.restaurant-os.com). Both the main Subscriptions page's invoice table and the Tenant Detail invoice list expose "Download PDF" (client-side jsPDF render — header/branding, billed-to block, line items, total, disclaimer footer) and "Resend email" / "Emailed" badge actions, the latter tracked in a separate emailLog map so resending an email never silently mutates payment status.

🟢 বাংলা সারাংশ: নতুন tenant রেজিস্ট্রেশন বা প্ল্যান রিনিউয়ালের সময় স্বয়ংক্রিয়ভাবে একটা ইনভয়েস তৈরি হয়ে যায়, paid হিসেবে মার্ক হয়, এবং owner-এর ইমেইলে (মক) পাঠানো হয়েছে ধরে নেওয়া হয়। প্রতিটি ইনভয়েসের জন্য PDF ডাউনলোড করা যায় (jsPDF দিয়ে ব্রাউজারেই তৈরি) এবং "Resend email" বাটন দিয়ে আবার পাঠানো (মক) যায় — এই দুটো অ্যাকশন পেমেন্ট স্ট্যাটাস থেকে সম্পূর্ণ আলাদাভাবে ট্র্যাক করা হয়।

Production TODO: replace jsPDF client render + toast-simulated email with a server-side PDF render (e.g. @react-pdf/renderer or a headless-Chromium HTML→PDF service) triggered from a Postgres AFTER INSERT trigger or a Server Action, and a real transactional email provider (Resend/Postmark/SendGrid) — see 08.


8. Users, Audit Log, Notifications, Support, Roadmap, Settings

ModuleWhat it does today
Users (users-board.tsx)Platform-admin team member list with role assignment (Super Admin / Admin / Support / Read-only), invite modal.
Audit log (audit-log-center.tsx)Merges the static seed audit trail with usePlatformAuditLog's live localStorage entries — every impersonation, plan change, flag toggle, VIP grant, and invoice action appears here in real time within the session.
Notifications (notification-center.tsx)Platform-wide notification feed (new signups, failed payments, support escalations).
Support (support-inbox.tsx)Ticket list + detail view, shared data model with the tenant-side support surface (useSupportTickets).
Roadmap (roadmap-showcase.tsx)Public-facing "what's shipping next" board, phase-tagged (MVP/P2/P3/P4), same phase vocabulary as Feature Flags.
Settings (platform-settings-panel.tsx)Platform-wide configuration (branding defaults, notification preferences, security policy placeholders).

🟢 বাংলা সারাংশ: বাকি মডিউলগুলো — Users (প্ল্যাটফর্ম টিম মেম্বার ও রোল), Audit Log (সব অ্যাকশনের ইতিহাস), Notifications, Support Inbox, Roadmap, এবং Settings — প্রতিটি নিজস্ব উদ্দেশ্যে কাজ করে এবং একই ডিজাইন প্যাটার্ন অনুসরণ করে।