added websocket implementaion and enhanced activity logs

This commit is contained in:
laxmanhalaki 2025-11-03 21:16:43 +05:30
parent 7b8fac5d8c
commit 3ee174e44e
22 changed files with 3084 additions and 466 deletions

View File

@ -5,7 +5,7 @@ import { Dashboard } from '@/pages/Dashboard';
import { OpenRequests } from '@/pages/OpenRequests';
import { ClosedRequests } from '@/pages/ClosedRequests';
import { RequestDetail } from '@/pages/RequestDetail';
import { WorkNoteChat } from '@/components/workNote/WorkNoteChat';
import { WorkNotes } from '@/pages/WorkNotes';
import { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard';
import { MyRequests } from '@/pages/MyRequests';
@ -547,17 +547,16 @@ function AppRoutes({ onLogout }: AppProps) {
<RequestDetail
requestId={selectedRequestId || ''}
onBack={handleBack}
onOpenModal={handleOpenModal}
dynamicRequests={dynamicRequests}
/>
</PageLayout>
}
/>
{/* Work Notes/Chat */}
{/* Work Notes - Dedicated Full-Screen Page */}
<Route
path="/work-notes/:requestId"
element={<WorkNoteChat requestId={''} onBack={handleBack} />}
element={<WorkNotes />}
/>
{/* New Request (Custom) */}

View File

@ -0,0 +1,32 @@
.file-preview-dialog {
width: 90vw !important;
max-width: 90vw !important;
height: 90vh !important;
max-height: 90vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.file-preview-dialog {
width: 100vw !important;
max-width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
border-radius: 0 !important;
margin: 0 !important;
}
}
.file-preview-content {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.file-preview-body {
flex: 1;
overflow: auto;
min-height: 0;
}

View File

@ -0,0 +1,248 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Download, X, Eye, ZoomIn, ZoomOut, RotateCw, Loader2 } from 'lucide-react';
import './FilePreview.css';
interface FilePreviewProps {
fileName: string;
fileType: string;
fileUrl?: string;
fileSize?: number;
attachmentId?: string;
onDownload?: (attachmentId: string) => Promise<void>;
open: boolean;
onClose: () => void;
}
export function FilePreview({
fileName,
fileType,
fileUrl,
fileSize,
attachmentId,
onDownload,
open,
onClose
}: FilePreviewProps) {
const [zoom, setZoom] = useState(100);
const [rotation, setRotation] = useState(0);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isImage = fileType.toLowerCase().includes('image') ||
fileType.toLowerCase().includes('png') ||
fileType.toLowerCase().includes('jpg') ||
fileType.toLowerCase().includes('jpeg') ||
fileType.toLowerCase().includes('gif') ||
fileType.toLowerCase().includes('webp');
const isPDF = fileType.toLowerCase().includes('pdf');
const canPreview = isImage || isPDF;
// Fetch file as blob for authenticated preview
useEffect(() => {
if (!open || !canPreview || !fileUrl) {
setBlobUrl(null);
return;
}
const fetchFile = async () => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('accessToken');
const response = await fetch(fileUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to load file');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
setBlobUrl(url);
} catch (err) {
console.error('Failed to load file for preview:', err);
setError('Failed to load file for preview');
} finally {
setLoading(false);
}
};
fetchFile();
// Cleanup blob URL when modal closes
return () => {
if (blobUrl) {
window.URL.revokeObjectURL(blobUrl);
}
};
}, [open, fileUrl, canPreview]);
const handleDownload = async () => {
if (onDownload && attachmentId) {
try {
await onDownload(attachmentId);
} catch (error) {
alert('Failed to download file');
}
}
};
const handleZoomIn = () => setZoom(prev => Math.min(prev + 25, 200));
const handleZoomOut = () => setZoom(prev => Math.max(prev - 25, 50));
const handleRotate = () => setRotation(prev => (prev + 90) % 360);
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="file-preview-dialog p-3 sm:p-6">
<div className="file-preview-content">
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
{fileName}
</DialogTitle>
<p className="text-xs sm:text-sm text-gray-500">
{fileType} {fileSize && `${(fileSize / 1024).toFixed(1)} KB`}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap mr-2">
{isImage && (
<div className="flex items-center gap-1 mr-2">
<Button
variant="ghost"
size="sm"
onClick={handleZoomOut}
disabled={zoom <= 50}
title="Zoom out"
className="h-8 w-8 p-0"
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-xs sm:text-sm text-gray-600 min-w-[3rem] text-center">{zoom}%</span>
<Button
variant="ghost"
size="sm"
onClick={handleZoomIn}
disabled={zoom >= 200}
title="Zoom in"
className="h-8 w-8 p-0"
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRotate}
title="Rotate"
className="h-8 w-8 p-0"
>
<RotateCw className="h-4 w-4" />
</Button>
</div>
)}
{onDownload && attachmentId && (
<Button
variant="outline"
size="sm"
onClick={handleDownload}
className="gap-2 h-9"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Download</span>
</Button>
)}
</div>
</div>
</DialogHeader>
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
{loading ? (
<div className="flex flex-col items-center justify-center h-full">
<Loader2 className="w-12 h-12 text-blue-600 animate-spin mb-4" />
<p className="text-sm text-gray-600">Loading preview...</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mb-4">
<X className="w-10 h-10 text-red-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Failed</h3>
<p className="text-sm text-gray-600 mb-6">{error}</p>
{onDownload && attachmentId && (
<Button onClick={handleDownload} className="gap-2">
<Download className="h-4 w-4" />
Download {fileName}
</Button>
)}
</div>
) : canPreview && blobUrl ? (
<>
{isImage && (
<div className="flex items-center justify-center h-full">
<img
src={blobUrl}
alt={fileName}
style={{
transform: `scale(${zoom / 100}) rotate(${rotation}deg)`,
transition: 'transform 0.2s ease-in-out',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
}}
className="rounded-lg shadow-lg"
/>
</div>
)}
{isPDF && (
<div className="w-full h-full flex items-center justify-center">
<iframe
src={blobUrl}
className="w-full h-full rounded-lg border-0"
title={fileName}
style={{
minHeight: '70vh',
height: '100%'
}}
/>
</div>
)}
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
<Eye className="w-10 h-10 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
<p className="text-sm text-gray-600 mb-6">
This file type cannot be previewed. Please download to view.
</p>
{onDownload && attachmentId && (
<Button onClick={handleDownload} className="gap-2">
<Download className="h-4 w-4" />
Download {fileName}
</Button>
)}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,2 @@
export { FilePreview } from './FilePreview';

View File

@ -96,13 +96,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
md:translate-x-0
${sidebarOpen ? 'md:w-64' : 'md:w-0 md:-translate-x-full'}
${sidebarOpen ? 'md:w-64' : 'md:w-0'}
z-50 md:z-auto
flex-shrink-0
border-r border-gray-800 bg-black
flex flex-col
overflow-hidden
`}>
<div className="w-64 h-full flex flex-col overflow-hidden">
<div className={`w-64 h-full flex flex-col overflow-hidden ${!sidebarOpen ? 'md:hidden' : ''}`}>
<div className="p-4 border-b border-gray-800 flex-shrink-0">
<div className="flex items-center gap-3">
<img

View File

@ -0,0 +1,255 @@
import { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Users, X, AtSign } from 'lucide-react';
import { searchUsers, type UserSummary } from '@/services/userApi';
interface AddApproverModalProps {
open: boolean;
onClose: () => void;
onConfirm: (email: string) => Promise<void> | void;
requestIdDisplay?: string;
requestTitle?: string;
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
}
export function AddApproverModal({
open,
onClose,
onConfirm,
requestIdDisplay,
requestTitle,
existingParticipants = []
}: AddApproverModalProps) {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchTimer = useRef<any>(null);
const handleConfirm = async () => {
const emailToAdd = email.trim().toLowerCase();
if (!emailToAdd) {
alert('Please enter an email address');
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(emailToAdd)) {
alert('Please enter a valid email address');
return;
}
// Check if user is already a participant
const existingParticipant = existingParticipants.find(
p => (p.email || '').toLowerCase() === emailToAdd
);
if (existingParticipant) {
const participantType = existingParticipant.participantType?.toUpperCase() || 'PARTICIPANT';
const userName = existingParticipant.name || emailToAdd;
if (participantType === 'INITIATOR') {
alert(`${userName} is the request initiator and cannot be added as an approver.`);
return;
} else if (participantType === 'APPROVER') {
alert(`${userName} is already an approver on this request.`);
return;
} else if (participantType === 'SPECTATOR') {
alert(`${userName} is currently a spectator on this request and cannot be added as an approver. Please remove them as spectator first.`);
return;
} else {
alert(`${userName} is already a participant on this request.`);
return;
}
}
try {
setIsSubmitting(true);
await onConfirm(emailToAdd);
setEmail('');
onClose();
} catch (error) {
console.error('Failed to add approver:', error);
// Error handling is done in the parent component
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
setEmail('');
setSearchResults([]);
setIsSearching(false);
onClose();
}
};
// Cleanup search timer on unmount
useEffect(() => {
return () => {
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
};
}, []);
// Handle user search with @ mention
const handleEmailChange = (value: string) => {
setEmail(value);
// Clear existing timer
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
// Only trigger search when using @ sign
if (!value || !value.startsWith('@') || value.length < 2) {
setSearchResults([]);
setIsSearching(false);
return;
}
// Start search with debounce
setIsSearching(true);
searchTimer.current = setTimeout(async () => {
try {
const term = value.slice(1); // Remove @ prefix
const results = await searchUsers(term, 10);
setSearchResults(results);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, 300);
};
// Select user from search results
const handleSelectUser = (user: UserSummary) => {
setEmail(user.email);
setSearchResults([]);
setIsSearching(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<button
onClick={handleClose}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
disabled={isSubmitting}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<DialogHeader>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
<DialogTitle className="text-xl font-bold text-gray-900">Add Approver</DialogTitle>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Description */}
<p className="text-sm text-gray-600 leading-relaxed">
Add a new approver to this request. They will be notified and can approve or reject the request.
</p>
{/* Email Input with @ Search */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Email Address</label>
<div className="relative">
<AtSign className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 z-10" />
<Input
type="text"
placeholder="@username or user@example.com"
value={email}
onChange={(e) => handleEmailChange(e.target.value)}
className="pl-10 h-11 border-gray-300"
disabled={isSubmitting}
autoFocus
/>
{/* Search Results Dropdown */}
{(isSearching || searchResults.length > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg max-h-60 overflow-auto">
{isSearching ? (
<div className="p-3 text-sm text-gray-500">Searching users...</div>
) : searchResults.length > 0 ? (
<ul className="divide-y">
{searchResults.map((user) => (
<li
key={user.userId}
className="p-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => handleSelectUser(user)}
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-blue-100 text-blue-800 text-xs font-semibold">
{(user.displayName || user.email)
.split(' ')
.map(s => s[0])
.join('')
.slice(0, 2)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">
{user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email}
</p>
<p className="text-xs text-gray-600 truncate">{user.email}</p>
{user.designation && (
<p className="text-xs text-gray-500">{user.designation}</p>
)}
</div>
</div>
</li>
))}
</ul>
) : null}
</div>
)}
</div>
<p className="text-xs text-gray-500">
Type <span className="font-semibold">@username</span> to search for users, or enter email directly.
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1 h-11 border-gray-300"
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
className="flex-1 h-11 bg-[#1a472a] hover:bg-[#152e1f] text-white"
disabled={isSubmitting || !email.trim()}
>
<Users className="w-4 h-4 mr-2" />
{isSubmitting ? 'Adding...' : 'Add Approver'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,2 @@
export { AddApproverModal } from './AddApproverModal';

View File

@ -0,0 +1,255 @@
import { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Eye, X, AtSign } from 'lucide-react';
import { searchUsers, type UserSummary } from '@/services/userApi';
interface AddSpectatorModalProps {
open: boolean;
onClose: () => void;
onConfirm: (email: string) => Promise<void> | void;
requestIdDisplay?: string;
requestTitle?: string;
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
}
export function AddSpectatorModal({
open,
onClose,
onConfirm,
requestIdDisplay,
requestTitle,
existingParticipants = []
}: AddSpectatorModalProps) {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchTimer = useRef<any>(null);
const handleConfirm = async () => {
const emailToAdd = email.trim().toLowerCase();
if (!emailToAdd) {
alert('Please enter an email address');
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(emailToAdd)) {
alert('Please enter a valid email address');
return;
}
// Check if user is already a participant
const existingParticipant = existingParticipants.find(
p => (p.email || '').toLowerCase() === emailToAdd
);
if (existingParticipant) {
const participantType = existingParticipant.participantType?.toUpperCase() || 'PARTICIPANT';
const userName = existingParticipant.name || emailToAdd;
if (participantType === 'INITIATOR') {
alert(`${userName} is the request initiator and cannot be added as a spectator.`);
return;
} else if (participantType === 'APPROVER') {
alert(`${userName} is already an approver on this request and cannot be added as a spectator.`);
return;
} else if (participantType === 'SPECTATOR') {
alert(`${userName} is already a spectator on this request.`);
return;
} else {
alert(`${userName} is already a participant on this request.`);
return;
}
}
try {
setIsSubmitting(true);
await onConfirm(emailToAdd);
setEmail('');
onClose();
} catch (error) {
console.error('Failed to add spectator:', error);
// Error handling is done in the parent component
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
setEmail('');
setSearchResults([]);
setIsSearching(false);
onClose();
}
};
// Cleanup search timer on unmount
useEffect(() => {
return () => {
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
};
}, []);
// Handle user search with @ mention
const handleEmailChange = (value: string) => {
setEmail(value);
// Clear existing timer
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
// Only trigger search when using @ sign
if (!value || !value.startsWith('@') || value.length < 2) {
setSearchResults([]);
setIsSearching(false);
return;
}
// Start search with debounce
setIsSearching(true);
searchTimer.current = setTimeout(async () => {
try {
const term = value.slice(1); // Remove @ prefix
const results = await searchUsers(term, 10);
setSearchResults(results);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, 300);
};
// Select user from search results
const handleSelectUser = (user: UserSummary) => {
setEmail(user.email);
setSearchResults([]);
setIsSearching(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<button
onClick={handleClose}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
disabled={isSubmitting}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<DialogHeader>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<Eye className="w-5 h-5 text-purple-600" />
</div>
<DialogTitle className="text-xl font-bold text-gray-900">Add Spectator</DialogTitle>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Description */}
<p className="text-sm text-gray-600 leading-relaxed">
Add a spectator to this request. They will receive notifications but cannot approve or reject.
</p>
{/* Email Input with @ Search */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Email Address</label>
<div className="relative">
<AtSign className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 z-10" />
<Input
type="text"
placeholder="@username or user@example.com"
value={email}
onChange={(e) => handleEmailChange(e.target.value)}
className="pl-10 h-11 border-gray-300"
disabled={isSubmitting}
autoFocus
/>
{/* Search Results Dropdown */}
{(isSearching || searchResults.length > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg max-h-60 overflow-auto">
{isSearching ? (
<div className="p-3 text-sm text-gray-500">Searching users...</div>
) : searchResults.length > 0 ? (
<ul className="divide-y">
{searchResults.map((user) => (
<li
key={user.userId}
className="p-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => handleSelectUser(user)}
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-purple-100 text-purple-800 text-xs font-semibold">
{(user.displayName || user.email)
.split(' ')
.map(s => s[0])
.join('')
.slice(0, 2)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">
{user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email}
</p>
<p className="text-xs text-gray-600 truncate">{user.email}</p>
{user.designation && (
<p className="text-xs text-gray-500">{user.designation}</p>
)}
</div>
</div>
</li>
))}
</ul>
) : null}
</div>
)}
</div>
<p className="text-xs text-gray-500">
Type <span className="font-semibold">@username</span> to search for users, or enter email directly.
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1 h-11 border-gray-300"
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
className="flex-1 h-11 bg-[#1a472a] hover:bg-[#152e1f] text-white"
disabled={isSubmitting || !email.trim()}
>
<Eye className="w-4 h-4 mr-2" />
{isSubmitting ? 'Adding...' : 'Add Spectator'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,2 @@
export { AddSpectatorModal } from './AddSpectatorModal';

View File

@ -1,16 +1,17 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails } from '@/services/workflowApi';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator } from '@/services/workflowApi';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { useParams } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { FilePreview } from '@/components/common/FilePreview';
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
import { formatDateTime } from '@/utils/dateFormatter';
import {
ArrowLeft,
Send,
@ -23,24 +24,13 @@ import {
MoreHorizontal,
MessageSquare,
Clock,
CheckCircle,
AlertCircle,
Search,
Hash,
AtSign,
Phone,
Video,
Settings,
Pin,
Share,
Archive,
Plus,
Filter,
Calendar,
Zap,
Activity,
Bell,
Star,
Flag,
X,
FileSpreadsheet,
@ -68,6 +58,7 @@ interface Message {
users: string[];
}[];
isHighPriority?: boolean;
isCurrentUser?: boolean;
}
interface Participant {
@ -84,7 +75,6 @@ interface WorkNoteChatProps {
requestId: string;
onBack?: () => void;
messages?: any[]; // optional external messages
loading?: boolean;
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
}
@ -160,41 +150,6 @@ const MOCK_PARTICIPANTS: Participant[] = [
}
];
const MOCK_DOCUMENTS = [
{
name: 'Q4_Marketing_Strategy.pdf',
size: '2.4 MB',
uploadedBy: 'Sarah Chen',
uploadedAt: '2024-10-05 14:32',
type: 'PDF',
url: '#'
},
{
name: 'Budget_Breakdown.xlsx',
size: '1.1 MB',
uploadedBy: 'Sarah Chen',
uploadedAt: '2024-10-05 14:35',
type: 'Excel',
url: '#'
},
{
name: 'Previous_Campaign_ROI.pdf',
size: '856 KB',
uploadedBy: 'Anna Smith',
uploadedAt: '2024-10-06 09:15',
type: 'PDF',
url: '#'
},
{
name: 'Competitor_Analysis.pptx',
size: '3.2 MB',
uploadedBy: 'Emily Davis',
uploadedAt: '2024-10-06 15:22',
type: 'PowerPoint',
url: '#'
}
];
const INITIAL_MESSAGES: Message[] = [
{
id: '1',
@ -294,34 +249,39 @@ const formatMessage = (content: string) => {
.replace(/\n/g, '<br />');
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
// File icon components using Lucide React
const FileIcon = ({ type }: { type: string }) => {
const iconClass = "w-4 h-4";
switch (type.toLowerCase()) {
case 'pdf': return <FileText className={`${iconClass} text-red-600`} />;
case 'excel': case 'xlsx': return <FileSpreadsheet className={`${iconClass} text-green-600`} />;
case 'powerpoint': case 'pptx': return <FileText className={`${iconClass} text-orange-600`} />;
case 'word': case 'docx': return <FileText className={`${iconClass} text-blue-600`} />;
case 'image': case 'png': case 'jpg': case 'jpeg': return <Image className={`${iconClass} text-purple-600`} />;
default: return <Paperclip className={`${iconClass} text-gray-600`} />;
}
const lowerType = type.toLowerCase();
if (lowerType.includes('pdf')) return <FileText className={`${iconClass} text-red-600`} />;
if (lowerType.includes('excel') || lowerType.includes('spreadsheet') || lowerType.includes('xlsx')) return <FileSpreadsheet className={`${iconClass} text-green-600`} />;
if (lowerType.includes('powerpoint') || lowerType.includes('presentation') || lowerType.includes('pptx')) return <FileText className={`${iconClass} text-orange-600`} />;
if (lowerType.includes('word') || lowerType.includes('document') || lowerType.includes('docx')) return <FileText className={`${iconClass} text-blue-600`} />;
if (lowerType.includes('image') || lowerType.includes('png') || lowerType.includes('jpg') || lowerType.includes('jpeg') || lowerType.includes('gif') || lowerType.includes('webp')) return <Image className={`${iconClass} text-purple-600`} />;
return <Paperclip className={`${iconClass} text-gray-600`} />;
};
export function WorkNoteChat({ requestId, onBack, messages: externalMessages, loading, onSend }: WorkNoteChatProps) {
export function WorkNoteChat({ requestId, onBack, messages: externalMessages, onSend }: WorkNoteChatProps) {
const routeParams = useParams<{ requestId: string }>();
const effectiveRequestId = requestId || routeParams.requestId || '';
const [message, setMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [activeTab, setActiveTab] = useState('chat');
const [searchTerm, setSearchTerm] = useState('');
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [showSidebar, setShowSidebar] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; attachmentId?: string } | null>(null);
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// console.log for debugging if needed
// console.log('WorkNoteChat onSend prop:', onSend);
// Get request info
const requestInfo = useMemo(() => {
@ -342,6 +302,52 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Extract all shared files from messages with attachments
const sharedFiles = useMemo(() => {
const files: any[] = [];
messages.forEach((msg) => {
if (msg.attachments && msg.attachments.length > 0) {
msg.attachments.forEach((attachment: any) => {
files.push({
attachmentId: attachment.attachmentId || attachment.attachment_id,
name: attachment.fileName || attachment.file_name || attachment.name || 'Untitled',
fileName: attachment.fileName || attachment.file_name || attachment.name || 'Untitled',
size: attachment.fileSize || attachment.file_size,
type: attachment.fileType || attachment.file_type || attachment.type || 'file',
uploadedBy: msg.user.name,
uploadedAt: msg.timestamp,
url: attachment.storageUrl || attachment.storage_url || attachment.url || '#'
});
});
}
});
return files;
}, [messages]);
// Get all existing participants for validation
const existingParticipants = useMemo(() => {
return participants.map(p => ({
email: (p.email || '').toLowerCase(),
participantType: p.role === 'Initiator' ? 'INITIATOR' :
p.role === 'Approver' ? 'APPROVER' :
p.role === 'Spectator' ? 'SPECTATOR' :
'PARTICIPANT',
name: p.name
}));
}, [participants]);
// Helper to format participant role (same as formatParticipantRole but for use in mapping)
const getFormattedRole = (role: string | undefined): string => {
if (!role) return 'Participant';
const roleUpper = role.toUpperCase();
switch (roleUpper) {
case 'INITIATOR': return 'Initiator';
case 'APPROVER': return 'Approver';
case 'SPECTATOR': return 'Spectator';
default: return role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
}
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
@ -350,6 +356,17 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
scrollToBottom();
}, [messages]);
// Helper to format participant role
const formatParticipantRole = (type: string): string => {
const typeUpper = type.toUpperCase();
switch (typeUpper) {
case 'INITIATOR': return 'Initiator';
case 'APPROVER': return 'Approver';
case 'SPECTATOR': return 'Spectator';
default: return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase();
}
};
// Load participants from backend workflow details
useEffect(() => {
(async () => {
@ -357,21 +374,43 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
const details = await getWorkflowDetails(effectiveRequestId);
const rows = Array.isArray(details?.participants) ? details.participants : [];
if (rows.length) {
const mapped: Participant[] = rows.map((p: any) => ({
name: p.userName || p.user_email || p.userEmail || 'User',
avatar: (p.userName || p.user_email || 'U').toString().split(' ').map((s: string)=>s[0]).filter(Boolean).join('').slice(0,2).toUpperCase(),
role: (p.participantType || p.participant_type || 'Participant').toString().toUpperCase() === 'SPECTATOR' ? 'Spectator' : 'Participant',
status: 'online', // presence to be wired via websocket later
email: p.userEmail || p.user_email || ''
}));
const mapped: Participant[] = rows.map((p: any) => {
const participantType = p.participantType || p.participant_type || 'participant';
const userId = p.userId || p.user_id || '';
return {
name: p.userName || p.user_name || p.user_email || p.userEmail || 'User',
avatar: (p.userName || p.user_name || p.user_email || 'U').toString().split(' ').map((s: string)=>s[0]).filter(Boolean).join('').slice(0,2).toUpperCase(),
role: formatParticipantRole(participantType.toString()),
status: 'offline', // will be updated by presence events
email: p.userEmail || p.user_email || '',
permissions: ['read', 'write', 'mention'], // default permissions, can be enhanced later
userId: userId // store userId for presence matching
} as any;
});
setParticipants(mapped);
}
} catch {}
})();
}, [effectiveRequestId]);
// Load current user ID on mount
useEffect(() => {
const userDataStr = localStorage.getItem('userData');
if (userDataStr) {
try {
const userData = JSON.parse(userDataStr);
const userId = userData?.id || userData?.userId || userData?.user_id || null;
setCurrentUserId(userId);
} catch (err) {
console.error('[WorkNoteChat] Failed to parse userData:', err);
}
}
}, []);
// Realtime updates via Socket.IO (standalone usage)
useEffect(() => {
if (!currentUserId) return; // Wait for currentUserId to be loaded
let joinedId = effectiveRequestId;
(async () => {
try {
@ -383,21 +422,90 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
try {
const base = (import.meta as any).env.VITE_BASE_URL || window.location.origin;
const s = getSocket(base);
joinRequestRoom(s, joinedId);
const handler = (payload: any) => {
const n = payload?.note || payload;
if (!n) return;
setMessages(prev => ([...prev, {
id: n.noteId || String(Date.now()),
user: { name: n.userName || 'User', avatar: (n.userName || 'U').slice(0,2).toUpperCase(), role: n.userRole || 'Participant' },
content: n.message || '',
timestamp: n.createdAt || new Date().toISOString()
} as any]));
// Optimistically mark self as online immediately
setParticipants(prev => prev.map(p =>
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
));
joinRequestRoom(s, joinedId, currentUserId);
// Handle new work notes
const noteHandler = (payload: any) => {
const n = payload?.note || payload;
if (!n) return;
// Prevent duplicates: check if message with same noteId already exists
setMessages(prev => {
if (prev.some(m => m.id === (n.noteId || n.id))) {
return prev; // Already exists, don't add
}
const userName = n.userName || n.user_name || 'User';
const userRole = n.userRole || n.user_role; // Get role directly from backend
const participantRole = getFormattedRole(userRole);
const noteUserId = n.userId || n.user_id;
return [...prev, {
id: n.noteId || String(Date.now()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: n.message || '',
timestamp: n.createdAt || new Date().toISOString(),
isCurrentUser: noteUserId === currentUserId,
attachments: Array.isArray(n.attachments) ? n.attachments.map((a: any) => ({
attachmentId: a.attachmentId || a.attachment_id,
name: a.fileName || a.file_name || a.name,
fileName: a.fileName || a.file_name || a.name,
url: a.storageUrl || a.storage_url || a.url || '#',
type: a.fileType || a.file_type || a.type || 'file',
fileType: a.fileType || a.file_type || a.type || 'file',
fileSize: a.fileSize || a.file_size
})) : undefined
} as any];
});
};
s.on('worknote:new', handler);
// Handle presence: user joined
const presenceJoinHandler = (data: { userId: string; requestId: string }) => {
console.log('[WorkNoteChat] User joined:', data);
setParticipants(prev => prev.map(p =>
(p as any).userId === data.userId ? { ...p, status: 'online' as const } : p
));
};
// Handle presence: user left
const presenceLeaveHandler = (data: { userId: string; requestId: string }) => {
console.log('[WorkNoteChat] User left:', data);
setParticipants(prev => prev.map(p =>
(p as any).userId === data.userId ? { ...p, status: 'offline' as const } : p
));
};
// Handle initial online users list
const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => {
console.log('[WorkNoteChat] Online users received:', data);
setParticipants(prev => prev.map(p => {
const pUserId = (p as any).userId || '';
const isOnline = data.userIds.includes(pUserId);
console.log(`[WorkNoteChat] User ${p.name} (${pUserId}): ${isOnline ? 'ONLINE' : 'offline'}`);
return { ...p, status: isOnline ? 'online' as const : 'offline' as const };
}));
};
s.on('worknote:new', noteHandler);
s.on('presence:join', presenceJoinHandler);
s.on('presence:leave', presenceLeaveHandler);
s.on('presence:online', presenceOnlineHandler);
// cleanup
const cleanup = () => {
s.off('worknote:new', handler);
s.off('worknote:new', noteHandler);
s.off('presence:join', presenceJoinHandler);
s.off('presence:leave', presenceLeaveHandler);
s.off('presence:online', presenceOnlineHandler);
leaveRequestRoom(s, joinedId);
};
(window as any).__wn_cleanup = cleanup;
@ -406,7 +514,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
return () => {
try { (window as any).__wn_cleanup?.(); } catch {}
};
}, [effectiveRequestId]);
}, [effectiveRequestId, currentUserId]);
const handleSendMessage = async () => {
if (message.trim() || selectedFiles.length > 0) {
@ -430,7 +538,8 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
}),
mentions: extractMentions(message),
isHighPriority: message.includes('!important') || message.includes('urgent'),
attachments: attachments.length > 0 ? attachments : undefined
attachments: attachments.length > 0 ? attachments : undefined,
isCurrentUser: true
};
// console.log('new message ->', newMessage, onSend);
// If external onSend provided, delegate to caller (RequestDetail will POST and refresh)
@ -441,12 +550,29 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
try {
await createWorkNoteMultipart(effectiveRequestId, { message }, selectedFiles);
const rows = await getWorkNotes(effectiveRequestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => ({
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const noteUserId = m.userId || m.user_id;
return {
id: m.noteId || m.id || String(Math.random()),
user: { name: m.userName || 'User', avatar: (m.userName || 'U').slice(0,2).toUpperCase(), role: m.userRole || 'Participant' },
user: {
name: m.userName || 'User',
avatar: (m.userName || 'U').slice(0,2).toUpperCase(),
role: m.userRole || 'Participant'
},
content: m.message || '',
timestamp: m.createdAt || new Date().toISOString(),
})) : [];
isCurrentUser: noteUserId === currentUserId,
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
attachmentId: a.attachmentId || a.attachment_id,
name: a.fileName || a.file_name || a.name,
fileName: a.fileName || a.file_name || a.name,
url: a.storageUrl || a.storage_url || a.url || '#',
type: a.fileType || a.file_type || a.type || 'file',
fileType: a.fileType || a.file_type || a.type || 'file',
fileSize: a.fileSize || a.file_size
})) : undefined
};
}) : [];
setMessages(mapped as any);
} catch {
setMessages(prev => [...prev, newMessage]);
@ -461,31 +587,89 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
useEffect(() => {
if (externalMessages && Array.isArray(externalMessages)) {
try {
const mapped: Message[] = externalMessages.map((m: any) => ({
id: m.noteId || m.id || String(Math.random()),
user: { name: m.userName || m.user?.name || 'User', avatar: (m.userName || 'U').slice(0,2).toUpperCase(), role: m.userRole || 'Participant' },
content: m.message || m.content || '',
timestamp: m.createdAt || m.timestamp || new Date().toISOString(),
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ name: a.fileName || a.name, url: a.storageUrl || a.url || '#', type: a.fileType || a.type || 'file' })) : undefined
}));
const mapped: Message[] = externalMessages.map((m: any) => {
const userName = m.userName || m.user_name || m.user?.name || 'User';
const userRole = m.userRole || m.user_role; // Get role directly from backend
const participantRole = getFormattedRole(userRole);
const noteUserId = m.userId || m.user_id;
console.log('[WorkNoteChat] Mapping external message:', {
rawMessage: m,
extracted: { userName, userRole, participantRole }
});
return {
id: m.noteId || m.note_id || m.id || String(Math.random()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: m.message || m.content || '',
timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(),
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
attachmentId: a.attachmentId || a.attachment_id,
name: a.fileName || a.file_name || a.name,
fileName: a.fileName || a.file_name || a.name,
url: a.storageUrl || a.storage_url || a.url || '#',
type: a.fileType || a.file_type || a.type || 'file',
fileType: a.fileType || a.file_type || a.type || 'file',
fileSize: a.fileSize || a.file_size
})) : undefined,
isCurrentUser: noteUserId === currentUserId
};
});
console.log('[WorkNoteChat] Mapped messages:', mapped);
setMessages(mapped);
} catch {}
} catch (err) {
console.error('[WorkNoteChat] Error mapping messages:', err);
}
} else {
// Fallback: load from backend if parent didn't pass messages
(async () => {
try {
const rows = await getWorkNotes(effectiveRequestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => ({
id: m.noteId || m.id || String(Math.random()),
user: { name: m.userName || 'User', avatar: (m.userName || 'U').slice(0,2).toUpperCase(), role: m.userRole || 'Participant' },
content: m.message || '',
timestamp: m.createdAt || new Date().toISOString(),
})) : [];
console.log('[WorkNoteChat] Loaded work notes from backend:', rows);
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role; // Get role directly from backend
const participantRole = getFormattedRole(userRole);
const noteUserId = m.userId || m.user_id;
console.log('[WorkNoteChat] Mapping note:', {
rawNote: m,
extracted: { userName, userRole, participantRole }
});
return {
id: m.noteId || m.note_id || m.id || String(Math.random()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(),
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
attachmentId: a.attachmentId || a.attachment_id,
name: a.fileName || a.file_name || a.name,
fileName: a.fileName || a.file_name || a.name,
url: a.storageUrl || a.storage_url || a.url || '#',
type: a.fileType || a.file_type || a.type || 'file',
fileType: a.fileType || a.file_type || a.type || 'file',
fileSize: a.fileSize || a.file_size
})) : undefined,
isCurrentUser: noteUserId === currentUserId
};
}) : [];
setMessages(mapped as any);
} catch {}
} catch (err) {
console.error('[WorkNoteChat] Error loading work notes:', err);
}
})();
}
}, [externalMessages, effectiveRequestId]);
}, [externalMessages, effectiveRequestId, participants]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
@ -511,6 +695,43 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
fileInputRef.current?.click();
};
// Handler for adding spectator
const handleAddSpectator = async (email: string) => {
try {
await addSpectator(effectiveRequestId, email);
// Refresh participants list
const details = await getWorkflowDetails(effectiveRequestId);
const rows = Array.isArray(details?.participants) ? details.participants : [];
if (rows.length) {
const mapped: Participant[] = rows.map((p: any) => {
const participantType = p.participantType || p.participant_type || 'participant';
const userId = p.userId || p.user_id || '';
const userName = p.userName || p.user_name || p.userEmail || p.user_email || 'User';
const userEmail = p.userEmail || p.user_email || '';
const initials = userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase();
return {
name: userName,
avatar: initials,
role: formatParticipantRole(participantType),
status: 'online' as const,
email: userEmail,
lastSeen: undefined,
permissions: ['read'],
userId
};
});
setParticipants(mapped);
}
setShowAddSpectatorModal(false);
alert('Spectator added successfully');
} catch (error: any) {
console.error('Failed to add spectator:', error);
alert(error?.response?.data?.error || 'Failed to add spectator');
throw error;
}
};
// Emoji picker data - Expanded collection
const emojiList = [
// Smileys & Emotions
@ -605,7 +826,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
};
return (
<div className="h-screen max-h-screen flex flex-col bg-gray-50 overflow-hidden">
<div className="h-full flex flex-col bg-gray-50 overflow-hidden">
{/* Header - Fixed at top */}
<div className="bg-white border-b border-gray-200 px-3 sm:px-6 py-4 flex-shrink-0">
<div className="flex items-center justify-between">
@ -647,14 +868,6 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowSidebar(true)}
className="lg:hidden"
>
<Users className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
@ -692,7 +905,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
</div>
{/* Chat Tab */}
<TabsContent value="chat" className="flex-1 flex flex-col m-0 overflow-hidden min-h-0">
<TabsContent value="chat" className="m-0 data-[state=active]:flex data-[state=active]:flex-1 data-[state=active]:flex-col overflow-hidden min-h-0">
{/* Search Bar - Fixed */}
<div className="bg-white border-b border-gray-200 px-2 sm:px-3 lg:px-6 py-2 sm:py-3 flex-shrink-0">
<div className="relative">
@ -709,9 +922,13 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
{/* Messages Area - Fixed height with proper scrolling */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-2 sm:px-3 lg:px-6 py-2 sm:py-4 min-h-0">
<div className="space-y-3 sm:space-y-6 max-w-full">
{filteredMessages.map((msg) => (
<div key={msg.id} className={`flex gap-2 sm:gap-3 lg:gap-4 ${msg.isSystem ? 'justify-center' : ''}`}>
{!msg.isSystem && (
{filteredMessages.map((msg) => {
// Check if this message is from the current user
const isCurrentUser = (msg as any).isCurrentUser || false;
return (
<div key={msg.id} className={`flex gap-2 sm:gap-3 lg:gap-4 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
{!msg.isSystem && !isCurrentUser && (
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0 ring-1 sm:ring-2 ring-white shadow-sm">
<AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${
msg.user.role === 'Initiator' ? 'bg-green-600' :
@ -724,7 +941,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
</Avatar>
)}
<div className={`flex-1 min-w-0 ${msg.isSystem ? 'text-center max-w-xs sm:max-w-md mx-auto' : ''}`}>
<div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-xs sm:max-w-md mx-auto' : ''}`}>
{msg.isSystem ? (
<div className="inline-flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-1.5 sm:py-2 bg-gray-100 rounded-full">
<Activity className="w-3 h-3 sm:w-4 sm:h-4 text-gray-500 flex-shrink-0" />
@ -734,14 +951,16 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
) : (
<div>
{/* Message Header */}
<div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2 flex-wrap">
<span className="font-semibold text-gray-900 text-sm sm:text-base truncate">{msg.user.name}</span>
<div className={`flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2 flex-wrap ${isCurrentUser ? 'justify-end' : ''}`}>
<span className="font-semibold text-gray-900 text-sm sm:text-base truncate">
{msg.user.name} {isCurrentUser && <span className="text-xs text-gray-500 font-normal">(you)</span>}
</span>
<Badge variant="outline" className="text-xs flex-shrink-0">
{msg.user.role}
</Badge>
<span className="text-xs text-gray-500 flex items-center gap-1 flex-shrink-0">
<Clock className="w-3 h-3" />
{msg.timestamp}
{formatDateTime(msg.timestamp)}
</span>
{msg.isHighPriority && (
<Badge variant="destructive" className="text-xs flex-shrink-0">
@ -752,7 +971,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
</div>
{/* Message Content */}
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4 shadow-sm">
<div className={`rounded-lg border p-3 sm:p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}>
<div
className="text-gray-800 leading-relaxed text-sm sm:text-base"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
@ -762,19 +981,84 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-100">
<div className="space-y-2">
{msg.attachments.map((attachment, index) => (
<div key={index} className="flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-lg">
{msg.attachments.map((attachment: any, index) => {
const fileSize = attachment.fileSize || attachment.file_size;
const fileName = attachment.fileName || attachment.file_name || attachment.name;
const fileType = attachment.fileType || attachment.file_type || attachment.type || '';
const attachmentId = attachment.attachmentId || attachment.attachment_id;
return (
<div key={index} className="flex items-center gap-2 sm:gap-3 p-2 sm:p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
<div className="flex-shrink-0">
<FileIcon type={attachment.type} />
<FileIcon type={fileType} />
</div>
<span className="text-xs sm:text-sm font-medium text-gray-700 flex-1 truncate">
{attachment.name}
</span>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 flex-shrink-0">
<div className="flex-1 min-w-0">
<p className="text-xs sm:text-sm font-medium text-gray-700 truncate">
{fileName}
</p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</p>
)}
</div>
{/* Preview button for images and PDFs */}
{attachmentId && (() => {
const type = (fileType || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
type.includes('jpg') || type.includes('jpeg') ||
type.includes('png') || type.includes('gif');
})() && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 sm:h-8 sm:w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
setPreviewFile({
fileName,
fileType,
fileUrl: previewUrl,
fileSize,
attachmentId
});
}}
title="Preview file"
>
<Eye className="w-3 h-3 sm:w-4 sm:h-4" />
</Button>
)}
{/* Download button */}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 sm:h-8 sm:w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (!attachmentId) {
alert('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
alert('Failed to download file');
}
}}
title="Download file"
>
<Download className="w-3 h-3 sm:w-4 sm:h-4" />
</Button>
</div>
))}
);
})}
</div>
</div>
)}
@ -810,25 +1094,18 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
</div>
)}
</div>
{!msg.isSystem && isCurrentUser && (
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0 ring-1 sm:ring-2 ring-white shadow-sm">
<AvatarFallback className="bg-blue-500 text-white font-semibold text-xs sm:text-sm">
{msg.user.avatar}
</AvatarFallback>
</Avatar>
)}
</div>
))}
);
})}
{isTyping && (
<div className="flex gap-2 sm:gap-4">
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0">
<AvatarFallback className="bg-gray-400 text-white">
<div className="flex gap-0.5 sm:gap-1">
<div className="w-1 h-1 bg-white rounded-full animate-bounce"></div>
<div className="w-1 h-1 bg-white rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-1 h-1 bg-white rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</AvatarFallback>
</Avatar>
<div className="flex items-center text-xs sm:text-sm text-gray-500 bg-gray-100 px-3 sm:px-4 py-2 rounded-lg">
Someone is typing...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
@ -973,8 +1250,8 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
</TabsContent>
{/* Files Tab */}
<TabsContent value="files" className="flex-1 p-2 sm:p-3 lg:p-6 m-0 pt-0">
<div className="max-w-full">
<TabsContent value="files" className="m-0 data-[state=active]:flex data-[state=active]:flex-1 data-[state=active]:flex-col overflow-hidden min-h-0">
<div className="flex-1 overflow-y-auto px-2 sm:px-3 lg:px-6 pt-4 pb-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 sm:mb-6 gap-3">
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Shared Files</h3>
<Button className="gap-2 text-sm h-9">
@ -984,44 +1261,98 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
</Button>
</div>
{sharedFiles.length === 0 ? (
<div className="col-span-full text-center py-12">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">No files shared yet</p>
<p className="text-gray-400 text-xs mt-1">Files shared in chat will appear here</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{MOCK_DOCUMENTS.map((doc, index) => (
<Card key={index} className="hover:shadow-lg transition-shadow">
{sharedFiles.map((file, index) => {
const fileType = (file.type || '').toLowerCase();
const displayType = fileType.includes('pdf') ? 'PDF' :
fileType.includes('excel') || fileType.includes('spreadsheet') ? 'Excel' :
fileType.includes('word') || fileType.includes('document') ? 'Word' :
fileType.includes('powerpoint') || fileType.includes('presentation') ? 'PowerPoint' :
fileType.includes('image') || fileType.includes('jpg') || fileType.includes('png') ? 'Image' :
'File';
return (
<Card key={file.attachmentId || index} className="hover:shadow-lg transition-shadow">
<CardContent className="p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
<FileIcon type={doc.type} />
<FileIcon type={displayType} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 truncate text-sm sm:text-base">{doc.name}</h4>
<h4 className="font-medium text-gray-900 truncate text-sm sm:text-base" title={file.name}>
{file.name}
</h4>
<p className="text-xs sm:text-sm text-gray-600 mt-1">
{doc.size} {doc.type}
{file.size ? formatFileSize(file.size) : 'Unknown size'} {displayType}
</p>
<p className="text-xs text-gray-500 mt-1">
by {doc.uploadedBy} {doc.uploadedAt}
by {file.uploadedBy} {formatDateTime(file.uploadedAt)}
</p>
</div>
</div>
<div className="flex items-center gap-2 mt-3 sm:mt-4">
<Button variant="outline" size="sm" className="flex-1 text-xs sm:text-sm h-8 sm:h-9">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs sm:text-sm h-8 sm:h-9 hover:bg-purple-50 hover:text-purple-600 hover:border-purple-200"
onClick={() => {
if (file.attachmentId) {
const previewUrl = getWorkNoteAttachmentPreviewUrl(file.attachmentId);
setPreviewFile({
fileName: file.name,
fileType: file.type,
fileUrl: previewUrl,
fileSize: file.size,
attachmentId: file.attachmentId
});
}
}}
disabled={!file.attachmentId}
>
<Eye className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
View
</Button>
<Button variant="outline" size="sm" className="flex-1 text-xs sm:text-sm h-8 sm:h-9">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs sm:text-sm h-8 sm:h-9 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200"
onClick={async () => {
if (!file.attachmentId) {
alert('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(file.attachmentId);
} catch (error) {
console.error('Download failed:', error);
alert('Failed to download file');
}
}}
disabled={!file.attachmentId}
>
<Download className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
Download
</Button>
</div>
</CardContent>
</Card>
))}
);
})}
</div>
)}
</div>
</TabsContent>
{/* Activity Tab */}
<TabsContent value="activity" className="flex-1 p-2 sm:p-3 lg:p-6 m-0 pt-0">
<div className="max-w-full">
<TabsContent value="activity" className="m-0 data-[state=active]:flex data-[state=active]:flex-1 data-[state=active]:flex-col overflow-hidden min-h-0">
<div className="flex-1 overflow-y-auto px-2 sm:px-3 lg:px-6 pt-4 pb-6">
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-4 sm:mb-6">Recent Activity</h3>
<div className="space-y-3 sm:space-y-4">
{messages.filter(msg => msg.isSystem).map((msg) => (
@ -1068,12 +1399,15 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
</Button>
</div>
<div className="space-y-3 sm:space-y-4">
{participants.map((participant, index) => (
{participants.map((participant, index) => {
const isCurrentUser = (participant as any).userId === currentUserId;
return (
<div key={index} className="flex items-center gap-3">
<div className="relative">
<Avatar className="h-9 w-9 sm:h-10 sm:w-10">
<AvatarFallback className={`text-white font-semibold text-sm ${
participant.role === 'Initiator' ? 'bg-green-600' : 'bg-slate-600'
participant.role === 'Initiator' ? 'bg-green-600' :
isCurrentUser ? 'bg-blue-500' : 'bg-slate-600'
}`}>
{participant.avatar}
</AvatarFallback>
@ -1081,7 +1415,9 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-white ${getStatusColor(participant.status)}`}></div>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate text-sm sm:text-base">{participant.name}</p>
<p className="font-medium text-gray-900 truncate text-sm sm:text-base">
{participant.name} {isCurrentUser && <span className="text-xs text-gray-500 font-normal">(you)</span>}
</p>
<div className="flex items-center gap-2">
<p className="text-xs text-gray-500">{participant.role}</p>
<span className="text-xs text-gray-400"></span>
@ -1095,16 +1431,22 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
))}
);
})}
</div>
</div>
<div className="p-4 sm:p-6">
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
<div className="space-y-2">
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
<Users className="h-4 w-4" />
Add Participant
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 h-9 text-sm"
onClick={() => setShowAddSpectatorModal(true)}
>
<Eye className="h-4 w-4" />
Add Spectator
</Button>
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
<Bell className="h-4 w-4" />
@ -1118,6 +1460,30 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, lo
</div>
</div>
</div>
{/* File Preview Modal */}
{previewFile && (
<FilePreview
fileName={previewFile.fileName}
fileType={previewFile.fileType}
fileUrl={previewFile.fileUrl}
fileSize={previewFile.fileSize}
attachmentId={previewFile.attachmentId}
onDownload={downloadWorkNoteAttachment}
open={!!previewFile}
onClose={() => setPreviewFile(null)}
/>
)}
{/* Add Spectator Modal */}
<AddSpectatorModal
open={showAddSpectatorModal}
onClose={() => setShowAddSpectatorModal(false)}
onConfirm={handleAddSpectator}
requestIdDisplay={effectiveRequestId}
requestTitle={requestInfo.title}
existingParticipants={existingParticipants}
/>
</div>
);
}

View File

@ -0,0 +1,732 @@
import { useState, useRef, useEffect } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { formatDateTime } from '@/utils/dateFormatter';
import { FilePreview } from '@/components/common/FilePreview';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
Send,
Smile,
Paperclip,
FileText,
Download,
Clock,
Flag,
X,
FileSpreadsheet,
Image,
AtSign,
Activity,
Search,
MessageSquare,
Plus,
Eye
} from 'lucide-react';
import { Input } from '@/components/ui/input';
interface Message {
id: string;
user: {
name: string;
avatar: string;
role: string;
};
content: string;
timestamp: string;
mentions?: string[];
isSystem?: boolean;
attachments?: {
name: string;
url: string;
type: string;
}[];
reactions?: {
emoji: string;
users: string[];
}[];
isHighPriority?: boolean;
}
interface WorkNoteChatSimpleProps {
requestId: string;
messages?: any[];
loading?: boolean;
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
}
const formatMessage = (content: string) => {
return content
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
.replace(/\n/g, '<br />');
};
const FileIcon = ({ type }: { type: string }) => {
const iconClass = "w-4 h-4";
const lowerType = type.toLowerCase();
if (lowerType.includes('pdf')) return <FileText className={`${iconClass} text-red-600`} />;
if (lowerType.includes('excel') || lowerType.includes('spreadsheet') || lowerType.includes('xlsx')) return <FileSpreadsheet className={`${iconClass} text-green-600`} />;
if (lowerType.includes('powerpoint') || lowerType.includes('presentation') || lowerType.includes('pptx')) return <FileText className={`${iconClass} text-orange-600`} />;
if (lowerType.includes('word') || lowerType.includes('document') || lowerType.includes('docx')) return <FileText className={`${iconClass} text-blue-600`} />;
if (lowerType.includes('image') || lowerType.includes('png') || lowerType.includes('jpg') || lowerType.includes('jpeg')) return <Image className={`${iconClass} text-purple-600`} />;
return <Paperclip className={`${iconClass} text-gray-600`} />;
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const formatParticipantRole = (role: string | undefined): string => {
if (!role) return 'Participant';
const roleUpper = role.toUpperCase();
switch (roleUpper) {
case 'INITIATOR': return 'Initiator';
case 'APPROVER': return 'Approver';
case 'SPECTATOR': return 'Spectator';
default: return role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
}
};
export function WorkNoteChatSimple({ requestId, messages: externalMessages, loading, onSend }: WorkNoteChatSimpleProps) {
const [message, setMessage] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; attachmentId?: string } | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const filteredMessages = messages.filter(msg =>
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// Load current user ID on mount
useEffect(() => {
const userDataStr = localStorage.getItem('userData');
if (userDataStr) {
try {
const userData = JSON.parse(userDataStr);
const userId = userData?.id || userData?.userId || userData?.user_id || null;
setCurrentUserId(userId);
} catch (err) {
console.error('[WorkNoteChat] Failed to parse userData:', err);
}
}
}, []);
// Realtime updates via Socket.IO
useEffect(() => {
if (!currentUserId) return; // Wait for currentUserId to be loaded
let joinedId = requestId;
(async () => {
try {
const details = await getWorkflowDetails(requestId);
if (details?.workflow?.requestId) {
joinedId = details.workflow.requestId;
}
} catch {}
try {
const base = (import.meta as any).env.VITE_BASE_URL || window.location.origin;
const s = getSocket(base);
joinRequestRoom(s, joinedId, currentUserId || undefined);
const noteHandler = (payload: any) => {
const n = payload?.note || payload;
if (!n) return;
console.log('[WorkNoteChat] Received note via socket:', n);
setMessages(prev => {
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
console.log('[WorkNoteChat] Duplicate detected, skipping');
return prev;
}
const userName = n.userName || n.user_name || 'User';
const userRole = n.userRole || n.user_role;
const participantRole = formatParticipantRole(userRole);
const noteUserId = n.userId || n.user_id;
const newMsg = {
id: n.noteId || n.note_id || String(Date.now()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: n.message || '',
timestamp: n.createdAt || n.created_at || new Date().toISOString(),
isCurrentUser: noteUserId === currentUserId
} as any;
console.log('[WorkNoteChat] Adding new message:', newMsg);
return [...prev, newMsg];
});
};
s.on('worknote:new', noteHandler);
const cleanup = () => {
s.off('worknote:new', noteHandler);
leaveRequestRoom(s, joinedId);
};
(window as any).__wn_cleanup = cleanup;
} catch (err) {
console.error('[WorkNoteChat] Socket setup error:', err);
}
})();
return () => {
try { (window as any).__wn_cleanup?.(); } catch {}
};
}, [requestId, currentUserId]);
const handleSendMessage = async () => {
if (message.trim() || selectedFiles.length > 0) {
if (onSend) {
try { await onSend(message, selectedFiles); } catch { /* ignore */ }
} else {
try {
await createWorkNoteMultipart(requestId, { message }, selectedFiles);
const rows = await getWorkNotes(requestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole);
return {
id: m.noteId || m.note_id || m.id || String(Math.random()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(),
};
}) : [];
setMessages(mapped as any);
} catch {}
}
setMessage('');
setSelectedFiles([]);
}
};
// Load messages
useEffect(() => {
if (externalMessages && Array.isArray(externalMessages)) {
try {
const mapped: Message[] = externalMessages.map((m: any) => {
const userName = m.userName || m.user_name || m.user?.name || 'User';
const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id;
return {
id: m.noteId || m.note_id || m.id || String(Math.random()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: m.message || m.content || '',
timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(),
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
attachmentId: a.attachmentId || a.attachment_id,
name: a.fileName || a.file_name || a.name,
fileName: a.fileName || a.file_name || a.name,
url: a.storageUrl || a.storage_url || a.url || '#',
type: a.fileType || a.file_type || a.type || 'file',
fileType: a.fileType || a.file_type || a.type || 'file',
fileSize: a.fileSize || a.file_size
})) : undefined,
isCurrentUser: noteUserId === currentUserId
} as any;
});
setMessages(mapped);
} catch {}
} else {
(async () => {
try {
const rows = await getWorkNotes(requestId);
console.log('[WorkNoteChat] Loaded work notes:', rows);
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id;
return {
id: m.noteId || m.note_id || m.id || String(Math.random()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(),
isCurrentUser: noteUserId === currentUserId
};
}) : [];
setMessages(mapped as any);
} catch (err) {
console.error('[WorkNoteChat] Error loading notes:', err);
}
})();
}
}, [externalMessages, requestId, currentUserId]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const filesArray = Array.from(e.target.files);
setSelectedFiles(prev => [...prev, ...filesArray]);
}
};
const handleRemoveFile = (index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleEmojiSelect = (emoji: string) => {
setMessage(prev => prev + emoji);
setShowEmojiPicker(false);
};
const handleAttachmentClick = () => {
fileInputRef.current?.click();
};
const emojiList = [
'😊', '😂', '🤣', '😁', '😃', '😄', '😅', '😆', '😉', '😌',
'😍', '🥰', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤩', '🤔',
'👍', '👎', '✊', '👊', '👏', '🙌', '👐', '🤝', '🙏',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '💔',
'💼', '📊', '📈', '📉', '💻', '📱', '📧', '✉️', '📄', '📝',
'✅', '✔️', '☑️', '🎯', '🏆', '⭐', '🌟', '✨', '🔥', '💯', '🎉',
'⚠️', '🚫', '❌', '⛔', '❗', '❓', '💢', '💬', '💭',
'⏰', '⏱️', '📅', '📆', '🗓️',
'🚀', '🎯', '🔍', '🔔', '💡'
];
const extractMentions = (text: string): string[] => {
const mentionRegex = /@([\w\s]+)(?=\s|$|[.,!?])/g;
const mentions: string[] = [];
let match;
while ((match = mentionRegex.exec(text)) !== null) {
if (match[1]) {
mentions.push(match[1].trim());
}
}
return mentions;
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const addReaction = (messageId: string, emoji: string) => {
setMessages(prev => prev.map(msg => {
if (msg.id === messageId) {
const reactions = msg.reactions || [];
const existingReaction = reactions.find(r => r.emoji === emoji);
if (existingReaction) {
if (existingReaction.users.includes('You')) {
existingReaction.users = existingReaction.users.filter(u => u !== 'You');
if (existingReaction.users.length === 0) {
return { ...msg, reactions: reactions.filter(r => r.emoji !== emoji) };
}
} else {
existingReaction.users.push('You');
}
} else {
reactions.push({ emoji, users: ['You'] });
}
return { ...msg, reactions };
}
return msg;
}));
};
return (
<div className="h-full flex flex-col bg-gray-50">
{/* Header with Search */}
<div className="bg-white border-b border-gray-200 px-6 py-4 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">Work Notes</h2>
<p className="text-sm text-gray-600">{filteredMessages.length} message{filteredMessages.length !== 1 ? 's' : ''}</p>
</div>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search messages..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-gray-50 border-gray-200 h-10"
/>
</div>
</div>
{/* Messages Area - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-4">
<div className="space-y-6 max-w-full">
{filteredMessages.map((msg) => {
const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You';
return (
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
{!msg.isSystem && !isCurrentUser && (
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
<AvatarFallback className={`text-white font-semibold text-sm ${
msg.user.role === 'Initiator' ? 'bg-green-600' :
msg.user.role === 'Approver' ? 'bg-blue-600' :
'bg-slate-600'
}`}>
{msg.user.avatar}
</AvatarFallback>
</Avatar>
)}
<div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}>
{msg.isSystem ? (
<div className="inline-flex items-center gap-3 px-4 py-2 bg-gray-100 rounded-full">
<Activity className="w-4 h-4 text-gray-500 flex-shrink-0" />
<span className="text-sm text-gray-700">{msg.content}</span>
<span className="text-xs text-gray-500">{formatDateTime(msg.timestamp)}</span>
</div>
) : (
<div>
{/* Message Header */}
<div className={`flex items-center gap-3 mb-2 flex-wrap ${isCurrentUser ? 'justify-end' : ''}`}>
<span className="font-semibold text-gray-900 text-base">
{msg.user.name} {isCurrentUser && <span className="text-xs text-gray-500 font-normal">(you)</span>}
</span>
<Badge variant="outline" className="text-xs flex-shrink-0">
{msg.user.role}
</Badge>
<span className="text-xs text-gray-500 flex items-center gap-1 flex-shrink-0">
<Clock className="w-3 h-3" />
{formatDateTime(msg.timestamp)}
</span>
{msg.isHighPriority && (
<Badge variant="destructive" className="text-xs flex-shrink-0">
<Flag className="w-3 h-3 mr-1" />
Priority
</Badge>
)}
</div>
{/* Message Content */}
<div className={`rounded-lg border p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}>
<div
className="text-gray-800 leading-relaxed text-base"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/>
{/* Attachments */}
{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="space-y-2">
{msg.attachments.map((attachment: any, index) => {
const fileSize = attachment.fileSize || attachment.file_size;
const fileName = attachment.fileName || attachment.file_name || attachment.name;
const fileType = attachment.fileType || attachment.file_type || attachment.type || '';
const attachmentId = attachment.attachmentId || attachment.attachment_id;
return (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
<div className="flex-shrink-0">
<FileIcon type={fileType} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 truncate">
{fileName}
</p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</p>
)}
</div>
{/* Preview button for images and PDFs */}
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
setPreviewFile({
fileName,
fileType,
fileUrl: previewUrl,
fileSize,
attachmentId
});
}}
title="Preview file"
>
<Eye className="w-4 h-4" />
</Button>
)}
{/* Download button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (!attachmentId) {
alert('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
alert('Failed to download file');
}
}}
title="Download file"
>
<Download className="w-4 h-4" />
</Button>
</div>
);
})}
</div>
</div>
)}
{/* Reactions */}
{msg.reactions && msg.reactions.length > 0 && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-gray-100 flex-wrap">
{msg.reactions.map((reaction, index) => (
<button
key={index}
onClick={() => addReaction(msg.id, reaction.emoji)}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${
reaction.users.includes('You')
? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<span>{reaction.emoji}</span>
<span className="text-xs font-medium">{reaction.users.length}</span>
</button>
))}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
>
<Plus className="w-3 h-3" />
</Button>
</div>
)}
</div>
</div>
)}
</div>
{!msg.isSystem && isCurrentUser && (
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
<AvatarFallback className="bg-blue-500 text-white font-semibold text-sm">
{msg.user.avatar}
</AvatarFallback>
</Avatar>
)}
</div>
);
})}
<div ref={messagesEndRef} />
</div>
</div>
{/* Message Input - Fixed at bottom */}
<div className="bg-white border-t border-gray-200 p-4 flex-shrink-0">
<div className="max-w-full">
{/* Hidden File Input */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
className="hidden"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
/>
{/* Selected Files Preview */}
{selectedFiles.length > 0 && (
<div className="mb-3 space-y-2 max-h-32 overflow-y-auto">
{selectedFiles.map((file, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex-shrink-0">
<FileIcon type={file.type.split('/')[1] || 'file'} />
</div>
<span className="text-sm text-gray-700 flex-1 truncate">{file.name}</span>
<span className="text-xs text-gray-500 flex-shrink-0">{(file.size / 1024).toFixed(1)} KB</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveFile(index)}
className="h-6 w-6 p-0 hover:bg-red-100 flex-shrink-0"
>
<X className="h-3 w-3 text-red-600" />
</Button>
</div>
))}
</div>
)}
{/* Textarea with Emoji Picker */}
<div className="relative mb-2">
<Textarea
placeholder="Type your message... Use @username to mention someone"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
className="min-h-[60px] resize-none border-gray-200 focus:ring-blue-500 focus:border-blue-500 w-full"
rows={2}
/>
{/* Emoji Picker Popup */}
{showEmojiPicker && (
<div className="absolute bottom-full left-0 mb-2 bg-white border border-gray-200 rounded-lg shadow-xl p-3 z-50 w-96 max-h-80 overflow-y-auto">
<div className="flex items-center justify-between mb-3 sticky top-0 bg-white pb-2 border-b">
<span className="text-sm font-semibold text-gray-700">Pick an emoji</span>
<Button
variant="ghost"
size="sm"
onClick={() => setShowEmojiPicker(false)}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-10 gap-1">
{emojiList.map((emoji, index) => (
<button
key={index}
onClick={() => handleEmojiSelect(emoji)}
className="text-2xl hover:bg-gray-100 rounded p-1 transition-colors"
title={emoji}
>
{emoji}
</button>
))}
</div>
</div>
)}
</div>
{/* Action Buttons Row */}
<div className="flex items-center justify-between gap-2">
{/* Left side - Action buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={handleAttachmentClick}
title="Attach file"
>
<Paperclip className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Add emoji"
>
<Smile className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setMessage(prev => prev + '@')}
title="Mention someone"
>
<AtSign className="h-4 w-4" />
</Button>
</div>
{/* Right side - Character count and Send button */}
<div className="flex items-center gap-2 ml-auto flex-shrink-0">
<span className="text-xs text-gray-500 whitespace-nowrap">
{message.length}/2000
</span>
<Button
onClick={handleSendMessage}
disabled={!message.trim() && selectedFiles.length === 0}
className="bg-blue-600 hover:bg-blue-700 h-9 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
size="sm"
>
<Send className="h-4 w-4 mr-2" />
<span>Send</span>
</Button>
</div>
</div>
</div>
</div>
{/* File Preview Modal */}
{previewFile && (
<FilePreview
fileName={previewFile.fileName}
fileType={previewFile.fileType}
fileUrl={previewFile.fileUrl}
fileSize={previewFile.fileSize}
attachmentId={previewFile.attachmentId}
onDownload={downloadWorkNoteAttachment}
open={!!previewFile}
onClose={() => setPreviewFile(null)}
/>
)}
</div>
);
}

View File

@ -1 +1,2 @@
export { WorkNoteChat } from './WorkNoteChat';
export { WorkNoteChatSimple } from './WorkNoteChatSimple';

View File

@ -268,21 +268,21 @@ export function ClaimManagementDetail({
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-white border border-gray-200 shadow-sm mb-6">
<TabsTrigger value="overview" className="gap-2">
<ClipboardList className="w-4 h-4" />
<TabsList className="grid w-full grid-cols-4 bg-gray-100 h-10 mb-6">
<TabsTrigger value="overview" className="flex items-center gap-2 text-xs sm:text-sm px-2">
<ClipboardList className="w-3 h-3 sm:w-4 sm:h-4" />
Overview
</TabsTrigger>
<TabsTrigger value="workflow" className="gap-2">
<TrendingUp className="w-4 h-4" />
<TabsTrigger value="workflow" className="flex items-center gap-2 text-xs sm:text-sm px-2">
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4" />
Workflow (8-Steps)
</TabsTrigger>
<TabsTrigger value="documents" className="gap-2">
<FileText className="w-4 h-4" />
<TabsTrigger value="documents" className="flex items-center gap-2 text-xs sm:text-sm px-2">
<FileText className="w-3 h-3 sm:w-4 sm:h-4" />
Documents
</TabsTrigger>
<TabsTrigger value="activity" className="gap-2">
<Activity className="w-4 h-4" />
<TabsTrigger value="activity" className="flex items-center gap-2 text-xs sm:text-sm px-2">
<Activity className="w-3 h-3 sm:w-4 sm:h-4" />
Activity
</TabsTrigger>
</TabsList>
@ -424,7 +424,7 @@ export function ClaimManagementDetail({
<div className="space-y-6">
{/* Quick Actions */}
<Card>
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="text-base">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
@ -468,7 +468,7 @@ export function ClaimManagementDetail({
{/* Spectators */}
{claim.spectators && claim.spectators.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="text-base">Spectators</CardTitle>
</CardHeader>
<CardContent className="space-y-3">

View File

@ -445,8 +445,37 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
};
const addUser = (user: any, type: 'approvers' | 'spectators' | 'ccList' | 'invitedUsers') => {
const userEmail = (user.email || '').toLowerCase();
const currentList = formData[type];
if (!currentList.find((u: any) => u.id === user.id)) {
// Check if user is already in the target list
if (currentList.find((u: any) => u.id === user.id || (u.email || '').toLowerCase() === userEmail)) {
alert(`${user.name || user.email} is already added as ${type.slice(0, -1)}.`);
return;
}
// Prevent adding same user in different roles
if (type === 'spectators') {
// Check if user is already an approver
const isApprover = formData.approvers.find((a: any) =>
a.id === user.id || (a.email || '').toLowerCase() === userEmail
);
if (isApprover) {
alert(`${user.name || user.email} is already an approver and cannot be added as a spectator.`);
return;
}
} else if (type === 'approvers') {
// Check if user is already a spectator
const isSpectator = formData.spectators.find((s: any) =>
s.id === user.id || (s.email || '').toLowerCase() === userEmail
);
if (isSpectator) {
alert(`${user.name || user.email} is already a spectator and cannot be added as an approver.`);
return;
}
}
// Add user to the list
const updatedList = [...currentList, user];
updateFormData(type, updatedList);
@ -454,7 +483,6 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
if (type === 'approvers') {
const maxApproverLevel = Math.max(...updatedList.map((a: any) => a.level), 0);
updateFormData('maxLevel', maxApproverLevel);
}
}
};
@ -1430,13 +1458,24 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
key={u.userId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => {
// Prevent adding an approver as spectator
const approverIds = (formData.approvers || []).map((a: any) => a?.userId).filter(Boolean);
const approverEmails = (formData.approvers || []).map((a: any) => a?.email?.toLowerCase?.()).filter(Boolean);
if (approverIds.includes(u.userId) || approverEmails.includes((u.email || '').toLowerCase())) {
alert('This user is already an approver and cannot be added as a spectator.');
// Check if user is already a spectator
const spectatorIds = (formData.spectators || []).map((s: any) => s?.id).filter(Boolean);
const spectatorEmails = (formData.spectators || []).map((s: any) => s?.email?.toLowerCase?.()).filter(Boolean);
if (spectatorIds.includes(u.userId) || spectatorEmails.includes((u.email || '').toLowerCase())) {
alert(`${u.displayName || u.email} is already a spectator and cannot be added as an approver.`);
return;
}
// Check if user is already another approver
const approverEmails = (formData.approvers || [])
.filter((_, i) => i !== index) // Exclude current position
.map((a: any) => a?.email?.toLowerCase?.())
.filter(Boolean);
if (approverEmails.includes((u.email || '').toLowerCase())) {
alert(`${u.displayName || u.email} is already an approver at another level.`);
return;
}
const updated = [...formData.approvers];
updated[index] = {
...updated[index],
@ -1777,11 +1816,26 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
key={u.userId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => {
// Check if user is already an approver
const approverIds = (formData.approvers || []).map((a: any) => a?.userId).filter(Boolean);
const approverEmails = (formData.approvers || []).map((a: any) => a?.email?.toLowerCase?.()).filter(Boolean);
if (approverIds.includes(u.userId) || approverEmails.includes((u.email || '').toLowerCase())) {
alert(`${u.displayName || u.email} is already an approver and cannot be added as a spectator.`);
setEmailInput('');
setSpectatorSearchResults([]);
return;
}
// Add selected spectator directly with precise id/name/email
const spectator = {
id: u.userId,
name: u.displayName || [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email.split('@')[0],
email: u.email,
avatar: (u.displayName || u.email).split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase(),
role: 'Spectator',
department: u.department || '',
level: 1,
canClose: false
} as any;
addUser(spectator, 'spectators');
setEmailInput('');

View File

@ -20,6 +20,7 @@ import {
} from 'lucide-react';
import { motion } from 'framer-motion';
import workflowApi from '@/services/workflowApi';
import { formatDateShort } from '@/utils/dateFormatter';
interface MyRequestsProps {
onViewRequest: (requestId: string, requestTitle?: string) => void;
@ -28,6 +29,40 @@ interface MyRequestsProps {
// Removed mock data; list renders API data only
// Helper to calculate due date from created date and TAT hours
const calculateDueDate = (createdAt: string, tatHours: number, priority: string): string => {
if (!createdAt || !tatHours) return '';
try {
const startDate = new Date(createdAt);
if (priority === 'express') {
// Express: Calendar days (includes weekends)
const dueDate = new Date(startDate);
dueDate.setHours(dueDate.getHours() + tatHours);
return dueDate.toISOString();
} else {
// Standard: Working days (8 hours per day, skip weekends)
let remainingHours = tatHours;
let currentDate = new Date(startDate);
while (remainingHours > 0) {
// Skip weekends (Saturday = 6, Sunday = 0)
if (currentDate.getDay() !== 0 && currentDate.getDay() !== 6) {
const hoursToAdd = Math.min(remainingHours, 8);
remainingHours -= hoursToAdd;
}
if (remainingHours > 0) {
currentDate.setDate(currentDate.getDate() + 1);
}
}
return currentDate.toISOString();
}
} catch (error) {
console.error('Error calculating due date:', error);
return '';
}
};
const getPriorityConfig = (priority: string) => {
switch (priority) {
@ -121,22 +156,31 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
// Convert API/dynamic requests to the format expected by this component
const sourceRequests = (apiRequests.length ? apiRequests : dynamicRequests);
const convertedDynamicRequests = sourceRequests.map((req: any) => ({
id: req.requestNumber || req.request_number || req.requestId || req.id || req.request_id, // Use requestNumber as primary identifier
requestId: req.requestId || req.id || req.request_id, // Keep requestId for API calls if needed
displayId: req.requestNumber || req.request_number || req.id,
title: req.title,
description: req.description,
status: (req.status || '').toString().toLowerCase().replace('in_progress','in-review'),
priority: (req.priority || '').toString().toLowerCase(),
department: req.department,
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
currentApprover: req.currentApprover?.name || req.currentApprover?.email || '—',
approverLevel: req.currentLevel && req.totalLevels ? `${req.currentLevel} of ${req.totalLevels}` : (req.currentStep && req.totalSteps ? `${req.currentStep} of ${req.totalSteps}` : '—'),
dueDate: req.dueDate ? new Date(req.dueDate).toISOString().split('T')[0] : undefined,
templateType: req.templateType,
templateName: req.templateName
}));
const convertedDynamicRequests = sourceRequests.map((req: any) => {
// Calculate due date
const totalTatHours = Number(req.totalTatHours || 0);
const createdAt = req.submittedAt || req.submitted_at || req.createdAt || req.created_at;
const priority = (req.priority || '').toString().toLowerCase();
const calculatedDueDate = calculateDueDate(createdAt, totalTatHours, priority);
return {
id: req.requestNumber || req.request_number || req.requestId || req.id || req.request_id, // Use requestNumber as primary identifier
requestId: req.requestId || req.id || req.request_id, // Keep requestId for API calls if needed
displayId: req.requestNumber || req.request_number || req.id,
title: req.title,
description: req.description,
status: (req.status || '').toString().toLowerCase().replace('in_progress','in-review'),
priority: priority,
department: req.department,
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
createdAt: createdAt,
currentApprover: req.currentApprover?.name || req.currentApprover?.email || '—',
approverLevel: req.currentLevel && req.totalLevels ? `${req.currentLevel} of ${req.totalLevels}` : (req.currentStep && req.totalSteps ? `${req.currentStep} of ${req.totalSteps}` : '—'),
dueDate: calculatedDueDate || (req.dueDate ? new Date(req.dueDate).toISOString().split('T')[0] : undefined),
templateType: req.templateType,
templateName: req.templateName
};
});
// Use only API/dynamic requests
const allRequests = convertedDynamicRequests;
@ -378,7 +422,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
</div>
<div className="text-right">
<span className="text-sm text-gray-500">
<span className="font-medium">Estimated completion:</span>
<span className="font-medium">Estimated completion:</span> {request.dueDate ? formatDateShort(request.dueDate) : 'Not set'}
</span>
</div>
</div>

View File

@ -8,6 +8,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Progress } from '@/components/ui/progress';
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, Eye, RefreshCw, Settings2, X } from 'lucide-react';
import workflowApi from '@/services/workflowApi';
import { formatDateShort } from '@/utils/dateFormatter';
interface Request {
id: string;
@ -23,6 +24,7 @@ interface Request {
dueDate?: string;
approvalStep?: string;
department?: string;
totalTatHours?: number;
}
interface OpenRequestsProps {
@ -94,6 +96,42 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
const [items, setItems] = useState<Request[]>([]);
const [loading, setLoading] = useState(false);
// Helper to calculate due date from created date and TAT hours
const calculateDueDate = (createdAt: string, tatHours: number, priority: string): string => {
if (!createdAt || !tatHours) return 'Not set';
try {
const startDate = new Date(createdAt);
if (priority === 'express') {
// Express: Calendar days (includes weekends)
const dueDate = new Date(startDate);
dueDate.setHours(dueDate.getHours() + tatHours);
return dueDate.toISOString();
} else {
// Standard: Working days (8 hours per day, skip weekends)
let remainingHours = tatHours;
let currentDate = new Date(startDate);
while (remainingHours > 0) {
// Skip weekends (Saturday = 6, Sunday = 0)
if (currentDate.getDay() !== 0 && currentDate.getDay() !== 6) {
const hoursToAdd = Math.min(remainingHours, 8);
remainingHours -= hoursToAdd;
}
if (remainingHours > 0) {
currentDate.setDate(currentDate.getDate() + 1);
}
}
return currentDate.toISOString();
}
} catch (error) {
console.error('Error calculating due date:', error);
return 'Error';
}
};
useEffect(() => {
let mounted = true;
(async () => {
@ -108,24 +146,33 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
? (result as any)
: [];
if (!mounted) return;
const mapped: Request[] = data.map((r: any) => ({
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
requestId: r.requestId, // Keep requestId for reference
// keep a display id for UI
displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title,
description: r.description,
status: ((r.status || '').toString().toUpperCase() === 'IN_PROGRESS') ? 'in-review' : 'pending',
priority: (r.priority || '').toString().toLowerCase(),
initiator: { name: (r.initiator?.displayName || r.initiator?.email || '—'), avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()) },
currentApprover: r.currentApprover ? { name: (r.currentApprover.name || r.currentApprover.email || '—'), avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()) } : undefined,
slaProgress: Number(r.sla?.percent || 0),
slaRemaining: r.sla?.remainingText || '—',
createdAt: r.submittedAt || '—',
dueDate: undefined,
approvalStep: undefined,
department: r.department
}));
const mapped: Request[] = data.map((r: any) => {
// Use totalTatHours directly from backend (already calculated)
const totalTatHours = Number(r.totalTatHours || 0);
const createdAt = r.submittedAt || r.submitted_at || r.createdAt || r.created_at;
const dueDate = calculateDueDate(createdAt, totalTatHours, (r.priority || '').toString().toLowerCase());
return {
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
requestId: r.requestId, // Keep requestId for reference
// keep a display id for UI
displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title,
description: r.description,
status: ((r.status || '').toString().toUpperCase() === 'IN_PROGRESS') ? 'in-review' : 'pending',
priority: (r.priority || '').toString().toLowerCase(),
initiator: { name: (r.initiator?.displayName || r.initiator?.email || '—'), avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()) },
currentApprover: r.currentApprover ? { name: (r.currentApprover.name || r.currentApprover.email || '—'), avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()) } : undefined,
slaProgress: Number(r.sla?.percent || 0),
slaRemaining: r.sla?.remainingText || '—',
createdAt: createdAt || '—',
dueDate: dueDate !== 'Not set' && dueDate !== 'Error' ? dueDate : undefined,
approvalStep: r.currentLevel ? `Step ${r.currentLevel} of ${r.totalLevels || '?'}` : undefined,
department: r.department,
totalTatHours
};
});
setItems(mapped);
} finally {
if (mounted) setLoading(false);
@ -464,12 +511,15 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
</div>
<div className="text-right">
<div className="flex items-center gap-4 text-xs text-gray-500">
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
Created {request.createdAt}
Created: {request.createdAt !== '—' ? formatDateShort(request.createdAt) : '—'}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Due: {request.dueDate ? formatDateShort(request.dueDate) : 'Not set'}
</span>
<span>Due {request.dueDate}</span>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
import { useParams, useNavigate } from 'react-router-dom';
import { WorkNoteChat } from '@/components/workNote/WorkNoteChat';
import { PageLayout } from '@/components/layout/PageLayout';
export function WorkNotes() {
const { requestId } = useParams<{ requestId: string }>();
const navigate = useNavigate();
const handleBack = () => {
navigate(`/request/${requestId}`);
};
const handleNavigate = (page: string) => {
navigate(`/${page}`);
};
const handleNewRequest = () => {
navigate('/new-request');
};
const handleLogout = () => {
// Logout will be handled by App.tsx
navigate('/login');
};
return (
<PageLayout
currentPage="work-notes"
onNavigate={handleNavigate}
onNewRequest={handleNewRequest}
onLogout={handleLogout}
>
<div className="h-full w-full overflow-hidden">
<WorkNoteChat
requestId={requestId || ''}
onBack={handleBack}
/>
</div>
</PageLayout>
);
}

View File

@ -0,0 +1,2 @@
export { WorkNotes } from './WorkNotes';

View File

@ -197,6 +197,105 @@ export async function createWorkNoteMultipart(requestId: string, payload: any, f
return res.data?.data || res.data;
}
export async function addApprover(requestId: string, email: string) {
const res = await apiClient.post(`/workflows/${requestId}/participants/approver`, { email });
return res.data?.data || res.data;
}
export async function addSpectator(requestId: string, email: string) {
const res = await apiClient.post(`/workflows/${requestId}/participants/spectator`, { email });
return res.data?.data || res.data;
}
export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`;
}
export function getDocumentPreviewUrl(documentId: string): string {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
return `${baseURL}/api/v1/workflows/documents/${documentId}/preview`;
}
export async function downloadDocument(documentId: string): Promise<void> {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const token = localStorage.getItem('accessToken');
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
try {
const response = await fetch(downloadUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Download failed: ${response.status} - ${errorText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition
? contentDisposition.split('filename=')[1]?.replace(/"/g, '')
: 'download';
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('[Download] Failed:', error);
throw error;
}
}
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const token = localStorage.getItem('accessToken');
const downloadUrl = `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
try {
const response = await fetch(downloadUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Download failed: ${response.status} - ${errorText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition
? contentDisposition.split('filename=')[1]?.replace(/"/g, '')
: 'download';
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('[Download] Failed:', error);
throw error;
}
}
export default {
createWorkflowFromForm,
createWorkflowMultipart,

104
src/utils/dateFormatter.ts Normal file
View File

@ -0,0 +1,104 @@
/**
* Format ISO date string to readable format
* @param dateString - ISO date string (e.g., "2025-10-31T12:02:50.394Z")
* @param options - Optional format options
* @returns Formatted date string (e.g., "Nov 3, 2025, 8:57 AM")
*/
export function formatDateTime(dateString: string | Date | null | undefined, options?: {
includeTime?: boolean;
includeSeconds?: boolean;
format?: 'short' | 'medium' | 'long';
}): string {
if (!dateString) return 'N/A';
try {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
const {
includeTime = true,
includeSeconds = false,
format = 'medium'
} = options || {};
if (!includeTime) {
// Date only
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: format === 'short' ? 'short' : 'long',
day: 'numeric'
});
}
// Date with time
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
...(includeSeconds && { second: '2-digit' }),
hour12: true
});
} catch (error) {
console.error('Error formatting date:', error);
return String(dateString);
}
}
/**
* Format date to relative time (e.g., "2 hours ago", "3 days ago")
*/
export function formatRelativeTime(dateString: string | Date | null | undefined): string {
if (!dateString) return 'N/A';
try {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour > 1 ? 's' : ''} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`;
// For older dates, show formatted date
return formatDateTime(dateString, { includeTime: false });
} catch (error) {
return String(dateString);
}
}
/**
* Format date to short format (e.g., "Oct 31, 2025")
*/
export function formatDateShort(dateString: string | Date | null | undefined): string {
return formatDateTime(dateString, { includeTime: false, format: 'short' });
}
/**
* Format time only (e.g., "8:57 AM")
*/
export function formatTimeOnly(dateString: string | Date | null | undefined): string {
if (!dateString) return 'N/A';
try {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
} catch (error) {
return String(dateString);
}
}

View File

@ -4,16 +4,39 @@ let socket: Socket | null = null;
export function getSocket(baseUrl: string): Socket {
if (socket) return socket;
console.log('[Socket] Connecting to:', baseUrl);
socket = io(baseUrl, {
withCredentials: true,
transports: ['websocket', 'polling'],
path: '/socket.io'
path: '/socket.io',
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5
});
socket.on('connect', () => {
console.log('[Socket] Connected successfully:', socket?.id);
});
socket.on('connect_error', (error) => {
console.error('[Socket] Connection error:', error.message);
});
socket.on('disconnect', (reason) => {
console.log('[Socket] Disconnected:', reason);
});
return socket;
}
export function joinRequestRoom(socket: Socket, requestId: string) {
socket.emit('join:request', requestId);
export function joinRequestRoom(socket: Socket, requestId: string, userId?: string) {
if (userId) {
socket.emit('join:request', { requestId, userId });
} else {
socket.emit('join:request', requestId);
}
}
export function leaveRequestRoom(socket: Socket, requestId: string) {