Compare commits
1 Commits
release-v2
...
chore/adde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeec5b2b84 |
55
worklenz-frontend/sonar-project.properties
Normal file
55
worklenz-frontend/sonar-project.properties
Normal 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
|
||||
@@ -3,7 +3,7 @@ import { getJSONFromLocalStorage, saveJSONToLocalStorage } from '../utils/localS
|
||||
import { Button, ConfigProvider, Tooltip } from '@/shared/antd-imports';
|
||||
import { PushpinFilled, PushpinOutlined } from '@/shared/antd-imports';
|
||||
import { colors } from '../styles/colors';
|
||||
import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes';
|
||||
import { navRoutes, NavRoutesType } from '../lib/navbar/navRoutes';
|
||||
|
||||
// Props type for the component
|
||||
type PinRouteToNavbarButtonProps = {
|
||||
|
||||
@@ -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 { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
@@ -11,7 +21,6 @@ import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.se
|
||||
import { IJobTitle } from '@/types/job.types';
|
||||
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
|
||||
import { ITeamMemberCreateRequest } from '@/types/teamMembers/team-member-create-request';
|
||||
import { LinkOutlined } from '@ant-design/icons';
|
||||
|
||||
interface FormValues {
|
||||
email: string[];
|
||||
|
||||
@@ -17,7 +17,7 @@ interface InvitationItemProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
const InvitationItem: React.FC<InvitationItemProps> = ({ item, isUnreadNotifications, t }) => {
|
||||
const InvitationItem = ({ item, isUnreadNotifications, t }: InvitationItemProps) => {
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -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 { 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 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 MobileMenuButton from './mobileMenu/MobileMenuButton';
|
||||
import NavbarLogo from './navbar-logo';
|
||||
import NotificationButton from '../../components/navbar/notifications/notifications-drawer/notification/notification-button';
|
||||
import ProfileButton from './user-profile/profile-button';
|
||||
import NavbarLogo from './NavbarLogo';
|
||||
import NotificationButton from './NotificationButton';
|
||||
import ProfileButton from './user-profile/ProfileButton';
|
||||
import SwitchTeamButton from './switchTeam/SwitchTeamButton';
|
||||
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 { getJSONFromLocalStorage } from '@/utils/localStorageFunctions';
|
||||
import { navRoutes, NavRoutesType } from './navRoutes';
|
||||
import { navRoutes, NavRoutesType } from '@/lib/navbar/navRoutes';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import TimerButton from './timers/timer-button';
|
||||
import HelpButton from './help/HelpButton';
|
||||
|
||||
const Navbar = () => {
|
||||
const Navbar = memo(() => {
|
||||
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 location = useLocation();
|
||||
const { isDesktop, isMobile, isTablet } = useResponsive();
|
||||
const { t } = useTranslation('navbar');
|
||||
const authService = useAuthService();
|
||||
const [navRoutesList, setNavRoutesList] = useState<NavRoutesType[]>(navRoutes);
|
||||
const [isOwnerOrAdmin, setIsOwnerOrAdmin] = useState<boolean>(authService.isOwnerOrAdmin());
|
||||
const showUpgradeTypes = [ISUBSCRIPTION_TYPE.TRIAL];
|
||||
const showUpgradeTypes = useMemo(() => [ISUBSCRIPTION_TYPE.TRIAL], []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
authApiService
|
||||
.verify()
|
||||
.then(authorizeResponse => {
|
||||
if (authorizeResponse.authenticated) {
|
||||
if (mounted && authorizeResponse.authenticated) {
|
||||
authService.setCurrentSession(authorizeResponse.user);
|
||||
setIsOwnerOrAdmin(!!(authorizeResponse.user.is_admin || authorizeResponse.user.owner));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Error during authorization', error);
|
||||
if (mounted) {
|
||||
logger.error('Error during authorization', error);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [authService]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedNavRoutesList: NavRoutesType[] = getJSONFromLocalStorage('navRoutes') || navRoutes;
|
||||
@@ -183,6 +188,8 @@ const Navbar = () => {
|
||||
{createPortal(<NotificationDrawer />, document.body, 'notification-drawer')}
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Navbar.displayName = 'Navbar';
|
||||
|
||||
export default Navbar;
|
||||
@@ -1,14 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import logo from '@/assets/images/worklenz-light-mode.png';
|
||||
import logoDark from '@/assets/images/worklenz-dark-mode.png';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '@/app/store';
|
||||
|
||||
const NavbarLogo = () => {
|
||||
const NavbarLogo = memo(() => {
|
||||
const { t } = useTranslation('navbar');
|
||||
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
|
||||
|
||||
@@ -23,6 +23,8 @@ const NavbarLogo = () => {
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NavbarLogo.displayName = 'NavbarLogo';
|
||||
|
||||
export default NavbarLogo;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { QuestionCircleOutlined } 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 './HelpButton.css';
|
||||
|
||||
const HelpButton = () => {
|
||||
const HelpButton = memo(() => {
|
||||
// localization
|
||||
const { t } = useTranslation('navbar');
|
||||
|
||||
@@ -18,6 +18,8 @@ const HelpButton = () => {
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
HelpButton.displayName = 'HelpButton';
|
||||
|
||||
export default HelpButton;
|
||||
@@ -1,12 +1,12 @@
|
||||
import { UsergroupAddOutlined } 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 { colors } from '../../../styles/colors';
|
||||
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();
|
||||
|
||||
// localization
|
||||
@@ -21,12 +21,14 @@ const InviteButton = () => {
|
||||
color: colors.skyBlue,
|
||||
borderColor: colors.skyBlue,
|
||||
}}
|
||||
onClick={() => dispatch(toggleInviteMemberDrawer())}
|
||||
onClick={useCallback(() => dispatch(toggleInviteMemberDrawer()), [dispatch])}
|
||||
>
|
||||
{t('invite')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
InviteButton.displayName = 'InviteButton';
|
||||
|
||||
export default InviteButton;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -1,3 +1,4 @@
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { Drawer, Empty, Segmented, Typography, Spin, Button, Flex } from '@/shared/antd-imports';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
fetchNotifications,
|
||||
setNotificationType,
|
||||
toggleDrawer,
|
||||
} from '../../../../../features/navbar/notificationSlice';
|
||||
} from '../../../../features/navbar/notificationSlice';
|
||||
import { NOTIFICATION_OPTION_READ, NOTIFICATION_OPTION_UNREAD } from '@/shared/constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
@@ -15,13 +16,13 @@ import { IWorklenzNotification } from '@/types/notifications/notifications.types
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import NotificationItem from './notification-item';
|
||||
import InvitationItem from './invitation-item';
|
||||
import NotificationItem from './notification/NotificationItem';
|
||||
import InvitationItem from '../../InvitationItem';
|
||||
import { notificationsApiService } from '@/api/notifications/notifications.api.service';
|
||||
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
|
||||
import { INotificationSettings } from '@/types/settings/notifications.types';
|
||||
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 { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { getUserSession } from '@/utils/session-helper';
|
||||
@@ -30,7 +31,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { createAuthService } from '@/services/auth/auth.service';
|
||||
const HTML_TAG_REGEXP = /<[^>]*>/g;
|
||||
|
||||
const NotificationDrawer = () => {
|
||||
const NotificationDrawer = memo(() => {
|
||||
const { isDrawerOpen, notificationType, notifications, invitations } = useAppSelector(
|
||||
state => state.notificationReducer
|
||||
);
|
||||
@@ -50,72 +51,88 @@ const NotificationDrawer = () => {
|
||||
const navigate = useNavigate();
|
||||
const authService = createAuthService(navigate);
|
||||
|
||||
const createPush = (message: string, title: string, teamId: string | null, url?: string) => {
|
||||
if (Notification.permission === 'granted' && showBrowserPush) {
|
||||
const img = 'https://worklenz.com/assets/icons/icon-128x128.png';
|
||||
const notification = new Notification(title, {
|
||||
body: message.replace(HTML_TAG_REGEXP, ''),
|
||||
icon: img,
|
||||
badge: img,
|
||||
});
|
||||
const createPush = useCallback(
|
||||
(message: string, title: string, teamId: string | null, url?: string) => {
|
||||
if (Notification.permission === 'granted' && showBrowserPush) {
|
||||
const img = 'https://worklenz.com/assets/icons/icon-128x128.png';
|
||||
const notification = new Notification(title, {
|
||||
body: message.replace(HTML_TAG_REGEXP, ''),
|
||||
icon: img,
|
||||
badge: img,
|
||||
});
|
||||
|
||||
notification.onclick = async event => {
|
||||
if (url) {
|
||||
window.focus();
|
||||
notification.onclick = async event => {
|
||||
if (url) {
|
||||
window.focus();
|
||||
|
||||
if (teamId) {
|
||||
await teamsApiService.setActiveTeam(teamId);
|
||||
if (teamId) {
|
||||
try {
|
||||
await teamsApiService.setActiveTeam(teamId);
|
||||
} catch (error) {
|
||||
logger.error('Error setting active team from notification', error);
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
[showBrowserPush]
|
||||
);
|
||||
|
||||
window.location.href = url;
|
||||
const handleInvitationsUpdate = useCallback(
|
||||
(data: ITeamInvitationViewModel[]) => {
|
||||
dispatch(fetchInvitations());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleNotificationsUpdate = useCallback(
|
||||
async (notification: IWorklenzNotification) => {
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
|
||||
if (isPushEnabled()) {
|
||||
const title = notification.team ? `${notification.team} | Worklenz` : 'Worklenz';
|
||||
let url = notification.url;
|
||||
if (url && notification.params && Object.keys(notification.params).length) {
|
||||
const q = toQueryString(notification.params);
|
||||
url += q;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvitationsUpdate = (data: ITeamInvitationViewModel[]) => {
|
||||
dispatch(fetchInvitations());
|
||||
};
|
||||
|
||||
const handleNotificationsUpdate = async (notification: IWorklenzNotification) => {
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
|
||||
if (isPushEnabled()) {
|
||||
const title = notification.team ? `${notification.team} | Worklenz` : 'Worklenz';
|
||||
let url = notification.url;
|
||||
if (url && notification.params && Object.keys(notification.params).length) {
|
||||
const q = toQueryString(notification.params);
|
||||
url += q;
|
||||
createPush(notification.message, title, notification.team_id, url);
|
||||
}
|
||||
|
||||
createPush(notification.message, title, notification.team_id, url);
|
||||
}
|
||||
// Show notification using the template
|
||||
showNotification(notification);
|
||||
},
|
||||
[dispatch, notificationType, isPushEnabled, createPush]
|
||||
);
|
||||
|
||||
// Show notification using the template
|
||||
showNotification(notification);
|
||||
};
|
||||
const handleTeamInvitationsUpdate = useCallback(
|
||||
async (data: ITeamInvitationViewModel) => {
|
||||
const notification: IWorklenzNotification = {
|
||||
id: data.id || '',
|
||||
team: data.team_name || '',
|
||||
team_id: data.team_id || '',
|
||||
message: `You have been invited to join ${data.team_name || 'a team'}`,
|
||||
};
|
||||
|
||||
const handleTeamInvitationsUpdate = async (data: ITeamInvitationViewModel) => {
|
||||
const notification: IWorklenzNotification = {
|
||||
id: data.id || '',
|
||||
team: data.team_name || '',
|
||||
team_id: data.team_id || '',
|
||||
message: `You have been invited to join ${data.team_name || 'a team'}`,
|
||||
};
|
||||
if (isPushEnabled()) {
|
||||
createPush(
|
||||
notification.message,
|
||||
notification.team || 'Worklenz',
|
||||
notification.team_id || null
|
||||
);
|
||||
}
|
||||
|
||||
if (isPushEnabled()) {
|
||||
createPush(
|
||||
notification.message,
|
||||
notification.team || 'Worklenz',
|
||||
notification.team_id || null
|
||||
);
|
||||
}
|
||||
|
||||
// Show notification using the template
|
||||
showNotification(notification);
|
||||
dispatch(fetchInvitations());
|
||||
};
|
||||
// Show notification using the template
|
||||
showNotification(notification);
|
||||
dispatch(fetchInvitations());
|
||||
},
|
||||
[isPushEnabled, createPush, dispatch]
|
||||
);
|
||||
|
||||
const askPushPermission = () => {
|
||||
if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) {
|
||||
@@ -135,27 +152,40 @@ const NotificationDrawer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const markNotificationAsRead = async (id: string) => {
|
||||
if (!id) return;
|
||||
const markNotificationAsRead = useCallback(
|
||||
async (id: string) => {
|
||||
if (!id) return;
|
||||
|
||||
const res = await notificationsApiService.updateNotification(id);
|
||||
if (res.done) {
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
try {
|
||||
const res = await notificationsApiService.updateNotification(id);
|
||||
if (res.done) {
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error marking notification as read', error);
|
||||
}
|
||||
},
|
||||
[dispatch, notificationType]
|
||||
);
|
||||
const handleVerifyAuth = useCallback(async () => {
|
||||
try {
|
||||
const result = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (result.authenticated) {
|
||||
dispatch(setUser(result.user));
|
||||
authService.setCurrentSession(result.user);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error verifying authentication', error);
|
||||
}
|
||||
};
|
||||
const handleVerifyAuth = async () => {
|
||||
const result = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (result.authenticated) {
|
||||
dispatch(setUser(result.user));
|
||||
authService.setCurrentSession(result.user);
|
||||
}
|
||||
};
|
||||
}, [dispatch, authService]);
|
||||
|
||||
const goToUrl = useCallback(
|
||||
async (event: React.MouseEvent, notification: IWorklenzNotification) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!notification.url) return;
|
||||
|
||||
const goToUrl = async (event: React.MouseEvent, notification: IWorklenzNotification) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (notification.url) {
|
||||
dispatch(toggleDrawer());
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -169,12 +199,13 @@ const NotificationDrawer = () => {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error navigating to URL:', error);
|
||||
logger.error('Error navigating to URL:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[dispatch, navigate, handleVerifyAuth]
|
||||
);
|
||||
|
||||
const fetchNotificationsSettings = async () => {
|
||||
try {
|
||||
@@ -190,11 +221,15 @@ const NotificationDrawer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
await notificationsApiService.readAllNotifications();
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
};
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
try {
|
||||
await notificationsApiService.readAllNotifications();
|
||||
dispatch(fetchNotifications(notificationType));
|
||||
dispatch(fetchInvitations());
|
||||
} catch (error) {
|
||||
logger.error('Error marking all notifications as read', error);
|
||||
}
|
||||
}, [dispatch, notificationType]);
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on(SocketEvents.INVITATIONS_UPDATE.toString(), handleInvitationsUpdate);
|
||||
@@ -242,12 +277,15 @@ const NotificationDrawer = () => {
|
||||
<Segmented<string>
|
||||
options={['Unread', 'Read']}
|
||||
defaultValue={NOTIFICATION_OPTION_UNREAD}
|
||||
onChange={(value: string) => {
|
||||
if (value === NOTIFICATION_OPTION_UNREAD)
|
||||
dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD));
|
||||
if (value === NOTIFICATION_OPTION_READ)
|
||||
dispatch(setNotificationType(NOTIFICATION_OPTION_READ));
|
||||
}}
|
||||
onChange={useCallback(
|
||||
(value: string) => {
|
||||
if (value === NOTIFICATION_OPTION_UNREAD)
|
||||
dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD));
|
||||
if (value === NOTIFICATION_OPTION_READ)
|
||||
dispatch(setNotificationType(NOTIFICATION_OPTION_READ));
|
||||
},
|
||||
[dispatch]
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="link" onClick={handleMarkAllAsRead}>
|
||||
@@ -261,7 +299,7 @@ const NotificationDrawer = () => {
|
||||
</div>
|
||||
)}
|
||||
{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 => (
|
||||
<InvitationItem
|
||||
key={invitation.id}
|
||||
@@ -273,13 +311,13 @@ const NotificationDrawer = () => {
|
||||
</div>
|
||||
) : null}
|
||||
{notifications && notifications.length > 0 ? (
|
||||
<div className="notification-list mt-3">
|
||||
<div className="notification-list mt-4 px-2">
|
||||
{notifications.map(notification => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
isUnreadNotifications={notificationType === NOTIFICATION_OPTION_UNREAD}
|
||||
markNotificationAsRead={id => Promise.resolve(markNotificationAsRead(id))}
|
||||
markNotificationAsRead={markNotificationAsRead}
|
||||
goToUrl={goToUrl}
|
||||
/>
|
||||
))}
|
||||
@@ -288,16 +326,13 @@ const NotificationDrawer = () => {
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('notificationsDrawer.noNotifications')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginBlockStart: 32,
|
||||
}}
|
||||
className="flex flex-col items-center mt-8"
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NotificationDrawer.displayName = 'NotificationDrawer';
|
||||
|
||||
export default NotificationDrawer;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
};
|
||||
@@ -1,33 +1,20 @@
|
||||
// Ant Design Icons
|
||||
import { BankOutlined, CaretDownFilled, CheckCircleFilled } from '@/shared/antd-imports';
|
||||
|
||||
// Ant Design Components
|
||||
import { Card, Divider, Dropdown, Flex, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
|
||||
// Redux Hooks
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
// Redux Actions
|
||||
import { fetchTeams, setActiveTeam } from '@/features/teams/teamSlice';
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
|
||||
// Hooks & Services
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createAuthService } from '@/services/auth/auth.service';
|
||||
|
||||
// Components
|
||||
import CustomAvatar from '@/components/CustomAvatar';
|
||||
|
||||
// Styles
|
||||
import { colors } from '@/styles/colors';
|
||||
import './switchTeam.css';
|
||||
import { useEffect } from 'react';
|
||||
import './SwitchTeamButton.css';
|
||||
import { useEffect, memo, useCallback, useMemo } from 'react';
|
||||
|
||||
const SwitchTeamButton = () => {
|
||||
const SwitchTeamButton = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const authService = createAuthService(navigate);
|
||||
@@ -43,32 +30,39 @@ const SwitchTeamButton = () => {
|
||||
dispatch(fetchTeams());
|
||||
}, [dispatch]);
|
||||
|
||||
const isActiveTeam = (teamId: string): boolean => {
|
||||
if (!teamId || !session?.team_id) return false;
|
||||
return teamId === session.team_id;
|
||||
};
|
||||
const isActiveTeam = useCallback(
|
||||
(teamId: string): boolean => {
|
||||
if (!teamId || !session?.team_id) return false;
|
||||
return teamId === session.team_id;
|
||||
},
|
||||
[session?.team_id]
|
||||
);
|
||||
|
||||
const handleVerifyAuth = async () => {
|
||||
const handleVerifyAuth = useCallback(async () => {
|
||||
const result = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (result.authenticated) {
|
||||
dispatch(setUser(result.user));
|
||||
authService.setCurrentSession(result.user);
|
||||
}
|
||||
};
|
||||
}, [dispatch, authService]);
|
||||
|
||||
const handleTeamSelect = async (id: string) => {
|
||||
if (!id) return;
|
||||
const handleTeamSelect = useCallback(
|
||||
async (id: string) => {
|
||||
if (!id) return;
|
||||
|
||||
await dispatch(setActiveTeam(id));
|
||||
await handleVerifyAuth();
|
||||
window.location.reload();
|
||||
};
|
||||
await dispatch(setActiveTeam(id));
|
||||
await handleVerifyAuth();
|
||||
window.location.reload();
|
||||
},
|
||||
[dispatch, handleVerifyAuth]
|
||||
);
|
||||
|
||||
const renderTeamCard = (team: any, index: number) => (
|
||||
const renderTeamCard = useCallback(
|
||||
(team: any, index: number) => (
|
||||
<Card
|
||||
className="switch-team-card"
|
||||
onClick={() => handleTeamSelect(team.id)}
|
||||
bordered={false}
|
||||
variant='borderless'
|
||||
style={{ width: 230 }}
|
||||
>
|
||||
<Flex vertical>
|
||||
@@ -92,14 +86,19 @@ const SwitchTeamButton = () => {
|
||||
{index < teamsList.length - 1 && <Divider style={{ margin: 0 }} />}
|
||||
</Flex>
|
||||
</Card>
|
||||
),
|
||||
[handleTeamSelect, isActiveTeam, teamsList.length]
|
||||
);
|
||||
|
||||
const dropdownItems =
|
||||
teamsList?.map((team, index) => ({
|
||||
key: team.id || '',
|
||||
label: renderTeamCard(team, index),
|
||||
type: 'item' as const,
|
||||
})) || [];
|
||||
const dropdownItems = useMemo(
|
||||
() =>
|
||||
teamsList?.map((team, index) => ({
|
||||
key: team.id || '',
|
||||
label: renderTeamCard(team, index),
|
||||
type: 'item' as const,
|
||||
})) || [],
|
||||
[teamsList, renderTeamCard]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -132,6 +131,8 @@ const SwitchTeamButton = () => {
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
SwitchTeamButton.displayName = 'SwitchTeamButton';
|
||||
|
||||
export default SwitchTeamButton;
|
||||
@@ -1,6 +1,16 @@
|
||||
import { ClockCircleOutlined, StopOutlined } from '@/shared/antd-imports';
|
||||
import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from '@/shared/antd-imports';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
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 { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Button, Tooltip } from '@/shared/antd-imports';
|
||||
import React from 'react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const UpgradePlanButton = () => {
|
||||
const UpgradePlanButton = memo(() => {
|
||||
// localization
|
||||
const { t } = useTranslation('navbar');
|
||||
const navigate = useNavigate();
|
||||
@@ -22,12 +22,14 @@ const UpgradePlanButton = () => {
|
||||
}}
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => navigate('/worklenz/admin-center/billing')}
|
||||
onClick={useCallback(() => navigate('/worklenz/admin-center/billing'), [navigate])}
|
||||
>
|
||||
{t('upgradePlan')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
UpgradePlanButton.displayName = 'UpgradePlanButton';
|
||||
|
||||
export default UpgradePlanButton;
|
||||
@@ -9,17 +9,17 @@ import { RootState } from '@/app/store';
|
||||
|
||||
import { getRole } from '@/utils/session-helper';
|
||||
|
||||
import './profile-dropdown.css';
|
||||
import './profile-button.css';
|
||||
import './ProfileDropdown.css';
|
||||
import './ProfileButton.css';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
interface ProfileButtonProps {
|
||||
isOwnerOrAdmin: boolean;
|
||||
}
|
||||
|
||||
const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
const ProfileButton = memo(({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
const { t } = useTranslation('navbar');
|
||||
const authService = useAuthService();
|
||||
const currentSession = useAppSelector((state: RootState) => state.userReducer);
|
||||
@@ -27,11 +27,15 @@ const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
const role = getRole();
|
||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||
|
||||
const getLinkStyle = () => ({
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : '#181818',
|
||||
});
|
||||
const getLinkStyle = useMemo(
|
||||
() => ({
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : '#181818',
|
||||
}),
|
||||
[themeMode]
|
||||
);
|
||||
|
||||
const profile: MenuProps['items'] = [
|
||||
const profile: MenuProps['items'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
@@ -81,20 +85,22 @@ const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
style={{ width: 230 }}
|
||||
>
|
||||
{isOwnerOrAdmin && (
|
||||
<Link to="/worklenz/admin-center/overview" style={getLinkStyle()}>
|
||||
<Link to="/worklenz/admin-center/overview" style={getLinkStyle}>
|
||||
{t('adminCenter')}
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/worklenz/settings/profile" style={getLinkStyle()}>
|
||||
<Link to="/worklenz/settings/profile" style={getLinkStyle}>
|
||||
{t('settings')}
|
||||
</Link>
|
||||
<Link to="/auth/logging-out" style={getLinkStyle()}>
|
||||
<Link to="/auth/logging-out" style={getLinkStyle}>
|
||||
{t('logOut')}
|
||||
</Link>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
],
|
||||
[currentSession, role, themeMode, getLinkStyle, isOwnerOrAdmin, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -123,6 +129,8 @@ const ProfileButton = ({ isOwnerOrAdmin }: ProfileButtonProps) => {
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
ProfileButton.displayName = 'ProfileButton';
|
||||
|
||||
export default ProfileButton;
|
||||
@@ -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;
|
||||
@@ -61,7 +61,7 @@ const notificationSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleDrawer: state => {
|
||||
state.isDrawerOpen ? (state.isDrawerOpen = false) : (state.isDrawerOpen = true);
|
||||
state.isDrawerOpen = !state.isDrawerOpen;
|
||||
},
|
||||
setNotificationType: (state, action) => {
|
||||
state.notificationType = action.payload;
|
||||
@@ -76,12 +76,12 @@ const notificationSlice = createSlice({
|
||||
state.invitations = action.payload;
|
||||
state.invitationsCount = action.payload.length;
|
||||
|
||||
state.invitations.map(invitation => {
|
||||
state._dataset.push({
|
||||
state._dataset = state._dataset.concat(
|
||||
state.invitations.map(invitation => ({
|
||||
type: 'invitation',
|
||||
data: invitation,
|
||||
});
|
||||
});
|
||||
}))
|
||||
);
|
||||
});
|
||||
builder.addCase(fetchInvitations.rejected, state => {
|
||||
state.loading = false;
|
||||
@@ -94,12 +94,12 @@ const notificationSlice = createSlice({
|
||||
state.notifications = action.payload;
|
||||
state.notificationsCount = action.payload.length;
|
||||
|
||||
state.notifications.map(notification => {
|
||||
state._dataset.push({
|
||||
state._dataset = state._dataset.concat(
|
||||
state.notifications.map(notification => ({
|
||||
type: 'notification',
|
||||
data: notification,
|
||||
});
|
||||
});
|
||||
}))
|
||||
);
|
||||
});
|
||||
builder.addCase(fetchUnreadCount.pending, state => {
|
||||
state.unreadNotificationsCount = 0;
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Outlet } from 'react-router-dom';
|
||||
import { memo, useMemo, useEffect, useRef } from 'react';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import Navbar from '../features/navbar/navbar';
|
||||
import { useAppSelector } from '../hooks/useAppSelector';
|
||||
import { colors } from '../styles/colors';
|
||||
import Navbar from '@/components/navbar/Navbar';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
|
||||
import { useRenderPerformance } from '@/utils/performance';
|
||||
import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Col, ConfigProvider, Layout } from '@/shared/antd-imports';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Navbar from '../features/navbar/navbar';
|
||||
import { useAppSelector } from '../hooks/useAppSelector';
|
||||
import { colors } from '../styles/colors';
|
||||
import { themeWiseColor } from '../utils/themeWiseColor';
|
||||
import ReportingSider from '../pages/reporting/sidebar/reporting-sider';
|
||||
import Navbar from '@/components/navbar/Navbar';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import ReportingSider from '@/pages/reporting/sidebar/reporting-sider';
|
||||
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 { reportingApiService } from '@/api/reporting/reporting.api.service';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
Reference in New Issue
Block a user