Compare commits

...

1 Commits

Author SHA1 Message Date
chamikaJ
eeec5b2b84 feat(navbar): implement new notification and invitation components
- Added NotificationDrawer and InvitationItem components to enhance the notification system.
- Refactored existing notification handling to improve user experience and maintainability.
- Introduced new styles and structure for notifications using Tailwind CSS for better visual consistency.
- Updated Navbar to include new components and improve overall layout.
- Created a centralized navRoutes file for better route management within the navbar.
2025-07-25 17:01:15 +05:30
34 changed files with 1074 additions and 634 deletions

View File

@@ -0,0 +1,55 @@
# SonarQube Configuration for Worklenz Frontend
sonar.projectKey=worklenz-frontend
sonar.projectName=Worklenz Frontend
sonar.projectVersion=1.0.0
# Source code configuration
sonar.sources=src
sonar.tests=src
sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx
# Language-specific configurations
sonar.typescript.node=node
sonar.typescript.lcov.reportPaths=coverage/lcov.info
sonar.javascript.lcov.reportPaths=coverage/lcov.info
# Exclusions
sonar.exclusions=**/node_modules/**,\
**/build/**,\
**/dist/**,\
**/public/**,\
**/*.d.ts,\
src/react-app-env.d.ts,\
src/vite-env.d.ts,\
**/*.config.js,\
**/*.config.ts,\
**/*.config.mts,\
scripts/**
# Test exclusions from coverage
sonar.coverage.exclusions=**/*.test.ts,\
**/*.test.tsx,\
**/*.spec.ts,\
**/*.spec.tsx,\
**/*.config.*,\
src/index.tsx,\
src/reportWebVitals.ts,\
src/serviceWorkerRegistration.ts,\
src/setupTests.ts
# Code quality rules
sonar.qualitygate.wait=true
# File encoding
sonar.sourceEncoding=UTF-8
# JavaScript/TypeScript specific settings
sonar.javascript.environments=browser,node,jest
sonar.typescript.tsconfigPath=tsconfig.json
# ESLint configuration (if available)
# sonar.eslint.reportPaths=eslint-report.json
# Additional settings for React projects
sonar.javascript.file.suffixes=.js,.jsx
sonar.typescript.file.suffixes=.ts,.tsx

View File

@@ -3,7 +3,7 @@ import { getJSONFromLocalStorage, saveJSONToLocalStorage } from '../utils/localS
import { Button, ConfigProvider, Tooltip } from '@/shared/antd-imports'; import { Button, ConfigProvider, Tooltip } from '@/shared/antd-imports';
import { PushpinFilled, PushpinOutlined } from '@/shared/antd-imports'; import { PushpinFilled, PushpinOutlined } from '@/shared/antd-imports';
import { colors } from '../styles/colors'; import { colors } from '../styles/colors';
import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes'; import { navRoutes, NavRoutesType } from '../lib/navbar/navRoutes';
// Props type for the component // Props type for the component
type PinRouteToNavbarButtonProps = { type PinRouteToNavbarButtonProps = {

View File

@@ -1,4 +1,14 @@
import { AutoComplete, Button, Drawer, Flex, Form, message, Modal, Select, Spin, Typography } from '@/shared/antd-imports'; import {
AutoComplete,
Button,
Flex,
Form,
message,
Modal,
Select,
Spin,
Typography,
} from '@/shared/antd-imports';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { import {
@@ -11,7 +21,6 @@ import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.se
import { IJobTitle } from '@/types/job.types'; import { IJobTitle } from '@/types/job.types';
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service'; import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
import { ITeamMemberCreateRequest } from '@/types/teamMembers/team-member-create-request'; import { ITeamMemberCreateRequest } from '@/types/teamMembers/team-member-create-request';
import { LinkOutlined } from '@ant-design/icons';
interface FormValues { interface FormValues {
email: string[]; email: string[];

View File

@@ -17,7 +17,7 @@ interface InvitationItemProps {
t: TFunction; t: TFunction;
} }
const InvitationItem: React.FC<InvitationItemProps> = ({ item, isUnreadNotifications, t }) => { const InvitationItem = ({ item, isUnreadNotifications, t }: InvitationItemProps) => {
const [accepting, setAccepting] = useState(false); const [accepting, setAccepting] = useState(false);
const [joining, setJoining] = useState(false); const [joining, setJoining] = useState(false);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();

View File

@@ -1,55 +1,60 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo, memo } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from '@/shared/antd-imports'; import { Col, ConfigProvider, Flex, Menu } from '@/shared/antd-imports';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members'; import InviteTeamMembers from '../common/invite-team-members/invite-team-members';
import InviteButton from './invite/InviteButton'; import InviteButton from './invite/InviteButton';
import MobileMenuButton from './mobileMenu/MobileMenuButton'; import MobileMenuButton from './mobileMenu/MobileMenuButton';
import NavbarLogo from './navbar-logo'; import NavbarLogo from './NavbarLogo';
import NotificationButton from '../../components/navbar/notifications/notifications-drawer/notification/notification-button'; import NotificationButton from './NotificationButton';
import ProfileButton from './user-profile/profile-button'; import ProfileButton from './user-profile/ProfileButton';
import SwitchTeamButton from './switchTeam/SwitchTeamButton'; import SwitchTeamButton from './switchTeam/SwitchTeamButton';
import UpgradePlanButton from './upgradePlan/UpgradePlanButton'; import UpgradePlanButton from './upgradePlan/UpgradePlanButton';
import NotificationDrawer from '../../components/navbar/notifications/notifications-drawer/notification/notfication-drawer'; import NotificationDrawer from './notifications/notifications-drawer/notfication-drawer';
import { useResponsive } from '@/hooks/useResponsive'; import { useResponsive } from '@/hooks/useResponsive';
import { getJSONFromLocalStorage } from '@/utils/localStorageFunctions'; import { getJSONFromLocalStorage } from '@/utils/localStorageFunctions';
import { navRoutes, NavRoutesType } from './navRoutes'; import { navRoutes, NavRoutesType } from '@/lib/navbar/navRoutes';
import { useAuthService } from '@/hooks/useAuth'; import { useAuthService } from '@/hooks/useAuth';
import { authApiService } from '@/api/auth/auth.api.service'; import { authApiService } from '@/api/auth/auth.api.service';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
import logger from '@/utils/errorLogger'; import logger from '@/utils/errorLogger';
import TimerButton from './timers/timer-button';
import HelpButton from './help/HelpButton'; import HelpButton from './help/HelpButton';
const Navbar = () => { const Navbar = memo(() => {
const [current, setCurrent] = useState<string>('home'); const [current, setCurrent] = useState<string>('home');
const currentSession = useAuthService().getCurrentSession(); const authService = useAuthService();
const currentSession = authService.getCurrentSession();
const [daysUntilExpiry, setDaysUntilExpiry] = useState<number | null>(null); const [daysUntilExpiry, setDaysUntilExpiry] = useState<number | null>(null);
const location = useLocation(); const location = useLocation();
const { isDesktop, isMobile, isTablet } = useResponsive(); const { isDesktop, isMobile, isTablet } = useResponsive();
const { t } = useTranslation('navbar'); const { t } = useTranslation('navbar');
const authService = useAuthService();
const [navRoutesList, setNavRoutesList] = useState<NavRoutesType[]>(navRoutes); const [navRoutesList, setNavRoutesList] = useState<NavRoutesType[]>(navRoutes);
const [isOwnerOrAdmin, setIsOwnerOrAdmin] = useState<boolean>(authService.isOwnerOrAdmin()); const [isOwnerOrAdmin, setIsOwnerOrAdmin] = useState<boolean>(authService.isOwnerOrAdmin());
const showUpgradeTypes = [ISUBSCRIPTION_TYPE.TRIAL]; const showUpgradeTypes = useMemo(() => [ISUBSCRIPTION_TYPE.TRIAL], []);
useEffect(() => { useEffect(() => {
let mounted = true;
authApiService authApiService
.verify() .verify()
.then(authorizeResponse => { .then(authorizeResponse => {
if (authorizeResponse.authenticated) { if (mounted && authorizeResponse.authenticated) {
authService.setCurrentSession(authorizeResponse.user); authService.setCurrentSession(authorizeResponse.user);
setIsOwnerOrAdmin(!!(authorizeResponse.user.is_admin || authorizeResponse.user.owner)); setIsOwnerOrAdmin(!!(authorizeResponse.user.is_admin || authorizeResponse.user.owner));
} }
}) })
.catch(error => { .catch(error => {
if (mounted) {
logger.error('Error during authorization', error); logger.error('Error during authorization', error);
}
}); });
}, []); return () => {
mounted = false;
};
}, [authService]);
useEffect(() => { useEffect(() => {
const storedNavRoutesList: NavRoutesType[] = getJSONFromLocalStorage('navRoutes') || navRoutes; const storedNavRoutesList: NavRoutesType[] = getJSONFromLocalStorage('navRoutes') || navRoutes;
@@ -183,6 +188,8 @@ const Navbar = () => {
{createPortal(<NotificationDrawer />, document.body, 'notification-drawer')} {createPortal(<NotificationDrawer />, document.body, 'notification-drawer')}
</Col> </Col>
); );
}; });
Navbar.displayName = 'Navbar';
export default Navbar; export default Navbar;

View File

@@ -1,14 +1,14 @@
import { memo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import logo from '@/assets/images/worklenz-light-mode.png'; import logo from '@/assets/images/worklenz-light-mode.png';
import logoDark from '@/assets/images/worklenz-dark-mode.png'; import logoDark from '@/assets/images/worklenz-dark-mode.png';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { RootState } from '@/app/store'; import { RootState } from '@/app/store';
const NavbarLogo = () => { const NavbarLogo = memo(() => {
const { t } = useTranslation('navbar'); const { t } = useTranslation('navbar');
const themeMode = useSelector((state: RootState) => state.themeReducer.mode); const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
@@ -23,6 +23,8 @@ const NavbarLogo = () => {
</div> </div>
</Link> </Link>
); );
}; });
NavbarLogo.displayName = 'NavbarLogo';
export default NavbarLogo; export default NavbarLogo;

View File

@@ -1,10 +1,10 @@
import { QuestionCircleOutlined } from '@/shared/antd-imports'; import { QuestionCircleOutlined } from '@/shared/antd-imports';
import { Button, Tooltip } from '@/shared/antd-imports'; import { Button, Tooltip } from '@/shared/antd-imports';
import React from 'react'; import React, { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import './HelpButton.css'; import './HelpButton.css';
const HelpButton = () => { const HelpButton = memo(() => {
// localization // localization
const { t } = useTranslation('navbar'); const { t } = useTranslation('navbar');
@@ -18,6 +18,8 @@ const HelpButton = () => {
/> />
</Tooltip> </Tooltip>
); );
}; });
HelpButton.displayName = 'HelpButton';
export default HelpButton; export default HelpButton;

View File

@@ -1,12 +1,12 @@
import { UsergroupAddOutlined } from '@/shared/antd-imports'; import { UsergroupAddOutlined } from '@/shared/antd-imports';
import { Button, Tooltip } from '@/shared/antd-imports'; import { Button, Tooltip } from '@/shared/antd-imports';
import React from 'react'; import React, { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { colors } from '../../../styles/colors'; import { colors } from '../../../styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleInviteMemberDrawer } from '../../settings/member/memberSlice'; import { toggleInviteMemberDrawer } from '../../../features/settings/member/memberSlice';
const InviteButton = () => { const InviteButton = memo(() => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
// localization // localization
@@ -21,12 +21,14 @@ const InviteButton = () => {
color: colors.skyBlue, color: colors.skyBlue,
borderColor: colors.skyBlue, borderColor: colors.skyBlue,
}} }}
onClick={() => dispatch(toggleInviteMemberDrawer())} onClick={useCallback(() => dispatch(toggleInviteMemberDrawer()), [dispatch])}
> >
{t('invite')} {t('invite')}
</Button> </Button>
</Tooltip> </Tooltip>
); );
}; });
InviteButton.displayName = 'InviteButton';
export default InviteButton; export default InviteButton;

View File

@@ -0,0 +1,112 @@
import {
Button,
Card,
Dropdown,
Flex,
MenuProps,
Space,
Typography,
HomeOutlined,
MenuOutlined,
ProjectOutlined,
QuestionCircleOutlined,
ReadOutlined,
} from '@/shared/antd-imports';
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { colors } from '@/styles/colors';
import { NavLink } from 'react-router-dom';
import InviteButton from '@/components/navbar/invite/InviteButton';
import SwitchTeamButton from '@/components/navbar/switchTeam/SwitchTeamButton';
// custom css
import './MobileMenuButton.css';
const MobileMenuButton = memo(() => {
// localization
const { t } = useTranslation('navbar');
const navLinks = useMemo(
() => [
{
name: 'home',
icon: React.createElement(HomeOutlined),
},
{
name: 'projects',
icon: React.createElement(ProjectOutlined),
},
// {
// name: 'schedule',
// icon: React.createElement(ClockCircleOutlined),
// },
{
name: 'reporting',
icon: React.createElement(ReadOutlined),
},
{
name: 'help',
icon: React.createElement(QuestionCircleOutlined),
},
],
[]
);
const mobileMenu: MenuProps['items'] = useMemo(
() => [
{
key: '1',
label: (
<Card className="mobile-menu-card" bordered={false} style={{ width: 230 }}>
{navLinks.map((navEl, index) => (
<NavLink key={index} to={`/worklenz/${navEl.name}`}>
<Typography.Text strong>
<Space>
{navEl.icon}
{t(navEl.name)}
</Space>
</Typography.Text>
</NavLink>
))}
<Flex
vertical
gap={12}
style={{
width: '90%',
marginInlineStart: 12,
marginBlock: 6,
}}
>
<Button
style={{
backgroundColor: colors.lightBeige,
color: 'black',
}}
>
{t('upgradePlan')}
</Button>
<InviteButton />
<SwitchTeamButton />
</Flex>
</Card>
),
},
],
[navLinks, t]
);
return (
<Dropdown
overlayClassName="mobile-menu-dropdown"
menu={{ items: mobileMenu }}
placement="bottomRight"
trigger={['click']}
>
<Button className="borderless-icon-btn" icon={<MenuOutlined style={{ fontSize: 20 }} />} />
</Dropdown>
);
});
MobileMenuButton.displayName = 'MobileMenuButton';
export default MobileMenuButton;

View File

@@ -0,0 +1,128 @@
# Notification Components Styling Fixes
## Issue Resolved
Fixed missing spacing and borders in notification templates that occurred during performance optimization.
## Root Cause
During the performance optimization, the CSS class references and styling approach were changed, which resulted in:
- Missing borders around notification items
- No spacing between notifications
- Improper padding and margins
## Solutions Applied
### 1. Updated CSS Class Usage
- **Before**: Used generic `ant-notification-notice` classes
- **After**: Implemented proper Tailwind CSS classes with fallback styling
### 2. Tailwind CSS Classes Implementation
#### NotificationItem.tsx
```jsx
// Container classes with proper spacing and borders
const containerClasses = [
'w-auto p-3 mb-3 rounded border border-gray-200 bg-white shadow-sm transition-all duration-300',
'hover:shadow-md hover:bg-gray-50',
notification.url ? 'cursor-pointer' : 'cursor-default',
'dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700'
].join(' ');
// Updated content structure
<div className="notification-content">
<div className="notification-description">
<Text type="secondary" className="mb-2 flex items-center gap-2">
<BankOutlined /> {notification.team}
</Text>
<div className="mb-2" dangerouslySetInnerHTML={safeMessageHtml} />
{shouldShowProject && (
<div className="mb-2">
<Tag style={tagStyle}>{notification.project}</Tag>
</div>
)}
</div>
<div className="flex items-baseline justify-between mt-2">
{/* Footer content */}
</div>
</div>
```
#### NotificationTemplate.tsx
Applied similar Tailwind classes for consistency:
- `p-3` for padding
- `mb-3` for bottom margin
- `rounded` for border radius
- `border border-gray-200` for borders
- `shadow-sm` for subtle shadows
- `transition-all duration-300` for smooth animations
#### NotificationDrawer.tsx
Updated container classes:
```jsx
<div className="notification-list mt-4 px-2">
{/* Notification items */}
</div>
```
### 3. Responsive Design Support
#### Light Mode
- Background: `bg-white`
- Border: `border-gray-200`
- Hover: `hover:bg-gray-50`
- Shadow: `shadow-sm``hover:shadow-md`
#### Dark Mode
- Background: `dark:bg-gray-800`
- Border: `dark:border-gray-600`
- Hover: `dark:hover:bg-gray-700`
- Maintains proper contrast
### 4. CSS Imports Fixed
- **NotificationItem.tsx**: Updated import from `PushNotificationTemplate.css` to `NotificationItem.css`
- **NotificationTemplate.tsx**: Added proper CSS import for styling
### 5. Spacing Improvements
#### Margins and Padding
- **Container**: `p-3` (12px padding)
- **Bottom margin**: `mb-3` (12px between items)
- **Internal spacing**: `mb-2` (8px between content sections)
- **Text**: `text-xs` for timestamp
#### Layout Classes
- **Flexbox**: `flex items-center gap-2` for inline elements
- **Alignment**: `flex items-baseline justify-between` for footer
- **Cursor**: `cursor-pointer` or `cursor-default` based on interactivity
## Visual Improvements
### Before Fix
- No visible borders
- Items touching each other
- Poor visual hierarchy
- Inconsistent spacing
### After Fix
- ✅ Clear borders around each notification
- ✅ Proper spacing between items
- ✅ Good visual hierarchy
- ✅ Consistent padding and margins
- ✅ Smooth hover effects
- ✅ Dark mode support
- ✅ Responsive design
## Performance Maintained
All performance optimizations (React.memo, useCallback, useMemo) remain intact while fixing the visual issues.
## Build Verification
✅ Production build successful
✅ No styling conflicts
✅ Proper Tailwind CSS compilation
✅ Cross-browser compatibility maintained
## Key Benefits
1. **Consistent Design**: Unified styling across all notification components
2. **Better UX**: Clear visual separation and proper interactive states
3. **Maintainable**: Using Tailwind CSS classes reduces custom CSS
4. **Accessible**: Proper contrast ratios and hover states
5. **Performance**: No impact on optimized component performance

View File

@@ -1,3 +1,4 @@
import React, { memo, useCallback, useMemo } from 'react';
import { Drawer, Empty, Segmented, Typography, Spin, Button, Flex } from '@/shared/antd-imports'; import { Drawer, Empty, Segmented, Typography, Spin, Button, Flex } from '@/shared/antd-imports';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
@@ -7,7 +8,7 @@ import {
fetchNotifications, fetchNotifications,
setNotificationType, setNotificationType,
toggleDrawer, toggleDrawer,
} from '../../../../../features/navbar/notificationSlice'; } from '../../../../features/navbar/notificationSlice';
import { NOTIFICATION_OPTION_READ, NOTIFICATION_OPTION_UNREAD } from '@/shared/constants'; import { NOTIFICATION_OPTION_READ, NOTIFICATION_OPTION_UNREAD } from '@/shared/constants';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SocketEvents } from '@/shared/socket-events'; import { SocketEvents } from '@/shared/socket-events';
@@ -15,13 +16,13 @@ import { IWorklenzNotification } from '@/types/notifications/notifications.types
import { useSocket } from '@/socket/socketContext'; import { useSocket } from '@/socket/socketContext';
import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types'; import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types';
import logger from '@/utils/errorLogger'; import logger from '@/utils/errorLogger';
import NotificationItem from './notification-item'; import NotificationItem from './notification/NotificationItem';
import InvitationItem from './invitation-item'; import InvitationItem from '../../InvitationItem';
import { notificationsApiService } from '@/api/notifications/notifications.api.service'; import { notificationsApiService } from '@/api/notifications/notifications.api.service';
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service'; import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
import { INotificationSettings } from '@/types/settings/notifications.types'; import { INotificationSettings } from '@/types/settings/notifications.types';
import { toQueryString } from '@/utils/toQueryString'; import { toQueryString } from '@/utils/toQueryString';
import { showNotification } from './push-notification-template'; import { showNotification } from './notification/PushNotificationTemplate';
import { teamsApiService } from '@/api/teams/teams.api.service'; import { teamsApiService } from '@/api/teams/teams.api.service';
import { verifyAuthentication } from '@/features/auth/authSlice'; import { verifyAuthentication } from '@/features/auth/authSlice';
import { getUserSession } from '@/utils/session-helper'; import { getUserSession } from '@/utils/session-helper';
@@ -30,7 +31,7 @@ import { useNavigate } from 'react-router-dom';
import { createAuthService } from '@/services/auth/auth.service'; import { createAuthService } from '@/services/auth/auth.service';
const HTML_TAG_REGEXP = /<[^>]*>/g; const HTML_TAG_REGEXP = /<[^>]*>/g;
const NotificationDrawer = () => { const NotificationDrawer = memo(() => {
const { isDrawerOpen, notificationType, notifications, invitations } = useAppSelector( const { isDrawerOpen, notificationType, notifications, invitations } = useAppSelector(
state => state.notificationReducer state => state.notificationReducer
); );
@@ -50,7 +51,8 @@ const NotificationDrawer = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const authService = createAuthService(navigate); const authService = createAuthService(navigate);
const createPush = (message: string, title: string, teamId: string | null, url?: string) => { const createPush = useCallback(
(message: string, title: string, teamId: string | null, url?: string) => {
if (Notification.permission === 'granted' && showBrowserPush) { if (Notification.permission === 'granted' && showBrowserPush) {
const img = 'https://worklenz.com/assets/icons/icon-128x128.png'; const img = 'https://worklenz.com/assets/icons/icon-128x128.png';
const notification = new Notification(title, { const notification = new Notification(title, {
@@ -64,20 +66,30 @@ const NotificationDrawer = () => {
window.focus(); window.focus();
if (teamId) { if (teamId) {
try {
await teamsApiService.setActiveTeam(teamId); await teamsApiService.setActiveTeam(teamId);
} catch (error) {
logger.error('Error setting active team from notification', error);
}
} }
window.location.href = url; window.location.href = url;
} }
}; };
} }
}; },
[showBrowserPush]
);
const handleInvitationsUpdate = (data: ITeamInvitationViewModel[]) => { const handleInvitationsUpdate = useCallback(
(data: ITeamInvitationViewModel[]) => {
dispatch(fetchInvitations()); dispatch(fetchInvitations());
}; },
[dispatch]
);
const handleNotificationsUpdate = async (notification: IWorklenzNotification) => { const handleNotificationsUpdate = useCallback(
async (notification: IWorklenzNotification) => {
dispatch(fetchNotifications(notificationType)); dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations()); dispatch(fetchInvitations());
@@ -94,9 +106,12 @@ const NotificationDrawer = () => {
// Show notification using the template // Show notification using the template
showNotification(notification); showNotification(notification);
}; },
[dispatch, notificationType, isPushEnabled, createPush]
);
const handleTeamInvitationsUpdate = async (data: ITeamInvitationViewModel) => { const handleTeamInvitationsUpdate = useCallback(
async (data: ITeamInvitationViewModel) => {
const notification: IWorklenzNotification = { const notification: IWorklenzNotification = {
id: data.id || '', id: data.id || '',
team: data.team_name || '', team: data.team_name || '',
@@ -115,7 +130,9 @@ const NotificationDrawer = () => {
// Show notification using the template // Show notification using the template
showNotification(notification); showNotification(notification);
dispatch(fetchInvitations()); dispatch(fetchInvitations());
}; },
[isPushEnabled, createPush, dispatch]
);
const askPushPermission = () => { const askPushPermission = () => {
if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) { if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) {
@@ -135,27 +152,40 @@ const NotificationDrawer = () => {
} }
}; };
const markNotificationAsRead = async (id: string) => { const markNotificationAsRead = useCallback(
async (id: string) => {
if (!id) return; if (!id) return;
try {
const res = await notificationsApiService.updateNotification(id); const res = await notificationsApiService.updateNotification(id);
if (res.done) { if (res.done) {
dispatch(fetchNotifications(notificationType)); dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations()); dispatch(fetchInvitations());
} }
}; } catch (error) {
const handleVerifyAuth = async () => { logger.error('Error marking notification as read', error);
}
},
[dispatch, notificationType]
);
const handleVerifyAuth = useCallback(async () => {
try {
const result = await dispatch(verifyAuthentication()).unwrap(); const result = await dispatch(verifyAuthentication()).unwrap();
if (result.authenticated) { if (result.authenticated) {
dispatch(setUser(result.user)); dispatch(setUser(result.user));
authService.setCurrentSession(result.user); authService.setCurrentSession(result.user);
} }
}; } catch (error) {
logger.error('Error verifying authentication', error);
}
}, [dispatch, authService]);
const goToUrl = async (event: React.MouseEvent, notification: IWorklenzNotification) => { const goToUrl = useCallback(
async (event: React.MouseEvent, notification: IWorklenzNotification) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (notification.url) { if (!notification.url) return;
dispatch(toggleDrawer()); dispatch(toggleDrawer());
setIsLoading(true); setIsLoading(true);
try { try {
@@ -169,12 +199,13 @@ const NotificationDrawer = () => {
); );
} }
} catch (error) { } catch (error) {
console.error('Error navigating to URL:', error); logger.error('Error navigating to URL:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
} },
}; [dispatch, navigate, handleVerifyAuth]
);
const fetchNotificationsSettings = async () => { const fetchNotificationsSettings = async () => {
try { try {
@@ -190,11 +221,15 @@ const NotificationDrawer = () => {
} }
}; };
const handleMarkAllAsRead = async () => { const handleMarkAllAsRead = useCallback(async () => {
try {
await notificationsApiService.readAllNotifications(); await notificationsApiService.readAllNotifications();
dispatch(fetchNotifications(notificationType)); dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations()); dispatch(fetchInvitations());
}; } catch (error) {
logger.error('Error marking all notifications as read', error);
}
}, [dispatch, notificationType]);
useEffect(() => { useEffect(() => {
socket?.on(SocketEvents.INVITATIONS_UPDATE.toString(), handleInvitationsUpdate); socket?.on(SocketEvents.INVITATIONS_UPDATE.toString(), handleInvitationsUpdate);
@@ -242,12 +277,15 @@ const NotificationDrawer = () => {
<Segmented<string> <Segmented<string>
options={['Unread', 'Read']} options={['Unread', 'Read']}
defaultValue={NOTIFICATION_OPTION_UNREAD} defaultValue={NOTIFICATION_OPTION_UNREAD}
onChange={(value: string) => { onChange={useCallback(
(value: string) => {
if (value === NOTIFICATION_OPTION_UNREAD) if (value === NOTIFICATION_OPTION_UNREAD)
dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD)); dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD));
if (value === NOTIFICATION_OPTION_READ) if (value === NOTIFICATION_OPTION_READ)
dispatch(setNotificationType(NOTIFICATION_OPTION_READ)); dispatch(setNotificationType(NOTIFICATION_OPTION_READ));
}} },
[dispatch]
)}
/> />
<Button type="link" onClick={handleMarkAllAsRead}> <Button type="link" onClick={handleMarkAllAsRead}>
@@ -261,7 +299,7 @@ const NotificationDrawer = () => {
</div> </div>
)} )}
{invitations && invitations.length > 0 && notificationType === NOTIFICATION_OPTION_UNREAD ? ( {invitations && invitations.length > 0 && notificationType === NOTIFICATION_OPTION_UNREAD ? (
<div className="notification-list mt-3"> <div className="notification-list mt-4 px-2">
{invitations.map(invitation => ( {invitations.map(invitation => (
<InvitationItem <InvitationItem
key={invitation.id} key={invitation.id}
@@ -273,13 +311,13 @@ const NotificationDrawer = () => {
</div> </div>
) : null} ) : null}
{notifications && notifications.length > 0 ? ( {notifications && notifications.length > 0 ? (
<div className="notification-list mt-3"> <div className="notification-list mt-4 px-2">
{notifications.map(notification => ( {notifications.map(notification => (
<NotificationItem <NotificationItem
key={notification.id} key={notification.id}
notification={notification} notification={notification}
isUnreadNotifications={notificationType === NOTIFICATION_OPTION_UNREAD} isUnreadNotifications={notificationType === NOTIFICATION_OPTION_UNREAD}
markNotificationAsRead={id => Promise.resolve(markNotificationAsRead(id))} markNotificationAsRead={markNotificationAsRead}
goToUrl={goToUrl} goToUrl={goToUrl}
/> />
))} ))}
@@ -288,16 +326,13 @@ const NotificationDrawer = () => {
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('notificationsDrawer.noNotifications')} description={t('notificationsDrawer.noNotifications')}
style={{ className="flex flex-col items-center mt-8"
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBlockStart: 32,
}}
/> />
)} )}
</Drawer> </Drawer>
); );
}; });
NotificationDrawer.displayName = 'NotificationDrawer';
export default NotificationDrawer; export default NotificationDrawer;

View File

@@ -0,0 +1,165 @@
import React, { memo, useState, useCallback, useMemo } from 'react';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { BankOutlined } from '@/shared/antd-imports';
import { Button, Tag, Typography, theme } from '@/shared/antd-imports';
import DOMPurify from 'dompurify';
import { fromNow } from '@/utils/dateUtils';
import './NotificationItem.css';
const { Text } = Typography;
interface NotificationItemProps {
notification: IWorklenzNotification;
isUnreadNotifications?: boolean;
markNotificationAsRead?: (id: string) => Promise<void>;
goToUrl?: (e: React.MouseEvent, notification: IWorklenzNotification) => Promise<void>;
}
const NotificationItem = memo<NotificationItemProps>(({
notification,
isUnreadNotifications = true,
markNotificationAsRead,
goToUrl,
}) => {
const { token } = theme.useToken();
const [loading, setLoading] = useState(false);
const isDarkMode = useMemo(
() =>
token.colorBgContainer === '#141414' ||
token.colorBgContainer.includes('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark',
[token.colorBgContainer]
);
const handleNotificationClick = useCallback(
async (e: React.MouseEvent) => {
await goToUrl?.(e, notification);
await markNotificationAsRead?.(notification.id);
},
[goToUrl, markNotificationAsRead, notification]
);
const handleMarkAsRead = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!notification.id) return;
setLoading(true);
try {
await markNotificationAsRead?.(notification.id);
} finally {
setLoading(false);
}
},
[markNotificationAsRead, notification.id]
);
const safeMessageHtml = useMemo(
() => ({ __html: DOMPurify.sanitize(notification.message) }),
[notification.message]
);
const tagStyle = useMemo(() => {
if (!notification.color) return {};
const bgColor = `${notification.color}4d`;
if (isDarkMode) {
return {
backgroundColor: bgColor,
color: '#ffffff',
borderColor: 'transparent',
};
}
return {
backgroundColor: bgColor,
borderColor: 'transparent',
};
}, [notification.color, isDarkMode]);
const containerStyle = useMemo(
() => ({
border: notification.color ? `2px solid ${notification.color}4d` : undefined,
}),
[notification.color]
);
const containerClasses = useMemo(
() => [
'w-auto p-3 mb-3 rounded border border-gray-200 bg-white shadow-sm transition-all duration-300',
'hover:shadow-md hover:bg-gray-50',
notification.url ? 'cursor-pointer' : 'cursor-default',
'dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700'
].join(' '),
[notification.url]
);
const formattedDate = useMemo(
() => (notification.created_at ? fromNow(notification.created_at) : ''),
[notification.created_at]
);
const shouldShowProject = useMemo(
() => Boolean(notification.project),
[notification.project]
);
const shouldShowMarkAsRead = useMemo(
() => Boolean(isUnreadNotifications && markNotificationAsRead),
[isUnreadNotifications, markNotificationAsRead]
);
return (
<div
style={containerStyle}
onClick={handleNotificationClick}
className={containerClasses}
>
<div className="notification-content">
<div className="notification-description">
{/* Team name */}
<div className="mb-2">
<Text type="secondary" className="flex items-center gap-2">
<BankOutlined /> {notification.team}
</Text>
</div>
{/* Message with HTML content */}
<div className="mb-2" dangerouslySetInnerHTML={safeMessageHtml} />
{/* Project tag */}
{shouldShowProject && (
<div className="mb-2">
<Tag style={tagStyle}>{notification.project}</Tag>
</div>
)}
</div>
{/* Footer with mark as read button and timestamp */}
<div className="flex items-baseline justify-between mt-2">
{shouldShowMarkAsRead && (
<Button
loading={loading}
type="link"
size="small"
shape="round"
className="p-0"
onClick={handleMarkAsRead}
>
<u>Mark as read</u>
</Button>
)}
<Text type="secondary" className="text-xs">
{formattedDate}
</Text>
</div>
</div>
</div>
);
});
NotificationItem.displayName = 'NotificationItem';
export default NotificationItem;

View File

@@ -0,0 +1,152 @@
import React, { memo, useCallback, useMemo } from 'react';
import { Button, Typography, Tag } from '@/shared/antd-imports';
import { BankOutlined } from '@/shared/antd-imports';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleDrawer } from '@features/navbar/notificationSlice';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { formatDistanceToNow } from 'date-fns';
import { tagBackground } from '@/utils/colorUtils';
import logger from '@/utils/errorLogger';
import './NotificationItem.css';
interface NotificationTemplateProps {
item: IWorklenzNotification;
isUnreadNotifications: boolean;
markNotificationAsRead: (id: string) => Promise<void>;
loadersMap: Record<string, boolean>;
}
const NotificationTemplate = memo<NotificationTemplateProps>(({
item,
isUnreadNotifications,
markNotificationAsRead,
loadersMap,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const goToUrl = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!item.url) return;
try {
dispatch(toggleDrawer());
if (item.team_id) {
await teamsApiService.setActiveTeam(item.team_id);
}
navigate(item.url, {
state: item.params || null,
});
} catch (error) {
logger.error('Error navigating to notification URL', error);
}
},
[item.url, item.team_id, item.params, dispatch, navigate]
);
const formattedDate = useMemo(() => {
if (!item.created_at) return '';
try {
return formatDistanceToNow(new Date(item.created_at), { addSuffix: true });
} catch (error) {
logger.error('Error formatting date', error);
return '';
}
}, [item.created_at]);
const handleMarkAsRead = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markNotificationAsRead(item.id);
},
[markNotificationAsRead, item.id]
);
const containerStyle = useMemo(
() => ({
border: item.color ? `2px solid ${item.color}4d` : undefined,
}),
[item.color]
);
const containerClassName = useMemo(
() => [
'w-auto p-3 mb-3 rounded border border-gray-200 bg-white shadow-sm transition-all duration-300',
'hover:shadow-md hover:bg-gray-50',
item.url ? 'cursor-pointer' : 'cursor-default',
'dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700'
].join(' '),
[item.url]
);
const messageHtml = useMemo(
() => ({ __html: item.message }),
[item.message]
);
const tagStyle = useMemo(
() => (item.color ? { backgroundColor: tagBackground(item.color) } : {}),
[item.color]
);
const shouldShowProject = useMemo(
() => Boolean(item.project && item.color),
[item.project, item.color]
);
const isLoading = useMemo(
() => Boolean(loadersMap[item.id]),
[loadersMap, item.id]
);
return (
<div
style={containerStyle}
onClick={goToUrl}
className={containerClassName}
>
<div className="notification-content">
<div className="notification-description">
<Typography.Text type="secondary" className="mb-2 flex items-center gap-2">
<BankOutlined /> {item.team}
</Typography.Text>
<div className="mb-2" dangerouslySetInnerHTML={messageHtml} />
{shouldShowProject && (
<div className="mb-2">
<Tag style={tagStyle}>{item.project}</Tag>
</div>
)}
</div>
<div className="flex items-baseline justify-between mt-2">
{isUnreadNotifications && (
<Button
type="link"
shape="round"
size="small"
loading={isLoading}
onClick={handleMarkAsRead}
>
<u>Mark as read</u>
</Button>
)}
<Typography.Text type="secondary" className="text-xs">
{formattedDate}
</Typography.Text>
</div>
</div>
</div>
);
});
NotificationTemplate.displayName = 'NotificationTemplate';
export default NotificationTemplate;

View File

@@ -0,0 +1,176 @@
import React, { memo, useCallback, useMemo } from 'react';
import { notification } from '@/shared/antd-imports';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { toQueryString } from '@/utils/toQueryString';
import { BankOutlined } from '@/shared/antd-imports';
import './PushNotificationTemplate.css';
interface PushNotificationTemplateProps {
notification: IWorklenzNotification;
}
const PushNotificationTemplate = memo(({
notification: notificationData,
}: PushNotificationTemplateProps) => {
const handleClick = useCallback(async () => {
if (!notificationData.url) return;
try {
let url = notificationData.url;
if (notificationData.params && Object.keys(notificationData.params).length) {
const q = toQueryString(notificationData.params);
url += q;
}
if (notificationData.team_id) {
await teamsApiService.setActiveTeam(notificationData.team_id);
}
window.location.href = url;
} catch (error) {
console.error('Error handling notification click:', error);
}
}, [notificationData.url, notificationData.params, notificationData.team_id]);
const containerStyle = useMemo(
() => ({
cursor: notificationData.url ? 'pointer' : 'default',
padding: '8px 0',
borderRadius: '8px',
}),
[notificationData.url]
);
const headerStyle = useMemo(
() => ({
display: 'flex',
alignItems: 'center',
marginBottom: '8px',
color: '#262626',
fontSize: '14px',
fontWeight: 500,
}),
[]
);
const iconStyle = useMemo(
() => ({ marginRight: '8px', color: '#1890ff' }),
[]
);
const messageStyle = useMemo(
() => ({
color: '#595959',
fontSize: '13px',
lineHeight: '1.5',
marginTop: '4px',
}),
[]
);
const className = useMemo(
() => `notification-content ${notificationData.url ? 'clickable' : ''}`,
[notificationData.url]
);
const messageHtml = useMemo(
() => ({ __html: notificationData.message }),
[notificationData.message]
);
return (
<div
onClick={handleClick}
className={className}
style={containerStyle}
>
<div style={headerStyle}>
{notificationData.team ? (
<>
<BankOutlined style={iconStyle} />
{notificationData.team}
</>
) : (
'Worklenz'
)}
</div>
<div
style={messageStyle}
dangerouslySetInnerHTML={messageHtml}
/>
</div>
);
});
PushNotificationTemplate.displayName = 'PushNotificationTemplate';
// Notification queue management
class NotificationQueueManager {
private queue: IWorklenzNotification[] = [];
private isProcessing = false;
private readonly maxQueueSize = 10;
private readonly notificationStyle = {
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
padding: '12px 16px',
minWidth: '300px',
maxWidth: '400px',
};
private processQueue = () => {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
const notificationData = this.queue.shift();
if (notificationData) {
notification.info({
message: null,
description: <PushNotificationTemplate notification={notificationData} />,
placement: 'topRight',
duration: 5,
style: this.notificationStyle,
onClose: () => {
this.isProcessing = false;
// Use setTimeout to prevent stack overflow with rapid notifications
setTimeout(() => this.processQueue(), 0);
},
});
} else {
this.isProcessing = false;
}
};
public addNotification = (notificationData: IWorklenzNotification) => {
// Prevent queue overflow
if (this.queue.length >= this.maxQueueSize) {
console.warn('Notification queue is full, dropping oldest notification');
this.queue.shift();
}
this.queue.push(notificationData);
this.processQueue();
};
public clearQueue = () => {
this.queue.length = 0;
this.isProcessing = false;
};
public getQueueLength = () => this.queue.length;
}
const notificationManager = new NotificationQueueManager();
export const showNotification = (notificationData: IWorklenzNotification) => {
notificationManager.addNotification(notificationData);
};
export const clearNotificationQueue = () => {
notificationManager.clearQueue();
};
export const getNotificationQueueLength = () => {
return notificationManager.getQueueLength();
};

View File

@@ -1,127 +0,0 @@
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { BankOutlined } from '@/shared/antd-imports';
import { Button, Tag, Typography, theme } from '@/shared/antd-imports';
import DOMPurify from 'dompurify';
import React, { useState } from 'react';
import { fromNow } from '@/utils/dateUtils';
import './notification-item.css';
const { Text } = Typography;
interface NotificationItemProps {
notification: IWorklenzNotification;
isUnreadNotifications?: boolean;
markNotificationAsRead?: (id: string) => Promise<void>;
goToUrl?: (e: React.MouseEvent, notification: IWorklenzNotification) => Promise<void>;
}
const NotificationItem = ({
notification,
isUnreadNotifications = true,
markNotificationAsRead,
goToUrl,
}: NotificationItemProps) => {
const { token } = theme.useToken();
const [loading, setLoading] = useState(false);
const isDarkMode =
token.colorBgContainer === '#141414' ||
token.colorBgContainer.includes('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark';
const handleNotificationClick = async (e: React.MouseEvent) => {
await goToUrl?.(e, notification);
await markNotificationAsRead?.(notification.id);
};
const handleMarkAsRead = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!notification.id) return;
setLoading(true);
try {
await markNotificationAsRead?.(notification.id);
} finally {
setLoading(false);
}
};
const createSafeHtml = (html: string) => {
return { __html: DOMPurify.sanitize(html) };
};
const getTagBackground = (color?: string) => {
if (!color) return {};
// Create a more transparent version of the color for the background
// This is equivalent to the color + '4d' in the Angular template
const bgColor = `${color}4d`;
// For dark mode, we might need to adjust the text color for better contrast
if (isDarkMode) {
return {
backgroundColor: bgColor,
color: '#ffffff',
borderColor: 'transparent',
};
}
return {
backgroundColor: bgColor,
borderColor: 'transparent',
};
};
return (
<div
style={{
width: 'auto',
border: notification.color ? `2px solid ${notification.color}4d` : undefined,
cursor: notification.url ? 'pointer' : 'default',
}}
onClick={handleNotificationClick}
className="ant-notification-notice worklenz-notification rounded-4"
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
{/* Team name */}
<div className="mb-1">
<Text type="secondary">
<BankOutlined /> {notification.team}
</Text>
</div>
{/* Message with HTML content */}
<div className="mb-1" dangerouslySetInnerHTML={createSafeHtml(notification.message)} />
{/* Project tag */}
{notification.project && (
<div>
<Tag style={getTagBackground(notification.color)}>{notification.project}</Tag>
</div>
)}
</div>
{/* Footer with mark as read button and timestamp */}
<div className="d-flex align-items-baseline justify-content-between mt-1">
{isUnreadNotifications && markNotificationAsRead && (
<Button
loading={loading}
type="link"
size="small"
shape="round"
className="p-0"
onClick={e => handleMarkAsRead(e)}
>
<u>Mark as read</u>
</Button>
)}
<Text type="secondary" className="small">
{notification.created_at ? fromNow(notification.created_at) : ''}
</Text>
</div>
</div>
</div>
);
};
export default NotificationItem;

View File

@@ -1,95 +0,0 @@
import { Button, Typography, Tag } from '@/shared/antd-imports';
import { BankOutlined } from '@/shared/antd-imports';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleDrawer } from '../../../../../features/navbar/notificationSlice';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { formatDistanceToNow } from 'date-fns';
import { tagBackground } from '@/utils/colorUtils';
interface NotificationTemplateProps {
item: IWorklenzNotification;
isUnreadNotifications: boolean;
markNotificationAsRead: (id: string) => Promise<void>;
loadersMap: Record<string, boolean>;
}
const NotificationTemplate: React.FC<NotificationTemplateProps> = ({
item,
isUnreadNotifications,
markNotificationAsRead,
loadersMap,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const goToUrl = async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
console.log('goToUrl triggered', { url: item.url, teamId: item.team_id });
if (item.url) {
dispatch(toggleDrawer());
if (item.team_id) {
await teamsApiService.setActiveTeam(item.team_id);
}
navigate(item.url, {
state: item.params || null,
});
}
};
const formatDate = (dateString?: string) => {
if (!dateString) return '';
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
};
const handleMarkAsRead = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markNotificationAsRead(item.id);
};
return (
<div
style={{ width: 'auto', border: `2px solid ${item.color}4d` }}
onClick={goToUrl}
className={`ant-notification-notice worklenz-notification rounded-4 ${item.url ? 'cursor-pointer' : ''}`}
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
<Typography.Text type="secondary" className="mb-1">
<BankOutlined /> {item.team}
</Typography.Text>
<div className="mb-1" dangerouslySetInnerHTML={{ __html: item.message }} />
{item.project && item.color && (
<Tag style={{ backgroundColor: tagBackground(item.color) }}>{item.project}</Tag>
)}
</div>
<div className="d-flex align-items-baseline justify-content-between mt-1">
{isUnreadNotifications && (
<Button
type="link"
shape="round"
size="small"
loading={loadersMap[item.id]}
onClick={handleMarkAsRead}
>
<u>Mark as read</u>
</Button>
)}
<Typography.Text type="secondary" className="small">
{formatDate(item.created_at)}
</Typography.Text>
</div>
</div>
</div>
);
};
export default NotificationTemplate;

View File

@@ -1,105 +0,0 @@
import { notification } from '@/shared/antd-imports';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { toQueryString } from '@/utils/toQueryString';
import { BankOutlined } from '@/shared/antd-imports';
import './push-notification-template.css';
const PushNotificationTemplate = ({
notification: notificationData,
}: {
notification: IWorklenzNotification;
}) => {
const handleClick = async () => {
if (notificationData.url) {
let url = notificationData.url;
if (notificationData.params && Object.keys(notificationData.params).length) {
const q = toQueryString(notificationData.params);
url += q;
}
if (notificationData.team_id) {
await teamsApiService.setActiveTeam(notificationData.team_id);
}
window.location.href = url;
}
};
return (
<div
onClick={handleClick}
className={`notification-content ${notificationData.url ? 'clickable' : ''}`}
style={{
cursor: notificationData.url ? 'pointer' : 'default',
padding: '8px 0',
borderRadius: '8px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '8px',
color: '#262626',
fontSize: '14px',
fontWeight: 500,
}}
>
{notificationData.team && (
<>
<BankOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
{notificationData.team}
</>
)}
{!notificationData.team && 'Worklenz'}
</div>
<div
style={{
color: '#595959',
fontSize: '13px',
lineHeight: '1.5',
marginTop: '4px',
}}
dangerouslySetInnerHTML={{ __html: notificationData.message }}
/>
</div>
);
};
let notificationQueue: IWorklenzNotification[] = [];
let isProcessing = false;
const processNotificationQueue = () => {
if (isProcessing || notificationQueue.length === 0) return;
isProcessing = true;
const notificationData = notificationQueue.shift();
if (notificationData) {
notification.info({
message: null,
description: <PushNotificationTemplate notification={notificationData} />,
placement: 'topRight',
duration: 5,
style: {
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
padding: '12px 16px',
minWidth: '300px',
maxWidth: '400px',
},
onClose: () => {
isProcessing = false;
processNotificationQueue();
},
});
} else {
isProcessing = false;
}
};
export const showNotification = (notificationData: IWorklenzNotification) => {
notificationQueue.push(notificationData);
processNotificationQueue();
};

View File

@@ -1,33 +1,20 @@
// Ant Design Icons
import { BankOutlined, CaretDownFilled, CheckCircleFilled } from '@/shared/antd-imports'; import { BankOutlined, CaretDownFilled, CheckCircleFilled } from '@/shared/antd-imports';
// Ant Design Components
import { Card, Divider, Dropdown, Flex, Tooltip, Typography } from '@/shared/antd-imports'; import { Card, Divider, Dropdown, Flex, Tooltip, Typography } from '@/shared/antd-imports';
// Redux Hooks
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
// Redux Actions
import { fetchTeams, setActiveTeam } from '@/features/teams/teamSlice'; import { fetchTeams, setActiveTeam } from '@/features/teams/teamSlice';
import { verifyAuthentication } from '@/features/auth/authSlice'; import { verifyAuthentication } from '@/features/auth/authSlice';
import { setUser } from '@/features/user/userSlice'; import { setUser } from '@/features/user/userSlice';
// Hooks & Services
import { useAuthService } from '@/hooks/useAuth'; import { useAuthService } from '@/hooks/useAuth';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { createAuthService } from '@/services/auth/auth.service'; import { createAuthService } from '@/services/auth/auth.service';
// Components
import CustomAvatar from '@/components/CustomAvatar'; import CustomAvatar from '@/components/CustomAvatar';
// Styles
import { colors } from '@/styles/colors'; import { colors } from '@/styles/colors';
import './switchTeam.css'; import './SwitchTeamButton.css';
import { useEffect } from 'react'; import { useEffect, memo, useCallback, useMemo } from 'react';
const SwitchTeamButton = () => { const SwitchTeamButton = memo(() => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const authService = createAuthService(navigate); const authService = createAuthService(navigate);
@@ -43,32 +30,39 @@ const SwitchTeamButton = () => {
dispatch(fetchTeams()); dispatch(fetchTeams());
}, [dispatch]); }, [dispatch]);
const isActiveTeam = (teamId: string): boolean => { const isActiveTeam = useCallback(
(teamId: string): boolean => {
if (!teamId || !session?.team_id) return false; if (!teamId || !session?.team_id) return false;
return teamId === session.team_id; return teamId === session.team_id;
}; },
[session?.team_id]
);
const handleVerifyAuth = async () => { const handleVerifyAuth = useCallback(async () => {
const result = await dispatch(verifyAuthentication()).unwrap(); const result = await dispatch(verifyAuthentication()).unwrap();
if (result.authenticated) { if (result.authenticated) {
dispatch(setUser(result.user)); dispatch(setUser(result.user));
authService.setCurrentSession(result.user); authService.setCurrentSession(result.user);
} }
}; }, [dispatch, authService]);
const handleTeamSelect = async (id: string) => { const handleTeamSelect = useCallback(
async (id: string) => {
if (!id) return; if (!id) return;
await dispatch(setActiveTeam(id)); await dispatch(setActiveTeam(id));
await handleVerifyAuth(); await handleVerifyAuth();
window.location.reload(); window.location.reload();
}; },
[dispatch, handleVerifyAuth]
);
const renderTeamCard = (team: any, index: number) => ( const renderTeamCard = useCallback(
(team: any, index: number) => (
<Card <Card
className="switch-team-card" className="switch-team-card"
onClick={() => handleTeamSelect(team.id)} onClick={() => handleTeamSelect(team.id)}
bordered={false} variant='borderless'
style={{ width: 230 }} style={{ width: 230 }}
> >
<Flex vertical> <Flex vertical>
@@ -92,14 +86,19 @@ const SwitchTeamButton = () => {
{index < teamsList.length - 1 && <Divider style={{ margin: 0 }} />} {index < teamsList.length - 1 && <Divider style={{ margin: 0 }} />}
</Flex> </Flex>
</Card> </Card>
),
[handleTeamSelect, isActiveTeam, teamsList.length]
); );
const dropdownItems = const dropdownItems = useMemo(
() =>
teamsList?.map((team, index) => ({ teamsList?.map((team, index) => ({
key: team.id || '', key: team.id || '',
label: renderTeamCard(team, index), label: renderTeamCard(team, index),
type: 'item' as const, type: 'item' as const,
})) || []; })) || [],
[teamsList, renderTeamCard]
);
return ( return (
<Dropdown <Dropdown
@@ -132,6 +131,8 @@ const SwitchTeamButton = () => {
</Tooltip> </Tooltip>
</Dropdown> </Dropdown>
); );
}; });
SwitchTeamButton.displayName = 'SwitchTeamButton';
export default SwitchTeamButton; export default SwitchTeamButton;

View File

@@ -1,6 +1,16 @@
import { ClockCircleOutlined, StopOutlined } from '@/shared/antd-imports'; import { ClockCircleOutlined, StopOutlined } from '@/shared/antd-imports';
import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from '@/shared/antd-imports'; import {
import React, { useEffect, useState, useCallback } from 'react'; Badge,
Button,
Dropdown,
List,
Tooltip,
Typography,
Space,
Divider,
theme,
} from '@/shared/antd-imports';
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service'; import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';

View File

@@ -1,11 +1,11 @@
import { Button, Tooltip } from '@/shared/antd-imports'; import { Button, Tooltip } from '@/shared/antd-imports';
import React from 'react'; import React, { memo, useCallback } from 'react';
import { colors } from '../../../styles/colors'; import { colors } from '../../../styles/colors';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
const UpgradePlanButton = () => { const UpgradePlanButton = memo(() => {
// localization // localization
const { t } = useTranslation('navbar'); const { t } = useTranslation('navbar');
const navigate = useNavigate(); const navigate = useNavigate();
@@ -22,12 +22,14 @@ const UpgradePlanButton = () => {
}} }}
size="small" size="small"
type="text" type="text"
onClick={() => navigate('/worklenz/admin-center/billing')} onClick={useCallback(() => navigate('/worklenz/admin-center/billing'), [navigate])}
> >
{t('upgradePlan')} {t('upgradePlan')}
</Button> </Button>
</Tooltip> </Tooltip>
); );
}; });
UpgradePlanButton.displayName = 'UpgradePlanButton';
export default UpgradePlanButton; export default UpgradePlanButton;

View File

@@ -9,17 +9,17 @@ import { RootState } from '@/app/store';
import { getRole } from '@/utils/session-helper'; import { getRole } from '@/utils/session-helper';
import './profile-dropdown.css'; import './ProfileDropdown.css';
import './profile-button.css'; import './ProfileButton.css';
import SingleAvatar from '@/components/common/single-avatar/single-avatar'; import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { useAuthService } from '@/hooks/useAuth'; import { useAuthService } from '@/hooks/useAuth';
import { useEffect, useState } from 'react'; import { memo, useMemo } from 'react';
interface ProfileButtonProps { interface ProfileButtonProps {
isOwnerOrAdmin: boolean; isOwnerOrAdmin: boolean;
} }
const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => { const ProfileButton = memo(({ isOwnerOrAdmin }: ProfileButtonProps) => {
const { t } = useTranslation('navbar'); const { t } = useTranslation('navbar');
const authService = useAuthService(); const authService = useAuthService();
const currentSession = useAppSelector((state: RootState) => state.userReducer); const currentSession = useAppSelector((state: RootState) => state.userReducer);
@@ -27,11 +27,15 @@ const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
const role = getRole(); const role = getRole();
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode); const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const getLinkStyle = () => ({ const getLinkStyle = useMemo(
() => ({
color: themeMode === 'dark' ? '#ffffffd9' : '#181818', color: themeMode === 'dark' ? '#ffffffd9' : '#181818',
}); }),
[themeMode]
);
const profile: MenuProps['items'] = [ const profile: MenuProps['items'] = useMemo(
() => [
{ {
key: '1', key: '1',
label: ( label: (
@@ -81,20 +85,22 @@ const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
style={{ width: 230 }} style={{ width: 230 }}
> >
{isOwnerOrAdmin && ( {isOwnerOrAdmin && (
<Link to="/worklenz/admin-center/overview" style={getLinkStyle()}> <Link to="/worklenz/admin-center/overview" style={getLinkStyle}>
{t('adminCenter')} {t('adminCenter')}
</Link> </Link>
)} )}
<Link to="/worklenz/settings/profile" style={getLinkStyle()}> <Link to="/worklenz/settings/profile" style={getLinkStyle}>
{t('settings')} {t('settings')}
</Link> </Link>
<Link to="/auth/logging-out" style={getLinkStyle()}> <Link to="/auth/logging-out" style={getLinkStyle}>
{t('logOut')} {t('logOut')}
</Link> </Link>
</Card> </Card>
), ),
}, },
]; ],
[currentSession, role, themeMode, getLinkStyle, isOwnerOrAdmin, t]
);
return ( return (
<Dropdown <Dropdown
@@ -123,6 +129,8 @@ const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
</Tooltip> </Tooltip>
</Dropdown> </Dropdown>
); );
}; });
ProfileButton.displayName = 'ProfileButton';
export default ProfileButton; export default ProfileButton;

View File

@@ -1,99 +0,0 @@
import {
ClockCircleOutlined,
HomeOutlined,
MenuOutlined,
ProjectOutlined,
QuestionCircleOutlined,
ReadOutlined,
} from '@/shared/antd-imports';
import { Button, Card, Dropdown, Flex, MenuProps, Space, Typography } from '@/shared/antd-imports';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { colors } from '../../../styles/colors';
import { NavLink } from 'react-router-dom';
import InviteButton from '../invite/InviteButton';
import SwitchTeamButton from '../switchTeam/SwitchTeamButton';
// custom css
import './mobileMenu.css';
const MobileMenuButton = () => {
// localization
const { t } = useTranslation('navbar');
const navLinks = [
{
name: 'home',
icon: React.createElement(HomeOutlined),
},
{
name: 'projects',
icon: React.createElement(ProjectOutlined),
},
{
name: 'schedule',
icon: React.createElement(ClockCircleOutlined),
},
{
name: 'reporting',
icon: React.createElement(ReadOutlined),
},
{
name: 'help',
icon: React.createElement(QuestionCircleOutlined),
},
];
const mobileMenu: MenuProps['items'] = [
{
key: '1',
label: (
<Card className="mobile-menu-card" bordered={false} style={{ width: 230 }}>
{navLinks.map((navEl, index) => (
<NavLink key={index} to={`/worklenz/${navEl.name}`}>
<Typography.Text strong>
<Space>
{navEl.icon}
{t(navEl.name)}
</Space>
</Typography.Text>
</NavLink>
))}
<Flex
vertical
gap={12}
style={{
width: '90%',
marginInlineStart: 12,
marginBlock: 6,
}}
>
<Button
style={{
backgroundColor: colors.lightBeige,
color: 'black',
}}
>
{t('upgradePlan')}
</Button>
<InviteButton />
<SwitchTeamButton />
</Flex>
</Card>
),
},
];
return (
<Dropdown
overlayClassName="mobile-menu-dropdown"
menu={{ items: mobileMenu }}
placement="bottomRight"
trigger={['click']}
>
<Button className="borderless-icon-btn" icon={<MenuOutlined style={{ fontSize: 20 }} />} />
</Dropdown>
);
};
export default MobileMenuButton;

View File

@@ -61,7 +61,7 @@ const notificationSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
toggleDrawer: state => { toggleDrawer: state => {
state.isDrawerOpen ? (state.isDrawerOpen = false) : (state.isDrawerOpen = true); state.isDrawerOpen = !state.isDrawerOpen;
}, },
setNotificationType: (state, action) => { setNotificationType: (state, action) => {
state.notificationType = action.payload; state.notificationType = action.payload;
@@ -76,12 +76,12 @@ const notificationSlice = createSlice({
state.invitations = action.payload; state.invitations = action.payload;
state.invitationsCount = action.payload.length; state.invitationsCount = action.payload.length;
state.invitations.map(invitation => { state._dataset = state._dataset.concat(
state._dataset.push({ state.invitations.map(invitation => ({
type: 'invitation', type: 'invitation',
data: invitation, data: invitation,
}); }))
}); );
}); });
builder.addCase(fetchInvitations.rejected, state => { builder.addCase(fetchInvitations.rejected, state => {
state.loading = false; state.loading = false;
@@ -94,12 +94,12 @@ const notificationSlice = createSlice({
state.notifications = action.payload; state.notifications = action.payload;
state.notificationsCount = action.payload.length; state.notificationsCount = action.payload.length;
state.notifications.map(notification => { state._dataset = state._dataset.concat(
state._dataset.push({ state.notifications.map(notification => ({
type: 'notification', type: 'notification',
data: notification, data: notification,
}); }))
}); );
}); });
builder.addCase(fetchUnreadCount.pending, state => { builder.addCase(fetchUnreadCount.pending, state => {
state.unreadNotificationsCount = 0; state.unreadNotificationsCount = 0;

View File

@@ -3,9 +3,9 @@ import { Outlet } from 'react-router-dom';
import { memo, useMemo, useEffect, useRef } from 'react'; import { memo, useMemo, useEffect, useRef } from 'react';
import { useMediaQuery } from 'react-responsive'; import { useMediaQuery } from 'react-responsive';
import Navbar from '../features/navbar/navbar'; import Navbar from '@/components/navbar/Navbar';
import { useAppSelector } from '../hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '../styles/colors'; import { colors } from '@/styles/colors';
import { useRenderPerformance } from '@/utils/performance'; import { useRenderPerformance } from '@/utils/performance';
import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations'; import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations';

View File

@@ -1,12 +1,12 @@
import { Col, ConfigProvider, Layout } from '@/shared/antd-imports'; import { Col, ConfigProvider, Layout } from '@/shared/antd-imports';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Navbar from '../features/navbar/navbar'; import Navbar from '@/components/navbar/Navbar';
import { useAppSelector } from '../hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '../styles/colors'; import { colors } from '@/styles/colors';
import { themeWiseColor } from '../utils/themeWiseColor'; import { themeWiseColor } from '@/utils/themeWiseColor';
import ReportingSider from '../pages/reporting/sidebar/reporting-sider'; import ReportingSider from '@/pages/reporting/sidebar/reporting-sider';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import ReportingCollapsedButton from '../pages/reporting/sidebar/reporting-collapsed-button'; import ReportingCollapsedButton from '@/pages/reporting/sidebar/reporting-collapsed-button';
import { useAuthService } from '@/hooks/useAuth'; import { useAuthService } from '@/hooks/useAuth';
import { reportingApiService } from '@/api/reporting/reporting.api.service'; import { reportingApiService } from '@/api/reporting/reporting.api.service';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';