589 lines
21 KiB
TypeScript
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
|
|
|
|
|