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));
|
||||
}
|
||||
|
||||
[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")]
|
||||
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?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, 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
|
||||
@@ -192,6 +193,16 @@ public class MenuService : IMenuService
|
||||
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) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
|
||||
@@ -763,7 +763,13 @@
|
||||
"addItemSuccess": "تمت إضافة الصنف",
|
||||
"updateItemSuccess": "تم تحديث الصنف",
|
||||
"addCategorySuccess": "تمت إضافة الفئة",
|
||||
"updateCategorySuccess": "تم تحديث الفئة"
|
||||
"updateCategorySuccess": "تم تحديث الفئة",
|
||||
"deleteItemConfirmTitle": "حذف الصنف",
|
||||
"deleteItemConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"deleteItemSuccess": "تم حذف الصنف",
|
||||
"deleteCategoryConfirmTitle": "حذف الفئة",
|
||||
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
|
||||
"deleteCategorySuccess": "تم حذف الفئة"
|
||||
},
|
||||
"branchMenu": {
|
||||
"title": "قائمة الفرع",
|
||||
|
||||
@@ -806,7 +806,13 @@
|
||||
"addItemSuccess": "Item added",
|
||||
"updateItemSuccess": "Item updated",
|
||||
"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": {
|
||||
"title": "Branch Menu",
|
||||
|
||||
@@ -806,7 +806,13 @@
|
||||
"addItemSuccess": "آیتم اضافه شد",
|
||||
"updateItemSuccess": "آیتم بهروز شد",
|
||||
"addCategorySuccess": "دسته اضافه شد",
|
||||
"updateCategorySuccess": "دسته بهروز شد"
|
||||
"updateCategorySuccess": "دسته بهروز شد",
|
||||
"deleteItemConfirmTitle": "حذف آیتم",
|
||||
"deleteItemConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.",
|
||||
"deleteItemSuccess": "آیتم حذف شد",
|
||||
"deleteCategoryConfirmTitle": "حذف دستهبندی",
|
||||
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
|
||||
"deleteCategorySuccess": "دسته حذف شد"
|
||||
},
|
||||
"branchMenu": {
|
||||
"title": "منوی شعبه",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
|
||||
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 { Box, Pencil, Plus, Search, Trash2, 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";
|
||||
@@ -12,7 +12,17 @@ import { CategoryVisual } from "@/components/menu/category-visual";
|
||||
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
|
||||
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
|
||||
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 { useApiError } from "@/lib/use-api-error";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
@@ -127,6 +137,9 @@ function ToggleSwitch({
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
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)}
|
||||
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",
|
||||
@@ -209,6 +222,11 @@ export function MenuAdminScreen() {
|
||||
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["menu-categories", cafeId],
|
||||
@@ -299,6 +317,30 @@ export function MenuAdminScreen() {
|
||||
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({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
|
||||
@@ -891,11 +933,28 @@ export function MenuAdminScreen() {
|
||||
</LabeledField>
|
||||
|
||||
{/* 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
|
||||
type="button"
|
||||
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")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -907,6 +966,7 @@ export function MenuAdminScreen() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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)}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
@@ -952,7 +1032,51 @@ export function MenuAdminScreen() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user