feat: demo data seeder — one-click setup for new cafés
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Has been cancelled
CI/CD / CI · Dashboard (tsc) (push) Has been cancelled
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-01 00:27:34 +03:30
parent ae5896d440
commit 260429afba
7 changed files with 342 additions and 5 deletions
@@ -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;
}
/// <summary>Seeds demo menu, tables, and inventory for any café. Owner-only.</summary>
[HttpPost("seed")]
public async Task<IActionResult> 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<DemoSeedResult>(true, result));
}
}
@@ -91,6 +91,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IQueueService, QueueService>();
services.AddScoped<IShiftService, ShiftService>();
services.AddScoped<IExpenseService, ExpenseService>();
services.AddScoped<IDemoSeedService, DemoSeedService>();
services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>();
services.AddHttpClient(nameof(PosDeviceService));
+172
View File
@@ -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<DemoSeedResult> SeedAsync(string cafeId, CancellationToken ct = default);
}
public class DemoSeedService : IDemoSeedService
{
private readonly AppDbContext _db;
private readonly ILogger<DemoSeedService> _logger;
public DemoSeedService(AppDbContext db, ILogger<DemoSeedService> logger)
{
_db = db;
_logger = logger;
}
public async Task<DemoSeedResult> 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<Ingredient> 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<Table> BuildDemoTables(string cafeId, string branchId)
{
var tables = new List<Table>();
// 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
};
}
@@ -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<DemoSeedResult | null>(null);
const seed = useMutation({
mutationFn: () =>
apiPost<DemoSeedResult>(`/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 (
<div
className={cn(
"flex items-center gap-3 rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE] px-4 py-3 text-sm text-[#0F6E56]",
className
)}
>
<Sparkles className="size-4 shrink-0" />
<span>
دادههای نمونه اضافه شد {summary.categoriesAdded} دسته،{" "}
{summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "}
{summary.ingredientsAdded} ماده اولیه
{summary.taxCreated ? "، مالیات ۹٪" : ""}.
</span>
</div>
);
}
return (
<div
className={cn(
"flex flex-col gap-3 rounded-xl border border-dashed border-[#0F6E56]/40 bg-[#E1F5EE]/40 px-5 py-4 sm:flex-row sm:items-center sm:justify-between",
className
)}
>
<div className="flex items-center gap-3">
<Sparkles className="size-5 shrink-0 text-[#0F6E56]" />
<div>
<p className="text-sm font-semibold text-[#0F6E56]">شروع سریع با دادههای نمونه</p>
<p className="text-xs text-muted-foreground mt-0.5">
۷ دسته، ۵۹+ آیتم منو، ۱۰ میز، ۱۵ ماده اولیه و مالیات ۹٪ بهصورت خودکار اضافه میشود.
</p>
</div>
</div>
<Button
size="sm"
className="shrink-0 bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={seed.isPending}
onClick={() => seed.mutate()}
>
{seed.isPending ? (
<Loader2 className="me-1.5 size-4 animate-spin" />
) : (
<Sparkles className="me-1.5 size-4" />
)}
افزودن دادههای نمونه
</Button>
</div>
);
}
@@ -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 ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : ingredients.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
<div className="space-y-3">
<DemoDataBanner
invalidateKeys={[
["inventory", cafeId!],
["menu-categories", cafeId!],
["menu-items-all", cafeId!],
["tables-board", cafeId!],
]}
/>
<p className="text-sm text-muted-foreground">{t("empty")}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{ingredients.map((ing) => {
@@ -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 ─────────────────────────────────────────────────── */
<div className="flex min-h-0 gap-4">
<div className="flex min-h-0 flex-col gap-4">
{categories.length === 0 && items.length === 0 && (
<DemoDataBanner
invalidateKeys={[
["menu-categories", cafeId],
["menu-items-all", cafeId],
["menu-items", cafeId],
["tables-board", cafeId],
["inventory", cafeId],
]}
/>
)}
<div className="flex min-h-0 gap-4">
{/* ── Category Sidebar (desktop) ─────────────────────────────── */}
<aside className="hidden w-52 shrink-0 lg:block">
@@ -752,6 +765,7 @@ export function MenuAdminScreen() {
</div>
)}
</div>
</div>
</div>
)}
@@ -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 ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : tables.length === 0 ? (
<p className="text-sm text-muted-foreground">
{branchId ? t("emptyBranch") : t("empty")}
</p>
<div className="space-y-3">
<DemoDataBanner
invalidateKeys={[
["tables-board", cafeId],
["menu-categories", cafeId],
["menu-items-all", cafeId],
["inventory", cafeId],
]}
/>
<p className="text-sm text-muted-foreground">
{branchId ? t("emptyBranch") : t("empty")}
</p>
</div>
) : (
<>
{actionMessage ? (