Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx
yaschalew 697799b00d fix
2026-07-10 14:24:58 +03:00

703 lines
28 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence, Variants } from "framer-motion";
import { useNavigate } from "react-router-dom";
import { useAuthUser } from "@/shared/hooks/useAuthUser";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { toast } from "sonner";
import {
ChevronDown,
FileText,
Menu,
X,
User,
UserPlus as UserPen,
Key,
LogOut,
Cog,
Bolt,
Globe,
Moon,
Sun,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Button } from "@/shared/common/ui/button";
import { useTheme } from "@/user-management/web-Management/hooks/useTheme";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useModules } from "@/user-management/web-Management/hooks/useModules";
import { useDarkMode } from "@/shared/hooks/useDarkMode";
import {
useTenantConfig,
resolveModuleConfig,
} from "@/layout/components/TenantConfig";
import {
UI_LANGUAGE_OPTIONS,
resolveUiLanguage,
} from "@/shared/i18n/uiLanguages";
interface NavItem {
id: string;
label?: string;
path?: string;
link?: string;
requiresCompleteRegistration?: true;
children?: NavItem[];
name?: {
am: string;
en: string;
};
description?: string;
}
const languageOptions = UI_LANGUAGE_OPTIONS;
const Header = () => {
const [activeTab, setActiveTab] = useState("overview");
const [scrolled, setScrolled] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const navigate = useNavigate();
const { items: item } = useTheme();
const { userDetails, logout } = useAuthUser();
const { t, i18n } = useTranslation();
const currentLanguage = resolveUiLanguage(i18n.language);
const { config: tenantConfig } = useTenantConfig();
const moduleConfig = resolveModuleConfig(tenantConfig);
const localizedName = useLocalizedName();
const hasCompletedRegistration = userDetails?.hasFinishedRegistration;
const baseParams = {
isActive: true,
};
const { items: modules } = useModules(baseParams);
const fullName = localizedName(userDetails?.name) || t("header.user");
const splittedName = fullName.trim().split(" ");
const initials =
splittedName.length === 1
? splittedName[0][0]
: `${splittedName[0][0]}${splittedName[1][0]}`;
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
};
const handleLogout = () => {
logout();
};
const { isDarkMode, toggleDarkMode } = useDarkMode();
useEffect(() => {
if (!localStorage.getItem("i18nextLng")) {
i18n.changeLanguage("am");
localStorage.setItem("i18nextLng", "am");
}
}, [i18n]);
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 10);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const isSuperAdmin = userDetails?.roles?.some(
(role) => role.key === "super_admin",
);
const isOrgAdmin = userDetails?.roles?.some(
(role) => role.key === "unit_admin" || role.key === "organization_admin",
);
const navItems: NavItem[] = [
{
id: "Modules",
label: t("nav.modules"),
children: [
...(moduleConfig.recordManagement
? [
{
id: "recordManagement",
label: t("nav.Record Management"),
path: "/record-management/dashboard",
description: t("msg.recordDes"),
},
]
: []),
...(moduleConfig.performance
? [
{
id: "performanceManagement",
label: t("nav.PerformanceManagement"),
path: "/performance-management/plan-years",
description: t("nav.PerformanceManagement"),
},
]
: []),
...(moduleConfig.objective
? [
{
id: "objectiveManagement",
label: t("nav.objectiveManagement"),
path: "/objective-management/plan-years",
description: t("nav.objectiveManagement"),
},
]
: []),
...(moduleConfig.dms
? [
{
id: "documentManagement",
label: t("nav.DocumentManagement"),
path: "/dms/dashboard",
description: t("nav.DocumentManagement"),
},
]
: []),
...(isOrgAdmin && moduleConfig.siteManagement
? [
{
id: "orgAdmin",
label: t("nav.admin"),
path: "/user-management/user_management-dashboard",
description: t("msg.orgAdminDes"),
},
]
: []),
...(isSuperAdmin
? [
{
id: "superAdmin",
label: t("OrganizationAdmin"),
path: "/user-management/dashboard",
},
]
: []),
],
},
{ id: "OfficeHeadMessage", label: t("nav.officeHeadMessage") },
{ id: "DeputyMessage", label: t("nav.deputyMessage") },
{
id: "OrganizationGoal",
label: t("nav.orgGoal"),
},
{ id: "NewsSection", label: t("nav.news") },
...(modules && modules.length > 0
? [
{
id: "Other",
label: t("nav.other"),
children:
modules?.map((i) => ({
id: i.id,
name: i.label,
link: i.link,
description: localizedName(i.description),
})) || [],
},
]
: []),
];
const handleNavClick = (item: NavItem) => {
if (item.requiresCompleteRegistration && !hasCompletedRegistration) {
toast.error(t("registration.registrationRequired"));
return;
}
if (item.path) {
navigate(item.path);
setMobileMenuOpen(false);
return;
}
if (item.link) {
let url = item.link.trim();
// 🧹 Fix common typos like "https//:" or "http//:"
url = url.replace(/^https?\/\/:/i, "https://");
// 🌐 If it doesn't start with http/https, add https://
if (!/^https?:\/\//i.test(url)) {
url = `https://${url.replace(/^\/+/, "")}`; // remove leading slashes
}
// ✅ Finally open the link safely in a new tab
window.open(url, "_blank", "noopener,noreferrer");
setMobileMenuOpen(false);
}
};
const mobileMenuVariants = {
hidden: {
opacity: 0,
height: 0,
transition: { duration: 0.3, when: "afterChildren" },
},
visible: {
opacity: 1,
height: "auto",
transition: {
duration: 0.3,
when: "beforeChildren",
staggerChildren: 0.08,
},
},
};
const mobileItemVariants: Variants = {
hidden: { x: -20, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: { type: "spring" as const, stiffness: 300, damping: 30 },
},
};
const servicesVariants = {
hidden: { opacity: 0, y: -15, scale: 0.92 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.25, staggerChildren: 0.06 },
},
};
const serviceItemVariants = {
hidden: { opacity: 0, x: -10 },
visible: { opacity: 1, x: 0 },
};
const getServiceIcon = (id: string, isOrgAdmin: any, isSuperAdmin: any) => {
switch (id) {
case "recordManagement":
return <FileText className="w-4 h-4" />;
case "orgAdmin":
return isOrgAdmin ? <Cog className="w-4 h-4" /> : null;
case "superAdmin":
return isSuperAdmin ? <Bolt className="w-4 h-4" /> : null;
default:
return <Globe className="w-4 h-4" />;
}
};
const getCurrentLanguageDisplay = () => {
const currentLang = languageOptions.find(
(lang) => lang.value === currentLanguage,
);
return currentLang ? currentLang.label : "English";
};
return (
<>
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled
? "bg-white/95 dark:bg-gray-900/95 backdrop-blur-md shadow-lg border-b border-gray-100 dark:border-gray-800"
: "bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm shadow-sm border-b border-gray-50 dark:border-gray-800"
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16 md:h-20">
<div className="flex items-center gap-3 md:gap-4">
<motion.img
src={tenantConfig.logo}
alt={tenantConfig.appName}
className="h-10 w-15 md:h-17 md:w-25 rounded-lg object-contain cursor-pointer shadow-sm hover:shadow-md transition-shadow"
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.95 }}
onClick={() => navigate("/")}
/>
</div>
<div className="hidden md:flex md:gap-2 lg:gap-4 items-center flex-1 ml-8">
{navItems.map((item) => (
<div key={item?.id} className="relative group">
{item?.children ? (
<div
className="relative"
onMouseEnter={() => setOpenDropdown(item.id)}
onMouseLeave={() => setOpenDropdown(null)}
>
<motion.button
onClick={() =>
setOpenDropdown(
openDropdown === item.id ? null : item.id,
)
}
className={`relative inline-flex items-center gap-1.5 px-3 py-2 text-sm lg:text-base font-medium transition-all duration-200 rounded-lg ${
activeTab === item?.id
? "text-primary bg-primary/10 dark:bg-primary/20"
: "text-gray-700 dark:text-gray-300 hover:text-primary hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
whileHover={{ y: -2 }}
>
{item?.label || localizedName(item?.name)}
<ChevronDown
className={`w-4 h-4 transition-transform duration-300 ${
openDropdown === item.id ? "rotate-180" : ""
}`}
/>
</motion.button>
<AnimatePresence>
{openDropdown === item.id && (
<motion.div
initial="hidden"
animate="visible"
exit="hidden"
variants={servicesVariants}
className="absolute top-full left-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-800 z-50 overflow-hidden"
>
<div className="py-2">
{item?.children.map((child) => (
<div
key={child.id}
className="group/child relative"
>
<motion.button
variants={serviceItemVariants}
onClick={() => handleNavClick(child)}
className="w-full text-left hover:bg-primary/20 dark:hover:bg-primary/30 hover:text-primary transition-all duration-200"
>
<div className="flex items-start gap-3 px-4 py-3">
<span className="text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5">
{getServiceIcon(
child.id,
isOrgAdmin,
isSuperAdmin,
)}
</span>
<div className="flex-1">
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
{child.label ||
localizedName(child.name)}
</div>
{child.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 max-h-0 overflow-hidden group-hover/child:max-h-20 transition-all duration-300">
{child.description}
</div>
)}
</div>
</div>
</motion.button>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<motion.button
onClick={() => {
setActiveTab(item?.id);
if (item?.path) {
handleNavClick(item);
} else {
const el = document.getElementById(item?.id);
if (el) {
el.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}
}}
className={`relative inline-flex items-center px-3 py-2 text-sm lg:text-base font-medium transition-all duration-200 rounded-lg ${
activeTab === item?.id
? "text-primary bg-primary/10 dark:bg-primary/20"
: "text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
whileHover={{ y: -2 }}
>
{item?.label || localizedName(item?.name)}
{activeTab === item?.id && (
<motion.div
layoutId="activeTabIndicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
transition={{
type: "spring",
bounce: 0.2,
duration: 0.6,
}}
/>
)}
</motion.button>
)}
</div>
))}
</div>
<div className="hidden md:flex items-center gap-3 lg:gap-4">
<motion.button
onClick={toggleDarkMode}
className="p-2 rounded-lg text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label={
isDarkMode ? t("header.lightMode") : t("header.darkMode")
}
>
{isDarkMode ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</motion.button>
<div className="w-32 lg:w-36">
<Select
value={currentLanguage}
onValueChange={(lng) => changeLanguage(lng)}
>
<SelectTrigger className="w-full text-sm rounded-lg border border-gray-200 px-3 py-2 shadow-sm hover:border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<SelectValue>{getCurrentLanguageDisplay()}</SelectValue>
</SelectTrigger>
<SelectContent>
{languageOptions.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex items-center gap-2 h-10 px-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={t("header.userMenu")}
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary-600 text-primary-foreground font-semibold text-sm shadow-md">
{initials.toUpperCase()}
</div>
<div className="hidden sm:flex flex-col items-start">
<span className="text-xs font-semibold leading-none">
{localizedName(userDetails?.name) || ""}
</span>
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none mt-0.5">
{userDetails?.roles && userDetails.roles.length > 0
? "User Role"
: "User"}
</span>
</div>
<ChevronDown className="h-4 w-4 text-gray-500" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56 p-2 bg-white dark:bg-gray-900 shadow-xl rounded-xl border border-gray-100 dark:border-gray-800"
align="end"
>
<DropdownMenuItem
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
onClick={() => navigate("/profile")}
>
<User className="w-4 h-4 text-primary-600 dark:text-primary-400" />
<span className="font-medium">
{t("header.viewProfile")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
onClick={() => navigate("/update-profile")}
>
<UserPen className="w-4 h-4 text-primary-600 dark:text-primary-400" />
<span className="font-medium">
{t("header.editProfile")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
onClick={() => navigate("/change-password")}
>
<Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
<span className="font-medium">
{t("header.changePassword")}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-2 h-px bg-gray-100 dark:bg-gray-800" />
<DropdownMenuItem
className="flex items-center gap-3 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 rounded-lg cursor-pointer transition-colors"
onClick={handleLogout}
>
<LogOut className="w-4 h-4" />
<span className="font-medium">{t("header.signOut")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center md:hidden gap-2">
<motion.button
onClick={toggleDarkMode}
className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
whileTap={{ scale: 0.9 }}
aria-label={
isDarkMode ? t("header.lightMode") : t("header.darkMode")
}
>
{isDarkMode ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</motion.button>
<motion.button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="inline-flex items-center justify-center p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all"
aria-expanded="false"
whileTap={{ scale: 0.9 }}
>
<span className="sr-only">Open main menu</span>
{mobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</motion.button>
</div>
</div>
</div>
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
initial="hidden"
animate="visible"
exit="hidden"
variants={mobileMenuVariants}
className="md:hidden overflow-hidden bg-white dark:bg-gray-900 border-t border-gray-100 dark:border-gray-800 shadow-xl"
>
<motion.div className="pt-2 pb-4 space-y-1 px-4">
{navItems.map((item) => (
<div key={item?.id}>
{item?.children ? (
<div>
<motion.button
onClick={() =>
setOpenDropdown(
openDropdown === item.id ? null : item.id,
)
}
className={`block w-full text-left px-3 py-3 rounded-lg text-base font-medium transition-all duration-200 ${
activeTab === item?.id
? "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400"
}`}
whileHover={{ x: 4 }}
>
<div className="flex items-center justify-between">
{item?.label || localizedName(item?.name)}
<ChevronDown
className={`w-4 h-4 transition-transform ${
openDropdown === item.id ? "rotate-180" : ""
}`}
/>
</div>
</motion.button>
<AnimatePresence>
{openDropdown === item.id && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="pl-4 overflow-hidden"
>
{item.children.map((child) => (
<motion.button
key={child.id}
onClick={() => handleNavClick(child)}
className="block w-full text-left px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg transition-colors"
whileHover={{ x: 4 }}
>
<div className="flex items-center gap-2">
<span className="text-primary-600 dark:text-primary-400">
{getServiceIcon(
child.id,
isOrgAdmin,
isSuperAdmin,
)}
</span>
{child.label || localizedName(child.name)}
</div>
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<motion.button
variants={mobileItemVariants}
onClick={() => {
setActiveTab(item?.id);
setMobileMenuOpen(false);
if (item.path) {
handleNavClick(item);
}
}}
whileHover={{ x: 4 }}
whileTap={{ scale: 0.98 }}
className={`block w-full text-left px-3 py-3 rounded-lg text-base font-medium transition-all duration-200 ${
activeTab === item?.id
? "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400"
}`}
>
{item?.label || localizedName(item?.name)}
</motion.button>
)}
</div>
))}
<motion.div
variants={mobileItemVariants}
className="pt-4 pb-2 border-t border-gray-100 space-y-3"
>
<div className="px-2">
<Select
value={currentLanguage}
onValueChange={(lng) => changeLanguage(lng)}
>
<SelectTrigger className="w-full text-sm bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2.5 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all">
<SelectValue>{getCurrentLanguageDisplay()}</SelectValue>
</SelectTrigger>
<SelectContent>
{languageOptions.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</nav>
</>
);
};
export default Header;