Merge pull request #913 from Tria-plc/freight/feat/fixes-v1q

Freight/feat/fixes v1
This commit is contained in:
Nathnael Wondisha
2026-07-22 16:06:02 +03:00
committed by GitHub
9 changed files with 549 additions and 2027 deletions

View File

@@ -627,7 +627,9 @@ const filterSidebarByPermission = (
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
items
.map((item) =>
item.children ? { ...item, children: filterItems(item.children) } : item,
item.children
? { ...item, children: filterItems(item.children) }
: item,
)
.filter((item) => {
if (etGl || djGl) {

View File

@@ -88,8 +88,8 @@ export const resolveModuleConfig = (config: TenantConfig): ModuleConfig => ({
});
const defaultConfig: TenantConfig = {
appName: "Smart Office",
organizationName: "Smart Office",
appName: "EDR Freight",
organizationName: "Ethio-Djibouti Railways",
canUseAttachmentFromDMS: false,
logo: "/assets/TriaTradinglogo.png",
primaryColor: "#1b354d",
@@ -116,8 +116,8 @@ const defaultConfig: TenantConfig = {
const tenantConfigs: Record<string, TenantConfig> = {
localhost: {
appName: "Smart Office",
organizationName: "Addis Ababa City Administration",
appName: "EDR Freight",
organizationName: "Ethio-Djibouti Railways",
logo: "",
primaryColor: "#0EA371",
moduleConfig: {

View File

@@ -1,413 +0,0 @@
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { NavLink, useLocation } from "react-router-dom";
import { cn } from "@/shared/lib/utils";
import {
LayoutGrid,
Upload,
Download,
CheckSquare,
UserCheck,
FileText,
LucideIcon,
Settings,
Handshake,
BellRing,
BarChart3,
} from "lucide-react";
import { useUserDetail } from "../hooks/useUserDetail";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import Cookies from "js-cookie";
import { useDelegations } from "../hooks/useDelegations";
import Top from "./Top";
import { useReport } from "../hooks/useReport";
import { hasApprovalPermission } from "@/record-management/routes/routes";
import { useReportByHooks } from "../hooks/useReportByHooks";
import { useMyCollaborations } from "../hooks/useMyCollaborations";
interface HeaderProps {
onToggleSidebar?: () => void;
}
export interface INavTabs {
icon: LucideIcon;
isVissible?: boolean;
label?: string;
title?: string;
href?: string;
url?: string;
isActive?: boolean;
count?: boolean;
isPrimary?: boolean;
countBadge?: number;
isUrgent?: boolean;
}
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
const baseParams = {
skip: 0,
take: 10,
orderBy: "employeePosition.createdAt:DESC",
};
const { t } = useTranslation();
const location = useLocation();
const { hasDelegated } = useDelegations(baseParams);
const { userDetails, selectedPositionPermissionKeys, selectedPosition } =
useAuthUser();
const { permissionKeys } = useUserDetail(userDetails);
const useParentCounts =
permissionKeys.includes("can:viewParentPositionRecord") &&
new URLSearchParams(location.search).get("view") === "parent";
const currentPosition = Cookies.get("current-position-id");
const canViewApprovalTab = hasApprovalPermission(
selectedPositionPermissionKeys,
);
// Reuse the auth layer's already-normalized active position instead of
// re-deriving it here. useAuthUser resolves it by matching BOTH `id` and
// `employeePositionId` (and honors the delegated-position cookie), so a
// delegate's position is found and `isDelegate` is reliable. The previous
// local lookup matched `pos.id === selectedPositionId` only — but
// `selectedPositionId` is normalized to `employeePositionId`, so the find
// returned undefined and `!undefined` left every tab visible for delegates.
const canViewDelegationTab = !selectedPosition?.isDelegate;
const userRoles =
userDetails?.roles?.map((r: { key: string }) => r.key) || [];
const showUserManagementShortcut =
userRoles.includes("admin") ||
userRoles.includes("unit_admin") ||
userRoles.includes("super_admin");
const {
dashboard,
ROdashboard,
TotalDraftExternal,
TotalDraftInternal,
TotalDraftCC,
ROTotalDraftIncoming,
ROPending,
TotalUrgentDraftInternal,
TotalUrgentDraftExternal,
ROPendingUrgent,
} = useReport("");
const { breakdownCounts } = useReportByHooks({
isSecretary: useParentCounts,
});
const { data: draftCollaborations } = useMyCollaborations({
skip: 0,
take: 1,
signStatus: "draft",
});
const collaborationDraftCount =
draftCollaborations?.count ?? dashboard?.collaborationCount?.draft ?? 0;
const incomingHref = useParentCounts
? "/record-management/userIncoming?view=parent"
: "/record-management/userIncoming";
const navigationTabs = useMemo(
(): INavTabs[] => [
{
icon: LayoutGrid,
label: t("header.navigation.dashboard"),
href: "/record-management/dashboard",
isActive: location.pathname.includes("/record-management/dashboard"),
isPrimary: true,
// Delegates see only Outgoing, Incoming, Approval — hide Dashboard.
isVissible: canViewDelegationTab,
},
{
icon: BarChart3,
label: "Reports",
href: "/record-management/sector-reports",
isActive: location.pathname.startsWith(
"/record-management/sector-reports",
),
isPrimary: false,
isVissible: false,
},
{
icon: Upload,
label: t("header.navigation.outgoing"),
href: "/record-management/userRecords",
isActive:
location.pathname.startsWith("/record-management/userRecords") &&
new URLSearchParams(location.search).get("from") !== "collaborations",
isPrimary: true,
isVissible: true,
},
{
icon: Download,
label: t("header.navigation.incoming"),
href: incomingHref,
isActive:
location.pathname.startsWith("/record-management/userIncoming") ||
location.pathname.startsWith(
"/record-management/viewIncoming/incoming/",
) ||
location.pathname.startsWith(
"/record-management/viewIncoming/internal",
) ||
location.pathname.startsWith("/record-management/viewIncoming/cc"),
isPrimary: true,
isVissible: true,
countBadge:
breakdownCounts.external +
breakdownCounts.externalSmart +
breakdownCounts.internal +
breakdownCounts.ccUnseen +
breakdownCounts.forYourReference,
isUrgent: TotalUrgentDraftInternal > 0 || TotalUrgentDraftExternal > 0,
},
{
icon: CheckSquare,
label: t("header.navigation.approval"),
href: "/record-management/approval",
isActive:
location.pathname.startsWith("/record-management/approval") ||
location.pathname.startsWith("/record-management/viewApproval"),
isPrimary: false,
isVissible: canViewApprovalTab,
countBadge: breakdownCounts?.approval || 0,
isUrgent:
(dashboard?.activeApprovalCount?.myUrgentWorkflowCount || 0) > 0,
},
{
icon: UserCheck,
label: t("header.navigation.delegation"),
href: "/record-management/delegation",
isActive: location.pathname.startsWith("/record-management/delegation"),
isPrimary: false,
isVissible: canViewDelegationTab,
},
{
icon: Handshake,
label: t("header.navigation.collaborations"),
href: "/record-management/collaborations",
isActive:
location.pathname.startsWith("/record-management/collaborations") ||
new URLSearchParams(location.search).get("from") === "collaborations",
isPrimary: false,
isVissible: canViewDelegationTab,
countBadge: collaborationDraftCount,
},
{
icon: Settings,
label: t("header.navigation.settings"),
href: "/record-management/uploadTeeterandSignature",
isActive: location.pathname.startsWith(
"/record-management/uploadTeeterandSignature",
),
isPrimary: false,
isVissible: canViewDelegationTab,
},
],
[
t,
location.pathname,
location.search,
TotalDraftCC,
TotalDraftInternal,
TotalDraftExternal,
TotalUrgentDraftInternal,
TotalUrgentDraftExternal,
canViewApprovalTab,
canViewDelegationTab,
useParentCounts,
incomingHref,
breakdownCounts.approval,
breakdownCounts.ccUnseen,
breakdownCounts.external,
breakdownCounts.externalSmart,
breakdownCounts.forYourReference,
breakdownCounts.internal,
dashboard?.activeApprovalCount?.myNotUrgentWorkflowsCount,
dashboard?.activeApprovalCount?.myUrgentWorkflowCount,
collaborationDraftCount,
],
);
const recordOfficerNavs = useMemo(
(): INavTabs[] => [
{
icon: LayoutGrid,
label: t("header.navigation.dashboard"),
href: "/record-management/dashboard",
isActive: location.pathname.includes("/dashboard"),
isVissible: true,
},
{
icon: Upload,
label: t("header.navigation.outgoing"),
href: "/record-management/recordOfficer/outgoing",
isActive:
location.pathname.startsWith(
"/record-management/recordOfficer/outgoing",
) ||
location.pathname.includes("/record-management/view/") ||
location.pathname.includes("/record-management/outgoingview/"),
isVissible: true,
},
{
icon: Download,
label: t("header.navigation.incoming"),
href: "/record-management/recordOfficer/incoming",
isActive:
location.pathname.startsWith(
"/record-management/recordOfficer/incoming",
) ||
location.pathname.includes("/record-management/viewIncoming/") ||
location.pathname.includes("/record-management/recordViewIncoming/"),
isVissible: true,
countBadge: ROTotalDraftIncoming,
},
{
icon: FileText,
label: t("header.navigation.pending"),
href: "/record-management/pending",
isActive:
location.pathname.startsWith("/record-management/pending") ||
location.pathname.includes("/record-management/viewPending/"),
isVissible: true,
countBadge: ROPending,
isUrgent: ROPendingUrgent > 0,
},
],
[t, location.pathname, ROTotalDraftIncoming, ROPending, ROPendingUrgent],
);
const activeNavigationTabs = useMemo(() => {
if (permissionKeys.includes("can:dispatchRecords")) {
return recordOfficerNavs;
}
return navigationTabs.filter((tab) => tab.isVissible);
}, [permissionKeys, navigationTabs, recordOfficerNavs]);
// const { user } = useAuth();
// console.log("User Info:", user);
// const organizationId =
// user?.employee && user.employee.length > 0
// ? user.employee[0].unitId
// : undefined;
// const unitId = organizationId;
return (
<div className="flex flex-col">
<Top
onToggleSidebar={onToggleSidebar}
showUserManagementShortcut={showUserManagementShortcut}
/>
{/* Navigation Tabs */}
{currentPosition ? (
<div className="fixed top-16 left-0 right-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-800 overflow-hidden pt-1 pb-1 px-2 sm:pt-1.5 sm:pb-1.5 sm:px-4 md:px-8">
{/* Mobile layout: Simple horizontal scrollable (up to sm) */}
<div className="sm:hidden">
<div className="flex items-center overflow-x-auto scrollbar-none">
{activeNavigationTabs.map((tab, index) => (
<NavLink
key={`${tab.href}-${index}`}
to={tab.href || "#"}
className={({ isActive }) =>
cn(
"flex items-center gap-1 px-3 py-2 mr-2 text-xs font-medium transition-colors whitespace-nowrap relative",
isActive
? "text-primary-800 dark:text-white border-b-2 border-primary-600 dark:border-primary-400"
: "text-gray-700 dark:text-gray-300 hover:text-primary-800 dark:hover:text-white",
)
}
>
<tab.icon className="h-4 w-4 flex-shrink-0" />
<span className="max-w-[80px] truncate">{tab.label}</span>
{/* Count Badge - positioned on tab */}
{typeof tab?.countBadge === "number" &&
tab.countBadge > 0 && (
<span className="bg-red-500 text-white text-[10px] font-bold rounded-full px-1.5 py-0.5 ml-1">
{tab.countBadge > 99 ? "99+" : tab.countBadge}
</span>
)}
{/* Urgent Bell - positioned on tab */}
{typeof tab?.isUrgent === "boolean" && tab.isUrgent && (
<span className="relative flex h-3 w-3 ml-1">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
)}
{/* Live indicator for Delegation */}
{hasDelegated &&
(tab.label?.toLowerCase() === "delegation" ||
tab.label?.toLowerCase() === "ዉክልና") && (
<span className="absolute -top-1 -right-1 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
)}
</NavLink>
))}
</div>
</div>
{/* Desktop layout: All tabs in one row (sm and up) */}
<div className="hidden sm:flex overflow-x-auto scrollbar-none w-full bg-gray-200 dark:bg-gray-800 rounded-md py-1.5">
<div className="flex w-full mx-1.5 gap-1.5">
{activeNavigationTabs.map((tab, index) => (
<NavLink
key={`${tab.href}-${index}`}
to={tab.href || "#"}
className={() =>
cn(
"flex items-center justify-center px-2.5 py-2.5 rounded-sm text-sm whitespace-nowrap flex-1 font-Urbanist relative",
tab.isActive
? "bg-primary-50 dark:bg-primary-900/40 text-primary-800 dark:text-white font-medium"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700",
)
}
>
<tab.icon className="h-4 w-4 mr-1.5 flex-shrink-0" />
<span className="flex items-center gap-1.5">
{tab.label}
{/* Count Badge (inline) */}
{typeof tab?.countBadge === "number" &&
tab.countBadge > 0 && (
<span className="bg-red-500 text-white text-xs font-semibold rounded-full px-1.5 py-0.5">
{tab.countBadge > 99 ? "99+" : tab.countBadge}
</span>
)}
{/* Urgent Bell (pulsing) */}
{typeof tab?.isUrgent === "boolean" && tab.isUrgent && (
<span className="relative flex h-4 w-4">
{/* Pulse effect */}
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
{/* Bell icon */}
<span className="relative inline-flex items-center justify-center">
<BellRing className="h-4 w-4 text-red-500" />
</span>
</span>
)}
</span>
{/* Live indicator for Delegation */}
{hasDelegated &&
(tab.label?.toLowerCase() === "delegation" ||
tab.label?.toLowerCase() === "ዉክልና") && (
<span className="relative -top-0.5 -right-0.5 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
)}
</NavLink>
))}
</div>
</div>
</div>
) : null}
</div>
);
};
export default Header;

View File

@@ -17,7 +17,6 @@ import {
ClipboardList,
Clock,
FileText,
Home,
Languages,
Key,
LogOut,
@@ -104,9 +103,9 @@ const Top: React.FC<HeaderProps> = ({
const activePositionName =
currentLanguage === "am"
? userDetails?.employee?.[0]?.positions?.[0]?.name?.am ||
userDetails?.employee?.[0]?.positions?.[0]?.name?.en
userDetails?.employee?.[0]?.positions?.[0]?.name?.en
: userDetails?.employee?.[0]?.positions?.[0]?.name?.en ||
userDetails?.employee?.[0]?.positions?.[0]?.name?.am;
userDetails?.employee?.[0]?.positions?.[0]?.name?.am;
const normalizedUserType = userDetails?.userType?.trim().toLowerCase() || "";
const userTypeLabel =
@@ -114,8 +113,8 @@ const Top: React.FC<HeaderProps> = ({
? t("header.user")
: normalizedUserType
? normalizedUserType
.replace(/[_-]/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
.replace(/[_-]/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
: "";
const roleLabel =
@@ -123,8 +122,8 @@ const Top: React.FC<HeaderProps> = ({
userTypeLabel ||
(normalizedUserType
? userDetails?.roles?.[0]?.key
?.replace(/[:_]/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
?.replace(/[:_]/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
: "");
const { permissionKeys } = useUserDetail(userDetails as MeDto);
@@ -159,57 +158,57 @@ const Top: React.FC<HeaderProps> = ({
},
...(moduleConfig.recordManagement
? [
{
id: "recordManagement",
label: t("nav.Record Management", "Record Management"),
path: "/record-management/dashboard",
},
]
{
id: "recordManagement",
label: t("nav.Record Management", "Record Management"),
path: "/record-management/dashboard",
},
]
: []),
...(moduleConfig.performance
? [
{
id: "performanceManagement",
label: t("nav.PerformanceManagement", "Performance Management"),
path: "/performance-management/plan-years",
},
]
{
id: "performanceManagement",
label: t("nav.PerformanceManagement", "Performance Management"),
path: "/performance-management/plan-years",
},
]
: []),
...(moduleConfig.objective
? [
{
id: "objectiveManagement",
label: t("nav.objectiveManagement", "Objective Management"),
path: "/objective-management/plan-years",
},
]
{
id: "objectiveManagement",
label: t("nav.objectiveManagement", "Objective Management"),
path: "/objective-management/plan-years",
},
]
: []),
...(moduleConfig.dms
? [
{
id: "documentManagement",
label: t("nav.DocumentManagement", "Document Management"),
path: "/dms/dashboard",
},
]
{
id: "documentManagement",
label: t("nav.DocumentManagement", "Document Management"),
path: "/dms/dashboard",
},
]
: []),
...(isOrgAdmin && moduleConfig.siteManagement
? [
{
id: "orgAdmin",
label: t("nav.admin", "Admin"),
path: "/user-management/user_management-dashboard",
},
]
{
id: "orgAdmin",
label: t("nav.admin", "Admin"),
path: "/user-management/user_management-dashboard",
},
]
: []),
...(isSuperAdmin
? [
{
id: "superAdmin",
label: t("OrganizationAdmin", "Super Admin"),
path: "/user-management/dashboard",
},
]
{
id: "superAdmin",
label: t("OrganizationAdmin", "Super Admin"),
path: "/user-management/dashboard",
},
]
: []),
];
@@ -256,10 +255,28 @@ const Top: React.FC<HeaderProps> = ({
};
return (
<div className="flex h-24 flex-col">
<header className="fixed left-0 right-0 top-0 z-40 h-16 border-b border-primary-100/70 bg-white/90 shadow-sm backdrop-blur-md dark:border-gray-800 dark:bg-gray-900/90">
<div className="flex h-full items-center justify-between px-2 sm:px-4 md:px-6">
<div className="flex min-w-0 flex-1 items-center gap-2 md:gap-3">
<header className="sticky top-0 z-40 h-14 shrink-0 border-b border-primary-100/70 bg-white/90 shadow-sm backdrop-blur-md dark:border-gray-800 dark:bg-gray-900/90">
<div className="flex h-full items-center justify-between px-2 sm:px-4 md:px-6">
<div className="flex min-w-0 flex-1 items-center gap-2 md:gap-3">
{onToggleSidebar ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onToggleSidebar}
className="h-9 w-9 flex-shrink-0 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40"
aria-label="Toggle sidebar"
title="Toggle sidebar"
>
<FiMenu className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Toggle sidebar
</TooltipContent>
</Tooltip>
) : (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@@ -320,191 +337,183 @@ const Top: React.FC<HeaderProps> = ({
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="hidden min-w-0 flex-1 sm:block sm:max-w-[34vw] lg:max-w-[41vw] xl:max-w-[48vw]">
<DynamicBreadcrumb />
</div>
{showUserManagementShortcut && (
<Button
variant="ghost"
size="sm"
className="flex h-9 w-9 sm:w-auto items-center justify-center sm:justify-start gap-1.5 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 px-0 sm:px-3"
onClick={() => navigate("/user-management")}
>
<User className="h-4 w-4 shrink-0 text-primary-500" />
<span className="hidden sm:inline text-xs font-semibold">
{t("dashboard.userManagement", "User Management")}
</span>
</Button>
)}
<div className="hidden min-w-0 flex-1 sm:block sm:max-w-[34vw] lg:max-w-[41vw] xl:max-w-[48vw]">
<DynamicBreadcrumb />
</div>
<div className="flex max-w-[56vw] flex-shrink-0 items-center space-x-1.5 sm:max-w-none sm:space-x-2.5">
{showUserManagementShortcut && (
<Button
variant="ghost"
size="sm"
className="flex h-9 w-9 sm:w-auto items-center justify-center sm:justify-start gap-1.5 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 px-0 sm:px-3"
onClick={() => navigate("/user-management")}
>
<User className="h-4 w-4 shrink-0 text-primary-500" />
<span className="hidden sm:inline text-xs font-semibold">
{t("dashboard.userManagement", "User Management")}
</span>
</Button>
)}
</div>
<div className="flex max-w-[56vw] flex-shrink-0 items-center space-x-1.5 sm:max-w-none sm:space-x-2.5">
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
aria-label={isDarkMode ? "Light mode" : "Dark mode"}
onClick={toggleDarkMode}
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
>
{isDarkMode ? (
<Sun className="h-4 w-4 text-yellow-500" />
) : (
<Moon className="h-4 w-4 text-gray-700 dark:text-gray-300" />
)}
</Button>
{canActivateUsers && (
<div className="relative">
<Button
variant="ghost"
size="icon"
className="relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
aria-label={t("header.notifications")}
onClick={() => setPendingUsers((prev) => !prev)}
>
<ClipboardList className="h-4 w-4" />
</Button>
{pendingUsersCount > 0 && (
<span className="absolute -right-1.5 -top-1.5 rounded-full bg-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow">
{pendingUsersCount > 99 ? "99+" : pendingUsersCount}
</span>
)}
{openPendingUsers && (
<div
ref={pendingUsersRef}
className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-y-auto rounded-2xl bg-white p-3 shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:max-h-96 sm:w-80 sm:p-4"
>
<UserApprovalDropdown />
</div>
)}
</div>
)}
<div className="relative" ref={dropdownRef}>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
aria-label={isDarkMode ? "Light mode" : "Dark mode"}
onClick={toggleDarkMode}
title={
isDarkMode ? "Switch to light mode" : "Switch to dark mode"
className={cn(
"relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10",
openNotifications &&
"bg-primary-50 text-primary-700 ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60",
)}
aria-label={t("header.notifications")}
onClick={() =>
setOpenNotifications((prev) => {
const next = !prev;
if (next) refresh();
return next;
})
}
>
{isDarkMode ? (
<Sun className="h-4 w-4 text-yellow-500" />
) : (
<Moon className="h-4 w-4 text-gray-700 dark:text-gray-300" />
<FiBell
className={cn(
"h-4 w-4 transition-colors",
openNotifications &&
"fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300",
)}
/>
{unseenCount > 0 && (
<span className="absolute -right-1.5 -top-1.5 min-w-[18px] rounded-full bg-red-500 px-1.5 py-0.5 text-center text-[10px] font-bold text-white shadow">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
)}
</Button>
{canActivateUsers && (
<div className="relative">
<Button
variant="ghost"
size="icon"
className="relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
aria-label={t("header.notifications")}
onClick={() => setPendingUsers((prev) => !prev)}
>
<ClipboardList className="h-4 w-4" />
</Button>
{pendingUsersCount > 0 && (
<span className="absolute -right-1.5 -top-1.5 rounded-full bg-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow">
{pendingUsersCount > 99 ? "99+" : pendingUsersCount}
</span>
)}
{openPendingUsers && (
<div
ref={pendingUsersRef}
className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-y-auto rounded-2xl bg-white p-3 shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:max-h-96 sm:w-80 sm:p-4"
>
<UserApprovalDropdown />
</div>
)}
{openNotifications && (
<div className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:w-[24rem]">
<NotificationList />
</div>
)}
</div>
<div className="relative" ref={dropdownRef}>
<Button
variant="ghost"
size="icon"
className={cn(
"relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10",
openNotifications &&
"bg-primary-50 text-primary-700 ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60",
)}
aria-label={t("header.notifications")}
onClick={() =>
setOpenNotifications((prev) => {
const next = !prev;
if (next) refresh();
return next;
})
}
>
<FiBell
{/* Reminders */}
<div className="relative" ref={remindersRef}>
<Button
variant="ghost"
size="icon"
className="relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary/10 hover:text-primary hover:ring-primary/30 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary/20 dark:hover:text-primary-300 dark:hover:ring-primary/40 sm:h-10 sm:w-10"
aria-label="Reminders"
onClick={() => setOpenReminders((prev) => !prev)}
>
<Clock className="h-4 w-4" />
{pendingCount > 0 && (
<span className="absolute -right-1.5 -top-1.5 min-w-[18px] rounded-full bg-primary px-1.5 py-0.5 text-center text-[10px] font-bold text-primary-foreground shadow">
{pendingCount > 99 ? "99+" : pendingCount}
</span>
)}
</Button>
{openReminders && (
<div className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:w-[24rem]">
<ReminderList />
</div>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Languages className="h-4 w-4 text-primary-500" />
<span className="hidden sm:inline text-xs">
{getUiLanguageShortLabel(currentLanguage, t)}
</span>
<ChevronDown className="hidden h-3.5 w-3.5 text-gray-400 sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-52 rounded-xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
align="end"
forceMount
>
{UI_LANGUAGE_OPTIONS.map((lang) => (
<DropdownMenuItem
key={lang.value}
onClick={() => changeLanguage(lang.value)}
className={cn(
"h-4 w-4 transition-colors",
openNotifications &&
"fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300",
"flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30",
currentLanguage === lang.value &&
"bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white",
)}
/>
{unseenCount > 0 && (
<span className="absolute -right-1.5 -top-1.5 min-w-[18px] rounded-full bg-red-500 px-1.5 py-0.5 text-center text-[10px] font-bold text-white shadow">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
)}
</Button>
>
<div className="flex items-center gap-2">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gray-100 text-[11px] font-bold text-gray-600 dark:bg-gray-700 dark:text-gray-200">
{getUiLanguageShortLabel(lang.value, t)}
</span>
<span>{getUiLanguageLabel(lang.value, t)}</span>
</div>
{currentLanguage === lang.value && (
<Check className="h-4 w-4 text-primary-600 dark:text-primary-400" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{openNotifications && (
<div className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:w-[24rem]">
<NotificationList />
</div>
)}
</div>
{/* Reminders */}
<div className="relative" ref={remindersRef}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary/10 hover:text-primary hover:ring-primary/30 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary/20 dark:hover:text-primary-300 dark:hover:ring-primary/40 sm:h-10 sm:w-10"
aria-label="Reminders"
onClick={() => setOpenReminders((prev) => !prev)}
className="group h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-r from-white to-primary-50/80 p-0 text-gray-700 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:from-primary-50 hover:to-primary-100 hover:ring-primary-200 dark:from-gray-900 dark:to-gray-800 dark:text-gray-200 dark:ring-white/10 dark:hover:from-gray-800 dark:hover:to-primary-900/20 dark:hover:ring-primary-700/40 sm:h-10 sm:w-auto sm:max-w-[170px] sm:justify-start sm:gap-2 sm:rounded-2xl sm:px-1.5 sm:pr-2 md:max-w-[200px]"
aria-label={t("header.userMenu")}
>
<Clock className="h-4 w-4" />
{pendingCount > 0 && (
<span className="absolute -right-1.5 -top-1.5 min-w-[18px] rounded-full bg-primary px-1.5 py-0.5 text-center text-[10px] font-bold text-primary-foreground shadow">
{pendingCount > 99 ? "99+" : pendingCount}
</span>
)}
</Button>
{openReminders && (
<div className="fixed left-3 right-3 top-16 z-50 mt-2 max-h-[calc(100dvh-5rem)] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:w-[24rem]">
<ReminderList />
</div>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-9 w-9 justify-center rounded-xl bg-white/90 px-0 text-xs font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-auto sm:gap-2 sm:px-3"
>
<Languages className="h-4 w-4 text-primary-500" />
<span className="hidden md:inline">
{getUiLanguageLabel(currentLanguage, t)}
</span>
<span className="hidden sm:inline md:hidden">
{getUiLanguageShortLabel(currentLanguage, t)}
</span>
<ChevronDown className="hidden h-3.5 w-3.5 text-gray-400 sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-52 rounded-xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
align="end"
forceMount
>
{UI_LANGUAGE_OPTIONS.map((lang) => (
<DropdownMenuItem
key={lang.value}
onClick={() => changeLanguage(lang.value)}
className={cn(
"flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30",
currentLanguage === lang.value &&
"bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white",
)}
>
<div className="flex items-center gap-2">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gray-100 text-[11px] font-bold text-gray-600 dark:bg-gray-700 dark:text-gray-200">
{getUiLanguageShortLabel(lang.value, t)}
</span>
<span>{getUiLanguageLabel(lang.value, t)}</span>
</div>
{currentLanguage === lang.value && (
<Check className="h-4 w-4 text-primary-600 dark:text-primary-400" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="group h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-r from-white to-primary-50/80 p-0 text-gray-700 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:from-primary-50 hover:to-primary-100 hover:ring-primary-200 dark:from-gray-900 dark:to-gray-800 dark:text-gray-200 dark:ring-white/10 dark:hover:from-gray-800 dark:hover:to-primary-900/20 dark:hover:ring-primary-700/40 sm:h-10 sm:w-auto sm:max-w-[170px] sm:justify-start sm:gap-2 sm:rounded-2xl sm:px-1.5 sm:pr-2 md:max-w-[200px]"
aria-label={t("header.userMenu")}
>
<div
className="
<div
className="
flex h-8 w-8 sm:h-9 sm:w-9
items-center justify-center
rounded-full
@@ -517,93 +526,92 @@ const Top: React.FC<HeaderProps> = ({
ring-2 ring-white dark:ring-gray-900
shrink-0
"
>
{initials.toUpperCase()}
</div>
<div className="hidden min-w-0 flex-col items-start text-left lg:flex">
<span className="max-w-[118px] truncate text-xs font-semibold leading-none">
{fullName}
</span>
<span className="max-w-[118px] truncate pt-1 text-[10px] font-medium leading-none text-gray-500 dark:text-gray-400">
{roleLabel}
</span>
</div>
<ChevronDown className="hidden h-4 w-4 text-gray-400 transition group-hover:text-primary-600 dark:group-hover:text-primary-300 sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-64 rounded-xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
align="end"
>
<div className="mb-1 rounded-lg bg-gradient-to-r from-primary-500 to-primary-600 p-3 text-white">
<p className="truncate text-sm font-semibold">{fullName}</p>
<p className="truncate pt-1 text-xs text-white/90">
{roleLabel}
</p>
>
{initials.toUpperCase()}
</div>
<div className="hidden min-w-0 flex-col items-start text-left lg:flex">
<span className="max-w-[118px] truncate text-xs font-semibold leading-none">
{fullName}
</span>
<span className="max-w-[118px] truncate pt-1 text-[10px] font-medium leading-none text-gray-500 dark:text-gray-400">
{roleLabel}
</span>
</div>
<ChevronDown className="hidden h-4 w-4 text-gray-400 transition group-hover:text-primary-600 dark:group-hover:text-primary-300 sm:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-64 rounded-xl bg-white p-2 shadow-xl ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
align="end"
>
<div className="mb-1 rounded-lg bg-gradient-to-r from-primary-500 to-primary-600 p-3 text-white">
<p className="truncate text-sm font-semibold">{fullName}</p>
<p className="truncate pt-1 text-xs text-white/90">
{roleLabel}
</p>
</div>
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/profile")}
>
<User className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.viewProfile")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/update-profile")}
>
<UserPen className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.editProfile")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/change-password")}
>
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.changePassword")}</span>
</DropdownMenuItem>
{showRecordManagementShortcut && (
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/profile")}
onClick={() => navigate("/record-management/dashboard")}
>
<User className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.viewProfile")}</span>
<FileText className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("nav.Record Management")}</span>
</DropdownMenuItem>
)}
{showUserManagementShortcut && (
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/update-profile")}
onClick={() => navigate("/user-management")}
>
<UserPen className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.editProfile")}</span>
<UsersRound className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>
{t("dashboard.userManagement", "User Management")}
</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/change-password")}
>
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.changePassword")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1 h-px bg-gray-200 dark:bg-gray-700" />
{showRecordManagementShortcut && (
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/record-management/dashboard")}
>
<FileText className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("nav.Record Management")}</span>
</DropdownMenuItem>
)}
{showUserManagementShortcut && (
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/user-management")}
>
<UsersRound className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>
{t("dashboard.userManagement", "User Management")}
</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="my-1 h-px bg-gray-200 dark:bg-gray-700" />
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-red-600 transition-colors hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={handleLogout}
>
<LogOut className="mr-2.5 h-4 w-4" />
<span>{t("header.signOut")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-red-600 transition-colors hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={handleLogout}
>
<LogOut className="mr-2.5 h-4 w-4" />
<span>{t("header.signOut")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
</div>
</div>
</header>
);
};

View File

@@ -1,4 +1,4 @@
import { NavLink } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import {
Archive,
BarChart,
@@ -8,28 +8,48 @@ import {
FileText,
Globe,
Settings,
ShieldAlert,
Users2,
UsersRound,
} from "lucide-react";
import { useAuth } from "@/shared/context/AuthContext";
import { usePermissions } from "@/shared/context/PermissionContext";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/shared/context/AuthContext";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/shared/common/ui/sidebar";
export interface MenuItem {
label: string;
href: string;
icon: React.ReactNode;
roles?: string[];
permissions?: string[];
isPrimary?: boolean;
displayLabel?: string;
children?: MenuItem[];
/** Sidebar section this item is bucketed under. */
group: string;
}
// Section render order; groups with no role-visible items are skipped.
const GROUP_ORDER = [
"Overview",
"Organizations",
"Content",
"Records",
"Configuration",
"Archive",
"System",
];
export const AppMenuTabs = () => {
const { user } = useAuth();
const { pathname } = useLocation();
const { setOpenMobile } = useSidebar();
const { t } = useTranslation();
const userRoles = user?.roles.map((role) => role.key) || [];
const menuItems: MenuItem[] = [
@@ -38,282 +58,193 @@ export const AppMenuTabs = () => {
href: "/user-management/dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
group: "Overview",
},
{
label: "organizations",
href: "/user-management/organizations",
icon: <Building2 className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
group: "Organizations",
},
{
label: "organizationAdmins", // Shortened for mobile
displayLabel: "organizationAdmins", // Full label for desktop
label: "organizationAdmins",
href: "/user-management/organization_admins",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
group: "Organizations",
},
{
label: "externalUsers", // Shortened for mobile
displayLabel: "externalUsers", // Full label for desktop
label: "externalUsers",
href: "/user-management/external_users",
icon: <Users2 className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
group: "Organizations",
},
{
label: "dashboard",
href: "/user-management/user_management-dashboard",
icon: <BarChart className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
group: "Overview",
},
{
label: "userManagement",
href: "/user-management/user_management",
icon: <UsersRound className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
group: "Overview",
},
// {
// label: "userPositionApproval",
// href: "/user-management/user-position-approval",
// icon: <UsersRound className="h-4 w-4" />,
// roles: ["admin", "organization_admin", "unit_admin"],
// permissions: ["can:activateEmployee"],
// isPrimary: true,
// },
// {
// label: "All Records",
// displayLabel: "All Records",
// href: "/user-management/all-records",
// icon: <FileText className="h-4 w-4" />,
// roles: ["admin", "organization_admin", "unit_admin"],
// permissions: ["can:canViewAllRecords"],
// isPrimary: true,
// },
{
label: "contentManagement", // Shortened for mobile
displayLabel: "contentManagement", // Full label for desktop
label: "contentManagement",
href: "/user-management/content-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
group: "Content",
},
{
label: "webManagement",
displayLabel: "webManagement",
href: "/user-management/web-management",
icon: <Globe className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Position",
displayLabel: "positionTypes",
href: "/user-management/position-management",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
isPrimary: true,
},
{
label: "migratedRecords",
displayLabel: "migratedRecords",
href: "/user-management/migrated-records-management",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "settings",
displayLabel: "settings",
href: "/user-management/organization-settings",
icon: <Settings className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
group: "Content",
},
{
label: "Bulk",
displayLabel: "bulkUpload",
href: "/user-management/bulk-upload",
icon: <FileText className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
group: "Content",
},
{
label: "Archive Users",
displayLabel: "Archive Users",
href: "/user-management/archive-users",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "Archived Organizations",
displayLabel: "Archived Organizations",
href: "/user-management/archived-organizations",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
},
{
label: "Archive Users",
displayLabel: "Archive Users",
href: "/user-management/archives",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Archived Units & Positions",
displayLabel: "Archived Units & Positions",
href: "/user-management/archived",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
isPrimary: true,
},
{
label: "Sector Reports",
displayLabel: "Sector Reports",
href: "/user-management/sector-reports",
icon: <ChartAreaIcon className="h-5 w-5" />,
roles: ["unit_admin", "admin", "organization_admin"],
isPrimary: true,
},
{
label: "activityLog",
href: "/user-management/activity_log",
icon: <ClipboardList className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: false,
},
{
label: "setting",
href: "/user-management/settings",
icon: <Settings className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: false,
},
{
label: "Letter Template",
href: "/user-management/templates",
label: "Position",
href: "/user-management/position-management",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: false,
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
group: "Configuration",
},
{
label: "settings",
href: "/user-management/organization-settings",
icon: <Settings className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Configuration",
},
{
label: "Add Site",
href: "/user-management/add-site",
icon: <Globe className="h-4 w-4" />,
roles: ["super_admin"],
isPrimary: true,
group: "Configuration",
},
{
label: "migratedRecords",
href: "/user-management/migrated-records-management",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
group: "Records",
},
{
label: "Sector Reports",
href: "/user-management/sector-reports",
icon: <ChartAreaIcon className="h-4 w-4" />,
roles: ["unit_admin", "admin", "organization_admin"],
group: "Records",
},
{
label: "Archive Users",
href: "/user-management/archive-users",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
group: "Archive",
},
{
label: "Archived Organizations",
href: "/user-management/archived-organizations",
icon: <Archive className="h-4 w-4" />,
roles: ["super_admin"],
group: "Archive",
},
{
label: "Archive Users",
href: "/user-management/archives",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Archive",
},
{
label: "Archived Units & Positions",
href: "/user-management/archived",
icon: <Archive className="h-4 w-4" />,
roles: ["admin", "organization_admin", "unit_admin"],
group: "Archive",
},
{
label: "activityLog",
href: "/user-management/activity_log",
icon: <ClipboardList className="h-4 w-4" />,
roles: ["super_admin"],
group: "System",
},
{
label: "setting",
href: "/user-management/settings",
icon: <Settings className="h-4 w-4" />,
roles: ["super_admin"],
group: "System",
},
{
label: "Letter Template",
href: "/user-management/templates",
icon: <FileText className="h-4 w-4" />,
roles: ["super_admin"],
group: "System",
},
// {
// label: "branding.title",
// href: "/user-management/web-management/Branding/Branding",
// icon: <ShieldAlert className="h-4 w-4" />,
// roles: ["super_admin"],
// isPrimary: true,
// },
];
const { permissions } = usePermissions();
const { t } = useTranslation();
const filteredMenu = menuItems.filter((item) =>
item?.roles?.some((r) => userRoles.includes(r)),
);
const primaryMenuItems = filteredMenu.filter(
(item) => item.isPrimary !== false,
);
const secondaryMenuItems = filteredMenu.filter(
(item) => item.isPrimary === false,
item.roles?.some((r) => userRoles.includes(r)),
);
const isActive = (href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
return (
// Sticky (not fixed) so it stays in flow: content below never needs a
// magic offset matching this bar's responsive height. top-16 keeps it
// pinned just below the fixed 64px <Top> header while scrolling.
// -mt-8 cancels the excess of <Top>'s in-flow h-24 wrapper over its 64px
// fixed header, so the bar sits flush under the header with no jump.
// shrink-0 is load-bearing: as a flex item with overflow-hidden this bar
// would otherwise be flex-squashed to zero height when the page overflows.
<div className="sticky top-16 -mt-8 z-30 shrink-0 bg-white dark:bg-gray-900 border-b dark:border-gray-800 overflow-hidden flex flex-col">
{/* Mobile View - Two separate rows */}
<div className="md:hidden flex flex-col pt-5 pb-3 px-4 space-y-3 mt-2">
{/* Primary items row */}
<div className="flex items-center overflow-x-auto scrollbar-none">
{primaryMenuItems.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${
isActive
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
}`
}
>
{item.icon}
<span className="max-w-[80px] truncate">
{t(`organization.${item.label}`, item.label)}
</span>
</NavLink>
))}
</div>
<>
{GROUP_ORDER.map((group) => {
const items = filteredMenu.filter((item) => item.group === group);
if (items.length === 0) return null;
{/* Secondary items row (if any) */}
{secondaryMenuItems.length > 0 && (
<div className="flex items-center overflow-x-auto scrollbar-none border-t dark:border-gray-800 pt-3">
{secondaryMenuItems.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${
isActive
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
}`
}
>
{item.icon}
<span className="max-w-[80px] truncate">
{t(`organization.${item.label}`, item.label)}
</span>
</NavLink>
))}
</div>
)}
</div>
{/* Desktop View - Single row with all items */}
<div className="hidden md:flex pt-5 pb-3 px-8 mt-1">
<div className="flex items-center gap-2 overflow-x-auto scrollbar-none">
{filteredMenu.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
isActive
? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400"
: "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400"
}`
}
>
{item.icon}
<span className="max-w-full truncate">
{t(`organization.${item.label}`, item.label)}
</span>
</NavLink>
))}
</div>
</div>
</div>
return (
<SidebarGroup key={group} className="pb-0">
<SidebarGroupLabel>{group}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const label = t(`organization.${item.label}`, item.label);
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={isActive(item.href)}
tooltip={label}
>
<Link
to={item.href}
onClick={() => setOpenMobile(false)}
>
{item.icon}
<span>{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
})}
</>
);
};

View File

@@ -1,43 +1,92 @@
import { useLocation } from "react-router-dom";
import { Outlet } from "react-router-dom";
import { AppMenuTabs } from "./AppMenuTabs";
import { useSidebar } from "@/shared/common/ui/sidebar";
import Top from "@/record-management/components/common/Top";
import { useAuth } from "@/shared/context/AuthContext";
export const AppLayout = () => {
const { pathname } = useLocation();
const isAuthPage = pathname === "/";
const { toggleSidebar } = useSidebar();
const { user } = useAuth();
const userRoles = user?.roles?.map((role) => role.key) || [];
const isSuperAdmin = userRoles.includes("super_admin");
// html/body/#root are `overflow: hidden` (index.css) — the host chrome
// scrolls inside FreightDashboardLayout. This subtree renders its own
// full-page layout instead, so it must be its own scroll container or
// nothing scrolls. Top/AppMenuTabs are `fixed`, unaffected by the scroller.
if (isAuthPage) {
return (
<div className="h-dvh w-full overflow-y-auto bg-gray-50">
<Outlet />
</div>
);
}
return (
// <Top> renders its own in-flow h-24 wrapper around the fixed 64px header,
// so flow already clears the header — no extra top padding here.
// AppMenuTabs is sticky and in flow, so content starts right below it.
<div className="w-full h-dvh overflow-y-auto flex flex-col bg-background text-foreground">
<Top onToggleSidebar={toggleSidebar} showRecordManagementShortcut={!isSuperAdmin} />
<AppMenuTabs />
<div className="px-2 pb-2 pt-4 sm:px-4 sm:pb-4 sm:pt-6 flex-1">
<div className="w-full overflow-x-auto">
<Outlet />
</div>
</div>
</div>
);
};
import { Link, Outlet, useLocation } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { AppMenuTabs } from "./AppMenuTabs";
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarInset,
SidebarRail,
useSidebar,
} from "@/shared/common/ui/sidebar";
import Top from "@/record-management/components/common/Top";
import { useAuth } from "@/shared/context/AuthContext";
export const AppLayout = () => {
const { pathname } = useLocation();
const isAuthPage = pathname === "/";
const { toggleSidebar } = useSidebar();
const { user } = useAuth();
const userRoles = user?.roles?.map((role) => role.key) || [];
const isSuperAdmin = userRoles.includes("super_admin");
// Standalone full-page scroll container for the auth screen — the host
// chrome is `overflow: hidden`, so this subtree must scroll itself.
if (isAuthPage) {
return (
<div className="h-dvh w-full overflow-y-auto bg-gray-50">
<Outlet />
</div>
);
}
return (
<>
{/* Full-height sidebar owning the left column (back-to-home + brand at the
top). <Top> lives inside <SidebarInset>, to the right of the sidebar,
and is `sticky` — so it never overlaps the sidebar and re-flows when
the sidebar collapses to its icon rail. Top's burger (onToggleSidebar)
drives collapse on desktop and the Sheet drawer on mobile. */}
<Sidebar collapsible="icon">
<SidebarHeader className="gap-1 border-b border-sidebar-border">
<Link
to="/dashboard/overview"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<ArrowLeft className="size-4 shrink-0" />
<span className="group-data-[collapsible=icon]:hidden">
Back to home
</span>
</Link>
<Link
to="/user-management"
className="flex items-center gap-2 px-1 py-2.5"
>
<img
src="/assets/logo.svg"
alt="EDR"
className="size-7 shrink-0 object-contain"
/>
<div className="flex flex-col group-data-[collapsible=icon]:hidden">
<span className="text-sm font-semibold leading-tight text-sidebar-foreground">
User Management
</span>
<span className="text-[10px] font-medium text-muted-foreground">
EDR Freight
</span>
</div>
</Link>
</SidebarHeader>
<SidebarContent className="pb-10">
<AppMenuTabs />
</SidebarContent>
<SidebarRail />
</Sidebar>
{/* Internal scroll container: the sidebar stays fixed and full-height
while this column scrolls beneath the sticky <Top> bar. */}
<SidebarInset className="h-svh min-w-0 overflow-y-auto">
<Top
onToggleSidebar={toggleSidebar}
showRecordManagementShortcut={!isSuperAdmin}
/>
<div className="flex-1 pb-2 sm:px-4 sm:pb-4 ">
<div className="w-full overflow-x-auto">
<Outlet />
</div>
</div>
</SidebarInset>
</>
);
};

View File

@@ -210,7 +210,7 @@ export default function PositionManagement() {
return (
<div className="p-6 space-y-6">
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
<Card className="py-0 col-span-2 shadow-none border-none bg-transparent px-0">
<CardHeader className="flex flex-row justify-between items-center px-0">
<CardTitle className="text-xl font-semibold ">
{t("contentManagement.permissionType")}

View File

@@ -1,13 +1,7 @@
import PositionManagement from "@/user-management/components/position-management/PositionLists";
import { t } from "i18next";
const PositionManagementPage = () => {
return (
<div className="p-6">
<h2 className="text-2xl font-bold mb-4">{t("contentManagement.permissionManagement")}</h2>
<PositionManagement />
</div>
);
return <PositionManagement />;
};
export default PositionManagementPage;