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

- 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:
soroush.asadi
2026-06-02 12:24:09 +03:30
parent 72f95aa0db
commit 7122df57b2
6 changed files with 192 additions and 30 deletions
@@ -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)
{ {
+11
View File
@@ -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();
+7 -1
View File
@@ -763,7 +763,13 @@
"addItemSuccess": "تمت إضافة الصنف", "addItemSuccess": "تمت إضافة الصنف",
"updateItemSuccess": "تم تحديث الصنف", "updateItemSuccess": "تم تحديث الصنف",
"addCategorySuccess": "تمت إضافة الفئة", "addCategorySuccess": "تمت إضافة الفئة",
"updateCategorySuccess": "تم تحديث الفئة" "updateCategorySuccess": "تم تحديث الفئة",
"deleteItemConfirmTitle": "حذف الصنف",
"deleteItemConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteItemSuccess": "تم حذف الصنف",
"deleteCategoryConfirmTitle": "حذف الفئة",
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
"deleteCategorySuccess": "تم حذف الفئة"
}, },
"branchMenu": { "branchMenu": {
"title": "قائمة الفرع", "title": "قائمة الفرع",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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>
); );
} }