From 260429afba5cb2506675f13af6f93756a2cd3ebe Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 1 Jun 2026 00:27:34 +0330 Subject: [PATCH] =?UTF-8?q?feat:=20demo=20data=20seeder=20=E2=80=94=20one-?= =?UTF-8?q?click=20setup=20for=20new=20caf=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/cafes/{cafeId}/demo/seed (owner-only) that seeds: - 9% default VAT tax - 7 menu categories + 59+ items via DemoMenuSeeder - 15 inventory ingredients (coffee shop staples) - 10 tables across 3 floors on the first active branch Frontend DemoDataBanner appears on menu, tables, and inventory pages when the café is completely empty, so owners can populate demo data in one click instead of entering everything manually. Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/DemoSeedController.cs | 33 ++++ .../Extensions/ServiceCollectionExtensions.cs | 1 + src/Meezi.API/Services/DemoSeedService.cs | 172 ++++++++++++++++++ .../src/components/demo/demo-data-banner.tsx | 95 ++++++++++ .../components/inventory/inventory-screen.tsx | 13 +- .../src/components/menu/menu-admin-screen.tsx | 16 +- .../src/components/tables/tables-screen.tsx | 17 +- 7 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 src/Meezi.API/Controllers/DemoSeedController.cs create mode 100644 src/Meezi.API/Services/DemoSeedService.cs create mode 100644 web/dashboard/src/components/demo/demo-data-banner.tsx diff --git a/src/Meezi.API/Controllers/DemoSeedController.cs b/src/Meezi.API/Controllers/DemoSeedController.cs new file mode 100644 index 0000000..5d16ef2 --- /dev/null +++ b/src/Meezi.API/Controllers/DemoSeedController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Meezi.API.Services; +using Meezi.Core.Interfaces; +using Meezi.Shared; + +namespace Meezi.API.Controllers; + +[Route("api/cafes/{cafeId}/demo")] +[Authorize] +public class DemoSeedController : CafeApiControllerBase +{ + private readonly IDemoSeedService _demoSeed; + + public DemoSeedController(IDemoSeedService demoSeed) + { + _demoSeed = demoSeed; + } + + /// Seeds demo menu, tables, and inventory for any café. Owner-only. + [HttpPost("seed")] + public async Task Seed( + string cafeId, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied; + + var result = await _demoSeed.SeedAsync(cafeId, ct); + return Ok(new ApiResponse(true, result)); + } +} diff --git a/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs b/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs index d67a30f..b40d8ea 100644 --- a/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs +++ b/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs @@ -91,6 +91,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddHttpClient(nameof(PosDeviceService)); diff --git a/src/Meezi.API/Services/DemoSeedService.cs b/src/Meezi.API/Services/DemoSeedService.cs new file mode 100644 index 0000000..c3021b5 --- /dev/null +++ b/src/Meezi.API/Services/DemoSeedService.cs @@ -0,0 +1,172 @@ +using Meezi.Core.Entities; +using Meezi.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Meezi.API.Services; + +public record DemoSeedResult( + int CategoriesAdded, + int ItemsAdded, + int TablesAdded, + int IngredientsAdded, + bool TaxCreated); + +public interface IDemoSeedService +{ + Task SeedAsync(string cafeId, CancellationToken ct = default); +} + +public class DemoSeedService : IDemoSeedService +{ + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public DemoSeedService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task SeedAsync(string cafeId, CancellationToken ct = default) + { + // 1. Ensure 9% default tax + var taxId = $"{cafeId}_demo_tax"; + var taxCreated = false; + if (!await _db.Taxes.AnyAsync(t => t.CafeId == cafeId && t.IsDefault, ct)) + { + _db.Taxes.Add(new Tax + { + Id = taxId, + CafeId = cafeId, + Name = "مالیات ارزش افزوده", + Rate = 9, + IsDefault = true, + IsRequired = true, + IsCompound = false + }); + await _db.SaveChangesAsync(ct); + taxCreated = true; + } + else + { + taxId = await _db.Taxes + .Where(t => t.CafeId == cafeId && t.IsDefault) + .Select(t => t.Id) + .FirstAsync(ct); + } + + // 2. Seed menu (categories + items) using café-agnostic seeder + var beforeCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct); + var beforeItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct); + await DemoMenuSeeder.EnsureMenuAsync(_db, cafeId, taxId, _logger); + var afterCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct); + var afterItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct); + + // 3. Seed ingredients if warehouse is empty + var ingredientsAdded = 0; + if (!await _db.Ingredients.AnyAsync(i => i.CafeId == cafeId, ct)) + { + var demoIngredients = BuildDemoIngredients(cafeId); + _db.Ingredients.AddRange(demoIngredients); + await _db.SaveChangesAsync(ct); + ingredientsAdded = demoIngredients.Count; + } + + // 4. Seed 10 tables if no tables exist for this café's first active branch + var tablesAdded = 0; + if (!await _db.Tables.AnyAsync(t => t.CafeId == cafeId, ct)) + { + var branchId = await _db.Branches + .Where(b => b.CafeId == cafeId && b.IsActive && b.DeletedAt == null) + .OrderBy(b => b.Id) + .Select(b => b.Id) + .FirstOrDefaultAsync(ct); + + if (branchId is not null) + { + var tables = BuildDemoTables(cafeId, branchId); + _db.Tables.AddRange(tables); + await _db.SaveChangesAsync(ct); + tablesAdded = tables.Count; + } + } + + _logger.LogInformation( + "Demo seed complete for cafe {CafeId}: +{Cats} cats, +{Items} items, +{Tables} tables, +{Ing} ingredients, tax={TaxCreated}", + cafeId, afterCats - beforeCats, afterItems - beforeItems, tablesAdded, ingredientsAdded, taxCreated); + + return new DemoSeedResult( + CategoriesAdded: afterCats - beforeCats, + ItemsAdded: afterItems - beforeItems, + TablesAdded: tablesAdded, + IngredientsAdded: ingredientsAdded, + TaxCreated: taxCreated); + } + + private static List BuildDemoIngredients(string cafeId) => + [ + Ingredient(cafeId, "قهوه اسپرسو", "گرم", 2000, 500, 80, 2000), + Ingredient(cafeId, "شیر", "میلی‌لیتر", 10000, 2000, 15, 10000), + Ingredient(cafeId, "شکر", "گرم", 5000, 1000, 5, 5000), + Ingredient(cafeId, "وانیل", "میلی‌لیتر", 500, 100, 50, 500), + Ingredient(cafeId, "شکلات تلخ", "گرم", 1000, 200, 120, 1000), + Ingredient(cafeId, "خامه", "میلی‌لیتر", 2000, 500, 30, 2000), + Ingredient(cafeId, "دارچین", "گرم", 300, 50, 40, 300), + Ingredient(cafeId, "چای سیاه", "گرم", 1000, 200, 60, 1000), + Ingredient(cafeId, "آب معدنی", "میلی‌لیتر", 20000, 5000, 3, 20000), + Ingredient(cafeId, "نان تست", "عدد", 100, 20, 8000, 100), + Ingredient(cafeId, "تخم‌مرغ", "عدد", 60, 12, 6000, 60), + Ingredient(cafeId, "کره", "گرم", 500, 100, 80, 500), + Ingredient(cafeId, "پنیر", "گرم", 1000, 200, 90, 1000), + Ingredient(cafeId, "اسپاتولا یخ", "عدد", 200, 50, 2000, 200), + Ingredient(cafeId, "سس کارامل", "میلی‌لیتر", 1000, 200, 60, 1000), + ]; + + private static Ingredient Ingredient( + string cafeId, string name, string unit, + decimal qty, decimal reorder, decimal cost, decimal par) => + new() + { + Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36], + CafeId = cafeId, + Name = name, + Unit = unit, + QuantityOnHand = qty, + ReorderLevel = reorder, + UnitCost = cost, + ParLevel = par, + LowStockWarningPercent = 20m + }; + + private static List BuildDemoTables(string cafeId, string branchId) + { + var tables = new List
(); + // Floor 1: tables 1-4 + for (var i = 1; i <= 4; i++) + tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه اول", i)); + // Floor 2: tables 5-8 + for (var i = 5; i <= 8; i++) + tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه دوم", i)); + // VIP: tables 9-10 + for (var i = 9; i <= 10; i++) + tables.Add(Table(cafeId, branchId, i.ToString(), 6, "VIP", i)); + return tables; + } + + private static Table Table( + string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) => + new() + { + Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36], + CafeId = cafeId, + BranchId = branchId, + Number = number, + Capacity = capacity, + Floor = floor, + SortOrder = sortOrder, + QrCode = Guid.NewGuid().ToString("N"), + IsActive = true, + IsCleaning = false + }; +} diff --git a/web/dashboard/src/components/demo/demo-data-banner.tsx b/web/dashboard/src/components/demo/demo-data-banner.tsx new file mode 100644 index 0000000..54ee68d --- /dev/null +++ b/web/dashboard/src/components/demo/demo-data-banner.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Sparkles, Loader2 } from "lucide-react"; +import { apiPost } from "@/lib/api/client"; +import { useAuthStore } from "@/lib/stores/auth.store"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface DemoSeedResult { + categoriesAdded: number; + itemsAdded: number; + tablesAdded: number; + ingredientsAdded: number; + taxCreated: boolean; +} + +interface Props { + /** Which queries to invalidate after seeding. */ + invalidateKeys: string[][]; + className?: string; +} + +export function DemoDataBanner({ invalidateKeys, className }: Props) { + const cafeId = useAuthStore((s) => s.user?.cafeId); + const role = useAuthStore((s) => s.user?.role); + const qc = useQueryClient(); + const [done, setDone] = useState(false); + const [summary, setSummary] = useState(null); + + const seed = useMutation({ + mutationFn: () => + apiPost(`/api/cafes/${cafeId}/demo/seed`, {}), + onSuccess: (result) => { + setSummary(result); + setDone(true); + for (const key of invalidateKeys) { + qc.invalidateQueries({ queryKey: key }); + } + }, + }); + + if (!cafeId || (role !== "Owner" && role !== "Manager")) return null; + if (done && summary) { + return ( +
+ + + داده‌های نمونه اضافه شد — {summary.categoriesAdded} دسته،{" "} + {summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "} + {summary.ingredientsAdded} ماده اولیه + {summary.taxCreated ? "، مالیات ۹٪" : ""}. + +
+ ); + } + + return ( +
+
+ +
+

شروع سریع با داده‌های نمونه

+

+ ۷ دسته، ۵۹+ آیتم منو، ۱۰ میز، ۱۵ ماده اولیه و مالیات ۹٪ به‌صورت خودکار اضافه می‌شود. +

+
+
+ +
+ ); +} diff --git a/web/dashboard/src/components/inventory/inventory-screen.tsx b/web/dashboard/src/components/inventory/inventory-screen.tsx index d4a2319..ae4df5e 100644 --- a/web/dashboard/src/components/inventory/inventory-screen.tsx +++ b/web/dashboard/src/components/inventory/inventory-screen.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslations, useLocale } from "next-intl"; import { Link } from "@/i18n/routing"; import { Pencil } from "lucide-react"; +import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client"; import { InventoryUnitField } from "@/components/inventory/inventory-unit-field"; import { useAuthStore } from "@/lib/stores/auth.store"; @@ -366,7 +367,17 @@ export function InventoryScreen() { {isLoading ? (

{tCommon("loading")}

) : ingredients.length === 0 ? ( -

{t("empty")}

+
+ +

{t("empty")}

+
) : (
{ingredients.map((ing) => { diff --git a/web/dashboard/src/components/menu/menu-admin-screen.tsx b/web/dashboard/src/components/menu/menu-admin-screen.tsx index dace65f..8689802 100644 --- a/web/dashboard/src/components/menu/menu-admin-screen.tsx +++ b/web/dashboard/src/components/menu/menu-admin-screen.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useLocale, useTranslations } from "next-intl"; import { useIsRtl } from "@/lib/use-is-rtl"; import { Box, Pencil, Plus, Search, Video, X } from "lucide-react"; +import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { Menu3dUpload } from "@/components/media/menu-3d-upload"; import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate"; import { CategoryVisual } from "@/components/menu/category-visual"; @@ -449,7 +450,19 @@ export function MenuAdminScreen() { ) ) : ( /* ── Catalog tab ─────────────────────────────────────────────────── */ -
+
+ {categories.length === 0 && items.length === 0 && ( + + )} +
{/* ── Category Sidebar (desktop) ─────────────────────────────── */}
)}
+
)} diff --git a/web/dashboard/src/components/tables/tables-screen.tsx b/web/dashboard/src/components/tables/tables-screen.tsx index 8079644..92654db 100644 --- a/web/dashboard/src/components/tables/tables-screen.tsx +++ b/web/dashboard/src/components/tables/tables-screen.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import * as signalR from "@microsoft/signalr"; import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react"; +import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { notify } from "@/lib/notify"; import { MediaPairUpload } from "@/components/media/media-pair-upload"; import { PageHeader } from "@/components/layout/page-header"; @@ -346,9 +347,19 @@ export function TablesScreen() { {isLoading ? (

{tCommon("loading")}

) : tables.length === 0 ? ( -

- {branchId ? t("emptyBranch") : t("empty")} -

+
+ +

+ {branchId ? t("emptyBranch") : t("empty")} +

+
) : ( <> {actionMessage ? (