2026-05-27 21:34:12 +03:30
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
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";
|
2026-05-28 08:10:25 +03:30
|
|
|
|
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react";
|
2026-05-27 21:34:12 +03:30
|
|
|
|
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
|
|
|
|
|
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
|
|
|
|
|
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 { useAuthStore } from "@/lib/stores/auth.store";
|
2026-05-28 08:10:25 +03:30
|
|
|
|
import { useBranchStore } from "@/lib/stores/branch.store";
|
2026-05-27 21:34:12 +03:30
|
|
|
|
import { formatCurrency, formatNumber } from "@/lib/format";
|
|
|
|
|
|
import { PageHeader } from "@/components/layout/page-header";
|
|
|
|
|
|
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { LabeledField } from "@/components/ui/labeled-field";
|
2026-05-28 08:10:25 +03:30
|
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
2026-05-27 21:34:12 +03:30
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
|
|
|
|
|
import { MenuItemMedia } from "@/components/menu/menu-item-media";
|
|
|
|
|
|
import { buildCategoryNameMap, inferMenuItemKind } from "@/lib/menu-item-image";
|
2026-05-28 08:10:25 +03:30
|
|
|
|
import { menuItemMatchesSearch } from "@/lib/menu-display";
|
|
|
|
|
|
import { BranchMenuOverrides } from "@/components/menu/branch-menu-overrides";
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
2026-05-27 21:34:12 +03:30
|
|
|
|
|
|
|
|
|
|
interface MenuCategory {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
nameEn?: string;
|
|
|
|
|
|
nameAr?: string;
|
|
|
|
|
|
sortOrder: number;
|
|
|
|
|
|
discountPercent: number;
|
|
|
|
|
|
icon?: string;
|
|
|
|
|
|
iconPresetId?: string;
|
|
|
|
|
|
iconStyle?: string;
|
|
|
|
|
|
imageUrl?: string;
|
|
|
|
|
|
isActive: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface MenuItem {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
categoryId: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
nameEn?: string;
|
|
|
|
|
|
nameAr?: string;
|
|
|
|
|
|
price: number;
|
|
|
|
|
|
discountPercent: number;
|
|
|
|
|
|
imageUrl?: string;
|
|
|
|
|
|
videoUrl?: string;
|
|
|
|
|
|
model3dUrl?: string;
|
|
|
|
|
|
isAvailable: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
interface ItemForm {
|
|
|
|
|
|
categoryId: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
nameEn: string;
|
|
|
|
|
|
price: string;
|
|
|
|
|
|
discount: string;
|
|
|
|
|
|
imageUrl: string;
|
|
|
|
|
|
videoUrl: string;
|
|
|
|
|
|
model3dUrl: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface CatForm {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
icon: string;
|
|
|
|
|
|
iconPreset: CategoryIconSelection;
|
|
|
|
|
|
imageUrl: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-27 21:34:12 +03:30
|
|
|
|
function discountedPrice(price: number, percent: number) {
|
|
|
|
|
|
if (percent <= 0) return price;
|
|
|
|
|
|
return Math.round(price * (1 - percent / 100));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mediaField(url: string) {
|
|
|
|
|
|
return url.trim() === "" ? "" : url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
const defaultItemForm: ItemForm = {
|
|
|
|
|
|
categoryId: "",
|
|
|
|
|
|
name: "",
|
|
|
|
|
|
nameEn: "",
|
|
|
|
|
|
price: "",
|
|
|
|
|
|
discount: "0",
|
|
|
|
|
|
imageUrl: "",
|
|
|
|
|
|
videoUrl: "",
|
|
|
|
|
|
model3dUrl: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const defaultCatForm: CatForm = {
|
|
|
|
|
|
name: "",
|
|
|
|
|
|
icon: "",
|
|
|
|
|
|
iconPreset: { iconPresetId: null, iconStyle: DEFAULT_CATEGORY_ICON_STYLE },
|
|
|
|
|
|
imageUrl: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Toggle Switch ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function ToggleSwitch({
|
|
|
|
|
|
checked,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
disabled,
|
|
|
|
|
|
label,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
checked: boolean;
|
|
|
|
|
|
onChange: (v: boolean) => void;
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
label?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
role="switch"
|
|
|
|
|
|
aria-checked={checked}
|
|
|
|
|
|
aria-label={label}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
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",
|
|
|
|
|
|
checked ? "bg-[#0F6E56]" : "bg-slate-300 dark:bg-slate-600",
|
|
|
|
|
|
disabled && "cursor-not-allowed opacity-50"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-md transition-transform duration-200",
|
|
|
|
|
|
checked ? "translate-x-4" : "translate-x-0"
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Modal wrapper ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function Modal({
|
|
|
|
|
|
open,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
title,
|
|
|
|
|
|
children,
|
|
|
|
|
|
maxWidth = "max-w-lg",
|
|
|
|
|
|
}: {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
|
maxWidth?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 pt-12">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"w-full rounded-2xl border border-border bg-background shadow-2xl",
|
|
|
|
|
|
maxWidth
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
|
|
|
|
|
<h2 className="text-base font-semibold">{title}</h2>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" onClick={onClose}>
|
|
|
|
|
|
<X className="size-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="max-h-[80vh] overflow-y-auto p-5">{children}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-27 21:34:12 +03:30
|
|
|
|
export function MenuAdminScreen() {
|
|
|
|
|
|
const t = useTranslations("menuAdmin");
|
|
|
|
|
|
const tCommon = useTranslations("common");
|
|
|
|
|
|
const isRtl = useIsRtl();
|
|
|
|
|
|
const locale = useLocale();
|
|
|
|
|
|
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
|
|
|
|
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
2026-05-28 08:10:25 +03:30
|
|
|
|
const branchId = useBranchStore((s) => s.branchId);
|
2026-05-27 21:34:12 +03:30
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
// ── UI state ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
const [activeTab, setActiveTab] = useState<"catalog" | "branch">("catalog");
|
|
|
|
|
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | "all">("all");
|
|
|
|
|
|
const [itemSearch, setItemSearch] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
// Item modal
|
|
|
|
|
|
const [itemModalOpen, setItemModalOpen] = useState(false);
|
|
|
|
|
|
const [editingItem, setEditingItem] = useState<MenuItem | null>(null);
|
|
|
|
|
|
const [itemForm, setItemForm] = useState<ItemForm>(defaultItemForm);
|
|
|
|
|
|
|
|
|
|
|
|
// Category modal
|
|
|
|
|
|
const [catModalOpen, setCatModalOpen] = useState(false);
|
|
|
|
|
|
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
|
|
|
|
|
|
const [catForm, setCatForm] = useState<CatForm>(defaultCatForm);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Data queries ───────────────────────────────────────────────────────────
|
2026-05-27 21:34:12 +03:30
|
|
|
|
const { data: categories = [] } = useQuery({
|
|
|
|
|
|
queryKey: ["menu-categories", cafeId],
|
|
|
|
|
|
queryFn: () => apiGet<MenuCategory[]>(`/api/cafes/${cafeId}/menu/categories`),
|
|
|
|
|
|
enabled: !!cafeId,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const { data: items = [], isLoading } = useQuery({
|
|
|
|
|
|
queryKey: ["menu-items-all", cafeId],
|
|
|
|
|
|
queryFn: () => apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`),
|
|
|
|
|
|
enabled: !!cafeId,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const categoryNameById = useMemo(
|
|
|
|
|
|
() => buildCategoryNameMap(categories),
|
|
|
|
|
|
[categories]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
// ── Derived data ───────────────────────────────────────────────────────────
|
|
|
|
|
|
const itemCountByCategory = useMemo(() => {
|
|
|
|
|
|
const counts: Record<string, number> = {};
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
|
counts[item.categoryId] = (counts[item.categoryId] ?? 0) + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return counts;
|
|
|
|
|
|
}, [items]);
|
|
|
|
|
|
|
|
|
|
|
|
const filteredItems = useMemo(() => {
|
|
|
|
|
|
let result = items;
|
|
|
|
|
|
if (selectedCategoryId !== "all") {
|
|
|
|
|
|
result = result.filter((i) => i.categoryId === selectedCategoryId);
|
|
|
|
|
|
}
|
|
|
|
|
|
const q = itemSearch.trim();
|
|
|
|
|
|
if (q) {
|
|
|
|
|
|
result = result.filter((i) => menuItemMatchesSearch(i, q, locale));
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}, [items, selectedCategoryId, itemSearch, locale]);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Mutations ──────────────────────────────────────────────────────────────
|
2026-05-27 21:34:12 +03:30
|
|
|
|
const invalidateMenu = () => {
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["menu-items-all", cafeId] });
|
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["menu-categories", cafeId] });
|
2026-05-28 08:10:25 +03:30
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["menu-items", cafeId] });
|
2026-05-27 21:34:12 +03:30
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
const addItemMutation = useMutation({
|
2026-05-27 21:34:12 +03:30
|
|
|
|
mutationFn: () =>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
apiPost(`/api/cafes/${cafeId}/menu/items`, {
|
|
|
|
|
|
categoryId: itemForm.categoryId,
|
|
|
|
|
|
name: itemForm.name,
|
|
|
|
|
|
nameEn: itemForm.nameEn.trim() || null,
|
|
|
|
|
|
price: parseFloat(itemForm.price),
|
|
|
|
|
|
discountPercent: parseFloat(itemForm.discount) || 0,
|
|
|
|
|
|
imageUrl: itemForm.imageUrl || null,
|
|
|
|
|
|
videoUrl: itemForm.videoUrl || null,
|
|
|
|
|
|
model3dUrl: itemForm.model3dUrl || null,
|
2026-05-27 21:34:12 +03:30
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => {
|
2026-05-28 08:10:25 +03:30
|
|
|
|
setItemModalOpen(false);
|
2026-05-27 21:34:12 +03:30
|
|
|
|
invalidateMenu();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
const updateItemMutation = useMutation({
|
2026-05-27 21:34:12 +03:30
|
|
|
|
mutationFn: (id: string) =>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}`, {
|
|
|
|
|
|
name: itemForm.name,
|
|
|
|
|
|
nameEn: itemForm.nameEn.trim() || null,
|
|
|
|
|
|
price: parseFloat(itemForm.price),
|
|
|
|
|
|
discountPercent: parseFloat(itemForm.discount) || 0,
|
|
|
|
|
|
imageUrl: mediaField(itemForm.imageUrl),
|
|
|
|
|
|
videoUrl: mediaField(itemForm.videoUrl),
|
|
|
|
|
|
model3dUrl: mediaField(itemForm.model3dUrl),
|
2026-05-27 21:34:12 +03:30
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => {
|
2026-05-28 08:10:25 +03:30
|
|
|
|
setItemModalOpen(false);
|
2026-05-27 21:34:12 +03:30
|
|
|
|
invalidateMenu();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
const toggleItemMutation = useMutation({
|
|
|
|
|
|
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
|
|
|
|
|
|
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
|
|
|
|
|
|
onSuccess: invalidateMenu,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const addCategoryMutation = useMutation({
|
2026-05-27 21:34:12 +03:30
|
|
|
|
mutationFn: () =>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
|
|
|
|
|
|
name: catForm.name,
|
|
|
|
|
|
sortOrder: categories.length + 1,
|
|
|
|
|
|
discountPercent: 0,
|
|
|
|
|
|
icon: catForm.icon.trim() || null,
|
|
|
|
|
|
iconPresetId: catForm.iconPreset.iconPresetId,
|
|
|
|
|
|
iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : null,
|
|
|
|
|
|
imageUrl: catForm.imageUrl.trim() || null,
|
2026-05-27 21:34:12 +03:30
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => {
|
2026-05-28 08:10:25 +03:30
|
|
|
|
setCatModalOpen(false);
|
2026-05-27 21:34:12 +03:30
|
|
|
|
invalidateMenu();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
const updateCategoryMutation = useMutation({
|
2026-05-27 21:34:12 +03:30
|
|
|
|
mutationFn: (id: string) =>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
apiPatch(`/api/cafes/${cafeId}/menu/categories/${id}`, {
|
|
|
|
|
|
name: catForm.name,
|
|
|
|
|
|
icon: mediaField(catForm.icon),
|
|
|
|
|
|
iconPresetId: catForm.iconPreset.iconPresetId ?? "",
|
|
|
|
|
|
iconStyle: catForm.iconPreset.iconPresetId ? catForm.iconPreset.iconStyle : "",
|
|
|
|
|
|
imageUrl: mediaField(catForm.imageUrl),
|
2026-05-27 21:34:12 +03:30
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => {
|
2026-05-28 08:10:25 +03:30
|
|
|
|
setCatModalOpen(false);
|
2026-05-27 21:34:12 +03:30
|
|
|
|
invalidateMenu();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
// ── Modal openers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
const openAddItem = () => {
|
|
|
|
|
|
setEditingItem(null);
|
|
|
|
|
|
setItemForm({
|
|
|
|
|
|
...defaultItemForm,
|
|
|
|
|
|
categoryId:
|
|
|
|
|
|
selectedCategoryId !== "all" ? selectedCategoryId : (categories[0]?.id ?? ""),
|
|
|
|
|
|
});
|
|
|
|
|
|
setItemModalOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const openEditItem = (item: MenuItem) => {
|
|
|
|
|
|
setEditingItem(item);
|
|
|
|
|
|
setItemForm({
|
|
|
|
|
|
categoryId: item.categoryId,
|
|
|
|
|
|
name: item.name,
|
|
|
|
|
|
nameEn: item.nameEn ?? "",
|
|
|
|
|
|
price: String(item.price),
|
|
|
|
|
|
discount: String(item.discountPercent),
|
|
|
|
|
|
imageUrl: item.imageUrl ?? "",
|
|
|
|
|
|
videoUrl: item.videoUrl ?? "",
|
|
|
|
|
|
model3dUrl: item.model3dUrl ?? "",
|
|
|
|
|
|
});
|
|
|
|
|
|
setItemModalOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const openAddCategory = () => {
|
|
|
|
|
|
setEditingCategory(null);
|
|
|
|
|
|
setCatForm(defaultCatForm);
|
|
|
|
|
|
setCatModalOpen(true);
|
|
|
|
|
|
};
|
2026-05-27 21:34:12 +03:30
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
const openEditCategory = (cat: MenuCategory) => {
|
|
|
|
|
|
setEditingCategory(cat);
|
|
|
|
|
|
setCatForm({
|
|
|
|
|
|
name: cat.name,
|
|
|
|
|
|
icon: cat.icon ?? "",
|
|
|
|
|
|
iconPreset: {
|
|
|
|
|
|
iconPresetId: cat.iconPresetId ?? null,
|
|
|
|
|
|
iconStyle:
|
|
|
|
|
|
(cat.iconStyle as CategoryIconSelection["iconStyle"]) ??
|
|
|
|
|
|
DEFAULT_CATEGORY_ICON_STYLE,
|
|
|
|
|
|
},
|
|
|
|
|
|
imageUrl: cat.imageUrl ?? "",
|
2026-05-27 21:34:12 +03:30
|
|
|
|
});
|
2026-05-28 08:10:25 +03:30
|
|
|
|
setCatModalOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Form submit handlers ───────────────────────────────────────────────────
|
|
|
|
|
|
const handleItemSave = () => {
|
|
|
|
|
|
if (editingItem) {
|
|
|
|
|
|
updateItemMutation.mutate(editingItem.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addItemMutation.mutate();
|
|
|
|
|
|
}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
const handleCategorySave = () => {
|
|
|
|
|
|
if (editingCategory) {
|
|
|
|
|
|
updateCategoryMutation.mutate(editingCategory.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addCategoryMutation.mutate();
|
|
|
|
|
|
}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
const itemMutationBusy =
|
|
|
|
|
|
addItemMutation.isPending || updateItemMutation.isPending;
|
|
|
|
|
|
const catMutationBusy =
|
|
|
|
|
|
addCategoryMutation.isPending || updateCategoryMutation.isPending;
|
|
|
|
|
|
|
|
|
|
|
|
const itemFormValid =
|
|
|
|
|
|
itemForm.name.trim() &&
|
|
|
|
|
|
itemForm.categoryId &&
|
|
|
|
|
|
itemForm.price &&
|
|
|
|
|
|
!isNaN(parseFloat(itemForm.price));
|
|
|
|
|
|
|
2026-05-27 21:34:12 +03:30
|
|
|
|
if (!cafeId) return null;
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
// ── Tab bar ────────────────────────────────────────────────────────────────
|
2026-05-27 21:34:12 +03:30
|
|
|
|
return (
|
2026-05-28 08:10:25 +03:30
|
|
|
|
<div className="space-y-4" dir={isRtl ? "rtl" : "ltr"}>
|
2026-05-27 21:34:12 +03:30
|
|
|
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
2026-05-28 08:10:25 +03:30
|
|
|
|
|
|
|
|
|
|
{/* Tab switcher */}
|
|
|
|
|
|
<div className="flex gap-1 rounded-xl border border-border bg-muted/40 p-1 w-fit">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setActiveTab("catalog")}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors cursor-pointer",
|
|
|
|
|
|
activeTab === "catalog"
|
|
|
|
|
|
? "bg-background shadow-sm text-foreground"
|
|
|
|
|
|
: "text-muted-foreground hover:text-foreground"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("tabCatalog")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setActiveTab("branch")}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors cursor-pointer",
|
|
|
|
|
|
activeTab === "branch"
|
|
|
|
|
|
? "bg-background shadow-sm text-foreground"
|
|
|
|
|
|
: "text-muted-foreground hover:text-foreground"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("tabBranch")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Branch tab */}
|
|
|
|
|
|
{activeTab === "branch" ? (
|
|
|
|
|
|
branchId ? (
|
|
|
|
|
|
<BranchMenuOverrides
|
|
|
|
|
|
cafeId={cafeId!}
|
|
|
|
|
|
branchId={branchId}
|
|
|
|
|
|
numberLocale={numberLocale}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
/>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
) : (
|
|
|
|
|
|
<p className="rounded-xl border border-border bg-muted/40 p-6 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
{t("selectBranchForOverrides")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)
|
|
|
|
|
|
) : (
|
|
|
|
|
|
/* ── Catalog tab ─────────────────────────────────────────────────── */
|
|
|
|
|
|
<div className="flex min-h-0 gap-4">
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Category Sidebar (desktop) ─────────────────────────────── */}
|
|
|
|
|
|
<aside className="hidden w-52 shrink-0 lg:block">
|
|
|
|
|
|
<div className="sticky top-4 flex flex-col gap-1 rounded-xl border border-border bg-card p-2 shadow-sm">
|
|
|
|
|
|
{/* "All items" entry */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setSelectedCategoryId("all")}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2.5 text-start text-sm transition-colors",
|
|
|
|
|
|
selectedCategoryId === "all"
|
|
|
|
|
|
? "bg-primary/10 font-semibold text-primary"
|
|
|
|
|
|
: "text-foreground hover:bg-accent"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="size-5 flex items-center justify-center text-base">✦</span>
|
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">{t("allItems")}</span>
|
|
|
|
|
|
<span className="shrink-0 rounded-full bg-border/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
|
|
|
|
|
{items.length}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="my-1 h-px bg-border/60" />
|
|
|
|
|
|
|
|
|
|
|
|
{/* Category entries */}
|
|
|
|
|
|
{categories.map((cat) => (
|
|
|
|
|
|
<div key={cat.id} className="group relative flex items-center">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setSelectedCategoryId(cat.id)}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2.5 text-start text-sm transition-colors pe-8",
|
|
|
|
|
|
selectedCategoryId === cat.id
|
|
|
|
|
|
? "bg-primary/10 font-semibold text-primary"
|
|
|
|
|
|
: "text-foreground hover:bg-accent"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<CategoryVisual
|
|
|
|
|
|
icon={cat.icon}
|
|
|
|
|
|
iconPresetId={cat.iconPresetId}
|
|
|
|
|
|
iconStyle={cat.iconStyle}
|
|
|
|
|
|
imageUrl={cat.imageUrl}
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">{cat.name}</span>
|
|
|
|
|
|
<span className="shrink-0 rounded-full bg-border/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
|
|
|
|
|
{itemCountByCategory[cat.id] ?? 0}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/* Edit category button */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label={t("editCategory")}
|
|
|
|
|
|
onClick={() => openEditCategory(cat)}
|
|
|
|
|
|
className="absolute end-1 top-1/2 -translate-y-1/2 flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Pencil className="size-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Add category button */}
|
|
|
|
|
|
<div className="mt-1 border-t border-border/60 pt-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={openAddCategory}
|
|
|
|
|
|
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="size-4 shrink-0" />
|
|
|
|
|
|
{t("addCategory")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Items panel ────────────────────────────────────────────── */}
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
|
|
|
|
|
|
{/* Mobile category tabs */}
|
|
|
|
|
|
<div className="mb-3 flex gap-1.5 overflow-x-auto pb-1 lg:hidden [scrollbar-width:none]">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setSelectedCategoryId("all")}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors cursor-pointer",
|
|
|
|
|
|
selectedCategoryId === "all"
|
|
|
|
|
|
? "border-primary bg-primary/10 text-primary"
|
|
|
|
|
|
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("allItems")} ({items.length})
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{categories.map((cat) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={cat.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setSelectedCategoryId(cat.id)}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
className={cn(
|
2026-05-28 08:10:25 +03:30
|
|
|
|
"shrink-0 flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors cursor-pointer",
|
|
|
|
|
|
selectedCategoryId === cat.id
|
|
|
|
|
|
? "border-primary bg-primary/10 text-primary"
|
|
|
|
|
|
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
2026-05-27 21:34:12 +03:30
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<CategoryVisual
|
2026-05-28 08:10:25 +03:30
|
|
|
|
icon={cat.icon}
|
|
|
|
|
|
iconPresetId={cat.iconPresetId}
|
|
|
|
|
|
iconStyle={cat.iconStyle}
|
|
|
|
|
|
imageUrl={cat.imageUrl}
|
|
|
|
|
|
size="xs"
|
2026-05-27 21:34:12 +03:30
|
|
|
|
/>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
{cat.name}
|
|
|
|
|
|
<span className="text-[10px] opacity-60">
|
|
|
|
|
|
{itemCountByCategory[cat.id] ?? 0}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={openAddCategory}
|
|
|
|
|
|
className="shrink-0 flex items-center gap-1 rounded-full border border-dashed border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground cursor-pointer"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="size-3" />
|
|
|
|
|
|
{t("addCategory")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Search + Add bar */}
|
|
|
|
|
|
<div className="mb-4 flex items-center gap-2">
|
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
|
<Search className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
2026-05-27 21:34:12 +03:30
|
|
|
|
<Input
|
2026-05-28 08:10:25 +03:30
|
|
|
|
type="search"
|
|
|
|
|
|
value={itemSearch}
|
|
|
|
|
|
onChange={(e) => setItemSearch(e.target.value)}
|
|
|
|
|
|
placeholder={t("searchItemsPlaceholder")}
|
|
|
|
|
|
className="h-9 ps-9 pe-9"
|
2026-05-27 21:34:12 +03:30
|
|
|
|
/>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
{itemSearch ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="absolute end-1 top-1/2 size-7 -translate-y-1/2"
|
|
|
|
|
|
onClick={() => setItemSearch("")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="size-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
2026-05-27 21:34:12 +03:30
|
|
|
|
<Button
|
2026-05-28 08:10:25 +03:30
|
|
|
|
onClick={openAddItem}
|
|
|
|
|
|
className="shrink-0 bg-[#0F6E56] hover:bg-[#0c5a46]"
|
|
|
|
|
|
disabled={categories.length === 0}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
<Plus className="me-1.5 size-4" />
|
|
|
|
|
|
{t("newItem")}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-28 08:10:25 +03:30
|
|
|
|
{/* Items grid */}
|
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
|
|
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
|
|
|
|
<Skeleton key={i} className="h-52 rounded-xl" />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : filteredItems.length === 0 ? (
|
|
|
|
|
|
/* Empty state */
|
|
|
|
|
|
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border py-16 text-center">
|
|
|
|
|
|
<div className="text-4xl">🍽️</div>
|
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
|
|
|
|
{itemSearch ? t("noItemsMatchSearch") : t("noItemsInCategory")}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{!itemSearch ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={openAddItem}
|
|
|
|
|
|
disabled={categories.length === 0}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="me-1.5 size-4" />
|
|
|
|
|
|
{t("addItem")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
|
|
|
|
{filteredItems.map((item) => {
|
|
|
|
|
|
const kind = inferMenuItemKind(
|
|
|
|
|
|
item.categoryId,
|
|
|
|
|
|
categoryNameById.get(item.categoryId)
|
|
|
|
|
|
);
|
|
|
|
|
|
const hasDiscount = item.discountPercent > 0;
|
|
|
|
|
|
const salePrice = discountedPrice(item.price, item.discountPercent);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={item.id}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
className={cn(
|
2026-05-28 08:10:25 +03:30
|
|
|
|
"group relative flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
|
|
|
|
|
|
!item.isAvailable && "opacity-70"
|
2026-05-27 21:34:12 +03:30
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
{/* Image area */}
|
|
|
|
|
|
<div className="relative aspect-[4/3] overflow-hidden bg-muted/50">
|
|
|
|
|
|
<MenuItemMedia
|
|
|
|
|
|
imageUrl={item.imageUrl}
|
|
|
|
|
|
kind={kind}
|
|
|
|
|
|
size="md"
|
|
|
|
|
|
className="absolute inset-0"
|
2026-05-27 21:34:12 +03:30
|
|
|
|
/>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
|
|
|
|
|
|
{/* Hover overlay — edit button */}
|
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-all group-hover:bg-black/25">
|
2026-05-27 21:34:12 +03:30
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
2026-05-28 08:10:25 +03:30
|
|
|
|
className="translate-y-2 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
|
|
|
|
|
|
onClick={() => openEditItem(item)}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
<Pencil className="me-1.5 size-3.5" />
|
|
|
|
|
|
{t("editItem")}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
|
|
|
|
|
|
{/* Discount badge */}
|
|
|
|
|
|
{hasDiscount ? (
|
|
|
|
|
|
<span className="absolute start-2 top-2 z-10 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-[#BA7517]">
|
|
|
|
|
|
{formatNumber(item.discountPercent, numberLocale)}% {t("discountBadge")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Media badges */}
|
|
|
|
|
|
<div className="absolute bottom-1.5 start-1.5 flex gap-1">
|
|
|
|
|
|
{item.videoUrl ? (
|
|
|
|
|
|
<span className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] text-white">
|
|
|
|
|
|
<Video className="size-2.5" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{item.model3dUrl ? (
|
|
|
|
|
|
<span className="flex items-center gap-0.5 rounded-md bg-[#0F6E56]/90 px-1.5 py-0.5 text-[10px] text-white">
|
|
|
|
|
|
<Box className="size-2.5" />
|
|
|
|
|
|
3D
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Out of stock overlay */}
|
|
|
|
|
|
{!item.isAvailable ? (
|
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-[1px]">
|
|
|
|
|
|
<span className="rounded-full bg-slate-800/80 px-3 py-1 text-xs font-medium text-white">
|
|
|
|
|
|
{t("outOfStock")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Body */}
|
|
|
|
|
|
<div className="flex items-start gap-2 p-3">
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<MenuItemLabels
|
|
|
|
|
|
item={item}
|
|
|
|
|
|
lines={1}
|
|
|
|
|
|
primaryClassName="text-sm font-medium"
|
|
|
|
|
|
secondaryClassName="text-[10px]"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="mt-1 flex items-baseline gap-1.5">
|
|
|
|
|
|
{hasDiscount ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="text-[11px] text-muted-foreground line-through">
|
|
|
|
|
|
{formatCurrency(item.price, numberLocale)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-sm font-semibold text-[#0F6E56]">
|
|
|
|
|
|
{formatCurrency(salePrice, numberLocale)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-sm font-semibold text-[#0F6E56]">
|
|
|
|
|
|
{formatCurrency(item.price, numberLocale)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Availability toggle */}
|
|
|
|
|
|
<ToggleSwitch
|
|
|
|
|
|
checked={item.isAvailable}
|
|
|
|
|
|
onChange={(v) =>
|
|
|
|
|
|
toggleItemMutation.mutate({ id: item.id, isAvailable: v })
|
|
|
|
|
|
}
|
|
|
|
|
|
disabled={toggleItemMutation.isPending}
|
|
|
|
|
|
label={
|
|
|
|
|
|
item.isAvailable ? t("available") : t("unavailable")
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-27 21:34:12 +03:30
|
|
|
|
</div>
|
2026-05-28 08:10:25 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Item Add / Edit Modal ─────────────────────────────────────────── */}
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
open={itemModalOpen}
|
|
|
|
|
|
onClose={() => setItemModalOpen(false)}
|
|
|
|
|
|
title={editingItem ? t("editItem") : t("newItem")}
|
|
|
|
|
|
maxWidth="max-w-lg"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{/* Category selector */}
|
|
|
|
|
|
<LabeledField label={t("category")} htmlFor="modal-item-category">
|
|
|
|
|
|
<select
|
|
|
|
|
|
id="modal-item-category"
|
|
|
|
|
|
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
|
|
|
|
|
|
value={itemForm.categoryId}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setItemForm((f) => ({ ...f, categoryId: e.target.value }))
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
|
{categories.map((c) => (
|
|
|
|
|
|
<option key={c.id} value={c.id}>
|
|
|
|
|
|
{c.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</LabeledField>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
|
|
|
<LabeledField label={t("name")} htmlFor="modal-item-name">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="modal-item-name"
|
|
|
|
|
|
value={itemForm.name}
|
|
|
|
|
|
onChange={(e) => setItemForm((f) => ({ ...f, name: e.target.value }))}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
/>
|
|
|
|
|
|
</LabeledField>
|
|
|
|
|
|
<LabeledField label={t("nameEnOptional")} htmlFor="modal-item-name-en">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="modal-item-name-en"
|
|
|
|
|
|
value={itemForm.nameEn}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setItemForm((f) => ({ ...f, nameEn: e.target.value }))
|
|
|
|
|
|
}
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
className="text-start"
|
|
|
|
|
|
placeholder="e.g. Espresso"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</LabeledField>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
|
|
|
<LabeledField label={t("price")} htmlFor="modal-item-price">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="modal-item-price"
|
|
|
|
|
|
value={itemForm.price}
|
|
|
|
|
|
onChange={(e) => setItemForm((f) => ({ ...f, price: e.target.value }))}
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
className="text-end"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</LabeledField>
|
|
|
|
|
|
<LabeledField label={t("discountPercent")} htmlFor="modal-item-discount">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="modal-item-discount"
|
|
|
|
|
|
value={itemForm.discount}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setItemForm((f) => ({ ...f, discount: e.target.value }))
|
|
|
|
|
|
}
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
className="text-end"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</LabeledField>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Media */}
|
|
|
|
|
|
<LabeledField label={t("media")}>
|
|
|
|
|
|
<MediaPairUpload
|
|
|
|
|
|
cafeId={cafeId!}
|
|
|
|
|
|
kind="menu"
|
|
|
|
|
|
imageUrl={itemForm.imageUrl}
|
|
|
|
|
|
videoUrl={itemForm.videoUrl}
|
|
|
|
|
|
onImageChange={(url) =>
|
|
|
|
|
|
setItemForm((f) => ({ ...f, imageUrl: url ?? "" }))
|
|
|
|
|
|
}
|
|
|
|
|
|
onVideoChange={(url) =>
|
|
|
|
|
|
setItemForm((f) => ({ ...f, videoUrl: url ?? "" }))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</LabeledField>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 3D model */}
|
|
|
|
|
|
<LabeledField label={t("model3d")}>
|
|
|
|
|
|
<Menu3dUpload
|
|
|
|
|
|
cafeId={cafeId!}
|
|
|
|
|
|
model3dUrl={itemForm.model3dUrl || null}
|
|
|
|
|
|
onChange={(url) =>
|
|
|
|
|
|
setItemForm((f) => ({ ...f, model3dUrl: url ?? "" }))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{editingItem ? (
|
|
|
|
|
|
<MenuAi3dGenerate
|
|
|
|
|
|
cafeId={cafeId!}
|
|
|
|
|
|
itemId={editingItem.id}
|
|
|
|
|
|
imageUrl={itemForm.imageUrl || editingItem.imageUrl}
|
|
|
|
|
|
onGenerated={(url) =>
|
|
|
|
|
|
setItemForm((f) => ({ ...f, model3dUrl: url }))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</LabeledField>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
|
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
onClick={() => setItemModalOpen(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tCommon("cancel")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
|
|
|
|
|
disabled={!itemFormValid || itemMutationBusy}
|
|
|
|
|
|
onClick={handleItemSave}
|
|
|
|
|
|
>
|
|
|
|
|
|
{itemMutationBusy ? t("saving") : tCommon("save")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Category Add / Edit Modal ─────────────────────────────────────── */}
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
open={catModalOpen}
|
|
|
|
|
|
onClose={() => setCatModalOpen(false)}
|
|
|
|
|
|
title={editingCategory ? t("editCategoryTitle") : t("newCategory")}
|
|
|
|
|
|
maxWidth="max-w-md"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<LabeledField label={t("name")} htmlFor="modal-cat-name">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="modal-cat-name"
|
|
|
|
|
|
value={catForm.name}
|
|
|
|
|
|
onChange={(e) => setCatForm((f) => ({ ...f, name: e.target.value }))}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
/>
|
|
|
|
|
|
</LabeledField>
|
|
|
|
|
|
|
|
|
|
|
|
<CategoryMediaFields
|
|
|
|
|
|
cafeId={cafeId!}
|
|
|
|
|
|
icon={catForm.icon}
|
|
|
|
|
|
iconPresetId={catForm.iconPreset.iconPresetId}
|
|
|
|
|
|
iconStyle={catForm.iconPreset.iconStyle}
|
|
|
|
|
|
imageUrl={catForm.imageUrl}
|
|
|
|
|
|
onIconChange={(icon) => setCatForm((f) => ({ ...f, icon }))}
|
|
|
|
|
|
onPresetChange={(iconPreset) => setCatForm((f) => ({ ...f, iconPreset }))}
|
|
|
|
|
|
onImageChange={(url) =>
|
|
|
|
|
|
setCatForm((f) => ({ ...f, imageUrl: url ?? "" }))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
|
|
|
|
|
<Button variant="ghost" onClick={() => setCatModalOpen(false)}>
|
|
|
|
|
|
{tCommon("cancel")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
|
|
|
|
|
disabled={!catForm.name.trim() || catMutationBusy}
|
|
|
|
|
|
onClick={handleCategorySave}
|
|
|
|
|
|
>
|
|
|
|
|
|
{catMutationBusy ? t("saving") : tCommon("save")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Modal>
|
2026-05-27 21:34:12 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|