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 ? (