Skip to content

04 — Restaurant Owner Dashboard Specification

Route root: /[locale]/tenants/[tenant]/dashboard/* · Access: restaurant owner/manager/staff, RBAC-gated per module · Status: ✅ UI fully built on mock/localStorage data

🟢 বাংলা ভূমিকা: এই ডকুমেন্টে রেস্টুরেন্ট মালিকের ড্যাশবোর্ডের প্রতিটি মডিউল — Overview, Menu, Orders, Tables, POS, Staff, Inventory, CRM, Reservations, Promos, Analytics, Settings — বিস্তারিতভাবে বর্ণনা করা হয়েছে।


0. Language

Every screen in this document is fully translated, English and Bengali. That was not always true: the dashboard was hardcoded English by convention, on the reasoning that only owners see it. For a platform sold to restaurants in Bangladesh that reasoning inverts — the owner is the person most likely to want Bengali — and the half-state was the worst of the three, a translated sidebar wrapped around an English screen.

Components read useDashboardDict() instead of taking a dict prop. Around 450 strings across twenty boards would otherwise mean a dictionary slice restated in twenty prop interfaces and every new string touching three files. The guest surfaces keep prop-threading, because they render from server pages that already hold the dictionary; the dashboard boards are all client components under one layout.

What is deliberately left in English, because it is data and not UI:

  • Seed content an owner would have typed themselves — dish names, promo combo lines. Translating a restaurant's own menu would be presumptuous and wrong.
  • Audit-log entries — they say what happened at a moment, in the language the dashboard was in at that moment, and rewriting them on a language switch would falsify the record.

Order timelines used to be on that list and no longer are, because the argument does not survive contact with who reads them. An audit entry has one audience: the person who has to reconstruct what an administrator did. A timeline row has two — the guest tracking their food and the staff cooking it, often in different languages, reading the same order at the same moment. Freezing it in the writer's language serves neither. Entries store a labelKey and each reader resolves their own words; timelineText falls back to the stored sentence for rows written before the key existed, since a phase with no server has no migration step.

Sentences that interpolate values are templates with named placeholders ("{feature} is included in the {plans} {planWord}") rather than concatenations, because Bengali does not put those values where English does and building the sentence by joining fragments forces the English order onto every other language.

🟢 বাংলা সারাংশ: এই ডকুমেন্টের প্রতিটি স্ক্রিন এখন সম্পূর্ণ দ্বিভাষিক। আগে ড্যাশবোর্ড ইচ্ছাকৃতভাবে ইংরেজি ছিল — যুক্তি ছিল, এটা তো শুধু মালিক দেখেন। বাংলাদেশের বাজারের জন্য এই যুক্তি উল্টো: মালিকই তো সবচেয়ে বেশি বাংলা চাইবেন। কম্পোনেন্টগুলো prop-এর বদলে useDashboardDict() থেকে স্ট্রিং নেয়, কারণ কুড়িটা বোর্ডে প্রায় ৪৫০টি স্ট্রিং prop দিয়ে পাঠালে কুড়িটা interface-এ একই জিনিস বারবার লিখতে হতো। যা ইচ্ছাকৃতভাবে অনূদিত হয় না: মালিকের নিজের লেখা কনটেন্ট (ডিশের নাম, কম্বোর তালিকা) আর নথিভুক্ত ইতিহাস (অডিট লগ, অর্ডারের timeline) — এগুলো ডেটা, UI নয়; ভাষা বদলালে ইতিহাস বদলে দেওয়া হবে, সেটা ঠিক নয়।


1. Dashboard Shell

Component: components/dashboard/shell.tsx + sidebar.tsx

  • Header — restaurant name/logo, current branch switcher (for multi-branch tenants), search, notification bell, theme toggle, accent picker, impersonation banner slot (see 03 §6).
  • Sidebar — one consistent nav-item design (icon + label + active-state accent bar) shared across every dashboard route; auto-collapses to icon-only on tablet widths and to a bottom sheet / off-canvas drawer on mobile.
  • Forms and confirmations (Modal) — a centred dialog from 640 px, a bottom sheet with a grabber below it. On a phone the centred card spent a third of the screen on margins and put Save at the far end of a scroll; the sheet rises from where the thumb is, like every other popup in the app. A photo lightbox opts out (centered).
  • Main content area — scrolls independently from the header/sidebar; the header stays pinned (sticky top-0) so page context (restaurant name, page title) never scrolls out of view during long lists (orders board, analytics).
  • Responsive contract: dashboard is desktop-first but every module must remain usable down to a 375px viewport with zero horizontal overflow — verified per this project's standing QA convention (see 11-testing-and-qa-strategy.md).

🟢 বাংলা সারাংশ: ড্যাশবোর্ড শেলে একটা হেডার (রেস্টুরেন্টের নাম, শাখা সুইচার, সার্চ, নোটিফিকেশন), একটা সাইডবার (সব পেজে একই ডিজাইন), এবং মেইন কন্টেন্ট এরিয়া আছে যেটা আলাদাভাবে স্ক্রল হয় — হেডার সবসময় উপরে আটকানো থাকে। ডেস্কটপ-ফার্স্ট হলেও মোবাইলেও (৩৭৫px পর্যন্ত) সম্পূর্ণ ব্যবহারযোগ্য থাকতে হবে।


2. Overview (/dashboard)

Component: overview-stats.tsx + charts.tsx

KPI cards (today's revenue, orders count, average ticket size, table occupancy %), a live orders-in-progress mini-feed, and a revenue trend chart (last 7/30 days). Pulls from useLiveOrders for the "in-progress" feed so it reflects the same simulated real-time stream the Orders board uses.

🟢 বাংলা সারাংশ: ওভারভিউ পেজে আজকের রেভিনিউ, অর্ডার সংখ্যা, গড় বিল, টেবিল ব্যস্ততা — এই মূল KPI কার্ডগুলো এবং একটা রেভিনিউ ট্রেন্ড চার্ট দেখায়।


3. Menu Builder (/dashboard/menu)

Component: menu-manager.tsx

3.1 Data model

type SpiceLevel = 0 | 1 | 2 | 3;
type DietTag = "veg" | "vegan" | "halal" | "gluten-free" | "dairy-free" | "nut-free";

interface MenuCategory { id: string; name: string; icon: string; description?: string; }
interface MenuItemVariant { id: string; label: string; priceDelta: number; }   // e.g. "Large" (+80)
interface MenuItemAddon { id: string; label: string; price: number; }          // e.g. "Extra cheese" (+40)

interface MenuItem {
  id: string; categoryId: string; name: string; description: string; price: number; visualToken: VisualToken; image?: string;
  dietTags: DietTag[]; spiceLevel: SpiceLevel;
  isPopular?: boolean; isNew?: boolean; isChefSpecial?: boolean;
  allergens: string[];                // free-text allergen labels — see 05 for the Allergy Intelligence Engine
  rating: number; ratingCount: number; prepTimeMinutes: number; calories?: number;
  variants?: MenuItemVariant[]; addons?: MenuItemAddon[];
  available: boolean;                 // 86'd / sold-out toggle
}

3.2 Capabilities

Dish names in two languages. The item form takes the name twice, English and Bengali, and both persist (ros-menu-names:{tenant}) — alongside allergens, the only fields on that form that reach the guest before the backend lands. The guest menu resolves each name against the locale being read, falling back to the owner's English and then to the seeded name, so a half-translated menu still reads. Dish names are the restaurant's own words, which is why they are an owner field rather than a dictionary key (rule 3).

Every other field persists too (ros-menu-edits:{tenant}, src/lib/menu-edits.ts). Until it did, Save changed the names and allergens and nothing else: a new price, a new dish, a new category and a delete all showed a success toast and vanished — and repricing is the edit an owner makes most. Now price, description, category, spice and diet tags patch the dish; new dishes and categories are stored whole; deletes are recorded; and the guest menu, the POS and the kitchen's sold-out sheet all read the result. A dish nobody has rated yet shows no rating rather than 0.0.

Bengali for every piece of owner text. The item form takes the description in Bengali beside the English, and Categories (which replaced "Add category") lists every category with its English and Bengali name, editable in place, above the form for a new one. The package form takes a Bengali name and description too. See docs/05 §3 for how the guest menu resolves them.

Pausing is a switch on the card. "We do not serve this any more" is the owner's available flag, flipped in place without opening the form. It is not the kitchen's sold-out, which is tonight only and cleared each shift.

On a phone the builder is a list, not a grid of cards — thumbnail, name, price, switch, edit, delete. As full cards the demo menu ran to fourteen screens, which is fine for admiring photographs and bad for finding the one dish to reprice.

🟢 বাংলা সারাংশ: মেনুর সব পরিবর্তন এখন সংরক্ষিত হয় — দাম, বর্ণনা, ক্যাটাগরি, ঝাল, নতুন ডিশ, নতুন ক্যাটাগরি, মুছে ফেলা — আর অতিথির মেনু, POS আর কিচেন সবাই সেটাই দেখে। আগে Save চাপলে শুধু একটা মেসেজ দেখাত, দাম আসলে বদলাত না। ডিশ বন্ধ রাখতে কার্ডেই একটা সুইচ আছে (এটা কিচেনের "আজ শেষ" থেকে আলাদা)। মোবাইলে মেনু এখন কার্ডের বদলে ছোট তালিকা, যাতে একটা ডিশ খুঁজে দাম বদলাতে চোদ্দ স্ক্রিন স্ক্রল করতে না হয়।

🟢 বাংলা সারাংশ: ডিশের নাম এখন দুইবার নেওয়া হয় — ইংরেজি আর বাংলা — আর দুটোই সংরক্ষিত হয়। অতিথি যে ভাষায় মেনু পড়ছেন সেই নামটাই দেখেন; বাংলা নাম না থাকলে ইংরেজিটা দেখায়, তাই অর্ধেক অনুবাদ করা মেনুও ঠিকঠাক চলে। ডিশের নাম রেস্টুরেন্টের নিজের কথা, তাই সেটা অভিধানে না রেখে মালিকের হাতে রাখা হয়েছে।

  • Category CRUD with drag-to-reorder.
  • Item CRUD with variant rows (size/portion) and addon rows (extras), each independently priced (priceDelta/price added to the base at checkout).
  • Allergen tagging per item (feeds directly into the customer-facing Allergy Filter — see 05).
  • Availability toggle (86 an item instantly — reflected on the live customer menu without a page reload once Realtime is wired).
  • Food costing (lib/food-costing.ts) — optional ingredient cost breakdown per item to estimate margin %, surfaced in Analytics' BCG matrix (see §11).
  • AI Smart Upselling hooks (lib/upsell.ts) — "frequently paired with" suggestions computed from historical order line-item co-occurrence, shown in the cart/item-detail modal on the customer side.

🟢 বাংলা সারাংশ: মেনু বিল্ডারে ক্যাটাগরি ও আইটেম CRUD, ভ্যারিয়েন্ট (সাইজ) ও অ্যাডঅন (এক্সট্রা) যোগ করা, অ্যালার্জেন ট্যাগিং, আইটেম available/unavailable টগল, এবং ফুড কস্টিং (মার্জিন হিসাব) করা যায়। Upselling ইঞ্জিন অর্ডার হিস্ট্রি থেকে "একসাথে বেশি কেনা হয়" এমন আইটেম সাজেস্ট করে।

Dish photos the owner uploads

Hook: use-menu-images.ts · Helper: src/lib/image-downscale.ts

MenuItem.images had existed in some form since the visual token was split out of imageUrl, and every surface already preferred it over the demo photography — but nothing could set it, so the field could only ever be undefined. This is the door. A dish with no photo keeps the seeded look, so a restaurant that uploads nothing still has a menu that reads as finished, and one that uploads a single photo changes exactly one dish.

A dish is several pictures, not one. The detail sheet has always shown a swipeable strip, and a restaurant photographs a biryani from the top, from the side and plated. So the field is images: string[], the picker takes several files at once — a restaurant shooting a dish takes them in one sitting, and sending them back to the picker for each is friction with nothing behind it — and up to five are kept per dish, past which a strip stops being a gallery and starts being a scroll.

images[0] is the cover: it is what the menu card, the cart line and the kitchen ticket show, so Make cover promotes a photo to the front rather than making the owner delete and re-upload in the right order. Removing the last photo drops the entry entirely, so the dish returns to stock photography — an empty array would claim the dish has no pictures rather than that nobody has added any.

Data written by the earlier single-image version is still in owners' browsers and there is no migration step in a phase with no server, so a bare string is read as a gallery of one.

The constraint that shapes it is storage. There is no object store yet, so a photo is a data URL in localStorage — an origin gets roughly 5MB, shared with every tenant's cart, live orders, promos and floor plan. A phone camera JPEG is 3–5MB by itself, so keeping what the owner picked would fill the quota with one dish and break every other feature in a way that looks nothing like "the photo was too big". Pictures are therefore downscaled before storage: 1200px on the longest edge, JPEG at 0.72, capped at ~180KB. Measured on a 2400×1600 upload: 3362KB in, 37KB stored.

Two details worth keeping:

  • Byte accounting is per character, doubled. Base64 carries three bytes in four characters and browsers store strings as UTF-16, so a data URL costs about twice its character count. Estimating from the decoded size — the intuitive reading — understates the real cost by half, which is how a budget silently becomes twice what was intended.
  • Data URLs bypass next/image. The optimiser needs a URL it can fetch, so unoptimized is set for data: sources in VisualTile, DishImageSlider and ImageLightbox. Nothing is lost: the image was already downscaled on the way in.

A full quota is reported, not thrown. An unhandled QuotaExceededError here would take down the item form mid-edit, losing the owner's other unsaved changes in order to tell them a photo did not fit. The remaining budget is shown in the form rather than left to be discovered when an upload is refused.

🟢 বাংলা সারাংশ: MenuItem.image ফিল্ডটা আগে থেকেই ছিল এবং সব পর্দা সেটাকে অগ্রাধিকার দিত, কিন্তু মালিকের ছবি দেওয়ার কোনো উপায় ছিল না — এখন আছে। যে ডিশে ছবি দেওয়া হয়নি সেটি আগের মতোই থাকে, তাই কেউ কোনো ছবি না দিলেও মেনু অসম্পূর্ণ দেখায় না। একটা ডিশের একাধিক ছবি থাকে — উপর থেকে, পাশ থেকে, প্লেটে সাজানো — তাই ফিল্ডটি images: string[], একসাথে কয়েকটা ফাইল বাছা যায়, আর প্রতি ডিশে সর্বোচ্চ পাঁচটি। images[0] হলো প্রচ্ছদ — মেনু কার্ড, কার্ট ও রান্নাঘরের টিকিটে এটিই দেখায়, তাই Make cover দিয়ে যেকোনো ছবিকে সামনে আনা যায়, মুছে আবার আপলোড করতে হয় না। মূল সীমাবদ্ধতা সংরক্ষণ: এখনো কোনো object store নেই, তাই ছবি data URL হিসেবে localStorage-এ থাকে — পুরো origin পায় প্রায় ৫MB, যা কার্ট, অর্ডার, প্রোমো ও ফ্লোর প্ল্যানের সাথে ভাগাভাগি। ফোনের একটা ছবিই ৩–৫MB, তাই আপলোডের আগে ছোট করা হয় (লম্বা দিক ১২০০px, JPEG ০.৭২, সর্বোচ্চ ~১৮০KB)। মেপে দেখা: ৩৩৬২KB → ৩৭KB। কোটা ভরে গেলে ত্রুটি ছুড়ে দেওয়া হয় না, জানানো হয় — নইলে ফর্মটাই ভেঙে মালিকের অন্য অসংরক্ষিত পরিবর্তনও হারাত।


4. Orders Board (/dashboard/orders)

Component: orders-board.tsx · Hook: use-live-orders.ts

Export CSV in the header downloads exactly the orders the filters are showing — the day-end report an owner hands the accountant (src/lib/orders-csv.ts). One row per order, amounts as plain numbers so a spreadsheet can sum them, times in the device's clock rather than UTC, a byte-order mark so Excel reads Bengali names correctly, and any cell that starts like a formula (=, +, -, @) prefixed so opening the file cannot run it.

4.1 Data model

type OrderStatus = "pending" | "confirmed" | "preparing" | "ready" | "served" | "out-for-delivery" | "delivered" | "completed" | "cancelled";
type OrderChannel = "dine-in" | "takeaway" | "delivery";

interface OrderLineItem { id: string; menuItemId: string; name: string; visualToken: VisualToken; image?: string; quantity: number; unitPrice: number; variantLabel?: string; addons?: { label: string; price: number }[]; notes?: string; }
interface OrderTimelineEvent { status: OrderStatus; label: string; timestamp: string; }

interface Order {
  id: string; orderNumber: string; tableId?: string; tableLabel?: string;
  channel: OrderChannel; status: OrderStatus; items: OrderLineItem[];
  subtotal: number; tax: number; serviceFee: number; deliveryFee?: number;
  discount?: number; promoCode?: string; total: number;
  createdAt: string; updatedAt: string;
  customerName?: string; customerPhone?: string; deliveryAddress?: string;
  riderId?: string; waiterId?: string; notes?: string; timeline: OrderTimelineEvent[];
  station?: "grill" | "fry" | "cold" | "bar" | "dessert" | "curry";
  paymentMethod?: "cash" | "card" | "wallet"; isPaid: boolean;
  billRequested?: boolean;
}

4.2 Board layout

Kanban-style columns by status (Pending → Confirmed → Preparing → Ready → Served/Out-for-delivery → Completed), filterable by channel and by branch. Each card shows line items, elapsed time (useElapsedTime), and channel-specific actions (assign waiter for dine-in, assign rider for delivery, mark picked-up for takeaway). Status transitions here are the same transitions KDS and the Waiter/Rider apps drive from their own surfaces — all views read/write the same Order.status field, which is why real-time propagation (Supabase Realtime, once wired — see 06) matters: a status bump on KDS must reflect here within milliseconds.

🟢 বাংলা সারাংশ: অর্ডার বোর্ড একটা Kanban-স্টাইল ভিউ, স্ট্যাটাস অনুযায়ী কলাম ভাগ করা (Pending → Confirmed → Preparing → Ready → Served/Delivery → Completed)। প্রতিটি কার্ডে লাইন আইটেম, সময় (কতক্ষণ ধরে পেন্ডিং), এবং চ্যানেল-নির্দিষ্ট অ্যাকশন থাকে। এই একই Order.status ফিল্ড KDS এবং Waiter/Rider অ্যাপেও ব্যবহৃত হয় — তাই রিয়েল-টাইম সিঙ্ক ভীষণ গুরুত্বপূর্ণ।


5. Tables & QR Management (/dashboard/tables)

The table tent card. "Print card" opens an A5 card built by src/lib/print-table-tent.ts: the restaurant's name, tagline and accent, the table label, the live QR (serialized from the rendered code, so a regenerated token invalidates old prints) and three steps in English and Bengali at once — the card is the one surface that cannot ask which language its reader wants. Owner-typed text is escaped on the way in, because the document is assembled by hand.

🟢 বাংলা সারাংশ: "কার্ড প্রিন্ট" একটা A5 কার্ড খোলে — রেস্টুরেন্টের নাম, ট্যাগলাইন, অ্যাকসেন্ট রং, টেবিল নম্বর, লাইভ QR আর তিনটা ধাপ ইংরেজি ও বাংলা দুটোতেই, কারণ টেবিলের কার্ড জানে না কে এসে বসবেন। QR সরাসরি রেন্ডার করা কোড থেকে নেওয়া, তাই টোকেন বদলালে পুরনো প্রিন্ট আর কাজ করে না।

Component: tables-floor.tsx

  • Visual floor-plan grid of tables, each showing occupancy state (empty/seated/bill-requested/needs-cleaning).
  • Per-table dynamic QR generation — each table gets a rotating token (useTableTokens), not a static URL, so a photographed/leaked QR code can be invalidated by regenerating the token from this screen (see 05 §2 for the full token lifecycle).
  • Table merge — combine adjoining tables for a large party; consolidates the real running order totals from each merged table rather than just visually grouping them.
  • "Call waiter" / "Request bill" signal inbox (useTableCalls) surfaces here and on the Waiter app simultaneously.

🟢 বাংলা সারাংশ: টেবিল ম্যানেজমেন্টে ভিজ্যুয়াল ফ্লোর-প্ল্যান, প্রতিটি টেবিলের জন্য ডাইনামিক QR (নির্দিষ্ট সময় পরপর টোকেন পরিবর্তন হয়, তাই পুরনো QR স্ক্যান করলে কাজ করবে না), টেবিল মার্জ (একাধিক টেবিলের বিল একত্রে হিসাব), এবং কাস্টমারের "ওয়েটার ডাকো"/"বিল চাই" রিকোয়েস্ট দেখা যায়।

Owner-defined zones and drag-and-drop placement

Components: floor-plan-editor.tsx · Hook: use-floor-plan.ts

Zones were a closed TypeScript union — five values baked into the type — which is a reasonable default and a wrong constraint: a restaurant's areas are the restaurant's business, and an owner with a terrace, a mezzanine or a family section had no way to say so. Zones are now data. The five seeds remain, and the owner adds, renames and removes their own on top.

A zone's name tells you whose words it is. A seeded zone carries a dictionary key, so "Rooftop" reaches a guest in the guest's language. A zone the owner typed carries the text itself and is never translated — the same rule dish names follow, because translating a restaurant's own name for a part of their own building is both presumptuous and, to anyone reading the sign on the wall, wrong. Renaming a seeded zone flips it from the first kind to the second, at the moment its name stops being ours.

Placement — one floor per zone. The seeded floorX/floorY were a guess in per-zone bands, since no real floor plan existed to copy. The editor is where an owner replaces the guess with their room, and it writes the same coordinates the waiter's floor plan reads — arranging it here is what makes that screen worth looking at, and the guest's /tables picker follows too.

Each zone gets its own canvas. A single surface for the whole restaurant was the wrong shape: the rooftop and the main hall are different rooms, so arranging them together asks the owner to draw a building that does not exist. Dragging a table onto another zone's floor is what moves it there — the same gesture that positions it, rather than a dropdown alongside saying where it lives. Because zones are separate rooms, the collision check is scoped to the destination zone; checking globally meant a rooftop table reserved a spot in the main hall it could not be standing in. When the exact landing cell in the destination is taken the table takes the first free one instead of bouncing back — a table dragged onto the rooftop should end up on the rooftop, and refusing it over a pixel reads as the drag having failed.

The drop coordinate is viewport space, not page space. Framer reports info.point as client plus scroll; getBoundingClientRect() is relative to the viewport. Hit-testing one against the other read every drop as scrollY pixels lower than it was, so on a scrolled page a table dragged inside the main hall landed on the rooftop, and one dragged inside the rooftop landed on the patio — always the next room down. viewportDropPoint in src/lib/drag-point.ts resolves the two, preferring the native event's clientX/clientY.

Worth recording why this shipped: a synthetic PointerEvent dispatched from a test carries no page offset, so point and client agree and the drag verifies clean. Only a real mouse on a scrolled page tells them apart. The regression test now reproduces the real shape — a page coordinate that differs from the client one — rather than the synthetic one that never could.

Arrange mode, and why it exists. Dragging is off until the owner turns it on. Framer sets touch-action: none on a draggable element, so with dragging always live a finger landing on a tile could not scroll the page — measured at 55% of the floor area covered on a 375px viewport before the split into per-zone canvases, 17% after, and neither is acceptable when the fix is this cheap. It also stops a tap meant to open a table's QR from nudging it across the room. Arrow keys move the focused table one grid cell at a time, since a floor plan arrangeable only by pointer is one some owners cannot arrange at all.

Two rules the implementation exists to enforce:

  • A drop onto an occupied cell in the same zone is refused, not accepted. Two tables in one spot means one is drawn entirely under the other — invisible and unclickable, so a waiter never sees that table's bill request. tables.test.ts already guards the seeded layout against exactly that; free dragging would have put it back by hand. Drops snap to a 4% grid so "is this cell taken" is a question with an answer, and the refusal says which table is in the way rather than appearing to ignore the drag.
  • A zone with tables in it cannot be deleted. Removing it and relocating the tables would put them in a room the restaurant no longer has, silently. The attempt is refused and names the tables blocking it, leaving the decision with the owner.

Two bugs found while building this, both worth remembering. Comparing a snapped drop against a raw seeded coordinate meant the collision check never fired — both sides have to be snapped, because the question is "is anything drawn here", not "is anything at this exact number". And seeding the waiter board's state with resolveTables(...) in a useState initializer silently did nothing: the initializer runs once, and on the hydration pass useLocalStorage is still returning its server snapshot, so the owner's arrangement had not loaded yet. It is layered at render time instead — the same trap CLAUDE.md records for the guest-name prompt.

🟢 বাংলা সারাংশ: জোন আগে কোডে লেখা পাঁচটি নির্দিষ্ট মান ছিল; এখন সেগুলো ডেটা — মালিক নিজের জোন যোগ করতে, নাম বদলাতে ও মুছতে পারেন। নামটি কার, সেটাই ঠিক করে অনুবাদ হবে কিনা: আমাদের দেওয়া পাঁচটি জোনের নাম অতিথির ভাষায় দেখায়, কিন্তু মালিকের টাইপ করা নাম হুবহু থাকে — ডিশের নামের মতোই, কারণ নিজের রেস্টুরেন্টের একটা অংশের নাম আমরা অনুবাদ করে দেওয়ার কেউ নই। প্রতিটি জোনের নিজস্ব ফ্লোর আছে — ছাদ আর মূল হল আলাদা ঘর, এক ক্যানভাসে সাজানো মানে এমন একটা বাড়ি আঁকতে বলা যা নেই। টেবিল টেনে অন্য জোনের ফ্লোরে ছাড়লেই সেটি ওই জোনে চলে যায়, আর সেই অবস্থানই ওয়েটারের ফ্লোর প্ল্যান ও অতিথির টেবিল তালিকা পড়ে। টানাটানি চালু হয় শুধু “সাজান” চাপলে — নইলে ফোনে টাইলের উপর আঙুল রাখলে পেজ স্ক্রলই হতো না; তীর চিহ্নের বোতাম দিয়েও টেবিল সরানো যায়, কারণ শুধু মাউস দিয়ে সাজানো যায় এমন ফ্লোর প্ল্যান অনেকের কাছেই অব্যবহার্য। দুটো নিয়ম কড়াভাবে মানা হয়: দখল করা জায়গায় টেবিল বসানো যায় না (দুটো টেবিল এক জায়গায় পড়লে একটি সম্পূর্ণ ঢাকা পড়ে যায় — অদৃশ্য ও অক্লিকযোগ্য), আর যে জোনে টেবিল আছে সেটি মোছা যায় না (নইলে টেবিলগুলো এমন ঘরে থেকে যেত যা আর নেই)।


6. POS / Billing (/dashboard/pos)

Component: pos-station.tsx

Cashier-facing quick-order + billing screen for walk-in/phone orders that don't originate from a QR scan. Supports split-bill (equal or itemized per-guest assignment), multiple payment methods (cash/card/wallet), and generates the same Order record + KOT print job as any other channel so kitchen/analytics never need channel-specific logic downstream.

Table binding and "Send to kitchen". That last sentence used to be a plan rather than a description: the POS could only charge, producing a receipt and a transaction row while nothing ever reached the kitchen. An order taken at the counter or by a waiter had to be relayed by voice, even though every other channel appeared on the KDS board. The ticket panel now carries a table selector, and Send to kitchen writes the same Order into the same useLiveOrders feed the guest cart writes to — so a counter order is genuinely indistinguishable downstream, including waiter assignment from assignedTables. Sending and charging are separate actions on purpose: staff fire the food when the order is taken and settle the bill when the guest leaves, and a Charge that did not send would leave the food unmade.

Tablets. The ticket sits beside the dishes from 1280 px and behind a View ticket bar below that. It used to be keyed on "not a phone" while the side panel only rendered from xl, so on every iPad — the most common counter device — there was no way to reach Charge at all. Dishes are dense tiles (the whole tile adds; a badge counts what is on the ticket), and the till reads the owner's saved menu and hides whatever the kitchen has marked sold out.

Arriving from the floor plan pre-selects the table: each table's QR modal in /dashboard/tables carries "Take an order for {label}", linking to pos?table={id}. Together with the guest-facing /tables picker (05 §1.1) that closes the no-phone case from both ends — the guest can seat themselves at the right table, or staff can take the order for them.

🟢 বাংলা সারাংশ: POS স্টেশন হলো ক্যাশিয়ারের জন্য — যেসব অর্ডার QR স্ক্যান ছাড়া সরাসরি আসে (ওয়াক-ইন/ফোন), সেগুলোর জন্য দ্রুত বিলিং স্ক্রিন। split-bill এবং একাধিক পেমেন্ট মেথড সাপোর্ট করে, এবং একই Order মডেল ব্যবহার করে বলে কিচেন/অ্যানালিটিক্সে আলাদা লজিক লাগে না। আগে এই শেষ কথাটা শুধু পরিকল্পনা ছিল — POS কেবল বিল করতে পারত, কিচেনে কিছুই পৌঁছাত না। এখন টিকিটে টেবিল বাছাই করা যায় এবং Send to kitchen দিলে গেস্টের কার্ট যে Order লেখে ঠিক সেটাই একই live feed-এ লেখা হয়, ওয়েটার অ্যাসাইনমেন্ট সহ। পাঠানো আর চার্জ করা আলাদা রাখা হয়েছে ইচ্ছাকৃতভাবে: অর্ডার নেওয়ার সাথে সাথে রান্না শুরু হয়, বিল মেটে গেস্ট চলে যাওয়ার সময়। ড্যাশবোর্ডের টেবিল থেকে "Take an order for {label}" ক্লিক করলে POS-এ সেই টেবিল আগেই বাছাই হয়ে থাকে।


7. Staff (/dashboard/staff)

Component: staff-board.tsx · Type: src/types/staff.ts

Staff directory with role assignment (Owner, Manager, Waiter, Kitchen, Rider, Cashier), shift scheduling, and a permission matrix per role. getTenantOwnerEmail(slug) (used by the Platform Admin invoicing flow) reads the role: "owner" record from this same data source — there is exactly one source of truth for "who owns this restaurant" across the whole platform.

7.1 One staff member — /dashboard/staff/[staffId]

Component: staff-profile.tsx

The roster answers "who works here". It cannot answer "how is this waiter doing", which is what a manager wants before a shift review, because that needs the person's orders. Every card on the roster — riders included, since a manager clicking a name does not care which array it came from — opens this page: identity and contact, shifts, rating, assigned tables, and the orders that went through them with the revenue those carried.

Attribution runs through Order.waiterId, and the page is explicit about that being the only link it has. Waiters get real figures. A chef's output is deliberately not shown, because no order records who cooked it — inventing a number for a page a manager might base a review on would be worse than an empty state that explains itself. When the kitchen gains per-station assignment, that is the field this page will read.

🟢 বাংলা সারাংশ: স্টাফ পেজে প্রতিটি স্টাফের রোল (Owner, Manager, Waiter, Kitchen, Rider, Cashier), শিফট শিডিউল, এবং রোল-ভিত্তিক পারমিশন ম্যাট্রিক্স ম্যানেজ করা যায়। প্রতিটি রেস্টুরেন্টের "owner" রেকর্ড এই একই ডেটা থেকে সব জায়গায় (যেমন ইনভয়েস ইমেইল) ব্যবহৃত হয়। প্রতিটি স্টাফের নিজস্ব পেজ (/dashboard/staff/[staffId]) আছে — সেখানে তার বরাদ্দকৃত টেবিল আর সার্ভ করা অর্ডারগুলো দেখা যায়। অর্ডারের সাথে সংযোগ শুধু Order.waiterId, তাই ওয়েটারের হিসাব আসল; শেফের হিসাব ইচ্ছাকৃতভাবে দেখানো হয় না, কারণ কে রান্না করেছে সেটা কোনো অর্ডারে লেখা থাকে না — আর যে পেজ দেখে ম্যানেজার রিভিউ দেবে, সেখানে বানানো সংখ্যা বসানো খালি রাখার চেয়ে খারাপ।


8. Inventory (/dashboard/inventory)

Component: inventory-board.tsx · Type: src/types/inventory.ts

Stock items with unit, current quantity, low-stock threshold, supplier reference, and purchase-order history. Menu items can optionally declare a BOM (bill of materials — ingredient quantities per serving) so that completing an order can (in the target backend) atomically decrement stock. Low-stock alerts surface on the Overview dashboard.

🟢 বাংলা সারাংশ: ইনভেন্টরি মডিউলে স্টক আইটেম, বর্তমান পরিমাণ, লো-স্টক থ্রেশহোল্ড, সাপ্লায়ার তথ্য, এবং পারচেজ অর্ডার হিস্ট্রি থাকে। মেনু আইটেমের সাথে ইনগ্রেডিয়েন্ট (BOM) যুক্ত করলে অর্ডার সম্পন্ন হওয়ার সাথে সাথে স্টক স্বয়ংক্রিয়ভাবে কমে যাবে (ব্যাকএন্ড যুক্ত হলে)।


9. CRM (/dashboard/crm)

Component: crm-board.tsx · Type: src/types/crm.ts

Customer profiles aggregated by phone number across orders: total spend, visit frequency, last order date, favorite items, and segment tags (VIP/at-risk/new) for targeted promo campaigns.

🟢 বাংলা সারাংশ: CRM মডিউলে ফোন নম্বর ধরে প্রতিটি কাস্টমারের প্রোফাইল একত্র করা হয় — মোট খরচ, কতবার এসেছে, প্রিয় আইটেম, এবং সেগমেন্ট ট্যাগ (VIP/at-risk/new) — টার্গেটেড প্রোমো ক্যাম্পেইনের জন্য।


10. Reservations (/dashboard/reservations)

Component: reservations-board.tsx · Type: src/types/reservation.ts

Booking board (date/time/party size/table assignment), matched to the customer-facing reservation widget (components/tenant/table/reservation-widget.tsx).

🟢 বাংলা সারাংশ: রিজার্ভেশন বোর্ডে তারিখ, সময়, কতজন, এবং কোন টেবিল বরাদ্দ — এই তথ্য নিয়ে বুকিং ম্যানেজ করা হয়, যা কাস্টমার-সাইড রিজার্ভেশন উইজেটের সাথে মিলে কাজ করে।


11. Analytics (/dashboard/analytics)

Component: analytics-board.tsx + charts.tsx · Data: src/lib/mock-data/analytics.ts

  • Revenue trend, order-volume trend, peak-hour heatmap.
  • BCG Menu Matrix — every menu item plotted on a 2×2 grid (popularity × profit margin) classifying items as Stars (high popularity, high margin — promote), Plowhorses (high popularity, low margin — re-price or re-engineer), Puzzles (low popularity, high margin — market harder), Dogs (low popularity, low margin — consider removing). Feeds directly from food-costing.ts margin data and order-line-item frequency.
  • Cohort/retention charts (P3+ phase).

🟢 বাংলা সারাংশ: অ্যানালিটিক্সে রেভিনিউ ট্রেন্ড, পিক-আওয়ার হিটম্যাপ, এবং একটা BCG মেনু ম্যাট্রিক্স আছে যা প্রতিটি মেনু আইটেমকে জনপ্রিয়তা ও মার্জিনের ভিত্তিতে ৪ ভাগে ভাগ করে — Stars (জনপ্রিয় + লাভজনক), Plowhorses (জনপ্রিয় কিন্তু কম লাভ), Puzzles (কম জনপ্রিয় কিন্তু লাভজনক), Dogs (দুটোই কম) — যাতে মালিক বুঝতে পারে কোন আইটেম প্রমোট করা উচিত বা মেনু থেকে বাদ দেওয়া উচিত।


12. Promos & Landing Page CMS (/dashboard/promos)

Component: promos-board.tsx · Hooks: use-promo-store.ts, use-promo-design.ts · Type: src/types/promo.ts

12.1 Campaign model

Each promo campaign is a first-class entity with its own dedicated landing page and QR code:

interface PromoComboItem { name: string; visualToken: VisualToken; image?: string; }

interface Promo {
  id: string; title: string; subtitle: string; description: string; bannerToken: VisualToken; bannerImage?: string;
  discountLabel: string;              // display string, e.g. "30% OFF" or "BOGO"
  originalPrice: number; discountedPrice: number;
  comboItems: PromoComboItem[];       // items bundled into this combo offer
  startsAt: string; endsAt: string;
  code: string;                       // e.g. "SWADH-FEAST" — same code usable at checkout on /table
  claimedCount: number;               // incremented each time the code is redeemed at checkout
  isActive: boolean;
}

Owner-created campaigns (use-promo-store.ts) are stored device-locally alongside the static mock promos and merged transparently, so getPromo(promoId) works identically for both — /promo/[promoId] doesn't need to know whether a campaign came from mock data or was created in the dashboard. Per-page view count analytics (distinct from claimedCount) are a planned enhancement — not yet a tracked field — see 09-caching-and-performance.md for where that instrumentation will hook in once basic analytics events are wired.

12.2 "Scan promo → order → revisit later" loop

This is the key marketing requirement this module fulfills: a customer scans a promo QR placed anywhere (flyer, table tent, social post) → lands on /promo/[promoId], a fully-branded campaign-specific landing page (banner, combo items, discount label, live countdown to endsAt) → taps "Order now" → is dropped into the normal /table ordering flow with the promo code pre-applied → completes checkout (dine-in or delivery), incrementing claimedCount. The same /promo/[promoId] link remains valid after the order — the customer (or anyone who received the link) can revisit it later and re-order under the same promo terms until endsAt, without needing to re-scan a physical QR.

12.3 Drag-and-drop landing designer

For owners who want more control than the default templated landing page, use-promo-design.ts persists a block-based layout (hero, offer-card, gallery, testimonial, countdown, CTA blocks) that can be reordered via drag-and-drop directly in the dashboard, with a live preview pane. Owners who don't want to customize keep the polished default template — customization is opt-in, never required.

🟢 বাংলা সারাংশ: প্রতিটি প্রোমো ক্যাম্পেইনের নিজস্ব একটা ডেডিকেটেড ল্যান্ডিং পেজ ও QR কোড থাকে (/promo/[promoId])। কাস্টমার QR স্ক্যান করে ল্যান্ডিং পেজে আসে, "Order now" চাপলে সরাসরি /table অর্ডার ফ্লোতে যায় (প্রোমো কোড আগে থেকেই বসানো), এবং একই লিংক পরবর্তীতেও কাজ করবে — অর্থাৎ একবার অর্ডার করার পরও কাস্টমার চাইলে সেই একই লিংক থেকে আবার অর্ডার করতে পারবে যতক্ষণ ক্যাম্পেইন চলমান থাকে। মালিক চাইলে ড্র্যাগ-এন্ড-ড্রপ দিয়ে নিজের মতো ল্যান্ডিং পেজ ডিজাইন করতে পারবে (ব্লক-বেসড লেআউট), অথবা ডিফল্ট সুন্দর টেমপ্লেট ব্যবহার করতে পারবে — কাস্টমাইজেশন সম্পূর্ণ ঐচ্ছিক।


12.5 Branches (/dashboard/branches) · gated by FEATURE.multiBranch

Component: branches-board.tsx

The dashboard already had a branch switcher, which answers "how is this branch doing" one branch at a time. An owner of three locations asks which one is behind, and that needs them on one screen — switching back and forth while holding numbers in your head is what this replaces. Each card carries today's orders and revenue, tables and staff, and a bar showing where it stands against the strongest branch, which is the comparison actually being made.

Orders now record where they were taken. Order.branchId is stamped from the table the guest sat at, or from the branch the staff member has selected in the topbar for a counter order — so the board counts rather than guesses. It used to attribute every live order to whichever branch was flagged main and admit as much on the card, which is honest but useless for the question the page exists to answer.

Two things had to change for that to be true rather than nearly true:

  • Every restaurant has a location. Only the flagship had branches seeded; the rest had none, so their tables had no branch and their orders could not be placed. A single-site restaurant is not a restaurant with no branch, so the others are seeded one from their own address.
  • Branches stopped being embedded on the tenant record. Tenant.branches duplicated what getBranchesForTenant already knew, and once single-site branches were derived from tenant addresses the two seeds imported each other. The dashboard shell takes them as a prop from the read layer instead.

Takeaway and delivery orders have no table and therefore no branch, which is accurate rather than a gap; the board reports that count separately instead of assigning them somewhere they were not.

🟢 বাংলা সারাংশ: ড্যাশবোর্ডে আগে থেকেই ব্রাঞ্চ সুইচার ছিল, যা একবারে একটা ব্রাঞ্চের হিসাব দেখায়। কিন্তু তিনটা শাখার মালিক জানতে চান কোনটা পিছিয়ে আছে — তার জন্য সবগুলো এক পর্দায় দরকার। প্রতিটি কার্ডে আজকের অর্ডার, আয়, টেবিল, স্টাফ, এবং সবচেয়ে ভালো শাখার তুলনায় অবস্থান দেখানো হয়। অর্ডারে এখন branchId থাকে — অতিথির টেবিল যে শাখার, অথবা কাউন্টারের অর্ডারের ক্ষেত্রে স্টাফ যে শাখায় কাজ করছেন। তাই বোর্ড এখন অনুমান করে না, গোনে। এর জন্য দুটো জিনিস বদলাতে হয়েছে: প্রতিটি রেস্টুরেন্টের অন্তত একটি শাখা seed করা হয়েছে (এক জায়গার রেস্টুরেন্ট মানে কোনো শাখা নেই তা নয়), আর শাখার তালিকা tenant রেকর্ড থেকে সরানো হয়েছে — দুটো seed একে অপরকে import করছিল। টেকঅ্যাওয়ে ও ডেলিভারিতে টেবিল নেই, তাই শাখাও নেই; সেগুলো আলাদা করে গোনা হয়।


12.6 Insights (/dashboard/insights) · gated by FEATURE.advancedAnalytics

Components: insights-board.tsx · src/lib/insights.ts

The questions an owner asks about their own numbers, answered from those numbers: what sells best, when they are busiest, what an average order comes to, how the week went. Below that, orders by hour, a next-seven-days projection, and three derived suggestions.

There is no model here and the page says so, in the interface and not only in a comment. Every answer is arithmetic over this restaurant's own orders, and the forecast is a weekday-shaped average labelled as an estimate. That honesty is the feature rather than a disclaimer: an owner who plans a rota or a purchase order around "AI" that turns out to be a weekday average has been misled, and the number is just as useful described accurately.

ordersByHour and projectWeek live in src/lib/insights.ts as pure functions with unit tests, because the answers on this page are read as facts about the business — a manager setting a rota from a wrongly-computed "busiest hour" has been actively misled, which is worse than having no page. ordersByHour deliberately omits hours with no orders: padding the day to twenty-four rows draws a long flat overnight stretch that reads as measured zero demand rather than a closed restaurant.

Building this is what made ff-13 real. It had sat enabled: false — the kill switch for an unbuilt feature — while already gating a fully-built Menu Matrix tab in Analytics that nobody could see, the same state ff-7 (delivery) was found in earlier. Flipping it revealed both.

🟢 বাংলা সারাংশ: মালিক নিজের সংখ্যা নিয়ে যেসব প্রশ্ন করেন, সেগুলোর উত্তর সেই সংখ্যা থেকেই দেওয়া হয় — কী বেশি বিক্রি হয়, কখন ভিড় বেশি, গড় অর্ডার কত, সপ্তাহটা কেমন গেল। সাথে ঘণ্টাভিত্তিক অর্ডার, আগামী সাত দিনের অনুমান, আর তিনটি পরামর্শ। এখানে কোনো মডেল নেই, এবং পেজেই সেটা লেখা আছে — সব হিসাব এই রেস্টুরেন্টের নিজের অর্ডার থেকে, আর পূর্বাভাসটা সপ্তাহের দিন অনুযায়ী গড়, যা "Estimate" লেবেল দিয়ে চিহ্নিত। এটাকে "AI" বলা হলে মালিক এর উপর যতটা ভরসা করতেন, সেটা ঠিক হতো না।


13. Settings (/dashboard/settings)

Component: settings-panel.tsx

Branding saves. The restaurant name, tagline and cuisine — each with a Bengali twin for the last two — persist (ros-branding-text:{tenant}) and the guest menu's hero reads them. Before, Save showed a toast and the menu kept the seeded tagline however often it was pressed.

A sticky row of section chips sits under the title (SectionJumpNav): seven sections ran to ten screens on a phone, and an owner coming to change a payment provider scrolled past branding every time. The chip for the section on screen stays highlighted.

Notification templates show the English and Bengali text together, and under every SMS template how many SMS it costs in each language — see docs/08.

  • Branding — logo, cover image, tagline, accent color (feeds AccentProvider, applied via CSS custom properties across the entire tenant experience).
  • Domain — subdomain (fixed, {slug}.restaurant-os.com) + optional custom domain. ✅ The owner's half of setup is built on the settings card: enter a domain (normalised from whatever was pasted; root domains, the platform's own domain and domains another restaurant holds are refused), get the exact CNAME record to add, then Check DNS — a real lookup over DNS-over-HTTPS, so "verified" means the record exists. ❌ Still not built: routing. The proxy runs on the server and reads the tenant record, so a domain verified in the browser does not reach the menu yet — the card says so. That and the Vercel Domains API call wait on the backend — see 10.
  • Business hours, storefront layout width (boxed vs full, per TenantBranding.layoutWidth), currency/timezone, notification preferences.

🟢 বাংলা সারাংশ: সেটিংস পেজে ব্র্যান্ডিং (লোগো, কভার ইমেজ, অ্যাকসেন্ট কালার), ডোমেইন ম্যাপিং (সাবডোমেইন + কাস্টম ডোমেইন), ব্যবসার সময়, এবং কারেন্সি/টাইমজোন কনফিগার করা যায়।

Logo and cover art

Hook: use-branding-images.ts

TenantBranding.logo and .coverImage were added when the visual tokens were split out and had been unreachable ever since: every surface preferred them over the gradient tile, and no screen could set one. A restaurant could pick a token suggesting the kind of food they serve; they could not show their actual sign.

Two slots, no gallery — a restaurant has one mark and one cover, and a list would invite the question of which is "the" logo. The logo is downscaled to 512px and the cover to 1400px, because the cover renders full-bleed across the top of the menu where a 512px image visibly softens.

The token stays as the fallback, not as a leftover. Deleting the logo returns the restaurant to its gradient mark rather than to nothing, which is why the token picker is still there and now says what it is for.

🟢 বাংলা সারাংশ: logo ও coverImage ফিল্ড দুটো আগে থেকেই ছিল এবং সব পর্দা সেগুলোকে অগ্রাধিকার দিত, কিন্তু কোনো স্ক্রিন থেকে সেট করার উপায় ছিল না — রেস্টুরেন্ট খাবারের ধরন বোঝানো একটা টোকেন বাছতে পারত, নিজের সাইনবোর্ডটা দেখাতে পারত না। এখন দুটো স্লট (একাধিক নয় — রেস্টুরেন্টের একটাই মার্ক, একটাই কভার)। লোগো ৫১২px, কভার ১৪০০px-এ ছোট করা হয়, কারণ কভার মেনুর উপরে পুরো প্রস্থে বসে। ছবি মুছে দিলে টোকেনের গ্রেডিয়েন্ট মার্কে ফিরে যায় — টোকেনটা তাই বাতিল কিছু নয়, বিকল্প।