first commit
CI/CD / CI · Admin API (dotnet build) (push) Successful in 41s
CI/CD / CI · Admin Web (tsc) (push) Failing after 5s
CI/CD / CI · Website (tsc) (push) Failing after 4s
CI/CD / CI · Koja (tsc) (push) Failing after 5s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m13s
CI/CD / CI · Dashboard (tsc) (push) Failing after 2m32s
CI/CD / Deploy · all services (push) Has been skipped
CI/CD / CI · Admin API (dotnet build) (push) Successful in 41s
CI/CD / CI · Admin Web (tsc) (push) Failing after 5s
CI/CD / CI · Website (tsc) (push) Failing after 4s
CI/CD / CI · Koja (tsc) (push) Failing after 5s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m13s
CI/CD / CI · Dashboard (tsc) (push) Failing after 2m32s
CI/CD / Deploy · all services (push) Has been skipped
This commit is contained in:
@@ -56,6 +56,29 @@
|
||||
"delivery": "عامل التوصيل",
|
||||
"unknown": "مستخدم"
|
||||
},
|
||||
"branchSwitcher": {
|
||||
"title": "الفرع النشط",
|
||||
"allBranches": "كل الفروع",
|
||||
"selectBranch": "اختر الفرع"
|
||||
},
|
||||
"branchAccess": {
|
||||
"title": "صلاحيات الفروع",
|
||||
"staff": "الموظفون",
|
||||
"noStaff": "لا يوجد موظفون بعد",
|
||||
"selectStaff": "اختر موظفًا لإدارة الصلاحيات",
|
||||
"ownerNote": "المالك لديه صلاحية الوصول لكل الفروع ولا يحتاج إلى أدوار خاصة بكل فرع.",
|
||||
"noAssignments": "لم يتم تعيين أي دور للفروع بعد",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"branch": "الفرع",
|
||||
"role": "الدور",
|
||||
"selectBranch": "اختر الفرع",
|
||||
"add": "إضافة",
|
||||
"remove": "حذف"
|
||||
},
|
||||
"access": {
|
||||
"deniedTitle": "لا تملك صلاحية الوصول إلى هذه الصفحة",
|
||||
"deniedBody": "دورك لا يملك صلاحية عرض هذه الصفحة. تواصل مع المدير أو المالك إذا كنت بحاجة إلى الوصول."
|
||||
},
|
||||
"nav": {
|
||||
"aria": "القائمة الرئيسية",
|
||||
"collapseSidebar": "طي الشريط الجانبي",
|
||||
@@ -163,6 +186,8 @@
|
||||
"cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.",
|
||||
"cancelOrderSuccess": "تم إلغاء الطلب",
|
||||
"cancelOrderError": "تعذّر إلغاء الطلب",
|
||||
"cancelReasonPlaceholder": "سبب الإلغاء (اختياري)",
|
||||
"cancelOrderHasPayments": "استرجع المدفوعات المسجّلة أولاً ثم ألغِ الطلب",
|
||||
"itemsCount": "صنف",
|
||||
"applyCoupon": "تطبيق القسيمة",
|
||||
"couponPlaceholder": "رمز القسيمة",
|
||||
@@ -360,7 +385,8 @@
|
||||
"tabs": {
|
||||
"attendance": "الحضور",
|
||||
"leave": "الإجازة",
|
||||
"payroll": "الرواتب"
|
||||
"payroll": "الرواتب",
|
||||
"access": "صلاحيات الفروع"
|
||||
},
|
||||
"myAttendance": "حضوري",
|
||||
"clockIn": "تسجيل دخول",
|
||||
|
||||
@@ -67,6 +67,29 @@
|
||||
"delivery": "Delivery",
|
||||
"unknown": "User"
|
||||
},
|
||||
"branchSwitcher": {
|
||||
"title": "Active branch",
|
||||
"allBranches": "All branches",
|
||||
"selectBranch": "Select branch"
|
||||
},
|
||||
"branchAccess": {
|
||||
"title": "Branch access",
|
||||
"staff": "Staff",
|
||||
"noStaff": "No staff yet",
|
||||
"selectStaff": "Select a staff member to manage access",
|
||||
"ownerNote": "The owner has access to all branches and does not need per-branch roles.",
|
||||
"noAssignments": "No branch roles assigned yet",
|
||||
"loading": "Loading...",
|
||||
"branch": "Branch",
|
||||
"role": "Role",
|
||||
"selectBranch": "Select branch",
|
||||
"add": "Add",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"access": {
|
||||
"deniedTitle": "No access to this page",
|
||||
"deniedBody": "Your role doesn't have permission to view this page. Contact a manager or owner if you need access."
|
||||
},
|
||||
"nav": {
|
||||
"aria": "Main navigation",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
@@ -182,6 +205,8 @@
|
||||
"cancelOrderConfirm": "Customer left without paying? The order will be cancelled and the table freed.",
|
||||
"cancelOrderSuccess": "Order cancelled",
|
||||
"cancelOrderError": "Could not cancel order",
|
||||
"cancelReasonPlaceholder": "Cancellation reason (optional)",
|
||||
"cancelOrderHasPayments": "Refund the recorded payments first, then cancel the order",
|
||||
"itemsCount": "items",
|
||||
"applyCoupon": "Apply coupon",
|
||||
"couponPlaceholder": "Coupon code",
|
||||
@@ -379,7 +404,8 @@
|
||||
"tabs": {
|
||||
"attendance": "Attendance",
|
||||
"leave": "Leave",
|
||||
"payroll": "Payroll"
|
||||
"payroll": "Payroll",
|
||||
"access": "Branch access"
|
||||
},
|
||||
"myAttendance": "My attendance",
|
||||
"clockIn": "Clock in",
|
||||
|
||||
@@ -67,6 +67,29 @@
|
||||
"delivery": "پیک",
|
||||
"unknown": "کاربر"
|
||||
},
|
||||
"branchSwitcher": {
|
||||
"title": "شعبه فعال",
|
||||
"allBranches": "همه شعب",
|
||||
"selectBranch": "انتخاب شعبه"
|
||||
},
|
||||
"branchAccess": {
|
||||
"title": "دسترسی شعب",
|
||||
"staff": "کارکنان",
|
||||
"noStaff": "کارمندی ثبت نشده است",
|
||||
"selectStaff": "یک کارمند را برای مدیریت دسترسی انتخاب کنید",
|
||||
"ownerNote": "مالک به همه شعب دسترسی دارد و نیازی به تعیین نقش شعبهای ندارد.",
|
||||
"noAssignments": "هنوز نقشی برای شعبهای تعیین نشده است",
|
||||
"loading": "در حال بارگذاری...",
|
||||
"branch": "شعبه",
|
||||
"role": "نقش",
|
||||
"selectBranch": "انتخاب شعبه",
|
||||
"add": "افزودن",
|
||||
"remove": "حذف"
|
||||
},
|
||||
"access": {
|
||||
"deniedTitle": "دسترسی به این صفحه ندارید",
|
||||
"deniedBody": "نقش شما اجازه مشاهده این صفحه را ندارد. در صورت نیاز با مدیر یا مالک هماهنگ کنید."
|
||||
},
|
||||
"nav": {
|
||||
"aria": "منوی اصلی",
|
||||
"collapseSidebar": "جمع کردن نوار کناری",
|
||||
@@ -182,6 +205,8 @@
|
||||
"cancelOrderConfirm": "مشتری بدون پرداخت رفته است؟ سفارش لغو میشود و میز آزاد میشود.",
|
||||
"cancelOrderSuccess": "سفارش لغو شد",
|
||||
"cancelOrderError": "لغو سفارش ناموفق بود",
|
||||
"cancelReasonPlaceholder": "دلیل لغو (اختیاری)",
|
||||
"cancelOrderHasPayments": "ابتدا پرداختهای ثبتشده را بازگردانید، سپس سفارش را لغو کنید",
|
||||
"itemsCount": "قلم",
|
||||
"applyCoupon": "اعمال کوپن",
|
||||
"couponPlaceholder": "کد کوپن",
|
||||
@@ -379,7 +404,8 @@
|
||||
"tabs": {
|
||||
"attendance": "حضور و غیاب",
|
||||
"leave": "مرخصی",
|
||||
"payroll": "حقوق"
|
||||
"payroll": "حقوق",
|
||||
"access": "دسترسی شعب"
|
||||
},
|
||||
"myAttendance": "حضور من",
|
||||
"clockIn": "ورود",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLocale } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Topbar } from "@/components/layout/topbar";
|
||||
import { RouteGuard } from "@/components/auth/route-guard";
|
||||
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useOfflineSync } from "@/lib/offline/use-offline-sync";
|
||||
@@ -31,7 +32,7 @@ export default function DashboardLayout({
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="min-h-0 flex-1 overflow-auto p-6 bg-background">
|
||||
{children}
|
||||
<RouteGuard>{children}</RouteGuard>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { RouteGuard } from "@/components/auth/route-guard";
|
||||
|
||||
/** Full-viewport routes (queue TV display) — auth only, no dashboard chrome. */
|
||||
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -19,7 +20,7 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo
|
||||
|
||||
return (
|
||||
<div className="min-h-svh" dir={locale === "en" ? "ltr" : "rtl"}>
|
||||
{children}
|
||||
<RouteGuard>{children}</RouteGuard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useHasPermission, type Permission } from "@/lib/permissions";
|
||||
|
||||
/**
|
||||
* Renders {@link children} only when the current user holds {@link permission}.
|
||||
* For action-level RBAC (buttons, menu entries). The server still enforces the
|
||||
* real check — this just hides controls the user can't use.
|
||||
*
|
||||
* @example
|
||||
* <Can permission="HandlePayments">
|
||||
* <Button onClick={refund}>Refund</Button>
|
||||
* </Can>
|
||||
*/
|
||||
export function Can({
|
||||
permission,
|
||||
children,
|
||||
fallback = null,
|
||||
}: {
|
||||
permission: Permission;
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}) {
|
||||
const allowed = useHasPermission(permission);
|
||||
return <>{allowed ? children : fallback}</>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ShieldX } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname } from "@/i18n/routing";
|
||||
import { NAV_GROUPS, type NavItemKey } from "@/lib/sidebar-nav";
|
||||
import { NAV_REQUIRED_PERMISSION } from "@/lib/permissions";
|
||||
import { canSeeNavItem } from "@/lib/auth-permissions";
|
||||
import { permissionsOf } from "@/lib/permissions";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
|
||||
/** Resolve the nav item key that owns the given pathname (locale already stripped). */
|
||||
function navKeyForPath(pathname: string): NavItemKey | null {
|
||||
for (const group of NAV_GROUPS) {
|
||||
for (const item of group.items) {
|
||||
if (pathname === item.href || pathname.startsWith(`${item.href}/`)) {
|
||||
return item.key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-level access gate for direct-URL navigation. Mirrors the sidebar's
|
||||
* visibility rules so a user who types a URL for a page they can't access sees a
|
||||
* friendly notice instead of an empty or erroring screen. The API still enforces
|
||||
* the real permission server-side.
|
||||
*/
|
||||
export function RouteGuard({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations("access");
|
||||
const pathname = usePathname();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
||||
|
||||
const allowed = useMemo(() => {
|
||||
const key = navKeyForPath(pathname);
|
||||
// Pages outside the nav map (e.g. detail routes without a gated key) pass through.
|
||||
if (!key) return true;
|
||||
if (!NAV_REQUIRED_PERMISSION[key]) {
|
||||
// No permission mapping — defer to role/branch visibility rules.
|
||||
return canSeeNavItem(key, user?.role, user?.branchId ?? null, permissionsOf(user));
|
||||
}
|
||||
return canSeeNavItem(key, user?.role, user?.branchId ?? null, permissionsOf(user));
|
||||
}, [pathname, user]);
|
||||
|
||||
// Avoid flashing the denied panel before the persisted auth state rehydrates.
|
||||
if (!hasHydrated) return <>{children}</>;
|
||||
if (allowed) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-red-50 text-[#A32D2D]">
|
||||
<ShieldX className="h-7 w-7" aria-hidden />
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t("deniedTitle")}</h2>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">{t("deniedBody")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
import {
|
||||
listBranchRoles,
|
||||
assignBranchRole,
|
||||
updateBranchRole,
|
||||
removeBranchRole,
|
||||
type BranchRoleAssignment,
|
||||
} from "@/lib/api/branch-roles";
|
||||
import { roleKey } from "@/lib/role-label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface Branch {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Branch-level roles an employee can be assigned (Owner is café-wide, excluded). */
|
||||
const ASSIGNABLE_ROLES = ["Manager", "Cashier", "Waiter", "Chef", "Delivery"] as const;
|
||||
|
||||
export function BranchAccessPanel({ cafeId }: { cafeId: string }) {
|
||||
const t = useTranslations("branchAccess");
|
||||
const tRoles = useTranslations("roles");
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [newBranchId, setNewBranchId] = useState("");
|
||||
const [newRole, setNewRole] = useState<string>("Cashier");
|
||||
|
||||
const { data: employees = [] } = useQuery({
|
||||
queryKey: ["employees", cafeId],
|
||||
queryFn: () => apiGet<Employee[]>(`/api/cafes/${cafeId}/employees`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: branches = [] } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const selected = employees.find((e) => e.id === selectedId) ?? null;
|
||||
const isOwner = selected?.role === "Owner";
|
||||
|
||||
const { data: assignments = [], isPending: loadingAssignments } = useQuery({
|
||||
queryKey: ["branch-roles", cafeId, selectedId],
|
||||
queryFn: () => listBranchRoles(cafeId, selectedId!),
|
||||
enabled: !!cafeId && !!selectedId && !isOwner,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["branch-roles", cafeId, selectedId] });
|
||||
|
||||
const assign = useMutation({
|
||||
mutationFn: () => assignBranchRole(cafeId, selectedId!, { branchId: newBranchId, role: newRole }),
|
||||
onSuccess: () => {
|
||||
setNewBranchId("");
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (vars: { assignmentId: string; role: string }) =>
|
||||
updateBranchRole(cafeId, selectedId!, vars.assignmentId, vars.role),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (assignmentId: string) => removeBranchRole(cafeId, selectedId!, assignmentId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const assignedBranchIds = useMemo(
|
||||
() => new Set(assignments.map((a) => a.branchId)),
|
||||
[assignments]
|
||||
);
|
||||
const availableBranches = branches.filter((b) => !assignedBranchIds.has(b.id));
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[260px_1fr]">
|
||||
{/* Employee picker */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("staff")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{employees.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("noStaff")}</p>
|
||||
) : (
|
||||
employees.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(e.id)}
|
||||
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-start text-sm transition-colors cursor-pointer ${
|
||||
selectedId === e.id ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{e.name}</span>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{tRoles(roleKey(e.role))}
|
||||
</Badge>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Assignment editor */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selected ? (
|
||||
<p className="text-sm text-muted-foreground">{t("selectStaff")}</p>
|
||||
) : isOwner ? (
|
||||
<p className="text-sm text-muted-foreground">{t("ownerNote")}</p>
|
||||
) : (
|
||||
<>
|
||||
{loadingAssignments ? (
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : assignments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("noAssignments")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{assignments.map((a: BranchRoleAssignment) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{a.branchName}
|
||||
</span>
|
||||
<select
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
|
||||
value={a.role}
|
||||
onChange={(e) => update.mutate({ assignmentId: a.id, role: e.target.value })}
|
||||
disabled={update.isPending}
|
||||
aria-label={t("role")}
|
||||
>
|
||||
{ASSIGNABLE_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{tRoles(roleKey(r))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
onClick={() => remove.mutate(a.id)}
|
||||
disabled={remove.isPending}
|
||||
title={t("remove")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
<span className="sr-only">{t("remove")}</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Add new assignment */}
|
||||
<div className="flex flex-wrap items-end gap-2 border-t border-border pt-4">
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{t("branch")}</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-2 text-sm"
|
||||
value={newBranchId}
|
||||
onChange={(e) => setNewBranchId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("selectBranch")}</option>
|
||||
{availableBranches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-[120px]">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{t("role")}</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-2 text-sm"
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
>
|
||||
{ASSIGNABLE_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{tRoles(roleKey(r))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => assign.mutate()}
|
||||
disabled={!newBranchId || assign.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
|
||||
|
||||
interface Employee {
|
||||
id: string;
|
||||
@@ -46,12 +47,14 @@ interface Salary {
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
type Tab = "attendance" | "leave" | "payroll";
|
||||
type Tab = "attendance" | "leave" | "payroll" | "access";
|
||||
|
||||
export function HrScreen() {
|
||||
const t = useTranslations("hr");
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const userId = useAuthStore((s) => s.user?.userId);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const canManageAccess = role === "Owner" || role === "Manager";
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<Tab>("attendance");
|
||||
const [monthYear, setMonthYear] = useState(
|
||||
@@ -119,7 +122,9 @@ export function HrScreen() {
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["attendance", "leave", "payroll"] as Tab[]).map((key) => (
|
||||
{((["attendance", "leave", "payroll", "access"] as Tab[]).filter(
|
||||
(key) => key !== "access" || canManageAccess
|
||||
)).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
size="sm"
|
||||
@@ -223,6 +228,8 @@ export function HrScreen() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { switchBranch } from "@/lib/api/branch-roles";
|
||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
/**
|
||||
* Active-branch session switcher. Calls /auth/switch-branch which re-issues a
|
||||
* token scoped to the chosen branch (and the role held there). Owners may also
|
||||
* pick "all branches" (café-wide). Hidden when the employee has a single branch
|
||||
* and is not the owner.
|
||||
*/
|
||||
export function BranchSwitcher() {
|
||||
const t = useTranslations("branchSwitcher");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const branches = user?.branches ?? [];
|
||||
const owner = isCafeOwner(user?.role);
|
||||
|
||||
// Owners always get the switcher (to scope into a branch); other staff only
|
||||
// when they actually belong to more than one branch.
|
||||
if (!user || (!owner && branches.length <= 1)) return null;
|
||||
|
||||
const activeLabel = user.isCafeWide
|
||||
? t("allBranches")
|
||||
: user.branchName ?? t("selectBranch");
|
||||
|
||||
async function choose(branchId: string | null) {
|
||||
if (pending) return;
|
||||
// No-op when re-selecting the current scope.
|
||||
if (branchId === (user!.branchId ?? null)) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const next = await switchBranch(branchId);
|
||||
setAuth(next);
|
||||
// Active branch changes nearly every scoped query + nav — full reload is safest.
|
||||
if (typeof window !== "undefined") window.location.reload();
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 max-w-[160px] gap-1.5 px-2.5 text-xs cursor-pointer"
|
||||
disabled={pending}
|
||||
title={t("title")}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span className="truncate">{activeLabel}</span>
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0 text-muted-foreground/60" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
<p className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{t("title")}</p>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
{owner && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => choose(null)}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
<Check
|
||||
className={`h-3.5 w-3.5 shrink-0 ${user.isCafeWide ? "opacity-100" : "opacity-0"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("allBranches")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{branches.map((b) => (
|
||||
<DropdownMenuItem
|
||||
key={b.branchId}
|
||||
onClick={() => choose(b.branchId)}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
<Check
|
||||
className={`h-3.5 w-3.5 shrink-0 ${
|
||||
!user.isCafeWide && user.branchId === b.branchId ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">{b.branchName}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link, usePathname } from "@/i18n/routing";
|
||||
import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions";
|
||||
import { permissionsOf } from "@/lib/permissions";
|
||||
import {
|
||||
NAV_GROUPS,
|
||||
NAV_GROUPS_STORAGE_KEY,
|
||||
@@ -102,6 +103,7 @@ function NavGroupSection({
|
||||
pathname,
|
||||
role,
|
||||
branchId,
|
||||
permissions,
|
||||
tItem,
|
||||
collapsed,
|
||||
}: {
|
||||
@@ -112,11 +114,12 @@ function NavGroupSection({
|
||||
pathname: string;
|
||||
role: string | undefined;
|
||||
branchId: string | null | undefined;
|
||||
permissions: Set<string> | null;
|
||||
tItem: (key: string) => string;
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
const visibleItems = group.items.filter((item) =>
|
||||
canSeeNavItem(item.key, role, branchId)
|
||||
canSeeNavItem(item.key, role, branchId, permissions)
|
||||
);
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
@@ -198,6 +201,7 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
|
||||
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
||||
const role = user?.role;
|
||||
const branchId = user?.branchId ?? null;
|
||||
const permissions = useMemo(() => permissionsOf(user), [user]);
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<OpenGroupsState>(buildDefaultOpenGroups);
|
||||
const [collapsed, setCollapsed] = useState<boolean>(readStoredCollapsed);
|
||||
@@ -229,9 +233,9 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
|
||||
() =>
|
||||
NAV_GROUPS.filter((g) => {
|
||||
if (!canSeeNavGroup(g.id, role, branchId)) return false;
|
||||
return g.items.some((item) => canSeeNavItem(item.key, role, branchId));
|
||||
return g.items.some((item) => canSeeNavItem(item.key, role, branchId, permissions));
|
||||
}),
|
||||
[role, branchId]
|
||||
[role, branchId, permissions]
|
||||
);
|
||||
|
||||
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
|
||||
@@ -332,6 +336,7 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
|
||||
pathname={pathname}
|
||||
role={role}
|
||||
branchId={branchId}
|
||||
permissions={permissions}
|
||||
tItem={(key) => t(key)}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
|
||||
import { BranchSwitcher } from "@/components/layout/branch-switcher";
|
||||
import { NotificationCenter } from "@/components/notifications/notification-center";
|
||||
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
|
||||
|
||||
@@ -62,6 +63,7 @@ export function Topbar() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-1 items-center justify-end gap-1.5">
|
||||
<BranchSwitcher />
|
||||
<SyncStatusIndicator />
|
||||
<NotificationCenter />
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import { printErrorMessage, printReceipt } from "@/lib/api/print";
|
||||
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
||||
import { PosSlipModal } from "@/components/pos/pos-slip-modal";
|
||||
@@ -54,6 +54,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [payMessage, setPayMessage] = useState<string | null>(null);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [receiptOrder, setReceiptOrder] = useState<Order | null>(null);
|
||||
const printSettingsBranchId = receiptOrder?.branchId ?? branchId ?? null;
|
||||
const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null);
|
||||
@@ -167,6 +168,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
||||
|
||||
useEffect(() => {
|
||||
setLoyaltyRedeem(0);
|
||||
setCancelReason("");
|
||||
}, [selected?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -246,23 +248,31 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
||||
});
|
||||
|
||||
const cancelOrder = useMutation({
|
||||
mutationFn: (orderId: string) =>
|
||||
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, {
|
||||
status: "Cancelled",
|
||||
mutationFn: ({ orderId, reason }: { orderId: string; reason: string }) =>
|
||||
apiPost(`/api/cafes/${cafeId}/orders/${orderId}/cancel`, {
|
||||
reason: reason.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setPayMessage(t("cancelOrderSuccess"));
|
||||
setCancelReason("");
|
||||
setSelectedId(null);
|
||||
setSelectedTableId(null);
|
||||
setFilterTableId(null);
|
||||
setPaymentRows([{ method: "Cash", amount: "" }]);
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setPayMessage(
|
||||
err instanceof ApiClientError ? err.message : t("cancelOrderError")
|
||||
);
|
||||
if (err instanceof ApiClientError) {
|
||||
if (err.code === "ORDER_HAS_PAYMENTS") {
|
||||
setPayMessage(t("cancelOrderHasPayments"));
|
||||
return;
|
||||
}
|
||||
setPayMessage(err.message || t("cancelOrderError"));
|
||||
return;
|
||||
}
|
||||
setPayMessage(t("cancelOrderError"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -596,6 +606,13 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
||||
>
|
||||
{t("previewBill")}
|
||||
</Button>
|
||||
<Input
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
placeholder={t("cancelReasonPlaceholder")}
|
||||
className="h-9"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -609,7 +626,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
||||
confirmLabel: t("cancelOrder"),
|
||||
});
|
||||
if (!ok) return;
|
||||
cancelOrder.mutate(selected.id);
|
||||
cancelOrder.mutate({ orderId: selected.id, reason: cancelReason });
|
||||
}}
|
||||
>
|
||||
{cancelOrder.isPending ? "..." : t("cancelOrder")}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/lib/api/client";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
|
||||
export interface BranchRoleAssignment {
|
||||
id: string;
|
||||
branchId: string;
|
||||
branchName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function listBranchRoles(cafeId: string, employeeId: string) {
|
||||
return apiGet<BranchRoleAssignment[]>(
|
||||
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles`
|
||||
);
|
||||
}
|
||||
|
||||
export function assignBranchRole(
|
||||
cafeId: string,
|
||||
employeeId: string,
|
||||
body: { branchId: string; role: string }
|
||||
) {
|
||||
return apiPost<BranchRoleAssignment, typeof body>(
|
||||
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles`,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
export function updateBranchRole(
|
||||
cafeId: string,
|
||||
employeeId: string,
|
||||
assignmentId: string,
|
||||
role: string
|
||||
) {
|
||||
return apiPatch<BranchRoleAssignment, { role: string }>(
|
||||
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles/${assignmentId}`,
|
||||
{ role }
|
||||
);
|
||||
}
|
||||
|
||||
export function removeBranchRole(
|
||||
cafeId: string,
|
||||
employeeId: string,
|
||||
assignmentId: string
|
||||
) {
|
||||
return apiDelete(
|
||||
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles/${assignmentId}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-issue the session token scoped to a branch (null = café-wide, Owner only). */
|
||||
export function switchBranch(branchId: string | null) {
|
||||
return apiPost<AuthTokenResponse, { branchId: string | null }>(
|
||||
`/api/auth/switch-branch`,
|
||||
{ branchId }
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,12 @@ export interface CafeMembership {
|
||||
planTier: string;
|
||||
}
|
||||
|
||||
export interface BranchMembership {
|
||||
branchId: string;
|
||||
branchName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
@@ -23,6 +29,14 @@ export interface AuthTokenResponse {
|
||||
actor?: string;
|
||||
branchId?: string | null;
|
||||
memberships?: CafeMembership[] | null;
|
||||
/** Display name of the currently active branch (null when café-wide). */
|
||||
branchName?: string | null;
|
||||
/** True when the session spans the whole café (Owner, no branch scope). */
|
||||
isCafeWide?: boolean;
|
||||
/** Branches this employee may operate as, with their role in each. */
|
||||
branches?: BranchMembership[] | null;
|
||||
/** Effective capabilities for the active role — drives page/action gating. */
|
||||
permissions?: string[] | null;
|
||||
}
|
||||
|
||||
/** Returned (in the data field) when a phone belongs to multiple cafés. */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BRANCH_ONLY_NAV_GROUP, type NavGroupId } from "@/lib/sidebar-nav";
|
||||
import { BRANCH_ONLY_NAV_GROUP, type NavGroupId, type NavItemKey } from "@/lib/sidebar-nav";
|
||||
import { NAV_REQUIRED_PERMISSION } from "@/lib/permissions";
|
||||
|
||||
/** Cafe owner (HQ) — billing, taxes, branches. */
|
||||
export function isCafeOwner(role: string | undefined): boolean {
|
||||
@@ -26,7 +27,8 @@ export function canSeeNavGroup(
|
||||
export function canSeeNavItem(
|
||||
key: string,
|
||||
role: string | undefined,
|
||||
branchId: string | null | undefined
|
||||
branchId: string | null | undefined,
|
||||
permissions?: Set<string> | null
|
||||
): boolean {
|
||||
if ((OWNER_ONLY_NAV_KEYS as readonly string[]).includes(key) && !isCafeOwner(role)) {
|
||||
return false;
|
||||
@@ -34,5 +36,14 @@ export function canSeeNavItem(
|
||||
if (key === "branches" && isBranchAccount(branchId)) {
|
||||
return false;
|
||||
}
|
||||
// Permission-based page visibility. `permissions === null` means a legacy
|
||||
// session with no permission list — fall back to the role/branch rules above
|
||||
// so those users keep their current access until the next token refresh.
|
||||
if (permissions) {
|
||||
const required = NAV_REQUIRED_PERMISSION[key as NavItemKey];
|
||||
if (required && !permissions.has(required)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import type { NavItemKey } from "@/lib/sidebar-nav";
|
||||
|
||||
/**
|
||||
* Client mirror of the backend `Meezi.Core.Authorization.Permission` enum. The
|
||||
* server (EnsurePermission) remains the single source of truth — these values
|
||||
* only drive what the UI *shows* (pages, action buttons). Never rely on them
|
||||
* for actual security.
|
||||
*/
|
||||
export type Permission =
|
||||
| "ManageCafeSettings"
|
||||
| "ManageBilling"
|
||||
| "ManageBranches"
|
||||
| "ManageStaff"
|
||||
| "ManageMenu"
|
||||
| "ManageInventory"
|
||||
| "ManageExpenses"
|
||||
| "ManageTaxes"
|
||||
| "ManageCoupons"
|
||||
| "ManageReservations"
|
||||
| "ManageTables"
|
||||
| "ViewReports"
|
||||
| "ReviewLeave"
|
||||
| "ManageSalaries"
|
||||
| "ManagePrintSettings"
|
||||
| "ProcessOrders"
|
||||
| "HandlePayments"
|
||||
| "OperateRegister"
|
||||
| "ManageQueue"
|
||||
| "ViewKitchen"
|
||||
| "HandleDelivery";
|
||||
|
||||
/**
|
||||
* Permission a nav page requires to be visible. Pages not listed here fall back
|
||||
* to the existing owner-only / branch-account visibility logic in
|
||||
* {@link file://./auth-permissions.ts}.
|
||||
*/
|
||||
export const NAV_REQUIRED_PERMISSION: Partial<Record<NavItemKey, Permission>> = {
|
||||
pos: "ProcessOrders",
|
||||
tables: "ManageTables",
|
||||
queue: "ManageQueue",
|
||||
kds: "ViewKitchen",
|
||||
reservations: "ManageReservations",
|
||||
menu: "ManageMenu",
|
||||
inventory: "ManageInventory",
|
||||
coupons: "ManageCoupons",
|
||||
reports: "ViewReports",
|
||||
expenses: "ManageExpenses",
|
||||
shifts: "OperateRegister",
|
||||
taxes: "ManageTaxes",
|
||||
hr: "ManageStaff",
|
||||
};
|
||||
|
||||
/** Read the effective permission set off an auth response (null = legacy session). */
|
||||
export function permissionsOf(
|
||||
user: { permissions?: string[] | null } | null | undefined
|
||||
): Set<string> | null {
|
||||
if (!user?.permissions) return null;
|
||||
return new Set(user.permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user holds a capability. Legacy sessions (no permissions array, e.g.
|
||||
* issued before this feature shipped) return `true` so the UI degrades gracefully
|
||||
* until the next token refresh — the server still enforces real access.
|
||||
*/
|
||||
export function hasPermission(
|
||||
user: { permissions?: string[] | null } | null | undefined,
|
||||
permission: Permission
|
||||
): boolean {
|
||||
const set = permissionsOf(user);
|
||||
if (set === null) return true;
|
||||
return set.has(permission);
|
||||
}
|
||||
|
||||
/** React hook: does the current user hold the given permission? */
|
||||
export function useHasPermission(permission: Permission): boolean {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
return hasPermission(user, permission);
|
||||
}
|
||||
@@ -4,5 +4,7 @@ import { routing } from "./i18n/routing";
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: ["/", "/(fa|ar|en)/:path*"],
|
||||
// Match every path so un-prefixed URLs get redirected to the default locale (fa).
|
||||
// Exclude API routes, Next internals, the guest QR menu (/q), and static files.
|
||||
matcher: ["/((?!api|_next|_vercel|q|.*\\..*).*)"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user