From d7864e6bfe5b1083633ab7df1378612a62f3e324 Mon Sep 17 00:00:00 2001 From: rozita-hasani Date: Fri, 28 Feb 2025 14:58:19 +0100 Subject: [PATCH 01/15] feat: Add unread status indicator to notifications --- package.json | 1 + pnpm-lock.yaml | 87 +++++++++++++++++++ .../notification/NotificationBell.tsx | 38 ++++++++ .../notification/NotificationPanel.tsx | 71 +++++++++++++++ .../notification/notificationService.ts | 55 ++++++++++++ src/components/sidebar/Sidebar.tsx | 11 +-- src/core/types/common.ts | 29 ++++--- src/core/types/enum.ts | 19 ++++ src/hooks/useNotifications.ts | 52 +++++++++++ 9 files changed, 342 insertions(+), 21 deletions(-) create mode 100644 src/components/notification/NotificationBell.tsx create mode 100644 src/components/notification/NotificationPanel.tsx create mode 100644 src/components/notification/notificationService.ts create mode 100644 src/hooks/useNotifications.ts diff --git a/package.json b/package.json index 2e0365c..cc699fc 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.6", "axios": "^1.7.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68a1626..63a8084 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@radix-ui/react-switch': specifier: ^1.1.0 version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-toast': specifier: ^1.2.1 version: 1.2.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -590,6 +593,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.2': + resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.1': resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} peerDependencies: @@ -804,6 +820,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.2': + resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.1.5': resolution: {integrity: sha512-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA==} peerDependencies: @@ -874,6 +903,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.3': + resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toast@1.2.5': resolution: {integrity: sha512-ZzUsAaOx8NdXZZKcFNDhbSlbsCUy8qQWmzTdgrlrhhZAOx2ofLtKrBDW9fkqhFvXgmtv560Uj16pkLkqML7SHA==} peerDependencies: @@ -2316,6 +2358,18 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-collection@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-compose-refs@1.1.1(@types/react@18.3.18)(react@18.3.1)': dependencies: react: 18.3.1 @@ -2539,6 +2593,23 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-roving-focus@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-select@2.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 @@ -2625,6 +2696,22 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-tabs@1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-toast@1.2.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 diff --git a/src/components/notification/NotificationBell.tsx b/src/components/notification/NotificationBell.tsx new file mode 100644 index 0000000..d23970b --- /dev/null +++ b/src/components/notification/NotificationBell.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import {Button} from "@/components/ui/button"; +import {Bell} from "lucide-react"; +import {useNotifications} from "@/hooks/useNotifications.ts"; +import {NotificationPanel} from "@/components/notification/NotificationPanel.tsx"; + +export const NotificationBell: React.FC = () => { + const {unreadCount, isOpen, toggleNotificationPanel, markAsRead, markAllAsRead, notifications} = useNotifications(); + + return ( +
+ + + {isOpen && ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/notification/NotificationPanel.tsx b/src/components/notification/NotificationPanel.tsx new file mode 100644 index 0000000..e574fd8 --- /dev/null +++ b/src/components/notification/NotificationPanel.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import {Card, CardContent, CardFooter, CardHeader} from "@/components/ui/card"; +import {Button} from "@/components/ui/button"; +import {X} from "lucide-react"; +import {useNavigate} from "react-router-dom"; +import {Notification} from "@/core/types/common"; +import {Badge} from "@/components/ui/badge.tsx"; + +interface NotificationPanelProps { + notifications: Notification[]; + onClose: () => void; + onMarkAsRead: (id: number) => void; + onMarkAllAsRead: () => void; +} + +export const NotificationPanel = ({notifications, onClose, onMarkAsRead, onMarkAllAsRead}: NotificationPanelProps) => { + const navigate = useNavigate(); + + const handleNotificationClick = (notification: Notification) => { + onMarkAsRead(notification.id); + navigate("/"); + onClose(); + }; + + return ( + + +

Notifications

+ +
+ + + {notifications.length === 0 ? ( +
No notifications
+ ) : ( +
+ {notifications.map((notification) => ( +
handleNotificationClick(notification)} + > +
+ {notification.status === "UNREAD" && ( + )} +

{notification.title}

+
+
+ {notification.date} + + {notification.event} +
+ {notification.description && ( +
{notification.description}
+ )} +
+ ))} +
+ )} +
+ + {notifications.some((n) => n.status === "UNREAD") && ( + + )} + +
+ ); +}; \ No newline at end of file diff --git a/src/components/notification/notificationService.ts b/src/components/notification/notificationService.ts new file mode 100644 index 0000000..3dad065 --- /dev/null +++ b/src/components/notification/notificationService.ts @@ -0,0 +1,55 @@ +import {Notification} from "@/core/types/common"; +import {EventType, NotificationChannel, NotificationStatus} from "@/core/types/enum.ts"; + +// Example mock data simulating API response +const mockNotifications: Notification[] = [ + { + id: 1, + user: {id: 101, name: "John Doe"}, + title: "Leave Request Approved", + description: "Your leave request has been approved.", + date: "2024-02-10", + status: NotificationStatus.UNREAD, + event: EventType.LEAVE_STATUS_UPDATED, + channel: NotificationChannel.EMAIL, + }, + { + id: 2, + user: {id: 102, name: "Jane Smith"}, + title: "New Organization Created", + description: "A new organization was successfully created.", + date: "2024-02-09", + status: NotificationStatus.READ, + event: EventType.ORGANIZATION_CREATED, + channel: NotificationChannel.SLACK, + } +]; + +// Simulated function to fetch notifications +export const fetchNotifications = async (): Promise => { + return new Promise((resolve) => { + setTimeout(() => resolve(mockNotifications), 500); + }); +}; + +// Simulated function to mark a notification as read +export const markNotificationAsRead = async (id: number): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + mockNotifications.forEach(notification => { + if (notification.id === id) notification.status = NotificationStatus.READ; + }); + resolve(); + }, 300); + }); +}; + +// Simulated function to mark all notifications as read +export const markAllNotificationsAsRead = async (): Promise => { + return new Promise((resolve) => { + setTimeout(() => { + mockNotifications.forEach(notification => (notification.status = NotificationStatus.READ)); + resolve(); + }, 300); + }); +}; \ No newline at end of file diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index f885a6b..6f626ac 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -1,10 +1,10 @@ import React, {useContext, useState} from 'react'; import {Link, useLocation} from 'react-router-dom'; -import {Bell, Building, Calendar, CalendarCheck, Home, Settings, TreePalm, User, Users} from 'lucide-react'; -import {Button} from "@/components/ui/button.tsx"; +import {Building, Calendar, CalendarCheck, Home, Settings, TreePalm, User, Users} from 'lucide-react'; import {UserContext} from "@/contexts/UserContext.tsx"; import SidebarAccountDropdown from "@/components/sidebar/SidebarAccountDropdown.tsx"; import Logo from "@/components/icon/Logo.tsx"; +import {NotificationBell} from "@/components/notification/NotificationBell.tsx"; const mainNavigation = [ {name: 'Home', href: '/', icon: Home}, @@ -39,15 +39,12 @@ export default function Sidebar() {
-
+
Teamwize - +
diff --git a/src/core/types/common.ts b/src/core/types/common.ts index 758441d..e278e76 100644 --- a/src/core/types/common.ts +++ b/src/core/types/common.ts @@ -1,3 +1,5 @@ +import {EventType, NotificationChannel, NotificationStatus} from "@/core/types/enum.ts"; + export type PagedResponse = { contents: T[]; pageNumber: number; @@ -6,22 +8,21 @@ export type PagedResponse = { totalContents: number } -export type Navigation = { - name: string; - icon: React.ComponentType>; - href: string -} - export type Country = { name: string; code: string } -export type Balance = { - label: 'Vacation' | 'Sick leave' | 'Paid time off'; - leaveQuantity: number; - leaveUsed: number; - leaveColor: string; -}; - -export type Nullable = T | null; +export interface Notification { + id: number; + user: { + id: number; + name: string; + }; + title: string; + description?: string; + date: string; + status: NotificationStatus; + event: EventType; + channel: NotificationChannel; +} \ No newline at end of file diff --git a/src/core/types/enum.ts b/src/core/types/enum.ts index 950e4c8..08455c9 100644 --- a/src/core/types/enum.ts +++ b/src/core/types/enum.ts @@ -64,3 +64,22 @@ export enum Week { SUNDAY = "SUNDAY" } +export enum NotificationStatus { + UNREAD = "UNREAD", + READ = "READ" +} + +export enum NotificationChannel { + EMAIL = "EMAIL", + SLACK = "SLACK" +} + +export enum EventType { + ORGANIZATION_CREATED = "ORGANIZATION_CREATED", + USER_CREATED = "USER_CREATED", + LEAVE_CREATED = "LEAVE_CREATED", + LEAVE_STATUS_UPDATED = "LEAVE_STATUS_UPDATED", + TEAM_CREATED = "TEAM_CREATED", + NOTIFICATION_CREATED = "NOTIFICATION_CREATED" +} + diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..d9258ff --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,52 @@ +import {useEffect, useState} from "react"; +import {Notification} from "@/core/types/common"; +import { + fetchNotifications, + markAllNotificationsAsRead, + markNotificationAsRead +} from "@/components/notification/notificationService.ts"; +import {NotificationStatus} from "@/core/types/enum.ts"; + +export const useNotifications = () => { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const getNotifications = async () => { + try { + const data = await fetchNotifications(); + setNotifications(data); + setUnreadCount(data.filter(n => n.status === "UNREAD").length); + } catch (error) { + console.error("Error fetching notifications:", error); + } + }; + + getNotifications(); + }, []); + + const toggleNotificationPanel = () => { + setIsOpen(!isOpen); + }; + + const markAsRead = async (id: number) => { + await markNotificationAsRead(id); + setNotifications((prevNotifications) => + prevNotifications.map((n) => + n.id === id ? {...n, status: NotificationStatus.READ} : n + ) + ); + setUnreadCount((prev) => Math.max(0, prev - 1)); + }; + + const markAllAsRead = async () => { + await markAllNotificationsAsRead(); + setNotifications((prevNotifications) => + prevNotifications.map((n) => ({...n, status: NotificationStatus.READ})) + ); + setUnreadCount(0); + }; + + return {notifications, unreadCount, isOpen, toggleNotificationPanel, markAsRead, markAllAsRead}; +}; \ No newline at end of file From d896d7888196c0f5225474b04e2a81ed8774f672 Mon Sep 17 00:00:00 2001 From: rozita-hasani Date: Mon, 10 Mar 2025 14:11:07 +0100 Subject: [PATCH 02/15] feat: Implement notification system with triggers and channels --- package.json | 1 + pnpm-lock.yaml | 33 ++ src/Routes.tsx | 2 + src/components/sidebar/Sidebar.tsx | 1 + src/core/services/notificationService.ts | 51 ++ src/core/types/notifications.ts | 109 ++++ src/modules/notification/Routes.tsx | 15 + .../notification/components/ChannelButton.tsx | 22 + .../notification/pages/NotificationsPage.tsx | 136 +++++ .../notification/pages/TriggerCreatePage.tsx | 493 ++++++++++++++++++ .../notification/pages/TriggersPage.tsx | 165 ++++++ src/modules/settings/pages/SettingsPage.tsx | 8 +- vite.config.ts | 2 +- 13 files changed, 1036 insertions(+), 2 deletions(-) create mode 100644 src/core/services/notificationService.ts create mode 100644 src/core/types/notifications.ts create mode 100644 src/modules/notification/Routes.tsx create mode 100644 src/modules/notification/components/ChannelButton.tsx create mode 100644 src/modules/notification/pages/NotificationsPage.tsx create mode 100644 src/modules/notification/pages/TriggerCreatePage.tsx create mode 100644 src/modules/notification/pages/TriggersPage.tsx diff --git a/package.json b/package.json index e85fde8..36f5b4d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62c4b02..ba4f26b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@radix-ui/react-radio-group': specifier: ^1.2.3 version: 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.2.3 + version: 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: ^2.1.1 version: 2.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -849,6 +852,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.2.3': + resolution: {integrity: sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.1.5': resolution: {integrity: sha512-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA==} peerDependencies: @@ -2644,6 +2660,23 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-scroll-area@1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-select@2.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 diff --git a/src/Routes.tsx b/src/Routes.tsx index 76f4c9e..9846a2f 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -7,6 +7,7 @@ import OrganizationRoutes from "@/modules/organization/Routes.tsx"; import SettingsRoutes from "@/modules/settings/Routes.tsx"; import TeamRoutes from "@/modules/team/Routes.tsx"; import UserRoutes from "@/modules/user/Routes.tsx"; +import NotificationRoutes from "@/modules/notification/Routes.tsx"; export default function Root() { return ( @@ -19,6 +20,7 @@ export default function Root() { {SettingsRoutes()} {TeamRoutes()} {UserRoutes()} + {NotificationRoutes()} ) } \ No newline at end of file diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 931e066..2802559 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -5,6 +5,7 @@ import {UserContext} from "@/contexts/UserContext.tsx"; import SidebarAccountDropdown from "@/components/sidebar/SidebarAccountDropdown.tsx"; import Logo from "@/components/icon/Logo.tsx"; import {NotificationBell} from "@/components/notification/NotificationBell.tsx"; +import {UserRole} from "@/core/types/enum.ts"; const navigationItems = { main: [ diff --git a/src/core/services/notificationService.ts b/src/core/services/notificationService.ts new file mode 100644 index 0000000..f237bac --- /dev/null +++ b/src/core/services/notificationService.ts @@ -0,0 +1,51 @@ +import { PagedResponse } from '../types/common'; +import axiosInstance from './httpService'; +import { + EventSchema, + NotificationFilterRequest, + NotificationTemplate, + NotificationTrigger, + NotificationTriggerCreateRequest +} from '@/core/types/notifications.ts'; + +const baseURL = '/v1/notifications'; + +async function getTemplate(id: number): Promise { + const response = await axiosInstance.get(`${baseURL}/templates/${id}`); + return response.data; +} + +async function getTemplates(): Promise { + const response = await axiosInstance.get(`${baseURL}/templates`); + return response.data; +} + +async function deleteTemplate(id: number): Promise { + await axiosInstance.delete(`${baseURL}/templates/${id}`); +} + +async function getNotificationTriggers(): Promise { + const response = await axiosInstance.get(`${baseURL}/triggers`); + return response.data; +} + +async function createNotificationTrigger(payload: NotificationTriggerCreateRequest): Promise { + const response = await axiosInstance.post(`${baseURL}/triggers`, payload); + return response.data; +} + +async function getNotifications(payload: NotificationFilterRequest,page: number,size: number): Promise> { + const response = await axiosInstance.get(`${baseURL}`, { params: { ...payload, page, size } }); + return response.data; +} + +async function getNotificationEventSchemas() : Promise { + const response = await axiosInstance.get(`${baseURL}/events`); + return response.data; +} + +async function deleteNotificationTrigger(id:number) : Promise { + await axiosInstance.get(`${baseURL}/notifications/triggers/${id}`) +} + +export {getTemplate, getTemplates, deleteTemplate, getNotificationTriggers, createNotificationTrigger, getNotifications, getNotificationEventSchemas, deleteNotificationTrigger,}; diff --git a/src/core/types/notifications.ts b/src/core/types/notifications.ts new file mode 100644 index 0000000..b66069f --- /dev/null +++ b/src/core/types/notifications.ts @@ -0,0 +1,109 @@ +import { UserResponse } from "@/core/types/user.ts"; + +export enum NotificationChannel { + EMAIL = 'EMAIL', + SLACK = 'SLACK' +} + +export enum NotificationTriggerStatus { + ENABLED = 'ENABLED', + DISABLED = 'DISABLED', + ARCHIVED = 'ARCHIVED' +} + +export enum EventType { + ORGANIZATION_CREATED = 'ORGANIZATION_CREATED', + USER_CREATED = 'USER_CREATED', + LEAVE_CREATED = 'LEAVE_CREATED', + LEAVE_STATUS_UPDATED = 'LEAVE_STATUS_UPDATED', + TEAM_CREATED = 'TEAM_CREATED', + NOTIFICATION_CREATED = 'NOTIFICATION_CREATED' +} + +export interface NotificationTemplate { + id: number; + name: string; + textTemplate: string; + htmlTemplate: string; + status: string; +} + +export interface NotificationTrigger { + id: number; + eventType: EventType; + name:string; + title:string; + textTemplate:string; + htmlTemplate:string; + template: NotificationTemplate; + channels: NotificationChannel[]; + receptors: string; + status: NotificationTriggerStatus; +} + +export interface NotificationTriggerCreateRequest { + title: string; + name: string; + textTemplate: string; + htmlTemplate: string; + eventType: EventType; + templateId: number; + channels: NotificationChannel[]; + receptors: string; +} + +export interface NotificationFilterRequest { + eventType?: EventType; + channel?: NotificationChannel; + startDate?: string; + endDate?: string; +} + +export interface Notification { + id: number; + user: UserResponse; + template: NotificationTemplate; + trigger: NotificationTrigger; + textContent: string; + htmlContent: string; + event: EventType; + params: Record; + channel: NotificationChannel; + sentAt: string; + createdAt: string; + status: NotificationStatus; +} + +export enum NotificationStatus { + PENDING = 'PENDING', + SENT = 'SENT', + FAILED = 'FAILED' +} + +export interface EventSchema { + name: string; + description: string; + schema: SchemaObject; + receptors: string[]; +} + +export interface SchemaObject { + type: string; + properties?: FieldSchema[]; +} + +export interface FieldSchema { + name: string; + type: string; + required: boolean; + description?: string; + enumValues?: string[]; + properties?: FieldSchema[]; + items?: ItemSchema; +} + +export interface ItemSchema { + type: string; + enumValues?: string[]; + properties?: FieldSchema[]; +} \ No newline at end of file diff --git a/src/modules/notification/Routes.tsx b/src/modules/notification/Routes.tsx new file mode 100644 index 0000000..26564e4 --- /dev/null +++ b/src/modules/notification/Routes.tsx @@ -0,0 +1,15 @@ +import { Route } from "react-router-dom"; +import { TriggersPage } from "./pages/TriggersPage"; +import AuthenticatedRoute from "../auth/components/AuthenticatedRoute"; +import NotificationsPage from "./pages/NotificationsPage"; +import TriggerCreatePage from "./pages/TriggerCreatePage"; +import DashboardLayout from "@/components/layout/DashboardLayout.tsx"; +export default function NotificationRoutes() { + return ( + }> + }> + }> + }> + + ); +} \ No newline at end of file diff --git a/src/modules/notification/components/ChannelButton.tsx b/src/modules/notification/components/ChannelButton.tsx new file mode 100644 index 0000000..a29592e --- /dev/null +++ b/src/modules/notification/components/ChannelButton.tsx @@ -0,0 +1,22 @@ +import {NotificationChannel} from "@/core/types/notifications.ts"; +import {Button} from "@/components/ui/button.tsx"; +import React from "react"; + +type ChannelButtonProps = { + channel: NotificationChannel; + isSelected: boolean; + onToggle: (channel: NotificationChannel) => void; +}; + +export const ChannelButton = ({channel, isSelected, onToggle}: ChannelButtonProps) => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/modules/notification/pages/NotificationsPage.tsx b/src/modules/notification/pages/NotificationsPage.tsx new file mode 100644 index 0000000..eedfcf7 --- /dev/null +++ b/src/modules/notification/pages/NotificationsPage.tsx @@ -0,0 +1,136 @@ +import React, {useEffect, useState} from 'react'; +import {Card} from '@/components/ui/card'; +import {Badge} from '@/components/ui/badge'; +import {getNotifications} from '@/core/services/notificationService'; +import PageHeader from '@/components/layout/PageHeader'; +import PageContent from '@/components/layout/PageContent'; +import {formatDate} from 'date-fns'; +import {ScrollArea} from '@/components/ui/scroll-area'; +import {Bell, CheckCircle2, XCircle, Clock, AlertCircle, FlaskConical} from 'lucide-react'; +import {Button} from '@/components/ui/button'; +import {useNavigate} from 'react-router-dom'; +import {Notification, NotificationStatus} from "@/core/types/notifications.ts"; +import {getErrorMessage} from "@/core/utils/errorHandler.ts"; +import {toast} from "@/components/ui/use-toast.ts"; +import {Alert, AlertDescription} from "@/components/ui/alert.tsx"; + +export default function NotificationsPage() { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + fetchNotifications(); + }, []); + + const fetchNotifications = async () => { + try { + setLoading(true); + const response = await getNotifications({}, 1, 10); + setNotifications(response.contents); + } catch (error) { + const errorMessage = getErrorMessage(error as Error); + setErrorMessage(errorMessage); + toast({ + title: "Error", + description: errorMessage, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const getStatusIcon = (status: NotificationStatus) => { + switch (status) { + case NotificationStatus.SENT: + return ; + case NotificationStatus.FAILED: + return ; + case NotificationStatus.PENDING: + return ; + default: + return ; + } + }; + + const getStatusColor = (status: NotificationStatus) => { + switch (status) { + case NotificationStatus.SENT: + return 'bg-green-50 text-green-700 border-green-200'; + case NotificationStatus.FAILED: + return 'bg-red-50 text-red-700 border-red-200'; + case NotificationStatus.PENDING: + return 'bg-yellow-50 text-yellow-700 border-yellow-200'; + default: + return 'bg-gray-50 text-gray-700 border-gray-200'; + } + }; + + if (loading) { + return ( +
+
+
+ +

Loading notifications...

+
+
+
+ ); + } + + return ( + <> + + + + + {errorMessage ? ( + + {errorMessage} + + ) : ( + + +
+ {notifications.map((notification) => ( +
+
+
{getStatusIcon(notification.status)}
+
+
+

{notification.trigger.name}

+ {notification.channel} + {notification.status} +
+

{notification.textContent}

+
+ Event: {notification.event} + + Sent: {formatDate(new Date(notification.sentAt), 'MMM dd, yyyy HH:mm')} +
+
+
+
+ ))} + + {notifications.length === 0 && ( +
+ +

No notifications yet

+

When notifications are sent, they will appear here.

+
+ )} +
+
+
+ )} +
+ + ); +} diff --git a/src/modules/notification/pages/TriggerCreatePage.tsx b/src/modules/notification/pages/TriggerCreatePage.tsx new file mode 100644 index 0000000..f4eab88 --- /dev/null +++ b/src/modules/notification/pages/TriggerCreatePage.tsx @@ -0,0 +1,493 @@ +import React, {useState, useEffect} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {Card, CardContent} from '@/components/ui/card'; +import {Button} from '@/components/ui/button'; +import {toast} from '@/components/ui/use-toast'; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select'; +import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form'; +import {Input} from '@/components/ui/input'; +import {useForm} from 'react-hook-form'; +import {zodResolver} from '@hookform/resolvers/zod'; +import {z} from 'zod'; +import {X, Save, Info, Copy, ChevronRight} from 'lucide-react'; +import PageHeader from '@/components/layout/PageHeader'; +import PageContent from '@/components/layout/PageContent'; +import {EventSchema, EventType, FieldSchema, NotificationChannel, SchemaObject} from '@/core/types/notifications.ts'; +import {createNotificationTrigger, getNotificationEventSchemas} from '@/core/services/notificationService'; +import {getErrorMessage} from '@/core/utils/errorHandler'; +import {Badge} from '@/components/ui/badge'; +import {Separator} from '@/components/ui/separator'; +import {Loader2} from 'lucide-react'; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip'; +import {Textarea} from '@/components/ui/textarea'; +import {Tabs, TabsTrigger, TabsList} from '@/components/ui/tabs'; +import {ScrollArea} from "@/components/ui/scroll-area"; +import {PageSection} from "@/components/layout/PageSection.tsx"; +import {ChannelButton} from "@/modules/notification/components/ChannelButton.tsx"; + +const FormSchema = z.object({ + title: z.string().min(1, 'Title is required'), + name: z.string().min(1, 'Name is required'), + textTemplate: z.string().min(1, 'Text template is required'), + htmlTemplate: z.string().min(1, 'HTML template is required'), + eventType: z.nativeEnum(EventType), + templateId: z.number(), + channels: z.array(z.nativeEnum(NotificationChannel)).min(1, 'Select at least one channel'), + receptors: z.string().min(1, 'Receptors is required'), +}); + +type TriggerCreateInputs = z.infer; + +export default function TriggerCreatePage() { + const navigate = useNavigate(); + const [isProcessing, setIsProcessing] = useState(false); + const [selectedChannels, setSelectedChannels] = useState([]); + const [activeTab, setActiveTab] = useState<'text' | 'html'>('text'); + const [eventSchemas, setEventSchemas] = useState([]); + const [selectedEventSchema, setSelectedEventSchema] = useState(null); + + useEffect(() => { + const fetchEventSchemas = async () => { + try { + const schemas = await getNotificationEventSchemas(); + setEventSchemas(schemas); + } catch (error) { + toast({ + title: "Error", + description: "Failed to fetch event schemas", + variant: "destructive", + }); + } + }; + fetchEventSchemas(); + }, []); + + const getVariablesFromSchema = (schema: SchemaObject): { [key: string]: any[] } => { + const variables: { + [key: string]: { + name: string; + description: string; + type: string; + required: boolean; + enumValues?: string[]; + }[] + } = {}; + + const processField = (field: FieldSchema, parentPath = '') => { + const path = parentPath ? `${parentPath}.${field.name}` : field.name; + const category = path.split('.')[0]; + + if (!variables[category]) { + variables[category] = []; + } + + let fieldType = field.type; + if (field.enumValues?.length) { + fieldType = `enum(${field.enumValues.join('|')})`; + } else if (field.items) { + fieldType = `array<${field.items.type}>`; + } + + variables[category].push({ + name: `{{${path}}}`, + description: field.description || `${field.type} field`, + type: fieldType, + required: field.required, + enumValues: field.enumValues + }); + + if (field.properties) { + field.properties.forEach(prop => processField(prop, path)); + } + }; + + schema.properties.forEach(field => processField(field)); + return variables; + }; + + const availableVariables = selectedEventSchema + ? getVariablesFromSchema(selectedEventSchema.schema) + : {}; + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + channels: [], + receptors: '', + title: '', + name: '', + textTemplate: '', + htmlTemplate: '', + eventType: EventType.USER_CREATED, + templateId: 0, + }, + }); + + const onSubmit = async (data: TriggerCreateInputs) => { + try { + setIsProcessing(true); + await createNotificationTrigger({ + title: data.title || "", + name: data.name || "", + textTemplate: data.textTemplate || "", + htmlTemplate: data.htmlTemplate || "", + eventType: data.eventType, + templateId: data.templateId || 0, + channels: data.channels || [], + receptors: data.receptors || "", + }); + + toast({ + title: 'Success', + description: 'Notification trigger created successfully!', + }); + navigate('/notifications/triggers'); + } catch (error) { + toast({ + title: 'Error', + description: getErrorMessage(error as Error), + variant: 'destructive', + }); + } finally { + setIsProcessing(false); + } + }; + + return ( + <> + + + + + +
+ +
+ {/* Basic Information Section */} +
+
+

Basic Information

+ + + + + + +

Basic details about the notification trigger

+
+
+
+
+ +
+ ( + + Title + + + + + + )} + /> + ( + + Name + + + + + + )} + /> +
+
+ + {/* Event Configuration Section */} +
+
+

Event Details

+ + + + + + +

Select when this notification should be triggered

+
+
+
+
+ +
+ ( + + Event Type + + + {selectedEventSchema && (

{selectedEventSchema.description}

)} +
+ )} + /> +
+
+ + + {/* Template Content Section */} +
+
+
+

Template Content

+ + + + + + +

Define the content for different formats

+
+
+
+
+
+ + +
+
+ setActiveTab(value as 'text' | 'html')}> + + Text Template + HTML Template + +
+ {activeTab === 'text' && ( + ( + + +