refactor: remove duplicate header components and consolidate layout

This commit is contained in:
Nathnael
2026-07-22 12:57:56 +00:00
parent 8389b2735d
commit 9baffb6439
6 changed files with 345 additions and 1797 deletions

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,
@@ -256,10 +255,28 @@ const Top: React.FC<HeaderProps> = ({
};
return (
<div className="flex h-14 flex-col">
<header className="fixed left-0 right-0 top-0 z-40 h-14 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,84 +1,92 @@
import { Link, Outlet, useLocation } from "react-router-dom";
import { AppMenuTabs } from "./AppMenuTabs";
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarInset,
SidebarRail,
SidebarTrigger,
} 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 { 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 (
<>
{/* <Top> is a shared component rendered as a full-width `fixed` 64px bar,
so the sidebar is offset to start beneath it (top-16) rather than
owning the top-left corner. Collapses to an icon rail on desktop; on
mobile it's a Sheet drawer opened by the <SidebarTrigger> below (Top's
own burger is a module-nav dropdown, not the sidebar toggle). */}
<Sidebar collapsible="icon" className="top-14! h-[calc(100svh-4rem)]!">
<SidebarHeader className="border-b border-sidebar-border">
<Link
to="/user-management"
className="flex items-center gap-2 px-1 py-1.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>
<SidebarInset className="min-w-0">
<Top showRecordManagementShortcut={!isSuperAdmin} />
{/* Top provides its own in-flow h-24 spacer clearing the fixed header. */}
{/* Mobile-only drawer opener — desktop shows the sidebar/rail directly. */}
<div className="flex items-center gap-2 px-3 md:hidden">
<SidebarTrigger className="h-9 w-9 rounded-lg" />
<span className="text-sm font-medium text-muted-foreground">
Menu
</span>
</div>
<div className="flex-1 pb-2 sm:px-4 sm:pb-4 ">
<div className="w-full overflow-x-auto">
<Outlet />
</div>
</div>
</SidebarInset>
</>
);
};
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;