enhanced the worknote section implemented websocket and in-app notifucation also aaudit tril api integrated

This commit is contained in:
laxmanhalaki 2026-02-23 19:09:47 +05:30
parent 3208a1ea7f
commit d919e925c8
13 changed files with 1165 additions and 321 deletions

88
package-lock.json generated
View File

@ -53,6 +53,7 @@
"react-resizable-panels": "^2.0.12",
"react-router-dom": "^6.22.3",
"recharts": "^2.12.2",
"socket.io-client": "^4.8.3",
"sonner": "^1.4.3",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
@ -2986,6 +2987,12 @@
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -4227,7 +4234,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -4338,6 +4344,28 @@
"embla-carousel": "8.6.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
@ -5575,7 +5603,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -6316,6 +6343,34 @@
"node": ">=8"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/sonner": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
@ -6815,6 +6870,35 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -55,6 +55,7 @@
"react-resizable-panels": "^2.0.12",
"react-router-dom": "^6.22.3",
"recharts": "^2.12.2",
"socket.io-client": "^4.8.3",
"sonner": "^1.4.3",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",

View File

@ -35,16 +35,17 @@ import { ConstitutionalChangePage } from './components/applications/Constitution
import { ConstitutionalChangeDetails } from './components/applications/ConstitutionalChangeDetails';
import { RelocationRequestPage } from './components/applications/RelocationRequestPage';
import { RelocationRequestDetails } from './components/applications/RelocationRequestDetails';
import { WorknotePage } from './components/applications/WorknotePage';
import { DealerResignationPage } from './components/dealer/DealerResignationPage';
import { DealerConstitutionalChangePage } from './components/dealer/DealerConstitutionalChangePage';
import { DealerRelocationPage } from './components/dealer/DealerRelocationPage';
import QuestionnaireBuilder from './components/admin/QuestionnaireBuilder';
import QuestionnaireList from './components/admin/QuestionnaireList';
import { WorkNotesPage } from './components/applications/WorkNotesPage';
import { Toaster } from './components/ui/sonner';
import { User } from './lib/mock-data';
import { toast } from 'sonner';
import { API } from './api/API';
import { SocketProvider } from './context/SocketContext';
// Layout Component
const AppLayout = ({ onLogout, title }: { onLogout: () => void, title: string }) => {
@ -174,6 +175,7 @@ export default function App() {
// Protected Routes
return (
<SocketProvider>
<Routes>
{/* Prospective Dealer Route - STRICTLY ISOLATED */}
<Route
@ -204,7 +206,15 @@ export default function App() {
{/* Applications */}
<Route path="/applications" element={<ApplicationsPage onViewDetails={(id) => navigate(`/applications/${id}`)} initialFilter="all" />} />
<Route path="/applications/:id" element={<ApplicationDetails applicationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/applications')} />} />
<Route path="/applications/:id" element={<ApplicationDetails />} />
<Route path="/applications/:id/worknotes" element={
<WorkNotesPage
applicationId={window.location.pathname.split('/')[2]}
applicationName=""
registrationNumber=""
onBack={() => window.history.back()}
/>
} />
<Route path="/all-applications" element={
currentUser?.role === 'DD' ? <AllApplicationsPage onViewDetails={(id) => navigate(`/applications/${id}`)} initialFilter="all" /> : <Navigate to="/dashboard" />
@ -288,5 +298,6 @@ export default function App() {
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Routes>
</SocketProvider>
);
}

View File

@ -77,6 +77,12 @@ export const API = {
deleteEmailTemplate: (id: string) => client.delete(`/admin/email-templates/${id}`),
previewEmailTemplate: (data: any) => client.post('/admin/email-templates/preview', data),
// Audit Trail
getAuditLogs: (entityType: string, entityId: string, page: number = 1, limit: number = 50) =>
client.get('/audit/logs', { entityType, entityId, page, limit }),
getAuditSummary: (entityType: string, entityId: string) =>
client.get('/audit/summary', { entityType, entityId }),
// Prospective Login
sendOtp: (phone: string) => client.post('/prospective-login/send-otp', { phone }),
verifyOtp: (phone: string, otp: string) => client.post('/prospective-login/verify-otp', { phone, otp }),

View File

@ -104,10 +104,22 @@ const QuestionnaireBuilder: React.FC = () => {
const updateQuestion = (index: number, field: keyof Question, value: any) => {
const newQuestions = [...questions];
// Weight validation and option score capping
if (field === 'weight') {
const newWeight = parseFloat(value) || 0;
// Cap existing option scores if weight is decreased
if (newQuestions[index].options) {
newQuestions[index].options = newQuestions[index].options!.map(opt => ({
...opt,
score: Math.min(opt.score, newWeight)
}));
}
}
// Auto-populate Yes/No options if switching to yesno
if (field === 'inputType' && value === 'yesno' && (!newQuestions[index].options || newQuestions[index].options?.length === 0)) {
newQuestions[index].options = [
{ text: 'Yes', score: 5 },
{ text: 'Yes', score: newQuestions[index].weight || 5 },
{ text: 'No', score: 0 }
];
} else if (field === 'inputType' && value === 'select' && (!newQuestions[index].options)) {
@ -128,9 +140,15 @@ const QuestionnaireBuilder: React.FC = () => {
const updateOption = (questionIndex: number, optionIndex: number, field: 'text' | 'score', value: any) => {
const newQuestions = [...questions];
if (newQuestions[questionIndex].options) {
let finalValue = value;
if (field === 'score') {
const maxWeight = newQuestions[questionIndex].weight || 0;
finalValue = Math.min(parseFloat(value) || 0, maxWeight);
}
newQuestions[questionIndex].options![optionIndex] = {
...newQuestions[questionIndex].options![optionIndex],
[field]: value
[field]: finalValue
};
setQuestions(newQuestions);
}
@ -150,6 +168,18 @@ const QuestionnaireBuilder: React.FC = () => {
return;
}
// Validate option scores vs weight
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (q.inputType === 'select' || q.inputType === 'yesno') {
const invalidOption = q.options?.find(opt => opt.score > q.weight);
if (invalidOption) {
toast.error(`Question ${i + 1}: Option "${invalidOption.text}" score (${invalidOption.score}) exceeds question weightage (${q.weight})`);
return;
}
}
}
if (totalWeight !== 100) {
toast.error(`Total weightage must be exactly 100. Current total: ${totalWeight}`);
return;
@ -320,8 +350,10 @@ const QuestionnaireBuilder: React.FC = () => {
<input
type="number"
value={opt.score}
onChange={(e) => updateOption(index, optIndex, 'score', parseFloat(e.target.value))}
className="w-20 border border-slate-300 p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none"
max={q.weight}
min={0}
onChange={(e) => updateOption(index, optIndex, 'score', e.target.value)}
className={`w-20 border ${opt.score > q.weight ? 'border-red-500 text-red-600' : 'border-slate-300'} p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none`}
/>
</div>
<button

View File

@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { mockAuditLogs, mockDocuments, mockWorkNotes, Application, ApplicationStatus } from '../../lib/mock-data';
import { mockDocuments, mockWorkNotes, Application, ApplicationStatus } from '../../lib/mock-data';
import { onboardingService } from '../../services/onboarding.service';
import { auditService } from '../../services/audit.service';
import { WorkNotesPage } from './WorkNotesPage';
import QuestionnaireResponseView from './QuestionnaireResponseView';
import { useSelector } from 'react-redux';
@ -320,6 +321,29 @@ export function ApplicationDetails() {
fetchApplication();
}
}, [applicationId]);
// Audit Trail State
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [auditLoading, setAuditLoading] = useState(false);
// Fetch audit logs when application loads
useEffect(() => {
if (applicationId) {
const fetchAuditLogs = async () => {
setAuditLoading(true);
try {
const logs = await auditService.getAuditLogs('application', applicationId);
setAuditLogs(Array.isArray(logs) ? logs : []);
} catch (error) {
console.error('Failed to fetch audit logs', error);
setAuditLogs([]);
} finally {
setAuditLoading(false);
}
};
fetchAuditLogs();
}
}, [applicationId]);
const [activeTab, setActiveTab] = useState('questionnaire');
const [showApproveModal, setShowApproveModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
@ -1172,19 +1196,7 @@ export function ApplicationDetails() {
// Final visibility flags
const shouldShowApproveReject = !hasMadeDecisionForUser && hasSubmittedFeedbackForActive;
// If Work Notes page is open, show that instead
if (showWorkNotesPage) {
return (
<WorkNotesPage
applicationId={applicationId}
applicationName={application.name}
registrationNumber={application.registrationNumber}
onBack={() => setShowWorkNotesPage(false)}
initialNotes={mockWorkNotes}
participants={application.participants}
/>
);
}
@ -1843,20 +1855,39 @@ export function ApplicationDetails() {
<TabsContent value="audit">
<ScrollArea className="h-96">
<div className="space-y-4">
{mockAuditLogs.map((log) => (
{auditLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600"></div>
<span className="ml-2 text-slate-500">Loading audit trail...</span>
</div>
) : auditLogs.length === 0 ? (
<div className="text-center py-8 text-slate-500">
No audit logs recorded yet for this application.
</div>
) : (
auditLogs.map((log: any) => (
<div key={log.id} className="flex gap-4 p-3 hover:bg-slate-50 rounded-lg">
<div className="w-2 h-2 bg-amber-600 rounded-full mt-2 flex-shrink-0"></div>
<div className="flex-1">
<div className="flex items-start justify-between">
<p className="text-slate-900">{log.action}</p>
<span className="text-slate-500">{log.timestamp}</span>
</div>
<p className="text-slate-600 mt-1">by {log.user}</p>
<p className="text-slate-500 mt-1">{log.details}</p>
</div>
<p className="text-slate-900 font-medium">{log.description || log.action}</p>
<span className="text-slate-500 text-sm whitespace-nowrap ml-4">
{new Date(log.timestamp).toLocaleString()}
</span>
</div>
<p className="text-slate-600 mt-1">by {log.userName || 'System'}</p>
{log.changes && log.changes.length > 0 && (
<div className="mt-1 space-y-0.5">
{log.changes.map((change: string, idx: number) => (
<p key={idx} className="text-slate-500 text-sm">{change}</p>
))}
</div>
)}
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</CardContent>
@ -1948,7 +1979,13 @@ export function ApplicationDetails() {
<Button
variant="outline"
className="w-full"
onClick={() => setShowWorkNotesPage(true)}
onClick={() => navigate(`/applications/${application.id}/worknotes`, {
state: {
applicationName: application.name,
registrationNumber: application.registrationNumber,
participants: application.participants
}
})}
>
<MessageSquare className="w-4 h-4 mr-2" />
Work Note
@ -2225,6 +2262,8 @@ export function ApplicationDetails() {
</DialogContent>
</Dialog >
{/* Schedule Interview Modal */}
< Dialog open={showScheduleModal} onOpenChange={setShowScheduleModal} >
<DialogContent>

View File

@ -1,50 +1,131 @@
import { useState, useRef, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
import { Avatar, AvatarFallback } from '../ui/avatar';
import { Badge } from '../ui/badge';
import { toast } from 'sonner';
import {
ArrowLeft,
Send,
Paperclip,
Smile,
Image as ImageIcon,
MessageSquare
MessageSquare,
FileText,
File as FileIcon,
X
} from 'lucide-react';
import { WorkNote, Participant } from '../../lib/mock-data';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { worknoteService } from '../../services/worknote.service';
import { onboardingService } from '../../services/onboarding.service';
import { useSocket } from '../../context/SocketContext';
interface Attachment {
id: string;
fileName: string;
filePath: string;
mimeType: string;
}
interface WorkNote {
id: string;
noteText: string;
noteType: string;
createdAt: string;
userId?: string;
author: {
name: string;
email: string;
role: string;
};
attachments?: Attachment[];
}
// Participant interface for mentions
interface WorkNotesPageProps {
applicationId: string;
applicationName: string;
registrationNumber: string;
onBack: () => void;
initialNotes?: WorkNote[];
participants?: Participant[];
initialNotes?: any[];
participants?: any[];
}
// This interface defines the structure for participants displayed in the UI
interface ParticipantUI {
id: string;
name: string;
email: string;
initials: string;
color: string;
}
export function WorkNotesPage({
applicationId,
applicationName,
registrationNumber,
onBack,
initialNotes = [],
participants: externalParticipants = []
}: WorkNotesPageProps) {
const [notes, setNotes] = useState<WorkNote[]>(initialNotes);
const BACKEND_URL = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
const { user: currentUser } = useSelector((state: RootState) => state.auth);
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
// Use props if provided (modal mode), otherwise use URL and state
const applicationId = props.applicationId || id || '';
const applicationName = props.applicationName || location.state?.applicationName || 'Application';
const registrationNumber = props.registrationNumber || location.state?.registrationNumber || '';
const onBack = props.onBack || (() => navigate(-1));
const externalParticipantsInit = props.participants || location.state?.participants || [];
const [externalParticipants, setExternalParticipants] = useState<any[]>(externalParticipantsInit);
const [notes, setNotes] = useState<WorkNote[]>([]);
const [message, setMessage] = useState('');
const [showMentionSuggestions, setShowMentionSuggestions] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [cursorPosition, setCursorPosition] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const [attachedFiles, setAttachedFiles] = useState<Attachment[]>([]);
const [isUploading, setIsUploading] = useState(false);
const { socket } = useSocket();
const inputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [previewFile, setPreviewFile] = useState<Attachment | null>(null);
const getFileIcon = (mimeType: string, filePath?: string) => {
if (mimeType.startsWith('image/') && filePath) {
return (
<img
src={`${BACKEND_URL}/${filePath.replace(/\\/g, '/')}`}
className="w-full h-full object-cover rounded"
alt="Thumbnail"
/>
);
}
if (mimeType.startsWith('image/')) return <ImageIcon className="w-5 h-5 text-blue-500" />;
if (mimeType === 'application/pdf') return <FileText className="w-5 h-5 text-red-500" />;
return <FileIcon className="w-5 h-5 text-slate-500" />;
};
const COMMON_EMOJIS = [
'😊', '😂', '🤣', '❤️', '👍', '🙏', '🔥', '✨',
'😍', '🥰', '😎', '🤔', '😅', '🙌', '👏', '🎉',
'✅', '❌', '📌', '📎', '📍', '💡', '🔔', '📢',
'⭐', '🌟', '💪', '🚀', '👀', '💯', '🌈', '☀️',
'😢', '😭', '😞', '😔', '😟', '😕', '😠', '😡',
'🤬', '😤', '😲', '🙄', '🤨', '😓', '😩', '😫',
'🤐', '😴', '🤢', '🤮', '😱', '🤡', '💀', '👻',
'🤝', '👋', '✌️', '👌', '✋', '🍎', '🍕', '☕',
'💻', '📱', '⌚', '📁', '📄', '📅', '🔒', '🔑',
'🛠️', '⚙️', '💬', '💭', '🌊', '🍀', '✈️', '🏠'
];
const getInitials = (name: string) => {
return name
@ -70,23 +151,102 @@ export function WorkNotesPage({
};
// Map backend participants to the UI sub-format
const participantsList: ParticipantUI[] = externalParticipants.map(p => ({
name: p.user?.name || 'Unknown User',
initials: getInitials(p.user?.name || 'U'),
color: getAvatarColor(p.user?.name || 'U')
}));
// Handles both nested structure { user: { id, fullName } } and flat { id, userId, name/fullName }
// NOTE: p.id is the RequestParticipant record ID. p.userId or p.user?.id is the actual User ID.
const participantsList: ParticipantUI[] = externalParticipants.map((p: any) => {
const id = p.user?.id || p.userId || p.id || '';
const name = p.user?.fullName || p.user?.name || p.fullName || p.name || 'Unknown User';
const email = p.user?.email || p.email || '';
return {
id,
name,
email,
initials: getInitials(name),
color: getAvatarColor(name)
};
});
// Fallback to current user if no participants
if (participantsList.length === 0) {
participantsList.push({ name: 'System User', initials: 'SU', color: 'bg-slate-600' });
}
console.log('Participants list for mentions:', participantsList.map(p => ({ id: p.id, name: p.name })));
// Scroll to bottom when new messages arrive
// Fetch Notes on load and join socket room
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
const fetchNotes = async () => {
try {
const res: any = await worknoteService.getWorknotes(applicationId, 'application');
if (res.success) {
setNotes(res.data.map((n: any) => ({
id: n.id,
noteText: n.noteText,
noteType: n.noteType,
createdAt: n.createdAt,
userId: n.userId,
author: n.author || { name: 'System', email: '', role: 'system' },
attachments: n.attachments || []
})));
}
}, [notes]);
} catch (error) {
console.error('Fetch notes error:', error);
toast.error('Failed to load work notes');
} finally {
setIsLoading(false);
}
};
fetchNotes();
if (socket) {
socket.emit('join_room', applicationId);
socket.on('new_worknote', (newNote: any) => {
setNotes(prev => {
// 1. Check for exact ID match (real vs real or real vs replaced temp)
const isDuplicate = prev.some(n => n.id === newNote.id);
if (isDuplicate) return prev;
// 2. Check for optimistic match (matching text and author from a very recent temp note)
const optimisticMatchIndex = prev.findIndex(n =>
n.id.startsWith('temp-') &&
n.noteText === newNote.noteText &&
(n.author.email?.toLowerCase() === newNote.author.email?.toLowerCase())
);
if (optimisticMatchIndex !== -1) {
// Replace the temp note with the real one from the socket
const newNotes = [...prev];
newNotes[optimisticMatchIndex] = newNote;
return newNotes;
}
// 3. New message from someone else
return [newNote, ...prev];
});
});
return () => {
socket.emit('leave_room', applicationId);
socket.off('new_worknote');
};
}
}, [applicationId, socket]);
// Fetch application details if participants are missing (e.g. on refresh)
useEffect(() => {
if (externalParticipants.length === 0 && applicationId) {
const fetchApplicationDetails = async () => {
try {
const appData = await onboardingService.getApplicationById(applicationId);
if (appData && appData.participants) {
setExternalParticipants(appData.participants);
}
} catch (error) {
console.error('Failed to fetch application details for participants:', error);
}
};
fetchApplicationDetails();
}
}, [applicationId, externalParticipants.length]);
// Scroll logic removed - handled by flex-col-reverse anchoring
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
@ -115,14 +275,16 @@ export function WorkNotesPage({
}
};
const handleMentionSelect = (name: string) => {
const handleMentionSelect = (participant: ParticipantUI) => {
const textBeforeCursor = message.substring(0, cursorPosition);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
const textAfterCursor = message.substring(cursorPosition);
// Insert clean @Name into input
const mentionText = `@${participant.name}`;
const newMessage =
message.substring(0, lastAtSymbol) +
`@${name} ` +
mentionText + ' ' +
textAfterCursor;
setMessage(newMessage);
@ -130,37 +292,115 @@ export function WorkNotesPage({
inputRef.current?.focus();
};
const handleSendMessage = () => {
if (!message.trim()) return;
const handleEmojiSelect = (emoji: string) => {
const start = inputRef.current?.selectionStart || message.length;
const newMessage = message.substring(0, start) + emoji + message.substring(start);
setMessage(newMessage);
setIsEmojiPickerOpen(false);
inputRef.current?.focus();
};
// Extract mentions from message
const mentionRegex = /@(\w+\s*\w*)/g;
const mentions: string[] = [];
let match;
while ((match = mentionRegex.exec(message)) !== null) {
mentions.push(match[1]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setIsUploading(true);
try {
for (const file of Array.from(files)) {
const res: any = await worknoteService.uploadAttachment(file, applicationId, 'application');
if (res.success) {
setAttachedFiles(prev => [...prev, res.data]);
}
}
} catch (error) {
console.error('File upload error:', error);
toast.error('Failed to upload attachment');
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
const newNote: WorkNote = {
id: Date.now().toString(),
user: 'Current User',
message: message,
timestamp: new Date().toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}),
mentions: mentions.length > 0 ? mentions : undefined,
};
setNotes([...notes, newNote]);
const removeAttachment = (id: string) => {
setAttachedFiles(prev => prev.filter(f => f.id !== id));
};
const handleSendMessage = async () => {
if (!message.trim() && attachedFiles.length === 0) return;
const optimisticMessage = message;
const optimisticAttachments = attachedFiles;
setMessage('');
setAttachedFiles([]);
// Convert clean @Name mentions to structured format: @[Name](user:Id)
let processedMessage = optimisticMessage;
const mentionedUserIds: string[] = [];
participantsList.forEach(p => {
if (p.id && p.name) {
// Simplified regex: Look for @ followed by the name, case-insensitive
// We use a more standard boundary check
const escapedName = p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mentionRegex = new RegExp(`@${escapedName}\\b`, 'gi');
if (processedMessage.match(mentionRegex)) {
console.log(`Mention found for ${p.name} (${p.id})`);
mentionedUserIds.push(p.id);
processedMessage = processedMessage.replace(mentionRegex, `@[${p.name}](user:${p.id})`);
} else {
console.log(`No match for participant: ${p.name} in message: "${processedMessage}"`);
}
}
});
console.log('Final processed message:', processedMessage);
console.log('Mentioned user IDs:', mentionedUserIds);
console.log('Final processed message for API:', processedMessage);
try {
// Optimistic update
const tempId = `temp-${Date.now()}`;
const tempNote: WorkNote = {
id: tempId,
noteText: processedMessage, // Structured text for proper rendering
noteType: 'General',
createdAt: new Date().toISOString(),
userId: currentUser?.id,
author: {
name: currentUser?.name || 'You',
email: currentUser?.email || '',
role: currentUser?.role || ''
},
attachments: optimisticAttachments
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
setNotes(prev => [tempNote, ...prev]);
const res: any = await worknoteService.addWorknote({
requestId: applicationId,
requestType: 'application',
noteText: processedMessage,
noteType: 'General',
tags: mentionedUserIds,
attachmentDocIds: optimisticAttachments.map(f => f.id)
});
if (res.success && res.data) {
// Replace optimistic update with actual data from server
setNotes(prev => prev.map(n => n.id === tempId ? res.data : n));
}
} catch (error) {
console.error('Send message error:', error);
toast.error('Failed to send message');
// Revert optimistic update? (Optional, but good practice)
// fetchNotes(); // or similar
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
@ -168,24 +408,36 @@ export function WorkNotesPage({
};
const renderMessageWithMentions = (text: string) => {
const parts = text.split(/(@\w+\s*\w*)/g);
if (!text) return '';
// Regex matches the structured mention: @[Name](user:Id)
const mentionRegex = /(@\[[^\]]+\]\([^\)]+\))/g;
const parts = text.split(mentionRegex);
return parts.map((part, index) => {
if (part.startsWith('@')) {
const mentionMatch = part.match(/@\[([^\]]+)\]\(([^\)]+)\)/);
if (mentionMatch) {
const name = mentionMatch[1];
return (
<span key={index} className="text-blue-600 hover:underline cursor-pointer">
{part}
<span key={index} className="text-blue-600 font-medium hover:underline cursor-pointer">
@{name}
</span>
);
}
return <span key={index}>{part}</span>;
return part;
});
};
const filteredParticipants = participantsList.filter(p => {
// Filter by name query
const matchesQuery = p.name.toLowerCase().includes(mentionQuery.toLowerCase());
const filteredParticipants = participantsList.filter(p =>
p.name.toLowerCase().includes(mentionQuery.toLowerCase())
);
// Filter out the current user by ID or Email (self-mention protection)
const isSelf = (p.id && currentUser?.id && String(p.id) === String(currentUser.id)) ||
(p.email && currentUser?.email && p.email.toLowerCase() === currentUser.email.toLowerCase());
return matchesQuery && !isSelf;
});
return (
<div className="h-screen flex flex-col bg-slate-50">
@ -239,71 +491,149 @@ export function WorkNotesPage({
{/* Messages Area */}
<ScrollArea className="flex-1 px-6 py-4">
<div className="max-w-4xl mx-auto space-y-6" ref={scrollRef}>
{notes.map((note, index) => {
const isCurrentUser = note.user === 'Current User';
const previousNote = index > 0 ? notes[index - 1] : null;
const showAvatar = !previousNote || previousNote.user !== note.user;
<div className="max-w-4xl mx-auto space-y-6 flex flex-col-reverse" ref={scrollRef}>
{notes.map((note) => {
const isMe = (note?.author?.email && currentUser?.email && note.author.email.toLowerCase() === currentUser.email.toLowerCase()) ||
(note?.userId && currentUser?.id && note.userId === currentUser.id) ||
note.id.startsWith('temp-');
return (
<div key={note.id} className="flex gap-3">
<div key={note.id} className={`flex w-full ${isMe ? 'justify-end' : 'justify-start'}`}>
<div className={`flex gap-3 max-w-[85%] ${isMe ? 'flex-row-reverse' : ''}`}>
{/* Avatar */}
{showAvatar ? (
<Avatar className="w-10 h-10 flex-shrink-0">
<AvatarFallback className={`${getAvatarColor(note.user)} text-white`}>
{getInitials(note.user)}
<Avatar className="w-10 h-10 flex-shrink-0 mt-1">
<AvatarFallback className={`${getAvatarColor(note?.author?.name || 'System')} text-white`}>
{getInitials(note?.author?.name || 'S')}
</AvatarFallback>
</Avatar>
) : (
<div className="w-10 flex-shrink-0" />
)}
{/* Message Content */}
<div className="flex-1 min-w-0">
{showAvatar && (
<div className="flex items-center gap-2 mb-1">
<span className="text-slate-900">{note.user}</span>
{index === 0 && (
<Badge variant="secondary" className="text-xs">
Initiator
</Badge>
)}
<span className="text-slate-400 text-xs">
{note.timestamp}
<div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
<div className={`flex items-center gap-2 mb-1 px-1 ${isMe ? 'flex-row-reverse text-right' : 'text-left'}`}>
<span className="text-slate-900 font-medium text-sm">{isMe ? 'You' : (note?.author?.name || 'Unknown')}</span>
<span className="text-slate-400 text-[10px] uppercase">
{(note?.author?.role && note.author.role !== '0' && note.author.role !== '') ? `(${note.author.role})` : ''}
</span>
<span className="text-slate-400 text-[10px]">
{note.createdAt ? new Date(note.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
)}
<div className="bg-white rounded-lg border border-slate-200 px-4 py-3 shadow-sm">
<p className="text-slate-700 leading-relaxed whitespace-pre-wrap">
{renderMessageWithMentions(note.message)}
<div className={`rounded-2xl border px-4 py-2.5 shadow-sm relative ${isMe
? 'bg-blue-50 border-blue-100 text-slate-800 rounded-tr-none'
: 'bg-white border-slate-200 text-slate-700 rounded-tl-none'
}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{renderMessageWithMentions(note.noteText)}
</p>
{note.attachments && note.attachments.length > 0 && (
<div className="mt-2 space-y-2 border-t border-slate-100 pt-2">
{note.attachments.map(file => {
const isImage = file.mimeType.startsWith('image/');
return (
<div key={file.id} className="flex items-center gap-2">
{isImage ? (
<div className="rounded-lg overflow-hidden border border-slate-100 max-w-[200px]">
<img
src={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
alt={file.fileName}
className="w-full h-auto cursor-pointer"
onClick={() => setPreviewFile(file)}
/>
</div>
) : file.mimeType === 'application/pdf' ? (
<button
onClick={() => setPreviewFile(file)}
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
>
<Paperclip className="w-3 h-3" />
{file.fileName} (Preview)
</button>
) : (
<a
href={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
>
<Paperclip className="w-3 h-3" />
{file.fileName}
</a>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
})}
{notes.length === 0 && (
{notes.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<MessageSquare className="w-16 h-16 text-slate-300 mb-4" />
<h3 className="text-slate-900 mb-2">No messages yet</h3>
<p className="text-slate-600">Start the conversation by sending a message below</p>
</div>
)}
{isLoading && (
<div className="flex justify-center items-center py-8">
<span className="text-slate-500">Loading notes...</span>
</div>
)}
</div>
</ScrollArea>
{/* Input Area */}
<div className="bg-white border-t border-slate-200 px-6 py-4">
<div className="max-w-4xl mx-auto">
{/* Mention Suggestions */}
<div className="max-w-4xl mx-auto space-y-4">
{/* Attachment Previews */}
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-3 mb-3">
{attachedFiles.map(file => {
const isPreviewable = file.mimeType.startsWith('image/') || file.mimeType === 'application/pdf';
return (
<div
key={file.id}
className="flex items-center gap-3 p-2 bg-white rounded-xl border border-slate-200 shadow-sm hover:border-blue-300 transition-all group max-w-[200px]"
>
<div className="w-10 h-10 bg-slate-50 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
{getFileIcon(file.mimeType, file.filePath)}
</div>
<div className="flex-1 min-w-0 pr-6 relative">
<p
className={`text-xs font-medium text-slate-700 truncate ${isPreviewable ? 'hover:text-blue-600 cursor-pointer hover:underline' : ''}`}
onClick={() => isPreviewable && setPreviewFile(file)}
>
{file.fileName}
</p>
<button
onClick={() => removeAttachment(file.id)}
className="absolute -top-1 -right-1 p-1 bg-white rounded-full border border-slate-100 text-slate-400 hover:text-red-500 shadow-sm opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
);
})}
</div>
)}
<div className="flex items-end gap-3 bg-white p-2.5 rounded-2xl border border-slate-200 shadow-sm focus-within:border-blue-400 focus-within:ring-1 focus-within:ring-blue-100 transition-all relative">
{/* Mention Suggestions moved inside relative container */}
{showMentionSuggestions && filteredParticipants.length > 0 && (
<div className="mb-2 bg-white border border-slate-200 rounded-lg shadow-lg overflow-hidden">
<div className="absolute bottom-full left-0 mb-2 w-64 bg-white border border-slate-200 rounded-lg shadow-lg overflow-hidden max-h-48 overflow-y-auto z-50 custom-scrollbar">
{filteredParticipants.map((participant) => (
<button
key={participant.name}
onClick={() => handleMentionSelect(participant.name)}
key={participant.id}
onClick={() => handleMentionSelect(participant)}
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-slate-50 transition-colors text-left"
>
<Avatar className="w-8 h-8">
@ -311,37 +641,73 @@ export function WorkNotesPage({
{participant.initials}
</AvatarFallback>
</Avatar>
<span className="text-slate-900">{participant.name}</span>
<div className="flex flex-col">
<span className="text-slate-900 text-sm font-medium">{participant.name}</span>
</div>
</button>
))}
</div>
)}
{/* Input Field */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 mb-1">
<input
type="file"
ref={fileInputRef}
className="hidden"
multiple
onChange={handleFileUpload}
/>
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-slate-600"
className="w-9 h-9 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-slate-600"
className={`w-9 h-9 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl relative ${isEmojiPickerOpen ? 'bg-blue-50 text-blue-600' : ''}`}
onClick={() => setIsEmojiPickerOpen(!isEmojiPickerOpen)}
>
<Smile className="w-5 h-5" />
{isEmojiPickerOpen && (
<div className="absolute bottom-12 left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-72 animate-in fade-in slide-in-from-bottom-2 overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100 bg-slate-50 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Emojis</span>
<button
onClick={() => setIsEmojiPickerOpen(false)}
className="text-slate-400 hover:text-slate-600 text-lg leading-none"
>
&times;
</button>
</div>
<div className="p-2 grid grid-cols-8 gap-1 max-h-60 overflow-y-auto custom-scrollbar">
{COMMON_EMOJIS.map(emoji => (
<button
key={emoji}
className="w-8 h-8 flex items-center justify-center hover:bg-blue-50 hover:scale-110 rounded-lg transition-all text-lg"
onClick={(e) => {
e.stopPropagation();
handleEmojiSelect(emoji);
}}
>
{emoji}
</button>
))}
</div>
</div>
)}
</Button>
<Button
variant="ghost"
size="icon"
className="w-9 h-9 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl"
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-slate-600"
>
<Smile className="w-5 h-5" />
</Button>
</div>
<div className="flex-1 relative">
<Input
@ -351,25 +717,68 @@ export function WorkNotesPage({
value={message}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
className="w-full pr-4"
className="w-full pr-4 border-none focus-visible:ring-0 px-0"
/>
</div>
<Button
onClick={handleSendMessage}
disabled={!message.trim()}
className="bg-blue-600 hover:bg-blue-700 text-white"
size="icon"
disabled={!message.trim() && attachedFiles.length === 0 || isUploading}
className="bg-blue-600 hover:bg-blue-700 text-white rounded-xl h-10 w-10 p-0"
>
{isUploading ? (
<div className="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
) : (
<Send className="w-5 h-5" />
)}
</Button>
</div>
<p className="text-slate-400 text-xs mt-2">
Press Enter to send Use @ to mention someone
<p className="text-slate-400 text-[10px] px-1">
Press Enter to send Use @ to mention someone {isUploading ? 'Uploading files...' : 'Files attached appear above'}
</p>
</div>
</div>
{/* Preview Modal */}
<Dialog open={!!previewFile} onOpenChange={(open) => !open && setPreviewFile(null)}>
<DialogContent className="max-w-6xl h-[90vh] flex flex-col p-4">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{previewFile && getFileIcon(previewFile.mimeType)}
<span className="truncate">{previewFile?.fileName}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden rounded-lg bg-slate-100 flex items-center justify-center p-4">
{previewFile?.mimeType.startsWith('image/') ? (
<img
src={`${BACKEND_URL}/${previewFile.filePath.replace(/\\/g, '/')}`}
className="max-w-full max-h-full object-contain"
alt="Preview"
/>
) : previewFile?.mimeType === 'application/pdf' ? (
<iframe
src={`${BACKEND_URL}/${previewFile.filePath.replace(/\\/g, '/')}`}
className="w-full h-full border-none"
title="PDF Preview"
/>
) : (
<div className="text-center">
<FileIcon className="w-16 h-16 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500">Preview not available for this file type.</p>
<a
href={`${BACKEND_URL}/${previewFile?.filePath.replace(/\\/g, '/')}`}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:underline text-sm"
>
Open in new tab
</a>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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<Notification[]>([]);
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 (
<header className="bg-white border-b border-slate-200 px-6 py-4">
@ -96,30 +139,48 @@ export function Header({ title, onRefresh }: HeaderProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="p-3 border-b">
<p>Notifications</p>
<div className="p-3 border-b flex items-center justify-between">
<p className="font-semibold text-slate-900">Notifications</p>
{unreadCount > 0 && (
<button
onClick={handleMarkAllAsRead}
className="text-xs text-blue-600 hover:underline"
>
Mark all read
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.map((notification) => (
<div className="max-h-96 overflow-y-auto custom-scrollbar">
{notifications.length === 0 ? (
<div className="p-8 text-center text-slate-500">
No notifications yet
</div>
) : (
notifications.map((notification) => (
<DropdownMenuItem
key={notification.id}
className={`p-3 cursor-pointer ${notification.unread ? 'bg-amber-50' : ''
className={`p-3 cursor-pointer flex items-start gap-3 ${!notification.isRead ? 'bg-blue-50/50' : ''
}`}
onClick={() => handleMarkAsRead(notification.id)}
>
<div className="flex-1">
<p className="text-slate-900">{notification.message}</p>
<p className="text-slate-500 mt-1">{notification.time}</p>
<div className="flex-1 min-w-0">
<p className="text-slate-900 text-sm font-medium">{notification.title}</p>
<p className="text-slate-600 text-xs mt-1 leading-relaxed">{notification.message}</p>
<p className="text-slate-400 text-[10px] mt-2">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
</div>
{notification.unread && (
<div className="w-2 h-2 bg-amber-600 rounded-full flex-shrink-0"></div>
{!notification.isRead && (
<div className="w-2 h-2 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></div>
)}
</DropdownMenuItem>
))}
))
)}
</div>
<div className="p-3 border-t text-center">
<button className="text-amber-600 hover:text-amber-700">
View All Notifications
</button>
<p className="text-xs text-slate-400">
Stay updated with your mentions and tasks
</p>
</div>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -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<SocketContextType>({
socket: null,
isConnected: false
});
export const useSocket = () => useContext(SocketContext);
export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [socket, setSocket] = useState<Socket | null>(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 (
<SocketContext.Provider value={{ socket, isConnected }}>
{children}
</SocketContext.Provider>
);
};

View File

@ -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;
}
}
};

View File

@ -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;

View File

@ -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;

View File

@ -188,3 +188,25 @@
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;
}