draft alert notification and websocjket functionalty added

This commit is contained in:
laxmanhalaki 2025-10-31 20:02:56 +05:30
parent 382040f8c2
commit 7b8fac5d8c
22 changed files with 1612 additions and 60 deletions

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" />

138
package-lock.json generated
View File

@ -54,6 +54,7 @@
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.4",
"recharts": "^2.13.3",
"socket.io-client": "^4.8.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.4",
"vaul": "^1.0.0"
@ -2914,6 +2915,12 @@
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@ -4195,6 +4202,45 @@
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/es-cookie": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
@ -5424,7 +5470,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mz": {
@ -6490,6 +6535,68 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/sonner": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
@ -7156,6 +7263,35 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -59,6 +59,7 @@
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.4",
"recharts": "^2.13.3",
"socket.io-client": "^4.8.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.4",
"vaul": "^1.0.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

26
public/service-worker.js Normal file
View File

@ -0,0 +1,26 @@
self.addEventListener('push', event => {
const data = event.data ? event.data.json() : {};
const title = data.title || 'Notification';
console.log('notification dat i recive', data);
const rawUrl = data.url || (data.requestNumber ? `/request/${data.requestNumber}` : '/');
const absoluteUrl = /^https?:\/\//i.test(rawUrl) ? rawUrl : (self.location.origin + rawUrl);
const options = {
body: data.body || 'New message',
icon: '/royal_enfield_logo.png',
badge: '/royal_enfield_logo.png',
data: { url: absoluteUrl }
};
console.log('options', options);
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', function (event) {
event.notification.close();
const targetUrl = (event.notification && event.notification.data && event.notification.data.url) || (self.location.origin + '/');
event.waitUntil((async () => {
// Always open a new window/tab to ensure SPA router picks up the correct path
if (clients.openWindow) return clients.openWindow(targetUrl);
})());
});

View File

@ -70,10 +70,17 @@ function AppRoutes({ onLogout }: AppProps) {
navigate(`/${page}`);
};
const handleViewRequest = (requestId: string, requestTitle?: string) => {
const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string) => {
setSelectedRequestId(requestId);
setSelectedRequestTitle(requestTitle || 'Unknown Request');
// Check if request is a draft - if so, route to edit form instead of detail view
const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';
if (isDraft) {
navigate(`/edit-request/${requestId}`);
} else {
navigate(`/request/${requestId}`);
}
};
const handleBack = () => {
@ -564,6 +571,19 @@ function AppRoutes({ onLogout }: AppProps) {
}
/>
{/* Edit Draft Request */}
<Route
path="/edit-request/:requestId"
element={
<CreateRequest
onBack={handleBack}
onSubmit={handleNewRequestSubmit}
requestId={undefined} // Will be read from URL params
isEditMode={true}
/>
}
/>
{/* Claim Management Wizard */}
<Route
path="/claim-management"

View File

@ -0,0 +1,116 @@
import { useMemo, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, AlertCircle } from 'lucide-react';
type ApprovalModalProps = {
open: boolean;
onClose: () => void;
onConfirm: (description: string) => Promise<void> | void;
defaultDescription?: string;
title?: string;
requestIdDisplay?: string;
requestTitle?: string;
};
export function ApprovalModal({
open,
onClose,
onConfirm,
defaultDescription = '',
title = 'Approve Request',
requestIdDisplay,
requestTitle,
}: ApprovalModalProps) {
const [description, setDescription] = useState(defaultDescription);
const [submitting, setSubmitting] = useState(false);
const charsUsed = description?.length || 0;
const limitedDescription = useMemo(() => description.slice(0, 500), [description]);
const handleSubmit = async () => {
try {
setSubmitting(true);
await onConfirm(limitedDescription);
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-[640px] bg-white">
<DialogHeader>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle className="w-7 h-7 text-green-600" />
</div>
<div>
<DialogTitle className="text-lg">{title}</DialogTitle>
<p className="text-sm text-gray-500 mt-1">Please provide your approval comments and remarks</p>
</div>
</div>
</DialogHeader>
{/* Request Info Card */}
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-gray-800">Request ID</span>
<span className="text-xs bg-gray-100 border border-gray-300 text-gray-800 rounded-full px-2 py-0.5">
{requestIdDisplay || '—'}
</span>
</div>
{/* Title - keep original stacked style without spacing between */}
<div className="mb-3">
<span className="text-xs font-bold text-gray-800 block">Title</span>
<p className="text-sm text-gray-600 mt-1 truncate">{requestTitle || '—'}</p>
</div>
{/* Action - label and badge inline without justify-between */}
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-gray-800">Action</span>
<Badge className="bg-green-100 text-green-800 border-green-200" variant="outline">APPROVE</Badge>
</div>
</div>
{/* Comments */}
<div className="space-y-2 mt-4">
<label className="text-sm font-semibold text-gray-800">Comments & Remarks *</label>
<Textarea
value={limitedDescription}
onChange={(e) => setDescription(e.target.value)}
rows={5}
placeholder="Enter your approval comments and any conditions or notes..."
className="border-gray-300"
/>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Comments are required and will be visible to all participants</span>
<span>{Math.min(charsUsed, 500)}/500</span>
</div>
</div>
{/* Confirmation Box */}
<div className="mt-4 border rounded-lg p-3 bg-green-50 border-green-200 text-green-900 flex items-start gap-2">
<div className="mt-0.5">
<CheckCircle className="w-4 h-4" />
</div>
<div className="text-sm">
<div className="font-semibold">Approval Confirmation</div>
<div>This request will be forwarded to the next approver or completed if this is the final step.</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={onClose} disabled={submitting}>Cancel</Button>
<Button onClick={handleSubmit} disabled={submitting} className="bg-green-600 hover:bg-green-700">
<CheckCircle className="w-4 h-4 mr-2" />
{submitting ? 'Approving...' : 'Approve Request'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,121 @@
import { useMemo, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
// No separate reason field per UI; comments & remarks only
import { Badge } from '@/components/ui/badge';
import { XCircle, AlertCircle } from 'lucide-react';
type RejectionModalProps = {
open: boolean;
onClose: () => void;
onConfirm: (description: string) => Promise<void> | void;
defaultDescription?: string;
title?: string;
requestIdDisplay?: string;
requestTitle?: string;
};
export function RejectionModal({
open,
onClose,
onConfirm,
defaultDescription = '',
title = 'Reject Request',
requestIdDisplay,
requestTitle,
}: RejectionModalProps) {
const [description, setDescription] = useState(defaultDescription);
const [submitting, setSubmitting] = useState(false);
const charsUsed = (description?.length || 0);
const limitedDescription = useMemo(() => description.slice(0, 500), [description]);
const handleSubmit = async () => {
if (!limitedDescription.trim()) {
alert('Comments & remarks are required');
return;
}
try {
setSubmitting(true);
await onConfirm(limitedDescription);
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-[640px] bg-white">
<DialogHeader>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<XCircle className="w-7 h-7 text-red-600" />
</div>
<div>
<DialogTitle className="text-lg">{title}</DialogTitle>
<p className="text-sm text-gray-500 mt-1">Please provide detailed reasons for rejection</p>
</div>
</div>
</DialogHeader>
{/* Request Info Card */}
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-gray-800">Request ID</span>
<span className="text-xs bg-gray-100 border border-gray-300 text-gray-800 rounded-full px-2 py-0.5">
{requestIdDisplay || '—'}
</span>
</div>
{/* Title - keep original stacked style without spacing between */}
<div className="mb-3">
<span className="text-xs font-bold text-gray-800 block">Title</span>
<p className="text-sm text-gray-600 mt-1 truncate">{requestTitle || '—'}</p>
</div>
{/* Action - label and badge inline without justify-between */}
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-gray-800">Action</span>
<Badge className="bg-red-100 text-red-800 border-red-200" variant="outline">REJECT</Badge>
</div>
</div>
{/* Comments */}
<div className="space-y-3 mt-4">
<label className="text-sm font-semibold text-gray-800">Comments & Remarks *</label>
<Textarea
value={limitedDescription}
onChange={(e) => setDescription(e.target.value)}
rows={5}
placeholder="Enter detailed reasons for rejection and any suggestions for improvement..."
className="border-gray-300"
/>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Comments are required and will be visible to all participants</span>
<span>{Math.min(charsUsed, 500)}/500</span>
</div>
</div>
{/* Guidelines */}
<div className="mt-4 border rounded-lg p-3 bg-red-50 border-red-200 text-red-900 flex items-start gap-2">
<div className="mt-0.5">
<AlertCircle className="w-4 h-4" />
</div>
<div className="text-sm">
<div className="font-semibold">Rejection Guidelines</div>
<div>Please provide specific, actionable feedback to help the initiator improve their request.</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={onClose} disabled={submitting}>Cancel</Button>
<Button variant="destructive" onClick={handleSubmit} disabled={submitting}>
<XCircle className="w-4 h-4 mr-2" />
{submitting ? 'Rejecting...' : 'Reject Request'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -227,16 +227,9 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
<User className="w-4 h-4 mr-2" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
// Settings page not ready yet - do nothing for now
}}
className="opacity-50 cursor-not-allowed"
>
<DropdownMenuItem onClick={() => onNavigate?.('settings')}>
<Settings className="w-4 h-4 mr-2" />
Settings
<span className="ml-auto text-xs text-gray-400">Coming soon</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowLogoutDialog(true)}

View File

@ -1,4 +1,7 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails } 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -80,6 +83,9 @@ interface Participant {
interface WorkNoteChatProps {
requestId: string;
onBack?: () => void;
messages?: any[]; // optional external messages
loading?: boolean;
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
}
// Get request data from the same source as RequestDetail
@ -100,7 +106,7 @@ const REQUEST_DATABASE = {
}
};
// Static data to prevent re-renders
// Static data as fallback
const MOCK_PARTICIPANTS: Participant[] = [
{
name: 'Sarah Chen',
@ -301,7 +307,9 @@ const FileIcon = ({ type }: { type: string }) => {
}
};
export function WorkNoteChat({ requestId, onBack }: WorkNoteChatProps) {
export function WorkNoteChat({ requestId, onBack, messages: externalMessages, loading, 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');
@ -312,20 +320,23 @@ export function WorkNoteChat({ requestId, onBack }: WorkNoteChatProps) {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
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(() => {
const data = REQUEST_DATABASE[requestId as keyof typeof REQUEST_DATABASE];
const data = REQUEST_DATABASE[effectiveRequestId as keyof typeof REQUEST_DATABASE];
return data || {
id: requestId,
id: effectiveRequestId,
title: 'Unknown Request',
department: 'Unknown',
priority: 'medium',
status: 'pending'
};
}, [requestId]);
}, [effectiveRequestId]);
const onlineParticipants = MOCK_PARTICIPANTS.filter(p => p.status === 'online');
const [participants, setParticipants] = useState<Participant[]>(MOCK_PARTICIPANTS);
const onlineParticipants = participants.filter(p => p.status === 'online');
const filteredMessages = messages.filter(msg =>
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
@ -339,7 +350,65 @@ export function WorkNoteChat({ requestId, onBack }: WorkNoteChatProps) {
scrollToBottom();
}, [messages]);
const handleSendMessage = () => {
// Load participants from backend workflow details
useEffect(() => {
(async () => {
try {
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 || ''
}));
setParticipants(mapped);
}
} catch {}
})();
}, [effectiveRequestId]);
// Realtime updates via Socket.IO (standalone usage)
useEffect(() => {
let joinedId = effectiveRequestId;
(async () => {
try {
const details = await getWorkflowDetails(effectiveRequestId);
if (details?.workflow?.requestId) {
joinedId = details.workflow.requestId; // join by UUID to match server emits
}
} catch {}
try {
const base = (import.meta as any).env.VITE_BASE_URL || window.location.origin;
const s = getSocket(base);
joinRequestRoom(s, joinedId);
const handler = (payload: any) => {
const n = payload?.note || payload;
if (!n) return;
setMessages(prev => ([...prev, {
id: n.noteId || String(Date.now()),
user: { name: n.userName || 'User', avatar: (n.userName || 'U').slice(0,2).toUpperCase(), role: n.userRole || 'Participant' },
content: n.message || '',
timestamp: n.createdAt || new Date().toISOString()
} as any]));
};
s.on('worknote:new', handler);
// cleanup
const cleanup = () => {
s.off('worknote:new', handler);
leaveRequestRoom(s, joinedId);
};
(window as any).__wn_cleanup = cleanup;
} catch {}
})();
return () => {
try { (window as any).__wn_cleanup?.(); } catch {}
};
}, [effectiveRequestId]);
const handleSendMessage = async () => {
if (message.trim() || selectedFiles.length > 0) {
const attachments = selectedFiles.map(file => ({
name: file.name,
@ -363,12 +432,61 @@ export function WorkNoteChat({ requestId, onBack }: WorkNoteChatProps) {
isHighPriority: message.includes('!important') || message.includes('urgent'),
attachments: attachments.length > 0 ? attachments : undefined
};
// console.log('new message ->', newMessage, onSend);
// If external onSend provided, delegate to caller (RequestDetail will POST and refresh)
if (onSend) {
try { await onSend(message, selectedFiles); } catch { /* ignore */ }
} else {
// Fallback: call backend directly
try {
await createWorkNoteMultipart(effectiveRequestId, { message }, selectedFiles);
const rows = await getWorkNotes(effectiveRequestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => ({
id: m.noteId || m.id || String(Math.random()),
user: { name: m.userName || 'User', avatar: (m.userName || 'U').slice(0,2).toUpperCase(), role: m.userRole || 'Participant' },
content: m.message || '',
timestamp: m.createdAt || new Date().toISOString(),
})) : [];
setMessages(mapped as any);
} catch {
setMessages(prev => [...prev, newMessage]);
}
}
setMessage('');
setSelectedFiles([]);
}
};
// If external messages provided, map them to local shape without disturbing UI
useEffect(() => {
if (externalMessages && Array.isArray(externalMessages)) {
try {
const mapped: Message[] = externalMessages.map((m: any) => ({
id: m.noteId || m.id || String(Math.random()),
user: { name: m.userName || m.user?.name || 'User', avatar: (m.userName || 'U').slice(0,2).toUpperCase(), role: m.userRole || 'Participant' },
content: m.message || m.content || '',
timestamp: m.createdAt || m.timestamp || new Date().toISOString(),
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ name: a.fileName || a.name, url: a.storageUrl || a.url || '#', type: a.fileType || a.type || 'file' })) : undefined
}));
setMessages(mapped);
} catch {}
} else {
// Fallback: load from backend if parent didn't pass messages
(async () => {
try {
const rows = await getWorkNotes(effectiveRequestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => ({
id: m.noteId || m.id || String(Math.random()),
user: { name: m.userName || 'User', avatar: (m.userName || 'U').slice(0,2).toUpperCase(), role: m.userRole || 'Participant' },
content: m.message || '',
timestamp: m.createdAt || new Date().toISOString(),
})) : [];
setMessages(mapped as any);
} catch {}
})();
}
}, [externalMessages, effectiveRequestId]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const filesArray = Array.from(e.target.files);
@ -950,7 +1068,7 @@ export function WorkNoteChat({ requestId, onBack }: WorkNoteChatProps) {
</Button>
</div>
<div className="space-y-3 sm:space-y-4">
{MOCK_PARTICIPANTS.map((participant, index) => (
{participants.map((participant, index) => (
<div key={index} className="flex items-center gap-3">
<div className="relative">
<Avatar className="h-9 w-9 sm:h-10 sm:w-10">

View File

@ -101,8 +101,9 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
const mapped: Request[] = data
.filter((r: any) => ['APPROVED', 'REJECTED'].includes((r.status || '').toString()))
.map((r: any) => ({
id: r.requestId || r.requestNumber,
displayId: r.requestNumber || r.requestId,
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
requestId: r.requestId, // Keep requestId for reference
displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title,
description: r.description,
status: (r.status || '').toString().toLowerCase(),

View File

@ -1,6 +1,7 @@
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { searchUsers, type UserSummary } from '@/services/userApi';
import { createWorkflowMultipart, submitWorkflow } from '@/services/workflowApi';
import { createWorkflowMultipart, submitWorkflow, getWorkflowDetails, updateWorkflow, updateWorkflowMultipart } from '@/services/workflowApi';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -41,11 +42,12 @@ import {
} from 'lucide-react';
import { format } from 'date-fns';
import { useAuth } from '@/contexts/AuthContext';
import documentApi from '@/services/documentApi';
interface CreateRequestProps {
onBack?: () => void;
onSubmit?: (requestData: any) => void;
requestId?: string; // For edit mode
isEditMode?: boolean; // Flag to indicate edit mode
}
interface RequestTemplate {
@ -133,7 +135,10 @@ const STEP_NAMES = [
'Review & Submit'
];
export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEditMode = false }: CreateRequestProps) {
const params = useParams<{ requestId: string }>();
const editRequestId = params.requestId || propRequestId || '';
const isEditing = isEditMode && !!editRequestId;
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(1);
const [selectedTemplate, setSelectedTemplate] = useState<RequestTemplate | null>(null);
@ -213,6 +218,138 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
});
const totalSteps = STEP_NAMES.length;
const [loadingDraft, setLoadingDraft] = useState(isEditing);
const [existingDocuments, setExistingDocuments] = useState<any[]>([]); // Track documents from backend
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]); // Track document IDs to delete
// Fetch draft data when in edit mode
useEffect(() => {
if (!isEditing || !editRequestId) return;
let mounted = true;
(async () => {
try {
setLoadingDraft(true);
const details = await getWorkflowDetails(editRequestId);
if (!mounted || !details) return;
const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
const participants = Array.isArray(details.participants) ? details.participants : [];
const documents = Array.isArray(details.documents) ? details.documents.filter((d: any) => !d.isDeleted) : [];
// Store existing documents for tracking
setExistingDocuments(documents);
// Map priority
const priority = (wf.priority || '').toString().toLowerCase();
const priorityMap: Record<string, string> = {
'standard': 'standard',
'express': 'express'
};
// Map template type
const templateType = wf.templateType === 'TEMPLATE' ? 'existing-template' : 'custom';
const template = REQUEST_TEMPLATES.find(t => t.id === templateType) || REQUEST_TEMPLATES[0] || null;
setSelectedTemplate(template);
// Map approvers
const mappedApprovers = approvals
.sort((a: any, b: any) => (a.levelNumber || 0) - (b.levelNumber || 0))
.map((approval: any) => {
const tatHours = Number(approval.tatHours || 24);
const tatDays = Math.floor(tatHours / 24);
const tatHoursRemainder = tatHours % 24;
return {
id: approval.approverId || `temp-${approval.levelNumber}`,
name: approval.approverName || approval.approverEmail || '',
email: approval.approverEmail || '',
role: approval.levelName || `Level ${approval.levelNumber}`,
department: '',
avatar: (approval.approverName || approval.approverEmail || 'XX').substring(0, 2).toUpperCase(),
level: approval.levelNumber || 1,
canClose: false,
tat: tatDays > 0 ? tatDays : tatHoursRemainder,
tatType: tatDays > 0 ? 'days' as const : 'hours' as const,
userId: approval.approverId
};
});
// Map spectators - check both participantType and participant_type fields
// Debug: log participants to understand the structure
console.log('Loading draft - participants:', participants);
const mappedSpectators = participants
.filter((p: any) => {
// Check multiple possible field names for participantType
const pt = (p.participantType || p.participant_type || '').toString().toUpperCase().trim();
const isSpectator = pt === 'SPECTATOR';
if (!isSpectator) {
return false;
}
// Also ensure we have at least an email
const hasEmail = !!(p.userEmail || p.user_email || p.email);
if (!hasEmail) {
console.warn('Skipping spectator without email:', p);
}
return hasEmail;
})
.map((p: any, index: number) => {
// Use userId if available, otherwise generate a stable unique ID
const userId = p.userId || p.user_id || p.id;
const userName = p.userName || p.user_name || p.name || '';
const userEmail = p.userEmail || p.user_email || p.email || '';
// Generate avatar from name or email
const avatarText = userName || userEmail || 'XX';
const avatar = avatarText
.split(' ')
.map((s: string) => s[0])
.filter(Boolean)
.join('')
.slice(0, 2)
.toUpperCase();
return {
id: userId || `spectator-${editRequestId}-${index}-${Date.now()}`,
userId: userId, // Keep userId separate for reference
name: userName || userEmail || 'Spectator',
email: userEmail,
role: 'Spectator',
department: p.department || '',
avatar: avatar,
level: 1,
canClose: false
};
});
// Debug: log mapped spectators
console.log('Mapped spectators:', mappedSpectators);
// Update form data
setFormData(prev => ({
...prev,
template: templateType,
title: wf.title || '',
description: wf.description || '',
priority: priorityMap[priority] || 'standard',
approvers: mappedApprovers,
approverCount: mappedApprovers.length || 1,
spectators: mappedSpectators,
maxLevel: Math.max(...mappedApprovers.map((a: any) => a.level || 1), 1)
}));
// Skip template selection step if editing
setCurrentStep(2);
} catch (error) {
console.error('Failed to load draft:', error);
} finally {
if (mounted) setLoadingDraft(false);
}
})();
return () => { mounted = false; };
}, [isEditing, editRequestId]);
const updateFormData = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
@ -459,6 +596,106 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
participants,
};
// Handle edit mode - update existing draft with full structure
if (isEditing && editRequestId) {
// Build approval levels
const approvalLevels = (formData.approvers || []).slice(0, formData.approverCount || 1).map((a: any, index: number) => {
const tat = typeof a.tat === 'number' ? a.tat : 0;
const tatHours = a.tatType === 'days' ? tat * 24 : tat || 24;
return {
levelNumber: index + 1,
levelName: `Level ${index + 1}`,
approverId: a.userId || '',
approverEmail: a.email || '',
approverName: a.name || a.email?.split('@')[0] || `Approver ${index + 1}`,
tatHours: tatHours,
isFinalApprover: index + 1 === (formData.approverCount || 1)
};
});
// Build participants
const participants = [
{
userId: user?.userId || '',
userEmail: (user as any)?.email || '',
userName: (user as any)?.displayName || (user as any)?.name || (user as any)?.email?.split('@')[0] || 'Initiator',
participantType: 'INITIATOR' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
},
...((formData.approvers || []).filter((a: any) => a?.email && a?.userId).map((a: any) => ({
userId: a.userId,
userEmail: a.email,
userName: a.name || a.email.split('@')[0],
participantType: 'APPROVER' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
})) as any[]),
...((formData.spectators || []).filter((s: any) => s?.email).map((s: any) => ({
userId: s.id || s.userId || undefined,
userEmail: s.email,
userName: s.name || s.email.split('@')[0],
participantType: 'SPECTATOR' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
})) as any[]),
];
// Build update payload
const updatePayload = {
title: formData.title,
description: formData.description,
priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD',
approvalLevels: approvalLevels,
participants: participants,
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
};
// Determine if we need multipart (new files or deletions)
const hasNewFiles = formData.documents && formData.documents.length > 0;
const hasDeletions = documentsToDelete.length > 0;
if (hasNewFiles || hasDeletions) {
// Use multipart update
updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete)
.then(async () => {
// Submit the updated workflow
try {
await submitWorkflow(editRequestId);
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
} catch (err) {
console.error('Failed to submit workflow:', err);
}
})
.catch((err) => {
console.error('Failed to update workflow:', err);
});
} else {
// Use regular update
updateWorkflow(editRequestId, updatePayload)
.then(async () => {
// Submit the updated workflow
try {
await submitWorkflow(editRequestId);
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
} catch (err) {
console.error('Failed to submit workflow:', err);
}
})
.catch((err) => {
console.error('Failed to update workflow:', err);
});
}
return;
}
// Create new workflow
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING')
.then(async (res) => {
const id = (res as any).id;
@ -478,6 +715,134 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
// allow minimal validation for draft: require title/description/priority/template
return;
}
// Handle edit mode - update existing draft with full structure
if (isEditing && editRequestId) {
// Build approval levels
const approvalLevels = (formData.approvers || []).slice(0, formData.approverCount || 1).map((a: any, index: number) => {
const tat = typeof a.tat === 'number' ? a.tat : 0;
const tatHours = a.tatType === 'days' ? tat * 24 : tat || 24;
return {
levelNumber: index + 1,
levelName: `Level ${index + 1}`,
approverId: a.userId || '',
approverEmail: a.email || '',
approverName: a.name || a.email?.split('@')[0] || `Approver ${index + 1}`,
tatHours: tatHours,
isFinalApprover: index + 1 === (formData.approverCount || 1)
};
});
// Build participants
const participants = [
{
userId: user?.userId || '',
userEmail: (user as any)?.email || '',
userName: (user as any)?.displayName || (user as any)?.name || (user as any)?.email?.split('@')[0] || 'Initiator',
participantType: 'INITIATOR' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
},
...((formData.approvers || []).filter((a: any) => a?.email && a?.userId).map((a: any) => ({
userId: a.userId,
userEmail: a.email,
userName: a.name || a.email.split('@')[0],
participantType: 'APPROVER' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
})) as any[]),
...((formData.spectators || []).filter((s: any) => s?.email).map((s: any) => ({
userId: s.id || s.userId || undefined,
userEmail: s.email,
userName: s.name || s.email.split('@')[0],
participantType: 'SPECTATOR' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
})) as any[]),
];
// Build update payload
const updatePayload = {
title: formData.title,
description: formData.description,
priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD',
approvalLevels: approvalLevels,
participants: participants,
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
};
// Determine if we need multipart (new files or deletions)
const hasNewFiles = formData.documents && formData.documents.length > 0;
const hasDeletions = documentsToDelete.length > 0;
if (hasNewFiles || hasDeletions) {
// Use multipart update
updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete)
.then(() => {
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
})
.catch((err) => console.error('Failed to update draft:', err));
} else {
// Use regular update
updateWorkflow(editRequestId, updatePayload)
.then(() => {
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
})
.catch((err) => console.error('Failed to update draft:', err));
}
return;
}
// Build participants array for draft (same as handleSubmit)
const initiatorId = user?.userId || '';
const initiatorEmail = (user as any)?.email || '';
const initiatorName = (user as any)?.displayName || (user as any)?.name || initiatorEmail.split('@')[0] || 'Initiator';
const participants = [
{
userId: initiatorId,
userEmail: initiatorEmail,
userName: initiatorName,
participantType: 'INITIATOR' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
addedBy: initiatorId,
},
// Approvers -> map to participants (APPROVER)
...((formData.approvers || []).filter((a: any) => a?.email).map((a: any) => ({
userId: a.userId || undefined,
userEmail: a.email,
userName: a.name || a.email.split('@')[0],
participantType: 'APPROVER' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
addedBy: initiatorId,
})) as any[]),
// Spectators -> map to participants (SPECTATOR)
...((formData.spectators || []).filter((s: any) => s?.email).map((s: any) => ({
userId: s.id || undefined,
userEmail: s.email,
userName: s.name || s.email.split('@')[0],
participantType: 'SPECTATOR' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: true,
notificationEnabled: true,
addedBy: initiatorId,
})) as any[]),
];
// Create new draft
const payload = {
templateId: selectedTemplate?.id || null,
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' as const : 'TEMPLATE' as const,
@ -508,7 +873,7 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
name: c?.name || c?.email?.split('@')?.[0] || 'CC',
email: c?.email || '',
})),
participants: [],
participants: participants, // Include participants array for draft
};
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING')
.then((res) => {
@ -517,6 +882,18 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
.catch((err) => console.error('Failed to save draft:', err));
};
// Show loading state while fetching draft data
if (loadingDraft) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading draft...</p>
</div>
</div>
);
}
const renderStepContent = () => {
switch (currentStep) {
case 1:
@ -1517,11 +1894,68 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
</CardContent>
</Card>
{/* Existing Documents (from backend) */}
{isEditing && existingDocuments.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Existing Documents</span>
<Badge variant="secondary">
{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length} file{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length !== 1 ? 's' : ''}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{existingDocuments.map((doc: any) => {
const docId = doc.documentId || doc.document_id || '';
const isDeleted = documentsToDelete.includes(docId);
if (isDeleted) return null;
return (
<div key={docId} className={`flex items-center justify-between p-4 rounded-lg border ${isDeleted ? 'bg-red-50 border-red-200 opacity-50' : 'bg-gray-50'}`}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">{doc.originalFileName || doc.fileName || 'Document'}</p>
<div className="flex items-center gap-3 text-sm text-gray-600">
<span>{doc.fileSize ? (Number(doc.fileSize) / (1024 * 1024)).toFixed(2) + ' MB' : 'Unknown size'}</span>
<span></span>
<span>Uploaded {doc.uploadedAt ? new Date(doc.uploadedAt).toLocaleDateString() : 'previously'}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" title="View document">
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDocumentsToDelete(prev => [...prev, docId]);
}}
title="Delete document"
>
<X className="h-4 w-4 text-red-600" />
</Button>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* New Documents (being uploaded) */}
{formData.documents.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Uploaded Files</span>
<span>New Files to Upload</span>
<Badge variant="secondary">
{formData.documents.length} file{formData.documents.length !== 1 ? 's' : ''}
</Badge>
@ -1545,7 +1979,7 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<Button variant="ghost" size="sm" title="View file">
<Eye className="h-4 w-4" />
</Button>
<Button
@ -1555,6 +1989,7 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
const newDocs = formData.documents.filter((_, i) => i !== index);
updateFormData('documents', newDocs);
}}
title="Remove file"
>
<X className="h-4 w-4" />
</Button>
@ -1565,6 +2000,40 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
</CardContent>
</Card>
)}
{/* Documents marked for deletion */}
{isEditing && documentsToDelete.length > 0 && (
<Card className="border-red-200 bg-red-50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-900">
<X className="w-5 h-5" />
Documents to be Deleted ({documentsToDelete.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{documentsToDelete.map((docId) => {
const doc = existingDocuments.find(d => (d.documentId || d.document_id) === docId);
return (
<div key={docId} className="flex items-center justify-between p-2 bg-red-100 rounded">
<span className="text-sm text-red-900">{doc?.originalFileName || doc?.fileName || 'Document'}</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDocumentsToDelete(prev => prev.filter(id => id !== docId));
}}
className="text-red-600 hover:text-red-800"
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
</motion.div>
);
@ -1950,7 +2419,9 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Create New Request</h1>
<h1 className="text-2xl font-bold text-gray-900">
{isEditing ? 'Edit Draft Request' : 'Create New Request'}
</h1>
<p className="text-gray-600">
Step {currentStep} of {totalSteps}: {STEP_NAMES[currentStep - 1]}
</p>
@ -2027,13 +2498,13 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={handleSaveDraft} size="lg">
Save Draft
<Button variant="outline" onClick={handleSaveDraft} size="lg" disabled={loadingDraft}>
{isEditing ? 'Update Draft' : 'Save Draft'}
</Button>
{currentStep === totalSteps ? (
<Button
onClick={handleSubmit}
disabled={!isStepValid()}
disabled={!isStepValid() || loadingDraft}
size="lg"
className="bg-green-600 hover:bg-green-700 px-8"
>

View File

@ -122,7 +122,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
// Convert API/dynamic requests to the format expected by this component
const sourceRequests = (apiRequests.length ? apiRequests : dynamicRequests);
const convertedDynamicRequests = sourceRequests.map((req: any) => ({
id: req.requestId || req.id || req.request_id,
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,
@ -314,7 +315,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
>
<Card
className="group hover:shadow-lg transition-all duration-300 cursor-pointer border border-gray-200 shadow-sm hover:shadow-md"
onClick={() => onViewRequest(request.id, request.title)}
onClick={() => onViewRequest(request.id, request.title, request.status)}
>
<CardContent className="p-6">
<div className="space-y-4">

View File

@ -109,9 +109,10 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
: [];
if (!mounted) return;
const mapped: Request[] = data.map((r: any) => ({
id: r.requestId || r.requestNumber,
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
requestId: r.requestId, // Keep requestId for reference
// keep a display id for UI
displayId: r.requestNumber || r.requestId,
displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title,
description: r.description,
status: ((r.status || '').toString().toUpperCase() === 'IN_PROGRESS') ? 'in-review' : 'pending',

View File

@ -1,13 +1,19 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { WorkNoteChat } from '@/components/workNote/WorkNoteChat';
import { getWorkNotes, createWorkNoteMultipart } from '@/services/workflowApi';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import workflowApi from '@/services/workflowApi';
import workflowApi, { approveLevel, rejectLevel } from '@/services/workflowApi';
import { ApprovalModal } from '@/components/approval/ApprovalModal/ApprovalModal';
import { RejectionModal } from '@/components/approval/RejectionModal/RejectionModal';
import { useAuth } from '@/contexts/AuthContext';
import {
ArrowLeft,
@ -149,23 +155,161 @@ const getActionTypeIcon = (type: string) => {
};
export function RequestDetail({
requestId,
requestId: propRequestId,
onBack,
onOpenModal,
dynamicRequests = []
}: RequestDetailProps) {
const params = useParams<{ requestId: string }>();
const navigate = useNavigate();
// Use requestNumber from URL params (which now contains requestNumber), fallback to prop
const requestIdentifier = params.requestId || propRequestId || '';
const [activeTab, setActiveTab] = useState('overview');
const [apiRequest, setApiRequest] = useState<any | null>(null);
const [loading, setLoading] = useState(false);
const [workNotes, setWorkNotes] = useState<any[]>([]);
const [loadingNotes, setLoadingNotes] = useState<boolean>(false);
// loading state not required for current UI
const [isSpectator, setIsSpectator] = useState(false);
// approving/rejecting local states are managed inside modals now
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
const [showApproveModal, setShowApproveModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const { user } = useAuth();
// Shared refresh routine
const refreshDetails = async () => {
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
if (!details) return;
const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
const participants = Array.isArray(details.participants) ? details.participants : [];
const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {};
const statusMap = (s: string) => {
const val = (s || '').toUpperCase();
if (val === 'IN_PROGRESS') return 'in-review';
if (val === 'PENDING') return 'pending';
if (val === 'APPROVED') return 'approved';
if (val === 'REJECTED') return 'rejected';
return (s || '').toLowerCase();
};
const approvalFlow = approvals.map((a: any) => ({
step: a.levelNumber,
levelId: a.levelId || a.level_id,
role: a.levelName || a.approverName || 'Approver',
status: statusMap(a.status),
approver: a.approverName || a.approverEmail,
approverId: a.approverId || a.approver_id,
approverEmail: a.approverEmail,
tatHours: Number(a.tatHours || 0),
elapsedHours: Number(a.elapsedHours || 0),
actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined,
comment: a.comments || undefined,
timestamp: a.actionDate || undefined,
}));
const updatedRequest = {
...wf,
id: wf.requestNumber || wf.requestId,
requestId: wf.requestId,
requestNumber: wf.requestNumber,
status: statusMap(wf.status),
priority: (wf.priority || '').toString().toLowerCase(),
approvalFlow,
approvals,
participants,
documents,
summary,
};
setApiRequest(updatedRequest);
const userEmail = (user as any)?.email?.toLowerCase();
const newCurrentLevel = approvals.find((a: any) => {
const st = (a.status || '').toString().toUpperCase();
const approverEmail = (a.approverEmail || '').toLowerCase();
return (st === 'PENDING' || st === 'IN_PROGRESS') && approverEmail === userEmail;
});
setCurrentApprovalLevel(newCurrentLevel || null);
};
// Work notes load
const loadWorkNotes = async () => {
try {
setLoadingNotes(true);
const rows = await getWorkNotes(requestIdentifier);
setWorkNotes(Array.isArray(rows) ? rows : []);
} finally {
setLoadingNotes(false);
}
};
// Realtime join/leave
useEffect(() => {
loadWorkNotes();
try {
const base = (import.meta as any).env.VITE_BASE_URL || window.location.origin;
const s = getSocket(base);
// We don't have UUID here; backend accepts requestNumber in findWorkflowByIdentifier
joinRequestRoom(s, requestIdentifier);
s.on('worknote:new', (payload: any) => {
setWorkNotes(prev => [...prev, payload?.note || payload]);
});
return () => {
s.off('worknote:new');
leaveRequestRoom(s, requestIdentifier);
};
} catch { /* no-op */ }
}, [requestIdentifier]);
const handleSendWorkNote = async (messageHtml: string, files: File[]) => {
console.log('WorkNote send ->', { requestIdentifier, filesCount: files?.length || 0 });
try {
const payload = { message: messageHtml };
console.log('WorkNote send ->', { requestIdentifier, filesCount: files?.length || 0 });
await createWorkNoteMultipart(requestIdentifier, payload, files || []);
await loadWorkNotes();
} catch (e) {
console.error('Failed to send work note', e);
(window as any)?.toast?.('Failed to send note');
}
};
// Approve modal onConfirm
async function handleApproveConfirm(description: string) {
const levelId = currentApprovalLevel?.levelId || currentApprovalLevel?.level_id;
if (!levelId) { alert('Approval level not found'); return; }
await approveLevel(requestIdentifier, levelId, description || '');
await refreshDetails();
// Close modal + notify (assumes global handlers, replace as needed)
(window as any)?.closeModal?.();
(window as any)?.toast?.('Approved successfully');
}
// Reject modal onConfirm (UI uses only comments/remarks; map it to both fields)
async function handleRejectConfirm(description: string) {
if (!description?.trim()) { alert('Comments & remarks are required'); return; }
const levelId = currentApprovalLevel?.levelId || currentApprovalLevel?.level_id;
if (!levelId) { alert('Approval level not found'); return; }
await rejectLevel(requestIdentifier, levelId, description.trim(), description.trim());
await refreshDetails();
// Close modal + notify (assumes global handlers, replace as needed)
(window as any)?.closeModal?.();
(window as any)?.toast?.('Rejected successfully');
}
useEffect(() => {
let mounted = true;
(async () => {
try {
setLoading(true);
const details = await workflowApi.getWorkflowDetails(requestId);
// Use requestIdentifier (which should now be requestNumber) for API call
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
if (!mounted || !details) return;
const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
@ -192,9 +336,12 @@ export function RequestDetail({
const approvalFlow = approvals.map((a: any) => ({
step: a.levelNumber,
levelId: a.levelId || a.level_id,
role: a.levelName || a.approverName || 'Approver',
status: statusMap(a.status),
approver: a.approverName || a.approverEmail,
approverId: a.approverId || a.approver_id,
approverEmail: a.approverEmail,
tatHours: Number(a.tatHours || 0),
elapsedHours: Number(a.elapsedHours || 0),
actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined,
@ -202,6 +349,16 @@ export function RequestDetail({
timestamp: a.actionDate || undefined,
}));
// Find current approval level for logged-in user
const userEmail = (user as any)?.email?.toLowerCase();
const currentLevel = approvals.find((a: any) => {
const status = (a.status || '').toString().toUpperCase();
const approverEmail = (a.approverEmail || '').toLowerCase();
return (status === 'PENDING' || status === 'IN_PROGRESS') &&
approverEmail === userEmail;
});
setCurrentApprovalLevel(currentLevel || null);
const spectators = participants
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
.map((p: any) => ({
@ -265,29 +422,33 @@ export function RequestDetail({
setIsSpectator(false);
}
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, [requestId]);
}, [requestIdentifier]);
// Get request from any database or dynamic requests
const request = useMemo(() => {
if (apiRequest) return apiRequest;
// First check custom request database
const customRequest = CUSTOM_REQUEST_DATABASE[requestId];
// First check custom request database (by requestNumber or requestId)
const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
if (customRequest) return customRequest;
// Then check claim management database
const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestId];
const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier];
if (claimRequest) return claimRequest;
// Then check dynamic requests
const dynamicRequest = dynamicRequests.find((req: any) => req.id === requestId);
// Then check dynamic requests (match by requestNumber or id)
const dynamicRequest = dynamicRequests.find((req: any) =>
req.id === requestIdentifier ||
req.requestNumber === requestIdentifier ||
req.request_number === requestIdentifier
);
if (dynamicRequest) return dynamicRequest;
return null;
}, [requestId, dynamicRequests, apiRequest]);
}, [requestIdentifier, dynamicRequests, apiRequest]);
if (!request) {
return (
@ -313,6 +474,7 @@ export function RequestDetail({
);
return (
<>
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-6">
{/* Header Section */}
@ -395,6 +557,10 @@ export function RequestDetail({
<Activity className="w-4 h-4" />
Activity
</TabsTrigger>
<TabsTrigger value="work-notes" className={triggerClass('work-notes')}>
<MessageSquare className="w-4 h-4" />
Work Notes
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
@ -553,7 +719,7 @@ export function RequestDetail({
<CardContent className="space-y-2">
<Button
className="w-full justify-start gap-2 bg-[#1a472a] text-white hover:bg-[#152e1f] hover:text-white border-0"
onClick={() => onOpenModal?.('work-note')}
onClick={() => navigate(`/work-notes/${requestIdentifier}`)}
>
<MessageSquare className="w-4 h-4" />
Add Work Note
@ -581,11 +747,11 @@ export function RequestDetail({
<div className="pt-4 space-y-2">
{!isSpectator && (
{!isSpectator && currentApprovalLevel && (
<>
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
onClick={() => onOpenModal?.('approve')}
onClick={() => setShowApproveModal(true)}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve Request
@ -593,7 +759,7 @@ export function RequestDetail({
<Button
variant="destructive"
className="w-full"
onClick={() => onOpenModal?.('reject')}
onClick={() => setShowRejectModal(true)}
>
<XCircle className="w-4 h-4 mr-2" />
Reject Request
@ -850,8 +1016,39 @@ export function RequestDetail({
</CardContent>
</Card>
</TabsContent>
{/* Work Notes Tab */}
<TabsContent value="work-notes">
<div className="bg-white rounded-lg border border-gray-300">
<WorkNoteChat
requestId={requestIdentifier}
loading={loadingNotes}
messages={workNotes}
onSend={handleSendWorkNote}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
<ApprovalModal
open={showApproveModal}
onClose={() => setShowApproveModal(false)}
onConfirm={handleApproveConfirm}
requestIdDisplay={request.id}
requestTitle={request.title}
/>
<RejectionModal
open={showRejectModal}
onClose={() => setShowRejectModal(false)}
onConfirm={handleRejectConfirm}
requestIdDisplay={request.id}
requestTitle={request.title}
/>
</>
);
}
// Render modals near the root return (below existing JSX)
// Note: Ensure this stays within the component scope

View File

@ -12,6 +12,7 @@ import {
Mail,
CheckCircle
} from 'lucide-react';
import { setupPushNotifications } from '@/utils/pushNotifications';
export function Settings() {
return (
@ -54,9 +55,9 @@ export function Settings() {
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">Notification settings will be available soon</p>
</div>
<Button onClick={async () => { try { await setupPushNotifications(); alert('Notifications enabled'); } catch (e) { alert('Failed to enable notifications'); } }}>
Enable Push Notifications
</Button>
</div>
</CardContent>
</Card>

View File

@ -6,7 +6,7 @@
import axios, { AxiosInstance } from 'axios';
import { TokenManager } from '../utils/tokenManager';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://re-workflow-nt-api-dev.siplsolutions.com/api/v1';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
// Create axios instance with default config
const apiClient: AxiosInstance = axios.create({

View File

@ -182,6 +182,21 @@ export async function getWorkflowDetails(requestId: string) {
return res.data?.data || res.data;
}
export async function getWorkNotes(requestId: string) {
const res = await apiClient.get(`/workflows/${requestId}/work-notes`);
return res.data?.data || res.data;
}
export async function createWorkNoteMultipart(requestId: string, payload: any, files: File[] = []) {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload || {}));
files.forEach(f => formData.append('files', f));
const res = await apiClient.post(`/workflows/${requestId}/work-notes`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data?.data || res.data;
}
export default {
createWorkflowFromForm,
createWorkflowMultipart,
@ -191,6 +206,8 @@ export default {
listClosedByMe,
submitWorkflow,
getWorkflowDetails,
getWorkNotes,
createWorkNoteMultipart,
};
export async function submitWorkflow(requestId: string) {
@ -198,6 +215,64 @@ export async function submitWorkflow(requestId: string) {
return res.data?.data || res.data;
}
export async function updateWorkflow(requestId: string, updateData: any) {
const res = await apiClient.put(`/workflows/${requestId}`, updateData);
return res.data?.data || res.data;
}
export async function updateWorkflowMultipart(requestId: string, updateData: any, files?: File[], deleteDocumentIds?: string[]) {
const payload = {
...updateData,
deleteDocumentIds: deleteDocumentIds || []
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('category', 'SUPPORTING');
if (files && files.length > 0) {
files.forEach(f => formData.append('files', f));
}
const res = await apiClient.put(`/workflows/${requestId}/multipart`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data?.data || res.data;
}
export async function approveLevel(requestId: string, levelId: string, comments?: string) {
const res = await apiClient.patch(`/workflows/${requestId}/approvals/${levelId}/approve`, {
action: 'APPROVE',
comments: comments || ''
});
return res.data?.data || res.data;
}
export async function rejectLevel(requestId: string, levelId: string, rejectionReason?: string, comments?: string) {
const res = await apiClient.patch(`/workflows/${requestId}/approvals/${levelId}/reject`, {
action: 'REJECT',
rejectionReason: rejectionReason || '',
comments: comments || ''
});
return res.data?.data || res.data;
}
export async function updateAndSubmitWorkflow(requestId: string, workflowData: CreateWorkflowFromFormPayload, files?: File[]) {
// First update the workflow
const payload: any = {
title: workflowData.title,
description: workflowData.description,
priority: workflowData.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD',
};
// Update workflow details
await apiClient.put(`/workflows/${requestId}`, payload);
// If files provided, update documents (this would need backend support for updating documents)
// For now, we'll just submit the updated workflow
const res = await apiClient.patch(`/workflows/${requestId}/submit`);
return res.data?.data || res.data;
}
// Also export in default for convenience
// Note: keeping separate named export above for tree-shaking

View File

@ -0,0 +1,47 @@
const VAPID_PUBLIC_KEY = import.meta.env.VITE_PUBLIC_VAPID_KEY as string;
const VITE_BASE_URL = import.meta.env.VITE_BASE_URL as string;
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) throw new Error('Service workers not supported');
const register = await navigator.serviceWorker.register('/service-worker.js');
return register;
}
export async function subscribeUserToPush(register: ServiceWorkerRegistration) {
if (!VAPID_PUBLIC_KEY) throw new Error('Missing VAPID public key');
const subscription = await register.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// Attach auth token if available
const token = (window as any)?.localStorage?.getItem?.('accessToken') || (document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || '';
await fetch(`${VITE_BASE_URL}/api/v1/workflows/notifications/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(subscription)
});
return subscription;
}
export async function setupPushNotifications() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const reg = await registerServiceWorker();
await subscribeUserToPush(reg);
}

23
src/utils/socket.ts Normal file
View File

@ -0,0 +1,23 @@
import { io, Socket } from 'socket.io-client';
let socket: Socket | null = null;
export function getSocket(baseUrl: string): Socket {
if (socket) return socket;
socket = io(baseUrl, {
withCredentials: true,
transports: ['websocket', 'polling'],
path: '/socket.io'
});
return socket;
}
export function joinRequestRoom(socket: Socket, requestId: string) {
socket.emit('join:request', requestId);
}
export function leaveRequestRoom(socket: Socket, requestId: string) {
socket.emit('leave:request', requestId);
}

View File

@ -18,7 +18,7 @@ export default defineConfig({
port: 3000,
open: true,
host: true,
allowedHosts: ['9b89f4bfd360.ngrok-free.app'],
allowedHosts: ['9b89f4bfd360.ngrok-free.app','c6ba819712b5.ngrok-free.app'],
},
build: {
outDir: 'dist',