Re_Figma_Code/src/services/workflowApi.ts

589 lines
21 KiB
TypeScript

import apiClient from './authApi';
export type PriorityUi = 'standard' | 'express';
export interface ApproverFormItem {
email: string;
name?: string;
tat?: number | '';
tatType?: 'hours' | 'days';
}
export interface ParticipantItem {
id?: string;
name: string;
email: string;
}
export interface CreateWorkflowFromFormPayload {
templateId?: string | null;
templateType: 'CUSTOM' | 'TEMPLATE';
title: string;
description: string;
priorityUi: PriorityUi;
approverCount: number;
approvers: ApproverFormItem[];
spectators?: ParticipantItem[];
ccList?: ParticipantItem[];
}
// Utility to generate a RFC4122 v4 UUID (fallback if crypto.randomUUID not available)
function generateUuid(): string {
if (typeof crypto !== 'undefined' && (crypto as any).randomUUID) {
return (crypto as any).randomUUID();
}
// Fallback
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export interface CreateWorkflowResponse {
id: string;
}
export async function createWorkflowFromForm(form: CreateWorkflowFromFormPayload): Promise<CreateWorkflowResponse> {
// Map UI priority to API enum
const priority = form.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD';
// Build approval levels to match backend schema
const approvalLevels = Array.from({ length: form.approverCount || 1 }, (_, i) => {
const idx = i;
const a = form.approvers[idx] || {} as ApproverFormItem;
const levelNumber = idx + 1;
const tatRaw = a.tat ?? '';
let tatHours = 0;
if (typeof tatRaw === 'number') {
tatHours = a.tatType === 'days' ? tatRaw * 24 : tatRaw;
}
const approverEmail = a.email || '';
const approverName = (a.name && a.name.trim()) || approverEmail.split('@')[0] || `Approver ${levelNumber}`;
return {
levelNumber,
levelName: `Level ${levelNumber}`,
approverId: generateUuid(),
approverEmail,
approverName,
tatHours: tatHours > 0 ? tatHours : 24,
isFinalApprover: levelNumber === (form.approverCount || 1),
};
});
// Participants -> spectators and ccList
const participants = [
...(form.spectators || []).map(p => ({
userId: generateUuid(),
userEmail: p.email,
userName: p.name || p.email.split('@')[0] || 'Spectator',
participantType: 'SPECTATOR' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: false,
notificationEnabled: true,
})),
...(form.ccList || []).map(p => ({
userId: generateUuid(),
userEmail: p.email,
userName: p.name || p.email.split('@')[0] || 'CC',
participantType: 'CONSULTATION' as const,
canComment: false,
canViewDocuments: true,
canDownloadDocuments: false,
notificationEnabled: true,
})),
];
const payload = {
templateType: form.templateType,
title: form.title,
description: form.description,
priority, // STANDARD | EXPRESS
approvalLevels,
participants: participants.length ? participants : undefined,
};
const res = await apiClient.post('/workflows', payload);
const data = (res.data?.data || res.data) as any;
return { id: data.id || data.workflowId || '' };
}
export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayload, files: File[], category: 'SUPPORTING' | 'APPROVAL' | 'REFERENCE' | 'FINAL' | 'OTHER' = 'SUPPORTING') {
// Simplified payload - backend handles user lookup and participant generation
const payload: any = {
templateType: form.templateType,
title: form.title,
description: form.description,
priority: form.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD',
// Simplified approvers format - only email and tatHours required
approvers: Array.from({ length: form.approverCount || 1 }, (_, i) => {
const a = form.approvers[i] || ({} as any);
const tat = typeof a.tat === 'number' ? a.tat : 0;
if (!a.email || !a.email.trim()) {
throw new Error(`Email is required for approver at level ${i + 1}.`);
}
return {
email: a.email,
tat: tat,
tatType: a.tatType || 'hours',
};
}),
};
// Add spectators if any (simplified - only email required)
if (form.spectators && form.spectators.length > 0) {
payload.spectators = form.spectators
.filter((s: any) => s?.email)
.map((s: any) => ({ email: s.email }));
}
// Note: participants array is auto-generated by backend from approvers and spectators
// No need to build or send it from frontend
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('category', category);
files.forEach(f => formData.append('files', f));
const res = await apiClient.post('/workflows/multipart', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const data = res.data?.data || res.data;
return { id: data?.requestId } as any;
}
export async function listWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows', {
params: {
page,
limit,
search,
status,
priority,
templateType,
department,
initiator,
approver,
slaCompliance,
dateRange,
startDate,
endDate
}
});
return res.data?.data || res.data;
}
// List requests where user is a participant (not initiator) - for regular users' "All Requests" page
// SEPARATE from listWorkflows (admin) to avoid interference
export async function listParticipantRequests(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; approverType?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/participant-requests', {
params: {
page,
limit,
search,
status,
priority,
templateType,
department,
initiator,
approver,
approverType,
slaCompliance,
dateRange,
startDate,
endDate
}
});
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
data: res.data?.data?.data || res.data?.data || [],
pagination: res.data?.data?.pagination || { page, limit, total: 0, totalPages: 1 }
};
}
// DEPRECATED: Use listParticipantRequests instead
// List requests where user is a participant (not initiator) - for "All Requests" page
export async function listMyWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/my', {
params: {
page,
limit,
search,
status,
priority,
department,
initiator,
approver,
slaCompliance,
dateRange,
startDate,
endDate
}
});
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
data: res.data?.data?.data || res.data?.data || [],
pagination: res.data?.data?.pagination || { page, limit, total: 0, totalPages: 1 }
};
}
// List requests where user is the initiator - for "My Requests" page
export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/my-initiated', {
params: {
page,
limit,
search,
status,
priority,
templateType,
department,
slaCompliance,
dateRange,
startDate,
endDate
}
});
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
data: res.data?.data?.data || res.data?.data || [],
pagination: res.data?.data?.pagination || { page, limit, total: 0, totalPages: 1 }
};
}
export async function listOpenForMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, sortBy, sortOrder } = params;
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit, search, status, priority, templateType, sortBy, sortOrder } });
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
data: res.data?.data?.data || res.data?.data || [],
pagination: res.data?.data?.pagination || { page, limit, total: 0, totalPages: 1 }
};
}
export async function listClosedByMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, sortBy, sortOrder } = params;
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit, search, status, priority, templateType, sortBy, sortOrder } });
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
data: res.data?.data?.data || res.data?.data || [],
pagination: res.data?.data?.pagination || { page, limit, total: 0, totalPages: 1 }
};
}
export async function getWorkflowDetails(requestId: string, _workflowType?: string) {
const res = await apiClient.get(`/workflows/${requestId}/details`);
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 async function addApprover(requestId: string, email: string) {
const res = await apiClient.post(`/workflows/${requestId}/participants/approver`, { email });
return res.data?.data || res.data;
}
export async function addApproverAtLevel(
requestId: string,
email: string,
tatHours: number,
level: number
) {
const res = await apiClient.post(`/workflows/${requestId}/approvers/at-level`, {
email,
tatHours,
level
});
return res.data?.data || res.data;
}
export async function skipApprover(requestId: string, levelId: string, reason?: string) {
const res = await apiClient.post(`/workflows/${requestId}/approvals/${levelId}/skip`, {
reason
});
return res.data?.data || res.data;
}
export async function addSpectator(requestId: string, email: string) {
const res = await apiClient.post(`/workflows/${requestId}/participants/spectator`, { email });
return res.data?.data || res.data;
}
export async function pauseWorkflow(
requestId: string,
levelId: string | null,
reason: string,
resumeDate: Date
) {
const res = await apiClient.post(`/workflows/${requestId}/pause`, {
levelId,
reason,
resumeDate: resumeDate.toISOString()
});
return res.data?.data || res.data;
}
export async function resumeWorkflow(requestId: string, notes?: string) {
const res = await apiClient.post(`/workflows/${requestId}/resume`, { notes });
return res.data?.data || res.data;
}
export async function retriggerPause(requestId: string) {
const res = await apiClient.post(`/workflows/${requestId}/pause/retrigger`);
return res.data?.data || res.data;
}
export async function getPauseDetails(requestId: string) {
const res = await apiClient.get(`/workflows/${requestId}/pause`);
return res.data?.data || res.data;
}
export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`;
}
export function getDocumentPreviewUrl(documentId: string): string {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
return `${baseURL}/api/v1/workflows/documents/${documentId}/preview`;
}
/**
* Extract filename from Content-Disposition header
* Handles both formats: filename="name" and filename*=UTF-8''encoded
*/
function extractFilenameFromContentDisposition(contentDisposition: string | null): string {
if (!contentDisposition) {
return 'download';
}
// Try to extract from filename* first (RFC 5987 encoded) - preferred for non-ASCII
const filenameStarMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/);
if (filenameStarMatch && filenameStarMatch[1]) {
try {
return decodeURIComponent(filenameStarMatch[1]);
} catch {
// If decoding fails, fall back to regular filename
}
}
// Fallback to regular filename (for ASCII-only filenames)
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/);
if (filenameMatch && filenameMatch.length > 1 && filenameMatch[1]) {
// Remove quotes and extract only the filename part (before any semicolon)
const filenameValue: string = filenameMatch[1];
const parts = filenameValue.replace(/^"|"$/g, '').split(';');
const extracted = parts[0]?.trim();
return extracted || 'download';
}
return 'download';
}
export async function downloadDocument(documentId: string): Promise<void> {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
// Build fetch options
const fetchOptions: RequestInit = {
credentials: 'include', // Send cookies in production
};
// In development, add Authorization header from localStorage
if (!isProduction) {
const token = localStorage.getItem('accessToken');
fetchOptions.headers = {
'Authorization': `Bearer ${token}`
};
}
const response = await fetch(downloadUrl, fetchOptions);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Download failed: ${response.status} - ${errorText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const contentDisposition = response.headers.get('Content-Disposition');
const filename = extractFilenameFromContentDisposition(contentDisposition);
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('[Download] Failed:', error);
throw error;
}
}
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
const downloadBaseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
// Build fetch options
const fetchOptions: RequestInit = {
credentials: 'include', // Send cookies in production
};
// In development, add Authorization header from localStorage
if (!isProduction) {
const token = localStorage.getItem('accessToken');
fetchOptions.headers = {
'Authorization': `Bearer ${token}`
};
}
const response = await fetch(downloadUrl, fetchOptions);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Download failed: ${response.status} - ${errorText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition');
const filename = extractFilenameFromContentDisposition(contentDisposition);
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('[Download] Failed:', error);
throw error;
}
}
export default {
createWorkflowFromForm,
createWorkflowMultipart,
listWorkflows, // Admin: All organization requests
listParticipantRequests, // Regular users: Participant requests only (not initiator)
listMyWorkflows, // DEPRECATED: Use listParticipantRequests
listMyInitiatedWorkflows, // Regular users: Initiator requests only
listOpenForMe,
listClosedByMe,
submitWorkflow,
getWorkflowDetails,
getWorkNotes,
createWorkNoteMultipart,
};
export async function submitWorkflow(requestId: string) {
const res = await apiClient.patch(`/workflows/${requestId}/submit`);
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;
}
/**
* Update breach reason for a TAT alert
*/
export async function updateBreachReason(levelId: string, breachReason: string): Promise<void> {
const response = await apiClient.put(`/tat/breach-reason/${levelId}`, {
breachReason
});
if (!response.data.success) {
throw new Error(response.data.error || 'Failed to update breach reason');
}
}
// Also export in default for convenience
// Note: keeping separate named export above for tree-shaking