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