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
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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user