feat(rbac): gate pages and action buttons in the UI by permission
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m45s

Nav already hides pages a role can't view (NAV_REQUIRED_PERMISSION). This wraps
the sensitive/CRUD action controls in <Can permission> so users only see what
they can do (server still enforces):

- POS/orders: void → VoidOrder, cancel → VoidOrder, transfer → EditOrder,
  pay/split → HandlePayments
- menu/inventory/coupons/customers/reservations/expenses/taxes/branches:
  add/edit/delete buttons → the matching Create/Edit/Delete permission
- reports CSV export → ExportReports; SMS send → SendSms, settings → ManageSmsSettings
- home dashboard: revenue/orders KPI queries gated on ViewReports so non-report
  roles don't 403 on the landing page

(Refund/discount/comp/cash-drawer have no UI control yet — no buttons to gate.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 05:58:56 +03:30
parent 53d90fa357
commit 2cff5051ac
15 changed files with 457 additions and 357 deletions
@@ -24,6 +24,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import { Can } from "@/components/auth/can";
type Branch = {
id: string;
@@ -266,19 +267,21 @@ export function BranchesScreen() {
<Eye className="h-3.5 w-3.5 me-1.5" />
{t("review")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className={cn(
"border-destructive/40 text-destructive hover:bg-destructive/10"
)}
disabled={activeBranches.length <= 1}
onClick={() => setDeleteTarget(b)}
>
<Trash2 className="h-3.5 w-3.5 me-1.5" />
{t("delete")}
</Button>
<Can permission="DeleteBranch">
<Button
type="button"
size="sm"
variant="outline"
className={cn(
"border-destructive/40 text-destructive hover:bg-destructive/10"
)}
disabled={activeBranches.length <= 1}
onClick={() => setDeleteTarget(b)}
>
<Trash2 className="h-3.5 w-3.5 me-1.5" />
{t("delete")}
</Button>
</Can>
</div>
</li>
))}
@@ -327,13 +330,15 @@ export function BranchesScreen() {
/>
</LabeledField>
</div>
<Button
type="submit"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!canSubmit}
>
{createBranch.isPending ? "..." : t("add")}
</Button>
<Can permission="CreateBranch">
<Button
type="submit"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!canSubmit}
>
{createBranch.isPending ? "..." : t("add")}
</Button>
</Can>
<p className="text-xs text-muted-foreground">{t("masterPlanHint")}</p>
</form>
<p className="text-xs text-muted-foreground">{t("branchSelectHint")}</p>
@@ -16,6 +16,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Can } from "@/components/auth/can";
export function CouponsScreen() {
const t = useTranslations("coupons");
@@ -68,10 +69,12 @@ export function CouponsScreen() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">{t("title")}</h2>
<Button onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4" />
{t("addCoupon")}
</Button>
<Can permission="CreateCoupon">
<Button onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4" />
{t("addCoupon")}
</Button>
</Can>
</div>
{showForm && (
@@ -148,16 +151,18 @@ export function CouponsScreen() {
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
</p>
<div className="mt-2 flex justify-end">
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="me-1.5 size-4" />
{tCommon("delete")}
</Button>
<Can permission="DeleteCoupon">
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="me-1.5 size-4" />
{tCommon("delete")}
</Button>
</Can>
</div>
</CardContent>
</Card>
+32 -25
View File
@@ -17,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
import { Can } from "@/components/auth/can";
export function CrmScreen() {
const t = useTranslations("crm");
@@ -67,13 +68,15 @@ export function CrmScreen() {
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-xl font-bold">{t("title")}</h2>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
onClick={() => openWizard("create")}
>
<Plus className="me-2 h-4 w-4" />
{t("addCustomer")}
</Button>
<Can permission="CreateCustomer">
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
onClick={() => openWizard("create")}
>
<Plus className="me-2 h-4 w-4" />
{t("addCustomer")}
</Button>
</Can>
</div>
<div className="flex flex-wrap items-end gap-2">
@@ -120,24 +123,28 @@ export function CrmScreen() {
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => openWizard("edit", c)}
>
<Pencil className="me-1 h-3.5 w-3.5" />
{tCommon("edit")}
</Button>
<Button
size="sm"
variant="outline"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Can permission="EditCustomer">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => openWizard("edit", c)}
>
<Pencil className="me-1 h-3.5 w-3.5" />
{tCommon("edit")}
</Button>
</Can>
<Can permission="DeleteCustomer">
<Button
size="sm"
variant="outline"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</Can>
</div>
</CardContent>
</Card>
@@ -15,6 +15,7 @@ import { MoneyInput } from "@/components/ui/money-input";
import { JalaliDateField } from "@/components/ui/jalali-date-field";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Can } from "@/components/auth/can";
type Branch = { id: string; name: string };
type ShiftDto = {
@@ -146,14 +147,16 @@ export function ExpensesScreen() {
title={t("title")}
subtitle={t("subtitle")}
action={
<Button
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
onClick={() => setShowModal(true)}
disabled={!branchId}
>
<Plus className="ms-2 h-4 w-4" />
{t("addExpense")}
</Button>
<Can permission="CreateExpense">
<Button
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
onClick={() => setShowModal(true)}
disabled={!branchId}
>
<Plus className="ms-2 h-4 w-4" />
{t("addExpense")}
</Button>
</Can>
}
/>
@@ -236,16 +239,18 @@ export function ExpensesScreen() {
</td>
{canDelete ? (
<td className="py-2.5 text-end">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[#A32D2D]"
onClick={() => deleteExpense.mutate(row.id)}
disabled={deleteExpense.isPending}
aria-label={tCommon("delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
<Can permission="DeleteExpense">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[#A32D2D]"
onClick={() => deleteExpense.mutate(row.id)}
disabled={deleteExpense.isPending}
aria-label={tCommon("delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</Can>
</td>
) : null}
</tr>
@@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { Can } from "@/components/auth/can";
type Ingredient = {
id: string;
@@ -369,13 +370,15 @@ export function InventoryScreen() {
<LabeledField label={t("reorderLevel")}>
<Input value={reorder} onChange={(e) => setReorder(e.target.value)} dir="ltr" className="text-end" />
</LabeledField>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end"
disabled={!name.trim()}
onClick={() => createIngredient.mutate()}
>
{tCommon("save")}
</Button>
<Can permission="CreateInventory">
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end"
disabled={!name.trim()}
onClick={() => createIngredient.mutate()}
>
{tCommon("save")}
</Button>
</Can>
</CardContent>
</Card>
@@ -458,14 +461,16 @@ export function InventoryScreen() {
{t("quantityEditHint")}
</p>
<div className="flex gap-2">
<Button
size="sm"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!editName.trim() || updateIngredient.isPending}
onClick={() => updateIngredient.mutate(ing.id)}
>
{tCommon("save")}
</Button>
<Can permission="EditInventory">
<Button
size="sm"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!editName.trim() || updateIngredient.isPending}
onClick={() => updateIngredient.mutate(ing.id)}
>
{tCommon("save")}
</Button>
</Can>
<Button
size="sm"
variant="outline"
@@ -483,26 +488,30 @@ export function InventoryScreen() {
{ing.isLowStock ? (
<Badge variant="outline">{t("lowStock")}</Badge>
) : null}
<Button
type="button"
size="icon"
variant="ghost"
className="size-8"
aria-label={t("editIngredient")}
onClick={() => startEdit(ing)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(ing)}
>
<Trash2 className="size-4" />
</Button>
<Can permission="EditInventory">
<Button
type="button"
size="icon"
variant="ghost"
className="size-8"
aria-label={t("editIngredient")}
onClick={() => startEdit(ing)}
>
<Pencil className="size-4" />
</Button>
</Can>
<Can permission="DeleteInventory">
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(ing)}
>
<Trash2 className="size-4" />
</Button>
</Can>
</div>
</div>
<p className="text-sm font-medium text-[#0F6E56]">
@@ -540,29 +549,31 @@ export function InventoryScreen() {
/>
</LabeledField>
) : null}
<Button
size="sm"
variant="outline"
disabled={!branchId && parseFloat(adjustQty[ing.id] ?? "0") > 0}
onClick={() => {
const delta = parseFloat(adjustQty[ing.id] ?? "0");
if (!delta) return;
const paid = parseFloat(adjustPaid[ing.id] ?? "0");
if (delta > 0 && paid <= 0) {
notify.error(t("purchaseRequired"));
return;
}
adjustStock.mutate({
id: ing.id,
delta,
paid: delta > 0 ? paid : undefined,
});
setAdjustQty((s) => ({ ...s, [ing.id]: "" }));
setAdjustPaid((s) => ({ ...s, [ing.id]: "" }));
}}
>
{t("adjust")}
</Button>
<Can permission="EditInventory">
<Button
size="sm"
variant="outline"
disabled={!branchId && parseFloat(adjustQty[ing.id] ?? "0") > 0}
onClick={() => {
const delta = parseFloat(adjustQty[ing.id] ?? "0");
if (!delta) return;
const paid = parseFloat(adjustPaid[ing.id] ?? "0");
if (delta > 0 && paid <= 0) {
notify.error(t("purchaseRequired"));
return;
}
adjustStock.mutate({
id: ing.id,
delta,
paid: delta > 0 ? paid : undefined,
});
setAdjustQty((s) => ({ ...s, [ing.id]: "" }));
setAdjustPaid((s) => ({ ...s, [ing.id]: "" }));
}}
>
{t("adjust")}
</Button>
</Can>
</div>
{parseFloat(adjustQty[ing.id] ?? "0") > 0 ? (
<p className="text-[11px] text-muted-foreground">{t("purchaseHint")}</p>
@@ -41,6 +41,7 @@ import { MenuItemMedia } from "@/components/menu/menu-item-media";
import { buildCategoryNameMap, inferMenuItemKind } from "@/lib/menu-item-image";
import { menuItemMatchesSearch } from "@/lib/menu-display";
import { BranchMenuOverrides } from "@/components/menu/branch-menu-overrides";
import { Can } from "@/components/auth/can";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -565,27 +566,31 @@ export function MenuAdminScreen() {
</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>
<Can permission="EditMenuItem">
<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>
</Can>
</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>
<Can permission="CreateMenuItem">
<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>
</Can>
</div>
</div>
</aside>
@@ -632,14 +637,16 @@ export function MenuAdminScreen() {
</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>
<Can permission="CreateMenuItem">
<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>
</Can>
</div>
{/* Search + Add bar */}
@@ -665,14 +672,16 @@ export function MenuAdminScreen() {
</Button>
) : null}
</div>
<Button
onClick={openAddItem}
className="shrink-0 bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={categories.length === 0}
>
<Plus className="me-1.5 size-4" />
{t("newItem")}
</Button>
<Can permission="CreateMenuItem">
<Button
onClick={openAddItem}
className="shrink-0 bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={categories.length === 0}
>
<Plus className="me-1.5 size-4" />
{t("newItem")}
</Button>
</Can>
</div>
{/* Items grid */}
@@ -690,15 +699,17 @@ export function MenuAdminScreen() {
{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>
<Can permission="CreateMenuItem">
<Button
variant="outline"
size="sm"
onClick={openAddItem}
disabled={categories.length === 0}
>
<Plus className="me-1.5 size-4" />
{t("addItem")}
</Button>
</Can>
) : null}
</div>
) : (
@@ -730,14 +741,16 @@ export function MenuAdminScreen() {
{/* Hover overlay — edit button */}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-all group-hover:bg-black/25">
<Button
size="sm"
className="translate-y-2 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
onClick={() => openEditItem(item)}
>
<Pencil className="me-1.5 size-3.5" />
{t("editItem")}
</Button>
<Can permission="EditMenuItem">
<Button
size="sm"
className="translate-y-2 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
onClick={() => openEditItem(item)}
>
<Pencil className="me-1.5 size-3.5" />
{t("editItem")}
</Button>
</Can>
</div>
{/* Discount badge */}
@@ -933,21 +946,23 @@ export function MenuAdminScreen() {
{/* Actions */}
<div className="flex items-center justify-between gap-2 border-t border-border pt-4">
{editingItem ? (
<Button
type="button"
variant="ghost"
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>
<Can permission="DeleteMenuItem">
<Button
type="button"
variant="ghost"
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>
</Can>
) : (
<span />
)}
@@ -999,21 +1014,23 @@ export function MenuAdminScreen() {
<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>
<Can permission="DeleteMenuItem">
<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>
</Can>
) : (
<span />
)}
@@ -19,6 +19,7 @@ import {
import { Link } from "@/i18n/routing";
import { apiGet } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useHasPermission } from "@/lib/permissions";
import { useLiveClock } from "@/lib/hooks/use-live-clock";
import {
addDaysIso,
@@ -100,6 +101,9 @@ export function OverviewScreen() {
const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useAuthStore((s) => s.user?.branchId ?? null);
const role = useAuthStore((s) => s.user?.role);
// KPI cards surface revenue/orders/net income — report data. Don't fetch (and
// 403) for roles without ViewReports; the cards simply stay empty for them.
const canViewReports = useHasPermission("ViewReports");
const clock = useLiveClock(10_000);
@@ -122,7 +126,7 @@ export function OverviewScreen() {
apiGet<DailyReportSnapshot[]>(
`/api/cafes/${cafeId}/reports/daily/range?from=${sevenDaysAgo}&to=${today}`
),
enabled: !!cafeId,
enabled: !!cafeId && canViewReports,
staleTime: 60_000,
});
@@ -132,7 +136,7 @@ export function OverviewScreen() {
apiGet<DailyReportSnapshot[]>(
`/api/cafes/${cafeId}/reports/daily/range?from=${yesterday}&to=${yesterday}`
),
enabled: !!cafeId,
enabled: !!cafeId && canViewReports,
staleTime: 300_000,
});
@@ -13,6 +13,7 @@ import { formatCurrency, formatNumber } from "@/lib/format";
import { formatPosOrderLabel } from "@/lib/pos-order-label";
import { formatOrderNumber } from "@/lib/order-number";
import { PosTableBoard } from "@/components/pos/pos-table-board";
import { Can } from "@/components/auth/can";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
@@ -613,24 +614,26 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
className="h-9"
maxLength={500}
/>
<Button
type="button"
variant="outline"
className="w-full border-[#A32D2D]/40 text-[#A32D2D] hover:bg-red-50"
disabled={cancelOrder.isPending}
onClick={async () => {
if (!selected) return;
const ok = await confirmDialog({
description: t("cancelOrderConfirm"),
variant: "destructive",
confirmLabel: t("cancelOrder"),
});
if (!ok) return;
cancelOrder.mutate({ orderId: selected.id, reason: cancelReason });
}}
>
{cancelOrder.isPending ? "..." : t("cancelOrder")}
</Button>
<Can permission="VoidOrder">
<Button
type="button"
variant="outline"
className="w-full border-[#A32D2D]/40 text-[#A32D2D] hover:bg-red-50"
disabled={cancelOrder.isPending}
onClick={async () => {
if (!selected) return;
const ok = await confirmDialog({
description: t("cancelOrderConfirm"),
variant: "destructive",
confirmLabel: t("cancelOrder"),
});
if (!ok) return;
cancelOrder.mutate({ orderId: selected.id, reason: cancelReason });
}}
>
{cancelOrder.isPending ? "..." : t("cancelOrder")}
</Button>
</Can>
<form
className="w-full"
onSubmit={(e) => {
@@ -640,13 +643,15 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
}
}}
>
<Button
type="submit"
className="w-full"
disabled={!canPay || payOrder.isPending}
>
{payOrder.isPending ? "..." : payButtonLabel}
</Button>
<Can permission="HandlePayments">
<Button
type="submit"
className="w-full"
disabled={!canPay || payOrder.isPending}
>
{payOrder.isPending ? "..." : payButtonLabel}
</Button>
</Can>
</form>
</div>
</>
+34 -27
View File
@@ -51,6 +51,7 @@ import {
buildCategoryNameMap,
inferMenuItemKind,
} from "@/lib/menu-item-image";
import { Can } from "@/components/auth/can";
import { PosPayPanel } from "@/components/pos/pos-pay-panel";
import { PosTableBoard } from "@/components/pos/pos-table-board";
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker";
@@ -1174,15 +1175,17 @@ export function PosScreen() {
{/* Transfer table (for table orders with active session) */}
{activeOrderId && tableId ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-full text-xs"
onClick={() => setShowTransferPicker(true)}
>
{t("transferTable")}
</Button>
<Can permission="EditOrder">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-full text-xs"
onClick={() => setShowTransferPicker(true)}
>
{t("transferTable")}
</Button>
</Can>
) : null}
{/* Assign table button (for counter orders) */}
@@ -1248,14 +1251,16 @@ export function PosScreen() {
line.orderItemId &&
!line.isVoided &&
activeOrderId ? (
<button
type="button"
className="cursor-pointer text-[10px] text-destructive hover:underline"
onClick={() => handleVoidItem(line.orderItemId!)}
aria-label={t("voidItem")}
>
{t("void")}
</button>
<Can permission="VoidOrder">
<button
type="button"
className="cursor-pointer text-[10px] text-destructive hover:underline"
onClick={() => handleVoidItem(line.orderItemId!)}
aria-label={t("voidItem")}
>
{t("void")}
</button>
</Can>
) : null}
{!line.isVoided ? (
<>
@@ -1443,16 +1448,18 @@ export function PosScreen() {
) : null}
<div className="flex flex-col gap-2 pt-0.5">
<Button
size="sm"
className="w-full"
disabled={!canSubmitOrder || isOrderBusy}
onClick={() => submitOrderAndPay.mutate()}
>
{submitOrderAndPay.isPending
? "..."
: t("submitOrderAndPay")}
</Button>
<Can permission="HandlePayments">
<Button
size="sm"
className="w-full"
disabled={!canSubmitOrder || isOrderBusy}
onClick={() => submitOrderAndPay.mutate()}
>
{submitOrderAndPay.isPending
? "..."
: t("submitOrderAndPay")}
</Button>
</Can>
<div className="flex gap-2">
<Button
size="sm"
@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify";
import { Can } from "@/components/auth/can";
type Item = { id: string; name: string; price: number; cat: string };
type Line = { item: Item; qty: number };
@@ -402,18 +403,22 @@ function Ticket({
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl bg-primary text-base font-bold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 active:scale-[0.98]">
<Send className="size-5" /> ارسال
</button>
<button type="button" disabled={!count} onClick={onPay}
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]">
<CreditCard className="size-5" /> پرداخت
</button>
<Can permission="HandlePayments">
<button type="button" disabled={!count} onClick={onPay}
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]">
<CreditCard className="size-5" /> پرداخت
</button>
</Can>
<button type="button" disabled={!count} onClick={onHold}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
<Pause className="size-4" /> نگهداشتن
</button>
<button type="button" disabled={!count} onClick={onSplit}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
<SplitSquareHorizontal className="size-4" /> تقسیم
</button>
<Can permission="HandlePayments">
<button type="button" disabled={!count} onClick={onSplit}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
<SplitSquareHorizontal className="size-4" /> تقسیم
</button>
</Can>
</div>
</div>
);
@@ -25,6 +25,7 @@ import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
import { submitOrderToApi, orderAmountDue, isLocalOrder } from "@/lib/pos/submit-order";
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { PosCustomerPicker } from "@/components/pos/pos-customer-picker";
import { Can } from "@/components/auth/can";
import type { Customer, MenuItem, Order, TableBoardItem } from "@/lib/api/types";
import { usePos2Categories, usePos2Menu, usePos2Tables, useMenuById } from "@/lib/pos2/use-pos2";
@@ -729,17 +730,21 @@ function Ticket({
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl bg-primary text-base font-bold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 active:scale-[0.98]">
<Send className="size-5" /> ارسال{pendingCount > 0 ? ` (${fmt(pendingCount)})` : ""}
</button>
<button type="button" disabled={count === 0} onClick={onPay}
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]">
<CreditCard className="size-5" /> پرداخت
</button>
<Can permission="HandlePayments">
<button type="button" disabled={count === 0} onClick={onPay}
className="flex min-h-[56px] items-center justify-center gap-2 rounded-xl border-2 border-primary text-base font-bold text-primary transition-colors hover:bg-primary/10 disabled:opacity-40 active:scale-[0.98]">
<CreditCard className="size-5" /> پرداخت
</button>
</Can>
<button type="button" disabled className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground opacity-50">
<Pause className="size-4" /> نگهداشتن
</button>
<button type="button" disabled={count === 0} onClick={onSplit}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
<SplitSquareHorizontal className="size-4" /> تقسیم
</button>
<Can permission="HandlePayments">
<button type="button" disabled={count === 0} onClick={onSplit}
className="flex min-h-[48px] items-center justify-center gap-1.5 rounded-xl bg-muted text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-40">
<SplitSquareHorizontal className="size-4" /> تقسیم
</button>
</Can>
</div>
</div>
);
@@ -33,6 +33,7 @@ import { ReportsChartsFallback } from "@/components/reports/reports-charts-fallb
import type { ReportsChartsProps } from "@/components/reports/reports-charts.types";
import { PaymentCorrectionsTab } from "@/components/reports/payment-corrections-tab";
import { AuditLogsTab } from "@/components/reports/audit-logs-tab";
import { Can } from "@/components/auth/can";
const LazyReportsCharts = lazy(() =>
import("@/components/reports/reports-charts").then((m) => ({
@@ -218,15 +219,17 @@ export function ReportsScreen() {
subtitle={t("subtitle")}
action={
tab === "performance" ? (
<Button
variant="outline"
className="border-[#0F6E56]/40"
onClick={handleExportCsv}
disabled={snapshots.length === 0}
>
<Download className="ms-2 h-4 w-4" />
{t("exportCsv")}
</Button>
<Can permission="ExportReports">
<Button
variant="outline"
className="border-[#0F6E56]/40"
onClick={handleExportCsv}
disabled={snapshots.length === 0}
>
<Download className="ms-2 h-4 w-4" />
{t("exportCsv")}
</Button>
</Can>
) : undefined
}
/>
@@ -19,6 +19,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { Table } from "@/lib/api/types";
import { Can } from "@/components/auth/can";
type ReservationStatus =
| "Pending"
@@ -194,12 +195,14 @@ export function ReservationsScreen() {
/>
</LabeledField>
<div className="sm:col-span-2 lg:col-span-3">
<Button
onClick={() => createReservation.mutate()}
disabled={!guestName.trim() || createReservation.isPending}
>
{createReservation.isPending ? "..." : t("create")}
</Button>
<Can permission="CreateReservation">
<Button
onClick={() => createReservation.mutate()}
disabled={!guestName.trim() || createReservation.isPending}
>
{createReservation.isPending ? "..." : t("create")}
</Button>
</Can>
</div>
</CardContent>
</Card>
@@ -228,19 +231,23 @@ export function ReservationsScreen() {
<div className="flex flex-wrap gap-2">
{r.status === "Pending" && (
<>
<Button
size="sm"
onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })}
>
{t("confirm")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Cancelled" })}
>
{t("cancel")}
</Button>
<Can permission="EditReservation">
<Button
size="sm"
onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })}
>
{t("confirm")}
</Button>
</Can>
<Can permission="EditReservation">
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Cancelled" })}
>
{t("cancel")}
</Button>
</Can>
</>
)}
{(r.status === "Confirmed" || r.status === "Seated") && (
@@ -249,23 +256,27 @@ export function ReservationsScreen() {
</Button>
)}
{r.status === "Seated" && (
<Can permission="EditReservation">
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Completed" })}
>
{t("markCompleted")}
</Button>
</Can>
)}
<Can permission="DeleteReservation">
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Completed" })}
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(r)}
>
{t("markCompleted")}
<Trash2 className="size-4" />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(r)}
>
<Trash2 className="size-4" />
</Button>
</Can>
</div>
</CardContent>
</Card>
+19 -14
View File
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Can } from "@/components/auth/can";
const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"];
const MANAGER_ROLES = new Set(["Owner", "Manager"]);
@@ -195,13 +196,15 @@ export function SmsScreen() {
</LabeledField>
{/* Send button */}
<Button
className="w-full"
disabled={!message.trim() || sendCampaign.isPending || !isConfigured}
onClick={() => sendCampaign.mutate()}
>
{sendCampaign.isPending ? t("sending") : t("send")}
</Button>
<Can permission="SendSms">
<Button
className="w-full"
disabled={!message.trim() || sendCampaign.isPending || !isConfigured}
onClick={() => sendCampaign.mutate()}
>
{sendCampaign.isPending ? t("sending") : t("send")}
</Button>
</Can>
{/* Result banner */}
{result && (
@@ -314,13 +317,15 @@ function ProviderSettingsCard({
<p className="text-[11px] text-muted-foreground">
{settings?.isConfigured ? t("configured") : t("notConfigured")}
</p>
<Button
size="sm"
disabled={!canSave || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? t("saving") : t("save")}
</Button>
<Can permission="ManageSmsSettings">
<Button
size="sm"
disabled={!canSave || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? t("saving") : t("save")}
</Button>
</Can>
</div>
</CardContent>
</Card>
@@ -15,6 +15,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useConfirm } from "@/components/providers/confirm-provider";
import { Can } from "@/components/auth/can";
interface TaxRow {
id: string;
@@ -89,14 +90,16 @@ export function TaxesScreen() {
subtitle={t("subtitle")}
action={
canEdit ? (
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!name.trim() || !rate}
onClick={() => addTax.mutate()}
>
<Plus className="me-2 h-4 w-4" />
{t("addTax")}
</Button>
<Can permission="CreateTax">
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!name.trim() || !rate}
onClick={() => addTax.mutate()}
>
<Plus className="me-2 h-4 w-4" />
{t("addTax")}
</Button>
</Can>
) : undefined
}
/>
@@ -152,16 +155,18 @@ export function TaxesScreen() {
</Button>
) : null}
{canEdit ? (
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={removeTax.isPending}
onClick={() => handleRemove(tax)}
aria-label={t("delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
<Can permission="DeleteTax">
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={removeTax.isPending}
onClick={() => handleRemove(tax)}
aria-label={t("delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</Can>
) : null}
</div>
</li>