- {/* Mention Suggestions */}
- {showMentionSuggestions && filteredParticipants.length > 0 && (
-
- {filteredParticipants.map((participant) => (
-
- ))}
+
+
+ {/* Attachment Previews */}
+ {attachedFiles.length > 0 && (
+
+ {attachedFiles.map(file => {
+ const isPreviewable = file.mimeType.startsWith('image/') || file.mimeType === 'application/pdf';
+ return (
+
+
+ {getFileIcon(file.mimeType, file.filePath)}
+
+
+
isPreviewable && setPreviewFile(file)}
+ >
+ {file.fileName}
+
+
+
+
+ );
+ })}
)}
- {/* Input Field */}
-
-
-
-
-
-
+
+ {/* Mention Suggestions moved inside relative container */}
+ {showMentionSuggestions && filteredParticipants.length > 0 && (
+
+ {filteredParticipants.map((participant) => (
+
+ ))}
+
+ )}
+
+
+
+
+
fileInputRef.current?.click()}
+ >
+
+
+
-
+ {isUploading ? (
+
+ ) : (
+
+ )}
-
- Press Enter to send โข Use @ to mention someone
+
+ Press Enter to send โข Use @ to mention someone โข {isUploading ? 'Uploading files...' : 'Files attached appear above'}
+
+ {/* Preview Modal */}
+
);
}
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index 7611b2a..635aee5 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -1,3 +1,4 @@
+import { useState, useEffect } from 'react';
import { Bell, RefreshCw, HelpCircle, User as UserIcon } from 'lucide-react';
import { Button } from '../ui/button';
import {
@@ -7,9 +8,13 @@ import {
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Badge } from '../ui/badge';
+import { toast } from 'sonner';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
+import { notificationService, Notification } from '../../services/notification.service';
+import { useSocket } from '../../context/SocketContext';
+import { formatDistanceToNow } from 'date-fns';
interface HeaderProps {
title: string;
@@ -18,28 +23,66 @@ interface HeaderProps {
export function Header({ title, onRefresh }: HeaderProps) {
const { user: currentUser } = useSelector((state: RootState) => state.auth);
- const notifications = [
- {
- id: '1',
- message: 'New application assigned: APP-006',
- time: '5 min ago',
- unread: true
- },
- {
- id: '2',
- message: 'Interview scheduled for APP-001',
- time: '1 hour ago',
- unread: true
- },
- {
- id: '3',
- message: 'Document verified for APP-004',
- time: '2 hours ago',
- unread: false
- }
- ];
+ const { socket } = useSocket();
+ const [notifications, setNotifications] = useState
([]);
- const unreadCount = notifications.filter(n => n.unread).length;
+ useEffect(() => {
+ const fetchNotifications = async () => {
+ try {
+ const res: any = await notificationService.getNotifications();
+ if (res.success) {
+ setNotifications(res.data);
+ }
+ } catch (error) {
+ console.error('Fetch notifications error:', error);
+ }
+ };
+
+ fetchNotifications();
+ }, []);
+
+ useEffect(() => {
+ if (socket) {
+ socket.on('notification', (newNotification: Notification) => {
+ setNotifications(prev => [newNotification, ...prev]);
+ toast(newNotification.title, {
+ description: newNotification.message,
+ action: newNotification.link ? {
+ label: 'View',
+ onClick: () => window.location.href = newNotification.link!
+ } : undefined
+ });
+ });
+
+ return () => {
+ socket.off('notification');
+ };
+ }
+ }, [socket]);
+
+ const handleMarkAsRead = async (id: string) => {
+ try {
+ const res: any = await notificationService.markAsRead(id);
+ if (res.success) {
+ setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
+ }
+ } catch (error) {
+ console.error('Mark as read error:', error);
+ }
+ };
+
+ const handleMarkAllAsRead = async () => {
+ try {
+ const res: any = await notificationService.markAllAsRead();
+ if (res.success) {
+ setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
+ }
+ } catch (error) {
+ console.error('Mark all as read error:', error);
+ }
+ };
+
+ const unreadCount = notifications.filter(n => !n.isRead).length;
return (
@@ -96,30 +139,48 @@ export function Header({ title, onRefresh }: HeaderProps) {
-
-
- {notifications.map((notification) => (
-
+ Notifications
+ {unreadCount > 0 && (
+
-
-
{notification.message}
-
{notification.time}
-
- {notification.unread && (
-
- )}
-
- ))}
+ Mark all read
+
+ )}
+
+
+ {notifications.length === 0 ? (
+
+ No notifications yet
+
+ ) : (
+ notifications.map((notification) => (
+
handleMarkAsRead(notification.id)}
+ >
+
+
{notification.title}
+
{notification.message}
+
+ {formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
+
+
+ {!notification.isRead && (
+
+ )}
+
+ ))
+ )}
-
- View All Notifications
-
+
+ Stay updated with your mentions and tasks
+
diff --git a/src/context/SocketContext.tsx b/src/context/SocketContext.tsx
new file mode 100644
index 0000000..f4ad61b
--- /dev/null
+++ b/src/context/SocketContext.tsx
@@ -0,0 +1,70 @@
+import React, { createContext, useContext, useEffect, useState } from 'react';
+import { io, Socket } from 'socket.io-client';
+import { useSelector } from 'react-redux';
+import { RootState } from '../store';
+
+interface SocketContextType {
+ socket: Socket | null;
+ isConnected: boolean;
+}
+
+const SocketContext = createContext({
+ socket: null,
+ isConnected: false
+});
+
+export const useSocket = () => useContext(SocketContext);
+
+export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [socket, setSocket] = useState(null);
+ const [isConnected, setIsConnected] = useState(false);
+
+ const { user: currentUser } = useSelector((state: RootState) => state.auth);
+
+ useEffect(() => {
+ let socketUrl = (import.meta as any).env.VITE_API_URL || 'http://localhost:5000';
+
+ // If URL ends with /api, strip it as Socket.io usually connects to the root
+ if (socketUrl.endsWith('/api')) {
+ socketUrl = socketUrl.replace(/\/api$/, '');
+ }
+
+ const newSocket = io(socketUrl, {
+ withCredentials: true
+ });
+
+ newSocket.on('connect', () => {
+ console.log('Socket connected:', newSocket.id);
+ setIsConnected(true);
+ });
+
+ newSocket.on('disconnect', () => {
+ console.log('Socket disconnected');
+ setIsConnected(false);
+ });
+
+ setSocket(newSocket);
+
+ return () => {
+ newSocket.close();
+ };
+ }, []);
+
+ // Join personal room for notifications
+ useEffect(() => {
+ if (socket && isConnected && currentUser?.id) {
+ socket.emit('join_room', `user_${currentUser.id}`);
+ console.log(`Joined private notification room: user_${currentUser.id}`);
+
+ return () => {
+ socket.emit('leave_room', `user_${currentUser.id}`);
+ };
+ }
+ }, [socket, isConnected, currentUser?.id]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/services/audit.service.ts b/src/services/audit.service.ts
new file mode 100644
index 0000000..337205e
--- /dev/null
+++ b/src/services/audit.service.ts
@@ -0,0 +1,35 @@
+import { API } from '../api/API';
+
+export const auditService = {
+ /**
+ * Get audit logs for a specific entity (e.g., application)
+ * @param entityType - The type of entity (e.g., 'application', 'resignation', 'dealer')
+ * @param entityId - The UUID of the entity
+ * @param page - Page number for pagination (default: 1)
+ * @param limit - Number of records per page (default: 50)
+ */
+ getAuditLogs: async (entityType: string, entityId: string, page: number = 1, limit: number = 50) => {
+ try {
+ const response: any = await API.getAuditLogs(entityType, entityId, page, limit);
+ return response.data?.data || response.data || [];
+ } catch (error) {
+ console.error('Get audit logs error:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Get audit summary/stats for a specific entity
+ * @param entityType - The type of entity
+ * @param entityId - The UUID of the entity
+ */
+ getAuditSummary: async (entityType: string, entityId: string) => {
+ try {
+ const response: any = await API.getAuditSummary(entityType, entityId);
+ return response.data?.data || response.data;
+ } catch (error) {
+ console.error('Get audit summary error:', error);
+ throw error;
+ }
+ }
+};
diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts
new file mode 100644
index 0000000..5a5b546
--- /dev/null
+++ b/src/services/notification.service.ts
@@ -0,0 +1,38 @@
+import client from '../api/client';
+
+const API_BASE = '/communication';
+
+export interface Notification {
+ id: string;
+ userId: string;
+ title: string;
+ message: string;
+ type: string;
+ link: string | null;
+ isRead: boolean;
+ createdAt: string;
+}
+
+export const notificationService = {
+ getNotifications: async () => {
+ const response = await client.get(`${API_BASE}/notifications`);
+ return response.data;
+ },
+
+ markAsRead: async (id: string) => {
+ const response = await client.patch(`${API_BASE}/notifications/${id}/read`);
+ return response.data;
+ },
+
+ markAllAsRead: async () => {
+ const response = await client.patch(`${API_BASE}/notifications/read-all`);
+ return response.data;
+ },
+
+ updatePushSubscription: async (subscription: any) => {
+ const response = await client.post(`${API_BASE}/notifications/subscribe`, { subscription });
+ return response.data;
+ }
+};
+
+export default notificationService;
diff --git a/src/services/worknote.service.ts b/src/services/worknote.service.ts
new file mode 100644
index 0000000..9566ad6
--- /dev/null
+++ b/src/services/worknote.service.ts
@@ -0,0 +1,36 @@
+import client from '../api/client';
+
+const API_BASE = '/collaboration';
+
+export const worknoteService = {
+ getWorknotes: async (requestId: string, requestType: string) => {
+ const response = await client.get(`${API_BASE}/worknotes`, { requestId, requestType });
+ return response.data;
+ },
+
+ addWorknote: async (data: {
+ requestId: string;
+ requestType: string;
+ noteText: string;
+ noteType?: string;
+ tags?: string[];
+ attachmentDocIds?: string[];
+ }) => {
+ const response = await client.post(`${API_BASE}/worknotes`, data);
+ return response.data;
+ },
+
+ uploadAttachment: async (file: File, requestId?: string, requestType?: string) => {
+ const formData = new FormData();
+ formData.append('file', file);
+ if (requestId) formData.append('requestId', requestId);
+ if (requestType) formData.append('requestType', requestType);
+
+ const response = await client.post(`${API_BASE}/upload`, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ });
+ return response.data;
+ }
+};
+
+export default worknoteService;
diff --git a/src/styles/globals.css b/src/styles/globals.css
index eab153b..8a54046 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -187,4 +187,26 @@
html {
font-size: var(--font-size);
+}
+
+.custom-scrollbar::-webkit-scrollbar {
+ width: 5px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: #e2e8f0;
+ border-radius: 10px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: #cbd5e1;
+}
+
+.custom-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: #e2e8f0 transparent;
}
\ No newline at end of file