diff --git a/src/pages/OpenRequests/OpenRequests.tsx b/src/pages/OpenRequests/OpenRequests.tsx
index 25859c2..b57a609 100644
--- a/src/pages/OpenRequests/OpenRequests.tsx
+++ b/src/pages/OpenRequests/OpenRequests.tsx
@@ -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',
diff --git a/src/pages/RequestDetail/RequestDetail.tsx b/src/pages/RequestDetail/RequestDetail.tsx
index b005704..e2ebfd0 100644
--- a/src/pages/RequestDetail/RequestDetail.tsx
+++ b/src/pages/RequestDetail/RequestDetail.tsx
@@ -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
(null);
- const [loading, setLoading] = useState(false);
+ const [workNotes, setWorkNotes] = useState([]);
+ const [loadingNotes, setLoadingNotes] = useState(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(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 (
+ <>
{/* Header Section */}
@@ -395,6 +557,10 @@ export function RequestDetail({
Activity
+
+
+ Work Notes
+
{/* Overview Tab */}
@@ -553,7 +719,7 @@ export function RequestDetail({
+
setShowApproveModal(false)}
+ onConfirm={handleApproveConfirm}
+ requestIdDisplay={request.id}
+ requestTitle={request.title}
+ />
+ 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
+
diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx
index 89515cc..7fa8d47 100644
--- a/src/pages/Settings/Settings.tsx
+++ b/src/pages/Settings/Settings.tsx
@@ -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() {
-
-
Notification settings will be available soon
-
+
{ try { await setupPushNotifications(); alert('Notifications enabled'); } catch (e) { alert('Failed to enable notifications'); } }}>
+ Enable Push Notifications
+
diff --git a/src/services/authApi.ts b/src/services/authApi.ts
index 12f55aa..4526ee8 100644
--- a/src/services/authApi.ts
+++ b/src/services/authApi.ts
@@ -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({
diff --git a/src/services/workflowApi.ts b/src/services/workflowApi.ts
index 4173fc1..9a0e297 100644
--- a/src/services/workflowApi.ts
+++ b/src/services/workflowApi.ts
@@ -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
diff --git a/src/utils/pushNotifications.ts b/src/utils/pushNotifications.ts
new file mode 100644
index 0000000..79cd9db
--- /dev/null
+++ b/src/utils/pushNotifications.ts
@@ -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);
+}
+
+
diff --git a/src/utils/socket.ts b/src/utils/socket.ts
new file mode 100644
index 0000000..77d44fd
--- /dev/null
+++ b/src/utils/socket.ts
@@ -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);
+}
+
+
diff --git a/vite.config.ts b/vite.config.ts
index 60a58a0..a47599d 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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',