draft alert notification and websocjket functionalty added
This commit is contained in:
parent
382040f8c2
commit
7b8fac5d8c
@ -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
138
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
BIN
public/royal_enfield_logo.png
Normal file
BIN
public/royal_enfield_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
204
public/royal_enfield_logo.svg
Normal file
204
public/royal_enfield_logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 88 KiB |
26
public/service-worker.js
Normal file
26
public/service-worker.js
Normal 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);
|
||||
})());
|
||||
});
|
||||
|
||||
|
||||
24
src/App.tsx
24
src/App.tsx
@ -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');
|
||||
navigate(`/request/${requestId}`);
|
||||
|
||||
// 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"
|
||||
|
||||
116
src/components/approval/ApprovalModal/ApprovalModal.tsx
Normal file
116
src/components/approval/ApprovalModal/ApprovalModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
121
src/components/approval/RejectionModal/RejectionModal.tsx
Normal file
121
src/components/approval/RejectionModal/RejectionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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
|
||||
};
|
||||
setMessages(prev => [...prev, newMessage]);
|
||||
// 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">
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,15 +336,28 @@ 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,
|
||||
comment: a.comments || undefined,
|
||||
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')
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
47
src/utils/pushNotifications.ts
Normal file
47
src/utils/pushNotifications.ts
Normal 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
23
src/utils/socket.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user