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
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:
@@ -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));
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user