added websocket implementaion and enhanced activity logs
This commit is contained in:
parent
7b8fac5d8c
commit
3ee174e44e
@ -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) */}
|
||||
|
||||
32
src/components/common/FilePreview/FilePreview.css
Normal file
32
src/components/common/FilePreview/FilePreview.css
Normal 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;
|
||||
}
|
||||
|
||||
248
src/components/common/FilePreview/FilePreview.tsx
Normal file
248
src/components/common/FilePreview/FilePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
2
src/components/common/FilePreview/index.ts
Normal file
2
src/components/common/FilePreview/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FilePreview } from './FilePreview';
|
||||
|
||||
@ -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
|
||||
|
||||
255
src/components/participant/AddApproverModal/AddApproverModal.tsx
Normal file
255
src/components/participant/AddApproverModal/AddApproverModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
2
src/components/participant/AddApproverModal/index.ts
Normal file
2
src/components/participant/AddApproverModal/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { AddApproverModal } from './AddApproverModal';
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
2
src/components/participant/AddSpectatorModal/index.ts
Normal file
2
src/components/participant/AddSpectatorModal/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { AddSpectatorModal } from './AddSpectatorModal';
|
||||
|
||||
@ -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) => {
|
||||
|
||||
// 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;
|
||||
setMessages(prev => ([...prev, {
|
||||
// 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: n.userName || 'User', avatar: (n.userName || 'U').slice(0,2).toUpperCase(), role: n.userRole || 'Participant' },
|
||||
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()
|
||||
} as any]));
|
||||
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' },
|
||||
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.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
|
||||
}));
|
||||
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' },
|
||||
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 || new Date().toISOString(),
|
||||
})) : [];
|
||||
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>
|
||||
</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>
|
||||
{!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 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>
|
||||
);
|
||||
})}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
732
src/components/workNote/WorkNoteChat/WorkNoteChatSimple.tsx
Normal file
732
src/components/workNote/WorkNoteChat/WorkNoteChatSimple.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { WorkNoteChat } from './WorkNoteChat';
|
||||
export { WorkNoteChatSimple } from './WorkNoteChatSimple';
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -455,7 +484,6 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
const maxApproverLevel = Math.max(...updatedList.map((a: any) => a.level), 0);
|
||||
updateFormData('maxLevel', maxApproverLevel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeUser = (userId: string, type: 'approvers' | 'spectators' | 'ccList' | 'invitedUsers') => {
|
||||
@ -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('');
|
||||
|
||||
@ -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) => ({
|
||||
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: (req.priority || '').toString().toLowerCase(),
|
||||
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: req.dueDate ? new Date(req.dueDate).toISOString().split('T')[0] : undefined,
|
||||
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>
|
||||
|
||||
@ -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,7 +146,14 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
? (result as any)
|
||||
: [];
|
||||
if (!mounted) return;
|
||||
const mapped: Request[] = data.map((r: any) => ({
|
||||
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
|
||||
@ -121,11 +166,13 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
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
|
||||
}));
|
||||
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
42
src/pages/WorkNotes/WorkNotes.tsx
Normal file
42
src/pages/WorkNotes/WorkNotes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
2
src/pages/WorkNotes/index.ts
Normal file
2
src/pages/WorkNotes/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { WorkNotes } from './WorkNotes';
|
||||
|
||||
@ -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
104
src/utils/dateFormatter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user