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