Files
picpeak/frontend/src/components/admin/AdminHeader.tsx
T
Paul Nothaft ea074d3102 Merge pull request #603 from Luca-Timo/feat/crm-improvements
CRM improvements: invoicing & payments, hours, email queue/scheduling, branding (dark mode + favicon), country pickers
2026-06-04 22:09:08 +02:00

445 lines
23 KiB
TypeScript

import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon, Globe, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAdminAuth } from '../../contexts';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector, SUPPORTED_LANGUAGES } from '../common';
import { notificationsService } from '../../services/notifications.service';
import { toast } from 'react-toastify';
import { buildResourceUrl } from '../../utils/url';
interface AdminHeaderProps {
onMenuClick: () => void;
}
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const navigate = useNavigate();
const { user, logout } = useAdminAuth();
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
const { t, i18n } = useTranslation();
const { format, formatDistanceToNow } = useLocalizedDate();
const [showUserMenu, setShowUserMenu] = useState(false);
const [showUserMenuLangSection, setShowUserMenuLangSection] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
const { data: brandingSettings, isLoading: brandingLoading } = usePublicSettings();
const currentLanguage = SUPPORTED_LANGUAGES.find(lang => lang.code === i18n.language) || SUPPORTED_LANGUAGES[0];
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
// Dark-mode logo variant. Symmetric fallback: if only one logo is set,
// use it for both modes (dark → dark||light, light → light||dark).
const lightLogo = brandingSettings?.branding_logo_url?.trim();
const darkLogo = brandingSettings?.branding_logo_url_dark?.trim();
const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
// Logo placement honours the same Branding > Logo Position setting
// the gallery does. 'sidepanel' moves the logo into the AdminSidebar
// brand row — suppress it here so it doesn't double up. left /
// center / right reposition the logo block within this header bar.
const logoPosition = brandingSettings?.branding_logo_position || 'left';
const logoInSidebar = logoPosition === 'sidepanel';
const resolvedLogoUrl = logoUrl
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
: '/picpeak-kamera-transparent.png';
// Renders the logo + wordmark block per the current logo_display_mode.
// Re-used in left / center / right slots below so all three positions
// produce visually identical brand chrome.
const showLogo = !logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text');
const showText = logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text';
// On <sm the wordmark hides when the logo carries the brand identity
// (logo_and_text). Same pattern LanguageSelector uses for its language
// name (#527). Without this, even with truncate, a phone-width admin
// shows things like "Ar..." after the logo image — readable but ugly,
// and on accounts whose company name lets the text reach the right
// cluster it overlaps the LanguageSelector button (#523 follow-up,
// Rekoo-PS's "Arkan Studio" screenshot in v3.59.0-beta.0). text_only
// mode keeps the wordmark on every width — nothing else would render.
const wordmarkVisibilityClass = showLogo ? 'hidden sm:inline' : 'inline';
const renderBrandBlock = () => {
// Skeleton placeholder while `usePublicSettings()` is in flight (#523
// follow-up — Rekoo-PS's "logo took some time to load" screenshot in
// 3.60.1-beta.0). The previous code used the static fallback image
// /picpeak-kamera-transparent.png as the during-loading state, and
// because the wordmark is `hidden sm:inline` whenever a logo is
// *intended* to be shown, a phone-width admin saw an empty header
// for the ~hundreds-of-ms window before the real branding payload
// arrived. The skeleton block holds the same h-8 (so no layout
// shift when the real content lands) and is wider on sm+ to hint
// at the wordmark slot.
if (brandingLoading) {
return (
<div className="flex items-center gap-2 min-w-0">
<div className="h-8 w-8 sm:w-32 bg-neutral-200 dark:bg-neutral-700 rounded animate-pulse" />
</div>
);
}
// min-w-0 + truncate on the name span so long company names shrink
// within the left cluster instead of pushing into the right-side
// action buttons on narrow mobile widths (#523 regression).
return (
<div className="flex items-center gap-2 min-w-0">
{showLogo && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain flex-shrink-0" />
)}
{showText && (
<span className={`${wordmarkVisibilityClass} text-xl sm:text-2xl truncate`} style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
);
};
// #523 follow-up: closes the user-menu lang sub-section whenever the
// outer dropdown closes, so re-opening it doesn't surprise the user
// with the language list already expanded from the previous session.
const closeUserMenu = () => {
setShowUserMenu(false);
setShowUserMenuLangSection(false);
};
const handleUserMenuLangSelect = (languageCode: string) => {
i18n.changeLanguage(languageCode);
closeUserMenu();
};
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
useOnClickOutside(userMenuRef, closeUserMenu);
useOnClickOutside(notificationRef, () => setShowNotifications(false));
const handleLogout = () => {
logout();
navigate('/admin/login');
};
// Fetch notifications
const { data: notificationsData } = useQuery({
queryKey: ['notifications', showNotifications],
queryFn: () => notificationsService.getNotifications(showNotifications, 20),
refetchInterval: 60000, // Refetch every minute
});
// Mark all as read mutation
const markAllAsReadMutation = useMutation({
mutationFn: notificationsService.markAllAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(t('admin.notificationToasts.markedAllRead'));
},
});
// Clear notifications mutation
const clearAllMutation = useMutation({
mutationFn: notificationsService.clearAllNotifications,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(t('admin.notificationToasts.clearedAll', { count: data.deletedCount }));
},
});
const notifications = notificationsData?.notifications || [];
const unreadCount = notificationsData?.unreadCount || 0;
return (
// border-b is on the OUTER <header> so it spans the full header
// width — putting it on the inner row instead left a 32px gap on
// the left (the px-4/sm:px-6/lg:px-8 padding) before the divider
// started, visible as a missing segment between the sidebar's
// brand-row bottom border and the header's bottom border.
//
// The outer header is explicitly h-16 with the default border-box,
// so the 1px border is painted INSIDE the 64px height (y=63..64)
// — same model as the sidebar's brand row (also h-16 border-b
// border-box). Both bottom borders meet at the exact same
// y-coordinate.
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 h-16 border-b border-neutral-200 dark:border-neutral-700">
<div className="px-4 sm:px-6 lg:px-8 h-full">
<div className="relative flex items-center justify-between h-full gap-3">
{/* Left side - Menu button, optional left-positioned logo, Date.
Logo block appears here when logo_position = 'left' (the
default). For 'center' it's absolutely positioned across
the whole header; for 'right' it sits in the right-side
cluster just before the action widgets. */}
<div className="flex items-center gap-3 min-w-0">
<button
onClick={onMenuClick}
className="lg:hidden text-neutral-500 hover:text-neutral-700"
>
<Menu className="w-6 h-6" />
</button>
{!logoInSidebar && logoPosition === 'left' && renderBrandBlock()}
{/* Narrow-viewport fallback for center / right positions.
On screens where the centered (lg+) or right-anchored
(md+) brand block is hidden, render it on the left so
the admin chrome doesn't go logo-less on phones. */}
{!logoInSidebar && logoPosition === 'center' && (
<div className="flex lg:hidden">{renderBrandBlock()}</div>
)}
{!logoInSidebar && logoPosition === 'right' && (
<div className="flex md:hidden">{renderBrandBlock()}</div>
)}
{/* Date display - hidden on smaller screens.
The vertical divider + left padding only render when
the logo sits on the left of the date (logo_position
= 'left'). For 'center' / 'right' / 'sidepanel' the
left cluster has only the mobile menu (which is
hidden on xl+), so a divider would be floating on
its own with nothing to separate. */}
{/* Explicit `flex items-center` (not just block) + the
self-stretch on the border-l variant so the divider
covers the full header row, AND the text baseline sits
exactly on the same y-axis as the brand-block / sidebar
logo to its left. The previous `hidden xl:block` left
the <p> inheriting its block-level vertical position,
which read as slightly off-centre next to the larger
logo image. */}
<div className={`hidden xl:flex items-center self-stretch ml-1 ${
logoPosition === 'left' && !logoInSidebar
? 'pl-3 border-l border-neutral-200 dark:border-neutral-700'
: ''
}`}>
<p className="text-base leading-none text-neutral-700 dark:text-neutral-300 m-0">
{format(new Date(), 'PPPP')}
</p>
</div>
</div>
{/* Centered logo. Absolutely positioned so the existing
left/right clusters keep their natural sizing; hidden on
sub-lg widths to avoid colliding with the right-side
action cluster on narrow screens. pointer-events-none on
the wrapper passes hover/click through (the logo itself
has no interactive children today). */}
{!logoInSidebar && logoPosition === 'center' && (
<div className="hidden lg:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
{renderBrandBlock()}
</div>
)}
{/* Right side actions (preceded by the brand block when
logo_position = 'right'). The right-anchored logo sits
before the language / dark-mode / notifications / user
cluster so the widgets stay where admins expect them. */}
<div className="flex items-center gap-3">
{!logoInSidebar && logoPosition === 'right' && (
<div className="hidden md:flex mr-1 pr-2 border-r border-neutral-200 dark:border-neutral-700">
{renderBrandBlock()}
</div>
)}
{/* Language Selector — hidden on <sm where it's surfaced
via the user dropdown instead (#523 follow-up: phone
view header was too crowded with 4 widgets; language
is a set-once preference so it doesn't deserve permanent
header real estate on mobile per Rekoo-PS's feedback). */}
<div className="hidden sm:block">
<LanguageSelector />
</div>
{/* Dark Mode Toggle — hidden entirely when an admin has locked
the instance to a specific mode via Branding > Force color mode. */}
{!forcedMode && (
<button
onClick={toggleDarkMode}
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
)}
{/* Notifications */}
<div className="relative" ref={notificationRef}>
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
{/* Notifications dropdown */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700">
<div className="px-4 py-3 border-b border-neutral-100 dark:border-neutral-700 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{t('admin.notifications')}</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
className="text-xs text-accent hover:opacity-80 flex items-center gap-1"
title={t('admin.markAllRead')}
>
<CheckCircle className="w-3 h-3" />
{t('admin.markAllRead')}
</button>
)}
<button
onClick={() => clearAllMutation.mutate()}
className="text-xs text-neutral-600 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 flex items-center gap-1"
title={t('admin.clearAll')}
>
<Trash2 className="w-3 h-3" />
{t('admin.clearAll')}
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
{t('admin.noNotificationsMessage')}
</div>
) : (
notifications.map((notification) => {
const style = notificationsService.getNotificationStyle(notification.type);
return (
<div
key={notification.id}
className={`px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-700 cursor-pointer border-l-4 ${
notification.isRead ? 'border-transparent opacity-75' : 'border-accent-dark'
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${style.color}`}>
<Bell className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900 dark:text-neutral-100">
{notificationsService.formatNotificationMessage(notification)}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{formatDistanceToNow(notification.createdAt, { addSuffix: true })}
</p>
</div>
</div>
</div>
);
})
)}
</div>
{notifications.length > 0 && (
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
<button
onClick={() => setShowNotifications(false)}
className="text-sm text-accent hover:opacity-80"
>
{t('admin.close')}
</button>
</div>
)}
</div>
)}
</div>
{/* User menu */}
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-3 p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<div className="text-right hidden sm:block">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
</div>
<div className="w-8 h-8 bg-accent-dark rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
</button>
{/* User dropdown */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1">
<div className="px-4 py-2 border-b border-neutral-100 dark:border-neutral-700 sm:hidden">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
</div>
{/* Language sub-section — phone-only (#523 follow-up).
Rekoo-PS asked for language to live inside the profile
menu since it's a set-once preference; on sm+ it
stays in the header cluster where it's been. Collapsible
so the menu isn't 8 rows taller by default. */}
<div className="sm:hidden border-b border-neutral-100 dark:border-neutral-700">
<button
onClick={() => setShowUserMenuLangSection(!showUserMenuLangSection)}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
aria-expanded={showUserMenuLangSection}
>
<Globe className="w-4 h-4" />
<span className="flex-1">{t('common.language', 'Language')}</span>
<currentLanguage.Flag className="w-4 h-4" />
<ChevronDown className={`w-4 h-4 transition-transform ${showUserMenuLangSection ? 'rotate-180' : ''}`} />
</button>
{showUserMenuLangSection && (
<div className="bg-neutral-50 dark:bg-neutral-900 py-1">
{SUPPORTED_LANGUAGES.map((language) => (
<button
key={language.code}
onClick={() => handleUserMenuLangSelect(language.code)}
className={`w-full pl-11 pr-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
language.code === i18n.language
? 'text-accent bg-accent-dark/15'
: 'text-neutral-700 dark:text-neutral-300'
}`}
>
<language.Flag className="w-4 h-4" />
<span>{language.name}</span>
</button>
))}
</div>
)}
</div>
<button
onClick={() => {
closeUserMenu();
navigate('/admin/settings');
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
>
<Settings className="w-4 h-4" />
{t('navigation.settings')}
</button>
<button
onClick={() => {
closeUserMenu();
setShowPasswordModal(true);
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
>
<Lock className="w-4 h-4" />
{t('admin.changePassword')}
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
>
<LogOut className="w-4 h-4" />
{t('common.logout')}
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Password Change Modal */}
<PasswordChangeModal
isOpen={showPasswordModal}
onClose={() => setShowPasswordModal(false)}
/>
</header>
);
};