Added Notification Management Component
This commit is contained in:
parent
96fafd33d7
commit
33982fb093
795
components/process-flow/NotificationNodeConfiguration.vue
Normal file
795
components/process-flow/NotificationNodeConfiguration.vue
Normal file
@ -0,0 +1,795 @@
|
||||
<template>
|
||||
<div class="notification-node-configuration">
|
||||
<!-- Step 1: Basic Configuration -->
|
||||
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center mr-2">
|
||||
<span class="text-xs font-semibold text-blue-600">1</span>
|
||||
</div>
|
||||
<h4 class="font-medium">Basic Configuration</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Notification Name -->
|
||||
<div>
|
||||
<label for="nodeLabel" class="block text-sm font-medium text-gray-700 mb-1">Notification Name</label>
|
||||
<input
|
||||
id="nodeLabel"
|
||||
v-model="localNodeData.label"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Enter a descriptive name"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
A clear name helps identify this notification in the process flow
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="nodeDescription" class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
id="nodeDescription"
|
||||
v-model="localNodeData.description"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Describe what this notification does"
|
||||
rows="2"
|
||||
@blur="saveChanges"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Optional description to explain this notification's purpose
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Notification Type -->
|
||||
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center mr-2">
|
||||
<span class="text-xs font-semibold text-blue-600">2</span>
|
||||
</div>
|
||||
<h4 class="font-medium">Notification Type</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<div
|
||||
v-for="type in notificationTypes"
|
||||
:key="type.value"
|
||||
@click="selectNotificationType(type.value)"
|
||||
class="notification-type-card cursor-pointer p-3 border rounded-md flex items-center"
|
||||
:class="localNodeData.notificationType === type.value ? 'bg-blue-50 border-blue-300' : 'bg-white border-gray-200 hover:bg-gray-50'"
|
||||
>
|
||||
<div :class="`text-${type.color}-500 mr-3`">
|
||||
<Icon :name="type.icon" class="text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-medium text-sm">{{ type.label }}</h5>
|
||||
<p class="text-xs text-gray-500">{{ type.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Recipients -->
|
||||
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center mr-2">
|
||||
<span class="text-xs font-semibold text-blue-600">3</span>
|
||||
</div>
|
||||
<h4 class="font-medium">Recipients</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<!-- Recipient Type Selection -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Recipient Type</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.recipientType"
|
||||
value="user"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">User</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.recipientType"
|
||||
value="role"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">Role</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.recipientType"
|
||||
value="group"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">Group</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.recipientType"
|
||||
value="variable"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">Process Variable</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.recipientType"
|
||||
value="email"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">Email Address</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Recipient Input based on type -->
|
||||
<div>
|
||||
<div v-if="localNodeData.recipientType === 'user'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Select User</label>
|
||||
<select
|
||||
v-model="localNodeData.recipientUser"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="">Select a user</option>
|
||||
<option v-for="user in mockUsers" :key="user.id" :value="user.id">
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="localNodeData.recipientType === 'role'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Select Role</label>
|
||||
<select
|
||||
v-model="localNodeData.recipientRole"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="">Select a role</option>
|
||||
<option v-for="role in mockRoles" :key="role.id" :value="role.id">
|
||||
{{ role.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="localNodeData.recipientType === 'group'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Select Group</label>
|
||||
<select
|
||||
v-model="localNodeData.recipientGroup"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="">Select a group</option>
|
||||
<option v-for="group in mockGroups" :key="group.id" :value="group.id">
|
||||
{{ group.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="localNodeData.recipientType === 'variable'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Select Variable</label>
|
||||
<select
|
||||
v-model="localNodeData.recipientVariable"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
@change="saveChanges"
|
||||
>
|
||||
<option value="">Select a variable</option>
|
||||
<option v-for="variable in availableVariables" :key="variable.name" :value="variable.name">
|
||||
{{ variable.label || variable.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Variable should contain a user ID, role ID, or email address
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="localNodeData.recipientType === 'email'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
|
||||
<input
|
||||
v-model="localNodeData.recipientEmail"
|
||||
type="email"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Enter email address"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Notification Content -->
|
||||
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center mr-2">
|
||||
<span class="text-xs font-semibold text-blue-600">4</span>
|
||||
</div>
|
||||
<h4 class="font-medium">Notification Content</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<!-- Subject -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Subject</label>
|
||||
<input
|
||||
v-model="localNodeData.subject"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Enter notification subject"
|
||||
@blur="saveChanges"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
You can use variable placeholders with {variableName} syntax
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Message Body -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Message</label>
|
||||
<!-- Message Format Selector -->
|
||||
<div class="flex items-center mb-2 space-x-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.messageFormat"
|
||||
value="text"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2 text-sm">Plain Text</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.messageFormat"
|
||||
value="richtext"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2 text-sm">Rich Text</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="localNodeData.messageFormat"
|
||||
value="html"
|
||||
class="form-radio"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2 text-sm">HTML</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Plain Text Editor -->
|
||||
<textarea
|
||||
v-if="localNodeData.messageFormat === 'text'"
|
||||
v-model="localNodeData.message"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Enter notification message"
|
||||
rows="4"
|
||||
@blur="saveChanges"
|
||||
></textarea>
|
||||
|
||||
<!-- Rich Text Editor -->
|
||||
<div v-if="localNodeData.messageFormat === 'richtext'" class="border rounded-md">
|
||||
<ClientOnly>
|
||||
<QuillEditor
|
||||
v-model:content="localNodeData.richTextMessage"
|
||||
contentType="html"
|
||||
theme="snow"
|
||||
toolbar="essential"
|
||||
@update:content="saveChanges"
|
||||
placeholder="Enter rich text notification message"
|
||||
/>
|
||||
</ClientOnly>
|
||||
<div class="bg-gray-50 border-t p-2">
|
||||
<p class="text-xs text-gray-500">
|
||||
You can use variable placeholders with {variableName} syntax in your content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rich Text Preview -->
|
||||
<div v-if="localNodeData.messageFormat === 'richtext'" class="mt-2">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Rich Text Preview</label>
|
||||
<button
|
||||
@click="showRichTextPreview = !showRichTextPreview"
|
||||
class="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{{ showRichTextPreview ? 'Hide Preview' : 'Show Preview' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showRichTextPreview" class="border rounded-md p-3 bg-white html-preview">
|
||||
<div v-html="localNodeData.richTextMessage"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTML Editor -->
|
||||
<div v-if="localNodeData.messageFormat === 'html'" class="border rounded-md">
|
||||
<div class="bg-gray-50 border-b p-2 flex justify-between items-center">
|
||||
<span class="text-sm font-medium">HTML Editor</span>
|
||||
<div class="flex space-x-2">
|
||||
<select
|
||||
v-model="htmlTemplate"
|
||||
class="text-xs p-1 border rounded"
|
||||
@change="applyHtmlTemplate"
|
||||
>
|
||||
<option value="">Select Template</option>
|
||||
<option value="basic">Basic Template</option>
|
||||
<option value="card">Card Template</option>
|
||||
<option value="email">Email Template</option>
|
||||
</select>
|
||||
<button
|
||||
class="text-xs px-2 py-1 bg-blue-50 text-blue-600 border border-blue-200 rounded hover:bg-blue-100"
|
||||
@click="showHtmlHelpers = !showHtmlHelpers"
|
||||
>
|
||||
{{ showHtmlHelpers ? 'Hide Helpers' : 'Show Helpers' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTML Helpers -->
|
||||
<div v-if="showHtmlHelpers" class="bg-blue-50 p-2 border-b border-blue-200">
|
||||
<div class="text-xs font-medium mb-1 text-blue-700">Insert HTML Elements:</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="(snippet, key) in htmlSnippets"
|
||||
:key="key"
|
||||
@click="insertHtmlSnippet(snippet.code)"
|
||||
class="text-xs px-2 py-1 bg-white border border-blue-200 rounded hover:bg-blue-100"
|
||||
:title="snippet.description"
|
||||
>
|
||||
{{ snippet.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<rs-code-mirror
|
||||
v-model="localNodeData.htmlMessage"
|
||||
mode="html"
|
||||
height="250px"
|
||||
@update:modelValue="saveChanges"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
You can use variable placeholders with {variableName} syntax
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- HTML Preview -->
|
||||
<div v-if="localNodeData.messageFormat === 'html'" class="mt-2">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">HTML Preview</label>
|
||||
<button
|
||||
@click="showHtmlPreview = !showHtmlPreview"
|
||||
class="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{{ showHtmlPreview ? 'Hide Preview' : 'Show Preview' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showHtmlPreview" class="border rounded-md p-3 bg-white html-preview">
|
||||
<div v-html="localNodeData.htmlMessage"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variable Preview -->
|
||||
<div v-if="hasVariablePlaceholders" class="bg-white p-3 border rounded-md">
|
||||
<h5 class="font-medium text-sm mb-2">Variable Placeholders Used</h5>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="variable in usedVariables"
|
||||
:key="variable"
|
||||
class="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-md flex items-center"
|
||||
>
|
||||
<span>{{ variable }}</span>
|
||||
<span
|
||||
v-if="!isValidVariable(variable)"
|
||||
class="ml-1 text-red-500"
|
||||
title="This variable is not defined in the process"
|
||||
>
|
||||
<Icon name="material-symbols:warning" class="text-xs" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Options -->
|
||||
<div class="mb-6 bg-gray-50 p-4 rounded-md border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center mr-2">
|
||||
<span class="text-xs font-semibold text-blue-600">5</span>
|
||||
</div>
|
||||
<h4 class="font-medium">Delivery Options</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="localNodeData.deliveryOptions.inApp"
|
||||
class="form-checkbox"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">In-app notification</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="localNodeData.deliveryOptions.email"
|
||||
class="form-checkbox"
|
||||
@change="saveChanges"
|
||||
/>
|
||||
<span class="ml-2">Email notification</span>
|
||||
</label>
|
||||
<p v-if="localNodeData.messageFormat === 'html' || localNodeData.messageFormat === 'richtext'" class="mt-1 text-xs text-blue-600 bg-blue-50 p-2 rounded">
|
||||
<Icon name="material-symbols:info-outline" class="inline-block mr-1" />
|
||||
{{ localNodeData.messageFormat === 'html' ? 'HTML' : 'Rich Text' }} formatting is only supported for email notifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useVariableStore } from '~/stores/variableStore';
|
||||
|
||||
const props = defineProps({
|
||||
nodeData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
availableVariables: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
// Initialize local data with defaults
|
||||
const localNodeData = ref({
|
||||
label: props.nodeData.label || 'Notification',
|
||||
description: props.nodeData.description || '',
|
||||
notificationType: props.nodeData.notificationType || 'info',
|
||||
recipientType: props.nodeData.recipientType || 'user',
|
||||
recipientUser: props.nodeData.recipientUser || '',
|
||||
recipientRole: props.nodeData.recipientRole || '',
|
||||
recipientGroup: props.nodeData.recipientGroup || '',
|
||||
recipientVariable: props.nodeData.recipientVariable || '',
|
||||
recipientEmail: props.nodeData.recipientEmail || '',
|
||||
subject: props.nodeData.subject || '',
|
||||
message: props.nodeData.message || '',
|
||||
priority: props.nodeData.priority || 'medium',
|
||||
deliveryOptions: props.nodeData.deliveryOptions || {
|
||||
inApp: true,
|
||||
email: false,
|
||||
sms: false
|
||||
},
|
||||
expiration: props.nodeData.expiration || {
|
||||
enabled: false,
|
||||
value: 24,
|
||||
unit: 'hours'
|
||||
},
|
||||
messageFormat: props.nodeData.messageFormat || 'text',
|
||||
htmlMessage: props.nodeData.htmlMessage || '',
|
||||
richTextMessage: props.nodeData.richTextMessage || ''
|
||||
});
|
||||
|
||||
// Preview state
|
||||
const showHtmlPreview = ref(false);
|
||||
const htmlTemplate = ref('');
|
||||
const showHtmlHelpers = ref(false);
|
||||
const showRichTextPreview = ref(false);
|
||||
|
||||
// HTML Templates
|
||||
const templates = {
|
||||
basic: `<div style="font-family: Arial, sans-serif; padding: 15px;">
|
||||
<h2 style="color: #3b82f6;">Notification Title</h2>
|
||||
<p>Hello {userName},</p>
|
||||
<p>This is a basic notification message.</p>
|
||||
<p>Thank you,<br>Process Maker</p>
|
||||
</div>`,
|
||||
card: `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
<div style="background-color: #3b82f6; color: white; padding: 15px;">
|
||||
<h2 style="margin: 0;">Important Notification</h2>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<p>Hello {userName},</p>
|
||||
<p>This is an important notification regarding {processName}.</p>
|
||||
<p>Please review the details below:</p>
|
||||
<ul>
|
||||
<li>Item 1: {item1}</li>
|
||||
<li>Item 2: {item2}</li>
|
||||
</ul>
|
||||
<div style="margin-top: 20px; padding: 10px; background-color: #f3f4f6; border-radius: 4px;">
|
||||
<p style="margin: 0; font-size: 14px;">Reference ID: {referenceId}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background-color: #f3f4f6; padding: 15px; text-align: center; font-size: 14px;">
|
||||
<p>This is an automated notification from Process Maker.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
email: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Process Notification</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; margin: 0; padding: 0; background-color: #f9fafb;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||
<tr>
|
||||
<td style="background-color: #3b82f6; padding: 20px; text-align: center;">
|
||||
<h1 style="color: white; margin: 0;">Process Notification</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px;">
|
||||
<p>Hello {userName},</p>
|
||||
<p>You have a new notification regarding process <strong>{processName}</strong>.</p>
|
||||
<p>Status: <span style="color: #10b981; font-weight: bold;">{status}</span></p>
|
||||
<p>Please take appropriate action by clicking the button below:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{actionUrl}" style="background-color: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">View Details</a>
|
||||
</div>
|
||||
<p>If you have any questions, please contact the system administrator.</p>
|
||||
<p>Thank you,<br>Process Maker Team</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color: #f3f4f6; padding: 15px; text-align: center; font-size: 14px; color: #6b7280;">
|
||||
<p>This is an automated email. Please do not reply to this message.</p>
|
||||
<p>© 2023 Process Maker. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
};
|
||||
|
||||
// Apply HTML template
|
||||
const applyHtmlTemplate = () => {
|
||||
if (htmlTemplate.value && templates[htmlTemplate.value]) {
|
||||
localNodeData.value.htmlMessage = templates[htmlTemplate.value];
|
||||
saveChanges();
|
||||
// Reset the select after applying
|
||||
htmlTemplate.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for changes to nodeData prop
|
||||
watch(() => props.nodeData, (value) => {
|
||||
// Merge incoming props with defaults for any missing values
|
||||
localNodeData.value = {
|
||||
label: value.label || localNodeData.value.label,
|
||||
description: value.description || localNodeData.value.description,
|
||||
notificationType: value.notificationType || localNodeData.value.notificationType,
|
||||
recipientType: value.recipientType || localNodeData.value.recipientType,
|
||||
recipientUser: value.recipientUser || localNodeData.value.recipientUser,
|
||||
recipientRole: value.recipientRole || localNodeData.value.recipientRole,
|
||||
recipientGroup: value.recipientGroup || localNodeData.value.recipientGroup,
|
||||
recipientVariable: value.recipientVariable || localNodeData.value.recipientVariable,
|
||||
recipientEmail: value.recipientEmail || localNodeData.value.recipientEmail,
|
||||
subject: value.subject || localNodeData.value.subject,
|
||||
message: value.message || localNodeData.value.message,
|
||||
priority: value.priority || localNodeData.value.priority,
|
||||
deliveryOptions: value.deliveryOptions || localNodeData.value.deliveryOptions,
|
||||
expiration: value.expiration || localNodeData.value.expiration,
|
||||
messageFormat: value.messageFormat || localNodeData.value.messageFormat,
|
||||
htmlMessage: value.htmlMessage || localNodeData.value.htmlMessage,
|
||||
richTextMessage: value.richTextMessage || localNodeData.value.richTextMessage
|
||||
};
|
||||
}, { deep: true });
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockUsers = [
|
||||
{ id: 'user1', name: 'John Doe' },
|
||||
{ id: 'user2', name: 'Jane Smith' },
|
||||
{ id: 'user3', name: 'Mike Johnson' }
|
||||
];
|
||||
|
||||
const mockRoles = [
|
||||
{ id: 'role1', name: 'Administrator' },
|
||||
{ id: 'role2', name: 'Manager' },
|
||||
{ id: 'role3', name: 'Approver' },
|
||||
{ id: 'role4', name: 'User' }
|
||||
];
|
||||
|
||||
const mockGroups = [
|
||||
{ id: 'group1', name: 'Marketing Team' },
|
||||
{ id: 'group2', name: 'Finance Team' },
|
||||
{ id: 'group3', name: 'Operations Team' }
|
||||
];
|
||||
|
||||
// Notification types
|
||||
const notificationTypes = [
|
||||
{
|
||||
value: 'info',
|
||||
label: 'Information',
|
||||
icon: 'material-symbols:info-outline',
|
||||
color: 'blue',
|
||||
description: 'General information notifications'
|
||||
},
|
||||
{
|
||||
value: 'success',
|
||||
label: 'Success',
|
||||
icon: 'material-symbols:check-circle-outline',
|
||||
color: 'green',
|
||||
description: 'Success or completion notifications'
|
||||
},
|
||||
{
|
||||
value: 'warning',
|
||||
label: 'Warning',
|
||||
icon: 'material-symbols:warning-outline',
|
||||
color: 'yellow',
|
||||
description: 'Warning notifications requiring attention'
|
||||
},
|
||||
{
|
||||
value: 'error',
|
||||
label: 'Error',
|
||||
icon: 'material-symbols:error-outline',
|
||||
color: 'red',
|
||||
description: 'Error notifications requiring action'
|
||||
},
|
||||
{
|
||||
value: 'reminder',
|
||||
label: 'Reminder',
|
||||
icon: 'material-symbols:notifications-active',
|
||||
color: 'purple',
|
||||
description: 'Reminders about pending tasks or deadlines'
|
||||
},
|
||||
{
|
||||
value: 'status',
|
||||
label: 'Status Update',
|
||||
icon: 'material-symbols:update',
|
||||
color: 'indigo',
|
||||
description: 'Updates about process or task status changes'
|
||||
}
|
||||
];
|
||||
|
||||
// Priority options
|
||||
const priorities = [
|
||||
{
|
||||
value: 'low',
|
||||
label: 'Low',
|
||||
icon: 'material-symbols:arrow-downward',
|
||||
color: 'gray'
|
||||
},
|
||||
{
|
||||
value: 'medium',
|
||||
label: 'Medium',
|
||||
icon: 'material-symbols:drag-handle',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
value: 'high',
|
||||
label: 'High',
|
||||
icon: 'material-symbols:arrow-upward',
|
||||
color: 'red'
|
||||
}
|
||||
];
|
||||
|
||||
// Methods
|
||||
const saveChanges = () => {
|
||||
emit('update', localNodeData.value);
|
||||
};
|
||||
|
||||
const selectNotificationType = (type) => {
|
||||
localNodeData.value.notificationType = type;
|
||||
saveChanges();
|
||||
};
|
||||
|
||||
// Extract variables from content
|
||||
const extractVariables = (text) => {
|
||||
const regex = /{([^}]+)}/g;
|
||||
const matches = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
matches.push(match[1]);
|
||||
}
|
||||
|
||||
return [...new Set(matches)]; // Return unique values
|
||||
};
|
||||
|
||||
// Check if a variable exists in available variables
|
||||
const isValidVariable = (variableName) => {
|
||||
return props.availableVariables.some(v => v.name === variableName);
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const usedVariables = computed(() => {
|
||||
const subjectVars = extractVariables(localNodeData.value.subject || '');
|
||||
const messageVars = extractVariables(localNodeData.value.message || '');
|
||||
const htmlVars = extractVariables(localNodeData.value.htmlMessage || '');
|
||||
const richTextVars = extractVariables(localNodeData.value.richTextMessage || '');
|
||||
return [...new Set([...subjectVars, ...messageVars, ...htmlVars, ...richTextVars])];
|
||||
});
|
||||
|
||||
const hasVariablePlaceholders = computed(() => {
|
||||
return usedVariables.value.length > 0;
|
||||
});
|
||||
|
||||
// HTML Helpers
|
||||
const htmlSnippets = [
|
||||
{ label: 'h1', code: '<h1>Heading 1</h1>', description: 'Adds a level 1 heading' },
|
||||
{ label: 'h2', code: '<h2>Heading 2</h2>', description: 'Adds a level 2 heading' },
|
||||
{ label: 'h3', code: '<h3>Heading 3</h3>', description: 'Adds a level 3 heading' },
|
||||
{ label: 'p', code: '<p>Paragraph</p>', description: 'Adds a paragraph' },
|
||||
{ label: 'a', code: '<a href="#">Link</a>', description: 'Adds a clickable link' },
|
||||
{ label: 'img', code: '<img src="https://via.placeholder.com/150" alt="Placeholder">', description: 'Adds an image' },
|
||||
{ label: 'ul', code: '<ul><li>Item 1</li><li>Item 2</li></ul>', description: 'Adds an unordered list' },
|
||||
{ label: 'ol', code: '<ol><li>Item 1</li><li>Item 2</li></ol>', description: 'Adds an ordered list' },
|
||||
{ label: 'div', code: '<div>Divider</div>', description: 'Adds a divider' },
|
||||
{ label: 'span', code: '<span>Highlight</span>', description: 'Adds a highlighted text' }
|
||||
];
|
||||
|
||||
const insertHtmlSnippet = (code) => {
|
||||
// Simply append the snippet to the end of the current content
|
||||
localNodeData.value.htmlMessage = (localNodeData.value.htmlMessage || '') + '\n' + code;
|
||||
saveChanges();
|
||||
};
|
||||
|
||||
// Watch for message format changes to enable email delivery for HTML and Rich Text
|
||||
watch(() => localNodeData.value.messageFormat, (newFormat) => {
|
||||
if ((newFormat === 'html' || newFormat === 'richtext') && !localNodeData.value.deliveryOptions.email) {
|
||||
// Automatically enable email delivery when HTML or Rich Text format is selected
|
||||
localNodeData.value.deliveryOptions.email = true;
|
||||
saveChanges();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-type-card {
|
||||
min-width: 200px;
|
||||
flex: 1 0 200px;
|
||||
max-width: 300px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* HTML Editor Styles */
|
||||
:deep(.cm-editor) {
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.cm-content) {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Preview Styles */
|
||||
.html-preview {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
113
components/process-flow/NotificationNodeConfigurationModal.vue
Normal file
113
components/process-flow/NotificationNodeConfigurationModal.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<RsModal
|
||||
v-model="showModal"
|
||||
title="Notification Configuration"
|
||||
size="xl"
|
||||
position="center"
|
||||
:okCallback="saveAndClose"
|
||||
okTitle="Save"
|
||||
:cancelCallback="closeModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="mb-6">
|
||||
<div class="flex items-start">
|
||||
<div class="mr-4 text-blue-500 flex-shrink-0 mt-1">
|
||||
<Icon name="material-symbols:notifications-outline" class="text-2xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-1">Configure Notification</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
Set up notifications that will be sent to users during the process execution.
|
||||
Notifications can be delivered via in-app alerts, email, or SMS.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main configuration area -->
|
||||
<NotificationNodeConfiguration
|
||||
:nodeData="localNodeData"
|
||||
:availableVariables="availableVariables"
|
||||
@update="handleUpdate"
|
||||
/>
|
||||
|
||||
<!-- Quick Reference Guide -->
|
||||
<div class="mt-6 bg-blue-50 p-4 rounded-md border border-blue-100">
|
||||
<h4 class="font-medium text-blue-700 mb-2 flex items-center">
|
||||
<Icon name="material-symbols:info-outline" class="mr-1" />
|
||||
Quick Reference Guide
|
||||
</h4>
|
||||
<div class="text-sm text-blue-700">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>Select notification type and priority to control how it appears</li>
|
||||
<li>Choose recipients who will receive the notification</li>
|
||||
<li>Use variable placeholders with <code class="bg-blue-100 px-1">{variableName}</code> syntax</li>
|
||||
<li>Configure delivery methods (in-app, email, SMS)</li>
|
||||
<li>Set expiration time for time-sensitive notifications</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RsModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import NotificationNodeConfiguration from './NotificationNodeConfiguration.vue';
|
||||
import { Icon } from '#components';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
nodeData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
availableVariables: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update']);
|
||||
|
||||
const showModal = ref(props.modelValue);
|
||||
const localNodeData = ref({ ...props.nodeData });
|
||||
|
||||
// Watch for changes to modelValue prop to sync modal visibility
|
||||
watch(() => props.modelValue, (value) => {
|
||||
showModal.value = value;
|
||||
});
|
||||
|
||||
// Watch for changes to showModal to emit update:modelValue
|
||||
watch(() => showModal.value, (value) => {
|
||||
emit('update:modelValue', value);
|
||||
});
|
||||
|
||||
// Watch for changes to nodeData prop
|
||||
watch(() => props.nodeData, (value) => {
|
||||
localNodeData.value = { ...value };
|
||||
}, { deep: true });
|
||||
|
||||
function handleUpdate(updatedData) {
|
||||
localNodeData.value = { ...updatedData };
|
||||
}
|
||||
|
||||
function saveAndClose() {
|
||||
emit('update', localNodeData.value);
|
||||
showModal.value = false;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
code {
|
||||
font-family: monospace;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
@ -119,22 +119,6 @@ const availableComponents = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'business-rule',
|
||||
name: 'Business Rule',
|
||||
category: 'Core',
|
||||
icon: 'material-symbols:rule',
|
||||
description: 'Apply business rules to process data',
|
||||
defaultProps: {
|
||||
label: 'Business Rule',
|
||||
data: {
|
||||
description: 'Applies business rules to process data',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
priority: 'medium'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'gateway',
|
||||
name: 'Decision Point',
|
||||
@ -149,6 +133,69 @@ const availableComponents = [
|
||||
defaultPath: 'Default'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
name: 'Notification',
|
||||
category: 'Core',
|
||||
icon: 'material-symbols:notifications-outline',
|
||||
description: 'Send notifications to users',
|
||||
defaultProps: {
|
||||
label: 'Notification',
|
||||
data: {
|
||||
description: 'Send notification to users',
|
||||
notificationType: 'info',
|
||||
recipientType: 'user',
|
||||
recipientUser: '',
|
||||
recipientRole: '',
|
||||
recipientVariable: '',
|
||||
recipientEmail: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
priority: 'medium',
|
||||
deliveryOptions: {
|
||||
inApp: true,
|
||||
email: false,
|
||||
sms: false
|
||||
},
|
||||
expiration: {
|
||||
enabled: false,
|
||||
value: 24,
|
||||
unit: 'hours'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Advanced components
|
||||
{
|
||||
type: 'script',
|
||||
name: 'Script Task',
|
||||
category: 'Advanced',
|
||||
icon: 'material-symbols:code',
|
||||
description: 'Execute custom code',
|
||||
defaultProps: {
|
||||
label: 'Script',
|
||||
data: {
|
||||
description: 'Script execution',
|
||||
language: 'JavaScript',
|
||||
script: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'business-rule',
|
||||
name: 'Business Rule',
|
||||
category: 'Advanced',
|
||||
icon: 'material-symbols:rule',
|
||||
description: 'Apply business rules to data',
|
||||
defaultProps: {
|
||||
label: 'Business Rule',
|
||||
data: {
|
||||
description: 'Apply business rules',
|
||||
ruleGroups: [],
|
||||
priority: 'medium'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -484,6 +484,87 @@ export const BusinessRuleNode = markRaw({
|
||||
}
|
||||
});
|
||||
|
||||
// Notification node
|
||||
export const NotificationNode = markRaw({
|
||||
props: ['id', 'type', 'label', 'selected', 'data'],
|
||||
computed: {
|
||||
nodeLabel() {
|
||||
// Get label from either prop or data, with fallback
|
||||
return this.label || (this.data && this.data.label) || 'Notification';
|
||||
},
|
||||
notificationType() {
|
||||
return this.data?.notificationType || 'info';
|
||||
},
|
||||
notificationTypeIcon() {
|
||||
const types = {
|
||||
info: 'material-symbols:info-outline',
|
||||
success: 'material-symbols:check-circle-outline',
|
||||
warning: 'material-symbols:warning-outline',
|
||||
error: 'material-symbols:error-outline'
|
||||
};
|
||||
return types[this.notificationType] || types.info;
|
||||
},
|
||||
notificationTypeColor() {
|
||||
const colors = {
|
||||
info: 'text-blue-500',
|
||||
success: 'text-green-500',
|
||||
warning: 'text-yellow-500',
|
||||
error: 'text-red-500'
|
||||
};
|
||||
return colors[this.notificationType] || colors.info;
|
||||
},
|
||||
recipientType() {
|
||||
return this.data?.recipientType || 'user';
|
||||
},
|
||||
recipientLabel() {
|
||||
const types = {
|
||||
user: 'User',
|
||||
role: 'Role',
|
||||
variable: 'Variable',
|
||||
email: 'Email'
|
||||
};
|
||||
return types[this.recipientType] || 'User';
|
||||
},
|
||||
isConfigured() {
|
||||
// Check if notification has required fields
|
||||
return !!(this.data?.subject && this.data?.message);
|
||||
}
|
||||
},
|
||||
render() {
|
||||
return h(CustomNode, {
|
||||
id: this.id,
|
||||
type: 'notification',
|
||||
label: this.nodeLabel,
|
||||
selected: this.selected,
|
||||
data: this.data,
|
||||
onClick: () => this.$emit('node-click', this.id)
|
||||
}, {
|
||||
icon: () => h('i', { class: `material-icons ${this.notificationTypeColor}` }, 'notifications'),
|
||||
default: () => h('div', { class: 'node-details' }, [
|
||||
h('p', { class: 'node-description' }, this.data?.description || 'Send notification'),
|
||||
h('div', { class: 'node-rule-detail flex items-center justify-between text-xs mt-1' }, [
|
||||
h('span', { class: 'node-rule-detail-label' }, 'Type:'),
|
||||
h('span', {
|
||||
class: `node-rule-detail-value ml-1 font-medium ${this.notificationTypeColor}`
|
||||
}, this.notificationType.charAt(0).toUpperCase() + this.notificationType.slice(1))
|
||||
]),
|
||||
h('div', { class: 'node-rule-detail flex items-center justify-between text-xs mt-1' }, [
|
||||
h('span', { class: 'node-rule-detail-label' }, 'Recipient:'),
|
||||
h('span', {
|
||||
class: 'node-rule-detail-value ml-1 font-medium text-blue-600'
|
||||
}, this.recipientLabel)
|
||||
]),
|
||||
h('div', { class: 'node-rule-detail flex items-center justify-between text-xs mt-1' }, [
|
||||
h('span', { class: 'node-rule-detail-label' }, 'Status:'),
|
||||
h('span', {
|
||||
class: this.isConfigured ? 'node-rule-detail-value ml-1 font-medium text-green-600' : 'node-rule-detail-value ml-1 font-medium text-red-600'
|
||||
}, this.isConfigured ? 'Configured' : 'Not configured')
|
||||
])
|
||||
])
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Export the node types object to use with Vue Flow
|
||||
export const nodeTypes = markRaw({
|
||||
task: TaskNode,
|
||||
@ -493,7 +574,8 @@ export const nodeTypes = markRaw({
|
||||
form: FormNode,
|
||||
script: ScriptNode,
|
||||
'business-rule': BusinessRuleNode,
|
||||
api: ApiCallNode
|
||||
api: ApiCallNode,
|
||||
notification: NotificationNode
|
||||
});
|
||||
|
||||
// Default CSS for the nodes to be imported where needed
|
||||
|
509
components/process-flow/notification/NotificationLogs.vue
Normal file
509
components/process-flow/notification/NotificationLogs.vue
Normal file
@ -0,0 +1,509 @@
|
||||
<template>
|
||||
<div class="notification-logs">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Notification Logs</h3>
|
||||
<div class="flex space-x-2">
|
||||
<RsButton @click="refreshLogs" variant="tertiary" size="sm">
|
||||
<Icon name="material-symbols:refresh" class="mr-1" />
|
||||
Refresh
|
||||
</RsButton>
|
||||
<RsButton @click="exportLogs" variant="secondary" size="sm" :disabled="logs.length === 0">
|
||||
<Icon name="material-symbols:download" class="mr-1" />
|
||||
Export
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-md p-3 mb-4">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
v-model="filters.status"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="read">Read</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
|
||||
<select
|
||||
v-model="filters.type"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="info">Information</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Channel</label>
|
||||
<select
|
||||
v-model="filters.channel"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="all">All Channels</option>
|
||||
<option value="in-app">In-App</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Date Range</label>
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
v-model="filters.dateFrom"
|
||||
type="date"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
/>
|
||||
<input
|
||||
v-model="filters.dateTo"
|
||||
type="date"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||
<input
|
||||
v-model="filters.search"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Search by recipient or subject..."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<RsButton @click="resetFilters" variant="tertiary" size="sm">
|
||||
Reset
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Items Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white border border-gray-200 rounded-md">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Timestamp
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Recipient
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Subject
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Channel
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-if="filteredLogs.length === 0">
|
||||
<td colspan="8" class="py-4 px-3 text-center text-sm text-gray-500">
|
||||
No notification logs found matching your filters.
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="log in paginatedLogs" :key="log.id" class="hover:bg-gray-50">
|
||||
<td class="py-2 px-3 text-sm">
|
||||
<span class="font-mono text-xs">{{ log.id.substring(0, 8) }}</span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
{{ formatDate(log.timestamp) }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
<div class="flex items-center">
|
||||
<div :class="`text-${getTypeColor(log.type)}-500 mr-2`">
|
||||
<Icon :name="getTypeIcon(log.type)" />
|
||||
</div>
|
||||
<span class="capitalize">{{ log.type }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
{{ log.recipient }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
{{ log.subject }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
<span class="capitalize">{{ log.channel }}</span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
<span
|
||||
:class="getStatusClass(log.status)"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="viewLog(log)"
|
||||
class="text-blue-600 hover:text-blue-800"
|
||||
title="View Details"
|
||||
>
|
||||
<Icon name="material-symbols:visibility-outline" />
|
||||
</button>
|
||||
<button
|
||||
v-if="log.status === 'failed'"
|
||||
@click="resendNotification(log)"
|
||||
class="text-green-600 hover:text-green-800"
|
||||
title="Resend"
|
||||
>
|
||||
<Icon name="material-symbols:refresh" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-500">
|
||||
Showing {{ paginationStart }} to {{ paginationEnd }} of {{ filteredLogs.length }} items
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<RsButton
|
||||
@click="prevPage"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
:disabled="currentPage === 1"
|
||||
>
|
||||
Previous
|
||||
</RsButton>
|
||||
<RsButton
|
||||
@click="nextPage"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
:disabled="currentPage >= totalPages"
|
||||
>
|
||||
Next
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Log Modal -->
|
||||
<RsModal
|
||||
v-model="showViewModal"
|
||||
title="Notification Log Details"
|
||||
size="lg"
|
||||
position="center"
|
||||
:cancelCallback="closeViewModal"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div v-if="selectedLog" class="space-y-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ selectedLog.subject }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ formatDate(selectedLog.timestamp) }}</p>
|
||||
</div>
|
||||
<span
|
||||
:class="getStatusClass(selectedLog.status)"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>
|
||||
{{ selectedLog.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Type</h4>
|
||||
<div class="flex items-center">
|
||||
<div :class="`text-${getTypeColor(selectedLog.type)}-500 mr-2`">
|
||||
<Icon :name="getTypeIcon(selectedLog.type)" />
|
||||
</div>
|
||||
<span class="capitalize">{{ selectedLog.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Channel</h4>
|
||||
<div class="flex items-center">
|
||||
<div class="text-blue-500 mr-2">
|
||||
<Icon :name="getChannelIcon(selectedLog.channel)" />
|
||||
</div>
|
||||
<span class="capitalize">{{ selectedLog.channel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Recipient</h4>
|
||||
<p>{{ selectedLog.recipient }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Template</h4>
|
||||
<p>{{ selectedLog.templateName || 'Custom notification' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Message</h4>
|
||||
<div class="bg-gray-50 p-3 rounded-md whitespace-pre-wrap">
|
||||
{{ selectedLog.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.status === 'failed'">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Error</h4>
|
||||
<div class="bg-red-50 p-3 rounded-md text-red-700 whitespace-pre-wrap">
|
||||
{{ selectedLog.error || 'Unknown error occurred' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.readAt">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Read Information</h4>
|
||||
<div class="bg-green-50 p-3 rounded-md">
|
||||
<p class="text-green-700">Read at: {{ formatDate(selectedLog.readAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.metadata">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Additional Information</h4>
|
||||
<div class="bg-gray-50 p-3 rounded-md font-mono text-xs">
|
||||
<pre>{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RsModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { Icon } from '#components';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'resend', 'export']);
|
||||
|
||||
// Log data
|
||||
const logs = ref(props.modelValue || []);
|
||||
const showViewModal = ref(false);
|
||||
const selectedLog = ref(null);
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
status: 'all',
|
||||
type: 'all',
|
||||
channel: 'all',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
search: ''
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const itemsPerPage = 10;
|
||||
const currentPage = ref(1);
|
||||
|
||||
// Watch for changes to modelValue prop
|
||||
watch(() => props.modelValue, (value) => {
|
||||
logs.value = value || [];
|
||||
}, { deep: true });
|
||||
|
||||
// Computed properties
|
||||
const filteredLogs = computed(() => {
|
||||
let items = [...logs.value];
|
||||
|
||||
// Apply status filter
|
||||
if (filters.value.status !== 'all') {
|
||||
items = items.filter(item => item.status === filters.value.status);
|
||||
}
|
||||
|
||||
// Apply type filter
|
||||
if (filters.value.type !== 'all') {
|
||||
items = items.filter(item => item.type === filters.value.type);
|
||||
}
|
||||
|
||||
// Apply channel filter
|
||||
if (filters.value.channel !== 'all') {
|
||||
items = items.filter(item => item.channel === filters.value.channel);
|
||||
}
|
||||
|
||||
// Apply date range filter
|
||||
if (filters.value.dateFrom) {
|
||||
const fromDate = new Date(filters.value.dateFrom);
|
||||
fromDate.setHours(0, 0, 0, 0);
|
||||
items = items.filter(item => new Date(item.timestamp) >= fromDate);
|
||||
}
|
||||
|
||||
if (filters.value.dateTo) {
|
||||
const toDate = new Date(filters.value.dateTo);
|
||||
toDate.setHours(23, 59, 59, 999);
|
||||
items = items.filter(item => new Date(item.timestamp) <= toDate);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (filters.value.search) {
|
||||
const searchTerm = filters.value.search.toLowerCase();
|
||||
items = items.filter(item =>
|
||||
(item.subject && item.subject.toLowerCase().includes(searchTerm)) ||
|
||||
(item.recipient && item.recipient.toLowerCase().includes(searchTerm)) ||
|
||||
(item.message && item.message.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
return items.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
});
|
||||
|
||||
const paginatedLogs = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
return filteredLogs.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredLogs.value.length / itemsPerPage) || 1;
|
||||
});
|
||||
|
||||
const paginationStart = computed(() => {
|
||||
if (filteredLogs.value.length === 0) return 0;
|
||||
return (currentPage.value - 1) * itemsPerPage + 1;
|
||||
});
|
||||
|
||||
const paginationEnd = computed(() => {
|
||||
if (filteredLogs.value.length === 0) return 0;
|
||||
return Math.min(currentPage.value * itemsPerPage, filteredLogs.value.length);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const refreshLogs = () => {
|
||||
// In a real implementation, this would fetch updated log data from the server
|
||||
// For this demo, we'll just emit an event that the parent can handle
|
||||
emit('refresh-logs');
|
||||
};
|
||||
|
||||
const exportLogs = () => {
|
||||
// In a real implementation, this would trigger an export on the server
|
||||
// For this demo, we'll just emit an event that the parent can handle
|
||||
emit('export', filteredLogs.value);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
status: 'all',
|
||||
type: 'all',
|
||||
channel: 'all',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
search: ''
|
||||
};
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
const viewLog = (log) => {
|
||||
selectedLog.value = log;
|
||||
showViewModal.value = true;
|
||||
};
|
||||
|
||||
const closeViewModal = () => {
|
||||
showViewModal.value = false;
|
||||
selectedLog.value = null;
|
||||
};
|
||||
|
||||
const resendNotification = (log) => {
|
||||
// Emit event for resending
|
||||
emit('resend', log);
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper methods
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getTypeIcon = (type) => {
|
||||
const icons = {
|
||||
info: 'material-symbols:info-outline',
|
||||
success: 'material-symbols:check-circle-outline',
|
||||
warning: 'material-symbols:warning-outline',
|
||||
error: 'material-symbols:error-outline'
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
};
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const colors = {
|
||||
info: 'blue',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red'
|
||||
};
|
||||
return colors[type] || colors.info;
|
||||
};
|
||||
|
||||
const getChannelIcon = (channel) => {
|
||||
const icons = {
|
||||
'in-app': 'material-symbols:notifications-outline',
|
||||
'email': 'material-symbols:mail-outline',
|
||||
'sms': 'material-symbols:smartphone-outline'
|
||||
};
|
||||
return icons[channel] || 'material-symbols:notifications-outline';
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const classes = {
|
||||
delivered: 'bg-green-100 text-green-800',
|
||||
read: 'bg-blue-100 text-blue-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800'
|
||||
};
|
||||
return classes[status] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-logs {
|
||||
@apply bg-white rounded-md;
|
||||
}
|
||||
</style>
|
746
components/process-flow/notification/NotificationManager.vue
Normal file
746
components/process-flow/notification/NotificationManager.vue
Normal file
@ -0,0 +1,746 @@
|
||||
<template>
|
||||
<div class="notification-manager">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Notification Manager</h2>
|
||||
<p class="text-gray-600">
|
||||
Manage notifications, templates, triggers, and preferences in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="mb-6 border-b border-gray-200">
|
||||
<nav class="flex -mb-px">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'py-3 px-4 text-center border-b-2 font-medium text-sm',
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
class="mr-6"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon :name="tab.icon" class="mr-2" />
|
||||
{{ tab.name }}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div v-if="activeTab === 'templates'" class="tab-content">
|
||||
<NotificationTemplates
|
||||
v-model="templates"
|
||||
@select="handleTemplateSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'triggers'" class="tab-content">
|
||||
<NotificationTriggers
|
||||
v-model="triggers"
|
||||
:availableTemplates="templates"
|
||||
:availableVariables="availableVariables"
|
||||
@select="handleTriggerSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'queue'" class="tab-content">
|
||||
<NotificationQueue
|
||||
v-model="queue"
|
||||
@process-queue="processQueue"
|
||||
@refresh-queue="refreshQueue"
|
||||
@resend="resendNotification"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'logs'" class="tab-content">
|
||||
<NotificationLogs
|
||||
v-model="logs"
|
||||
@refresh-logs="refreshLogs"
|
||||
@export="exportLogs"
|
||||
@resend="resendNotification"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'preferences'" class="tab-content">
|
||||
<UserPreferences
|
||||
v-model="userPreferences"
|
||||
:notificationTypes="notificationTypes"
|
||||
@save="saveUserPreferences"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard (Summary Tab) -->
|
||||
<div v-if="activeTab === 'dashboard'" class="tab-content">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Summary Cards -->
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center mr-3">
|
||||
<Icon name="material-symbols:template-outline" class="text-blue-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Templates</h3>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-3xl font-bold text-gray-800">{{ templates.length }}</span>
|
||||
<button
|
||||
@click="activeTab = 'templates'"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 flex items-center"
|
||||
>
|
||||
View All
|
||||
<Icon name="material-symbols:chevron-right" class="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-orange-100 flex items-center justify-center mr-3">
|
||||
<Icon name="material-symbols:bolt" class="text-orange-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Triggers</h3>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-3xl font-bold text-gray-800">{{ triggers.length }}</span>
|
||||
<button
|
||||
@click="activeTab = 'triggers'"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 flex items-center"
|
||||
>
|
||||
View All
|
||||
<Icon name="material-symbols:chevron-right" class="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center mr-3">
|
||||
<Icon name="material-symbols:notifications-outline" class="text-green-600" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Notifications</h3>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<span class="text-3xl font-bold text-gray-800">{{ queue.length }}</span>
|
||||
<span class="text-sm text-gray-500 ml-2">In Queue</span>
|
||||
</div>
|
||||
<button
|
||||
@click="activeTab = 'queue'"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 flex items-center"
|
||||
>
|
||||
View Queue
|
||||
<Icon name="material-symbols:chevron-right" class="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm mb-8">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="font-medium text-gray-900">Recent Notifications</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div v-if="recentLogs.length === 0" class="text-center py-4">
|
||||
<p class="text-gray-500">No recent notifications.</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="log in recentLogs" :key="log.id" class="border-b border-gray-100 pb-3 last:border-b-0 last:pb-0">
|
||||
<div class="flex items-start">
|
||||
<div :class="`text-${getTypeColor(log.type)}-500 mr-3 mt-1`">
|
||||
<Icon :name="getTypeIcon(log.type)" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between items-start">
|
||||
<h4 class="font-medium text-gray-900">{{ log.subject }}</h4>
|
||||
<span
|
||||
:class="getStatusClass(log.status)"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{ log.recipient }} | {{ formatDate(log.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
@click="activeTab = 'logs'"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center mx-auto"
|
||||
>
|
||||
View All Logs
|
||||
<Icon name="material-symbols:chevron-right" class="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="font-medium text-gray-900">Quick Actions</h3>
|
||||
</div>
|
||||
<div class="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<RsButton @click="activeTab = 'templates'; showNewTemplateModal = true" class="w-full">
|
||||
<Icon name="material-symbols:add" class="mr-1" />
|
||||
New Template
|
||||
</RsButton>
|
||||
<RsButton @click="activeTab = 'triggers'; showNewTriggerModal = true" class="w-full">
|
||||
<Icon name="material-symbols:add" class="mr-1" />
|
||||
New Trigger
|
||||
</RsButton>
|
||||
<RsButton @click="activeTab = 'queue'; processQueue()" class="w-full">
|
||||
<Icon name="material-symbols:play-arrow" class="mr-1" />
|
||||
Process Queue
|
||||
</RsButton>
|
||||
<RsButton @click="activeTab = 'logs'; exportLogs(logs)" class="w-full">
|
||||
<Icon name="material-symbols:download" class="mr-1" />
|
||||
Export Logs
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { Icon } from '#components';
|
||||
import NotificationTemplates from './NotificationTemplates.vue';
|
||||
import NotificationTriggers from './NotificationTriggers.vue';
|
||||
import NotificationQueue from './NotificationQueue.vue';
|
||||
import NotificationLogs from './NotificationLogs.vue';
|
||||
import UserPreferences from './UserPreferences.vue';
|
||||
|
||||
const props = defineProps({
|
||||
userId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
availableVariables: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
// Tab navigation
|
||||
const tabs = [
|
||||
{ id: 'dashboard', name: 'Dashboard', icon: 'material-symbols:dashboard' },
|
||||
{ id: 'templates', name: 'Templates', icon: 'material-symbols:template-outline' },
|
||||
{ id: 'triggers', name: 'Triggers', icon: 'material-symbols:bolt' },
|
||||
{ id: 'queue', name: 'Queue', icon: 'material-symbols:queue' },
|
||||
{ id: 'logs', name: 'Logs', icon: 'material-symbols:history' },
|
||||
{ id: 'preferences', name: 'Preferences', icon: 'material-symbols:settings' }
|
||||
];
|
||||
|
||||
const activeTab = ref('dashboard');
|
||||
|
||||
// Mock data for demo purposes
|
||||
// In a real implementation, these would be loaded from an API
|
||||
const templates = ref([
|
||||
{
|
||||
id: 'template_1',
|
||||
name: 'Task Assignment',
|
||||
description: 'Notify users when they are assigned a task',
|
||||
type: 'info',
|
||||
subject: 'New Task Assignment: {taskName}',
|
||||
message: 'Hello {userName},\n\nYou have been assigned a new task: {taskName}.\n\nDue Date: {dueDate}\nPriority: {priority}\n\nPlease log in to the system to view the details.',
|
||||
emailFormat: 'html',
|
||||
htmlEmailContent: `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 5px;">
|
||||
<div style="background-color: #f0f7ff; padding: 15px; border-radius: 5px 5px 0 0; border-bottom: 2px solid #3b82f6;">
|
||||
<h2 style="color: #1e40af; margin: 0;">New Task Assignment</h2>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<p style="margin-top: 0;">Hello {userName},</p>
|
||||
<p>You have been assigned a new task:</p>
|
||||
<div style="background-color: #f9fafb; border-left: 4px solid #3b82f6; padding: 15px; margin: 15px 0;">
|
||||
<h3 style="margin-top: 0; color: #1e40af;">{taskName}</h3>
|
||||
<p style="margin-bottom: 5px;"><strong>Due Date:</strong> {dueDate}</p>
|
||||
<p style="margin-bottom: 0;"><strong>Priority:</strong> {priority}</p>
|
||||
</div>
|
||||
<p>Please log in to the system to view the task details and take action.</p>
|
||||
<div style="text-align: center; margin-top: 25px;">
|
||||
<a href="#" style="background-color: #3b82f6; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; font-weight: bold;">View Task</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background-color: #f9fafb; padding: 15px; border-top: 1px solid #e0e0e0; font-size: 12px; color: #6b7280; border-radius: 0 0 5px 5px;">
|
||||
<p style="margin: 0;">This is an automated notification from the Process Management System.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: true,
|
||||
sms: false
|
||||
},
|
||||
priority: 'medium',
|
||||
language: 'en',
|
||||
expiration: {
|
||||
enabled: true,
|
||||
value: 48,
|
||||
unit: 'hours'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'template_2',
|
||||
name: 'Approval Request',
|
||||
description: 'Request approval for a document or process',
|
||||
type: 'warning',
|
||||
subject: 'Approval Required: {documentName}',
|
||||
message: 'Hello {userName},\n\nYour approval is required for {documentName} submitted by {requesterName}.\n\nPlease review and approve or reject by {dueDate}.',
|
||||
emailFormat: 'html',
|
||||
htmlEmailContent: `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 5px;">
|
||||
<div style="background-color: #fffbeb; padding: 15px; border-radius: 5px 5px 0 0; border-bottom: 2px solid #f59e0b;">
|
||||
<h2 style="color: #92400e; margin: 0;">Approval Required</h2>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<p style="margin-top: 0;">Hello {userName},</p>
|
||||
<p>Your approval is required for the following document:</p>
|
||||
<div style="background-color: #f9fafb; border-left: 4px solid #f59e0b; padding: 15px; margin: 15px 0;">
|
||||
<h3 style="margin-top: 0; color: #92400e;">{documentName}</h3>
|
||||
<p style="margin-bottom: 5px;"><strong>Submitted by:</strong> {requesterName}</p>
|
||||
<p style="margin-bottom: 0;"><strong>Due by:</strong> {dueDate}</p>
|
||||
</div>
|
||||
<p>Please review the document and take action by the due date.</p>
|
||||
<div style="text-align: center; margin-top: 25px; display: flex; justify-content: center; gap: 10px;">
|
||||
<a href="#" style="background-color: #22c55e; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; font-weight: bold;">Approve</a>
|
||||
<a href="#" style="background-color: #ef4444; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; font-weight: bold;">Reject</a>
|
||||
<a href="#" style="background-color: #6b7280; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; font-weight: bold;">Review</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background-color: #f9fafb; padding: 15px; border-top: 1px solid #e0e0e0; font-size: 12px; color: #6b7280; border-radius: 0 0 5px 5px;">
|
||||
<p style="margin: 0;">This approval request requires your attention. Please respond by the due date.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: true,
|
||||
sms: false
|
||||
},
|
||||
priority: 'high',
|
||||
language: 'en',
|
||||
expiration: {
|
||||
enabled: false,
|
||||
value: 24,
|
||||
unit: 'hours'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'template_3',
|
||||
name: 'Process Completed',
|
||||
description: 'Notify when a process is successfully completed',
|
||||
type: 'success',
|
||||
subject: 'Process {processName} Completed Successfully',
|
||||
message: 'Hello {userName},\n\nWe are pleased to inform you that the process {processName} has been completed successfully.\n\nCompletion Date: {completionDate}\n\nThank you for using our system.',
|
||||
emailFormat: 'html',
|
||||
htmlEmailContent: `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 5px;">
|
||||
<div style="background-color: #ecfdf5; padding: 15px; border-radius: 5px 5px 0 0; border-bottom: 2px solid #10b981;">
|
||||
<h2 style="color: #065f46; margin: 0;">Process Completed Successfully</h2>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<p style="margin-top: 0;">Hello {userName},</p>
|
||||
<p>We are pleased to inform you that the following process has been completed successfully:</p>
|
||||
<div style="background-color: #f9fafb; border-left: 4px solid #10b981; padding: 15px; margin: 15px 0; text-align: center;">
|
||||
<h3 style="margin-top: 0; color: #065f46;">{processName}</h3>
|
||||
<p style="margin-bottom: 0;"><strong>Completion Date:</strong> {completionDate}</p>
|
||||
</div>
|
||||
<div style="text-align: center; margin: 25px 0;">
|
||||
<img src="https://via.placeholder.com/100" alt="Success" style="width: 60px; height: 60px;">
|
||||
<p style="color: #10b981; font-size: 18px; font-weight: bold; margin-top: 10px;">All tasks completed</p>
|
||||
</div>
|
||||
<p style="text-align: center;">Thank you for using our system.</p>
|
||||
</div>
|
||||
<div style="background-color: #f9fafb; padding: 15px; border-top: 1px solid #e0e0e0; font-size: 12px; color: #6b7280; border-radius: 0 0 5px 5px; text-align: center;">
|
||||
<p style="margin: 0;">This is an automated notification from the Process Management System.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: true,
|
||||
sms: false
|
||||
},
|
||||
priority: 'medium',
|
||||
language: 'en',
|
||||
expiration: {
|
||||
enabled: false,
|
||||
value: 24,
|
||||
unit: 'hours'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const triggers = ref([
|
||||
{
|
||||
id: 'trigger_1',
|
||||
name: 'Task Assignment Notification',
|
||||
description: 'Send notification when a task is assigned',
|
||||
triggerType: 'event',
|
||||
eventType: 'task_assignment',
|
||||
conditions: [
|
||||
{ field: 'priority', operator: 'equals', value: 'high' }
|
||||
],
|
||||
templateId: 'template_1',
|
||||
templateName: 'Task Assignment',
|
||||
recipientType: 'task_assignee',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'trigger_2',
|
||||
name: 'Approval Request Notification',
|
||||
description: 'Send notification when approval is needed',
|
||||
triggerType: 'event',
|
||||
eventType: 'status_change',
|
||||
fromStatus: 'pending',
|
||||
toStatus: 'in_progress',
|
||||
conditions: [],
|
||||
templateId: 'template_2',
|
||||
templateName: 'Approval Request',
|
||||
recipientType: 'role',
|
||||
recipientRoleId: 'role3',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'trigger_3',
|
||||
name: 'Daily Task Reminder',
|
||||
description: 'Send daily reminders for overdue tasks',
|
||||
triggerType: 'schedule',
|
||||
scheduleType: 'recurring',
|
||||
recurrencePattern: 'daily',
|
||||
timeOfDay: '09:00',
|
||||
conditions: [
|
||||
{ field: 'status', operator: 'not_equals', value: 'completed' },
|
||||
{ field: 'dueDate', operator: 'less_than', value: 'today' }
|
||||
],
|
||||
templateId: 'template_1',
|
||||
templateName: 'Task Assignment',
|
||||
recipientType: 'task_assignee',
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
|
||||
const queue = ref([
|
||||
{
|
||||
id: 'notification_1',
|
||||
type: 'info',
|
||||
recipient: 'john.doe@example.com',
|
||||
subject: 'New Task Assignment: Annual Report Review',
|
||||
message: 'Hello John,\n\nYou have been assigned a new task: Annual Report Review.\n\nDue Date: 2023-12-15\nPriority: High\n\nPlease log in to the system to view the details.',
|
||||
status: 'pending',
|
||||
createdAt: '2023-11-25T09:30:00Z',
|
||||
channels: ['in-app', 'email'],
|
||||
attempts: 0
|
||||
},
|
||||
{
|
||||
id: 'notification_2',
|
||||
type: 'warning',
|
||||
recipient: 'jane.smith@example.com',
|
||||
subject: 'Approval Required: Q4 Marketing Budget',
|
||||
message: 'Hello Jane,\n\nYour approval is required for Q4 Marketing Budget submitted by John Doe.\n\nPlease review and approve or reject by 2023-11-30.',
|
||||
status: 'sent',
|
||||
createdAt: '2023-11-24T14:15:00Z',
|
||||
channels: ['in-app', 'email'],
|
||||
attempts: 1
|
||||
},
|
||||
{
|
||||
id: 'notification_3',
|
||||
type: 'error',
|
||||
recipient: 'mike.johnson@example.com',
|
||||
subject: 'Urgent: System Maintenance Required',
|
||||
message: 'Hello Mike,\n\nUrgent attention is required for system maintenance.\n\nPlease address this issue as soon as possible.',
|
||||
status: 'failed',
|
||||
createdAt: '2023-11-23T18:45:00Z',
|
||||
channels: ['in-app', 'email', 'sms'],
|
||||
attempts: 3,
|
||||
error: 'Failed to deliver SMS: Invalid phone number format',
|
||||
lastAttempt: '2023-11-23T19:15:00Z'
|
||||
}
|
||||
]);
|
||||
|
||||
const logs = ref([
|
||||
{
|
||||
id: 'log_1',
|
||||
type: 'info',
|
||||
recipient: 'john.doe@example.com',
|
||||
subject: 'New Task Assignment: Annual Report Review',
|
||||
message: 'Hello John,\n\nYou have been assigned a new task: Annual Report Review.\n\nDue Date: 2023-12-15\nPriority: High\n\nPlease log in to the system to view the details.',
|
||||
status: 'delivered',
|
||||
timestamp: '2023-11-20T09:30:00Z',
|
||||
channel: 'in-app',
|
||||
templateName: 'Task Assignment'
|
||||
},
|
||||
{
|
||||
id: 'log_2',
|
||||
type: 'info',
|
||||
recipient: 'john.doe@example.com',
|
||||
subject: 'New Task Assignment: Annual Report Review',
|
||||
message: 'Hello John,\n\nYou have been assigned a new task: Annual Report Review.\n\nDue Date: 2023-12-15\nPriority: High\n\nPlease log in to the system to view the details.',
|
||||
status: 'delivered',
|
||||
timestamp: '2023-11-20T09:30:05Z',
|
||||
channel: 'email',
|
||||
templateName: 'Task Assignment'
|
||||
},
|
||||
{
|
||||
id: 'log_3',
|
||||
type: 'warning',
|
||||
recipient: 'jane.smith@example.com',
|
||||
subject: 'Approval Required: Q4 Marketing Budget',
|
||||
message: 'Hello Jane,\n\nYour approval is required for Q4 Marketing Budget submitted by John Doe.\n\nPlease review and approve or reject by 2023-11-30.',
|
||||
status: 'read',
|
||||
timestamp: '2023-11-19T14:15:00Z',
|
||||
readAt: '2023-11-19T15:20:00Z',
|
||||
channel: 'in-app',
|
||||
templateName: 'Approval Request'
|
||||
},
|
||||
{
|
||||
id: 'log_4',
|
||||
type: 'success',
|
||||
recipient: 'mike.johnson@example.com',
|
||||
subject: 'Process Quarterly Review Completed Successfully',
|
||||
message: 'Hello Mike,\n\nWe are pleased to inform you that the process Quarterly Review has been completed successfully.\n\nCompletion Date: 2023-11-18\n\nThank you for using our system.',
|
||||
status: 'delivered',
|
||||
timestamp: '2023-11-18T11:30:00Z',
|
||||
channel: 'in-app',
|
||||
templateName: 'Process Completed'
|
||||
},
|
||||
{
|
||||
id: 'log_5',
|
||||
type: 'error',
|
||||
recipient: '+1234567890',
|
||||
subject: 'Urgent: System Maintenance Required',
|
||||
message: 'Hello Mike,\n\nUrgent attention is required for system maintenance.\n\nPlease address this issue as soon as possible.',
|
||||
status: 'failed',
|
||||
timestamp: '2023-11-17T18:45:00Z',
|
||||
channel: 'sms',
|
||||
error: 'Failed to deliver SMS: Invalid phone number format'
|
||||
}
|
||||
]);
|
||||
|
||||
const userPreferences = ref({
|
||||
enabled: true,
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: true,
|
||||
sms: false
|
||||
},
|
||||
email: 'current.user@example.com',
|
||||
phoneNumber: '',
|
||||
frequency: 'instant',
|
||||
digestTime: '08:00',
|
||||
dnd: {
|
||||
enabled: true,
|
||||
startTime: '22:00',
|
||||
endTime: '07:00'
|
||||
},
|
||||
typePreferences: {
|
||||
template_1: {
|
||||
enabled: true,
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: true,
|
||||
sms: false
|
||||
}
|
||||
},
|
||||
template_2: {
|
||||
enabled: true,
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: true,
|
||||
sms: false
|
||||
}
|
||||
},
|
||||
template_3: {
|
||||
enabled: true,
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: false,
|
||||
sms: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Notification types for preferences
|
||||
const notificationTypes = computed(() => {
|
||||
return templates.value.map(template => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
icon: getTypeIcon(template.type),
|
||||
color: getTypeColor(template.type)
|
||||
}));
|
||||
});
|
||||
|
||||
// Recent logs for dashboard
|
||||
const recentLogs = computed(() => {
|
||||
return logs.value.slice(0, 5);
|
||||
});
|
||||
|
||||
// Show modals for new items
|
||||
const showNewTemplateModal = ref(false);
|
||||
const showNewTriggerModal = ref(false);
|
||||
|
||||
// Watch for changes to showNewTemplateModal
|
||||
watch(() => showNewTemplateModal.value, (value) => {
|
||||
if (value) {
|
||||
// Trigger the createNewTemplate method in the NotificationTemplates component
|
||||
// This is done by emitting an event that the component will listen for
|
||||
emit('new-template');
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for changes to showNewTriggerModal
|
||||
watch(() => showNewTriggerModal.value, (value) => {
|
||||
if (value) {
|
||||
// Trigger the createNewTrigger method in the NotificationTriggers component
|
||||
// This is done by emitting an event that the component will listen for
|
||||
emit('new-trigger');
|
||||
}
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handleTemplateSelect = (template) => {
|
||||
console.log('Template selected:', template);
|
||||
// In a real implementation, this would load the template for editing
|
||||
};
|
||||
|
||||
const handleTriggerSelect = (trigger) => {
|
||||
console.log('Trigger selected:', trigger);
|
||||
// In a real implementation, this would load the trigger for editing
|
||||
};
|
||||
|
||||
const processQueue = () => {
|
||||
console.log('Processing queue...');
|
||||
// In a real implementation, this would call an API to process the queue
|
||||
|
||||
// For demo purposes, we'll update a few items in the queue
|
||||
const pendingItems = queue.value.filter(item => item.status === 'pending');
|
||||
pendingItems.forEach(item => {
|
||||
const index = queue.value.findIndex(i => i.id === item.id);
|
||||
if (index !== -1) {
|
||||
queue.value[index] = {
|
||||
...item,
|
||||
status: Math.random() > 0.2 ? 'sent' : 'failed',
|
||||
attempts: item.attempts + 1,
|
||||
lastAttempt: new Date().toISOString(),
|
||||
error: Math.random() > 0.8 ? 'Connection timeout' : null
|
||||
};
|
||||
|
||||
// Add to logs for sent items
|
||||
if (queue.value[index].status === 'sent') {
|
||||
logs.value.unshift({
|
||||
id: `log_${Date.now()}_${queue.value[index].id}`,
|
||||
type: queue.value[index].type,
|
||||
recipient: queue.value[index].recipient,
|
||||
subject: queue.value[index].subject,
|
||||
message: queue.value[index].message,
|
||||
status: 'delivered',
|
||||
timestamp: new Date().toISOString(),
|
||||
channel: queue.value[index].channels[0],
|
||||
templateName: queue.value[index].templateName || 'Custom Notification'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const refreshQueue = () => {
|
||||
console.log('Refreshing queue...');
|
||||
// In a real implementation, this would fetch updated queue data from the API
|
||||
};
|
||||
|
||||
const refreshLogs = () => {
|
||||
console.log('Refreshing logs...');
|
||||
// In a real implementation, this would fetch updated log data from the API
|
||||
};
|
||||
|
||||
const exportLogs = (logsToExport) => {
|
||||
console.log('Exporting logs:', logsToExport.length);
|
||||
// In a real implementation, this would trigger an export of logs
|
||||
};
|
||||
|
||||
const resendNotification = (item) => {
|
||||
console.log('Resending notification:', item);
|
||||
// In a real implementation, this would call an API to resend the notification
|
||||
|
||||
// For demo purposes, we'll add it back to the queue
|
||||
queue.value.push({
|
||||
id: `notification_${Date.now()}`,
|
||||
type: item.type,
|
||||
recipient: item.recipient,
|
||||
subject: item.subject,
|
||||
message: item.message,
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
channels: [item.channel],
|
||||
attempts: 0
|
||||
});
|
||||
};
|
||||
|
||||
const saveUserPreferences = (preferences) => {
|
||||
console.log('Saving user preferences:', preferences);
|
||||
// In a real implementation, this would call an API to save the preferences
|
||||
};
|
||||
|
||||
// Helper methods
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getTypeIcon = (type) => {
|
||||
const icons = {
|
||||
info: 'material-symbols:info-outline',
|
||||
success: 'material-symbols:check-circle-outline',
|
||||
warning: 'material-symbols:warning-outline',
|
||||
error: 'material-symbols:error-outline'
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
};
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const colors = {
|
||||
info: 'blue',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red'
|
||||
};
|
||||
return colors[type] || colors.info;
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const classes = {
|
||||
delivered: 'bg-green-100 text-green-800',
|
||||
read: 'bg-blue-100 text-blue-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
sent: 'bg-green-100 text-green-800'
|
||||
};
|
||||
return classes[status] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-manager {
|
||||
@apply bg-white rounded-md p-6;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
@apply min-h-[500px];
|
||||
}
|
||||
</style>
|
527
components/process-flow/notification/NotificationQueue.vue
Normal file
527
components/process-flow/notification/NotificationQueue.vue
Normal file
@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div class="notification-queue">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Notification Queue</h3>
|
||||
<div class="flex space-x-2">
|
||||
<RsButton @click="refreshQueue" variant="tertiary" size="sm">
|
||||
<Icon name="material-symbols:refresh" class="mr-1" />
|
||||
Refresh
|
||||
</RsButton>
|
||||
<RsButton @click="processQueue" variant="primary" size="sm" :disabled="queueItems.length === 0">
|
||||
<Icon name="material-symbols:play-arrow" class="mr-1" />
|
||||
Process Queue
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Summary -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="bg-blue-50 border border-blue-100 rounded-md p-3 flex flex-col items-center">
|
||||
<span class="text-blue-500 text-2xl font-semibold">{{ queueSummary.total }}</span>
|
||||
<span class="text-blue-700 text-sm">Total</span>
|
||||
</div>
|
||||
<div class="bg-yellow-50 border border-yellow-100 rounded-md p-3 flex flex-col items-center">
|
||||
<span class="text-yellow-500 text-2xl font-semibold">{{ queueSummary.pending }}</span>
|
||||
<span class="text-yellow-700 text-sm">Pending</span>
|
||||
</div>
|
||||
<div class="bg-green-50 border border-green-100 rounded-md p-3 flex flex-col items-center">
|
||||
<span class="text-green-500 text-2xl font-semibold">{{ queueSummary.sent }}</span>
|
||||
<span class="text-green-700 text-sm">Sent</span>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-100 rounded-md p-3 flex flex-col items-center">
|
||||
<span class="text-red-500 text-2xl font-semibold">{{ queueSummary.failed }}</span>
|
||||
<span class="text-red-700 text-sm">Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-md p-3 mb-4">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
v-model="filters.status"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
|
||||
<select
|
||||
v-model="filters.type"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="info">Information</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||
<input
|
||||
v-model="filters.search"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Search by recipient or subject..."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<RsButton @click="resetFilters" variant="tertiary" size="sm">
|
||||
Reset
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Items Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white border border-gray-200 rounded-md">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Recipient
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Subject
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-if="filteredQueueItems.length === 0">
|
||||
<td colspan="7" class="py-4 px-3 text-center text-sm text-gray-500">
|
||||
No notifications found matching your filters.
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="item in filteredQueueItems" :key="item.id" class="hover:bg-gray-50">
|
||||
<td class="py-2 px-3 text-sm">
|
||||
<span class="font-mono text-xs">{{ item.id.substring(0, 8) }}</span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
<div class="flex items-center">
|
||||
<div :class="`text-${getTypeColor(item.type)}-500 mr-2`">
|
||||
<Icon :name="getTypeIcon(item.type)" />
|
||||
</div>
|
||||
<span class="capitalize">{{ item.type }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
{{ item.recipient }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
{{ item.subject }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
{{ formatDate(item.createdAt) }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
<span
|
||||
:class="getStatusClass(item.status)"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>
|
||||
{{ item.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-3 text-sm">
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="viewItem(item)"
|
||||
class="text-blue-600 hover:text-blue-800"
|
||||
title="View Details"
|
||||
>
|
||||
<Icon name="material-symbols:visibility-outline" />
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'pending' || item.status === 'failed'"
|
||||
@click="resendItem(item)"
|
||||
class="text-green-600 hover:text-green-800"
|
||||
title="Resend"
|
||||
>
|
||||
<Icon name="material-symbols:send" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteItem(item)"
|
||||
class="text-red-600 hover:text-red-800"
|
||||
title="Delete"
|
||||
>
|
||||
<Icon name="material-symbols:delete-outline" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-500">
|
||||
Showing {{ paginationStart }} to {{ paginationEnd }} of {{ filteredQueueItems.length }} items
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<RsButton
|
||||
@click="prevPage"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
:disabled="currentPage === 1"
|
||||
>
|
||||
Previous
|
||||
</RsButton>
|
||||
<RsButton
|
||||
@click="nextPage"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
:disabled="currentPage >= totalPages"
|
||||
>
|
||||
Next
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Item Modal -->
|
||||
<RsModal
|
||||
v-model="showViewModal"
|
||||
title="Notification Details"
|
||||
size="lg"
|
||||
position="center"
|
||||
:cancelCallback="closeViewModal"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div v-if="selectedItem" class="space-y-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">{{ selectedItem.subject }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ formatDate(selectedItem.createdAt) }}</p>
|
||||
</div>
|
||||
<span
|
||||
:class="getStatusClass(selectedItem.status)"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>
|
||||
{{ selectedItem.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Type</h4>
|
||||
<div class="flex items-center">
|
||||
<div :class="`text-${getTypeColor(selectedItem.type)}-500 mr-2`">
|
||||
<Icon :name="getTypeIcon(selectedItem.type)" />
|
||||
</div>
|
||||
<span class="capitalize">{{ selectedItem.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Channels</h4>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span v-for="channel in selectedItem.channels" :key="channel"
|
||||
class="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full">
|
||||
{{ channel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Recipient</h4>
|
||||
<p>{{ selectedItem.recipient }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Message</h4>
|
||||
<div class="bg-gray-50 p-3 rounded-md whitespace-pre-wrap">
|
||||
{{ selectedItem.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedItem.status === 'failed'">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Error</h4>
|
||||
<div class="bg-red-50 p-3 rounded-md text-red-700 whitespace-pre-wrap">
|
||||
{{ selectedItem.error || 'Unknown error occurred' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedItem.attempts > 0">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-1">Delivery Attempts</h4>
|
||||
<p>{{ selectedItem.attempts }} attempt(s)</p>
|
||||
<div v-if="selectedItem.lastAttempt" class="text-xs text-gray-500">
|
||||
Last attempt: {{ formatDate(selectedItem.lastAttempt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RsModal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<RsModal
|
||||
v-model="showDeleteModal"
|
||||
title="Delete Notification"
|
||||
size="md"
|
||||
position="center"
|
||||
:okCallback="confirmDeleteItem"
|
||||
okTitle="Delete"
|
||||
:cancelCallback="cancelDeleteItem"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="mr-4 text-red-500 flex-shrink-0 mt-1">
|
||||
<Icon name="material-symbols:delete-outline" class="text-2xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Delete Notification</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Are you sure you want to delete this notification from the queue? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="itemToDelete" class="bg-gray-50 p-3 rounded-md">
|
||||
<p class="font-medium">{{ itemToDelete.subject }}</p>
|
||||
<p class="text-sm text-gray-500">Status: {{ itemToDelete.status }}</p>
|
||||
<p class="text-sm text-gray-500">Recipient: {{ itemToDelete.recipient }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RsModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { Icon } from '#components';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'process-queue']);
|
||||
|
||||
// Queue data
|
||||
const queueItems = ref(props.modelValue || []);
|
||||
const showViewModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
const itemToDelete = ref(null);
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
status: 'all',
|
||||
type: 'all',
|
||||
search: ''
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const itemsPerPage = 10;
|
||||
const currentPage = ref(1);
|
||||
|
||||
// Watch for changes to modelValue prop
|
||||
watch(() => props.modelValue, (value) => {
|
||||
queueItems.value = value || [];
|
||||
}, { deep: true });
|
||||
|
||||
// Computed properties
|
||||
const filteredQueueItems = computed(() => {
|
||||
let items = [...queueItems.value];
|
||||
|
||||
// Apply status filter
|
||||
if (filters.value.status !== 'all') {
|
||||
items = items.filter(item => item.status === filters.value.status);
|
||||
}
|
||||
|
||||
// Apply type filter
|
||||
if (filters.value.type !== 'all') {
|
||||
items = items.filter(item => item.type === filters.value.type);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (filters.value.search) {
|
||||
const searchTerm = filters.value.search.toLowerCase();
|
||||
items = items.filter(item =>
|
||||
item.subject.toLowerCase().includes(searchTerm) ||
|
||||
item.recipient.toLowerCase().includes(searchTerm) ||
|
||||
item.message.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
return filteredQueueItems.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredQueueItems.value.length / itemsPerPage) || 1;
|
||||
});
|
||||
|
||||
const paginationStart = computed(() => {
|
||||
if (filteredQueueItems.value.length === 0) return 0;
|
||||
return (currentPage.value - 1) * itemsPerPage + 1;
|
||||
});
|
||||
|
||||
const paginationEnd = computed(() => {
|
||||
if (filteredQueueItems.value.length === 0) return 0;
|
||||
return Math.min(currentPage.value * itemsPerPage, filteredQueueItems.value.length);
|
||||
});
|
||||
|
||||
const queueSummary = computed(() => {
|
||||
const total = queueItems.value.length;
|
||||
const pending = queueItems.value.filter(item => item.status === 'pending').length;
|
||||
const sent = queueItems.value.filter(item => item.status === 'sent').length;
|
||||
const failed = queueItems.value.filter(item => item.status === 'failed').length;
|
||||
|
||||
return { total, pending, sent, failed };
|
||||
});
|
||||
|
||||
// Methods
|
||||
const refreshQueue = () => {
|
||||
// In a real implementation, this would fetch updated queue data from the server
|
||||
// For this demo, we'll just emit an event that the parent can handle
|
||||
emit('refresh-queue');
|
||||
};
|
||||
|
||||
const processQueue = () => {
|
||||
// In a real implementation, this would trigger queue processing on the server
|
||||
// For this demo, we'll just emit an event that the parent can handle
|
||||
emit('process-queue');
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
status: 'all',
|
||||
type: 'all',
|
||||
search: ''
|
||||
};
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
const viewItem = (item) => {
|
||||
selectedItem.value = item;
|
||||
showViewModal.value = true;
|
||||
};
|
||||
|
||||
const closeViewModal = () => {
|
||||
showViewModal.value = false;
|
||||
selectedItem.value = null;
|
||||
};
|
||||
|
||||
const resendItem = (item) => {
|
||||
// In a real implementation, this would trigger a re-send on the server
|
||||
// For this demo, we'll just update the item status
|
||||
const index = queueItems.value.findIndex(i => i.id === item.id);
|
||||
if (index !== -1) {
|
||||
const updatedItem = { ...queueItems.value[index] };
|
||||
updatedItem.status = 'pending';
|
||||
updatedItem.attempts = (updatedItem.attempts || 0) + 1;
|
||||
updatedItem.lastAttempt = new Date().toISOString();
|
||||
queueItems.value[index] = updatedItem;
|
||||
|
||||
emit('update:modelValue', queueItems.value);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (item) => {
|
||||
itemToDelete.value = item;
|
||||
showDeleteModal.value = true;
|
||||
};
|
||||
|
||||
const confirmDeleteItem = () => {
|
||||
if (itemToDelete.value) {
|
||||
queueItems.value = queueItems.value.filter(item => item.id !== itemToDelete.value.id);
|
||||
emit('update:modelValue', queueItems.value);
|
||||
itemToDelete.value = null;
|
||||
showDeleteModal.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDeleteItem = () => {
|
||||
itemToDelete.value = null;
|
||||
showDeleteModal.value = false;
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper methods
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getTypeIcon = (type) => {
|
||||
const icons = {
|
||||
info: 'material-symbols:info-outline',
|
||||
success: 'material-symbols:check-circle-outline',
|
||||
warning: 'material-symbols:warning-outline',
|
||||
error: 'material-symbols:error-outline'
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
};
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const colors = {
|
||||
info: 'blue',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red'
|
||||
};
|
||||
return colors[type] || colors.info;
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const classes = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
sent: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
scheduled: 'bg-blue-100 text-blue-800'
|
||||
};
|
||||
return classes[status] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-queue {
|
||||
@apply bg-white rounded-md;
|
||||
}
|
||||
</style>
|
579
components/process-flow/notification/NotificationTemplates.vue
Normal file
579
components/process-flow/notification/NotificationTemplates.vue
Normal file
@ -0,0 +1,579 @@
|
||||
<template>
|
||||
<div class="notification-templates">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Notification Templates</h3>
|
||||
<RsButton @click="showNewTemplateModal = true" variant="primary" size="sm">
|
||||
<Icon name="material-symbols:add" class="mr-1" />
|
||||
New Template
|
||||
</RsButton>
|
||||
</div>
|
||||
|
||||
<!-- Templates List -->
|
||||
<div v-if="templates.length > 0" class="space-y-3">
|
||||
<div v-for="template in templates" :key="template.id"
|
||||
class="border rounded-md p-3 hover:bg-gray-50 cursor-pointer"
|
||||
@click="selectTemplate(template)">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-800">{{ template.name }}</h4>
|
||||
<p class="text-sm text-gray-500">{{ template.description }}</p>
|
||||
</div>
|
||||
<div :class="`text-${getTypeColor(template.type)}-500`">
|
||||
<Icon :name="getTypeIcon(template.type)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mt-2 text-xs text-gray-500 space-x-4">
|
||||
<span class="flex items-center">
|
||||
<Icon name="material-symbols:label-outline" class="mr-1" />
|
||||
{{ template.type }}
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<Icon name="material-symbols:device-hub" class="mr-1" />
|
||||
{{ getChannelsText(template.channels) }}
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<Icon name="material-symbols:flag" class="mr-1" />
|
||||
{{ template.priority }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8 bg-gray-50 rounded-md">
|
||||
<Icon name="material-symbols:notifications-outline" class="text-4xl text-gray-400 mb-2" />
|
||||
<h4 class="text-gray-500 font-medium">No Templates</h4>
|
||||
<p class="text-sm text-gray-400 mb-4">Create notification templates to reuse across your processes</p>
|
||||
<RsButton @click="showNewTemplateModal = true" variant="primary" size="sm">
|
||||
Create Template
|
||||
</RsButton>
|
||||
</div>
|
||||
|
||||
<!-- Template Modal -->
|
||||
<RsModal
|
||||
v-model="showTemplateModal"
|
||||
:title="editingTemplate ? 'Edit Template' : 'New Template'"
|
||||
size="lg"
|
||||
position="center"
|
||||
:okCallback="saveTemplate"
|
||||
okTitle="Save Template"
|
||||
:cancelCallback="closeTemplateModal"
|
||||
>
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Template Name</label>
|
||||
<input
|
||||
v-model="currentTemplate.name"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Enter template name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Notification Type</label>
|
||||
<select
|
||||
v-model="currentTemplate.type"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="info">Information</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
v-model="currentTemplate.description"
|
||||
rows="2"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Describe this template's purpose"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Channels -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Delivery Channels</label>
|
||||
<div class="space-y-2">
|
||||
<label class="inline-flex items-center mr-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="currentTemplate.channels.inApp"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span class="ml-2">In-app notification</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center mr-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="currentTemplate.channels.email"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span class="ml-2">Email notification</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Subject</label>
|
||||
<input
|
||||
v-model="currentTemplate.subject"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Enter notification subject"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
You can use variable placeholders with {variableName} syntax
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Message Body</label>
|
||||
<textarea
|
||||
v-model="currentTemplate.message"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Enter notification message"
|
||||
rows="4"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
You can use variable placeholders with {variableName} syntax
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Email HTML Content -->
|
||||
<div v-if="currentTemplate.channels.email">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Email Format</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="currentTemplate.emailFormat"
|
||||
value="plain"
|
||||
class="form-radio"
|
||||
/>
|
||||
<span class="ml-1 text-sm">Plain Text</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="currentTemplate.emailFormat"
|
||||
value="html"
|
||||
class="form-radio"
|
||||
/>
|
||||
<span class="ml-1 text-sm">HTML</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTemplate.emailFormat === 'html'" class="border rounded-md">
|
||||
<div class="bg-gray-50 border-b p-2 flex justify-between items-center">
|
||||
<span class="text-sm font-medium">HTML Email Content</span>
|
||||
<div class="flex space-x-1">
|
||||
<button class="p-1 hover:bg-gray-200 rounded" title="Bold">
|
||||
<Icon name="material-symbols:format-bold" />
|
||||
</button>
|
||||
<button class="p-1 hover:bg-gray-200 rounded" title="Italic">
|
||||
<Icon name="material-symbols:format-italic" />
|
||||
</button>
|
||||
<button class="p-1 hover:bg-gray-200 rounded" title="Link">
|
||||
<Icon name="material-symbols:link" />
|
||||
</button>
|
||||
<button class="p-1 hover:bg-gray-200 rounded" title="List">
|
||||
<Icon name="material-symbols:format-list-bulleted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="currentTemplate.htmlEmailContent"
|
||||
class="w-full p-2 border-0 focus:ring-0 text-sm font-mono"
|
||||
placeholder="<div>Enter HTML email content here</div>"
|
||||
rows="10"
|
||||
></textarea>
|
||||
<div class="bg-gray-50 border-t p-2">
|
||||
<p class="text-xs text-gray-500">
|
||||
You can use variable placeholders with {variableName} syntax in your HTML
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTML Preview -->
|
||||
<div v-if="currentTemplate.emailFormat === 'html'" class="mt-2">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Preview</label>
|
||||
<button
|
||||
@click="showHtmlPreview = !showHtmlPreview"
|
||||
class="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{{ showHtmlPreview ? 'Hide Preview' : 'Show Preview' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showHtmlPreview" class="border rounded-md p-3 bg-white">
|
||||
<div v-html="currentTemplate.htmlEmailContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Advanced Settings</label>
|
||||
<button
|
||||
@click="showAdvancedSettings = !showAdvancedSettings"
|
||||
class="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{{ showAdvancedSettings ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showAdvancedSettings" class="space-y-4 border-t pt-3 mt-2">
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Priority</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="priority in priorities"
|
||||
:key="priority.value"
|
||||
class="inline-flex items-center cursor-pointer p-2 border rounded-md"
|
||||
:class="currentTemplate.priority === priority.value ? `bg-${priority.color}-50 border-${priority.color}-300` : 'bg-white border-gray-200 hover:bg-gray-50'"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
v-model="currentTemplate.priority"
|
||||
:value="priority.value"
|
||||
class="form-radio hidden"
|
||||
/>
|
||||
<span :class="`text-${priority.color}-500 mr-2`">
|
||||
<Icon :name="priority.icon" class="text-lg" />
|
||||
</span>
|
||||
<span>{{ priority.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Support -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Language</label>
|
||||
<select
|
||||
v-model="currentTemplate.language"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="zh">Chinese</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Expiration Settings -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Expiration</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="currentTemplate.expiration.enabled"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span>Expire after</span>
|
||||
<input
|
||||
v-model.number="currentTemplate.expiration.value"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-20 p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
:disabled="!currentTemplate.expiration.enabled"
|
||||
/>
|
||||
<select
|
||||
v-model="currentTemplate.expiration.unit"
|
||||
class="p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
:disabled="!currentTemplate.expiration.enabled"
|
||||
>
|
||||
<option value="minutes">Minutes</option>
|
||||
<option value="hours">Hours</option>
|
||||
<option value="days">Days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variable Preview -->
|
||||
<div v-if="usedVariables.length > 0" class="bg-gray-50 p-3 border rounded-md">
|
||||
<h5 class="font-medium text-sm mb-2">Variable Placeholders Used</h5>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="variable in usedVariables"
|
||||
:key="variable"
|
||||
class="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-md"
|
||||
>
|
||||
{{ variable }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RsModal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<RsModal
|
||||
v-model="showDeleteModal"
|
||||
title="Delete Template"
|
||||
size="md"
|
||||
position="center"
|
||||
:okCallback="confirmDeleteTemplate"
|
||||
okTitle="Delete"
|
||||
:cancelCallback="cancelDeleteTemplate"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="mr-4 text-red-500 flex-shrink-0 mt-1">
|
||||
<Icon name="material-symbols:delete-outline" class="text-2xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Delete Template</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-3 rounded-md">
|
||||
<p class="font-medium">{{ templateToDelete?.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ templateToDelete?.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RsModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { Icon } from '#components';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'select']);
|
||||
|
||||
// Template data
|
||||
const templates = ref(props.modelValue || []);
|
||||
const showTemplateModal = ref(false);
|
||||
const showNewTemplateModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
const showAdvancedSettings = ref(false);
|
||||
const showHtmlPreview = ref(false);
|
||||
const editingTemplate = ref(false);
|
||||
const templateToDelete = ref(null);
|
||||
|
||||
// Current template being edited
|
||||
const currentTemplate = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'info',
|
||||
subject: '',
|
||||
message: '',
|
||||
emailFormat: 'plain',
|
||||
htmlEmailContent: '',
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: false,
|
||||
sms: false
|
||||
},
|
||||
priority: 'medium',
|
||||
language: 'en',
|
||||
expiration: {
|
||||
enabled: false,
|
||||
value: 24,
|
||||
unit: 'hours'
|
||||
}
|
||||
});
|
||||
|
||||
// Priority options
|
||||
const priorities = [
|
||||
{
|
||||
value: 'low',
|
||||
label: 'Low',
|
||||
icon: 'material-symbols:arrow-downward',
|
||||
color: 'gray'
|
||||
},
|
||||
{
|
||||
value: 'medium',
|
||||
label: 'Medium',
|
||||
icon: 'material-symbols:drag-handle',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
value: 'high',
|
||||
label: 'High',
|
||||
icon: 'material-symbols:arrow-upward',
|
||||
color: 'red'
|
||||
}
|
||||
];
|
||||
|
||||
// Watch for changes to modelValue prop
|
||||
watch(() => props.modelValue, (value) => {
|
||||
templates.value = value || [];
|
||||
}, { deep: true });
|
||||
|
||||
// Methods
|
||||
const selectTemplate = (template) => {
|
||||
editingTemplate.value = true;
|
||||
currentTemplate.value = JSON.parse(JSON.stringify(template));
|
||||
// Set default emailFormat if it doesn't exist
|
||||
if (!currentTemplate.value.emailFormat) {
|
||||
currentTemplate.value.emailFormat = 'plain';
|
||||
}
|
||||
// Set default htmlEmailContent if it doesn't exist
|
||||
if (!currentTemplate.value.htmlEmailContent) {
|
||||
currentTemplate.value.htmlEmailContent = '';
|
||||
}
|
||||
showTemplateModal.value = true;
|
||||
emit('select', template);
|
||||
};
|
||||
|
||||
const createNewTemplate = () => {
|
||||
editingTemplate.value = false;
|
||||
currentTemplate.value = {
|
||||
id: `template_${Date.now()}`,
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'info',
|
||||
subject: '',
|
||||
message: '',
|
||||
emailFormat: 'plain',
|
||||
htmlEmailContent: '',
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: false,
|
||||
sms: false
|
||||
},
|
||||
priority: 'medium',
|
||||
language: 'en',
|
||||
expiration: {
|
||||
enabled: false,
|
||||
value: 24,
|
||||
unit: 'hours'
|
||||
}
|
||||
};
|
||||
showTemplateModal.value = true;
|
||||
};
|
||||
|
||||
// Watch for new template modal trigger
|
||||
watch(() => showNewTemplateModal.value, (value) => {
|
||||
if (value) {
|
||||
createNewTemplate();
|
||||
showNewTemplateModal.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const saveTemplate = () => {
|
||||
if (!currentTemplate.value.name) {
|
||||
// Show error or validation
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingTemplate.value) {
|
||||
// Update existing template
|
||||
const index = templates.value.findIndex(t => t.id === currentTemplate.value.id);
|
||||
if (index !== -1) {
|
||||
templates.value[index] = JSON.parse(JSON.stringify(currentTemplate.value));
|
||||
}
|
||||
} else {
|
||||
// Add new template
|
||||
templates.value.push(JSON.parse(JSON.stringify(currentTemplate.value)));
|
||||
}
|
||||
|
||||
emit('update:modelValue', templates.value);
|
||||
showTemplateModal.value = false;
|
||||
};
|
||||
|
||||
const closeTemplateModal = () => {
|
||||
showTemplateModal.value = false;
|
||||
};
|
||||
|
||||
const deleteTemplate = (template) => {
|
||||
templateToDelete.value = template;
|
||||
showDeleteModal.value = true;
|
||||
};
|
||||
|
||||
const confirmDeleteTemplate = () => {
|
||||
if (templateToDelete.value) {
|
||||
templates.value = templates.value.filter(t => t.id !== templateToDelete.value.id);
|
||||
emit('update:modelValue', templates.value);
|
||||
templateToDelete.value = null;
|
||||
showDeleteModal.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDeleteTemplate = () => {
|
||||
templateToDelete.value = null;
|
||||
showDeleteModal.value = false;
|
||||
};
|
||||
|
||||
// Helper methods
|
||||
const getTypeIcon = (type) => {
|
||||
const icons = {
|
||||
info: 'material-symbols:info-outline',
|
||||
success: 'material-symbols:check-circle-outline',
|
||||
warning: 'material-symbols:warning-outline',
|
||||
error: 'material-symbols:error-outline'
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
};
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const colors = {
|
||||
info: 'blue',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red'
|
||||
};
|
||||
return colors[type] || colors.info;
|
||||
};
|
||||
|
||||
const getChannelsText = (channels) => {
|
||||
const enabledChannels = [];
|
||||
if (channels.inApp) enabledChannels.push('In-app');
|
||||
if (channels.email) enabledChannels.push('Email');
|
||||
if (channels.sms) enabledChannels.push('SMS');
|
||||
|
||||
return enabledChannels.length > 0
|
||||
? enabledChannels.join(', ')
|
||||
: 'None';
|
||||
};
|
||||
|
||||
// Extract variables from content
|
||||
const extractVariables = (text) => {
|
||||
const regex = /{([^}]+)}/g;
|
||||
const matches = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
matches.push(match[1]);
|
||||
}
|
||||
|
||||
return [...new Set(matches)]; // Return unique values
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const usedVariables = computed(() => {
|
||||
const subjectVars = extractVariables(currentTemplate.value.subject || '');
|
||||
const messageVars = extractVariables(currentTemplate.value.message || '');
|
||||
const htmlVars = extractVariables(currentTemplate.value.htmlEmailContent || '');
|
||||
return [...new Set([...subjectVars, ...messageVars, ...htmlVars])];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-templates {
|
||||
@apply bg-white rounded-md;
|
||||
}
|
||||
</style>
|
620
components/process-flow/notification/NotificationTriggers.vue
Normal file
620
components/process-flow/notification/NotificationTriggers.vue
Normal file
@ -0,0 +1,620 @@
|
||||
<template>
|
||||
<div class="notification-triggers">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Notification Triggers</h3>
|
||||
<RsButton @click="showNewTriggerModal = true" variant="primary" size="sm">
|
||||
<Icon name="material-symbols:add" class="mr-1" />
|
||||
New Trigger
|
||||
</RsButton>
|
||||
</div>
|
||||
|
||||
<!-- Triggers List -->
|
||||
<div v-if="triggers.length > 0" class="space-y-3">
|
||||
<div v-for="trigger in triggers" :key="trigger.id"
|
||||
class="border rounded-md p-3 hover:bg-gray-50 cursor-pointer"
|
||||
@click="selectTrigger(trigger)">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-800">{{ trigger.name }}</h4>
|
||||
<p class="text-sm text-gray-500">{{ trigger.description }}</p>
|
||||
</div>
|
||||
<div :class="`text-${getTriggerTypeColor(trigger.triggerType)}-500`">
|
||||
<Icon :name="getTriggerTypeIcon(trigger.triggerType)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mt-2 text-xs text-gray-500 space-x-4">
|
||||
<span class="flex items-center">
|
||||
<Icon name="material-symbols:trigger" class="mr-1" />
|
||||
{{ trigger.triggerType }}
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<Icon name="material-symbols:template-outline" class="mr-1" />
|
||||
{{ trigger.templateName || 'No template' }}
|
||||
</span>
|
||||
<span v-if="trigger.enabled" class="flex items-center text-green-500">
|
||||
<Icon name="material-symbols:check-circle-outline" class="mr-1" />
|
||||
Enabled
|
||||
</span>
|
||||
<span v-else class="flex items-center text-gray-400">
|
||||
<Icon name="material-symbols:disabled-circle-outline" class="mr-1" />
|
||||
Disabled
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8 bg-gray-50 rounded-md">
|
||||
<Icon name="material-symbols:bolt" class="text-4xl text-gray-400 mb-2" />
|
||||
<h4 class="text-gray-500 font-medium">No Triggers</h4>
|
||||
<p class="text-sm text-gray-400 mb-4">Create triggers to define when notifications should be sent</p>
|
||||
<RsButton @click="showNewTriggerModal = true" variant="primary" size="sm">
|
||||
Create Trigger
|
||||
</RsButton>
|
||||
</div>
|
||||
|
||||
<!-- Trigger Modal -->
|
||||
<RsModal
|
||||
v-model="showTriggerModal"
|
||||
:title="editingTrigger ? 'Edit Trigger' : 'New Trigger'"
|
||||
size="lg"
|
||||
position="center"
|
||||
:okCallback="saveTrigger"
|
||||
okTitle="Save Trigger"
|
||||
:cancelCallback="closeTriggerModal"
|
||||
>
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Trigger Name</label>
|
||||
<input
|
||||
v-model="currentTrigger.name"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Enter trigger name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Trigger Type</label>
|
||||
<select
|
||||
v-model="currentTrigger.triggerType"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="event">Event-based</option>
|
||||
<option value="schedule">Schedule-based</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
v-model="currentTrigger.description"
|
||||
rows="2"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Describe this trigger's purpose"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Event-based settings -->
|
||||
<div v-if="currentTrigger.triggerType === 'event'" class="border-t pt-4">
|
||||
<h4 class="font-medium text-gray-700 mb-3">Event Settings</h4>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Event Type</label>
|
||||
<select
|
||||
v-model="currentTrigger.eventType"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="status_change">Status Change</option>
|
||||
<option value="task_assignment">Task Assignment</option>
|
||||
<option value="form_submission">Form Submission</option>
|
||||
<option value="process_started">Process Started</option>
|
||||
<option value="process_completed">Process Completed</option>
|
||||
<option value="task_deadline_approaching">Task Deadline Approaching</option>
|
||||
<option value="task_overdue">Task Overdue</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status change specific fields -->
|
||||
<div v-if="currentTrigger.eventType === 'status_change'" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">From Status</label>
|
||||
<select
|
||||
v-model="currentTrigger.fromStatus"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="any">Any Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">To Status</label>
|
||||
<select
|
||||
v-model="currentTrigger.toStatus"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task deadline specific fields -->
|
||||
<div v-if="currentTrigger.eventType === 'task_deadline_approaching'" class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Hours Before Deadline</label>
|
||||
<input
|
||||
v-model.number="currentTrigger.hoursBeforeDeadline"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule-based settings -->
|
||||
<div v-if="currentTrigger.triggerType === 'schedule'" class="border-t pt-4">
|
||||
<h4 class="font-medium text-gray-700 mb-3">Schedule Settings</h4>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Schedule Type</label>
|
||||
<select
|
||||
v-model="currentTrigger.scheduleType"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="one_time">One-time</option>
|
||||
<option value="recurring">Recurring</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- One-time schedule -->
|
||||
<div v-if="currentTrigger.scheduleType === 'one_time'" class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Scheduled Date/Time</label>
|
||||
<input
|
||||
v-model="currentTrigger.scheduledDateTime"
|
||||
type="datetime-local"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Recurring schedule -->
|
||||
<div v-if="currentTrigger.scheduleType === 'recurring'" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Recurrence Pattern</label>
|
||||
<select
|
||||
v-model="currentTrigger.recurrencePattern"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Weekly options -->
|
||||
<div v-if="currentTrigger.recurrencePattern === 'weekly'" class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Days of Week</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label v-for="day in weekDays" :key="day.value" class="inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="currentTrigger.daysOfWeek"
|
||||
:value="day.value"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span class="ml-2">{{ day.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Time of Day</label>
|
||||
<input
|
||||
v-model="currentTrigger.timeOfDay"
|
||||
type="time"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conditions -->
|
||||
<div class="border-t pt-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h4 class="font-medium text-gray-700">Conditions</h4>
|
||||
<RsButton @click="addCondition" variant="tertiary" size="xs">
|
||||
<Icon name="material-symbols:add" class="mr-1" />
|
||||
Add Condition
|
||||
</RsButton>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTrigger.conditions.length === 0" class="text-center py-4 bg-gray-50 rounded-md text-sm text-gray-500">
|
||||
No conditions added. Notification will trigger without additional conditions.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="(condition, index) in currentTrigger.conditions" :key="index" class="p-3 border rounded-md bg-gray-50">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 w-full">
|
||||
<div>
|
||||
<select
|
||||
v-model="condition.field"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="">Select field</option>
|
||||
<option v-for="field in availableFields" :key="field.name" :value="field.name">
|
||||
{{ field.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
v-model="condition.operator"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="equals">Equals</option>
|
||||
<option value="not_equals">Not Equals</option>
|
||||
<option value="contains">Contains</option>
|
||||
<option value="greater_than">Greater Than</option>
|
||||
<option value="less_than">Less Than</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
v-model="condition.value"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="removeCondition(index)" class="ml-2 text-red-500 hover:text-red-700">
|
||||
<Icon name="material-symbols:delete-outline" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification template -->
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="font-medium text-gray-700 mb-3">Notification Settings</h4>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Select Template</label>
|
||||
<select
|
||||
v-model="currentTrigger.templateId"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="">Select a template</option>
|
||||
<option v-for="template in availableTemplates" :key="template.id" :value="template.id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Recipients -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Recipients</label>
|
||||
<select
|
||||
v-model="currentTrigger.recipientType"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm mb-2"
|
||||
>
|
||||
<option value="user">Specific User</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="process_variable">Process Variable</option>
|
||||
<option value="event_initiator">Event Initiator</option>
|
||||
<option value="task_assignee">Task Assignee</option>
|
||||
</select>
|
||||
|
||||
<div v-if="currentTrigger.recipientType === 'user'">
|
||||
<select
|
||||
v-model="currentTrigger.recipientUserId"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="">Select user</option>
|
||||
<option v-for="user in mockUsers" :key="user.id" :value="user.id">
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTrigger.recipientType === 'role'">
|
||||
<select
|
||||
v-model="currentTrigger.recipientRoleId"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="">Select role</option>
|
||||
<option v-for="role in mockRoles" :key="role.id" :value="role.id">
|
||||
{{ role.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTrigger.recipientType === 'process_variable'">
|
||||
<select
|
||||
v-model="currentTrigger.recipientVariable"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="">Select variable</option>
|
||||
<option v-for="variable in availableVariables" :key="variable.name" :value="variable.name">
|
||||
{{ variable.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enable/Disable -->
|
||||
<div class="border-t pt-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="currentTrigger.enabled"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<span class="ml-2">Enable this trigger</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</RsModal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<RsModal
|
||||
v-model="showDeleteModal"
|
||||
title="Delete Trigger"
|
||||
size="md"
|
||||
position="center"
|
||||
:okCallback="confirmDeleteTrigger"
|
||||
okTitle="Delete"
|
||||
:cancelCallback="cancelDeleteTrigger"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="mr-4 text-red-500 flex-shrink-0 mt-1">
|
||||
<Icon name="material-symbols:delete-outline" class="text-2xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">Delete Trigger</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Are you sure you want to delete this trigger? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-3 rounded-md">
|
||||
<p class="font-medium">{{ triggerToDelete?.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ triggerToDelete?.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RsModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { Icon } from '#components';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
availableTemplates: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
availableVariables: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'select']);
|
||||
|
||||
// Trigger data
|
||||
const triggers = ref(props.modelValue || []);
|
||||
const showTriggerModal = ref(false);
|
||||
const showNewTriggerModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
const editingTrigger = ref(false);
|
||||
const triggerToDelete = ref(null);
|
||||
|
||||
// Mock data
|
||||
const mockUsers = [
|
||||
{ id: 'user1', name: 'John Doe' },
|
||||
{ id: 'user2', name: 'Jane Smith' },
|
||||
{ id: 'user3', name: 'Mike Johnson' }
|
||||
];
|
||||
|
||||
const mockRoles = [
|
||||
{ id: 'role1', name: 'Administrator' },
|
||||
{ id: 'role2', name: 'Manager' },
|
||||
{ id: 'role3', name: 'Approver' },
|
||||
{ id: 'role4', name: 'User' }
|
||||
];
|
||||
|
||||
const weekDays = [
|
||||
{ value: 'monday', label: 'Mon' },
|
||||
{ value: 'tuesday', label: 'Tue' },
|
||||
{ value: 'wednesday', label: 'Wed' },
|
||||
{ value: 'thursday', label: 'Thu' },
|
||||
{ value: 'friday', label: 'Fri' },
|
||||
{ value: 'saturday', label: 'Sat' },
|
||||
{ value: 'sunday', label: 'Sun' }
|
||||
];
|
||||
|
||||
// Available fields for conditions
|
||||
const availableFields = [
|
||||
{ name: 'status', label: 'Status' },
|
||||
{ name: 'assignee', label: 'Assignee' },
|
||||
{ name: 'dueDate', label: 'Due Date' },
|
||||
{ name: 'priority', label: 'Priority' },
|
||||
{ name: 'formData.firstName', label: 'Form: First Name' },
|
||||
{ name: 'formData.lastName', label: 'Form: Last Name' },
|
||||
{ name: 'formData.email', label: 'Form: Email' }
|
||||
];
|
||||
|
||||
// Current trigger being edited
|
||||
const currentTrigger = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
triggerType: 'event',
|
||||
eventType: 'status_change',
|
||||
fromStatus: 'any',
|
||||
toStatus: 'completed',
|
||||
hoursBeforeDeadline: 24,
|
||||
scheduleType: 'recurring',
|
||||
scheduledDateTime: '',
|
||||
recurrencePattern: 'daily',
|
||||
daysOfWeek: [],
|
||||
timeOfDay: '09:00',
|
||||
conditions: [],
|
||||
templateId: '',
|
||||
templateName: '',
|
||||
recipientType: 'user',
|
||||
recipientUserId: '',
|
||||
recipientRoleId: '',
|
||||
recipientVariable: '',
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// Watch for changes to modelValue prop
|
||||
watch(() => props.modelValue, (value) => {
|
||||
triggers.value = value || [];
|
||||
}, { deep: true });
|
||||
|
||||
// Methods
|
||||
const selectTrigger = (trigger) => {
|
||||
editingTrigger.value = true;
|
||||
currentTrigger.value = JSON.parse(JSON.stringify(trigger));
|
||||
showTriggerModal.value = true;
|
||||
emit('select', trigger);
|
||||
};
|
||||
|
||||
const createNewTrigger = () => {
|
||||
editingTrigger.value = false;
|
||||
currentTrigger.value = {
|
||||
id: `trigger_${Date.now()}`,
|
||||
name: '',
|
||||
description: '',
|
||||
triggerType: 'event',
|
||||
eventType: 'status_change',
|
||||
fromStatus: 'any',
|
||||
toStatus: 'completed',
|
||||
hoursBeforeDeadline: 24,
|
||||
scheduleType: 'recurring',
|
||||
scheduledDateTime: '',
|
||||
recurrencePattern: 'daily',
|
||||
daysOfWeek: [],
|
||||
timeOfDay: '09:00',
|
||||
conditions: [],
|
||||
templateId: '',
|
||||
templateName: '',
|
||||
recipientType: 'user',
|
||||
recipientUserId: '',
|
||||
recipientRoleId: '',
|
||||
recipientVariable: '',
|
||||
enabled: true
|
||||
};
|
||||
showTriggerModal.value = true;
|
||||
};
|
||||
|
||||
// Watch for new trigger modal trigger
|
||||
watch(() => showNewTriggerModal.value, (value) => {
|
||||
if (value) {
|
||||
createNewTrigger();
|
||||
showNewTriggerModal.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const saveTrigger = () => {
|
||||
if (!currentTrigger.value.name) {
|
||||
// Show error or validation
|
||||
return;
|
||||
}
|
||||
|
||||
// Update template name reference for display
|
||||
if (currentTrigger.value.templateId) {
|
||||
const selectedTemplate = props.availableTemplates.find(t => t.id === currentTrigger.value.templateId);
|
||||
if (selectedTemplate) {
|
||||
currentTrigger.value.templateName = selectedTemplate.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingTrigger.value) {
|
||||
// Update existing trigger
|
||||
const index = triggers.value.findIndex(t => t.id === currentTrigger.value.id);
|
||||
if (index !== -1) {
|
||||
triggers.value[index] = JSON.parse(JSON.stringify(currentTrigger.value));
|
||||
}
|
||||
} else {
|
||||
// Add new trigger
|
||||
triggers.value.push(JSON.parse(JSON.stringify(currentTrigger.value)));
|
||||
}
|
||||
|
||||
emit('update:modelValue', triggers.value);
|
||||
showTriggerModal.value = false;
|
||||
};
|
||||
|
||||
const closeTriggerModal = () => {
|
||||
showTriggerModal.value = false;
|
||||
};
|
||||
|
||||
const addCondition = () => {
|
||||
currentTrigger.value.conditions.push({
|
||||
field: '',
|
||||
operator: 'equals',
|
||||
value: ''
|
||||
});
|
||||
};
|
||||
|
||||
const removeCondition = (index) => {
|
||||
currentTrigger.value.conditions.splice(index, 1);
|
||||
};
|
||||
|
||||
const deleteTrigger = (trigger) => {
|
||||
triggerToDelete.value = trigger;
|
||||
showDeleteModal.value = true;
|
||||
};
|
||||
|
||||
const confirmDeleteTrigger = () => {
|
||||
if (triggerToDelete.value) {
|
||||
triggers.value = triggers.value.filter(t => t.id !== triggerToDelete.value.id);
|
||||
emit('update:modelValue', triggers.value);
|
||||
triggerToDelete.value = null;
|
||||
showDeleteModal.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDeleteTrigger = () => {
|
||||
triggerToDelete.value = null;
|
||||
showDeleteModal.value = false;
|
||||
};
|
||||
|
||||
// Helper methods
|
||||
const getTriggerTypeIcon = (type) => {
|
||||
if (type === 'event') return 'material-symbols:bolt';
|
||||
if (type === 'schedule') return 'material-symbols:schedule';
|
||||
return 'material-symbols:bolt';
|
||||
};
|
||||
|
||||
const getTriggerTypeColor = (type) => {
|
||||
if (type === 'event') return 'orange';
|
||||
if (type === 'schedule') return 'blue';
|
||||
return 'gray';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-triggers {
|
||||
@apply bg-white rounded-md;
|
||||
}
|
||||
</style>
|
315
components/process-flow/notification/UserPreferences.vue
Normal file
315
components/process-flow/notification/UserPreferences.vue
Normal file
@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<div class="user-preferences">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900">Notification Preferences</h3>
|
||||
<div class="flex space-x-2">
|
||||
<RsButton @click="savePreferences" variant="primary" size="sm">
|
||||
<Icon name="material-symbols:save" class="mr-1" />
|
||||
Save Preferences
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Preferences -->
|
||||
<div class="bg-white border border-gray-200 rounded-md mb-4">
|
||||
<div class="border-b border-gray-200 px-4 py-3 bg-gray-50">
|
||||
<h4 class="font-medium text-gray-700">General Preferences</h4>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<div>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="preferences.enabled"
|
||||
class="form-checkbox h-4 w-4 text-blue-600"
|
||||
/>
|
||||
<span class="ml-2">Enable all notifications</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 ml-6">
|
||||
When disabled, you will not receive any notifications regardless of other settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="preferences.enabled">
|
||||
<h5 class="text-sm font-medium text-gray-700 mb-2">Default Delivery Methods</h5>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="preferences.channels.inApp"
|
||||
class="form-checkbox h-4 w-4 text-blue-600"
|
||||
/>
|
||||
<span class="ml-2">In-app notifications</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="preferences.channels.email"
|
||||
class="form-checkbox h-4 w-4 text-blue-600"
|
||||
/>
|
||||
<span class="ml-2">Email notifications</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="preferences.enabled && preferences.channels.email">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
|
||||
<input
|
||||
v-model="preferences.email"
|
||||
type="email"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="preferences.enabled">
|
||||
<h5 class="text-sm font-medium text-gray-700 mb-2">Notification Frequency</h5>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="preferences.frequency"
|
||||
value="instant"
|
||||
class="form-radio h-4 w-4 text-blue-600"
|
||||
/>
|
||||
<span class="ml-2">Instant (receive notifications immediately)</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
v-model="preferences.frequency"
|
||||
value="digest"
|
||||
class="form-radio h-4 w-4 text-blue-600"
|
||||
/>
|
||||
<span class="ml-2">Digest (receive a daily summary)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="preferences.enabled && preferences.frequency === 'digest'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Digest Time</label>
|
||||
<select
|
||||
v-model="preferences.digestTime"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
>
|
||||
<option value="08:00">Morning (8:00 AM)</option>
|
||||
<option value="12:00">Noon (12:00 PM)</option>
|
||||
<option value="17:00">Evening (5:00 PM)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="preferences.enabled">
|
||||
<h5 class="text-sm font-medium text-gray-700 mb-2">Do Not Disturb</h5>
|
||||
<label class="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="preferences.dnd.enabled"
|
||||
class="form-checkbox h-4 w-4 text-blue-600"
|
||||
/>
|
||||
<span class="ml-2">Enable Do Not Disturb hours</span>
|
||||
</label>
|
||||
|
||||
<div v-if="preferences.dnd.enabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<input
|
||||
v-model="preferences.dnd.startTime"
|
||||
type="time"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">End Time</label>
|
||||
<input
|
||||
v-model="preferences.dnd.endTime"
|
||||
type="time"
|
||||
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Type Preferences -->
|
||||
<div class="bg-white border border-gray-200 rounded-md">
|
||||
<div class="border-b border-gray-200 px-4 py-3 bg-gray-50">
|
||||
<h4 class="font-medium text-gray-700">Notification Type Preferences</h4>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Customize how you receive different types of notifications.
|
||||
</p>
|
||||
|
||||
<div v-if="notificationTypes.length === 0" class="text-center py-4 bg-gray-50 rounded-md">
|
||||
<p class="text-gray-500">No notification types available to configure.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="type in notificationTypes" :key="type.id" class="border-b border-gray-100 pb-4 last:border-b-0 last:pb-0">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<div :class="`text-${type.color}-500 mr-2`">
|
||||
<Icon :name="type.icon" class="text-xl" />
|
||||
</div>
|
||||
<h5 class="font-medium">{{ type.name }}</h5>
|
||||
</div>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="typePreferences[type.id].enabled"
|
||||
class="form-checkbox h-4 w-4 text-blue-600"
|
||||
:disabled="!preferences.enabled"
|
||||
/>
|
||||
<span class="ml-2 text-sm">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mb-3">{{ type.description }}</p>
|
||||
|
||||
<div v-if="typePreferences[type.id].enabled && preferences.enabled" class="bg-gray-50 p-3 rounded-md">
|
||||
<h6 class="text-sm font-medium mb-2">Delivery Methods</h6>
|
||||
<div class="space-y-1">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="typePreferences[type.id].channels.inApp"
|
||||
class="form-checkbox h-4 w-4 text-blue-600"
|
||||
:disabled="!preferences.channels.inApp"
|
||||
/>
|
||||
<span class="ml-2 text-sm" :class="{'text-gray-400': !preferences.channels.inApp}">
|
||||
In-app notifications
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="typePreferences[type.id].channels.email"
|
||||
class="form-checkbox h-4 w-4 text-blue-600"
|
||||
:disabled="!preferences.channels.email"
|
||||
/>
|
||||
<span class="ml-2 text-sm" :class="{'text-gray-400': !preferences.channels.email}">
|
||||
Email notifications
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { Icon } from '#components';
|
||||
|
||||
const props = defineProps({
|
||||
userId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
notificationTypes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save']);
|
||||
|
||||
// Default preferences
|
||||
const defaultPreferences = {
|
||||
enabled: true,
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: false,
|
||||
sms: false
|
||||
},
|
||||
email: '',
|
||||
phoneNumber: '',
|
||||
frequency: 'instant',
|
||||
digestTime: '08:00',
|
||||
dnd: {
|
||||
enabled: false,
|
||||
startTime: '22:00',
|
||||
endTime: '08:00'
|
||||
}
|
||||
};
|
||||
|
||||
// User preferences
|
||||
const preferences = ref({ ...defaultPreferences });
|
||||
|
||||
// Type-specific preferences
|
||||
const typePreferences = ref({});
|
||||
|
||||
// Initialize preferences from props
|
||||
watch(() => props.modelValue, (value) => {
|
||||
if (value && Object.keys(value).length > 0) {
|
||||
preferences.value = {
|
||||
...defaultPreferences,
|
||||
...value
|
||||
};
|
||||
} else {
|
||||
preferences.value = { ...defaultPreferences };
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
// Initialize type preferences
|
||||
onMounted(() => {
|
||||
initializeTypePreferences();
|
||||
});
|
||||
|
||||
watch(() => props.notificationTypes, () => {
|
||||
initializeTypePreferences();
|
||||
}, { deep: true });
|
||||
|
||||
const initializeTypePreferences = () => {
|
||||
// Create default preferences for each notification type
|
||||
props.notificationTypes.forEach(type => {
|
||||
// If we already have preferences for this type, keep them
|
||||
if (!typePreferences.value[type.id]) {
|
||||
typePreferences.value[type.id] = {
|
||||
enabled: true,
|
||||
channels: {
|
||||
inApp: true,
|
||||
email: false,
|
||||
sms: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// If we have saved preferences in the modelValue, use those
|
||||
if (props.modelValue && props.modelValue.typePreferences && props.modelValue.typePreferences[type.id]) {
|
||||
typePreferences.value[type.id] = {
|
||||
...typePreferences.value[type.id],
|
||||
...props.modelValue.typePreferences[type.id]
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const savePreferences = () => {
|
||||
// Combine general preferences with type-specific preferences
|
||||
const combinedPreferences = {
|
||||
...preferences.value,
|
||||
typePreferences: typePreferences.value
|
||||
};
|
||||
|
||||
// Update model value
|
||||
emit('update:modelValue', combinedPreferences);
|
||||
|
||||
// Emit save event
|
||||
emit('save', combinedPreferences);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-preferences {
|
||||
@apply bg-white rounded-md;
|
||||
}
|
||||
</style>
|
75
pages/notifications/index.vue
Normal file
75
pages/notifications/index.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useVariableStore } from '~/stores/variableStore';
|
||||
import NotificationManager from '~/components/process-flow/notification/NotificationManager.vue';
|
||||
|
||||
// Define page meta
|
||||
definePageMeta({
|
||||
title: "Notification Manager",
|
||||
description: "Manage notifications, templates, triggers, and user preferences",
|
||||
layout: "default",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
// Initialize the store
|
||||
const variableStore = useVariableStore();
|
||||
|
||||
// Get available variables for usage in notifications
|
||||
const availableVariables = computed(() => {
|
||||
const processVars = variableStore.getAllVariables.process.map(v => ({
|
||||
name: v.name || 'unnamed',
|
||||
label: v?.description
|
||||
? `${v.description} (${v.name || 'unnamed'}, process)`
|
||||
: `${v.name || 'unnamed'} (process)` ,
|
||||
type: v.type || 'string',
|
||||
scope: 'process'
|
||||
}));
|
||||
const globalVars = variableStore.getAllVariables.global.map(v => ({
|
||||
name: v.name || 'unnamed',
|
||||
label: v?.description
|
||||
? `${v.description} (${v.name || 'unnamed'}, global)`
|
||||
: `${v.name || 'unnamed'} (global)` ,
|
||||
type: v.type || 'string',
|
||||
scope: 'global'
|
||||
}));
|
||||
|
||||
return [...processVars, ...globalVars];
|
||||
});
|
||||
|
||||
// Current user ID
|
||||
const userId = ref('current_user');
|
||||
|
||||
// Get notification preferences (in a real implementation, this would be fetched from the API)
|
||||
onMounted(() => {
|
||||
// In a real implementation, fetch user data, notification templates, triggers, etc.
|
||||
console.log('Notification Manager loaded');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
Notification Center
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
<NotificationManager
|
||||
:userId="userId"
|
||||
:availableVariables="availableVariables"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional styles if needed */
|
||||
</style>
|
@ -17,6 +17,7 @@ import FormNodeConfigurationModal from '~/components/process-flow/FormNodeConfig
|
||||
import TaskNodeConfiguration from '~/components/process-flow/TaskNodeConfiguration.vue';
|
||||
import BusinessRuleNodeConfiguration from '~/components/process-flow/BusinessRuleNodeConfiguration.vue';
|
||||
import BusinessRuleNodeConfigurationModal from '~/components/process-flow/BusinessRuleNodeConfigurationModal.vue';
|
||||
import NotificationNodeConfigurationModal from '~/components/process-flow/NotificationNodeConfigurationModal.vue';
|
||||
|
||||
// Define page meta
|
||||
definePageMeta({
|
||||
@ -64,6 +65,7 @@ const showFormConfigModal = ref(false);
|
||||
const showApiConfigModal = ref(false);
|
||||
const showGatewayConfigModal = ref(false);
|
||||
const showBusinessRuleConfigModal = ref(false);
|
||||
const showNotificationConfigModal = ref(false);
|
||||
|
||||
// Component definitions
|
||||
const components = [
|
||||
@ -717,6 +719,14 @@ const handleDefaultPathUpdate = (path) => {
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle notification node update
|
||||
const handleNotificationNodeUpdate = (updatedData) => {
|
||||
if (selectedNodeData.value) {
|
||||
selectedNodeData.value.data = { ...updatedData };
|
||||
updateNodeInStore();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -851,6 +861,13 @@ const handleDefaultPathUpdate = (path) => {
|
||||
Configure Business Rule
|
||||
</RsButton>
|
||||
</div>
|
||||
|
||||
<!-- Notification Configuration -->
|
||||
<div v-if="selectedNodeData.type === 'notification'">
|
||||
<RsButton @click="showNotificationConfigModal = true" variant="primary" class="w-full">
|
||||
Configure Notification
|
||||
</RsButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -957,6 +974,15 @@ const handleDefaultPathUpdate = (path) => {
|
||||
:availableVariables="gatewayAvailableVariables"
|
||||
@update="handleBusinessRuleUpdate"
|
||||
/>
|
||||
|
||||
<!-- Notification Configuration Modal -->
|
||||
<NotificationNodeConfigurationModal
|
||||
v-if="selectedNodeData && selectedNodeData.type === 'notification'"
|
||||
v-model="showNotificationConfigModal"
|
||||
:nodeData="selectedNodeData.data"
|
||||
:availableVariables="gatewayAvailableVariables"
|
||||
@update="handleNotificationNodeUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1020,6 +1046,13 @@ const handleDefaultPathUpdate = (path) => {
|
||||
border-left: 4px solid #9333ea; /* Purple border to match our icon color */
|
||||
}
|
||||
|
||||
:deep(.node-notification) {
|
||||
min-width: 160px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-left: 4px solid #3b82f6; /* Blue border to match our icon color */
|
||||
}
|
||||
|
||||
:deep(.node-details) {
|
||||
margin-top: 8px;
|
||||
font-size: 0.75rem;
|
||||
|
Loading…
x
Reference in New Issue
Block a user