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

This commit is contained in:
soroush.asadi
2026-05-31 11:06:24 +03:30
parent 51e422272d
commit 345ae0a4b5
69 changed files with 11964 additions and 152 deletions
@@ -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 />