feat(menu): delete category/item + fix RTL availability toggle
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 58s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m18s
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 58s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m18s
- Add DELETE /api/cafes/{cafeId}/menu/items/{id} (DeleteItemAsync soft-delete,
mirroring the existing category delete) — item delete had no backend route.
- Dashboard menu admin: destructive "delete" action in the item and category
edit modals, behind a shared confirm dialog (AlertDialog). Deleting the
selected category falls back to "all items".
- Fix the availability ToggleSwitch in RTL: force dir="ltr" so the knob's
translate-x stays inside the track instead of escaping on the right
(same fix as the admin-panel toggles).
- i18n: deleteItem/deleteCategory confirm + success strings (fa/en/ar).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,15 @@ public class MenuController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("items/{id}")]
|
||||||
|
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
|
||||||
|
if (!deleted) return NotFoundError();
|
||||||
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("ai-3d/usage")]
|
[HttpGet("ai-3d/usage")]
|
||||||
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public interface IMenuService
|
|||||||
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
|
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||||
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
|
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||||
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
|
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MenuService : IMenuService
|
public class MenuService : IMenuService
|
||||||
@@ -192,6 +193,16 @@ public class MenuService : IMenuService
|
|||||||
return ToItemDto(entity);
|
return ToItemDto(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await _db.MenuItems.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
|
||||||
|
if (entity is null) return false;
|
||||||
|
|
||||||
|
entity.DeletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? NormalizeOptionalText(string? value) =>
|
private static string? NormalizeOptionalText(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
|||||||
@@ -763,7 +763,13 @@
|
|||||||
"addItemSuccess": "تمت إضافة الصنف",
|
"addItemSuccess": "تمت إضافة الصنف",
|
||||||
"updateItemSuccess": "تم تحديث الصنف",
|
"updateItemSuccess": "تم تحديث الصنف",
|
||||||
"addCategorySuccess": "تمت إضافة الفئة",
|
"addCategorySuccess": "تمت إضافة الفئة",
|
||||||
"updateCategorySuccess": "تم تحديث الفئة"
|
"updateCategorySuccess": "تم تحديث الفئة",
|
||||||
|
"deleteItemConfirmTitle": "حذف الصنف",
|
||||||
|
"deleteItemConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"deleteItemSuccess": "تم حذف الصنف",
|
||||||
|
"deleteCategoryConfirmTitle": "حذف الفئة",
|
||||||
|
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
|
||||||
|
"deleteCategorySuccess": "تم حذف الفئة"
|
||||||
},
|
},
|
||||||
"branchMenu": {
|
"branchMenu": {
|
||||||
"title": "قائمة الفرع",
|
"title": "قائمة الفرع",
|
||||||
|
|||||||
@@ -806,7 +806,13 @@
|
|||||||
"addItemSuccess": "Item added",
|
"addItemSuccess": "Item added",
|
||||||
"updateItemSuccess": "Item updated",
|
"updateItemSuccess": "Item updated",
|
||||||
"addCategorySuccess": "Category added",
|
"addCategorySuccess": "Category added",
|
||||||
"updateCategorySuccess": "Category updated"
|
"updateCategorySuccess": "Category updated",
|
||||||
|
"deleteItemConfirmTitle": "Delete item",
|
||||||
|
"deleteItemConfirmDesc": "Are you sure you want to delete “{name}”? This can't be undone.",
|
||||||
|
"deleteItemSuccess": "Item deleted",
|
||||||
|
"deleteCategoryConfirmTitle": "Delete category",
|
||||||
|
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
|
||||||
|
"deleteCategorySuccess": "Category deleted"
|
||||||
},
|
},
|
||||||
"branchMenu": {
|
"branchMenu": {
|
||||||
"title": "Branch Menu",
|
"title": "Branch Menu",
|
||||||
|
|||||||
@@ -806,7 +806,13 @@
|
|||||||
"addItemSuccess": "آیتم اضافه شد",
|
"addItemSuccess": "آیتم اضافه شد",
|
||||||
"updateItemSuccess": "آیتم بهروز شد",
|
"updateItemSuccess": "آیتم بهروز شد",
|
||||||
"addCategorySuccess": "دسته اضافه شد",
|
"addCategorySuccess": "دسته اضافه شد",
|
||||||
"updateCategorySuccess": "دسته بهروز شد"
|
"updateCategorySuccess": "دسته بهروز شد",
|
||||||
|
"deleteItemConfirmTitle": "حذف آیتم",
|
||||||
|
"deleteItemConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.",
|
||||||
|
"deleteItemSuccess": "آیتم حذف شد",
|
||||||
|
"deleteCategoryConfirmTitle": "حذف دستهبندی",
|
||||||
|
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
|
||||||
|
"deleteCategorySuccess": "دسته حذف شد"
|
||||||
},
|
},
|
||||||
"branchMenu": {
|
"branchMenu": {
|
||||||
"title": "منوی شعبه",
|
"title": "منوی شعبه",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useIsRtl } from "@/lib/use-is-rtl";
|
import { useIsRtl } from "@/lib/use-is-rtl";
|
||||||
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react";
|
import { Box, Pencil, Plus, Search, Trash2, Video, X } from "lucide-react";
|
||||||
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
||||||
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
||||||
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
||||||
@@ -12,7 +12,17 @@ import { CategoryVisual } from "@/components/menu/category-visual";
|
|||||||
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
||||||
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
||||||
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
|
||||||
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
import { useApiError } from "@/lib/use-api-error";
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
@@ -127,6 +137,9 @@ function ToggleSwitch({
|
|||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
type="button"
|
type="button"
|
||||||
|
// Force LTR so the knob's translate-x stays inside the track; in RTL the
|
||||||
|
// flex start sits on the right and translate-x-4 would push it out.
|
||||||
|
dir="ltr"
|
||||||
onClick={() => !disabled && onChange(!checked)}
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200",
|
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200",
|
||||||
@@ -209,6 +222,11 @@ export function MenuAdminScreen() {
|
|||||||
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
|
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
|
||||||
const [catForm, setCatForm] = useState<CatForm>(defaultCatForm);
|
const [catForm, setCatForm] = useState<CatForm>(defaultCatForm);
|
||||||
|
|
||||||
|
// Delete confirmation (shared dialog for items + categories)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<
|
||||||
|
{ kind: "item" | "category"; id: string; name: string } | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
// ── Data queries ───────────────────────────────────────────────────────────
|
// ── Data queries ───────────────────────────────────────────────────────────
|
||||||
const { data: categories = [] } = useQuery({
|
const { data: categories = [] } = useQuery({
|
||||||
queryKey: ["menu-categories", cafeId],
|
queryKey: ["menu-categories", cafeId],
|
||||||
@@ -299,6 +317,30 @@ export function MenuAdminScreen() {
|
|||||||
onError: showError,
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteItemMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/items/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setItemModalOpen(false);
|
||||||
|
notify.success(t("deleteItemSuccess"));
|
||||||
|
invalidateMenu();
|
||||||
|
},
|
||||||
|
onError: showError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCategoryMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/categories/${id}`),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setCatModalOpen(false);
|
||||||
|
// If the deleted category was selected, fall back to "all items".
|
||||||
|
setSelectedCategoryId((prev) => (prev === id ? "all" : prev));
|
||||||
|
notify.success(t("deleteCategorySuccess"));
|
||||||
|
invalidateMenu();
|
||||||
|
},
|
||||||
|
onError: showError,
|
||||||
|
});
|
||||||
|
|
||||||
const addCategoryMutation = useMutation({
|
const addCategoryMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
|
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
|
||||||
@@ -891,11 +933,28 @@ export function MenuAdminScreen() {
|
|||||||
</LabeledField>
|
</LabeledField>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
<div className="flex items-center justify-between gap-2 border-t border-border pt-4">
|
||||||
|
{editingItem ? (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setItemModalOpen(false)}
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onClick={() =>
|
||||||
|
setConfirmDelete({
|
||||||
|
kind: "item",
|
||||||
|
id: editingItem.id,
|
||||||
|
name: editingItem.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
<Trash2 className="me-1.5 size-4" />
|
||||||
|
{tCommon("delete")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setItemModalOpen(false)}>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -907,6 +966,7 @@ export function MenuAdminScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* ── Category Add / Edit Modal ─────────────────────────────────────── */}
|
{/* ── Category Add / Edit Modal ─────────────────────────────────────── */}
|
||||||
@@ -939,7 +999,27 @@ export function MenuAdminScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
<div className="flex items-center justify-between gap-2 border-t border-border pt-4">
|
||||||
|
{editingCategory ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onClick={() =>
|
||||||
|
setConfirmDelete({
|
||||||
|
kind: "category",
|
||||||
|
id: editingCategory.id,
|
||||||
|
name: editingCategory.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="me-1.5 size-4" />
|
||||||
|
{tCommon("delete")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" onClick={() => setCatModalOpen(false)}>
|
<Button variant="ghost" onClick={() => setCatModalOpen(false)}>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -952,7 +1032,51 @@ export function MenuAdminScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Delete confirmation (items + categories) ──────────────────────── */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!confirmDelete}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setConfirmDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent dir={isRtl ? "rtl" : "ltr"}>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{confirmDelete?.kind === "category"
|
||||||
|
? t("deleteCategoryConfirmTitle")
|
||||||
|
: t("deleteItemConfirmTitle")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{confirmDelete?.kind === "category"
|
||||||
|
? t("deleteCategoryConfirmDesc", { name: confirmDelete?.name ?? "" })
|
||||||
|
: t("deleteItemConfirmDesc", { name: confirmDelete?.name ?? "" })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
disabled={deleteItemMutation.isPending || deleteCategoryMutation.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault(); // keep dialog open until the mutation resolves
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
if (confirmDelete.kind === "category") {
|
||||||
|
deleteCategoryMutation.mutate(confirmDelete.id);
|
||||||
|
} else {
|
||||||
|
deleteItemMutation.mutate(confirmDelete.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteItemMutation.isPending || deleteCategoryMutation.isPending
|
||||||
|
? t("saving")
|
||||||
|
: tCommon("delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user