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