Update various configuration files, components, and assets; enhance notification system and API endpoints; improve documentation and styles across the application.
This commit is contained in:
898
pages/notification/create/index.vue
Normal file
898
pages/notification/create/index.vue
Normal file
@@ -0,0 +1,898 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Info Card -->
|
||||
<rs-card class="mb-5">
|
||||
<template #header>
|
||||
<div class="flex">
|
||||
<span title="Info"
|
||||
><Icon class="mr-2 flex justify-center" name="ic:outline-info"></Icon
|
||||
></span>
|
||||
Create Notification
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="mb-4">
|
||||
Create and send notifications to your audience. Configure basic settings and
|
||||
choose from multiple channels including email and push notifications.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Main Form Card -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">
|
||||
{{ isEditMode ? "Edit" : "Create" }} Notification
|
||||
</h2>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Step {{ currentStep }} of {{ totalSteps }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="pt-2">
|
||||
<!-- Step Progress Indicator -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flex items-center"
|
||||
:class="{ 'flex-1': index < steps.length - 1 }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
|
||||
:class="{
|
||||
'bg-primary text-white': index + 1 <= currentStep,
|
||||
'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400':
|
||||
index + 1 > currentStep,
|
||||
}"
|
||||
>
|
||||
<Icon
|
||||
v-if="index + 1 < currentStep"
|
||||
name="material-symbols:check"
|
||||
class="text-sm"
|
||||
/>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="ml-2 text-sm font-medium"
|
||||
:class="{
|
||||
'text-primary': index + 1 === currentStep,
|
||||
'text-gray-900 dark:text-gray-100': index + 1 < currentStep,
|
||||
'text-gray-500 dark:text-gray-400': index + 1 > currentStep,
|
||||
}"
|
||||
>
|
||||
{{ step.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="flex-1 h-0.5 mx-4"
|
||||
:class="{
|
||||
'bg-primary': index + 1 < currentStep,
|
||||
'bg-gray-200 dark:bg-gray-700': index + 1 >= currentStep,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormKit
|
||||
type="form"
|
||||
@submit="submitNotification"
|
||||
:actions="false"
|
||||
class="w-full"
|
||||
>
|
||||
<!-- Step 1: Basic Settings -->
|
||||
<div v-show="currentStep === 1" class="space-y-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
name="title"
|
||||
label="Notification Title"
|
||||
placeholder="Enter notification title"
|
||||
validation="required"
|
||||
v-model="notificationForm.title"
|
||||
help="This is for internal identification purposes"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="type"
|
||||
label="Notification Type"
|
||||
:options="notificationTypes"
|
||||
validation="required"
|
||||
v-model="notificationForm.type"
|
||||
help="Choose notification type"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="priority"
|
||||
label="Priority Level"
|
||||
:options="priorityLevels"
|
||||
validation="required"
|
||||
v-model="notificationForm.priority"
|
||||
help="Set the importance level of this notification"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="category"
|
||||
label="Category"
|
||||
:options="categoryOptions"
|
||||
validation="required"
|
||||
v-model="notificationForm.category"
|
||||
help="Categorize your notification for better organization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
name="channels"
|
||||
label="Delivery Channels"
|
||||
:options="channelOptions"
|
||||
validation="required|min:1"
|
||||
v-model="notificationForm.channels"
|
||||
decorator-icon="material-symbols:check"
|
||||
options-class="grid grid-cols-1 gap-y-2 pt-1"
|
||||
help="Select one or more delivery channels"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
v-if="notificationForm.channels.includes('email')"
|
||||
type="text"
|
||||
name="emailSubject"
|
||||
label="Email Subject Line"
|
||||
placeholder="Enter email subject"
|
||||
validation="required"
|
||||
v-model="notificationForm.emailSubject"
|
||||
help="This will be the email subject line"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="radio"
|
||||
name="deliveryType"
|
||||
label="Delivery Schedule"
|
||||
:options="deliveryTypes"
|
||||
validation="required"
|
||||
v-model="notificationForm.deliveryType"
|
||||
decorator-icon="material-symbols:radio-button-checked"
|
||||
options-class="space-y-3 pt-1"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
v-if="notificationForm.deliveryType === 'scheduled'"
|
||||
type="datetime-local"
|
||||
name="scheduledAt"
|
||||
label="Scheduled Date & Time"
|
||||
validation="required"
|
||||
v-model="notificationForm.scheduledAt"
|
||||
help="When should this notification be sent?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Target Audience -->
|
||||
<div v-show="currentStep === 2" class="space-y-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="radio"
|
||||
name="audienceType"
|
||||
label="Audience Selection"
|
||||
:options="audienceTypes"
|
||||
validation="required"
|
||||
v-model="notificationForm.audienceType"
|
||||
decorator-icon="material-symbols:radio-button-checked"
|
||||
options-class="space-y-3 pt-1"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="notificationForm.audienceType === 'specific'"
|
||||
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<FormKit
|
||||
type="textarea"
|
||||
name="specificUsers"
|
||||
label="User IDs or Email Addresses"
|
||||
placeholder="Enter user IDs or emails, one per line"
|
||||
rows="4"
|
||||
v-model="notificationForm.specificUsers"
|
||||
help="Enter user IDs or email addresses, one per line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notificationForm.audienceType === 'segmented'"
|
||||
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
name="userSegments"
|
||||
label="User Segments"
|
||||
:options="userSegmentOptions"
|
||||
v-model="notificationForm.userSegments"
|
||||
decorator-icon="material-symbols:check"
|
||||
options-class="grid grid-cols-1 gap-y-2 pt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/20">
|
||||
<h3 class="font-semibold text-blue-800 dark:text-blue-200 mb-2">
|
||||
Audience Preview
|
||||
</h3>
|
||||
<p class="text-sm text-blue-600 dark:text-blue-300">
|
||||
Estimated reach:
|
||||
<span class="font-bold">{{ estimatedReach }}</span>
|
||||
users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
name="excludeUnsubscribed"
|
||||
label="Exclude Unsubscribed Users"
|
||||
v-model="notificationForm.excludeUnsubscribed"
|
||||
help="Automatically exclude users who have unsubscribed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Content -->
|
||||
<div v-show="currentStep === 3" class="space-y-6">
|
||||
<FormKit
|
||||
type="radio"
|
||||
name="contentType"
|
||||
label="Content Source"
|
||||
:options="contentTypes"
|
||||
validation="required"
|
||||
v-model="notificationForm.contentType"
|
||||
decorator-icon="material-symbols:radio-button-checked"
|
||||
options-class="flex gap-8 pt-1"
|
||||
/>
|
||||
|
||||
<!-- Content Section -->
|
||||
<div
|
||||
v-if="notificationForm.contentType === 'template'"
|
||||
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="font-semibold">Select Notification Template</h3>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="$router.push('/notification/templates')"
|
||||
type="button"
|
||||
>
|
||||
<Icon name="material-symbols:library-books-outline" class="mr-1" />
|
||||
Browse All Templates
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<!-- Template Selection -->
|
||||
<div>
|
||||
<FormKit
|
||||
type="select"
|
||||
name="selectedTemplate"
|
||||
label="Select Template"
|
||||
:options="templateOptions"
|
||||
validation="required"
|
||||
v-model="notificationForm.selectedTemplate"
|
||||
help="Choose from existing notification templates"
|
||||
:disabled="isLoadingTemplates"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create New Content Section -->
|
||||
<div v-if="notificationForm.contentType === 'new'" class="space-y-4">
|
||||
<div
|
||||
v-if="notificationForm.channels.includes('push')"
|
||||
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<h3 class="font-semibold">Push Notification Content</h3>
|
||||
<FormKit
|
||||
type="text"
|
||||
name="pushTitle"
|
||||
label="Push Title"
|
||||
placeholder="Enter push notification title"
|
||||
validation="required|length:0,50"
|
||||
v-model="notificationForm.pushTitle"
|
||||
help="Maximum 50 characters"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="textarea"
|
||||
name="pushBody"
|
||||
label="Push Message"
|
||||
placeholder="Enter push notification message"
|
||||
validation="required|length:0,150"
|
||||
rows="3"
|
||||
v-model="notificationForm.pushBody"
|
||||
help="Maximum 150 characters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notificationForm.channels.includes('email')"
|
||||
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<h3 class="font-semibold">Email Content</h3>
|
||||
<FormKit
|
||||
type="textarea"
|
||||
name="emailContent"
|
||||
label="Email Body"
|
||||
validation="required"
|
||||
v-model="notificationForm.emailContent"
|
||||
rows="8"
|
||||
help="You can use HTML formatting"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="text"
|
||||
name="callToActionText"
|
||||
label="Call-to-Action Button Text (Optional)"
|
||||
placeholder="e.g., Learn More, Get Started"
|
||||
v-model="notificationForm.callToActionText"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="url"
|
||||
name="callToActionUrl"
|
||||
label="Call-to-Action URL (Optional)"
|
||||
placeholder="https://example.com"
|
||||
v-model="notificationForm.callToActionUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
|
||||
<h4 class="font-semibold mb-3">Send Test Notification</h4>
|
||||
<div class="flex gap-4 items-end">
|
||||
<FormKit
|
||||
type="email"
|
||||
name="testEmail"
|
||||
label="Test Email Address"
|
||||
placeholder="test@example.com"
|
||||
v-model="testEmail"
|
||||
outer-class="flex-1 mb-0"
|
||||
/>
|
||||
<rs-button
|
||||
@click="sendTestNotification"
|
||||
variant="outline"
|
||||
type="button"
|
||||
:disabled="isSending"
|
||||
>
|
||||
<Icon name="material-symbols:send" class="mr-1" />
|
||||
Send Test
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Navigation -->
|
||||
<div class="flex justify-between items-center mt-8 pt-6 border-t">
|
||||
<div class="flex gap-3">
|
||||
<rs-button
|
||||
v-if="currentStep > 1"
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="previousStep"
|
||||
>
|
||||
<Icon name="material-symbols:arrow-back" class="mr-1" />
|
||||
Previous
|
||||
</rs-button>
|
||||
<rs-button
|
||||
v-if="currentStep < totalSteps"
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="saveDraftNotification"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<Icon name="material-symbols:save-as-outline" class="mr-1" />
|
||||
Save as Draft
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<rs-button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="$router.push('/notification/list')"
|
||||
>
|
||||
Cancel
|
||||
</rs-button>
|
||||
<rs-button
|
||||
v-if="currentStep < totalSteps"
|
||||
type="button"
|
||||
@click="nextStep"
|
||||
:disabled="!isCurrentStepValid"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': !isCurrentStepValid,
|
||||
}"
|
||||
>
|
||||
Next
|
||||
<Icon name="material-symbols:arrow-forward" class="ml-1" />
|
||||
</rs-button>
|
||||
<rs-button
|
||||
v-if="currentStep === totalSteps"
|
||||
type="submit"
|
||||
:disabled="!isFormValid || isSending"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !isFormValid || isSending }"
|
||||
@click="submitNotification"
|
||||
>
|
||||
<Icon name="material-symbols:send" class="mr-1" />
|
||||
{{
|
||||
notificationForm.deliveryType === "immediate"
|
||||
? "Send Now"
|
||||
: "Schedule Notification"
|
||||
}}
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</FormKit>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useNotifications } from "~/composables/useNotifications";
|
||||
import { useDebounceFn } from "~/composables/useDebounceFn";
|
||||
|
||||
definePageMeta({
|
||||
title: "Create Notification",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
const { $swal } = useNuxtApp();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// Initialize notifications composable
|
||||
const {
|
||||
isLoading: notificationLoading,
|
||||
createNotification,
|
||||
updateNotification,
|
||||
getNotificationById,
|
||||
saveDraft,
|
||||
testSendNotification,
|
||||
getAudiencePreview,
|
||||
} = useNotifications();
|
||||
|
||||
// Step management
|
||||
const currentStep = ref(1);
|
||||
const totalSteps = ref(3);
|
||||
|
||||
const steps = [
|
||||
{ title: "Basic Settings", key: "basic" },
|
||||
{ title: "Target Audience", key: "audience" },
|
||||
{ title: "Content", key: "content" },
|
||||
];
|
||||
|
||||
// Reactive data
|
||||
const isEditMode = ref(!!route.query.id);
|
||||
const testEmail = ref("");
|
||||
const estimatedReachCount = ref(0);
|
||||
|
||||
// Loading states
|
||||
const isLoadingTemplates = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isSending = ref(false);
|
||||
|
||||
// Form data
|
||||
const notificationForm = ref({
|
||||
title: "",
|
||||
type: "single",
|
||||
priority: "medium",
|
||||
category: "",
|
||||
channels: [],
|
||||
emailSubject: "",
|
||||
deliveryType: "immediate",
|
||||
scheduledAt: "",
|
||||
timezone: "UTC",
|
||||
audienceType: "all",
|
||||
specificUsers: "",
|
||||
userSegments: [],
|
||||
excludeUnsubscribed: true,
|
||||
contentType: "new",
|
||||
selectedTemplate: "",
|
||||
pushTitle: "",
|
||||
pushBody: "",
|
||||
emailContent: "",
|
||||
callToActionText: "",
|
||||
callToActionUrl: "",
|
||||
});
|
||||
|
||||
// Options for form fields
|
||||
const notificationTypes = [
|
||||
{ label: "Single Notification", value: "single" },
|
||||
{ label: "Bulk Notification", value: "bulk" },
|
||||
];
|
||||
|
||||
const priorityLevels = [
|
||||
{ label: "Low", value: "low" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Critical", value: "critical" },
|
||||
];
|
||||
|
||||
// Dynamic options loaded from API
|
||||
const categoryOptions = ref([
|
||||
{ label: "System", value: "system" },
|
||||
{ label: "Marketing", value: "marketing" },
|
||||
{ label: "Transactional", value: "transactional" },
|
||||
{ label: "Alerts", value: "alerts" },
|
||||
{ label: "Updates", value: "updates" },
|
||||
]);
|
||||
|
||||
const templateOptions = ref([]);
|
||||
|
||||
const channelOptions = [
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "Push Notification", value: "push" },
|
||||
{ label: "SMS", value: "sms" },
|
||||
];
|
||||
|
||||
const deliveryTypes = [
|
||||
{ label: "Send Immediately", value: "immediate" },
|
||||
{ label: "Schedule for Later", value: "scheduled" },
|
||||
];
|
||||
|
||||
const audienceTypes = [
|
||||
{ label: "All Users", value: "all" },
|
||||
{ label: "Specific Users", value: "specific" },
|
||||
{ label: "Segmented Users", value: "segmented" },
|
||||
];
|
||||
|
||||
const userSegmentOptions = [
|
||||
{ label: "New Users (< 30 days)", value: "new_users" },
|
||||
{ label: "Active Users", value: "active_users" },
|
||||
{ label: "Premium Subscribers", value: "premium_users" },
|
||||
{ label: "Inactive Users", value: "inactive_users" },
|
||||
];
|
||||
|
||||
const contentTypes = [
|
||||
{ label: "Create New Content", value: "new" },
|
||||
{ label: "Use Existing Template", value: "template" },
|
||||
];
|
||||
|
||||
// Computed properties
|
||||
const estimatedReach = computed(() => {
|
||||
if (estimatedReachCount.value > 0) {
|
||||
return estimatedReachCount.value.toLocaleString();
|
||||
}
|
||||
|
||||
// Fallback calculation while API data loads
|
||||
if (notificationForm.value.audienceType === "all") {
|
||||
return "15,000";
|
||||
} else if (notificationForm.value.audienceType === "specific") {
|
||||
const lines = notificationForm.value.specificUsers
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
return lines.length.toLocaleString();
|
||||
} else if (notificationForm.value.audienceType === "segmented") {
|
||||
return "5,000";
|
||||
}
|
||||
return "0";
|
||||
});
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
notificationForm.value.title &&
|
||||
notificationForm.value.type &&
|
||||
notificationForm.value.priority &&
|
||||
notificationForm.value.category &&
|
||||
notificationForm.value.channels.length > 0 &&
|
||||
notificationForm.value.audienceType &&
|
||||
(notificationForm.value.contentType === "template"
|
||||
? notificationForm.value.selectedTemplate
|
||||
: (notificationForm.value.channels.includes("push")
|
||||
? notificationForm.value.pushTitle && notificationForm.value.pushBody
|
||||
: true) &&
|
||||
(notificationForm.value.channels.includes("email")
|
||||
? notificationForm.value.emailSubject && notificationForm.value.emailContent
|
||||
: true))
|
||||
);
|
||||
});
|
||||
|
||||
// Computed properties for step validation
|
||||
const isCurrentStepValid = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 1: // Basic Settings
|
||||
return (
|
||||
notificationForm.value.title &&
|
||||
notificationForm.value.type &&
|
||||
notificationForm.value.priority &&
|
||||
notificationForm.value.category &&
|
||||
notificationForm.value.channels.length > 0 &&
|
||||
(!notificationForm.value.channels.includes("email") ||
|
||||
notificationForm.value.emailSubject) &&
|
||||
(notificationForm.value.deliveryType === "immediate" ||
|
||||
notificationForm.value.scheduledAt)
|
||||
);
|
||||
case 2: // Target Audience
|
||||
return (
|
||||
notificationForm.value.audienceType &&
|
||||
(notificationForm.value.audienceType !== "specific" ||
|
||||
notificationForm.value.specificUsers.trim())
|
||||
);
|
||||
case 3: // Content
|
||||
return (
|
||||
notificationForm.value.contentType &&
|
||||
(notificationForm.value.contentType === "template"
|
||||
? notificationForm.value.selectedTemplate
|
||||
: (!notificationForm.value.channels.includes("push") ||
|
||||
(notificationForm.value.pushTitle && notificationForm.value.pushBody)) &&
|
||||
(!notificationForm.value.channels.includes("email") ||
|
||||
notificationForm.value.emailContent))
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// API Methods
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
isLoadingTemplates.value = true;
|
||||
const response = await $fetch("/api/notifications/templates");
|
||||
if (response.success) {
|
||||
templateOptions.value = response.data.templates.map((template) => ({
|
||||
label: template.title,
|
||||
value: template.value,
|
||||
id: template.id,
|
||||
subject: template.subject,
|
||||
emailContent: template.email_content,
|
||||
pushTitle: template.push_title,
|
||||
pushBody: template.push_body,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading templates:", error);
|
||||
$swal.fire("Error", "Failed to load templates", "error");
|
||||
} finally {
|
||||
isLoadingTemplates.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced function for audience calculation
|
||||
const debouncedCalculateReach = useDebounceFn(calculateEstimatedReach, 500);
|
||||
|
||||
async function calculateEstimatedReach() {
|
||||
try {
|
||||
const requestBody = {
|
||||
audienceType: notificationForm.value.audienceType,
|
||||
specificUsers: notificationForm.value.specificUsers,
|
||||
userSegments: notificationForm.value.userSegments,
|
||||
excludeUnsubscribed: notificationForm.value.excludeUnsubscribed,
|
||||
};
|
||||
|
||||
const response = await getAudiencePreview(requestBody);
|
||||
|
||||
if (response.success) {
|
||||
estimatedReachCount.value = response.data.totalCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calculating estimated reach:", error);
|
||||
// Don't show error to user for background calculation
|
||||
}
|
||||
}
|
||||
|
||||
// Methods
|
||||
const sendTestNotification = async () => {
|
||||
if (!testEmail.value) {
|
||||
$swal.fire("Error", "Please enter a test email address", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSending.value = true;
|
||||
const testData = {
|
||||
email: testEmail.value,
|
||||
testData: {
|
||||
title: notificationForm.value.title,
|
||||
channels: notificationForm.value.channels,
|
||||
emailSubject: notificationForm.value.emailSubject,
|
||||
emailContent: notificationForm.value.emailContent,
|
||||
pushTitle: notificationForm.value.pushTitle,
|
||||
pushBody: notificationForm.value.pushBody,
|
||||
callToActionText: notificationForm.value.callToActionText,
|
||||
callToActionUrl: notificationForm.value.callToActionUrl,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await testSendNotification(testData);
|
||||
|
||||
if (response.success) {
|
||||
$swal.fire("Success", `Test notification sent to ${testEmail.value}`, "success");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending test notification:", error);
|
||||
$swal.fire("Error", "Failed to send test notification", "error");
|
||||
} finally {
|
||||
isSending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveDraftNotification = async () => {
|
||||
try {
|
||||
isSaving.value = true;
|
||||
const response = await saveDraft(notificationForm.value);
|
||||
|
||||
if (response.success) {
|
||||
$swal
|
||||
.fire({
|
||||
title: "Success",
|
||||
text: "Notification saved as draft",
|
||||
icon: "success",
|
||||
confirmButtonText: "Continue Editing",
|
||||
showCancelButton: true,
|
||||
cancelButtonText: "Go to List",
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result.isConfirmed) {
|
||||
router.push("/notification/list");
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving draft:", error);
|
||||
$swal.fire("Error", "Failed to save draft", "error");
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitNotification = async () => {
|
||||
if (!isFormValid.value) {
|
||||
$swal.fire("Validation Error", "Please complete all required fields", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSending.value = true;
|
||||
|
||||
const response = isEditMode.value
|
||||
? await updateNotification(route.query.id, notificationForm.value)
|
||||
: await createNotification(notificationForm.value);
|
||||
|
||||
if (response.success) {
|
||||
const actionText =
|
||||
notificationForm.value.deliveryType === "immediate" ? "sent" : "scheduled";
|
||||
|
||||
$swal
|
||||
.fire({
|
||||
title: "Success!",
|
||||
text: `Notification has been ${actionText} successfully.`,
|
||||
icon: "success",
|
||||
confirmButtonText: "View List",
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
router.push("/notification/list");
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating notification:", error);
|
||||
const errorMessage = error.data?.message || "Failed to process notification";
|
||||
$swal.fire("Error", errorMessage, "error");
|
||||
} finally {
|
||||
isSending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load notification data if in edit mode
|
||||
const loadNotificationData = async (id) => {
|
||||
try {
|
||||
const notification = await getNotificationById(id);
|
||||
|
||||
if (notification) {
|
||||
// Map notification data to form
|
||||
notificationForm.value = {
|
||||
title: notification.title,
|
||||
type: notification.type,
|
||||
priority: notification.priority,
|
||||
category: notification.category_value,
|
||||
channels: notification.channels.map((c) => c.channel_type),
|
||||
emailSubject: notification.email_subject,
|
||||
deliveryType: notification.delivery_type,
|
||||
scheduledAt: notification.scheduled_at,
|
||||
timezone: notification.timezone || "UTC",
|
||||
audienceType: notification.audience_type,
|
||||
specificUsers: notification.specific_users,
|
||||
userSegments: notification.user_segments?.map((s) => s.value) || [],
|
||||
excludeUnsubscribed: notification.exclude_unsubscribed,
|
||||
contentType: notification.content_type,
|
||||
selectedTemplate: notification.template_value,
|
||||
pushTitle: notification.push_title,
|
||||
pushBody: notification.push_body,
|
||||
emailContent: notification.email_content,
|
||||
callToActionText: notification.call_to_action_text,
|
||||
callToActionUrl: notification.call_to_action_url,
|
||||
};
|
||||
|
||||
// Update estimated reach
|
||||
estimatedReachCount.value = notification.estimated_reach;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading notification data:", error);
|
||||
$swal.fire("Error", "Failed to load notification data", "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Step navigation methods
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < totalSteps.value && isCurrentStepValid.value) {
|
||||
currentStep.value++;
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} else if (!isCurrentStepValid.value) {
|
||||
$swal.fire(
|
||||
"Incomplete Information",
|
||||
"Please complete all required fields before proceeding",
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const previousStep = () => {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value--;
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for audience changes to calculate estimated reach
|
||||
watch(
|
||||
() => [
|
||||
notificationForm.value.audienceType,
|
||||
notificationForm.value.specificUsers,
|
||||
notificationForm.value.userSegments,
|
||||
notificationForm.value.excludeUnsubscribed,
|
||||
],
|
||||
() => {
|
||||
// Use debounced function to avoid too many API calls
|
||||
debouncedCalculateReach();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// Load templates
|
||||
await loadTemplates();
|
||||
|
||||
// Load data if editing existing notification
|
||||
if (isEditMode.value && route.query.id) {
|
||||
await loadNotificationData(route.query.id);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any custom styles if needed */
|
||||
/* .formkit-input {
|
||||
@apply appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400;
|
||||
} */
|
||||
</style>
|
||||
391
pages/notification/dashboard/index.vue
Normal file
391
pages/notification/dashboard/index.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Header -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-dashboard"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Notification Dashboard</h1>
|
||||
</div>
|
||||
<rs-button size="sm" @click="refreshData">
|
||||
<Icon :name="isRefreshing ? 'ic:outline-refresh' : 'ic:outline-refresh'" :class="{ 'animate-spin': isRefreshing }" class="mr-1"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Overview of your notification system performance and statistics.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="32" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Overview Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<!-- Total Notifications -->
|
||||
<rs-card class="transition-all duration-300 hover:shadow-lg">
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div class="p-5 flex justify-center items-center rounded-2xl bg-blue-100">
|
||||
<Icon class="text-3xl text-blue-600" name="ic:outline-notifications"></Icon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<span class="block font-bold text-2xl text-blue-600">
|
||||
{{ overview.total || 0 }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">Total Notifications</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
|
||||
<!-- Sent Notifications -->
|
||||
<rs-card class="transition-all duration-300 hover:shadow-lg">
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div class="p-5 flex justify-center items-center rounded-2xl bg-green-100">
|
||||
<Icon class="text-3xl text-green-600" name="ic:outline-send"></Icon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<span class="block font-bold text-2xl text-green-600">
|
||||
{{ overview.sent || 0 }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">Sent</span>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span v-if="overview.growthRate > 0" class="text-green-600">
|
||||
↑ {{ overview.growthRate }}%
|
||||
</span>
|
||||
<span v-else-if="overview.growthRate < 0" class="text-red-600">
|
||||
↓ {{ Math.abs(overview.growthRate) }}%
|
||||
</span>
|
||||
<span v-else class="text-gray-600">→ 0%</span>
|
||||
vs last week
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
|
||||
<!-- Scheduled Notifications -->
|
||||
<rs-card class="transition-all duration-300 hover:shadow-lg">
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div class="p-5 flex justify-center items-center rounded-2xl bg-orange-100">
|
||||
<Icon class="text-3xl text-orange-600" name="ic:outline-schedule"></Icon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<span class="block font-bold text-2xl text-orange-600">
|
||||
{{ overview.scheduled || 0 }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">Scheduled</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
|
||||
<!-- Delivery Rate -->
|
||||
<rs-card class="transition-all duration-300 hover:shadow-lg">
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div class="p-5 flex justify-center items-center rounded-2xl bg-purple-100">
|
||||
<Icon class="text-3xl text-purple-600" name="ic:outline-check-circle"></Icon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<span class="block font-bold text-2xl text-purple-600">
|
||||
{{ delivery.deliveryRate || 0 }}%
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">Delivery Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Stats Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<!-- Total Recipients -->
|
||||
<rs-card>
|
||||
<div class="pt-5 pb-3 px-5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-gray-600 text-sm">Total Recipients</span>
|
||||
<Icon name="ic:outline-people" class="text-gray-400"></Icon>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-bold">{{ delivery.totalRecipients || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
|
||||
<!-- Successful Deliveries -->
|
||||
<rs-card>
|
||||
<div class="pt-5 pb-3 px-5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-gray-600 text-sm">Successful</span>
|
||||
<Icon name="ic:outline-check" class="text-green-500"></Icon>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-bold text-green-600">{{ delivery.successful || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
|
||||
<!-- Failed Deliveries -->
|
||||
<rs-card>
|
||||
<div class="pt-5 pb-3 px-5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-gray-600 text-sm">Failed</span>
|
||||
<Icon name="ic:outline-error" class="text-red-500"></Icon>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-bold text-red-600">{{ delivery.failed || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Channel Performance -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-bar-chart"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Channel Performance</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="channelPerformance.length === 0" class="text-center py-8 text-gray-500">
|
||||
No channel data available
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="channel in channelPerformance" :key="channel.channel" class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon :name="getChannelIcon(channel.channel)" class="text-xl"></Icon>
|
||||
<span class="font-semibold capitalize">{{ channel.channel }}</span>
|
||||
</div>
|
||||
<rs-badge :variant="channel.successRate >= 90 ? 'success' : channel.successRate >= 70 ? 'warning' : 'danger'" size="sm">
|
||||
{{ channel.successRate }}% Success
|
||||
</rs-badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div class="text-gray-600">Total</div>
|
||||
<div class="font-semibold">{{ channel.totalSent }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Successful</div>
|
||||
<div class="font-semibold text-green-600">{{ channel.successful }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Failed</div>
|
||||
<div class="font-semibold text-red-600">{{ channel.failed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="mt-3">
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-green-500 h-2 rounded-full" :style="{ width: channel.successRate + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Top Categories -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-category"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Top Categories</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="topCategories.length === 0" class="text-center py-8 text-gray-500">
|
||||
No category data available
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="(cat, index) in topCategories" :key="cat.categoryId" class="flex items-center justify-between p-3 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 flex items-center justify-center rounded-full bg-primary text-white font-bold text-sm">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<span class="font-medium">{{ cat.categoryName }}</span>
|
||||
</div>
|
||||
<rs-badge variant="primary">{{ cat.count }}</rs-badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Recent Notifications -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Recent Notifications</h2>
|
||||
</div>
|
||||
<NuxtLink to="/notification/list">
|
||||
<rs-button size="sm" variant="outline">
|
||||
View All
|
||||
</rs-button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="recentNotifications.length === 0" class="text-center py-8 text-gray-500">
|
||||
No recent notifications
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Category</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Channels</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Recipients</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="notif in recentNotifications" :key="notif.id" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<NuxtLink :to="`/notification/view/${notif.id}`" class="text-primary hover:underline font-medium">
|
||||
{{ notif.title }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm">{{ notif.category }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-1">
|
||||
<Icon v-for="channel in notif.channels" :key="channel" :name="getChannelIcon(channel)" class="text-gray-600" size="18"></Icon>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<rs-badge :variant="getStatusVariant(notif.status)" size="sm">
|
||||
{{ notif.status }}
|
||||
</rs-badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm">{{ notif.recipientCount }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm text-gray-600">{{ formatDate(notif.createdAt) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
definePageMeta({
|
||||
title: 'Notification Dashboard',
|
||||
middleware: ['auth'],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: 'Notification',
|
||||
path: '/notification',
|
||||
},
|
||||
{
|
||||
name: 'Dashboard',
|
||||
path: '/notification/dashboard',
|
||||
type: 'current',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
const isRefreshing = ref(false);
|
||||
const overview = ref({});
|
||||
const delivery = ref({});
|
||||
const channelPerformance = ref([]);
|
||||
const topCategories = ref([]);
|
||||
const recentNotifications = ref([]);
|
||||
|
||||
async function fetchDashboardData() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
const [overviewData, recentData, channelData] = await Promise.all([
|
||||
$fetch('/api/notifications/dashboard/overview'),
|
||||
$fetch('/api/notifications/dashboard/recent?limit=10'),
|
||||
$fetch('/api/notifications/dashboard/channel-performance'),
|
||||
]);
|
||||
|
||||
if (overviewData.success) {
|
||||
overview.value = overviewData.data.overview;
|
||||
delivery.value = overviewData.data.delivery;
|
||||
topCategories.value = overviewData.data.topCategories;
|
||||
}
|
||||
|
||||
if (recentData.success) {
|
||||
recentNotifications.value = recentData.data;
|
||||
}
|
||||
|
||||
if (channelData.success) {
|
||||
channelPerformance.value = channelData.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
isRefreshing.value = true;
|
||||
await fetchDashboardData();
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
|
||||
function getChannelIcon(channel) {
|
||||
const icons = {
|
||||
email: 'ic:outline-email',
|
||||
push: 'ic:outline-notifications',
|
||||
sms: 'ic:outline-sms',
|
||||
};
|
||||
return icons[channel] || 'ic:outline-circle';
|
||||
}
|
||||
|
||||
function getStatusVariant(status) {
|
||||
const variants = {
|
||||
draft: 'secondary',
|
||||
scheduled: 'warning',
|
||||
sending: 'primary',
|
||||
sent: 'success',
|
||||
failed: 'danger',
|
||||
};
|
||||
return variants[status] || 'secondary';
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDashboardData();
|
||||
|
||||
// Auto-refresh every 60 seconds
|
||||
const interval = setInterval(fetchDashboardData, 60000);
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => clearInterval(interval));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
643
pages/notification/delivery/index.vue
Normal file
643
pages/notification/delivery/index.vue
Normal file
@@ -0,0 +1,643 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Info Card -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-send"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Delivery Settings</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Configure email, push notification, and SMS delivery settings.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="fixed inset-0 blur-lg bg-opacity-50 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div class="bg-white rounded-lg p-6 flex items-center space-x-4">
|
||||
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="24" />
|
||||
<span class="text-gray-700">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<rs-alert
|
||||
v-if="error"
|
||||
variant="danger"
|
||||
class="mb-6"
|
||||
dismissible
|
||||
@dismiss="error = null"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon name="ic:outline-error" />
|
||||
</template>
|
||||
{{ error }}
|
||||
</rs-alert>
|
||||
|
||||
<!-- Channel Configuration -->
|
||||
<div class="space-y-8 mb-6">
|
||||
<!-- Email Configuration -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-email"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Email Configuration</h2>
|
||||
</div>
|
||||
<rs-badge :variant="emailConfig.enabled ? 'success' : 'secondary'">
|
||||
{{ emailConfig.enabled ? "Active" : "Disabled" }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="24" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">Enable Email Delivery</span>
|
||||
<FormKit type="toggle" v-model="emailConfig.enabled" />
|
||||
</div>
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Email Provider"
|
||||
v-model="emailConfig.provider"
|
||||
:options="emailProviders"
|
||||
:disabled="!emailConfig.enabled"
|
||||
class="w-full"
|
||||
/>
|
||||
<!-- Provider Configuration -->
|
||||
<div class="space-y-4">
|
||||
<!-- Mailtrap Configuration -->
|
||||
<div v-if="emailConfig.provider === 'mailtrap'">
|
||||
<!-- Mailtrap Info Banner -->
|
||||
<div class="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon name="ic:outline-info" class="text-blue-600 text-xl mt-0.5"></Icon>
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold text-blue-900 mb-1">Mailtrap SMTP Configuration</p>
|
||||
<p class="text-blue-700">
|
||||
Use <code class="px-1 py-0.5 bg-blue-100 rounded">live.smtp.mailtrap.io</code> for production sending.
|
||||
Port 587 (recommended) with STARTTLS.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
label="SMTP Host"
|
||||
v-model="emailConfig.config.host"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Use: live.smtp.mailtrap.io"
|
||||
/>
|
||||
<FormKit
|
||||
type="number"
|
||||
label="SMTP Port"
|
||||
v-model="emailConfig.config.port"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Recommended: 587"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="SMTP Username"
|
||||
v-model="emailConfig.config.user"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Usually: apismtp@mailtrap.io"
|
||||
/>
|
||||
<FormKit
|
||||
type="password"
|
||||
label="SMTP Password / API Token"
|
||||
v-model="emailConfig.config.pass"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Your Mailtrap API token"
|
||||
validation="required"
|
||||
/>
|
||||
<FormKit
|
||||
type="email"
|
||||
label="Sender Email"
|
||||
v-model="emailConfig.config.senderEmail"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Email address that will appear as the sender of notifications"
|
||||
validation="required|email"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Sender Name (Optional)"
|
||||
v-model="emailConfig.config.senderName"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Name that will appear as the sender"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AWS SES Configuration -->
|
||||
<div v-if="emailConfig.provider === 'aws-ses'">
|
||||
<!-- AWS SES Info Banner -->
|
||||
<div class="p-4 bg-orange-50 border border-orange-200 rounded-lg mb-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon name="ic:outline-info" class="text-orange-600 text-xl mt-0.5"></Icon>
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold text-orange-900 mb-1">AWS SES SMTP Configuration</p>
|
||||
<p class="text-orange-700">
|
||||
Use region-specific SMTP endpoint: <code class="px-1 py-0.5 bg-orange-100 rounded">email-smtp.<region>.amazonaws.com</code>
|
||||
(e.g., email-smtp.us-east-1.amazonaws.com). Port 587 with STARTTLS.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
label="SMTP Host"
|
||||
v-model="emailConfig.config.host"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="e.g., email-smtp.us-east-1.amazonaws.com"
|
||||
placeholder="email-smtp.us-east-1.amazonaws.com"
|
||||
/>
|
||||
<FormKit
|
||||
type="number"
|
||||
label="SMTP Port"
|
||||
v-model="emailConfig.config.port"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Use 587 (STARTTLS) or 465 (TLS)"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="SMTP Username"
|
||||
v-model="emailConfig.config.user"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Your AWS SES SMTP username (not IAM user)"
|
||||
placeholder="AKIAIOSFODNN7EXAMPLE"
|
||||
/>
|
||||
<FormKit
|
||||
type="password"
|
||||
label="SMTP Password"
|
||||
v-model="emailConfig.config.pass"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Your AWS SES SMTP password (not secret key)"
|
||||
validation="required"
|
||||
/>
|
||||
<FormKit
|
||||
type="email"
|
||||
label="Sender Email"
|
||||
v-model="emailConfig.config.senderEmail"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Must be verified in AWS SES"
|
||||
validation="required|email"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Sender Name (Optional)"
|
||||
v-model="emailConfig.config.senderName"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="Name that will appear as the sender"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="AWS Region"
|
||||
v-model="emailConfig.config.region"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="AWS region where SES is configured"
|
||||
placeholder="us-east-1"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Configuration Set (Optional)"
|
||||
v-model="emailConfig.config.configurationSet"
|
||||
:disabled="!emailConfig.enabled"
|
||||
help="AWS SES configuration set for tracking"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add more providers as needed -->
|
||||
<div class="mt-6 p-4 bg-gray-50 rounded-lg flex flex-wrap gap-8">
|
||||
<div>
|
||||
<span class="text-gray-600">Status:</span>
|
||||
<span class="ml-2 font-medium">{{ emailConfig.status }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Success Rate:</span>
|
||||
<span class="ml-2 font-medium">{{ emailConfig.successRate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<rs-button @click="saveEmailConfig" :disabled="isLoadingEmail">
|
||||
<Icon
|
||||
:name="isLoadingEmail ? 'ic:outline-refresh' : 'ic:outline-save'"
|
||||
class="mr-1"
|
||||
:class="{ 'animate-spin': isLoadingEmail }"
|
||||
/>
|
||||
{{ isLoadingEmail ? "Saving..." : "Save" }}
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
<!-- Push Configuration -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-notifications"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">
|
||||
Push Notification Configuration
|
||||
</h2>
|
||||
</div>
|
||||
<rs-badge :variant="pushConfig.enabled ? 'success' : 'secondary'">
|
||||
{{ pushConfig.enabled ? "Active" : "Disabled" }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="24" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">Enable Push Notifications</span>
|
||||
<FormKit type="toggle" v-model="pushConfig.enabled" />
|
||||
</div>
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Push Provider"
|
||||
v-model="pushConfig.provider"
|
||||
:options="pushProviders"
|
||||
:disabled="!pushConfig.enabled"
|
||||
class="w-full"
|
||||
/>
|
||||
<!-- Provider-specific fields -->
|
||||
<div
|
||||
v-if="pushConfig.provider === 'firebase'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="API Key"
|
||||
v-model="pushConfig.config.apiKey"
|
||||
:disabled="!pushConfig.enabled"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Project ID"
|
||||
v-model="pushConfig.config.projectId"
|
||||
:disabled="!pushConfig.enabled"
|
||||
/>
|
||||
</div>
|
||||
<!-- Add more providers as needed -->
|
||||
<div class="mt-6 p-4 bg-gray-50 rounded-lg flex flex-wrap gap-8">
|
||||
<div>
|
||||
<span class="text-gray-600">Status:</span>
|
||||
<span class="ml-2 font-medium">{{ pushConfig.status }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Success Rate:</span>
|
||||
<span class="ml-2 font-medium">{{ pushConfig.successRate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<rs-button @click="savePushConfig" :disabled="isLoadingPush">
|
||||
<Icon
|
||||
:name="isLoadingPush ? 'ic:outline-refresh' : 'ic:outline-save'"
|
||||
class="mr-1"
|
||||
:class="{ 'animate-spin': isLoadingPush }"
|
||||
/>
|
||||
{{ isLoadingPush ? "Saving..." : "Save" }}
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
<!-- SMS Configuration -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-sms"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">SMS Configuration</h2>
|
||||
</div>
|
||||
<rs-badge :variant="smsConfig.enabled ? 'success' : 'secondary'">
|
||||
{{ smsConfig.enabled ? "Active" : "Disabled" }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="24" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">Enable SMS Delivery</span>
|
||||
<FormKit type="toggle" v-model="smsConfig.enabled" />
|
||||
</div>
|
||||
<FormKit
|
||||
type="select"
|
||||
label="SMS Provider"
|
||||
v-model="smsConfig.provider"
|
||||
:options="smsProviders"
|
||||
:disabled="!smsConfig.enabled"
|
||||
class="w-full"
|
||||
/>
|
||||
<!-- Provider-specific fields -->
|
||||
<div
|
||||
v-if="smsConfig.provider === 'twilio'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Account SID"
|
||||
v-model="smsConfig.config.accountSid"
|
||||
:disabled="!smsConfig.enabled"
|
||||
/>
|
||||
<FormKit
|
||||
type="password"
|
||||
label="Auth Token"
|
||||
v-model="smsConfig.config.authToken"
|
||||
:disabled="!smsConfig.enabled"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
label="From Number"
|
||||
v-model="smsConfig.config.from"
|
||||
:disabled="!smsConfig.enabled"
|
||||
/>
|
||||
</div>
|
||||
<!-- Add more providers as needed -->
|
||||
<div class="mt-6 p-4 bg-gray-50 rounded-lg flex flex-wrap gap-8">
|
||||
<div>
|
||||
<span class="text-gray-600">Status:</span>
|
||||
<span class="ml-2 font-medium">{{ smsConfig.status }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Success Rate:</span>
|
||||
<span class="ml-2 font-medium">{{ smsConfig.successRate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<rs-button @click="saveSmsConfig" :disabled="isLoadingSms">
|
||||
<Icon
|
||||
:name="isLoadingSms ? 'ic:outline-refresh' : 'ic:outline-save'"
|
||||
class="mr-1"
|
||||
:class="{ 'animate-spin': isLoadingSms }"
|
||||
/>
|
||||
{{ isLoadingSms ? "Saving..." : "Save" }}
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useNotificationDelivery } from "@/composables/useNotificationDelivery";
|
||||
|
||||
definePageMeta({
|
||||
title: "Notification Delivery",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Delivery",
|
||||
path: "/notification/delivery",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Email Configuration
|
||||
const emailConfig = ref({
|
||||
enabled: true,
|
||||
provider: "mailtrap",
|
||||
config: {
|
||||
host: "live.smtp.mailtrap.io",
|
||||
port: 587,
|
||||
user: "apismtp@mailtrap.io",
|
||||
pass: "",
|
||||
senderEmail: "",
|
||||
senderName: "",
|
||||
},
|
||||
status: "Connected",
|
||||
successRate: 99.2,
|
||||
});
|
||||
|
||||
const emailProviders = [
|
||||
{ label: "Mailtrap", value: "mailtrap" },
|
||||
{ label: "AWS SES", value: "aws-ses" }
|
||||
];
|
||||
|
||||
// Push Configuration
|
||||
const pushConfig = ref({
|
||||
enabled: true,
|
||||
provider: "firebase",
|
||||
config: {
|
||||
apiKey: "",
|
||||
projectId: "",
|
||||
},
|
||||
status: "Connected",
|
||||
successRate: 95.8,
|
||||
});
|
||||
|
||||
const pushProviders = [{ label: "Firebase FCM", value: "firebase" }];
|
||||
|
||||
// Add SMS config and providers
|
||||
const smsConfig = ref({
|
||||
enabled: false,
|
||||
provider: "twilio",
|
||||
config: {
|
||||
accountSid: "",
|
||||
authToken: "",
|
||||
from: "",
|
||||
},
|
||||
status: "Not Configured",
|
||||
successRate: 0,
|
||||
});
|
||||
|
||||
const smsProviders = [
|
||||
{ label: "Twilio", value: "twilio" },
|
||||
// Add more providers as needed
|
||||
];
|
||||
|
||||
const isLoadingEmail = ref(false);
|
||||
const isLoadingPush = ref(false);
|
||||
const isLoadingSms = ref(false);
|
||||
|
||||
const toast = useToast();
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
fetchDeliveryStats,
|
||||
fetchEmailConfig,
|
||||
fetchPushConfig,
|
||||
fetchSmsConfig,
|
||||
fetchDeliverySettings,
|
||||
updateEmailConfig,
|
||||
updatePushConfig,
|
||||
updateSmsConfig,
|
||||
updateDeliverySettings,
|
||||
} = useNotificationDelivery();
|
||||
|
||||
// Store provider-specific configs
|
||||
const providerConfigs = ref({
|
||||
mailtrap: null,
|
||||
'aws-ses': null
|
||||
});
|
||||
|
||||
// Methods
|
||||
async function refreshData() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const [emailData, pushData, smsData] = await Promise.all([
|
||||
fetchEmailConfig(),
|
||||
fetchPushConfig(),
|
||||
fetchSmsConfig(),
|
||||
]);
|
||||
|
||||
// Store all provider configs
|
||||
if (emailData.providers) {
|
||||
providerConfigs.value = emailData.providers;
|
||||
}
|
||||
|
||||
// Update email config with active provider
|
||||
const activeProviderKey = emailData.activeProvider || emailData.provider;
|
||||
const activeProviderData = emailData.providers?.[activeProviderKey] || emailData;
|
||||
|
||||
emailConfig.value = {
|
||||
enabled: activeProviderData.enabled,
|
||||
provider: activeProviderKey,
|
||||
config: activeProviderData.config || {},
|
||||
status: activeProviderData.status,
|
||||
successRate: activeProviderData.successRate,
|
||||
};
|
||||
|
||||
// Update push config
|
||||
pushConfig.value = {
|
||||
enabled: pushData.enabled,
|
||||
provider: pushData.provider,
|
||||
config: pushData.config || {},
|
||||
status: pushData.status,
|
||||
successRate: pushData.successRate,
|
||||
};
|
||||
// Update SMS config
|
||||
smsConfig.value = {
|
||||
enabled: smsData.enabled,
|
||||
provider: smsData.provider,
|
||||
config: smsData.config || {},
|
||||
status: smsData.status,
|
||||
successRate: smsData.successRate,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Error refreshing data:", err);
|
||||
toast.error(err.message || "Failed to refresh data");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEmailConfig() {
|
||||
try {
|
||||
isLoadingEmail.value = true;
|
||||
await updateEmailConfig({
|
||||
enabled: emailConfig.value.enabled,
|
||||
provider: emailConfig.value.provider,
|
||||
config: emailConfig.value.config,
|
||||
});
|
||||
toast.success("Email settings saved!");
|
||||
} catch (err) {
|
||||
toast.error(err.message || "Failed to save email settings");
|
||||
} finally {
|
||||
isLoadingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePushConfig() {
|
||||
try {
|
||||
isLoadingPush.value = true;
|
||||
await updatePushConfig({
|
||||
enabled: pushConfig.value.enabled,
|
||||
provider: pushConfig.value.provider,
|
||||
config: pushConfig.value.config,
|
||||
});
|
||||
toast.success("Push settings saved!");
|
||||
} catch (err) {
|
||||
toast.error(err.message || "Failed to save push settings");
|
||||
} finally {
|
||||
isLoadingPush.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSmsConfig() {
|
||||
try {
|
||||
isLoadingSms.value = true;
|
||||
await updateSmsConfig({
|
||||
enabled: smsConfig.value.enabled,
|
||||
provider: smsConfig.value.provider,
|
||||
config: smsConfig.value.config,
|
||||
});
|
||||
toast.success("SMS settings saved!");
|
||||
} catch (err) {
|
||||
toast.error(err.message || "Failed to save SMS settings");
|
||||
} finally {
|
||||
isLoadingSms.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for provider changes and load saved config
|
||||
watch(() => emailConfig.value.provider, (newProvider) => {
|
||||
if (providerConfigs.value[newProvider]) {
|
||||
// Load saved config for this provider
|
||||
const savedConfig = providerConfigs.value[newProvider];
|
||||
emailConfig.value.config = savedConfig.config || {};
|
||||
emailConfig.value.status = savedConfig.status;
|
||||
emailConfig.value.successRate = savedConfig.successRate;
|
||||
} else {
|
||||
// Load default config for new provider
|
||||
if (newProvider === 'mailtrap') {
|
||||
emailConfig.value.config = {
|
||||
host: 'live.smtp.mailtrap.io',
|
||||
port: 587,
|
||||
user: 'apismtp@mailtrap.io',
|
||||
pass: '',
|
||||
senderEmail: '',
|
||||
senderName: '',
|
||||
};
|
||||
} else if (newProvider === 'aws-ses') {
|
||||
emailConfig.value.config = {
|
||||
host: '',
|
||||
port: 587,
|
||||
user: '',
|
||||
pass: '',
|
||||
senderEmail: '',
|
||||
senderName: '',
|
||||
region: 'us-east-1',
|
||||
configurationSet: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load initial data
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
661
pages/notification/delivery/monitor.vue
Normal file
661
pages/notification/delivery/monitor.vue
Normal file
@@ -0,0 +1,661 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Info Card -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-monitor"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Delivery Monitor</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Real-time monitoring of notification deliveries across all channels. Track individual messages,
|
||||
monitor batch progress, and analyze delivery performance with detailed metrics.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Real-time Metrics -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(metric, index) in realTimeMetrics"
|
||||
:key="index"
|
||||
class="transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-5 flex justify-center items-center rounded-2xl transition-all duration-300"
|
||||
:class="metric.bgColor"
|
||||
>
|
||||
<Icon class="text-3xl" :name="metric.icon" :class="metric.iconColor"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-2xl leading-tight" :class="metric.textColor">
|
||||
{{ metric.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ metric.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Message Search & Filter -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-primary">Message Tracking</h2>
|
||||
<div class="flex gap-2">
|
||||
<rs-button size="sm" variant="primary-outline" @click="refreshMessages">
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
<rs-button size="sm" variant="primary-outline" @click="showFilters = !showFilters">
|
||||
<Icon class="mr-1" name="ic:outline-filter-list"></Icon>
|
||||
Filters
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<!-- Search and Filters -->
|
||||
<div class="mb-6 space-y-4">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<FormKit
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search by message ID, recipient, or content..."
|
||||
prefix-icon="search"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="selectedChannel"
|
||||
:options="channelFilterOptions"
|
||||
placeholder="All Channels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters -->
|
||||
<div v-if="showFilters" class="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="filters.status"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="filters.priority"
|
||||
:options="priorityFilterOptions"
|
||||
label="Priority"
|
||||
/>
|
||||
<FormKit
|
||||
type="date"
|
||||
v-model="filters.dateFrom"
|
||||
label="From Date"
|
||||
/>
|
||||
<FormKit
|
||||
type="date"
|
||||
v-model="filters.dateTo"
|
||||
label="To Date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Table -->
|
||||
<rs-table
|
||||
:field="messageTableFields"
|
||||
:data="filteredMessages"
|
||||
:advanced="true"
|
||||
:options="{ striped: true, hover: true }"
|
||||
:optionsAdvanced="{
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
responsive: true,
|
||||
}"
|
||||
:pageSize="20"
|
||||
>
|
||||
<template #messageId="{ row }">
|
||||
<button
|
||||
@click="viewMessageDetails(row)"
|
||||
class="text-primary hover:underline font-mono text-sm"
|
||||
>
|
||||
{{ row.messageId }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<rs-badge :variant="getStatusVariant(row.status)" size="sm">
|
||||
{{ row.status }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<template #channel="{ row }">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2" :name="getChannelIcon(row.channel)"></Icon>
|
||||
{{ row.channel }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #priority="{ row }">
|
||||
<rs-badge
|
||||
:variant="getPriorityVariant(row.priority)"
|
||||
size="sm"
|
||||
>
|
||||
{{ row.priority }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<template #progress="{ row }">
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: row.progress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ row.progress }}%</div>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<div class="flex gap-2">
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="primary-outline"
|
||||
@click="viewMessageDetails(row)"
|
||||
>
|
||||
<Icon name="ic:outline-visibility"></Icon>
|
||||
</rs-button>
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="secondary-outline"
|
||||
@click="retryMessage(row)"
|
||||
v-if="row.status === 'failed'"
|
||||
>
|
||||
<Icon name="ic:outline-refresh"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-table>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Live Activity Feed -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
|
||||
<!-- Real-time Activity -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-live-tv"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Live Activity</h2>
|
||||
</div>
|
||||
<rs-badge variant="success" size="sm">Live</rs-badge>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-3 max-h-96 overflow-y-auto">
|
||||
<div
|
||||
v-for="activity in liveActivities"
|
||||
:key="activity.id"
|
||||
class="flex items-start gap-3 p-3 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<Icon
|
||||
class="mt-1 flex-shrink-0"
|
||||
:name="activity.icon"
|
||||
:class="activity.iconColor"
|
||||
></Icon>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm">{{ activity.message }}</div>
|
||||
<div class="text-xs text-gray-500">{{ activity.timestamp }}</div>
|
||||
<div v-if="activity.details" class="text-xs text-gray-600 mt-1">
|
||||
{{ activity.details }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Channel Performance -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-speed"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Channel Performance</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="channel in channelPerformance"
|
||||
:key="channel.name"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2" :name="channel.icon"></Icon>
|
||||
<span class="font-semibold">{{ channel.name }}</span>
|
||||
</div>
|
||||
<rs-badge :variant="channel.statusVariant" size="sm">
|
||||
{{ channel.status }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-gray-600">Throughput</div>
|
||||
<div class="font-semibold">{{ channel.throughput }}/min</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Success Rate</div>
|
||||
<div class="font-semibold">{{ channel.successRate }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Avg Latency</div>
|
||||
<div class="font-semibold">{{ channel.avgLatency }}ms</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Queue Size</div>
|
||||
<div class="font-semibold">{{ channel.queueSize }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Chart -->
|
||||
<div class="mt-3">
|
||||
<div class="text-xs text-gray-500 mb-1">Performance Trend</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="channel.performanceClass"
|
||||
:style="{ width: channel.performanceScore + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Message Details Modal -->
|
||||
<rs-modal v-model="showMessageModal" size="lg">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Message Details</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="selectedMessage" class="space-y-6">
|
||||
|
||||
<!-- Message Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Message ID</label>
|
||||
<div class="font-mono text-sm">{{ selectedMessage.messageId }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Status</label>
|
||||
<rs-badge :variant="getStatusVariant(selectedMessage.status)">
|
||||
{{ selectedMessage.status }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Channel</label>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2" :name="getChannelIcon(selectedMessage.channel)"></Icon>
|
||||
{{ selectedMessage.channel }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Priority</label>
|
||||
<rs-badge :variant="getPriorityVariant(selectedMessage.priority)">
|
||||
{{ selectedMessage.priority }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Timeline -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-3">Delivery Timeline</label>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="event in selectedMessage.timeline"
|
||||
:key="event.id"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="w-3 h-3 rounded-full" :class="event.statusColor"></div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ event.status }}</div>
|
||||
<div class="text-xs text-gray-500">{{ event.timestamp }}</div>
|
||||
<div v-if="event.details" class="text-xs text-gray-600">{{ event.details }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Content -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Content</label>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-sm">
|
||||
<div><strong>To:</strong> {{ selectedMessage.recipient }}</div>
|
||||
<div><strong>Subject:</strong> {{ selectedMessage.subject }}</div>
|
||||
<div class="mt-2"><strong>Body:</strong></div>
|
||||
<div class="mt-1">{{ selectedMessage.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider Response -->
|
||||
<div v-if="selectedMessage.providerResponse">
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Provider Response</label>
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<pre class="text-xs">{{ JSON.stringify(selectedMessage.providerResponse, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<rs-button variant="outline" @click="showMessageModal = false">Close</rs-button>
|
||||
<rs-button
|
||||
variant="primary"
|
||||
@click="retryMessage(selectedMessage)"
|
||||
v-if="selectedMessage?.status === 'failed'"
|
||||
>
|
||||
Retry Message
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Delivery Monitor",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Delivery Engine",
|
||||
path: "/notification/delivery",
|
||||
},
|
||||
{
|
||||
name: "Monitor",
|
||||
path: "/notification/delivery/monitor",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
|
||||
// State
|
||||
const showFilters = ref(false);
|
||||
const showMessageModal = ref(false);
|
||||
const selectedMessage = ref(null);
|
||||
const searchQuery = ref("");
|
||||
const selectedChannel = ref("");
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
status: "",
|
||||
priority: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
});
|
||||
|
||||
// Real-time metrics
|
||||
const realTimeMetrics = ref([
|
||||
{
|
||||
title: "Messages/Min",
|
||||
value: "1,247",
|
||||
icon: "ic:outline-speed",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-600",
|
||||
textColor: "text-blue-600",
|
||||
},
|
||||
{
|
||||
title: "Success Rate",
|
||||
value: "98.7%",
|
||||
icon: "ic:outline-check-circle",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-600",
|
||||
textColor: "text-green-600",
|
||||
},
|
||||
{
|
||||
title: "Failed/Retrying",
|
||||
value: "23",
|
||||
icon: "ic:outline-error",
|
||||
bgColor: "bg-red-100",
|
||||
iconColor: "text-red-600",
|
||||
textColor: "text-red-600",
|
||||
},
|
||||
{
|
||||
title: "Avg Latency",
|
||||
value: "1.2s",
|
||||
icon: "ic:outline-timer",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-600",
|
||||
textColor: "text-purple-600",
|
||||
},
|
||||
]);
|
||||
|
||||
// Filter options
|
||||
const channelFilterOptions = [
|
||||
{ label: "All Channels", value: "" },
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "SMS", value: "sms" },
|
||||
{ label: "Push", value: "push" },
|
||||
{ label: "In-App", value: "inapp" },
|
||||
];
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ label: "All Status", value: "" },
|
||||
{ label: "Queued", value: "queued" },
|
||||
{ label: "Sent", value: "sent" },
|
||||
{ label: "Delivered", value: "delivered" },
|
||||
{ label: "Opened", value: "opened" },
|
||||
{ label: "Failed", value: "failed" },
|
||||
{ label: "Bounced", value: "bounced" },
|
||||
];
|
||||
|
||||
const priorityFilterOptions = [
|
||||
{ label: "All Priorities", value: "" },
|
||||
{ label: "Critical", value: "critical" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "Low", value: "low" },
|
||||
];
|
||||
|
||||
// Table configuration
|
||||
const messageTableFields = ref([
|
||||
{ key: "messageId", label: "Message ID", sortable: true },
|
||||
{ key: "recipient", label: "Recipient", sortable: true },
|
||||
{ key: "channel", label: "Channel", sortable: true },
|
||||
{ key: "status", label: "Status", sortable: true },
|
||||
{ key: "priority", label: "Priority", sortable: true },
|
||||
{ key: "createdAt", label: "Created", sortable: true },
|
||||
{ key: "progress", label: "Progress", sortable: false },
|
||||
{ key: "actions", label: "Actions", sortable: false },
|
||||
]);
|
||||
|
||||
// Sample messages data
|
||||
const messages = ref([
|
||||
{
|
||||
messageId: "msg_001",
|
||||
recipient: "user@example.com",
|
||||
channel: "Email",
|
||||
status: "delivered",
|
||||
priority: "high",
|
||||
createdAt: "2024-01-15 10:30:00",
|
||||
progress: 100,
|
||||
subject: "Welcome to our platform",
|
||||
content: "Thank you for joining us...",
|
||||
timeline: [
|
||||
{ id: 1, status: "Queued", timestamp: "2024-01-15 10:30:00", statusColor: "bg-blue-500", details: "Message queued for processing" },
|
||||
{ id: 2, status: "Sent", timestamp: "2024-01-15 10:30:15", statusColor: "bg-yellow-500", details: "Sent via SendGrid" },
|
||||
{ id: 3, status: "Delivered", timestamp: "2024-01-15 10:30:18", statusColor: "bg-green-500", details: "Successfully delivered" },
|
||||
],
|
||||
providerResponse: { messageId: "sg_abc123", status: "delivered" },
|
||||
},
|
||||
// Add more sample data...
|
||||
]);
|
||||
|
||||
// Live activities
|
||||
const liveActivities = ref([
|
||||
{
|
||||
id: 1,
|
||||
message: "Email batch completed",
|
||||
timestamp: "Just now",
|
||||
icon: "ic:outline-email",
|
||||
iconColor: "text-green-500",
|
||||
details: "1,250 emails sent successfully",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
message: "SMS delivery in progress",
|
||||
timestamp: "2 seconds ago",
|
||||
icon: "ic:outline-sms",
|
||||
iconColor: "text-blue-500",
|
||||
details: "89/120 messages delivered",
|
||||
},
|
||||
// Add more activities...
|
||||
]);
|
||||
|
||||
// Channel performance
|
||||
const channelPerformance = ref([
|
||||
{
|
||||
name: "Email",
|
||||
icon: "ic:outline-email",
|
||||
status: "Healthy",
|
||||
statusVariant: "success",
|
||||
throughput: "1,200",
|
||||
successRate: 99.2,
|
||||
avgLatency: 800,
|
||||
queueSize: 45,
|
||||
performanceScore: 95,
|
||||
performanceClass: "bg-green-500",
|
||||
},
|
||||
{
|
||||
name: "SMS",
|
||||
icon: "ic:outline-sms",
|
||||
status: "Warning",
|
||||
statusVariant: "warning",
|
||||
throughput: "450",
|
||||
successRate: 97.8,
|
||||
avgLatency: 2100,
|
||||
queueSize: 123,
|
||||
performanceScore: 78,
|
||||
performanceClass: "bg-yellow-500",
|
||||
},
|
||||
// Add more channels...
|
||||
]);
|
||||
|
||||
// Computed
|
||||
const filteredMessages = computed(() => {
|
||||
let filtered = messages.value;
|
||||
|
||||
if (searchQuery.value) {
|
||||
filtered = filtered.filter(msg =>
|
||||
msg.messageId.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
msg.recipient.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedChannel.value) {
|
||||
filtered = filtered.filter(msg => msg.channel.toLowerCase() === selectedChannel.value);
|
||||
}
|
||||
|
||||
if (filters.value.status) {
|
||||
filtered = filtered.filter(msg => msg.status === filters.value.status);
|
||||
}
|
||||
|
||||
if (filters.value.priority) {
|
||||
filtered = filtered.filter(msg => msg.priority === filters.value.priority);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Methods
|
||||
function getStatusVariant(status) {
|
||||
const variants = {
|
||||
queued: "info",
|
||||
sent: "warning",
|
||||
delivered: "success",
|
||||
opened: "success",
|
||||
failed: "danger",
|
||||
bounced: "danger",
|
||||
};
|
||||
return variants[status] || "secondary";
|
||||
}
|
||||
|
||||
function getChannelIcon(channel) {
|
||||
const icons = {
|
||||
Email: "ic:outline-email",
|
||||
SMS: "ic:outline-sms",
|
||||
Push: "ic:outline-notifications",
|
||||
"In-App": "ic:outline-app-registration",
|
||||
};
|
||||
return icons[channel] || "ic:outline-help";
|
||||
}
|
||||
|
||||
function getPriorityVariant(priority) {
|
||||
const variants = {
|
||||
critical: "danger",
|
||||
high: "warning",
|
||||
medium: "info",
|
||||
low: "secondary",
|
||||
};
|
||||
return variants[priority] || "secondary";
|
||||
}
|
||||
|
||||
function viewMessageDetails(message) {
|
||||
selectedMessage.value = message;
|
||||
showMessageModal.value = true;
|
||||
}
|
||||
|
||||
function retryMessage(message) {
|
||||
console.log("Retrying message:", message.messageId);
|
||||
// Implementation for retrying failed messages
|
||||
}
|
||||
|
||||
function refreshMessages() {
|
||||
console.log("Refreshing messages...");
|
||||
// Implementation for refreshing message list
|
||||
}
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
let refreshInterval;
|
||||
onMounted(() => {
|
||||
refreshInterval = setInterval(() => {
|
||||
refreshMessages();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
805
pages/notification/delivery/providers.vue
Normal file
805
pages/notification/delivery/providers.vue
Normal file
@@ -0,0 +1,805 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Info Card -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-extension"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Provider Management</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Configure and manage third-party notification service providers. Set up
|
||||
credentials, fallback rules, and monitor provider performance across all
|
||||
channels.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Provider Overview Stats -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(stat, index) in providerStats"
|
||||
:key="index"
|
||||
class="transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-5 flex justify-center items-center rounded-2xl transition-all duration-300"
|
||||
:class="stat.bgColor"
|
||||
>
|
||||
<Icon class="text-3xl" :name="stat.icon" :class="stat.iconColor"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-2xl leading-tight" :class="stat.textColor">
|
||||
{{ stat.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ stat.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Providers by Channel -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Email Providers -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-email"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Email Providers</h2>
|
||||
</div>
|
||||
<rs-button size="sm" variant="primary-outline" @click="addProvider('email')">
|
||||
<Icon class="mr-1" name="ic:outline-add"></Icon>
|
||||
Add Provider
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="provider in emailProviders"
|
||||
:key="provider.id"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-2xl" :name="provider.icon"></Icon>
|
||||
<div>
|
||||
<div class="font-semibold">{{ provider.name }}</div>
|
||||
<div class="text-sm text-gray-600">{{ provider.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-badge
|
||||
:variant="provider.status === 'active' ? 'success' : 'danger'"
|
||||
size="sm"
|
||||
>
|
||||
{{ provider.status }}
|
||||
</rs-badge>
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="secondary-outline"
|
||||
@click="configureProvider(provider)"
|
||||
>
|
||||
<Icon name="ic:outline-settings"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-gray-600">Success Rate</div>
|
||||
<div class="font-semibold">{{ provider.successRate }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Quota Used</div>
|
||||
<div class="font-semibold">{{ provider.quotaUsed }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Priority</div>
|
||||
<div class="font-semibold">{{ provider.priority }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- SMS Providers -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-sms"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">SMS Providers</h2>
|
||||
</div>
|
||||
<rs-button size="sm" variant="primary-outline" @click="addProvider('sms')">
|
||||
<Icon class="mr-1" name="ic:outline-add"></Icon>
|
||||
Add Provider
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="provider in smsProviders"
|
||||
:key="provider.id"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-2xl" :name="provider.icon"></Icon>
|
||||
<div>
|
||||
<div class="font-semibold">{{ provider.name }}</div>
|
||||
<div class="text-sm text-gray-600">{{ provider.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-badge
|
||||
:variant="provider.status === 'active' ? 'success' : 'danger'"
|
||||
size="sm"
|
||||
>
|
||||
{{ provider.status }}
|
||||
</rs-badge>
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="secondary-outline"
|
||||
@click="configureProvider(provider)"
|
||||
>
|
||||
<Icon name="ic:outline-settings"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-gray-600">Success Rate</div>
|
||||
<div class="font-semibold">{{ provider.successRate }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Quota Used</div>
|
||||
<div class="font-semibold">{{ provider.quotaUsed }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Priority</div>
|
||||
<div class="font-semibold">{{ provider.priority }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Push Providers -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-notifications"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Push Providers</h2>
|
||||
</div>
|
||||
<rs-button size="sm" variant="primary-outline" @click="addProvider('push')">
|
||||
<Icon class="mr-1" name="ic:outline-add"></Icon>
|
||||
Add Provider
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="provider in pushProviders"
|
||||
:key="provider.id"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-2xl" :name="provider.icon"></Icon>
|
||||
<div>
|
||||
<div class="font-semibold">{{ provider.name }}</div>
|
||||
<div class="text-sm text-gray-600">{{ provider.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-badge
|
||||
:variant="provider.status === 'active' ? 'success' : 'danger'"
|
||||
size="sm"
|
||||
>
|
||||
{{ provider.status }}
|
||||
</rs-badge>
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="secondary-outline"
|
||||
@click="configureProvider(provider)"
|
||||
>
|
||||
<Icon name="ic:outline-settings"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-gray-600">Success Rate</div>
|
||||
<div class="font-semibold">{{ provider.successRate }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Quota Used</div>
|
||||
<div class="font-semibold">{{ provider.quotaUsed }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Priority</div>
|
||||
<div class="font-semibold">{{ provider.priority }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Webhook Providers -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-webhook"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Webhook Endpoints</h2>
|
||||
</div>
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="primary-outline"
|
||||
@click="addProvider('webhook')"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-add"></Icon>
|
||||
Add Webhook
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="provider in webhookProviders"
|
||||
:key="provider.id"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-2xl" :name="provider.icon"></Icon>
|
||||
<div>
|
||||
<div class="font-semibold">{{ provider.name }}</div>
|
||||
<div class="text-sm text-gray-600 font-mono">{{ provider.url }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-badge
|
||||
:variant="provider.status === 'active' ? 'success' : 'danger'"
|
||||
size="sm"
|
||||
>
|
||||
{{ provider.status }}
|
||||
</rs-badge>
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="secondary-outline"
|
||||
@click="configureProvider(provider)"
|
||||
>
|
||||
<Icon name="ic:outline-settings"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-gray-600">Success Rate</div>
|
||||
<div class="font-semibold">{{ provider.successRate }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Avg Response</div>
|
||||
<div class="font-semibold">{{ provider.avgResponse }}ms</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-600">Last Used</div>
|
||||
<div class="font-semibold">{{ provider.lastUsed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Fallback Configuration -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-alt-route"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Fallback Configuration</h2>
|
||||
</div>
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="primary-outline"
|
||||
@click="showFallbackModal = true"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-add"></Icon>
|
||||
Add Rule
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="rule in fallbackRules"
|
||||
:key="rule.id"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div class="font-semibold">{{ rule.name }}</div>
|
||||
<div class="text-sm text-gray-600">{{ rule.description }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-badge :variant="rule.enabled ? 'success' : 'secondary'" size="sm">
|
||||
{{ rule.enabled ? "Active" : "Disabled" }}
|
||||
</rs-badge>
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="secondary-outline"
|
||||
@click="editFallbackRule(rule)"
|
||||
>
|
||||
<Icon name="ic:outline-edit"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-600">Primary:</span>
|
||||
<span class="font-medium">{{ rule.primary }}</span>
|
||||
<Icon name="ic:outline-arrow-forward" class="text-gray-400"></Icon>
|
||||
<span class="text-gray-600">Fallback:</span>
|
||||
<span class="font-medium">{{ rule.fallback }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">Trigger:</span>
|
||||
<span class="font-medium">{{ rule.trigger }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Provider Configuration Modal -->
|
||||
<rs-modal v-model="showProviderModal" size="lg">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ selectedProvider ? "Configure" : "Add" }} Provider
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="selectedProvider" class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
v-model="providerForm.name"
|
||||
label="Provider Name"
|
||||
placeholder="Enter provider name"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="providerForm.channel"
|
||||
label="Channel"
|
||||
:options="channelOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Provider-specific Configuration -->
|
||||
<div v-if="providerForm.channel === 'email'">
|
||||
<h4 class="font-semibold mb-3">Email Configuration</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="providerForm.provider"
|
||||
label="Provider Type"
|
||||
:options="emailProviderOptions"
|
||||
/>
|
||||
<FormKit
|
||||
type="password"
|
||||
v-model="providerForm.apiKey"
|
||||
label="API Key"
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
<FormKit
|
||||
type="password"
|
||||
v-model="providerForm.apiSecret"
|
||||
label="API Secret"
|
||||
placeholder="Enter API secret"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
v-model="providerForm.fromEmail"
|
||||
label="From Email"
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="providerForm.channel === 'sms'">
|
||||
<h4 class="font-semibold mb-3">SMS Configuration</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="providerForm.provider"
|
||||
label="Provider Type"
|
||||
:options="smsProviderOptions"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
v-model="providerForm.accountSid"
|
||||
label="Account SID"
|
||||
placeholder="Enter account SID"
|
||||
/>
|
||||
<FormKit
|
||||
type="password"
|
||||
v-model="providerForm.authToken"
|
||||
label="Auth Token"
|
||||
placeholder="Enter auth token"
|
||||
/>
|
||||
<FormKit
|
||||
type="text"
|
||||
v-model="providerForm.fromNumber"
|
||||
label="From Number"
|
||||
placeholder="+1234567890"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority & Limits -->
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Priority & Limits</h4>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<FormKit
|
||||
type="number"
|
||||
v-model="providerForm.priority"
|
||||
label="Priority"
|
||||
placeholder="1-10"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
<FormKit
|
||||
type="number"
|
||||
v-model="providerForm.rateLimit"
|
||||
label="Rate Limit (per minute)"
|
||||
placeholder="100"
|
||||
/>
|
||||
<FormKit
|
||||
type="number"
|
||||
v-model="providerForm.dailyQuota"
|
||||
label="Daily Quota"
|
||||
placeholder="10000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Connection -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold mb-3">Test Connection</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<rs-button variant="secondary" @click="testProviderConnection">
|
||||
<Icon class="mr-1" name="ic:outline-wifi-tethering"></Icon>
|
||||
Test Connection
|
||||
</rs-button>
|
||||
<div v-if="connectionTestResult" class="flex items-center gap-2">
|
||||
<Icon
|
||||
:name="
|
||||
connectionTestResult.success
|
||||
? 'ic:outline-check-circle'
|
||||
: 'ic:outline-error'
|
||||
"
|
||||
:class="
|
||||
connectionTestResult.success ? 'text-green-500' : 'text-red-500'
|
||||
"
|
||||
></Icon>
|
||||
<span
|
||||
:class="
|
||||
connectionTestResult.success ? 'text-green-600' : 'text-red-600'
|
||||
"
|
||||
>
|
||||
{{ connectionTestResult.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<rs-button variant="outline" @click="showProviderModal = false"
|
||||
>Cancel</rs-button
|
||||
>
|
||||
<rs-button variant="primary" @click="saveProvider">Save Provider</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Provider Management",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Delivery Engine",
|
||||
path: "/notification/delivery",
|
||||
},
|
||||
{
|
||||
name: "Providers",
|
||||
path: "/notification/delivery/providers",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
import { ref, reactive } from "vue";
|
||||
|
||||
// Modal states
|
||||
const showProviderModal = ref(false);
|
||||
const showFallbackModal = ref(false);
|
||||
const selectedProvider = ref(null);
|
||||
const connectionTestResult = ref(null);
|
||||
|
||||
// Provider form
|
||||
const providerForm = reactive({
|
||||
name: "",
|
||||
channel: "",
|
||||
provider: "",
|
||||
apiKey: "",
|
||||
apiSecret: "",
|
||||
accountSid: "",
|
||||
authToken: "",
|
||||
fromEmail: "",
|
||||
fromNumber: "",
|
||||
priority: 5,
|
||||
rateLimit: 100,
|
||||
dailyQuota: 10000,
|
||||
});
|
||||
|
||||
// Statistics
|
||||
const providerStats = ref([
|
||||
{
|
||||
title: "Active Providers",
|
||||
value: "12",
|
||||
icon: "ic:outline-verified",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-600",
|
||||
textColor: "text-green-600",
|
||||
},
|
||||
{
|
||||
title: "Total Messages Today",
|
||||
value: "28.5K",
|
||||
icon: "ic:outline-send",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-600",
|
||||
textColor: "text-blue-600",
|
||||
},
|
||||
{
|
||||
title: "Avg Success Rate",
|
||||
value: "98.2%",
|
||||
icon: "ic:outline-trending-up",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-600",
|
||||
textColor: "text-purple-600",
|
||||
},
|
||||
{
|
||||
title: "Failed Providers",
|
||||
value: "1",
|
||||
icon: "ic:outline-error",
|
||||
bgColor: "bg-red-100",
|
||||
iconColor: "text-red-600",
|
||||
textColor: "text-red-600",
|
||||
},
|
||||
]);
|
||||
|
||||
// Provider data
|
||||
const emailProviders = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: "Mailtrap",
|
||||
description: "Primary email delivery service (SMTP)",
|
||||
icon: "ic:outline-email",
|
||||
status: "active",
|
||||
successRate: 99.5,
|
||||
quotaUsed: 45,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "AWS SES",
|
||||
description: "Scalable email service from AWS",
|
||||
icon: "ic:outline-cloud",
|
||||
status: "inactive",
|
||||
successRate: 99.0,
|
||||
quotaUsed: 0,
|
||||
priority: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
const smsProviders = ref([
|
||||
{
|
||||
id: 4,
|
||||
name: "Twilio",
|
||||
description: "Primary SMS service",
|
||||
icon: "ic:outline-sms",
|
||||
status: "active",
|
||||
successRate: 97.8,
|
||||
quotaUsed: 78,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Nexmo",
|
||||
description: "International SMS fallback",
|
||||
icon: "ic:outline-sms",
|
||||
status: "active",
|
||||
successRate: 96.5,
|
||||
quotaUsed: 34,
|
||||
priority: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
const pushProviders = ref([
|
||||
{
|
||||
id: 6,
|
||||
name: "Firebase FCM",
|
||||
description: "Android push notifications",
|
||||
icon: "ic:outline-notifications",
|
||||
status: "active",
|
||||
successRate: 95.4,
|
||||
quotaUsed: 45,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Apple APNs",
|
||||
description: "iOS push notifications",
|
||||
icon: "ic:outline-phone-iphone",
|
||||
status: "active",
|
||||
successRate: 94.8,
|
||||
quotaUsed: 52,
|
||||
priority: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const webhookProviders = ref([
|
||||
{
|
||||
id: 8,
|
||||
name: "CRM Webhook",
|
||||
url: "https://api.crm.com/webhooks/delivery",
|
||||
icon: "ic:outline-webhook",
|
||||
status: "active",
|
||||
successRate: 99.1,
|
||||
avgResponse: 150,
|
||||
lastUsed: "2 min ago",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "Analytics Webhook",
|
||||
url: "https://analytics.example.com/webhook",
|
||||
icon: "ic:outline-analytics",
|
||||
status: "active",
|
||||
successRate: 98.7,
|
||||
avgResponse: 89,
|
||||
lastUsed: "5 min ago",
|
||||
},
|
||||
]);
|
||||
|
||||
// Fallback rules
|
||||
const fallbackRules = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: "Email Provider Failover",
|
||||
description: "Switch from SendGrid to Mailgun on failure",
|
||||
primary: "SendGrid",
|
||||
fallback: "Mailgun",
|
||||
trigger: "API error or rate limit exceeded",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "SMS Provider Failover",
|
||||
description: "Switch from Twilio to Nexmo on failure",
|
||||
primary: "Twilio",
|
||||
fallback: "Nexmo",
|
||||
trigger: "Delivery failure or timeout",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Email to SMS Fallback",
|
||||
description: "Send SMS if email delivery fails",
|
||||
primary: "Email Channel",
|
||||
fallback: "SMS Channel",
|
||||
trigger: "Hard bounce or 30s timeout",
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Form options
|
||||
const channelOptions = [
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "SMS", value: "sms" },
|
||||
{ label: "Push Notification", value: "push" },
|
||||
{ label: "Webhook", value: "webhook" },
|
||||
];
|
||||
|
||||
const emailProviderOptions = [
|
||||
{ label: "Mailtrap", value: "mailtrap" },
|
||||
{ label: "AWS SES", value: "aws-ses" },
|
||||
];
|
||||
|
||||
const smsProviderOptions = [
|
||||
{ label: "Twilio", value: "twilio" },
|
||||
{ label: "Nexmo", value: "nexmo" },
|
||||
{ label: "CM.com", value: "cm" },
|
||||
];
|
||||
|
||||
// Methods
|
||||
function addProvider(channel) {
|
||||
selectedProvider.value = null;
|
||||
providerForm.channel = channel;
|
||||
providerForm.name = "";
|
||||
providerForm.provider = "";
|
||||
showProviderModal.value = true;
|
||||
}
|
||||
|
||||
function configureProvider(provider) {
|
||||
selectedProvider.value = provider;
|
||||
// Populate form with provider data
|
||||
providerForm.name = provider.name;
|
||||
showProviderModal.value = true;
|
||||
}
|
||||
|
||||
function saveProvider() {
|
||||
console.log("Saving provider:", providerForm);
|
||||
showProviderModal.value = false;
|
||||
// Reset form
|
||||
Object.keys(providerForm).forEach((key) => {
|
||||
if (typeof providerForm[key] === "string") providerForm[key] = "";
|
||||
if (typeof providerForm[key] === "number") providerForm[key] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
function testProviderConnection() {
|
||||
console.log("Testing provider connection...");
|
||||
connectionTestResult.value = null;
|
||||
|
||||
// Simulate connection test
|
||||
setTimeout(() => {
|
||||
connectionTestResult.value = {
|
||||
success: Math.random() > 0.3,
|
||||
message:
|
||||
Math.random() > 0.3
|
||||
? "Connection successful"
|
||||
: "Connection failed - Check credentials",
|
||||
};
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function editFallbackRule(rule) {
|
||||
console.log("Editing fallback rule:", rule);
|
||||
// Implementation for editing fallback rules
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
822
pages/notification/delivery/webhooks.vue
Normal file
822
pages/notification/delivery/webhooks.vue
Normal file
@@ -0,0 +1,822 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Info Card -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-webhook"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Webhook Management</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Configure and manage webhook endpoints for delivery status updates. Monitor webhook performance,
|
||||
manage retry policies, and view delivery logs.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Webhook Statistics -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(stat, index) in webhookStats"
|
||||
:key="index"
|
||||
class="transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-5 flex justify-center items-center rounded-2xl transition-all duration-300"
|
||||
:class="stat.bgColor"
|
||||
>
|
||||
<Icon class="text-3xl" :name="stat.icon" :class="stat.iconColor"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-2xl leading-tight" :class="stat.textColor">
|
||||
{{ stat.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ stat.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Endpoints -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-webhook"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Webhook Endpoints</h2>
|
||||
</div>
|
||||
<rs-button variant="primary" @click="showAddWebhookModal = true">
|
||||
<Icon class="mr-1" name="ic:outline-add"></Icon>
|
||||
Add Webhook
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="webhook in webhooks"
|
||||
:key="webhook.id"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="font-semibold text-lg">{{ webhook.name }}</h3>
|
||||
<rs-badge :variant="webhook.enabled ? 'success' : 'secondary'" size="sm">
|
||||
{{ webhook.enabled ? 'Active' : 'Disabled' }}
|
||||
</rs-badge>
|
||||
<rs-badge :variant="getHealthVariant(webhook.health)" size="sm">
|
||||
{{ webhook.health }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 mb-2">{{ webhook.description }}</div>
|
||||
<div class="font-mono text-sm bg-gray-50 p-2 rounded">{{ webhook.url }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<rs-button size="sm" variant="secondary-outline" @click="editWebhook(webhook)">
|
||||
<Icon name="ic:outline-edit"></Icon>
|
||||
</rs-button>
|
||||
<rs-button size="sm" variant="secondary-outline" @click="testWebhook(webhook)">
|
||||
<Icon name="ic:outline-send"></Icon>
|
||||
</rs-button>
|
||||
<rs-button size="sm" variant="danger-outline" @click="deleteWebhook(webhook)">
|
||||
<Icon name="ic:outline-delete"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Details -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Events</label>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<rs-badge
|
||||
v-for="event in webhook.events"
|
||||
:key="event"
|
||||
variant="info"
|
||||
size="xs"
|
||||
>
|
||||
{{ event }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Success Rate</label>
|
||||
<div class="font-semibold">{{ webhook.successRate }}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Avg Response Time</label>
|
||||
<div class="font-semibold">{{ webhook.avgResponseTime }}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Chart -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-500 mb-2">Performance Trend (24h)</label>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="getPerformanceClass(webhook.performance)"
|
||||
:style="{ width: webhook.performance + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ webhook.performance }}% performance score</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Deliveries -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-2">Recent Deliveries</label>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="delivery in webhook.recentDeliveries"
|
||||
:key="delivery.id"
|
||||
class="flex items-center justify-between p-2 bg-gray-50 rounded text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon
|
||||
:name="delivery.success ? 'ic:outline-check-circle' : 'ic:outline-error'"
|
||||
:class="delivery.success ? 'text-green-500' : 'text-red-500'"
|
||||
></Icon>
|
||||
<span>{{ delivery.event }}</span>
|
||||
<span class="text-gray-500">{{ delivery.timestamp }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500">{{ delivery.responseTime }}ms</span>
|
||||
<span :class="delivery.success ? 'text-green-600' : 'text-red-600'">
|
||||
{{ delivery.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Delivery Logs -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
|
||||
<h2 class="text-lg font-semibold text-primary">Delivery Logs</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<rs-button size="sm" variant="secondary-outline" @click="refreshLogs">
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
<rs-button size="sm" variant="secondary-outline" @click="exportLogs">
|
||||
<Icon class="mr-1" name="ic:outline-download"></Icon>
|
||||
Export
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<!-- Filters -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="logFilters.webhook"
|
||||
:options="webhookFilterOptions"
|
||||
placeholder="All Webhooks"
|
||||
label="Webhook"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="logFilters.event"
|
||||
:options="eventFilterOptions"
|
||||
placeholder="All Events"
|
||||
label="Event"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="logFilters.status"
|
||||
:options="statusFilterOptions"
|
||||
placeholder="All Statuses"
|
||||
label="Status"
|
||||
/>
|
||||
<FormKit
|
||||
type="date"
|
||||
v-model="logFilters.date"
|
||||
label="Date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Logs Table -->
|
||||
<rs-table
|
||||
:field="logTableFields"
|
||||
:data="filteredLogs"
|
||||
:advanced="true"
|
||||
:options="{ striped: true, hover: true }"
|
||||
:optionsAdvanced="{
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
responsive: true,
|
||||
}"
|
||||
:pageSize="20"
|
||||
>
|
||||
<template #webhook="{ row }">
|
||||
<div class="font-medium">{{ row.webhookName }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.url }}</div>
|
||||
</template>
|
||||
|
||||
<template #event="{ row }">
|
||||
<rs-badge variant="info" size="sm">
|
||||
{{ row.event }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<rs-badge :variant="row.success ? 'success' : 'danger'" size="sm">
|
||||
{{ row.status }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<template #responseTime="{ row }">
|
||||
<span :class="row.responseTime > 1000 ? 'text-red-600' : 'text-green-600'">
|
||||
{{ row.responseTime }}ms
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #actions="{ row }">
|
||||
<div class="flex gap-2">
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="primary-outline"
|
||||
@click="viewLogDetails(row)"
|
||||
>
|
||||
<Icon name="ic:outline-visibility"></Icon>
|
||||
</rs-button>
|
||||
<rs-button
|
||||
size="sm"
|
||||
variant="secondary-outline"
|
||||
@click="retryWebhook(row)"
|
||||
v-if="!row.success"
|
||||
>
|
||||
<Icon name="ic:outline-refresh"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-table>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Add/Edit Webhook Modal -->
|
||||
<rs-modal v-model="showAddWebhookModal" size="lg">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">{{ editingWebhook ? 'Edit' : 'Add' }} Webhook</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
v-model="webhookForm.name"
|
||||
label="Webhook Name"
|
||||
placeholder="Enter webhook name"
|
||||
validation="required"
|
||||
/>
|
||||
<FormKit
|
||||
type="url"
|
||||
v-model="webhookForm.url"
|
||||
label="Endpoint URL"
|
||||
placeholder="https://api.example.com/webhook"
|
||||
validation="required|url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormKit
|
||||
type="textarea"
|
||||
v-model="webhookForm.description"
|
||||
label="Description"
|
||||
placeholder="Describe what this webhook is used for"
|
||||
rows="2"
|
||||
/>
|
||||
|
||||
<!-- Events Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">Events to Subscribe</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="event in availableEvents"
|
||||
:key="event.value"
|
||||
class="flex items-center"
|
||||
>
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
v-model="webhookForm.events"
|
||||
:value="event.value"
|
||||
:label="event.label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security -->
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Security Settings</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="password"
|
||||
v-model="webhookForm.secret"
|
||||
label="Secret Key"
|
||||
placeholder="Optional secret for signature verification"
|
||||
help="Used to sign webhook payloads for verification"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="webhookForm.authType"
|
||||
label="Authentication Type"
|
||||
:options="authTypeOptions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="webhookForm.authType === 'bearer'" class="mt-4">
|
||||
<FormKit
|
||||
type="password"
|
||||
v-model="webhookForm.bearerToken"
|
||||
label="Bearer Token"
|
||||
placeholder="Enter bearer token"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="webhookForm.authType === 'basic'" class="grid grid-cols-2 gap-4 mt-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
v-model="webhookForm.username"
|
||||
label="Username"
|
||||
placeholder="Enter username"
|
||||
/>
|
||||
<FormKit
|
||||
type="password"
|
||||
v-model="webhookForm.password"
|
||||
label="Password"
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Retry Settings -->
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Retry Settings</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<FormKit
|
||||
type="number"
|
||||
v-model="webhookForm.maxRetries"
|
||||
label="Max Retries"
|
||||
placeholder="3"
|
||||
min="0"
|
||||
max="10"
|
||||
/>
|
||||
<FormKit
|
||||
type="number"
|
||||
v-model="webhookForm.retryDelay"
|
||||
label="Retry Delay (seconds)"
|
||||
placeholder="60"
|
||||
min="1"
|
||||
max="3600"
|
||||
/>
|
||||
<FormKit
|
||||
type="number"
|
||||
v-model="webhookForm.timeout"
|
||||
label="Timeout (seconds)"
|
||||
placeholder="30"
|
||||
min="1"
|
||||
max="300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Webhook -->
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold mb-3">Test Webhook</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<rs-button variant="secondary" @click="testWebhookEndpoint">
|
||||
<Icon class="mr-1" name="ic:outline-send"></Icon>
|
||||
Send Test
|
||||
</rs-button>
|
||||
<div v-if="testResult" class="flex items-center gap-2">
|
||||
<Icon
|
||||
:name="testResult.success ? 'ic:outline-check-circle' : 'ic:outline-error'"
|
||||
:class="testResult.success ? 'text-green-500' : 'text-red-500'"
|
||||
></Icon>
|
||||
<span :class="testResult.success ? 'text-green-600' : 'text-red-600'">
|
||||
{{ testResult.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<rs-button variant="outline" @click="showAddWebhookModal = false">Cancel</rs-button>
|
||||
<rs-button variant="primary" @click="saveWebhook">
|
||||
{{ editingWebhook ? 'Update' : 'Create' }} Webhook
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
|
||||
<!-- Log Details Modal -->
|
||||
<rs-modal v-model="showLogModal" size="lg">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Webhook Delivery Details</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="selectedLog" class="space-y-6">
|
||||
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Webhook</label>
|
||||
<div class="font-medium">{{ selectedLog.webhookName }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Event</label>
|
||||
<rs-badge variant="info">{{ selectedLog.event }}</rs-badge>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Status</label>
|
||||
<rs-badge :variant="selectedLog.success ? 'success' : 'danger'">
|
||||
{{ selectedLog.status }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Response Time</label>
|
||||
<div class="font-medium">{{ selectedLog.responseTime }}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request Details -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Request Payload</label>
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<pre class="text-xs overflow-x-auto">{{ JSON.stringify(selectedLog.payload, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Details -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Response</label>
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<div class="text-sm mb-2">
|
||||
<strong>Status Code:</strong> {{ selectedLog.responseCode }}
|
||||
</div>
|
||||
<div class="text-sm mb-2">
|
||||
<strong>Headers:</strong>
|
||||
</div>
|
||||
<pre class="text-xs overflow-x-auto mb-2">{{ JSON.stringify(selectedLog.responseHeaders, null, 2) }}</pre>
|
||||
<div class="text-sm mb-2">
|
||||
<strong>Body:</strong>
|
||||
</div>
|
||||
<pre class="text-xs overflow-x-auto">{{ selectedLog.responseBody }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Details (if any) -->
|
||||
<div v-if="selectedLog.error">
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Error Details</label>
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div class="text-sm text-red-800">{{ selectedLog.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<rs-button variant="outline" @click="showLogModal = false">Close</rs-button>
|
||||
<rs-button
|
||||
variant="primary"
|
||||
@click="retryWebhook(selectedLog)"
|
||||
v-if="!selectedLog?.success"
|
||||
>
|
||||
Retry Delivery
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Webhook Management",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Delivery Engine",
|
||||
path: "/notification/delivery",
|
||||
},
|
||||
{
|
||||
name: "Webhooks",
|
||||
path: "/notification/delivery/webhooks",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
import { ref, reactive, computed } from "vue";
|
||||
|
||||
// Modal states
|
||||
const showAddWebhookModal = ref(false);
|
||||
const showLogModal = ref(false);
|
||||
const editingWebhook = ref(null);
|
||||
const selectedLog = ref(null);
|
||||
const testResult = ref(null);
|
||||
|
||||
// Form data
|
||||
const webhookForm = reactive({
|
||||
name: "",
|
||||
url: "",
|
||||
description: "",
|
||||
events: [],
|
||||
secret: "",
|
||||
authType: "none",
|
||||
bearerToken: "",
|
||||
username: "",
|
||||
password: "",
|
||||
maxRetries: 3,
|
||||
retryDelay: 60,
|
||||
timeout: 30,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Filter states
|
||||
const logFilters = reactive({
|
||||
webhook: "",
|
||||
event: "",
|
||||
status: "",
|
||||
date: "",
|
||||
});
|
||||
|
||||
// Statistics
|
||||
const webhookStats = ref([
|
||||
{
|
||||
title: "Active Webhooks",
|
||||
value: "8",
|
||||
icon: "ic:outline-webhook",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-600",
|
||||
textColor: "text-blue-600",
|
||||
},
|
||||
{
|
||||
title: "Deliveries Today",
|
||||
value: "1.2K",
|
||||
icon: "ic:outline-send",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-600",
|
||||
textColor: "text-green-600",
|
||||
},
|
||||
{
|
||||
title: "Success Rate",
|
||||
value: "98.5%",
|
||||
icon: "ic:outline-trending-up",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-600",
|
||||
textColor: "text-purple-600",
|
||||
},
|
||||
{
|
||||
title: "Failed Deliveries",
|
||||
value: "18",
|
||||
icon: "ic:outline-error",
|
||||
bgColor: "bg-red-100",
|
||||
iconColor: "text-red-600",
|
||||
textColor: "text-red-600",
|
||||
},
|
||||
]);
|
||||
|
||||
// Webhook data
|
||||
const webhooks = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: "CRM Integration",
|
||||
description: "Delivery status updates for CRM system",
|
||||
url: "https://api.crm.com/webhooks/delivery",
|
||||
enabled: true,
|
||||
health: "Healthy",
|
||||
events: ["delivered", "opened", "bounced"],
|
||||
successRate: 99.2,
|
||||
avgResponseTime: 150,
|
||||
performance: 95,
|
||||
recentDeliveries: [
|
||||
{ id: 1, event: "delivered", timestamp: "2 min ago", success: true, status: "200", responseTime: 140 },
|
||||
{ id: 2, event: "opened", timestamp: "5 min ago", success: true, status: "200", responseTime: 160 },
|
||||
{ id: 3, event: "bounced", timestamp: "8 min ago", success: false, status: "500", responseTime: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Analytics Platform",
|
||||
description: "Send delivery metrics to analytics dashboard",
|
||||
url: "https://analytics.example.com/webhook",
|
||||
enabled: true,
|
||||
health: "Warning",
|
||||
events: ["sent", "delivered", "failed"],
|
||||
successRate: 87.5,
|
||||
avgResponseTime: 2100,
|
||||
performance: 78,
|
||||
recentDeliveries: [
|
||||
{ id: 4, event: "sent", timestamp: "1 min ago", success: true, status: "200", responseTime: 1900 },
|
||||
{ id: 5, event: "delivered", timestamp: "3 min ago", success: false, status: "timeout", responseTime: 0 },
|
||||
{ id: 6, event: "failed", timestamp: "6 min ago", success: true, status: "200", responseTime: 2300 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Delivery logs
|
||||
const deliveryLogs = ref([
|
||||
{
|
||||
id: 1,
|
||||
webhookId: 1,
|
||||
webhookName: "CRM Integration",
|
||||
url: "https://api.crm.com/webhooks/delivery",
|
||||
event: "delivered",
|
||||
success: true,
|
||||
status: "200 OK",
|
||||
responseTime: 140,
|
||||
timestamp: "2024-01-15 10:30:00",
|
||||
payload: {
|
||||
messageId: "msg_001",
|
||||
event: "delivered",
|
||||
timestamp: "2024-01-15T10:30:00Z",
|
||||
channel: "email",
|
||||
recipient: "user@example.com"
|
||||
},
|
||||
responseCode: 200,
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
responseBody: '{"status": "received"}',
|
||||
error: null,
|
||||
},
|
||||
// Add more logs...
|
||||
]);
|
||||
|
||||
// Form options
|
||||
const availableEvents = [
|
||||
{ label: "Message Queued", value: "queued" },
|
||||
{ label: "Message Sent", value: "sent" },
|
||||
{ label: "Message Delivered", value: "delivered" },
|
||||
{ label: "Message Opened", value: "opened" },
|
||||
{ label: "Message Failed", value: "failed" },
|
||||
{ label: "Message Bounced", value: "bounced" },
|
||||
];
|
||||
|
||||
const authTypeOptions = [
|
||||
{ label: "None", value: "none" },
|
||||
{ label: "Bearer Token", value: "bearer" },
|
||||
{ label: "Basic Auth", value: "basic" },
|
||||
];
|
||||
|
||||
// Filter options
|
||||
const webhookFilterOptions = computed(() => [
|
||||
{ label: "All Webhooks", value: "" },
|
||||
...webhooks.value.map(w => ({ label: w.name, value: w.id }))
|
||||
]);
|
||||
|
||||
const eventFilterOptions = [
|
||||
{ label: "All Events", value: "" },
|
||||
...availableEvents,
|
||||
];
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ label: "All Statuses", value: "" },
|
||||
{ label: "Success", value: "success" },
|
||||
{ label: "Failed", value: "failed" },
|
||||
];
|
||||
|
||||
// Table configuration
|
||||
const logTableFields = ref([
|
||||
{ key: "timestamp", label: "Timestamp", sortable: true },
|
||||
{ key: "webhook", label: "Webhook", sortable: true },
|
||||
{ key: "event", label: "Event", sortable: true },
|
||||
{ key: "status", label: "Status", sortable: true },
|
||||
{ key: "responseTime", label: "Response Time", sortable: true },
|
||||
{ key: "actions", label: "Actions", sortable: false },
|
||||
]);
|
||||
|
||||
// Computed
|
||||
const filteredLogs = computed(() => {
|
||||
let filtered = deliveryLogs.value;
|
||||
|
||||
if (logFilters.webhook) {
|
||||
filtered = filtered.filter(log => log.webhookId === logFilters.webhook);
|
||||
}
|
||||
|
||||
if (logFilters.event) {
|
||||
filtered = filtered.filter(log => log.event === logFilters.event);
|
||||
}
|
||||
|
||||
if (logFilters.status) {
|
||||
const isSuccess = logFilters.status === "success";
|
||||
filtered = filtered.filter(log => log.success === isSuccess);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Methods
|
||||
function getHealthVariant(health) {
|
||||
const variants = {
|
||||
"Healthy": "success",
|
||||
"Warning": "warning",
|
||||
"Critical": "danger",
|
||||
};
|
||||
return variants[health] || "secondary";
|
||||
}
|
||||
|
||||
function getPerformanceClass(performance) {
|
||||
if (performance >= 90) return "bg-green-500";
|
||||
if (performance >= 70) return "bg-yellow-500";
|
||||
return "bg-red-500";
|
||||
}
|
||||
|
||||
function editWebhook(webhook) {
|
||||
editingWebhook.value = webhook;
|
||||
// Populate form with webhook data
|
||||
Object.keys(webhookForm).forEach(key => {
|
||||
if (webhook[key] !== undefined) {
|
||||
webhookForm[key] = webhook[key];
|
||||
}
|
||||
});
|
||||
showAddWebhookModal.value = true;
|
||||
}
|
||||
|
||||
function testWebhook(webhook) {
|
||||
console.log("Testing webhook:", webhook.name);
|
||||
// Implementation for testing webhook
|
||||
}
|
||||
|
||||
function deleteWebhook(webhook) {
|
||||
console.log("Deleting webhook:", webhook.name);
|
||||
// Implementation for deleting webhook
|
||||
}
|
||||
|
||||
function saveWebhook() {
|
||||
console.log("Saving webhook:", webhookForm);
|
||||
showAddWebhookModal.value = false;
|
||||
// Reset form
|
||||
Object.keys(webhookForm).forEach(key => {
|
||||
if (typeof webhookForm[key] === 'string') webhookForm[key] = '';
|
||||
if (typeof webhookForm[key] === 'number') webhookForm[key] = 0;
|
||||
if (Array.isArray(webhookForm[key])) webhookForm[key] = [];
|
||||
if (typeof webhookForm[key] === 'boolean') webhookForm[key] = true;
|
||||
});
|
||||
}
|
||||
|
||||
function testWebhookEndpoint() {
|
||||
console.log("Testing webhook endpoint...");
|
||||
testResult.value = null;
|
||||
|
||||
// Simulate test
|
||||
setTimeout(() => {
|
||||
testResult.value = {
|
||||
success: Math.random() > 0.3,
|
||||
message: Math.random() > 0.3 ? "Test successful" : "Connection failed - Check URL and credentials"
|
||||
};
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function viewLogDetails(log) {
|
||||
selectedLog.value = log;
|
||||
showLogModal.value = true;
|
||||
}
|
||||
|
||||
function retryWebhook(log) {
|
||||
console.log("Retrying webhook delivery:", log);
|
||||
// Implementation for retrying webhook delivery
|
||||
}
|
||||
|
||||
function refreshLogs() {
|
||||
console.log("Refreshing logs...");
|
||||
// Implementation for refreshing logs
|
||||
}
|
||||
|
||||
function exportLogs() {
|
||||
console.log("Exporting logs...");
|
||||
// Implementation for exporting logs
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
661
pages/notification/edit/[id].vue
Normal file
661
pages/notification/edit/[id].vue
Normal file
@@ -0,0 +1,661 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Info Card -->
|
||||
<rs-card class="mb-5">
|
||||
<template #header>
|
||||
<div class="flex">
|
||||
<Icon class="mr-2 flex justify-center" name="ic:outline-info"></Icon>
|
||||
Edit Notification
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="mb-4">
|
||||
Edit and update your notification settings. Modify delivery options, content,
|
||||
and targeting to improve your notification campaign effectiveness.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-12">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon name="ic:outline-refresh" size="2rem" class="text-primary animate-spin" />
|
||||
</div>
|
||||
<p class="text-gray-600">Loading notification details...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<rs-card v-else-if="error" class="mb-5">
|
||||
<template #body>
|
||||
<div class="text-center py-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon name="ic:outline-error" size="4rem" class="text-red-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-500 mb-2">
|
||||
Error Loading Notification
|
||||
</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
{{ error }}
|
||||
</p>
|
||||
<rs-button @click="$router.push('/notification/list')">
|
||||
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
|
||||
Back to List
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Notification Not Found -->
|
||||
<rs-card v-else-if="!notification">
|
||||
<template #body>
|
||||
<div class="text-center py-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon name="ic:outline-error" size="4rem" class="text-red-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-500 mb-2">Notification Not Found</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
The notification you're trying to edit doesn't exist or has been deleted.
|
||||
</p>
|
||||
<rs-button @click="$router.push('/notification/list')">
|
||||
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
|
||||
Back to List
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<rs-card v-else>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">Edit Notification</h2>
|
||||
<div class="flex gap-3">
|
||||
<rs-button @click="$router.push('/notification/list')" variant="outline">
|
||||
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
|
||||
Back to List
|
||||
</rs-button>
|
||||
<rs-button @click="viewNotification" variant="primary">
|
||||
<Icon name="ic:outline-visibility" class="mr-1"></Icon>
|
||||
View Details
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="pt-2">
|
||||
<FormKit
|
||||
type="form"
|
||||
@submit="handleUpdateNotification"
|
||||
:actions="false"
|
||||
class="w-full"
|
||||
>
|
||||
<!-- Basic Information -->
|
||||
<div class="space-y-6 mb-8">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700 pb-2"
|
||||
>
|
||||
Basic Information
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
name="title"
|
||||
label="Notification Title"
|
||||
placeholder="Enter notification title"
|
||||
validation="required"
|
||||
v-model="notificationForm.title"
|
||||
help="This is for internal identification purposes"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="type"
|
||||
label="Notification Type"
|
||||
:options="notificationTypes"
|
||||
validation="required"
|
||||
v-model="notificationForm.type"
|
||||
help="Choose between single targeted notification or bulk notification"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="priority"
|
||||
label="Priority Level"
|
||||
:options="priorityLevels"
|
||||
validation="required"
|
||||
v-model="notificationForm.priority"
|
||||
help="Set the importance level of this notification"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="category"
|
||||
label="Category"
|
||||
:options="categoryOptions"
|
||||
validation="required"
|
||||
v-model="notificationForm.category"
|
||||
help="Categorize your notification for better organization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
name="channels"
|
||||
label="Delivery Channels"
|
||||
:options="channelOptions"
|
||||
validation="required|min:1"
|
||||
v-model="notificationForm.channels"
|
||||
decorator-icon="material-symbols:check"
|
||||
options-class="grid grid-cols-1 gap-y-2 pt-1"
|
||||
help="Select one or more delivery channels"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
v-if="notificationForm.channels.includes('email')"
|
||||
type="text"
|
||||
name="emailSubject"
|
||||
label="Email Subject Line"
|
||||
placeholder="Enter email subject"
|
||||
validation="required"
|
||||
v-model="notificationForm.emailSubject"
|
||||
help="This will be the email subject line"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="datetime-local"
|
||||
name="expiresAt"
|
||||
label="Expiration Date & Time (Optional)"
|
||||
v-model="notificationForm.expiresAt"
|
||||
help="Set when this notification should expire"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduling -->
|
||||
<div class="space-y-6 mb-8">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700 pb-2"
|
||||
>
|
||||
Scheduling
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="radio"
|
||||
name="deliveryType"
|
||||
label="Delivery Schedule"
|
||||
:options="deliveryTypes"
|
||||
validation="required"
|
||||
v-model="notificationForm.deliveryType"
|
||||
decorator-icon="material-symbols:radio-button-checked"
|
||||
options-class="space-y-3 pt-1"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="notificationForm.deliveryType === 'scheduled'"
|
||||
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<FormKit
|
||||
type="datetime-local"
|
||||
name="scheduledAt"
|
||||
label="Scheduled Date & Time"
|
||||
validation="required"
|
||||
v-model="notificationForm.scheduledAt"
|
||||
help="When should this notification be sent?"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="timezone"
|
||||
label="Timezone"
|
||||
:options="timezoneOptions"
|
||||
validation="required"
|
||||
v-model="notificationForm.timezone"
|
||||
help="Select the timezone for scheduling"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="space-y-4"
|
||||
v-if="notificationForm.deliveryType === 'scheduled'"
|
||||
>
|
||||
<div class="p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/20">
|
||||
<h4 class="font-semibold text-blue-800 dark:text-blue-200 mb-2">
|
||||
Scheduling Information
|
||||
</h4>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<p v-if="notificationForm.scheduledAt">
|
||||
<strong>Scheduled for:</strong>
|
||||
{{ formatScheduledTime() }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Timezone:</strong>
|
||||
{{ notificationForm.timezone }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Audience -->
|
||||
<div class="space-y-6 mb-8">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700 pb-2"
|
||||
>
|
||||
Target Audience
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="radio"
|
||||
name="audienceType"
|
||||
label="Target Audience"
|
||||
:options="audienceTypeOptions"
|
||||
validation="required"
|
||||
v-model="notificationForm.audienceType"
|
||||
decorator-icon="material-symbols:radio-button-checked"
|
||||
options-class="space-y-3 pt-1"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="notificationForm.audienceType === 'specific'"
|
||||
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<FormKit
|
||||
type="textarea"
|
||||
name="specificUsers"
|
||||
label="Specific Users"
|
||||
placeholder="Enter email addresses or user IDs (one per line)"
|
||||
validation="required"
|
||||
v-model="notificationForm.specificUsers"
|
||||
rows="6"
|
||||
help="Enter one email address or user ID per line"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 border rounded-lg bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="font-semibold text-green-800 dark:text-green-200 mb-2">
|
||||
Estimated Reach
|
||||
</h4>
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{{ estimatedReach.toLocaleString() }} users
|
||||
</div>
|
||||
<p class="text-sm text-green-700 dark:text-green-300 mt-1">
|
||||
Based on your audience selection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div
|
||||
class="flex justify-between items-center pt-6 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<rs-button @click="handleSaveDraft" variant="outline">
|
||||
<Icon name="material-symbols:save" class="mr-1"></Icon>
|
||||
Save as Draft
|
||||
</rs-button>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<rs-button @click="$router.push('/notification/list')" variant="outline">
|
||||
Cancel
|
||||
</rs-button>
|
||||
<rs-button btnType="submit">
|
||||
<Icon name="material-symbols:update" class="mr-1"></Icon>
|
||||
Update Notification
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</FormKit>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useNotifications } from "~/composables/useNotifications";
|
||||
|
||||
const router = useRouter();
|
||||
const nuxtApp = useNuxtApp();
|
||||
|
||||
definePageMeta({
|
||||
title: "Edit Notification",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "List",
|
||||
path: "/notification/list",
|
||||
},
|
||||
{
|
||||
name: "Edit",
|
||||
path: "",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Get route params
|
||||
const route = useRoute();
|
||||
const notificationId = route.params.id;
|
||||
|
||||
// Use the notifications composable
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
getNotificationById,
|
||||
updateNotification,
|
||||
saveDraft,
|
||||
testSendNotification,
|
||||
getAudiencePreview,
|
||||
} = useNotifications();
|
||||
|
||||
// Reactive data
|
||||
const notification = ref(null);
|
||||
const testEmail = ref("");
|
||||
const estimatedReach = ref(0);
|
||||
|
||||
// Form data
|
||||
const notificationForm = ref({
|
||||
title: "",
|
||||
type: "single",
|
||||
priority: "medium",
|
||||
category: "",
|
||||
channels: [],
|
||||
emailSubject: "",
|
||||
expiresAt: "",
|
||||
deliveryType: "immediate",
|
||||
scheduledAt: "",
|
||||
timezone: "UTC",
|
||||
audienceType: "all",
|
||||
specificUsers: "",
|
||||
userSegments: [],
|
||||
userStatus: "",
|
||||
registrationPeriod: "",
|
||||
excludeUnsubscribed: true,
|
||||
});
|
||||
|
||||
// Form options
|
||||
const notificationTypes = [
|
||||
{ label: "Single Notification", value: "single" },
|
||||
{ label: "Bulk Notification", value: "bulk" },
|
||||
];
|
||||
|
||||
const priorityLevels = [
|
||||
{ label: "Low", value: "low" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Critical", value: "critical" },
|
||||
];
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: "User Management", value: "user_management" },
|
||||
{ label: "Orders & Transactions", value: "orders" },
|
||||
{ label: "Security & Authentication", value: "security" },
|
||||
{ label: "Marketing & Promotions", value: "marketing" },
|
||||
{ label: "System Updates", value: "system" },
|
||||
{ label: "General Information", value: "general" },
|
||||
];
|
||||
|
||||
const channelOptions = [
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "Push Notification", value: "push" },
|
||||
];
|
||||
|
||||
const deliveryTypes = [
|
||||
{ label: "Send Immediately", value: "immediate" },
|
||||
{ label: "Schedule for Later", value: "scheduled" },
|
||||
];
|
||||
|
||||
const timezoneOptions = [
|
||||
{ label: "UTC", value: "UTC" },
|
||||
{ label: "Asia/Kuala_Lumpur", value: "Asia/Kuala_Lumpur" },
|
||||
{ label: "America/New_York", value: "America/New_York" },
|
||||
{ label: "Europe/London", value: "Europe/London" },
|
||||
{ label: "Asia/Tokyo", value: "Asia/Tokyo" },
|
||||
];
|
||||
|
||||
const audienceTypeOptions = [
|
||||
{ label: "All Users", value: "all" },
|
||||
{ label: "Specific Users", value: "specific" },
|
||||
];
|
||||
|
||||
// Methods
|
||||
const formatScheduledTime = () => {
|
||||
if (!notificationForm.value.scheduledAt) return "";
|
||||
return new Date(notificationForm.value.scheduledAt).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const viewNotification = () => {
|
||||
router.push(`/notification/view/${notificationId}`);
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
try {
|
||||
await saveDraft(notificationForm.value);
|
||||
nuxtApp.$swal.fire("Success", "Notification saved as draft", "success");
|
||||
} catch (error) {
|
||||
console.error("Error saving draft:", error);
|
||||
nuxtApp.$swal.fire("Error", "Failed to save draft", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateNotification = async () => {
|
||||
try {
|
||||
// Validate channels data
|
||||
if (
|
||||
!Array.isArray(notificationForm.value.channels) ||
|
||||
notificationForm.value.channels.length === 0
|
||||
) {
|
||||
throw new Error("Please select at least one delivery channel");
|
||||
}
|
||||
|
||||
// Validate that all selected channels are valid
|
||||
const validChannels = channelOptions.map((option) => option.value);
|
||||
const hasInvalidChannel = notificationForm.value.channels.some(
|
||||
(channel) => !validChannels.includes(channel)
|
||||
);
|
||||
if (hasInvalidChannel) {
|
||||
throw new Error("Invalid delivery channel selected");
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
const loadingSwal = nuxtApp.$swal.fire({
|
||||
title: "Updating...",
|
||||
text: "Please wait while we update the notification",
|
||||
allowOutsideClick: false,
|
||||
allowEscapeKey: false,
|
||||
showConfirmButton: false,
|
||||
didOpen: () => {
|
||||
nuxtApp.$swal.showLoading();
|
||||
},
|
||||
});
|
||||
|
||||
// Transform data to match API expectations (camelCase to snake_case)
|
||||
const formData = {
|
||||
title: notificationForm.value.title,
|
||||
type: notificationForm.value.type,
|
||||
priority: notificationForm.value.priority,
|
||||
// Send null for category_id instead of the value
|
||||
category_id: null, // We'll omit this field and let the server keep the existing value
|
||||
status: notificationForm.value.status || "active",
|
||||
delivery_type: notificationForm.value.deliveryType,
|
||||
scheduled_at: notificationForm.value.scheduledAt,
|
||||
timezone: notificationForm.value.timezone,
|
||||
expires_at: notificationForm.value.expiresAt,
|
||||
audience_type: notificationForm.value.audienceType,
|
||||
specific_users: notificationForm.value.specificUsers,
|
||||
user_segments: notificationForm.value.userSegments || [],
|
||||
user_status: notificationForm.value.userStatus,
|
||||
registration_period: notificationForm.value.registrationPeriod,
|
||||
exclude_unsubscribed: notificationForm.value.excludeUnsubscribed,
|
||||
email_subject: notificationForm.value.emailSubject,
|
||||
// Transform channels to API format
|
||||
channels: notificationForm.value.channels.map((channel) => ({
|
||||
type: channel,
|
||||
is_enabled: true,
|
||||
})),
|
||||
};
|
||||
|
||||
console.log("Sending update with data:", formData);
|
||||
const response = await updateNotification(notificationId, formData);
|
||||
console.log("Update response:", response);
|
||||
|
||||
// Close loading indicator
|
||||
loadingSwal.close();
|
||||
|
||||
if (response && response.success) {
|
||||
nuxtApp.$swal
|
||||
.fire({
|
||||
title: "Success!",
|
||||
text: "Notification has been updated successfully.",
|
||||
icon: "success",
|
||||
confirmButtonText: "Back to List",
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
router.push("/notification/list");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(response?.message || "Update failed with unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating notification:", error);
|
||||
const errorMessage =
|
||||
error.data?.message || error.message || "Failed to update notification";
|
||||
nuxtApp.$swal.fire("Error", errorMessage, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotification = async () => {
|
||||
try {
|
||||
const data = await getNotificationById(notificationId);
|
||||
notification.value = data;
|
||||
|
||||
console.log("Retrieved notification data:", data);
|
||||
|
||||
// Transform channels data from backend format to frontend format
|
||||
let channels = [];
|
||||
if (Array.isArray(data.channels)) {
|
||||
channels = data.channels;
|
||||
} else if (Array.isArray(data.notification_channels)) {
|
||||
channels = data.notification_channels.map((c) => c.channel_type);
|
||||
}
|
||||
|
||||
// Populate form with existing data
|
||||
notificationForm.value = {
|
||||
title: data.title,
|
||||
type: data.type || "single",
|
||||
priority: data.priority,
|
||||
category: data.category?.value,
|
||||
channels: channels,
|
||||
emailSubject: data.emailSubject,
|
||||
expiresAt: data.expiresAt,
|
||||
deliveryType: data.deliveryType,
|
||||
scheduledAt: data.scheduledAt,
|
||||
timezone: data.timezone || "UTC",
|
||||
audienceType: data.audienceType,
|
||||
specificUsers: data.specificUsers,
|
||||
userSegments: data.userSegments || [],
|
||||
userStatus: data.userStatus,
|
||||
registrationPeriod: data.registrationPeriod,
|
||||
excludeUnsubscribed: data.excludeUnsubscribed !== false,
|
||||
};
|
||||
|
||||
console.log("Populated form:", notificationForm.value);
|
||||
|
||||
// Update estimated reach
|
||||
estimatedReach.value = data.analytics?.estimatedReach || 0;
|
||||
} catch (error) {
|
||||
console.error("Error loading notification:", error);
|
||||
notification.value = null;
|
||||
nuxtApp.$swal
|
||||
.fire({
|
||||
title: "Error",
|
||||
text: "Failed to load notification details",
|
||||
icon: "error",
|
||||
confirmButtonText: "Back to List",
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
router.push("/notification/list");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for audience type changes to update estimated reach
|
||||
watch(
|
||||
() => [
|
||||
notificationForm.value.audienceType,
|
||||
notificationForm.value.specificUsers,
|
||||
notificationForm.value.userSegments,
|
||||
notificationForm.value.userStatus,
|
||||
notificationForm.value.registrationPeriod,
|
||||
notificationForm.value.excludeUnsubscribed,
|
||||
notificationForm.value.channels,
|
||||
],
|
||||
async () => {
|
||||
try {
|
||||
// Skip if audience type is not set
|
||||
if (!notificationForm.value.audienceType) return;
|
||||
|
||||
const response = await getAudiencePreview({
|
||||
audienceType: notificationForm.value.audienceType,
|
||||
specificUsers: notificationForm.value.specificUsers,
|
||||
userSegments: notificationForm.value.userSegments || [],
|
||||
userStatus: notificationForm.value.userStatus,
|
||||
registrationPeriod: notificationForm.value.registrationPeriod,
|
||||
excludeUnsubscribed: notificationForm.value.excludeUnsubscribed,
|
||||
channels: notificationForm.value.channels,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
estimatedReach.value = response.data.totalCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting audience preview:", error);
|
||||
// Don't show error to user for background calculation
|
||||
estimatedReach.value = 0;
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadNotification();
|
||||
});
|
||||
</script>
|
||||
656
pages/notification/list/index.vue
Normal file
656
pages/notification/list/index.vue
Normal file
@@ -0,0 +1,656 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Info Card -->
|
||||
<rs-card class="mb-5">
|
||||
<template #header>
|
||||
<div class="flex">
|
||||
<Icon class="mr-2 flex justify-center" name="ic:outline-info"></Icon>
|
||||
Info
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="mb-4">
|
||||
View and manage all created notifications. Monitor their status, delivery
|
||||
progress, and performance metrics. You can create new notifications, edit
|
||||
existing ones, or view detailed analytics.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Notification List</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="pt-2">
|
||||
<!-- Create Button -->
|
||||
<div class="flex justify-end items-center mb-6">
|
||||
<rs-button @click="$router.push('/notification/create')">
|
||||
<Icon name="material-symbols:add" class="mr-1"></Icon>
|
||||
Create Notification
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
v-model="filters.status"
|
||||
class="w-full border border-gray-300 dark:border-gray-700 rounded-md p-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option
|
||||
v-for="option in statusOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
v-model="filters.priority"
|
||||
class="w-full border border-gray-300 dark:border-gray-700 rounded-md p-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
v-model="filters.category"
|
||||
class="w-full border border-gray-300 dark:border-gray-700 rounded-md p-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option
|
||||
v-for="option in categoryOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Search
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
v-model="filters.search"
|
||||
placeholder="Search notifications..."
|
||||
class="w-full border border-gray-300 dark:border-gray-700 rounded-md p-2 pl-9 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:search"
|
||||
class="text-gray-500"
|
||||
size="16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<rs-button variant="outline" @click="clearFilters" class="mr-2">
|
||||
<Icon name="material-symbols:refresh" class="mr-1" />
|
||||
Clear
|
||||
</rs-button>
|
||||
<rs-button @click="applyFilters">
|
||||
<Icon name="material-symbols:filter-alt" class="mr-1" />
|
||||
Apply Filters
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="mb-6">
|
||||
<rs-alert variant="danger" :dismissible="true" @dismiss="error = null">
|
||||
<template #title>Error</template>
|
||||
{{ error }}
|
||||
</rs-alert>
|
||||
</div>
|
||||
|
||||
<!-- Notifications Table -->
|
||||
<rs-table
|
||||
v-if="notificationList && notificationList.length > 0"
|
||||
:data="notificationList"
|
||||
:fields="tableFields"
|
||||
:options="{
|
||||
variant: 'default',
|
||||
striped: true,
|
||||
borderless: true,
|
||||
class: 'align-middle',
|
||||
serverSort: true,
|
||||
sortBy: sortBy,
|
||||
sortDesc: sortOrder === 'desc',
|
||||
noSort: false
|
||||
}"
|
||||
advanced
|
||||
@sort-changed="handleSort"
|
||||
>
|
||||
<!-- Title column with icon -->
|
||||
<template v-slot:title="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<div class="font-semibold">{{ value.title }}</div>
|
||||
<div class="text-xs text-gray-500">{{ value.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Channels column with icons -->
|
||||
<template v-slot:channels="{ value }">
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<template v-for="channel in value.channels" :key="channel">
|
||||
<span :title="channel" class="flex items-center gap-1">
|
||||
<Icon
|
||||
:name="getChannelIcon(channel)"
|
||||
class="text-gray-700 dark:text-gray-300"
|
||||
size="16"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Priority column with badges -->
|
||||
<template v-slot:priority="{ text }">
|
||||
<rs-badge :variant="getPriorityVariant(text)">
|
||||
{{ text }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<!-- Status column with badges -->
|
||||
<template v-slot:status="{ text }">
|
||||
<rs-badge :variant="getStatusVariant(text)">
|
||||
{{ text }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<!-- Recipients column with formatting -->
|
||||
<template v-slot:recipients="{ text }">
|
||||
<span class="font-medium">{{ formatNumber(text) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Created At column with relative time -->
|
||||
<template v-slot:createdAt="{ text }">
|
||||
<div>
|
||||
<div class="text-sm">{{ formatDate(text) }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatTimeAgo(text) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Actions column -->
|
||||
<template v-slot:action="{ value }">
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<span title="View Details">
|
||||
<Icon
|
||||
name="ic:outline-visibility"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="20"
|
||||
@click="viewNotification(value)"
|
||||
/>
|
||||
</span>
|
||||
<span title="Edit">
|
||||
<Icon
|
||||
name="material-symbols:edit-outline-rounded"
|
||||
class="text-blue-600 hover:text-blue-700 cursor-pointer"
|
||||
size="20"
|
||||
@click="editNotification(value)"
|
||||
/>
|
||||
</span>
|
||||
<span title="Delete">
|
||||
<Icon
|
||||
name="material-symbols:delete-outline-rounded"
|
||||
class="text-red-500 hover:text-red-600 cursor-pointer"
|
||||
size="20"
|
||||
@click="handleDeleteNotification(value)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</rs-table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="pagination && pagination.totalPages > 1"
|
||||
class="flex justify-center mt-6"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="handlePageChange(1)"
|
||||
:disabled="pagination.page === 1"
|
||||
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<Icon name="material-symbols:first-page" size="16" />
|
||||
</button>
|
||||
<button
|
||||
@click="handlePageChange(pagination.page - 1)"
|
||||
:disabled="!pagination.hasPrevPage"
|
||||
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<Icon name="material-symbols:chevron-left" size="16" />
|
||||
</button>
|
||||
|
||||
<template v-for="p in pagination.totalPages" :key="p">
|
||||
<button
|
||||
v-if="
|
||||
p === 1 ||
|
||||
p === pagination.totalPages ||
|
||||
(p >= pagination.page - 1 && p <= pagination.page + 1)
|
||||
"
|
||||
@click="handlePageChange(p)"
|
||||
:class="[
|
||||
'px-3 py-1 rounded',
|
||||
pagination.page === p
|
||||
? 'bg-primary text-white'
|
||||
: 'border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
]"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="
|
||||
(p === pagination.page - 2 && p > 1) ||
|
||||
(p === pagination.page + 2 && p < pagination.totalPages)
|
||||
"
|
||||
class="px-1"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<button
|
||||
@click="handlePageChange(pagination.page + 1)"
|
||||
:disabled="!pagination.hasNextPage"
|
||||
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<Icon name="material-symbols:chevron-right" size="16" />
|
||||
</button>
|
||||
<button
|
||||
@click="handlePageChange(pagination.totalPages)"
|
||||
:disabled="pagination.page === pagination.totalPages"
|
||||
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<Icon name="material-symbols:last-page" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="notificationList && notificationList.length === 0 && !isLoading"
|
||||
class="text-center py-12"
|
||||
>
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon
|
||||
name="ic:outline-notifications-none"
|
||||
size="4rem"
|
||||
class="text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-500 mb-2">No Notifications Found</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
Create your first notification to get started.
|
||||
</p>
|
||||
<rs-button @click="$router.push('/notification/create')">
|
||||
<Icon name="material-symbols:add" class="mr-1"></Icon>
|
||||
Create First Notification
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-12">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon
|
||||
name="ic:outline-refresh"
|
||||
size="2rem"
|
||||
class="text-primary animate-spin"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-gray-600">Loading notifications...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useNotifications } from "~/composables/useNotifications";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
definePageMeta({
|
||||
title: "Notification List",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "List",
|
||||
path: "/notification/list",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Use the notifications composable
|
||||
const {
|
||||
isLoading,
|
||||
notifications,
|
||||
pagination,
|
||||
error,
|
||||
fetchNotifications,
|
||||
deleteNotification,
|
||||
} = useNotifications();
|
||||
|
||||
// Use computed for the notification list to maintain reactivity
|
||||
const notificationList = computed(() => notifications.value);
|
||||
|
||||
// Filter states
|
||||
const filters = ref({
|
||||
status: "",
|
||||
priority: "",
|
||||
category: "",
|
||||
search: "",
|
||||
});
|
||||
|
||||
const currentPage = ref(1);
|
||||
const itemsPerPage = ref(10);
|
||||
// Current variables and default values for sorting
|
||||
const sortBy = ref("createdAt"); // Use camelCase to match frontend data format
|
||||
const sortOrder = ref("desc");
|
||||
|
||||
// Table field configuration
|
||||
const tableFields = [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Notification',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'channels',
|
||||
label: 'Channels',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
label: 'Priority',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'recipients',
|
||||
label: 'Recipients',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Actions',
|
||||
sortable: false
|
||||
}
|
||||
];
|
||||
|
||||
// Options for filters
|
||||
const statusOptions = [
|
||||
{ label: "All Statuses", value: "" },
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Scheduled", value: "scheduled" },
|
||||
{ label: "Sending", value: "sending" },
|
||||
{ label: "Sent", value: "sent" },
|
||||
{ label: "Failed", value: "failed" },
|
||||
{ label: "Cancelled", value: "cancelled" },
|
||||
];
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: "All Priorities", value: "" },
|
||||
{ label: "Low", value: "low" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Critical", value: "critical" },
|
||||
];
|
||||
|
||||
const categoryOptions = ref([]);
|
||||
|
||||
// Fetch categories
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await $fetch("/api/notifications/categories");
|
||||
if (response.success) {
|
||||
categoryOptions.value = [
|
||||
{ label: "All Categories", value: "" },
|
||||
...response.data.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.value,
|
||||
})),
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching categories:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const getChannelIcon = (channel) => {
|
||||
const icons = {
|
||||
email: "material-symbols:mail-outline-rounded",
|
||||
push: "material-symbols:notifications-active-outline-rounded",
|
||||
sms: "material-symbols:sms-outline-rounded",
|
||||
"in-app": "material-symbols:chat-bubble-outline-rounded",
|
||||
};
|
||||
return icons[channel] || "material-symbols:help-outline-rounded";
|
||||
};
|
||||
|
||||
const getPriorityVariant = (priority) => {
|
||||
const variants = {
|
||||
low: "info",
|
||||
medium: "primary",
|
||||
high: "warning",
|
||||
critical: "danger",
|
||||
};
|
||||
return variants[priority] || "primary";
|
||||
};
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
const variants = {
|
||||
draft: "secondary",
|
||||
scheduled: "info",
|
||||
sending: "warning",
|
||||
sent: "success",
|
||||
failed: "danger",
|
||||
cancelled: "dark",
|
||||
};
|
||||
return variants[status] || "secondary";
|
||||
};
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num === undefined || num === null) return "0";
|
||||
return Number(num).toLocaleString();
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString) => {
|
||||
if (!dateString) return "";
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
const diffHour = Math.round(diffMin / 60);
|
||||
const diffDay = Math.round(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return `${diffSec} sec ago`;
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
if (diffHour < 24) return `${diffHour} hr ago`;
|
||||
if (diffDay < 30) return `${diffDay} days ago`;
|
||||
|
||||
return formatDate(dateString);
|
||||
};
|
||||
|
||||
// Actions
|
||||
const viewNotification = (notification) => {
|
||||
router.push(`/notification/view/${notification.id}`);
|
||||
};
|
||||
|
||||
const editNotification = (notification) => {
|
||||
router.push(`/notification/edit/${notification.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteNotification = async (notification) => {
|
||||
const { $swal } = useNuxtApp();
|
||||
|
||||
try {
|
||||
const result = await $swal.fire({
|
||||
title: "Are you sure?",
|
||||
text: "You won't be able to revert this!",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, delete it!",
|
||||
cancelButtonText: "Cancel",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
await deleteNotification(notification.id);
|
||||
await $swal.fire("Deleted!", "The notification has been deleted.", "success");
|
||||
}
|
||||
} catch (err) {
|
||||
// Get the specific error message from the server
|
||||
const errorMessage =
|
||||
err.data?.message ||
|
||||
err.data?.statusMessage ||
|
||||
err.message ||
|
||||
"Failed to delete notification";
|
||||
await $swal.fire("Error", errorMessage, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch data with filters
|
||||
const loadData = async () => {
|
||||
try {
|
||||
await fetchNotifications({
|
||||
page: currentPage.value,
|
||||
limit: itemsPerPage.value,
|
||||
status: filters.value.status,
|
||||
priority: filters.value.priority,
|
||||
category: filters.value.category,
|
||||
search: filters.value.search,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error loading notifications:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = (page) => {
|
||||
currentPage.value = page;
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Handle filter changes
|
||||
const applyFilters = () => {
|
||||
currentPage.value = 1; // Reset to first page
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Handle sort changes
|
||||
const handleSort = (sortInfo) => {
|
||||
// Handle both direct column string and sort event object
|
||||
if (typeof sortInfo === 'string') {
|
||||
// Direct column name (legacy)
|
||||
if (sortBy.value === sortInfo) {
|
||||
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortBy.value = sortInfo;
|
||||
sortOrder.value = "desc"; // Default to descending
|
||||
}
|
||||
} else if (sortInfo && sortInfo.sortBy) {
|
||||
// Sort event object from table component
|
||||
sortBy.value = sortInfo.sortBy;
|
||||
sortOrder.value = sortInfo.sortDesc ? "desc" : "asc";
|
||||
}
|
||||
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
filters.value = {
|
||||
status: "",
|
||||
priority: "",
|
||||
category: "",
|
||||
search: "",
|
||||
};
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
// Life cycle hooks
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchCategories(), loadData()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom styles if needed */
|
||||
</style>
|
||||
329
pages/notification/log-audit/analytics.vue
Normal file
329
pages/notification/log-audit/analytics.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Info Card -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-bar-chart"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Analytics Dashboard</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
View metrics for notification performance, delivery rates, and user engagement.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Key Metrics Summary -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(metric, index) in keyMetrics"
|
||||
:key="index"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl"
|
||||
>
|
||||
<Icon class="text-primary text-3xl" :name="metric.icon"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-2xl leading-tight text-primary">
|
||||
{{ metric.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ metric.title }}
|
||||
</span>
|
||||
<div class="flex items-center mt-1" v-if="metric.change">
|
||||
<Icon
|
||||
:name="metric.trend === 'up' ? 'ic:outline-trending-up' : 'ic:outline-trending-down'"
|
||||
:class="metric.trend === 'up' ? 'text-green-500' : 'text-red-500'"
|
||||
class="text-sm mr-1"
|
||||
/>
|
||||
<span
|
||||
:class="metric.trend === 'up' ? 'text-green-600' : 'text-red-600'"
|
||||
class="text-xs font-medium"
|
||||
>
|
||||
{{ metric.change }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Time Range Filter -->
|
||||
<rs-card class="mb-6">
|
||||
<template #body>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="text-lg font-semibold text-primary">Analytics Period</h3>
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
outer-class="mb-0"
|
||||
@input="updateAnalytics"
|
||||
/>
|
||||
</div>
|
||||
<rs-button variant="primary-outline" size="sm" @click="refreshAnalytics">
|
||||
<Icon name="ic:outline-refresh" class="mr-1"/> Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<div v-if="analyticsLoading" class="flex justify-center py-16">
|
||||
<Loading />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Main Analytics Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-bar-chart" class="mr-2 text-primary"/>
|
||||
<h3 class="text-lg font-semibold text-primary">Delivery Rate Analysis</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="h-64 bg-gray-100 dark:bg-gray-800 flex items-center justify-center rounded">
|
||||
<div class="text-center">
|
||||
<p class="text-gray-500">Delivery success over time</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-green-600">{{ channelPerformance[0]?.successRate || '0' }}%</div>
|
||||
<div class="text-sm text-gray-600">Success Rate</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-red-600">{{ channelPerformance[0]?.failureRate || '0' }}%</div>
|
||||
<div class="text-sm text-gray-600">Failed Rate</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-yellow-600">{{ channelPerformance[0]?.bounceRate || '0' }}%</div>
|
||||
<div class="text-sm text-gray-600">Bounce Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-device-hub" class="mr-2 text-primary"/>
|
||||
<h3 class="text-lg font-semibold text-primary">Channel Performance</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="channel in channelPerformance"
|
||||
:key="channel.name"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Icon :name="channel.icon" class="mr-2 text-primary"/>
|
||||
<span class="font-medium">{{ channel.name }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-primary">{{ channel.successRate }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-primary h-2 rounded-full"
|
||||
:style="{ width: channel.successRate + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>{{ channel.sent }} sent</span>
|
||||
<span>{{ channel.failed }} failed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Recent Analytics Events -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-insights" class="mr-2 text-primary"/>
|
||||
<h3 class="text-lg font-semibold text-primary">Recent Events</h3>
|
||||
</div>
|
||||
<rs-button variant="outline" size="sm" @click="navigateTo('/notification/log-audit/logs')">
|
||||
View All Logs
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="recentEvents.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(event, index) in recentEvents"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full mr-3"
|
||||
:class="{
|
||||
'bg-green-500': event.type === 'success',
|
||||
'bg-yellow-500': event.type === 'warning',
|
||||
'bg-red-500': event.type === 'error',
|
||||
'bg-blue-500': event.type === 'info',
|
||||
}"
|
||||
></div>
|
||||
<div>
|
||||
<p class="font-medium">{{ event.title }}</p>
|
||||
<p class="text-sm text-gray-600">{{ event.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium">{{ event.value }}</p>
|
||||
<p class="text-xs text-gray-500">{{ event.time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
<Icon name="ic:outline-search-off" class="text-4xl mb-2 mx-auto" />
|
||||
<p>No recent events found.</p>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Analytics Dashboard",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Logs & Audit Trail",
|
||||
path: "/notification/log-audit",
|
||||
},
|
||||
{
|
||||
name: "Analytics",
|
||||
path: "/notification/log-audit/analytics",
|
||||
type: "current"
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
// Use the notification logs composable
|
||||
const {
|
||||
analyticsData,
|
||||
analyticsLoading,
|
||||
analyticsError,
|
||||
fetchAnalytics,
|
||||
formatDate
|
||||
} = useNotificationLogs()
|
||||
|
||||
// Time period selection
|
||||
const selectedPeriod = ref('7d')
|
||||
const periodOptions = [
|
||||
{ label: 'Last 24 Hours', value: '1d' },
|
||||
{ label: 'Last 7 Days', value: '7d' },
|
||||
{ label: 'Last 30 Days', value: '30d' },
|
||||
{ label: 'Last 90 Days', value: '90d' },
|
||||
{ label: 'Last 12 Months', value: '12m' },
|
||||
]
|
||||
|
||||
// Key metrics data - will be updated from API
|
||||
const keyMetrics = computed(() => analyticsData.value?.keyMetrics || [
|
||||
{
|
||||
title: "Total Sent",
|
||||
value: "0",
|
||||
icon: "ic:outline-send",
|
||||
trend: "up",
|
||||
change: "+0.0%"
|
||||
},
|
||||
{
|
||||
title: "Success Rate",
|
||||
value: "0.0%",
|
||||
icon: "ic:outline-check-circle",
|
||||
trend: "up",
|
||||
change: "+0.0%"
|
||||
},
|
||||
{
|
||||
title: "Open Rate",
|
||||
value: "0.0%",
|
||||
icon: "ic:outline-open-in-new",
|
||||
trend: "up",
|
||||
change: "+0.0%"
|
||||
},
|
||||
{
|
||||
title: "Click Rate",
|
||||
value: "0.0%",
|
||||
icon: "ic:outline-touch-app",
|
||||
trend: "up",
|
||||
change: "+0.0%"
|
||||
},
|
||||
])
|
||||
|
||||
// Channel performance data - will be updated from API
|
||||
const channelPerformance = computed(() => analyticsData.value?.channelPerformance || [
|
||||
{
|
||||
name: "Email",
|
||||
icon: "ic:outline-email",
|
||||
successRate: "0",
|
||||
sent: "0",
|
||||
failed: "0",
|
||||
bounceRate: "0",
|
||||
failureRate: "0"
|
||||
},
|
||||
])
|
||||
|
||||
// Recent analytics events from API
|
||||
const recentEvents = computed(() => analyticsData.value?.recentEvents || [])
|
||||
|
||||
// Methods
|
||||
const updateAnalytics = async () => {
|
||||
await fetchAnalytics(selectedPeriod.value)
|
||||
}
|
||||
|
||||
const refreshAnalytics = async () => {
|
||||
await fetchAnalytics(selectedPeriod.value)
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
onMounted(async () => {
|
||||
await fetchAnalytics(selectedPeriod.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.formkit-outer) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.formkit-label) {
|
||||
font-weight: 500;
|
||||
color: rgb(107 114 128);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.formkit-input) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
382
pages/notification/log-audit/index.vue
Normal file
382
pages/notification/log-audit/index.vue
Normal file
@@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Info Card -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-assessment"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Notification Logs & Audit Trail</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Track notification activities, monitor performance, and ensure compliance with
|
||||
detailed audit trails.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card v-for="(item, index) in summaryStats" :key="index">
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl">
|
||||
<Icon class="text-primary text-3xl" :name="item.icon"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-2xl leading-tight text-primary">
|
||||
{{ item.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="cursor-pointer"
|
||||
@click="navigateTo(feature.path)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" :name="feature.icon"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">{{ feature.title }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600 mb-4">{{ feature.description }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<rs-button variant="outline" size="sm">
|
||||
<Icon class="mr-1" name="ic:outline-arrow-forward"></Icon>
|
||||
Open
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Recent Logs Section -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">Recent Logs</h3>
|
||||
</div>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="navigateTo('/notification/log-audit/logs')"
|
||||
>
|
||||
View All
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<Loading />
|
||||
</div>
|
||||
|
||||
<rs-table
|
||||
v-else-if="logs && logs.length > 0"
|
||||
:data="logs"
|
||||
:field="logTableFields"
|
||||
:options="{
|
||||
variant: 'default',
|
||||
striped: true,
|
||||
borderless: false,
|
||||
hover: true,
|
||||
}"
|
||||
:options-advanced="{
|
||||
sortable: true,
|
||||
responsive: true,
|
||||
outsideBorder: true,
|
||||
}"
|
||||
advanced
|
||||
>
|
||||
<template #timestamp="{ value }">
|
||||
<div class="text-sm">
|
||||
<div class="font-medium">{{ formatDate(value.created_at, true) }}</div>
|
||||
<div class="text-gray-500 text-xs">
|
||||
{{ new Date(value.created_at).toLocaleTimeString() }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #status="{ value }">
|
||||
<rs-badge
|
||||
:variant="
|
||||
value.status === 'Sent'
|
||||
? 'success'
|
||||
: value.status === 'Failed'
|
||||
? 'danger'
|
||||
: value.status === 'Opened'
|
||||
? 'info'
|
||||
: value.status === 'Queued'
|
||||
? 'warning'
|
||||
: 'secondary'
|
||||
"
|
||||
size="sm"
|
||||
>
|
||||
{{ value.status }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<template #actions="{ value }">
|
||||
<rs-button variant="primary-text" size="sm" @click="viewLogDetails(value)">
|
||||
<Icon name="ic:outline-visibility" class="mr-1" /> View
|
||||
</rs-button>
|
||||
</template>
|
||||
</rs-table>
|
||||
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
<Icon name="ic:outline-search-off" class="text-4xl mb-2 mx-auto" />
|
||||
<p>No log entries found.</p>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Log Details Modal -->
|
||||
<rs-modal
|
||||
v-model="isLogDetailModalOpen"
|
||||
title="Log Entry Details"
|
||||
size="lg"
|
||||
:overlay-close="true"
|
||||
>
|
||||
<template #body>
|
||||
<div v-if="selectedLog" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Log ID</label>
|
||||
<p class="font-mono text-sm">{{ selectedLog.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1"
|
||||
>Timestamp</label
|
||||
>
|
||||
<p class="text-sm">{{ formatDate(selectedLog.created_at, true) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Action</label>
|
||||
<p class="text-sm">{{ selectedLog.action }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Actor</label>
|
||||
<p class="text-sm">{{ selectedLog.actor }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Channel</label>
|
||||
<p class="text-sm">{{ selectedLog.channel_type }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Status</label>
|
||||
<rs-badge
|
||||
:variant="
|
||||
selectedLog.status === 'Sent'
|
||||
? 'success'
|
||||
: selectedLog.status === 'Failed'
|
||||
? 'danger'
|
||||
: selectedLog.status === 'Opened'
|
||||
? 'info'
|
||||
: selectedLog.status === 'Queued'
|
||||
? 'warning'
|
||||
: 'secondary'
|
||||
"
|
||||
size="sm"
|
||||
>
|
||||
{{ selectedLog.status }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1"
|
||||
>Source IP</label
|
||||
>
|
||||
<p class="text-sm font-mono">{{ selectedLog.source_ip }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Details</label>
|
||||
<p class="text-sm p-3 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
{{ selectedLog.details }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-if="selectedLog.status === 'Failed'">
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="text-lg font-medium text-red-600 dark:text-red-400 mb-3">
|
||||
Error Information
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1"
|
||||
>Error Code</label
|
||||
>
|
||||
<p class="text-sm font-mono text-red-600 dark:text-red-400">
|
||||
{{ selectedLog.error_code }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1"
|
||||
>Error Message</label
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ selectedLog.error_message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<rs-button @click="isLogDetailModalOpen = false" variant="primary-outline">
|
||||
Close
|
||||
</rs-button>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Notification Logs & Audit Trail",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Logs & Audit Trail",
|
||||
path: "/notification/log-audit",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
|
||||
// Use the notification logs composable
|
||||
const {
|
||||
logs,
|
||||
loading,
|
||||
error,
|
||||
summaryStats: rawSummaryStats,
|
||||
fetchLogs,
|
||||
formatDate,
|
||||
} = useNotificationLogs();
|
||||
|
||||
// Transform summary stats from object to array format for display
|
||||
const summaryStats = computed(() => {
|
||||
if (!rawSummaryStats.value) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: "Total Logs",
|
||||
value: rawSummaryStats.value.totalLogs || 0,
|
||||
icon: "ic:outline-article",
|
||||
},
|
||||
{
|
||||
title: "Successful Deliveries",
|
||||
value: rawSummaryStats.value.successfulDeliveries || 0,
|
||||
icon: "ic:outline-mark-email-read",
|
||||
},
|
||||
{
|
||||
title: "Failed Deliveries",
|
||||
value: rawSummaryStats.value.failedDeliveries || 0,
|
||||
icon: "ic:outline-error-outline",
|
||||
},
|
||||
{
|
||||
title: "Success Rate",
|
||||
value: `${rawSummaryStats.value.successRate || 0}%`,
|
||||
icon: "ic:outline-insights",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// Navigation features
|
||||
const features = ref([
|
||||
{
|
||||
title: "Analytics Dashboard",
|
||||
description:
|
||||
"View metrics and trends for notification performance and delivery rates.",
|
||||
icon: "ic:outline-bar-chart",
|
||||
path: "/notification/log-audit/analytics",
|
||||
},
|
||||
{
|
||||
title: "Audit Logs",
|
||||
description:
|
||||
"Detailed logs of all notification activities with filtering capabilities.",
|
||||
icon: "ic:outline-list-alt",
|
||||
path: "/notification/log-audit/logs",
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
description: "Generate and export reports for compliance and analysis purposes.",
|
||||
icon: "ic:outline-file-download",
|
||||
path: "/notification/log-audit/reports",
|
||||
},
|
||||
]);
|
||||
|
||||
// Fields for RsTable
|
||||
const logTableFields = [
|
||||
"timestamp",
|
||||
"action",
|
||||
"actor",
|
||||
"channel_type",
|
||||
"status",
|
||||
"actions",
|
||||
];
|
||||
|
||||
// Log detail modal
|
||||
const isLogDetailModalOpen = ref(false);
|
||||
const selectedLog = ref(null);
|
||||
|
||||
const viewLogDetails = (log) => {
|
||||
selectedLog.value = log;
|
||||
isLogDetailModalOpen.value = true;
|
||||
};
|
||||
|
||||
// Fetch logs on component mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fetchLogs();
|
||||
} catch (err) {
|
||||
console.error("Error loading logs:", err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.formkit-outer) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.formkit-label) {
|
||||
font-weight: 500;
|
||||
color: rgb(107 114 128);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.formkit-input) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
410
pages/notification/log-audit/logs.vue
Normal file
410
pages/notification/log-audit/logs.vue
Normal file
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Info Card -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-list-alt"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Audit Logs</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
View notification logs and filter by date, channel, status, or content.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(item, index) in summaryStats"
|
||||
:key="index"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl"
|
||||
>
|
||||
<Icon class="text-primary text-3xl" :name="item.icon"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-2xl leading-tight text-primary">
|
||||
{{ item.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">All Audit Logs</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-button variant="primary-outline" size="sm" @click="refreshLogs">
|
||||
<Icon name="ic:outline-refresh" class="mr-1"/> Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<!-- Filters Section -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Actor"
|
||||
v-model="filters.actor"
|
||||
placeholder="Enter user or actor"
|
||||
outer-class="mb-0"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Channel"
|
||||
v-model="filters.channel"
|
||||
placeholder="Select channel"
|
||||
:options="availableChannels"
|
||||
outer-class="mb-0"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Status"
|
||||
v-model="filters.status"
|
||||
placeholder="Select status"
|
||||
:options="availableStatuses"
|
||||
outer-class="mb-0"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="search"
|
||||
label="Search"
|
||||
v-model="filters.keyword"
|
||||
placeholder="Search in logs..."
|
||||
outer-class="mb-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<div class="text-sm text-gray-600" v-if="!loading">
|
||||
Showing {{ logs.length }} entries
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<rs-button @click="clearFilters" variant="secondary-outline" size="sm">
|
||||
<Icon name="ic:outline-clear" class="mr-1"/> Clear
|
||||
</rs-button>
|
||||
<rs-button @click="applyFilters(filters)" variant="primary">
|
||||
<Icon name="ic:outline-search" class="mr-1"/> Apply
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<Loading />
|
||||
</div>
|
||||
|
||||
<!-- Log Table -->
|
||||
<rs-table
|
||||
v-else-if="logs && logs.length > 0"
|
||||
:data="logs"
|
||||
:field="logTableFields"
|
||||
:options="{
|
||||
variant: 'default',
|
||||
striped: true,
|
||||
borderless: false,
|
||||
hover: true
|
||||
}"
|
||||
:options-advanced="{
|
||||
sortable: true,
|
||||
responsive: true,
|
||||
outsideBorder: true
|
||||
}"
|
||||
advanced
|
||||
>
|
||||
<template #timestamp="{ value }">
|
||||
<div class="text-sm">
|
||||
<div class="font-medium">{{ formatDate(value.created_at, true) }}</div>
|
||||
<div class="text-gray-500 text-xs">{{ new Date(value.created_at).toLocaleTimeString() }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #status="{ value }">
|
||||
<rs-badge
|
||||
:variant="value.status === 'Sent' ? 'success' :
|
||||
value.status === 'Failed' ? 'danger' :
|
||||
value.status === 'Opened' ? 'info' :
|
||||
value.status === 'Queued' ? 'warning' : 'secondary'"
|
||||
size="sm"
|
||||
>
|
||||
{{ value.status }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<template #actions="{ value }">
|
||||
<rs-button variant="primary-text" size="sm" @click="viewLogDetails(value)">
|
||||
<Icon name="ic:outline-visibility" class="mr-1"/> View
|
||||
</rs-button>
|
||||
</template>
|
||||
</rs-table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
<Icon name="ic:outline-search-off" class="text-4xl mb-2 mx-auto"/>
|
||||
<p>No log entries found matching your filters.</p>
|
||||
<rs-button variant="primary-outline" @click="clearFilters" class="mt-4">
|
||||
Clear Filters
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="logs && logs.length > 0" class="flex justify-center mt-6">
|
||||
<div class="flex items-center gap-1">
|
||||
<rs-button
|
||||
variant="primary-text"
|
||||
size="sm"
|
||||
:disabled="pagination.page === 1"
|
||||
@click="changePage(pagination.page - 1)"
|
||||
>
|
||||
<Icon name="ic:outline-chevron-left"/>
|
||||
</rs-button>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<rs-button
|
||||
v-for="p in paginationButtons"
|
||||
:key="p"
|
||||
variant="primary-text"
|
||||
size="sm"
|
||||
:class="pagination.page === p ? 'bg-primary/10' : ''"
|
||||
@click="changePage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<rs-button
|
||||
variant="primary-text"
|
||||
size="sm"
|
||||
:disabled="pagination.page === pagination.pages"
|
||||
@click="changePage(pagination.page + 1)"
|
||||
>
|
||||
<Icon name="ic:outline-chevron-right"/>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Log Details Modal -->
|
||||
<rs-modal
|
||||
v-model="isLogDetailModalOpen"
|
||||
title="Log Entry Details"
|
||||
size="lg"
|
||||
:overlay-close="true"
|
||||
>
|
||||
<template #body>
|
||||
<div v-if="selectedLog" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Log ID</label>
|
||||
<p class="font-mono text-sm">{{ selectedLog.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Timestamp</label>
|
||||
<p class="text-sm">{{ formatDate(selectedLog.created_at, true) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Action</label>
|
||||
<p class="text-sm">{{ selectedLog.action }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Actor</label>
|
||||
<p class="text-sm">{{ selectedLog.actor }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Channel</label>
|
||||
<p class="text-sm">{{ selectedLog.channel_type }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Status</label>
|
||||
<rs-badge
|
||||
:variant="selectedLog.status === 'Sent' ? 'success' :
|
||||
selectedLog.status === 'Failed' ? 'danger' :
|
||||
selectedLog.status === 'Opened' ? 'info' :
|
||||
selectedLog.status === 'Queued' ? 'warning' : 'secondary'"
|
||||
size="sm"
|
||||
>
|
||||
{{ selectedLog.status }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Source IP</label>
|
||||
<p class="text-sm font-mono">{{ selectedLog.source_ip }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Details</label>
|
||||
<p class="text-sm p-3 bg-gray-50 dark:bg-gray-800 rounded">{{ selectedLog.details }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="selectedLog.status === 'Failed'">
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="text-lg font-medium text-red-600 dark:text-red-400 mb-3">Error Information</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Error Code</label>
|
||||
<p class="text-sm font-mono text-red-600 dark:text-red-400">{{ selectedLog.error_code }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 mb-1">Error Message</label>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ selectedLog.error_message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<rs-button @click="isLogDetailModalOpen = false" variant="primary-outline">
|
||||
Close
|
||||
</rs-button>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Audit Logs",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Logs & Audit Trail",
|
||||
path: "/notification/log-audit",
|
||||
},
|
||||
{
|
||||
name: "Audit Logs",
|
||||
path: "/notification/log-audit/logs",
|
||||
type: "current"
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
// Use the notification logs composable
|
||||
const {
|
||||
logs,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
summaryStats,
|
||||
filters,
|
||||
fetchLogs,
|
||||
applyFilters,
|
||||
clearFilters,
|
||||
changePage,
|
||||
formatDate,
|
||||
availableActions,
|
||||
availableChannels,
|
||||
availableStatuses
|
||||
} = useNotificationLogs()
|
||||
|
||||
// Computed property for pagination buttons
|
||||
const paginationButtons = computed(() => {
|
||||
const current = pagination.value.page
|
||||
const total = pagination.value.pages
|
||||
const buttons = []
|
||||
|
||||
if (total <= 7) {
|
||||
// Less than 7 pages, show all
|
||||
for (let i = 1; i <= total; i++) {
|
||||
buttons.push(i)
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
buttons.push(1)
|
||||
|
||||
if (current > 3) {
|
||||
buttons.push('...')
|
||||
}
|
||||
|
||||
// Pages around current
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(current + 1, total - 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
buttons.push(i)
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
buttons.push('...')
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
buttons.push(total)
|
||||
}
|
||||
|
||||
return buttons
|
||||
})
|
||||
|
||||
// Fields for RsTable
|
||||
const logTableFields = ['timestamp', 'action', 'actor', 'channel_type', 'status', 'actions']
|
||||
|
||||
// Log detail modal
|
||||
const isLogDetailModalOpen = ref(false)
|
||||
const selectedLog = ref(null)
|
||||
|
||||
const viewLogDetails = (log) => {
|
||||
selectedLog.value = log
|
||||
isLogDetailModalOpen.value = true
|
||||
}
|
||||
|
||||
const refreshLogs = async () => {
|
||||
await fetchLogs()
|
||||
}
|
||||
|
||||
// Fetch logs on component mount
|
||||
onMounted(async () => {
|
||||
await fetchLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.formkit-outer) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.formkit-label) {
|
||||
font-weight: 500;
|
||||
color: rgb(107 114 128);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.formkit-input) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
689
pages/notification/log-audit/monitoring.vue
Normal file
689
pages/notification/log-audit/monitoring.vue
Normal file
@@ -0,0 +1,689 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Info Card -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-monitor"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Real-Time Monitoring</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Live monitoring of notification system performance with real-time alerts and
|
||||
system health indicators. Track ongoing activities, monitor system load, and
|
||||
receive immediate notifications about issues.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- System Status Overview -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(status, index) in systemStatus"
|
||||
:key="index"
|
||||
class="transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-5 flex justify-center items-center rounded-2xl transition-all duration-300"
|
||||
:class="
|
||||
status.status === 'healthy'
|
||||
? 'bg-green-100'
|
||||
: status.status === 'warning'
|
||||
? 'bg-yellow-100'
|
||||
: 'bg-red-100'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
class="text-3xl"
|
||||
:class="
|
||||
status.status === 'healthy'
|
||||
? 'text-green-600'
|
||||
: status.status === 'warning'
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
"
|
||||
:name="status.icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-2xl leading-tight text-primary">
|
||||
{{ status.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ status.title }}
|
||||
</span>
|
||||
<div class="flex items-center mt-1">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full mr-2"
|
||||
:class="
|
||||
status.status === 'healthy'
|
||||
? 'bg-green-500'
|
||||
: status.status === 'warning'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
"
|
||||
></div>
|
||||
<span
|
||||
class="text-xs font-medium capitalize"
|
||||
:class="
|
||||
status.status === 'healthy'
|
||||
? 'text-green-600'
|
||||
: status.status === 'warning'
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
"
|
||||
>
|
||||
{{ status.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Real-Time Controls -->
|
||||
<rs-card class="mb-6">
|
||||
<template #body>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="text-lg font-semibold text-primary">Monitoring Controls</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full animate-pulse"
|
||||
:class="isMonitoring ? 'bg-green-500' : 'bg-gray-400'"
|
||||
></div>
|
||||
<span class="text-sm font-medium">
|
||||
{{ isMonitoring ? "Live" : "Paused" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<FormKit
|
||||
type="select"
|
||||
v-model="refreshInterval"
|
||||
:options="refreshOptions"
|
||||
outer-class="mb-0"
|
||||
@input="updateRefreshInterval"
|
||||
/>
|
||||
<rs-button
|
||||
:variant="isMonitoring ? 'danger-outline' : 'primary'"
|
||||
size="sm"
|
||||
@click="toggleMonitoring"
|
||||
>
|
||||
<Icon
|
||||
:name="isMonitoring ? 'ic:outline-pause' : 'ic:outline-play-arrow'"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ isMonitoring ? "Pause" : "Start" }}
|
||||
</rs-button>
|
||||
<rs-button variant="primary-outline" size="sm" @click="refreshData">
|
||||
<Icon name="ic:outline-refresh" class="mr-1" /> Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- System Performance Dashboard -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-speed" class="mr-2 text-primary" />
|
||||
<h3 class="text-lg font-semibold text-primary">System Performance</h3>
|
||||
</div>
|
||||
<rs-button variant="outline" size="sm" @click="exportPerformanceData">
|
||||
<Icon name="ic:outline-file-download" class="mr-1" /> Export Data
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<!-- CPU Usage -->
|
||||
<div class="text-center">
|
||||
<div class="relative mx-auto w-32 h-32 mb-4">
|
||||
<div
|
||||
class="w-full h-full bg-gray-200 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{{ performanceMetrics.cpu }}%
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">CPU</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Usage -->
|
||||
<div class="text-center">
|
||||
<div class="relative mx-auto w-32 h-32 mb-4">
|
||||
<div
|
||||
class="w-full h-full bg-gray-200 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{{ performanceMetrics.memory }}%
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">Memory</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Load -->
|
||||
<div class="text-center">
|
||||
<div class="relative mx-auto w-32 h-32 mb-4">
|
||||
<div
|
||||
class="w-full h-full bg-gray-200 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{{ performanceMetrics.queueLoad }}%
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">Queue Load</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Chart Placeholder -->
|
||||
<div
|
||||
class="h-64 bg-gray-100 dark:bg-gray-800 flex items-center justify-center rounded"
|
||||
>
|
||||
<div class="text-center">
|
||||
<Icon name="ic:outline-show-chart" class="text-4xl text-gray-400 mb-2" />
|
||||
<p class="text-gray-500">Real-time Performance Chart</p>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Implementation pending for live performance metrics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Live Activity & Alerts Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Live Activity Feed -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-notifications-active" class="mr-2 text-primary" />
|
||||
<h3 class="text-lg font-semibold text-primary">Live Activity Feed</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-button variant="outline" size="sm" @click="clearActivityFeed">
|
||||
<Icon name="ic:outline-clear" class="mr-1" /> Clear
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="h-96 overflow-y-auto space-y-3">
|
||||
<div
|
||||
v-for="(activity, index) in liveActivityFeed"
|
||||
:key="index"
|
||||
class="flex items-start p-3 bg-gray-50 dark:bg-gray-800 rounded-lg transition-all duration-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 rounded-full mr-3 mt-2 flex-shrink-0"
|
||||
:class="{
|
||||
'bg-green-500': activity.type === 'success',
|
||||
'bg-blue-500': activity.type === 'info',
|
||||
'bg-yellow-500': activity.type === 'warning',
|
||||
'bg-red-500': activity.type === 'error',
|
||||
}"
|
||||
></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm">{{ activity.action }}</p>
|
||||
<p class="text-xs text-gray-600 mt-1">{{ activity.details }}</p>
|
||||
<div class="flex items-center mt-2 text-xs text-gray-500">
|
||||
<Icon name="ic:outline-access-time" class="mr-1" />
|
||||
<span>{{ activity.timestamp }}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{{ activity.source }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="liveActivityFeed.length === 0"
|
||||
class="text-center py-8 text-gray-500"
|
||||
>
|
||||
<Icon name="ic:outline-wifi-tethering" class="text-3xl mb-2 mx-auto" />
|
||||
<p>Waiting for live activity...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Error Alerts -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-warning" class="mr-2 text-primary" />
|
||||
<h3 class="text-lg font-semibold text-primary">Error Alerts</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-badge
|
||||
:variant="
|
||||
errorAlerts.filter((a) => a.severity === 'critical').length > 0
|
||||
? 'danger'
|
||||
: 'secondary'
|
||||
"
|
||||
size="sm"
|
||||
>
|
||||
{{ errorAlerts.length }} Active
|
||||
</rs-badge>
|
||||
<rs-button variant="outline" size="sm" @click="acknowledgeAllAlerts">
|
||||
<Icon name="ic:outline-check" class="mr-1" /> Acknowledge All
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="h-96 overflow-y-auto space-y-3">
|
||||
<div
|
||||
v-for="(alert, index) in errorAlerts"
|
||||
:key="index"
|
||||
class="p-3 rounded-lg border-l-4 transition-all duration-300 hover:shadow-sm"
|
||||
:class="{
|
||||
'bg-red-50 border-red-400': alert.severity === 'critical',
|
||||
'bg-yellow-50 border-yellow-400': alert.severity === 'warning',
|
||||
'bg-blue-50 border-blue-400': alert.severity === 'info',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<Icon
|
||||
:name="
|
||||
alert.severity === 'critical'
|
||||
? 'ic:outline-error'
|
||||
: alert.severity === 'warning'
|
||||
? 'ic:outline-warning'
|
||||
: 'ic:outline-info'
|
||||
"
|
||||
:class="{
|
||||
'text-red-600': alert.severity === 'critical',
|
||||
'text-yellow-600': alert.severity === 'warning',
|
||||
'text-blue-600': alert.severity === 'info',
|
||||
}"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="font-medium text-sm">{{ alert.title }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">{{ alert.description }}</p>
|
||||
<div class="flex items-center mt-2 text-xs text-gray-500">
|
||||
<span>{{ alert.timestamp }}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{{ alert.component }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="acknowledgeAlert(index)"
|
||||
class="ml-2"
|
||||
>
|
||||
<Icon name="ic:outline-check" />
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorAlerts.length === 0" class="text-center py-8 text-gray-500">
|
||||
<Icon
|
||||
name="ic:outline-check-circle"
|
||||
class="text-3xl mb-2 mx-auto text-green-500"
|
||||
/>
|
||||
<p>No active alerts</p>
|
||||
<p class="text-sm text-gray-400 mt-1">All systems operating normally</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Queue Status -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-queue" class="mr-2 text-primary" />
|
||||
<h3 class="text-lg font-semibold text-primary">Queue Status</h3>
|
||||
</div>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="navigateTo('/notification/queue-scheduler/monitor')"
|
||||
>
|
||||
View Queue Monitor
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="queue in queueStatus"
|
||||
:key="queue.name"
|
||||
class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium">{{ queue.name }}</span>
|
||||
<rs-badge
|
||||
:variant="
|
||||
queue.status === 'active'
|
||||
? 'success'
|
||||
: queue.status === 'warning'
|
||||
? 'warning'
|
||||
: 'danger'
|
||||
"
|
||||
size="sm"
|
||||
>
|
||||
{{ queue.status }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-primary mb-1">{{ queue.count }}</div>
|
||||
<div class="text-sm text-gray-600">{{ queue.description }}</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="
|
||||
queue.status === 'active'
|
||||
? 'bg-green-500'
|
||||
: queue.status === 'warning'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
"
|
||||
:style="{ width: queue.utilization + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
{{ queue.utilization }}% utilized
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Recent Logs -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-history" class="mr-2 text-primary" />
|
||||
<h3 class="text-lg font-semibold text-primary">Recent Activity Logs</h3>
|
||||
</div>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="navigateTo('/notification/log-audit/logs')"
|
||||
>
|
||||
View All Logs
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(log, index) in recentLogs"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full mr-3"
|
||||
:class="{
|
||||
'bg-green-500': log.status === 'sent' || log.status === 'created',
|
||||
'bg-yellow-500': log.status === 'queued',
|
||||
'bg-red-500': log.status === 'failed',
|
||||
'bg-blue-500': log.status === 'opened',
|
||||
}"
|
||||
></div>
|
||||
<div>
|
||||
<p class="font-medium">{{ log.action }}</p>
|
||||
<p class="text-sm text-gray-600">{{ log.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium capitalize">{{ log.status }}</p>
|
||||
<p class="text-xs text-gray-500">{{ log.time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Real-Time Monitoring",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Logs & Audit Trail",
|
||||
path: "/notification/log-audit",
|
||||
},
|
||||
{
|
||||
name: "Monitoring",
|
||||
path: "/notification/log-audit/monitoring",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
|
||||
// Use the notification logs composable
|
||||
const {
|
||||
monitoringData,
|
||||
monitoringLoading,
|
||||
monitoringError,
|
||||
fetchMonitoringData,
|
||||
formatTimeAgo,
|
||||
} = useNotificationLogs();
|
||||
|
||||
// Monitoring state
|
||||
const isMonitoring = ref(true);
|
||||
const refreshInterval = ref("5s");
|
||||
const refreshIntervalId = ref(null);
|
||||
|
||||
const refreshOptions = [
|
||||
{ label: "1 second", value: "1s" },
|
||||
{ label: "5 seconds", value: "5s" },
|
||||
{ label: "10 seconds", value: "10s" },
|
||||
{ label: "30 seconds", value: "30s" },
|
||||
{ label: "1 minute", value: "1m" },
|
||||
];
|
||||
|
||||
// System status data - will be updated from API
|
||||
const systemStatus = computed(
|
||||
() =>
|
||||
monitoringData.value?.systemStatus || [
|
||||
{
|
||||
title: "System Health",
|
||||
value: "Healthy",
|
||||
icon: "ic:outline-favorite",
|
||||
status: "healthy",
|
||||
},
|
||||
{
|
||||
title: "Throughput",
|
||||
value: "0/hr",
|
||||
icon: "ic:outline-speed",
|
||||
status: "healthy",
|
||||
},
|
||||
{
|
||||
title: "Error Rate",
|
||||
value: "0.00%",
|
||||
icon: "ic:outline-error-outline",
|
||||
status: "healthy",
|
||||
},
|
||||
{
|
||||
title: "Response Time",
|
||||
value: "0ms",
|
||||
icon: "ic:outline-timer",
|
||||
status: "healthy",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Performance metrics - will be updated from API
|
||||
const performanceMetrics = computed(
|
||||
() =>
|
||||
monitoringData.value?.performanceMetrics || {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
queueLoad: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// Live activity feed - will be updated from API
|
||||
const liveActivityFeed = computed(() => monitoringData.value?.recentActivity || []);
|
||||
|
||||
// Error alerts - will be updated from API
|
||||
const errorAlerts = computed(() => monitoringData.value?.errorAlerts || []);
|
||||
|
||||
// Queue status - will be updated from API
|
||||
const queueStatus = computed(() => monitoringData.value?.queueStatus || []);
|
||||
|
||||
// Recent logs - using the same activity feed
|
||||
const recentLogs = computed(() => liveActivityFeed.value);
|
||||
|
||||
// Methods
|
||||
const toggleMonitoring = () => {
|
||||
isMonitoring.value = !isMonitoring.value;
|
||||
if (isMonitoring.value) {
|
||||
startMonitoring();
|
||||
} else {
|
||||
stopMonitoring();
|
||||
}
|
||||
};
|
||||
|
||||
const updateRefreshInterval = () => {
|
||||
if (isMonitoring.value) {
|
||||
stopMonitoring();
|
||||
startMonitoring();
|
||||
}
|
||||
};
|
||||
|
||||
const startMonitoring = () => {
|
||||
const intervalMs =
|
||||
{
|
||||
"1s": 1000,
|
||||
"5s": 5000,
|
||||
"10s": 10000,
|
||||
"30s": 30000,
|
||||
"1m": 60000,
|
||||
}[refreshInterval.value] || 5000;
|
||||
|
||||
// Fetch immediately
|
||||
fetchMonitoringData();
|
||||
|
||||
// Then set up the interval
|
||||
refreshIntervalId.value = setInterval(async () => {
|
||||
await fetchMonitoringData();
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
const stopMonitoring = () => {
|
||||
if (refreshIntervalId.value) {
|
||||
clearInterval(refreshIntervalId.value);
|
||||
refreshIntervalId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
await fetchMonitoringData();
|
||||
};
|
||||
|
||||
const clearActivityFeed = () => {
|
||||
// For now, just refresh the data - in a real app, you might have an API endpoint to clear the feed
|
||||
fetchMonitoringData();
|
||||
};
|
||||
|
||||
const acknowledgeAlert = (index) => {
|
||||
// In a real app, you would call an API to acknowledge the alert
|
||||
errorAlerts.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const acknowledgeAllAlerts = () => {
|
||||
// In a real app, you would call an API to acknowledge all alerts
|
||||
errorAlerts.value = [];
|
||||
};
|
||||
|
||||
const exportPerformanceData = () => {
|
||||
console.log("Exporting performance data...");
|
||||
alert("Exporting performance data. (Implementation pending)");
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (isMonitoring.value) {
|
||||
startMonitoring();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopMonitoring();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Custom styles for FormKit consistency
|
||||
:deep(.formkit-outer) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.formkit-label) {
|
||||
font-weight: 500;
|
||||
color: rgb(107 114 128);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.formkit-input) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
// Badge component styles (if RsBadge doesn't exist, these can be adjusted)
|
||||
.rs-badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.rs-badge.variant-success {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.rs-badge.variant-danger {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
.rs-badge.variant-warning {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.rs-badge.variant-info {
|
||||
@apply bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.rs-badge.variant-secondary {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
</style>
|
||||
439
pages/notification/log-audit/reports.vue
Normal file
439
pages/notification/log-audit/reports.vue
Normal file
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Page Info Card -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-file-download"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Reports & Export</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Generate reports and export log data for compliance, auditing, and analysis.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Quick Export Actions -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(action, index) in quickExportActions"
|
||||
:key="index"
|
||||
class="cursor-pointer"
|
||||
@click="quickExport(action.type)"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 text-center">
|
||||
<div
|
||||
class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl mx-auto mb-4 w-fit"
|
||||
>
|
||||
<Icon class="text-primary text-3xl" :name="action.icon"></Icon>
|
||||
</div>
|
||||
<span class="block font-bold text-lg leading-tight text-primary mb-2">
|
||||
{{ action.title }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-600">
|
||||
{{ action.description }}
|
||||
</span>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Custom Report Builder -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-build" class="mr-2 text-primary" />
|
||||
<h3 class="text-lg font-semibold text-primary">Custom Report Builder</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Report Configuration -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Report Name"
|
||||
v-model="customReport.name"
|
||||
placeholder="Enter report name"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Report Type"
|
||||
v-model="customReport.type"
|
||||
:options="reportTypeOptions"
|
||||
placeholder="Select report type"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Date Range"
|
||||
v-model="customReport.dateRange"
|
||||
:options="dateRangeOptions"
|
||||
placeholder="Select date range"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
label="Export Format"
|
||||
v-model="customReport.format"
|
||||
:options="exportFormatOptions"
|
||||
placeholder="Select format"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
label="Include Channels"
|
||||
v-model="customReport.channels"
|
||||
:options="channelOptions"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
label="Include Status"
|
||||
v-model="customReport.statuses"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Report Preview -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-lg font-semibold">Report Preview</h4>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg min-h-64">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-medium">Report Name:</span>
|
||||
<span class="text-primary">{{
|
||||
customReport.name || "Untitled Report"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-medium">Type:</span>
|
||||
<span>{{ getReportTypeLabel(customReport.type) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-medium">Date Range:</span>
|
||||
<span>{{ getDateRangeLabel(customReport.dateRange) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-medium">Format:</span>
|
||||
<span>{{ getFormatLabel(customReport.format) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="font-medium">Channels:</span>
|
||||
<div class="text-right">
|
||||
<div v-if="customReport.channels.length === 0" class="text-gray-500">
|
||||
All channels
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="channel in customReport.channels"
|
||||
:key="channel"
|
||||
class="text-sm"
|
||||
>
|
||||
{{ channel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="font-medium">Status:</span>
|
||||
<div class="text-right">
|
||||
<div v-if="customReport.statuses.length === 0" class="text-gray-500">
|
||||
All statuses
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="status in customReport.statuses"
|
||||
:key="status"
|
||||
class="text-sm"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<rs-button
|
||||
@click="generateCustomReport"
|
||||
variant="primary"
|
||||
:disabled="!customReport.name || !customReport.type"
|
||||
class="flex-1"
|
||||
>
|
||||
<Icon name="ic:outline-play-arrow" class="mr-1" /> Generate Report
|
||||
</rs-button>
|
||||
<rs-button @click="saveReportTemplate" variant="secondary-outline">
|
||||
<Icon name="ic:outline-save" class="mr-1" /> Save Template
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Export History -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon name="ic:outline-history" class="mr-2 text-primary" />
|
||||
<h3 class="text-lg font-semibold text-primary">Export History</h3>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(export_, index) in exportHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon
|
||||
:name="
|
||||
export_.format === 'pdf'
|
||||
? 'ic:outline-picture-as-pdf'
|
||||
: export_.format === 'csv'
|
||||
? 'ic:outline-table-chart'
|
||||
: 'ic:outline-grid-on'
|
||||
"
|
||||
class="mr-3 text-primary text-xl"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">{{ export_.name }}</p>
|
||||
<p class="text-sm text-gray-600">{{ export_.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium">{{ export_.size }}</p>
|
||||
<p class="text-xs text-gray-500">{{ export_.timestamp }}</p>
|
||||
</div>
|
||||
<rs-badge
|
||||
:variant="
|
||||
export_.status === 'completed'
|
||||
? 'success'
|
||||
: export_.status === 'processing'
|
||||
? 'warning'
|
||||
: 'danger'
|
||||
"
|
||||
size="sm"
|
||||
>
|
||||
{{ export_.status }}
|
||||
</rs-badge>
|
||||
<div class="flex gap-1">
|
||||
<rs-button
|
||||
v-if="export_.status === 'completed'"
|
||||
variant="primary-text"
|
||||
size="sm"
|
||||
@click="downloadExport(export_)"
|
||||
>
|
||||
<Icon name="ic:outline-download" />
|
||||
</rs-button>
|
||||
<rs-button variant="danger-text" size="sm" @click="deleteExport(index)">
|
||||
<Icon name="ic:outline-delete" />
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exportHistory.length === 0" class="text-center py-8 text-gray-500">
|
||||
<Icon name="ic:outline-folder-open" class="text-4xl mb-2 mx-auto" />
|
||||
<p>No export history available</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Reports & Export",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Logs & Audit Trail",
|
||||
path: "/notification/log-audit",
|
||||
},
|
||||
{
|
||||
name: "Reports",
|
||||
path: "/notification/log-audit/reports",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
// Quick export actions
|
||||
const quickExportActions = ref([
|
||||
{
|
||||
title: "CSV Export",
|
||||
description: "Export current data to CSV format",
|
||||
icon: "ic:outline-table-chart",
|
||||
type: "csv",
|
||||
},
|
||||
{
|
||||
title: "PDF Report",
|
||||
description: "Generate comprehensive PDF report",
|
||||
icon: "ic:outline-picture-as-pdf",
|
||||
type: "pdf",
|
||||
},
|
||||
{
|
||||
title: "Excel Export",
|
||||
description: "Export data to Excel spreadsheet",
|
||||
icon: "ic:outline-grid-on",
|
||||
type: "excel",
|
||||
},
|
||||
{
|
||||
title: "JSON Export",
|
||||
description: "Export raw data in JSON format",
|
||||
icon: "ic:outline-code",
|
||||
type: "json",
|
||||
},
|
||||
]);
|
||||
|
||||
// Custom report builder
|
||||
const customReport = ref({
|
||||
name: "",
|
||||
type: "",
|
||||
dateRange: "",
|
||||
format: "",
|
||||
channels: [],
|
||||
statuses: [],
|
||||
});
|
||||
|
||||
const reportTypeOptions = [
|
||||
{ label: "Delivery Report", value: "delivery" },
|
||||
{ label: "Performance Analytics", value: "performance" },
|
||||
{ label: "Error Analysis", value: "errors" },
|
||||
{ label: "User Engagement", value: "engagement" },
|
||||
{ label: "Channel Comparison", value: "channels" },
|
||||
{ label: "Audit Trail", value: "audit" },
|
||||
];
|
||||
|
||||
const dateRangeOptions = [
|
||||
{ label: "Last 24 Hours", value: "1d" },
|
||||
{ label: "Last 7 Days", value: "7d" },
|
||||
{ label: "Last 30 Days", value: "30d" },
|
||||
{ label: "Last 90 Days", value: "90d" },
|
||||
{ label: "Last 12 Months", value: "12m" },
|
||||
{ label: "Custom Range", value: "custom" },
|
||||
];
|
||||
|
||||
const exportFormatOptions = [
|
||||
{ label: "CSV", value: "csv" },
|
||||
{ label: "PDF", value: "pdf" },
|
||||
{ label: "Excel", value: "excel" },
|
||||
{ label: "JSON", value: "json" },
|
||||
];
|
||||
|
||||
const channelOptions = [
|
||||
{ label: "Email", value: "Email" },
|
||||
{ label: "SMS", value: "SMS" },
|
||||
{ label: "Push Notification", value: "Push Notification" },
|
||||
{ label: "Webhook", value: "Webhook" },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "Sent", value: "Sent" },
|
||||
{ label: "Failed", value: "Failed" },
|
||||
{ label: "Bounced", value: "Bounced" },
|
||||
{ label: "Opened", value: "Opened" },
|
||||
{ label: "Queued", value: "Queued" },
|
||||
];
|
||||
|
||||
// Export history
|
||||
const exportHistory = ref([
|
||||
{
|
||||
name: "Notification Analytics Report",
|
||||
description: "Monthly performance analysis",
|
||||
format: "pdf",
|
||||
size: "2.4 MB",
|
||||
timestamp: "2 hours ago",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
name: "Delivery Logs Export",
|
||||
description: "Last 30 days delivery data",
|
||||
format: "csv",
|
||||
size: "856 KB",
|
||||
timestamp: "1 day ago",
|
||||
status: "completed",
|
||||
},
|
||||
]);
|
||||
|
||||
// Helper functions
|
||||
const getReportTypeLabel = (value) => {
|
||||
const option = reportTypeOptions.find((opt) => opt.value === value);
|
||||
return option ? option.label : "Not selected";
|
||||
};
|
||||
|
||||
const getDateRangeLabel = (value) => {
|
||||
const option = dateRangeOptions.find((opt) => opt.value === value);
|
||||
return option ? option.label : "Not selected";
|
||||
};
|
||||
|
||||
const getFormatLabel = (value) => {
|
||||
const option = exportFormatOptions.find((opt) => opt.value === value);
|
||||
return option ? option.label : "Not selected";
|
||||
};
|
||||
|
||||
// Methods
|
||||
const quickExport = (type) => {
|
||||
console.log(`Quick export: ${type}`);
|
||||
alert(`Exporting data in ${type} format. (Implementation pending)`);
|
||||
};
|
||||
|
||||
const generateCustomReport = () => {
|
||||
console.log("Generating custom report:", customReport.value);
|
||||
alert(`Generating custom report: ${customReport.value.name}. (Implementation pending)`);
|
||||
};
|
||||
|
||||
const saveReportTemplate = () => {
|
||||
console.log("Saving report template:", customReport.value);
|
||||
alert(`Saving report template: ${customReport.value.name}. (Implementation pending)`);
|
||||
};
|
||||
|
||||
const downloadExport = (export_) => {
|
||||
console.log("Downloading export:", export_);
|
||||
alert(`Downloading ${export_.name}. (Implementation pending)`);
|
||||
};
|
||||
|
||||
const deleteExport = (index) => {
|
||||
exportHistory.value.splice(index, 1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.formkit-outer) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
:deep(.formkit-label) {
|
||||
font-weight: 500;
|
||||
color: rgb(107 114 128);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(.formkit-input) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
642
pages/notification/preferences/index.vue
Normal file
642
pages/notification/preferences/index.vue
Normal file
@@ -0,0 +1,642 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
definePageMeta({
|
||||
title: "Admin: User Preferences & System Settings",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
const currentTab = ref('categories'); // categories, channels, frequencies, globalQuietHours, userAudit, bulkOps
|
||||
|
||||
// --- Admin: Notification Categories ---
|
||||
const adminNotificationCategories = ref([
|
||||
{ id: 'cat_promo', name: 'Promotions & Offers', description: 'Updates on new promotions, discounts, etc.', defaultSubscribed: true, isActive: true },
|
||||
{ id: 'cat_alerts', name: 'Critical Alerts', description: 'Important security or account issue alerts.', defaultSubscribed: true, isActive: true },
|
||||
{ id: 'cat_updates', name: 'Product Updates', description: 'New features, improvements, system maintenance.', defaultSubscribed: true, isActive: true },
|
||||
{ id: 'cat_newsletter', name: 'Newsletter', description: 'Regular news and tips.', defaultSubscribed: false, isActive: true },
|
||||
{ id: 'cat_surveys', name: 'Feedback Surveys', description: 'Occasional surveys to improve our service.', defaultSubscribed: false, isActive: false },
|
||||
]);
|
||||
const showCategoryModal = ref(false);
|
||||
const editingCategory = ref(null);
|
||||
const categoryForm = ref({ id: null, name: '', description: '', defaultSubscribed: false, isActive: true });
|
||||
|
||||
function openAddCategoryModal() {
|
||||
editingCategory.value = null;
|
||||
categoryForm.value = { id: `cat_${Date.now().toString().slice(-4)}`, name: '', description: '', defaultSubscribed: false, isActive: true };
|
||||
showCategoryModal.value = true;
|
||||
}
|
||||
function openEditCategoryModal(category) {
|
||||
editingCategory.value = { ...category };
|
||||
categoryForm.value = { ...category };
|
||||
showCategoryModal.value = true;
|
||||
}
|
||||
function saveCategory() {
|
||||
if (editingCategory.value && editingCategory.value.id) {
|
||||
const index = adminNotificationCategories.value.findIndex(c => c.id === editingCategory.value.id);
|
||||
if (index !== -1) {
|
||||
adminNotificationCategories.value[index] = { ...categoryForm.value };
|
||||
}
|
||||
} else {
|
||||
adminNotificationCategories.value.push({ ...categoryForm.value, id: categoryForm.value.id || `cat_${Date.now().toString().slice(-4)}` });
|
||||
}
|
||||
showCategoryModal.value = false;
|
||||
}
|
||||
function toggleCategoryStatus(category) {
|
||||
category.isActive = !category.isActive;
|
||||
}
|
||||
|
||||
// --- Admin: Channels ---
|
||||
const adminChannels = ref([
|
||||
{ id: 'email', name: 'Email', isEnabled: true, defaultFrequencyCap: 'No Limit', supportedMessageTypes: ['cat_promo', 'cat_alerts', 'cat_updates', 'cat_newsletter'] },
|
||||
{ id: 'sms', name: 'SMS', isEnabled: true, defaultFrequencyCap: '5 per day', supportedMessageTypes: ['cat_alerts'] },
|
||||
{ id: 'push', name: 'Push Notifications', isEnabled: false, defaultFrequencyCap: '10 per day', supportedMessageTypes: ['cat_alerts', 'cat_updates'] },
|
||||
]);
|
||||
|
||||
// --- Admin: Frequencies ---
|
||||
const adminFrequencies = ref([
|
||||
{ id: 'freq_immediate', label: 'Immediate', value: 'immediate', isUserSelectable: true, isDefault: true },
|
||||
{ id: 'freq_hourly', label: 'Hourly Digest', value: 'hourly', isUserSelectable: true, isDefault: false },
|
||||
{ id: 'freq_daily', label: 'Daily Digest', value: 'daily', isUserSelectable: true, isDefault: false },
|
||||
{ id: 'freq_weekly', label: 'Weekly Digest', value: 'weekly', isUserSelectable: true, isDefault: false }, // Made user selectable for demo
|
||||
{ id: 'freq_monthly', label: 'Monthly Summary', value: 'monthly', isUserSelectable: false, isDefault: false },
|
||||
]);
|
||||
const showFrequencyModal = ref(false);
|
||||
const editingFrequency = ref(null);
|
||||
const frequencyForm = ref({ id: null, label: '', value: '', isUserSelectable: true, isDefault: false });
|
||||
|
||||
function openAddFrequencyModal() {
|
||||
editingFrequency.value = null;
|
||||
frequencyForm.value = { id: `freq_${Date.now().toString().slice(-4)}`, label: '', value: '', isUserSelectable: true, isDefault: false };
|
||||
showFrequencyModal.value = true;
|
||||
}
|
||||
function openEditFrequencyModal(freq) {
|
||||
editingFrequency.value = { ...freq };
|
||||
frequencyForm.value = { ...freq };
|
||||
showFrequencyModal.value = true;
|
||||
}
|
||||
function saveFrequency() {
|
||||
if (editingFrequency.value && editingFrequency.value.id) {
|
||||
const index = adminFrequencies.value.findIndex(f => f.id === editingFrequency.value.id);
|
||||
if (index !== -1) {
|
||||
adminFrequencies.value[index] = { ...frequencyForm.value };
|
||||
}
|
||||
} else {
|
||||
adminFrequencies.value.push({ ...frequencyForm.value, id: frequencyForm.value.id || `freq_${Date.now().toString().slice(-4)}` });
|
||||
}
|
||||
showFrequencyModal.value = false;
|
||||
}
|
||||
function deleteFrequency(freqId) {
|
||||
if (confirm(`Are you sure you want to delete frequency option with ID: ${freqId}?`)) {
|
||||
adminFrequencies.value = adminFrequencies.value.filter(f => f.id !== freqId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin: Global Quiet Hours ---
|
||||
const adminGlobalQuietHours = ref({
|
||||
enabled: false,
|
||||
startTime: '22:00',
|
||||
endTime: '07:00',
|
||||
allowUserOverride: true,
|
||||
});
|
||||
|
||||
// --- Admin: User Preference Audit ---
|
||||
const userAuditSearchQuery = ref('');
|
||||
const searchedUser = ref(null); // Will hold structure like: { id: 'user123', name: 'John Doe', preferences: { defaultChannel: 'email', subscriptions: { 'cat_promo': { subscribed: true, channel: 'email', frequency: 'weekly' } }, quietHours: { enabled: false, startTime: '22:00', endTime: '07:00'} } }
|
||||
const isSearchingUser = ref(false);
|
||||
const userPreferencesForm = ref(null); // For editing the searched user's prefs
|
||||
|
||||
// Mock user data - in a real app, this would come from an API
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 'user001',
|
||||
name: 'Alice Wonderland',
|
||||
email: 'alice@example.com',
|
||||
preferences: {
|
||||
defaultPreferredChannel: 'email',
|
||||
subscriptions: {
|
||||
'cat_promo': { subscribed: true, channel: 'email', frequency: 'freq_weekly' },
|
||||
'cat_alerts': { subscribed: true, channel: 'sms', frequency: 'freq_immediate' },
|
||||
'cat_updates': { subscribed: false, channel: 'email', frequency: 'freq_daily' },
|
||||
'cat_newsletter': { subscribed: true, channel: 'email', frequency: 'freq_monthly' }
|
||||
},
|
||||
quietHours: { enabled: false, startTime: '22:00', endTime: '08:00' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'user002',
|
||||
name: 'Bob The Builder',
|
||||
email: 'bob@example.com',
|
||||
preferences: {
|
||||
defaultPreferredChannel: 'sms',
|
||||
subscriptions: {
|
||||
'cat_promo': { subscribed: false, channel: 'email', frequency: 'freq_weekly' },
|
||||
'cat_alerts': { subscribed: true, channel: 'sms', frequency: 'freq_immediate' },
|
||||
'cat_updates': { subscribed: true, channel: 'push', frequency: 'freq_daily' }, // Assuming push is a configured channel id
|
||||
'cat_newsletter': { subscribed: false, channel: 'email', frequency: 'freq_monthly' }
|
||||
},
|
||||
quietHours: { enabled: true, startTime: '23:00', endTime: '07:30' }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function handleUserSearch() {
|
||||
if (!userAuditSearchQuery.value.trim()) {
|
||||
searchedUser.value = null;
|
||||
userPreferencesForm.value = null;
|
||||
return;
|
||||
}
|
||||
isSearchingUser.value = true;
|
||||
setTimeout(() => { // Simulate API call
|
||||
const found = mockUsers.find(u => u.id.includes(userAuditSearchQuery.value.trim()) || u.name.toLowerCase().includes(userAuditSearchQuery.value.trim().toLowerCase()) || u.email.toLowerCase().includes(userAuditSearchQuery.value.trim().toLowerCase()));
|
||||
if (found) {
|
||||
searchedUser.value = JSON.parse(JSON.stringify(found)); // Deep copy
|
||||
// Initialize form data by ensuring all admin-defined categories are present
|
||||
const prefsCopy = JSON.parse(JSON.stringify(found.preferences));
|
||||
adminNotificationCategories.value.forEach(adminCat => {
|
||||
if (!prefsCopy.subscriptions[adminCat.id]) {
|
||||
prefsCopy.subscriptions[adminCat.id] = { subscribed: false, channel: found.preferences.defaultPreferredChannel, frequency: adminFrequencies.value.find(f=>f.isDefault)?.id || adminFrequencies.value[0]?.id };
|
||||
}
|
||||
});
|
||||
userPreferencesForm.value = prefsCopy;
|
||||
} else {
|
||||
searchedUser.value = null;
|
||||
userPreferencesForm.value = null;
|
||||
alert('User not found.');
|
||||
}
|
||||
isSearchingUser.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function saveUserPreferences() {
|
||||
if (!searchedUser.value || !userPreferencesForm.value) return;
|
||||
// In a real app, send userPreferencesForm.value to the backend to save.
|
||||
// For this mock, update the mockUsers array or searchedUser directly.
|
||||
const userIndex = mockUsers.findIndex(u => u.id === searchedUser.value.id);
|
||||
if (userIndex !== -1) {
|
||||
mockUsers[userIndex].preferences = JSON.parse(JSON.stringify(userPreferencesForm.value));
|
||||
}
|
||||
searchedUser.value.preferences = JSON.parse(JSON.stringify(userPreferencesForm.value)); // Update current view
|
||||
alert(`Preferences for ${searchedUser.value.name} saved (mock).`);
|
||||
}
|
||||
|
||||
function cancelUserEdit() {
|
||||
if (searchedUser.value) {
|
||||
// Re-initialize form from original searchedUser data if needed, or just clear
|
||||
const prefsCopy = JSON.parse(JSON.stringify(searchedUser.value.preferences));
|
||||
adminNotificationCategories.value.forEach(adminCat => {
|
||||
if (!prefsCopy.subscriptions[adminCat.id]) {
|
||||
prefsCopy.subscriptions[adminCat.id] = { subscribed: false, channel: searchedUser.value.preferences.defaultPreferredChannel, frequency: adminFrequencies.value.find(f=>f.isDefault)?.id || adminFrequencies.value[0]?.id };
|
||||
}
|
||||
});
|
||||
userPreferencesForm.value = prefsCopy;
|
||||
} else {
|
||||
userPreferencesForm.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Admin: Bulk Operations ---
|
||||
const handleAdminImport = () => {
|
||||
alert('Admin bulk import initiated (not implemented).');
|
||||
};
|
||||
const handleAdminExport = () => {
|
||||
alert('Admin bulk export initiated (not implemented).');
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ key: 'categories', label: 'Notification Categories' },
|
||||
{ key: 'channels', label: 'Channels & Governance' },
|
||||
{ key: 'frequencies', label: 'Frequency Options' },
|
||||
{ key: 'globalQuietHours', label: 'Global Quiet Hours' },
|
||||
{ key: 'userAudit', label: 'User Preference Audit' },
|
||||
{ key: 'bulkOps', label: 'Bulk Operations' },
|
||||
];
|
||||
|
||||
const getChannelName = (channelId) => {
|
||||
const channel = adminChannels.value.find(c => c.id === channelId);
|
||||
return channel ? channel.name : channelId;
|
||||
};
|
||||
|
||||
const getFrequencyLabel = (frequencyId) => {
|
||||
const freq = adminFrequencies.value.find(f => f.id === frequencyId);
|
||||
return freq ? freq.label : frequencyId;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="text-xl font-semibold text-gray-900">
|
||||
Admin: User Preferences & System Settings
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="p-4 md:p-6">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="mb-6 border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="currentTab = tab.key; searchedUser = null; userPreferencesForm = null; userAuditSearchQuery = ''"
|
||||
:class="[
|
||||
currentTab === tab.key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||
'whitespace-nowrap py-4 px-3 border-b-2 font-medium text-sm transition-colors duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50'
|
||||
]"
|
||||
:aria-current="currentTab === tab.key ? 'page' : undefined"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div :key="currentTab">
|
||||
<!-- == Section: Notification Categories (Admin CRUD) == -->
|
||||
<section v-if="currentTab === 'categories'" aria-labelledby="categories-heading">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 id="categories-heading" class="text-lg font-semibold text-gray-800">Manage Notification Categories</h2>
|
||||
<p class="text-sm text-gray-600">Define categories users can subscribe to. Inactive categories are hidden from users.</p>
|
||||
</div>
|
||||
<button @click="openAddCategoryModal" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Add New Category
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto shadow border-b border-gray-200 sm:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Default Subscribed</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="category in adminNotificationCategories" :key="category.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ category.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-normal text-sm text-gray-500 max-w-xs truncate" :title="category.description">{{ category.description }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ category.defaultSubscribed ? 'Yes' : 'No' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="['px-2 inline-flex text-xs leading-5 font-semibold rounded-full', category.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
|
||||
{{ category.isActive ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button @click="openEditCategoryModal(category)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
|
||||
<button @click="toggleCategoryStatus(category)" :class="[category.isActive ? 'text-red-600 hover:text-red-900' : 'text-green-600 hover:text-green-900']">
|
||||
{{ category.isActive ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="adminNotificationCategories.length === 0">
|
||||
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">No categories defined yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- == Section: Channels & Governance (Admin) == -->
|
||||
<section v-if="currentTab === 'channels'" aria-labelledby="channels-heading">
|
||||
<h2 id="channels-heading" class="text-lg font-semibold text-gray-800 mb-3">Manage Channels & Governance</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Enable/disable communication channels and set global rules.</p>
|
||||
<div class="space-y-6">
|
||||
<div v-for="channel in adminChannels" :key="channel.id" class="p-4 border rounded-lg shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-md font-medium text-gray-900">{{ channel.name }}</h3>
|
||||
<label :for="`channel-enabled-${channel.id}`" class="flex items-center cursor-pointer">
|
||||
<span class="mr-2 text-sm text-gray-700">{{ channel.isEnabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
<div class="relative">
|
||||
<input type="checkbox" :id="`channel-enabled-${channel.id}`" class="sr-only peer" v-model="channel.isEnabled">
|
||||
<div class="w-10 h-4 bg-gray-300 rounded-full shadow-inner peer-checked:bg-blue-500 transition-colors"></div>
|
||||
<div class="absolute left-0 top-[-4px] w-6 h-6 bg-white border-2 border-gray-300 rounded-full shadow transform peer-checked:translate-x-full peer-checked:border-blue-500 transition-transform"></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="channel.isEnabled" class="mt-4 pt-4 border-t">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label :for="`channel-cap-${channel.id}`" class="block text-sm font-medium text-gray-700">Default Frequency Cap</label>
|
||||
<input type="text" :id="`channel-cap-${channel.id}`" v-model="channel.defaultFrequencyCap" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="e.g., 5 per day">
|
||||
</div>
|
||||
<div>
|
||||
<label :for="`channel-types-${channel.id}`" class="block text-sm font-medium text-gray-700">Supported Message Types (Category IDs)</label>
|
||||
<input type="text" :id="`channel-types-${channel.id}`" :value="channel.supportedMessageTypes.join(', ')" @change="channel.supportedMessageTypes = $event.target.value.split(',').map(s => s.trim()).filter(Boolean)" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="e.g., cat_alerts, cat_updates">
|
||||
<p class="text-xs text-gray-500 mt-1">Comma-separated category IDs that can use this channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- == Section: Frequency Options (Admin) == -->
|
||||
<section v-if="currentTab === 'frequencies'" aria-labelledby="frequencies-heading">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 id="frequencies-heading" class="text-lg font-semibold text-gray-800">Manage Frequency Options</h2>
|
||||
<p class="text-sm text-gray-600">Define frequency choices available to users or for system defaults.</p>
|
||||
</div>
|
||||
<button @click="openAddFrequencyModal" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Add New Frequency
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto shadow border-b border-gray-200 sm:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Label</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value (System ID)</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User Selectable</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Default for New Users</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="freq in adminFrequencies" :key="freq.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ freq.label }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ freq.value }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ freq.isUserSelectable ? 'Yes' : 'No' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ freq.isDefault ? 'Yes' : 'No' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button @click="openEditFrequencyModal(freq)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
|
||||
<button @click="deleteFrequency(freq.id)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="adminFrequencies.length === 0">
|
||||
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">No frequency options defined yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- == Section: Global Quiet Hours (Admin) == -->
|
||||
<section v-if="currentTab === 'globalQuietHours'" aria-labelledby="globalqh-heading">
|
||||
<h2 id="globalqh-heading" class="text-lg font-semibold text-gray-800 mb-3">Configure Global Quiet Hours</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Set system-wide default "Do Not Disturb" periods. These can potentially be overridden by users if allowed.</p>
|
||||
<div class="space-y-4 max-w-lg p-4 border rounded-lg shadow-sm">
|
||||
<div class="flex items-center">
|
||||
<input id="admin-qh-enabled" type="checkbox" class="form-checkbox h-5 w-5 text-blue-600 rounded focus:ring-blue-500" v-model="adminGlobalQuietHours.enabled">
|
||||
<label for="admin-qh-enabled" class="ml-3 block text-sm font-medium text-gray-700 cursor-pointer">Enable Global Quiet Hours</label>
|
||||
</div>
|
||||
<div v-if="adminGlobalQuietHours.enabled" class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3 mt-3">
|
||||
<div>
|
||||
<label for="admin-qh-start" class="block text-sm font-medium text-gray-700 mb-1">Start Time:</label>
|
||||
<input type="time" id="admin-qh-start" class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" v-model="adminGlobalQuietHours.startTime">
|
||||
</div>
|
||||
<div>
|
||||
<label for="admin-qh-end" class="block text-sm font-medium text-gray-700 mb-1">End Time:</label>
|
||||
<input type="time" id="admin-qh-end" class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" v-model="adminGlobalQuietHours.endTime">
|
||||
</div>
|
||||
<div class="sm:col-span-2 flex items-center">
|
||||
<input id="admin-qh-override" type="checkbox" class="form-checkbox h-5 w-5 text-blue-600 rounded focus:ring-blue-500" v-model="adminGlobalQuietHours.allowUserOverride">
|
||||
<label for="admin-qh-override" class="ml-3 block text-sm font-medium text-gray-700 cursor-pointer">Allow users to override global quiet hours</label>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="alert('Save Global Quiet Hours clicked (mock)')" class="mt-4 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Save Global Quiet Hours</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- == Section: User Preference Audit (Admin) == -->
|
||||
<section v-if="currentTab === 'userAudit'" aria-labelledby="useraudit-heading">
|
||||
<h2 id="useraudit-heading" class="text-lg font-semibold text-gray-800 mb-3">User Preference Audit & Management</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Search for a user by ID, name, or email to view or modify their notification preferences.</p>
|
||||
|
||||
<div class="flex space-x-3 mb-6 max-w-xl">
|
||||
<input type="text" v-model="userAuditSearchQuery" placeholder="Enter User ID, Name, or Email" class="form-input flex-grow block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
<button @click="handleUserSearch" :disabled="isSearchingUser || !userAuditSearchQuery.trim()" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50">
|
||||
{{ isSearchingUser ? 'Searching...' : 'Search User' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isSearchingUser" class="text-center py-6">
|
||||
<p class="text-gray-500">Loading user data...</p> <!-- Add a spinner later -->
|
||||
</div>
|
||||
|
||||
<div v-if="!isSearchingUser && searchedUser && userPreferencesForm" class="mt-6 p-6 border rounded-lg shadow-lg">
|
||||
<h3 class="text-xl font-semibold text-gray-800 mb-2">Editing Preferences for: <span class="font-normal">{{ searchedUser.name }} ({{ searchedUser.id }})</span></h3>
|
||||
<p class="text-sm text-gray-500 mb-6">Email: {{ searchedUser.email }}</p>
|
||||
|
||||
<form @submit.prevent="saveUserPreferences">
|
||||
<div class="space-y-8">
|
||||
<!-- Default Preferred Channel for User -->
|
||||
<div>
|
||||
<label for="userDefaultChannel" class="block text-sm font-medium text-gray-700 mb-1">User's Default Notification Channel</label>
|
||||
<select id="userDefaultChannel" v-model="userPreferencesForm.defaultPreferredChannel" class="form-select mt-1 block w-full md:w-1/2 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
<option v-for="channel in adminChannels.filter(c => c.isEnabled)" :key="channel.id" :value="channel.id">{{ channel.name }}</option>
|
||||
<option value="none">None (Mute All)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- User Subscriptions to Categories -->
|
||||
<div>
|
||||
<h4 class="text-md font-semibold text-gray-700 mb-3">Category Subscriptions & Overrides</h4>
|
||||
<div class="space-y-6">
|
||||
<div v-for="adminCat in adminNotificationCategories.filter(ac => ac.isActive)" :key="adminCat.id" class="p-4 border rounded-md bg-gray-50">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h5 class="font-medium text-gray-800">{{ adminCat.name }}</h5>
|
||||
<p class="text-xs text-gray-500">{{ adminCat.description }}</p>
|
||||
</div>
|
||||
<input :id="`user-cat-sub-${adminCat.id}`" type="checkbox" v-model="userPreferencesForm.subscriptions[adminCat.id].subscribed" class="form-checkbox h-5 w-5 text-blue-600 rounded focus:ring-blue-500 cursor-pointer">
|
||||
</div>
|
||||
|
||||
<div v-if="userPreferencesForm.subscriptions[adminCat.id].subscribed" class="mt-3 pt-3 border-t grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<label :for="`user-cat-channel-${adminCat.id}`" class="block text-xs font-medium text-gray-600 mb-0.5">Channel Override:</label>
|
||||
<select :id="`user-cat-channel-${adminCat.id}`" v-model="userPreferencesForm.subscriptions[adminCat.id].channel" class="form-select mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-xs">
|
||||
<option v-for="channel in adminChannels.filter(c => c.isEnabled && c.supportedMessageTypes.includes(adminCat.id))" :key="channel.id" :value="channel.id">{{ channel.name }}</option>
|
||||
<option :value="userPreferencesForm.defaultPreferredChannel">(User Default: {{ getChannelName(userPreferencesForm.defaultPreferredChannel) }})</option>
|
||||
<option value="none">None (Mute This Category)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label :for="`user-cat-freq-${adminCat.id}`" class="block text-xs font-medium text-gray-600 mb-0.5">Frequency Override:</label>
|
||||
<select :id="`user-cat-freq-${adminCat.id}`" v-model="userPreferencesForm.subscriptions[adminCat.id].frequency" class="form-select mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-xs">
|
||||
<option v-for="freq in adminFrequencies.filter(f => f.isUserSelectable)" :key="freq.id" :value="freq.id">{{ freq.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Quiet Hours -->
|
||||
<div>
|
||||
<h4 class="text-md font-semibold text-gray-700 mb-3">User Quiet Hours Override</h4>
|
||||
<div class="space-y-3 p-4 border rounded-md bg-gray-50 max-w-md">
|
||||
<div class="flex items-center">
|
||||
<input id="user-qh-enabled" type="checkbox" v-model="userPreferencesForm.quietHours.enabled" class="form-checkbox h-5 w-5 text-blue-600 rounded focus:ring-blue-500">
|
||||
<label for="user-qh-enabled" class="ml-3 block text-sm font-medium text-gray-700 cursor-pointer">Enable Quiet Hours for this User</label>
|
||||
</div>
|
||||
<div v-if="userPreferencesForm.quietHours.enabled" class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 mt-2">
|
||||
<div>
|
||||
<label for="user-qh-start" class="block text-xs font-medium text-gray-600 mb-0.5">Start Time:</label>
|
||||
<input type="time" id="user-qh-start" v-model="userPreferencesForm.quietHours.startTime" class="form-input mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="user-qh-end" class="block text-xs font-medium text-gray-600 mb-0.5">End Time:</label>
|
||||
<input type="time" id="user-qh-end" v-model="userPreferencesForm.quietHours.endTime" class="form-input mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save/Cancel User Prefs -->
|
||||
<div class="mt-8 pt-5 border-t">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="cancelUserEdit" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Reset / Cancel Edit
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
|
||||
Save Changes for {{ searchedUser.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="!isSearchingUser && !searchedUser && userAuditSearchQuery" class="text-center py-6">
|
||||
<p class="text-gray-500">No user found matching "{{ userAuditSearchQuery }}". Try a different search term.</p>
|
||||
</div>
|
||||
<div v-if="!isSearchingUser && !searchedUser && !userAuditSearchQuery" class="text-center py-6">
|
||||
<p class="text-gray-500">Enter a user ID, name, or email to search.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section v-if="currentTab === 'bulkOps'" aria-labelledby="bulkops-heading">
|
||||
<h2 id="bulkops-heading" class="text-lg font-semibold text-gray-800 mb-3">Bulk User Preference Operations</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Import or export user preference data for backup, migration, or system-wide updates.</p>
|
||||
<div class="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-3 mt-4">
|
||||
<button @click="handleAdminImport" class="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition ease-in-out duration-150">
|
||||
Import User Preferences (Bulk)
|
||||
</button>
|
||||
<button @click="handleAdminExport" class="w-full sm:w-auto px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition ease-in-out duration-150">
|
||||
Export User Preferences (Bulk)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Modal for Add/Edit Notification Category -->
|
||||
<div v-if="showCategoryModal" class="fixed inset-0 z-50 overflow-y-auto bg-gray-600 bg-opacity-75 flex items-center justify-center p-4">
|
||||
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-auto my-8 max-h-[90vh] overflow-y-auto">
|
||||
<form @submit.prevent="saveCategory" class="p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
{{ editingCategory ? 'Edit' : 'Add New' }} Notification Category
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="catName" class="block text-sm font-medium text-gray-700">Category Name</label>
|
||||
<input type="text" v-model="categoryForm.name" id="catName" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="catDesc" class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="categoryForm.description" id="catDesc" rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="catDefaultSubscribed" v-model="categoryForm.defaultSubscribed" type="checkbox" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="catDefaultSubscribed" class="font-medium text-gray-700">Default Subscribed</label>
|
||||
<p class="text-gray-500">Users will be subscribed to this category by default.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="catIsActive" v-model="categoryForm.isActive" type="checkbox" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="catIsActive" class="font-medium text-gray-700">Active</label>
|
||||
<p class="text-gray-500">Inactive categories are not visible or configurable by users.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-3 sticky bottom-0 bg-white py-4 px-6 -mx-6 -mb-6 border-t border-gray-200 rounded-b-lg">
|
||||
<button type="button" @click="showCategoryModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
{{ editingCategory ? 'Save Changes' : 'Create Category' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Add/Edit Frequency Option -->
|
||||
<div v-if="showFrequencyModal" class="fixed inset-0 z-50 overflow-y-auto bg-gray-600 bg-opacity-75 flex items-center justify-center p-4">
|
||||
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-auto my-8 max-h-[90vh] overflow-y-auto">
|
||||
<form @submit.prevent="saveFrequency" class="p-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
{{ editingFrequency ? 'Edit' : 'Add New' }} Frequency Option
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="freqLabel" class="block text-sm font-medium text-gray-700">Label (User-facing)</label>
|
||||
<input type="text" v-model="frequencyForm.label" id="freqLabel" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="freqValue" class="block text-sm font-medium text-gray-700">Value (System ID)</label>
|
||||
<input type="text" v-model="frequencyForm.value" id="freqValue" required :disabled="editingFrequency !== null" placeholder="e.g., immediate, daily_digest" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm disabled:bg-gray-100">
|
||||
<p v-if="editingFrequency" class="text-xs text-gray-500 mt-1">System value cannot be changed after creation.</p>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="freqUserSelectable" v-model="frequencyForm.isUserSelectable" type="checkbox" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="freqUserSelectable" class="font-medium text-gray-700">User Selectable</label>
|
||||
<p class="text-gray-500">Can users choose this frequency option for their preferences?</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input id="freqIsDefault" v-model="frequencyForm.isDefault" type="checkbox" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="freqIsDefault" class="font-medium text-gray-700">Default for New Users</label>
|
||||
<p class="text-gray-500">Is this a default frequency for new users or new subscriptions?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end space-x-3 sticky bottom-0 bg-white py-4 px-6 -mx-6 -mb-6 border-t border-gray-200 rounded-b-lg">
|
||||
<button type="button" @click="showFrequencyModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
{{ editingFrequency ? 'Save Changes' : 'Create Frequency' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Using Tailwind utility classes. */
|
||||
/* Ensure @tailwindcss/forms plugin is installed for nice form styling. */
|
||||
.form-checkbox:focus, .form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
/* You might want to ensure focus rings are consistent if not using the plugin */
|
||||
/* Example: ring-2 ring-offset-2 ring-indigo-500 */
|
||||
}
|
||||
</style>
|
||||
537
pages/notification/queue/batch.vue
Normal file
537
pages/notification/queue/batch.vue
Normal file
@@ -0,0 +1,537 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-batch-prediction"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Batch Processing</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Process notifications in batches for better efficiency.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<rs-card
|
||||
class="cursor-pointer hover:shadow-md transition-shadow"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
<div class="p-4 flex items-center gap-4">
|
||||
<div class="p-3 bg-blue-100 rounded-lg">
|
||||
<Icon class="text-blue-600 text-xl" name="ic:outline-add"></Icon>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-blue-600">Create New Batch</h3>
|
||||
<p class="text-sm text-gray-600">Start a new batch processing job</p>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
|
||||
<rs-card class="cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div class="p-4 flex items-center gap-4">
|
||||
<div class="p-3 bg-green-100 rounded-lg">
|
||||
<Icon class="text-green-600 text-xl" name="ic:outline-schedule"></Icon>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-green-600">Schedule Batch</h3>
|
||||
<p class="text-sm text-gray-600">Schedule for later execution</p>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Batch Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-blue-600">{{ stats.pending }}</h3>
|
||||
<p class="text-sm text-gray-600">Pending</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-yellow-600">
|
||||
{{ stats.processing }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Processing</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-green-600">
|
||||
{{ stats.completed }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Completed</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-red-600">{{ stats.failed }}</h3>
|
||||
<p class="text-sm text-gray-600">Failed</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Active Batches -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-primary">Active Batches</h3>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshBatches"
|
||||
:loading="isLoading"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex justify-center items-center py-8">
|
||||
<rs-spinner size="lg" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="batches.length === 0" class="text-center py-8">
|
||||
<Icon name="ic:outline-inbox" class="text-4xl text-gray-400 mb-2" />
|
||||
<p class="text-gray-600">No batches found</p>
|
||||
</div>
|
||||
|
||||
<!-- Batches List -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="batch in batches"
|
||||
:key="batch.id"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full mr-3"
|
||||
:class="getStatusColor(batch.status)"
|
||||
></div>
|
||||
<div>
|
||||
<h4 class="font-semibold">{{ batch.name }}</h4>
|
||||
<p class="text-sm text-gray-600">{{ batch.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-medium capitalize">{{ batch.status }}</span>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatDate(batch.time) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Info -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600">
|
||||
Progress: {{ batch.processed }}/{{ batch.total }} ({{
|
||||
Math.round((batch.processed / batch.total) * 100)
|
||||
}}%)
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full"
|
||||
:style="{
|
||||
width: `${Math.round((batch.processed / batch.total) * 100)}%`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="pagination.totalPages > 1"
|
||||
class="flex justify-center items-center space-x-2 mt-4"
|
||||
>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="pagination.page === 1"
|
||||
@click="pagination.page--"
|
||||
>
|
||||
Previous
|
||||
</rs-button>
|
||||
<span class="text-sm text-gray-600">
|
||||
Page {{ pagination.page }} of {{ pagination.totalPages }}
|
||||
</span>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="pagination.page === pagination.totalPages"
|
||||
@click="pagination.page++"
|
||||
>
|
||||
Next
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Create Batch Modal -->
|
||||
<rs-modal v-model="showCreateModal" title="Create New Batch">
|
||||
<div class="space-y-4">
|
||||
<!-- Basic Information -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Name</label>
|
||||
<input
|
||||
v-model="newBatch.name"
|
||||
type="text"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
placeholder="Enter batch name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Message Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Message Type</label>
|
||||
<select
|
||||
v-model="newBatch.type"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="push">Push Notification</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Priority</label>
|
||||
<select
|
||||
v-model="newBatch.priority"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Template Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Template (Optional)</label
|
||||
>
|
||||
<select
|
||||
v-model="newBatch.template"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">No template</option>
|
||||
<option v-for="template in templates" :key="template.id" :value="template.id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- User Segment -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>User Segment (Optional)</label
|
||||
>
|
||||
<select
|
||||
v-model="newBatch.segment"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">All Users</option>
|
||||
<option v-for="segment in segments" :key="segment.id" :value="segment.value">
|
||||
{{ segment.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled Time -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Schedule For (Optional)</label
|
||||
>
|
||||
<input
|
||||
v-model="newBatch.scheduledAt"
|
||||
type="datetime-local"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea
|
||||
v-model="newBatch.description"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
rows="3"
|
||||
placeholder="Describe this batch"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<rs-button
|
||||
variant="outline"
|
||||
@click="showCreateModal = false"
|
||||
:disabled="isCreating"
|
||||
>Cancel</rs-button
|
||||
>
|
||||
<rs-button @click="createBatch" :loading="isCreating" :disabled="isCreating">
|
||||
{{ isCreating ? "Creating..." : "Create Batch" }}
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, watch } from "vue";
|
||||
import { useToast } from "vue-toastification";
|
||||
|
||||
definePageMeta({
|
||||
title: "Batch Processing",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue & Scheduler",
|
||||
path: "/notification/queue-scheduler",
|
||||
},
|
||||
{
|
||||
name: "Batch Processing",
|
||||
path: "/notification/queue-scheduler/batch",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Basic stats
|
||||
const stats = ref({
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
// Batches list
|
||||
const batches = ref([]);
|
||||
|
||||
// Loading states
|
||||
const isLoading = ref(false);
|
||||
const isCreating = ref(false);
|
||||
|
||||
// Modal state
|
||||
const showCreateModal = ref(false);
|
||||
|
||||
// Form data
|
||||
const newBatch = ref({
|
||||
name: "",
|
||||
type: "",
|
||||
description: "",
|
||||
scheduledAt: "",
|
||||
template: "",
|
||||
segment: "",
|
||||
priority: "medium",
|
||||
});
|
||||
|
||||
// Templates and segments (will be fetched)
|
||||
const templates = ref([]);
|
||||
const segments = ref([]);
|
||||
|
||||
// Fetch data on mount
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchStats(), fetchBatches(), fetchTemplates(), fetchSegments()]);
|
||||
});
|
||||
|
||||
// Watch for page changes
|
||||
watch(() => pagination.value.page, fetchBatches);
|
||||
|
||||
// Fetch functions
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await $fetch("/api/notifications/batch/stats");
|
||||
if (response.success) {
|
||||
stats.value = response.data;
|
||||
} else {
|
||||
throw new Error(response.statusMessage || "Failed to fetch statistics");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching stats:", error);
|
||||
// Show error notification
|
||||
useToast().error("Failed to fetch statistics");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBatches() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const response = await $fetch("/api/notifications/batch", {
|
||||
params: {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
batches.value = response.data.batches;
|
||||
pagination.value = {
|
||||
...pagination.value,
|
||||
total: response.data.pagination.total,
|
||||
totalPages: response.data.pagination.totalPages,
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.statusMessage || "Failed to fetch batches");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching batches:", error);
|
||||
useToast().error("Failed to fetch batches");
|
||||
batches.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplates() {
|
||||
try {
|
||||
const response = await $fetch("/api/notifications/templates");
|
||||
if (Array.isArray(response)) {
|
||||
templates.value = response;
|
||||
} else if (response && response.success && Array.isArray(response.data)) {
|
||||
templates.value = response.data;
|
||||
} else {
|
||||
templates.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching templates:", error);
|
||||
templates.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSegments() {
|
||||
try {
|
||||
const response = await $fetch("/api/notifications/segments");
|
||||
if (Array.isArray(response)) {
|
||||
segments.value = response;
|
||||
} else if (response && response.success && Array.isArray(response.data)) {
|
||||
segments.value = response.data;
|
||||
} else {
|
||||
segments.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching segments:", error);
|
||||
segments.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Create batch
|
||||
async function createBatch() {
|
||||
try {
|
||||
isCreating.value = true;
|
||||
|
||||
// Validate form
|
||||
if (!newBatch.value.name || !newBatch.value.type) {
|
||||
useToast().error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a copy of the batch data for submission
|
||||
const batchData = { ...newBatch.value };
|
||||
|
||||
// Format scheduledAt if it exists
|
||||
if (batchData.scheduledAt) {
|
||||
try {
|
||||
const date = new Date(batchData.scheduledAt);
|
||||
if (isNaN(date.getTime())) {
|
||||
useToast().error("Invalid scheduled date");
|
||||
return;
|
||||
}
|
||||
batchData.scheduledAt = date.toISOString();
|
||||
} catch (error) {
|
||||
useToast().error("Invalid scheduled date");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await $fetch("/api/notifications/batch", {
|
||||
method: "POST",
|
||||
body: batchData,
|
||||
});
|
||||
|
||||
if (!response || !response.success) {
|
||||
throw new Error(response?.statusMessage || "Failed to create batch");
|
||||
}
|
||||
|
||||
// Show success message
|
||||
useToast().success("Batch created successfully");
|
||||
|
||||
// Reset form
|
||||
newBatch.value = {
|
||||
name: "",
|
||||
type: "",
|
||||
description: "",
|
||||
scheduledAt: "",
|
||||
template: "",
|
||||
segment: "",
|
||||
priority: "medium",
|
||||
};
|
||||
|
||||
// Close modal
|
||||
showCreateModal.value = false;
|
||||
|
||||
// Refresh data
|
||||
await Promise.all([fetchStats(), fetchBatches()]);
|
||||
} catch (error) {
|
||||
console.error("Error creating batch:", error);
|
||||
useToast().error(error.message || "Failed to create batch");
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function for status color
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
draft: "bg-gray-500",
|
||||
scheduled: "bg-blue-500",
|
||||
sending: "bg-yellow-500",
|
||||
sent: "bg-green-500",
|
||||
failed: "bg-red-500",
|
||||
};
|
||||
return colors[status] || "bg-gray-500";
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refreshBatches = async () => {
|
||||
await Promise.all([fetchStats(), fetchBatches()]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
192
pages/notification/queue/index.vue
Normal file
192
pages/notification/queue/index.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-schedule"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Queue</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">Manage notification queues and scheduled tasks.</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Basic Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<rs-card v-for="(stat, index) in queueStats" :key="index">
|
||||
<div class="p-4 text-center">
|
||||
<div v-if="isLoading" class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<h3 :class="['text-2xl font-bold', stat.colorClass]">
|
||||
{{ stat.value }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">{{ stat.label }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Main Features -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<rs-card
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="cursor-pointer hover:shadow-md transition-shadow"
|
||||
@click="navigateTo(feature.path)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" :name="feature.icon"></Icon>
|
||||
<h3 class="font-semibold text-primary">{{ feature.title }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600 text-sm">{{ feature.description }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<rs-button variant="outline" size="sm" class="w-full"> Open </rs-button>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<rs-alert v-if="error" variant="danger" class="mt-4">
|
||||
{{ error }}
|
||||
</rs-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Queue & Scheduler",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue",
|
||||
path: "/notification/queue",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { $swal } = useNuxtApp();
|
||||
|
||||
// Reactive state
|
||||
const isLoading = ref(true);
|
||||
const error = ref(null);
|
||||
const queueStats = ref([]);
|
||||
|
||||
// Hardcoded features
|
||||
const features = ref([
|
||||
{
|
||||
title: "Queue Monitor",
|
||||
description: "View and manage current notification queues",
|
||||
icon: "ic:outline-monitor",
|
||||
path: "/notification/queue/monitor",
|
||||
},
|
||||
{
|
||||
title: "Performance",
|
||||
description: "Check system performance and metrics",
|
||||
icon: "ic:outline-speed",
|
||||
path: "/notification/queue/performance",
|
||||
},
|
||||
// {
|
||||
// title: "Batch Processing",
|
||||
// description: "Process notifications in batches",
|
||||
// icon: "ic:outline-batch-prediction",
|
||||
// path: "/notification/queue/batch",
|
||||
// },
|
||||
{
|
||||
title: "Failed Jobs",
|
||||
description: "Handle and retry failed notifications",
|
||||
icon: "ic:outline-refresh",
|
||||
path: "/notification/queue/retry",
|
||||
},
|
||||
]);
|
||||
|
||||
// Fetch queue statistics
|
||||
async function fetchQueueStats() {
|
||||
try {
|
||||
const { data } = await useFetch("/api/notifications/queue/stats", {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
console.log(data.value);
|
||||
|
||||
if (data.value?.success) {
|
||||
queueStats.value = [
|
||||
{
|
||||
value: data.value.data.pending,
|
||||
label: "Pending Jobs",
|
||||
colorClass: "text-primary",
|
||||
},
|
||||
{
|
||||
value: data.value.data.completed,
|
||||
label: "Completed Today",
|
||||
colorClass: "text-green-600",
|
||||
},
|
||||
{
|
||||
value: data.value.data.failed,
|
||||
label: "Failed Jobs",
|
||||
colorClass: "text-red-600",
|
||||
},
|
||||
];
|
||||
} else {
|
||||
throw new Error(data.value?.message || "Failed to fetch queue statistics");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching queue stats:", err);
|
||||
error.value = "Failed to load queue statistics. Please try again later.";
|
||||
|
||||
// Set default values for stats
|
||||
queueStats.value = [
|
||||
{ value: "-", label: "Pending Jobs", colorClass: "text-primary" },
|
||||
{ value: "-", label: "Completed Today", colorClass: "text-green-600" },
|
||||
{ value: "-", label: "Failed Jobs", colorClass: "text-red-600" },
|
||||
];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize data
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
await fetchQueueStats();
|
||||
} catch (err) {
|
||||
console.error("Error initializing queue page:", err);
|
||||
error.value = "Failed to initialize the page. Please refresh to try again.";
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-refresh stats every 30 seconds
|
||||
let refreshInterval;
|
||||
onMounted(() => {
|
||||
refreshInterval = setInterval(async () => {
|
||||
await fetchQueueStats();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
339
pages/notification/queue/monitor.vue
Normal file
339
pages/notification/queue/monitor.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-monitor"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Queue Monitor</h1>
|
||||
</div>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshData"
|
||||
:loading="isLoading"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">Monitor current notification queues and job statuses.</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<rs-alert v-if="error" variant="danger" class="mb-6">
|
||||
{{ error }}
|
||||
</rs-alert>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<rs-card v-for="i in 4" :key="i">
|
||||
<div class="p-4 text-center">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2 animate-pulse"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto animate-pulse"></div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Stats -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-blue-600">{{ stats.pending }}</h3>
|
||||
<p class="text-sm text-gray-600">Pending</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-yellow-600">
|
||||
{{ stats.processing || 0 }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Processing</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-green-600">
|
||||
{{ stats.completed }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Completed</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-red-600">{{ stats.failed }}</h3>
|
||||
<p class="text-sm text-gray-600">Failed</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Job List -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-primary">Recent Jobs</h3>
|
||||
<div>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="px-2 py-1 border border-gray-300 rounded-md text-sm mr-2"
|
||||
@change="fetchJobs()"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="queued">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="isLoadingJobs" class="p-8 text-center">
|
||||
<div
|
||||
class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"
|
||||
></div>
|
||||
<p class="mt-2 text-gray-600">Loading jobs...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="jobs.length === 0" class="p-8 text-center">
|
||||
<Icon name="ic:outline-info" class="text-3xl text-gray-400 mb-2" />
|
||||
<p class="text-gray-600">No jobs found</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(job, index) in jobs"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full mr-3"
|
||||
:class="getStatusColor(job.status)"
|
||||
></div>
|
||||
<div>
|
||||
<p class="font-medium">{{ job.type }} - {{ truncateId(job.id) }}</p>
|
||||
<p class="text-sm text-gray-600">{{ job.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium capitalize">{{ job.status }}</p>
|
||||
<p class="text-xs text-gray-500">{{ formatTime(job.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="pagination.totalPages > 1" class="flex justify-center mt-4">
|
||||
<button
|
||||
class="px-3 py-1 border border-gray-300 rounded-l-md disabled:opacity-50"
|
||||
:disabled="pagination.page === 1"
|
||||
@click="changePage(pagination.page - 1)"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div class="px-3 py-1 border-t border-b border-gray-300 text-sm">
|
||||
{{ pagination.page }} / {{ pagination.totalPages }}
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 border border-gray-300 rounded-r-md disabled:opacity-50"
|
||||
:disabled="pagination.page === pagination.totalPages"
|
||||
@click="changePage(pagination.page + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Queue Monitor",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue",
|
||||
path: "/notification/queue",
|
||||
},
|
||||
{
|
||||
name: "Monitor",
|
||||
path: "/notification/queue/monitor",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Loading and error state
|
||||
const isLoading = ref(false);
|
||||
const isLoadingJobs = ref(false);
|
||||
const error = ref(null);
|
||||
const statusFilter = ref("");
|
||||
|
||||
// Stats data
|
||||
const stats = ref({
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
// Jobs data with pagination
|
||||
const jobs = ref([]);
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalItems: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Fetch stats from API
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const { data } = await useFetch("/api/notifications/queue/stats");
|
||||
|
||||
if (!data.value) throw new Error("Failed to fetch statistics");
|
||||
|
||||
if (data.value?.success) {
|
||||
stats.value = {
|
||||
pending: data.value.data.pending || 0,
|
||||
processing: data.value.data.processing || 0,
|
||||
completed: data.value.data.completed || 0,
|
||||
failed: data.value.data.failed || 0,
|
||||
};
|
||||
} else {
|
||||
throw new Error(data.value.statusMessage || "Failed to fetch statistics");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching stats:", error);
|
||||
error.value = `Failed to load statistics: ${error.message}`;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch jobs from API
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
isLoadingJobs.value = true;
|
||||
|
||||
const params = {
|
||||
page: pagination.value.page,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
if (statusFilter.value) {
|
||||
params.status = statusFilter.value;
|
||||
}
|
||||
|
||||
const { data } = await useFetch("/api/notifications/queue/jobs", {
|
||||
query: params,
|
||||
});
|
||||
|
||||
if (!data.value) throw new Error("Failed to fetch jobs");
|
||||
|
||||
if (data.value?.success) {
|
||||
jobs.value = data.value.data.jobs;
|
||||
pagination.value = data.value.data.pagination;
|
||||
} else {
|
||||
throw new Error(data.value.statusMessage || "Failed to fetch jobs");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching jobs:", error);
|
||||
error.value = `Failed to load jobs: ${error.message}`;
|
||||
jobs.value = [];
|
||||
} finally {
|
||||
isLoadingJobs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Change page
|
||||
const changePage = (page) => {
|
||||
pagination.value.page = page;
|
||||
fetchJobs();
|
||||
};
|
||||
|
||||
// Status color mapping
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
queued: "bg-blue-500",
|
||||
pending: "bg-blue-500",
|
||||
processing: "bg-yellow-500",
|
||||
completed: "bg-green-500",
|
||||
sent: "bg-green-500",
|
||||
failed: "bg-red-500",
|
||||
};
|
||||
return colors[status] || "bg-gray-500";
|
||||
};
|
||||
|
||||
// Format time for display
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return "Unknown";
|
||||
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (isNaN(diffMins)) return "Unknown";
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffMins < 1440) {
|
||||
const hours = Math.floor(diffMins / 60);
|
||||
return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
||||
}
|
||||
|
||||
return date.toLocaleString();
|
||||
} catch (e) {
|
||||
console.error("Error formatting time:", e);
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
// Truncate long IDs
|
||||
const truncateId = (id) => {
|
||||
if (!id) return "Unknown";
|
||||
if (id.length > 8) {
|
||||
return id.substring(0, 8) + "...";
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refreshData = async () => {
|
||||
await Promise.all([fetchStats(), fetchJobs()]);
|
||||
};
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
let refreshInterval;
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
refreshInterval = setInterval(refreshData, 30000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
548
pages/notification/queue/performance.vue
Normal file
548
pages/notification/queue/performance.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-speed"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Performance</h1>
|
||||
</div>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshMetrics"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">Monitor system performance and queue processing metrics.</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<rs-alert v-if="error" variant="danger" class="mb-6">
|
||||
{{ error }}
|
||||
</rs-alert>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex justify-center items-center py-12 text-gray-600">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
|
||||
<span class="ml-2">Loading metrics...</span>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Key Metrics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-blue-600">
|
||||
{{ metrics.throughput }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Messages/min</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-green-600">{{ metrics.uptime }}%</h3>
|
||||
<p class="text-sm text-gray-600">System Uptime</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-purple-600">
|
||||
{{ metrics.workers }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Active Workers</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<h3 class="text-2xl font-bold text-orange-600">{{ metrics.queueLoad }}%</h3>
|
||||
<p class="text-sm text-gray-600">Queue Load</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Performance Summary -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Throughput Summary -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-primary">Throughput Summary</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<span class="text-gray-600">Current Rate</span>
|
||||
<span class="font-bold">{{ throughput.current }}/min</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<span class="text-gray-600">Peak Today</span>
|
||||
<span class="font-bold">{{ throughput.peak }}/min</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<span class="text-gray-600">Average</span>
|
||||
<span class="font-bold">{{ throughput.average }}/min</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- System Status -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-primary">System Status</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<span class="text-gray-600">Uptime Today</span>
|
||||
<span class="font-bold text-green-600">
|
||||
{{ systemStatus.uptimeToday }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<span class="text-gray-600">Response Time</span>
|
||||
<span class="font-bold">{{ systemStatus.responseTime }}ms</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
|
||||
<span class="text-gray-600">Error Rate</span>
|
||||
<span class="font-bold text-red-600">{{ systemStatus.errorRate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Historical Performance Chart -->
|
||||
<rs-card class="mt-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-primary">Historical Performance</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<select
|
||||
v-model="historyFilter.metric"
|
||||
class="px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
@change="fetchHistoricalData"
|
||||
>
|
||||
<option value="throughput">Throughput</option>
|
||||
<option value="error_rate">Error Rate</option>
|
||||
<option value="response_time">Response Time</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="historyFilter.period"
|
||||
class="px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
@change="fetchHistoricalData"
|
||||
>
|
||||
<option value="hour">Last Hour</option>
|
||||
<option value="day">Last 24 Hours</option>
|
||||
<option value="week">Last Week</option>
|
||||
<option value="month">Last Month</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="isLoadingHistory" class="flex flex-col items-center justify-center h-64 bg-gray-50">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
|
||||
<p class="mt-4 text-gray-500">Loading historical data...</p>
|
||||
</div>
|
||||
<div v-else-if="historyError" class="flex flex-col items-center justify-center h-64 bg-red-50">
|
||||
<Icon name="ic:outline-error" class="text-6xl text-red-300" />
|
||||
<p class="mt-4 text-red-500">{{ historyError }}</p>
|
||||
<rs-button variant="outline" size="sm" class="mt-4" @click="fetchHistoricalData">
|
||||
<Icon name="ic:outline-refresh" class="mr-1" />
|
||||
Retry
|
||||
</rs-button>
|
||||
</div>
|
||||
<div v-else-if="!historicalData.dataPoints || historicalData.dataPoints.length === 0" class="flex flex-col items-center justify-center h-64 bg-gray-50">
|
||||
<Icon name="ic:outline-insert-chart" class="text-6xl text-gray-300" />
|
||||
<p class="mt-4 text-gray-500">No historical data available for the selected period</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Chart Summary -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||
<div class="bg-green-50 p-2 rounded text-center">
|
||||
<p class="text-sm text-green-700">Maximum</p>
|
||||
<p class="text-lg font-bold text-green-600">{{ historicalData.summary.max }}{{ getMetricUnit() }}</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 p-2 rounded text-center">
|
||||
<p class="text-sm text-blue-700">Average</p>
|
||||
<p class="text-lg font-bold text-blue-600">{{ historicalData.summary.avg }}{{ getMetricUnit() }}</p>
|
||||
</div>
|
||||
<div class="bg-purple-50 p-2 rounded text-center">
|
||||
<p class="text-sm text-purple-700">Trend</p>
|
||||
<p class="text-lg font-bold text-purple-600">
|
||||
<Icon
|
||||
:name="historicalData.summary.trend === 'increasing' ? 'ic:outline-trending-up' : 'ic:outline-trending-down'"
|
||||
class="inline-block mr-1"
|
||||
/>
|
||||
{{ historicalData.summary.trend }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple Line Chart -->
|
||||
<div class="h-64 w-full relative">
|
||||
<!-- Y-axis labels -->
|
||||
<div class="absolute top-0 left-0 h-full flex flex-col justify-between text-xs text-gray-500">
|
||||
<div>{{ Math.ceil(historicalData.summary.max * 1.1) }}{{ getMetricUnit() }}</div>
|
||||
<div>{{ Math.ceil(historicalData.summary.max * 0.75) }}{{ getMetricUnit() }}</div>
|
||||
<div>{{ Math.ceil(historicalData.summary.max * 0.5) }}{{ getMetricUnit() }}</div>
|
||||
<div>{{ Math.ceil(historicalData.summary.max * 0.25) }}{{ getMetricUnit() }}</div>
|
||||
<div>0{{ getMetricUnit() }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart area -->
|
||||
<div class="ml-10 h-full relative">
|
||||
<!-- Horizontal grid lines -->
|
||||
<div class="absolute top-0 left-0 w-full h-full border-b border-gray-200">
|
||||
<div class="border-t border-gray-200 h-1/4"></div>
|
||||
<div class="border-t border-gray-200 h-1/4"></div>
|
||||
<div class="border-t border-gray-200 h-1/4"></div>
|
||||
<div class="border-t border-gray-200 h-1/4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Line chart -->
|
||||
<svg class="absolute top-0 left-0 w-full h-full overflow-visible">
|
||||
<path
|
||||
:d="getChartPath()"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="2"
|
||||
></path>
|
||||
|
||||
<!-- Data points -->
|
||||
<circle
|
||||
v-for="(point, i) in chartPoints"
|
||||
:key="i"
|
||||
:cx="point.x"
|
||||
:cy="point.y"
|
||||
r="3"
|
||||
fill="#3b82f6"
|
||||
></circle>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- X-axis labels (show some key timestamps) -->
|
||||
<div class="mt-2 ml-10 flex justify-between text-xs text-gray-500">
|
||||
<div v-for="(label, i) in getXAxisLabels()" :key="i">
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
title: "Performance",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue",
|
||||
path: "/notification/queue",
|
||||
},
|
||||
{
|
||||
name: "Performance",
|
||||
path: "/notification/queue/performance",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Reactive state
|
||||
const isLoading = ref(true);
|
||||
const error = ref(null);
|
||||
const metrics = ref({
|
||||
throughput: "0",
|
||||
uptime: "0",
|
||||
workers: "0",
|
||||
queueLoad: "0",
|
||||
});
|
||||
|
||||
const throughput = ref({
|
||||
current: "0",
|
||||
peak: "0",
|
||||
average: "0",
|
||||
});
|
||||
|
||||
const systemStatus = ref({
|
||||
uptimeToday: "0",
|
||||
responseTime: "0",
|
||||
errorRate: "0",
|
||||
});
|
||||
|
||||
// Historical data state
|
||||
const isLoadingHistory = ref(true);
|
||||
const historyError = ref(null);
|
||||
const historicalData = ref({
|
||||
dataPoints: [],
|
||||
summary: {
|
||||
max: "0",
|
||||
avg: "0",
|
||||
trend: "stable", // 'increasing', 'decreasing', or 'stable'
|
||||
},
|
||||
});
|
||||
|
||||
const historyFilter = ref({
|
||||
metric: "throughput",
|
||||
period: "hour",
|
||||
});
|
||||
|
||||
// Fetch metrics from API
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const { data } = await useFetch("/api/notifications/queue/performance", {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!data.value) {
|
||||
throw new Error("Failed to fetch metrics data");
|
||||
}
|
||||
|
||||
if (data.value?.success) {
|
||||
const performanceData = data.value.data;
|
||||
|
||||
// Update metrics with fetched data
|
||||
metrics.value = performanceData.metrics || {
|
||||
throughput: "0",
|
||||
uptime: "0",
|
||||
workers: "0",
|
||||
queueLoad: "0",
|
||||
};
|
||||
|
||||
// Update throughput data
|
||||
throughput.value = performanceData.throughput || {
|
||||
current: "0",
|
||||
peak: "0",
|
||||
average: "0",
|
||||
};
|
||||
|
||||
// Update system status
|
||||
systemStatus.value = performanceData.systemStatus || {
|
||||
uptimeToday: "0",
|
||||
responseTime: "0",
|
||||
errorRate: "0",
|
||||
};
|
||||
} else {
|
||||
throw new Error(data.value?.statusMessage || "Failed to fetch metrics");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching metrics:", err);
|
||||
error.value = err.message || "Failed to load performance metrics";
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh metrics
|
||||
const refreshMetrics = () => {
|
||||
fetchMetrics();
|
||||
};
|
||||
|
||||
// Fetch historical data
|
||||
const fetchHistoricalData = async () => {
|
||||
isLoadingHistory.value = true;
|
||||
historyError.value = null;
|
||||
try {
|
||||
const { data } = await useFetch("/api/notifications/queue/history", {
|
||||
method: "GET",
|
||||
query: {
|
||||
metric: historyFilter.value.metric,
|
||||
period: historyFilter.value.period,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data.value) {
|
||||
throw new Error("Failed to fetch historical data");
|
||||
}
|
||||
|
||||
if (data.value?.success) {
|
||||
historicalData.value = data.value.data;
|
||||
} else {
|
||||
throw new Error(data.value?.statusMessage || "Failed to fetch historical data");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching historical data:", err);
|
||||
historyError.value = err.message || "Failed to load historical data";
|
||||
} finally {
|
||||
isLoadingHistory.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Get chart path for the selected metric
|
||||
const getChartPath = () => {
|
||||
if (!historicalData.value.dataPoints || historicalData.value.dataPoints.length < 2) {
|
||||
return "M 0 0"; // Return an empty path if no data points
|
||||
}
|
||||
|
||||
const points = historicalData.value.dataPoints;
|
||||
const maxVal = historicalData.value.summary.max || 1;
|
||||
const width = 100 * (points.length - 1); // Total width based on points
|
||||
|
||||
const path = [];
|
||||
points.forEach((point, i) => {
|
||||
// Calculate x position (evenly spaced)
|
||||
const x = (i / (points.length - 1)) * width;
|
||||
|
||||
// Calculate y position (inverted, as SVG y=0 is top)
|
||||
// Scale value to fit in the chart height (0-100%)
|
||||
const y = 100 - ((point.value / maxVal) * 100);
|
||||
|
||||
if (i === 0) {
|
||||
path.push(`M ${x} ${y}`);
|
||||
} else {
|
||||
path.push(`L ${x} ${y}`);
|
||||
}
|
||||
});
|
||||
|
||||
return path.join(" ");
|
||||
};
|
||||
|
||||
// Get data points for the chart
|
||||
const chartPoints = computed(() => {
|
||||
if (!historicalData.value.dataPoints) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const points = historicalData.value.dataPoints;
|
||||
const maxVal = historicalData.value.summary.max || 1;
|
||||
const width = 100 * (points.length - 1); // Total width based on points
|
||||
|
||||
return points.map((point, i) => ({
|
||||
x: (i / (points.length - 1)) * width,
|
||||
y: 100 - ((point.value / maxVal) * 100),
|
||||
}));
|
||||
});
|
||||
|
||||
// Get Y-axis labels
|
||||
const getYAxisLabels = () => {
|
||||
if (!historicalData.value.dataPoints) {
|
||||
return [];
|
||||
}
|
||||
const minY = Math.min(...historicalData.value.dataPoints.map(p => p.y));
|
||||
const maxY = Math.max(...historicalData.value.dataPoints.map(p => p.y));
|
||||
const yRange = maxY - minY;
|
||||
const labels = [];
|
||||
if (yRange > 0) {
|
||||
const step = yRange / 4;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
labels.push(Math.ceil(minY + i * step));
|
||||
}
|
||||
} else {
|
||||
labels.push(minY);
|
||||
labels.push(0);
|
||||
}
|
||||
return labels;
|
||||
};
|
||||
|
||||
// Get X-axis labels
|
||||
const getXAxisLabels = () => {
|
||||
if (!historicalData.value.dataPoints || historicalData.value.dataPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const points = historicalData.value.dataPoints;
|
||||
const numLabels = 5; // Number of labels to show
|
||||
const step = Math.max(1, Math.floor(points.length / (numLabels - 1)));
|
||||
const labels = [];
|
||||
|
||||
for (let i = 0; i < points.length; i += step) {
|
||||
if (labels.length < numLabels) {
|
||||
const date = new Date(points[i].timestamp);
|
||||
|
||||
// Format based on the period
|
||||
let label;
|
||||
switch (historyFilter.value.period) {
|
||||
case 'hour':
|
||||
label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
break;
|
||||
case 'day':
|
||||
label = date.toLocaleTimeString([], { hour: '2-digit' }) + 'h';
|
||||
break;
|
||||
case 'week':
|
||||
case 'month':
|
||||
label = date.toLocaleDateString([], { day: 'numeric', month: 'short' });
|
||||
break;
|
||||
}
|
||||
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure we add the last point
|
||||
if (labels.length < numLabels) {
|
||||
const date = new Date(points[points.length - 1].timestamp);
|
||||
let label;
|
||||
switch (historyFilter.value.period) {
|
||||
case 'hour':
|
||||
label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
break;
|
||||
case 'day':
|
||||
label = date.toLocaleTimeString([], { hour: '2-digit' }) + 'h';
|
||||
break;
|
||||
case 'week':
|
||||
case 'month':
|
||||
label = date.toLocaleDateString([], { day: 'numeric', month: 'short' });
|
||||
break;
|
||||
}
|
||||
labels.push(label);
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
// Get metric unit based on selected metric
|
||||
const getMetricUnit = () => {
|
||||
if (historyFilter.value.metric === "throughput") {
|
||||
return "/min";
|
||||
} else if (historyFilter.value.metric === "error_rate") {
|
||||
return "%";
|
||||
} else if (historyFilter.value.metric === "response_time") {
|
||||
return "ms";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// Fetch metrics on component mount
|
||||
onMounted(() => {
|
||||
fetchMetrics();
|
||||
fetchHistoricalData(); // Fetch initial historical data
|
||||
});
|
||||
|
||||
// Auto-refresh every 60 seconds
|
||||
let refreshInterval;
|
||||
onMounted(() => {
|
||||
refreshInterval = setInterval(fetchMetrics, 60000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
803
pages/notification/queue/persistence.vue
Normal file
803
pages/notification/queue/persistence.vue
Normal file
@@ -0,0 +1,803 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-storage"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Queue Persistence Configuration</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
|
||||
<span class="text-sm text-gray-600">Persistence Active</span>
|
||||
</div>
|
||||
<rs-button variant="outline" size="sm" @click="testPersistence">
|
||||
<Icon class="mr-1" name="ic:outline-bug-report"></Icon>
|
||||
Test Recovery
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Configure queue data persistence to ensure notifications survive system restarts and failures.
|
||||
Critical for maintaining queue integrity and preventing message loss during system maintenance.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Persistence Status Overview -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(metric, index) in persistenceMetrics"
|
||||
:key="index"
|
||||
class="transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-4 flex justify-center items-center rounded-2xl"
|
||||
:class="metric.bgColor"
|
||||
>
|
||||
<Icon class="text-2xl" :class="metric.iconColor" :name="metric.icon"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-2xl leading-tight" :class="metric.valueColor">
|
||||
{{ metric.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ metric.title }}
|
||||
</span>
|
||||
<div class="flex items-center mt-1" v-if="metric.status">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full mr-1"
|
||||
:class="{
|
||||
'bg-green-500': metric.status === 'healthy',
|
||||
'bg-yellow-500': metric.status === 'warning',
|
||||
'bg-red-500': metric.status === 'error'
|
||||
}"
|
||||
></div>
|
||||
<span class="text-xs capitalize" :class="{
|
||||
'text-green-600': metric.status === 'healthy',
|
||||
'text-yellow-600': metric.status === 'warning',
|
||||
'text-red-600': metric.status === 'error'
|
||||
}">
|
||||
{{ metric.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Storage Configuration -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Primary Storage -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-database"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">Primary Storage Configuration</h3>
|
||||
</div>
|
||||
<rs-button variant="outline" size="sm" @click="showStorageModal = true">
|
||||
<Icon class="mr-1" name="ic:outline-settings"></Icon>
|
||||
Configure
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<!-- Storage Type -->
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">Storage Type</p>
|
||||
<p class="text-sm text-gray-600">{{ storageConfig.type }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': storageConfig.status === 'connected',
|
||||
'bg-red-100 text-red-800': storageConfig.status === 'disconnected',
|
||||
'bg-yellow-100 text-yellow-800': storageConfig.status === 'reconnecting'
|
||||
}"
|
||||
>
|
||||
{{ storageConfig.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Details -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center p-3 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-gray-600">Connection Pool</p>
|
||||
<p class="font-bold text-blue-600">{{ storageConfig.connectionPool }}/{{ storageConfig.maxConnections }}</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-green-50 rounded-lg">
|
||||
<p class="text-sm text-gray-600">Response Time</p>
|
||||
<p class="font-bold text-green-600">{{ storageConfig.responseTime }}ms</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Metrics -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Used Space</span>
|
||||
<span class="text-sm font-medium">{{ storageConfig.usedSpace }} / {{ storageConfig.totalSpace }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full"
|
||||
:style="{ width: storageConfig.usagePercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Backup -->
|
||||
<div class="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium text-green-800">Last Backup</p>
|
||||
<p class="text-sm text-green-600">{{ storageConfig.lastBackup }}</p>
|
||||
</div>
|
||||
<Icon class="text-green-600" name="ic:outline-backup"></Icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Backup & Recovery -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-backup"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">Backup & Recovery</h3>
|
||||
</div>
|
||||
<rs-button variant="outline" size="sm" @click="createBackup">
|
||||
<Icon class="mr-1" name="ic:outline-backup"></Icon>
|
||||
Create Backup
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<!-- Backup Schedule -->
|
||||
<div class="p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="font-medium">Automatic Backups</p>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="backupConfig.autoBackupEnabled"
|
||||
class="mr-2"
|
||||
@change="updateBackupConfig"
|
||||
>
|
||||
<span class="text-sm">{{ backupConfig.autoBackupEnabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">
|
||||
Frequency: {{ backupConfig.frequency }} |
|
||||
Retention: {{ backupConfig.retention }} days
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Recent Backups -->
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 mb-2">Recent Backups</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(backup, index) in recentBackups"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-2 bg-gray-50 rounded"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon
|
||||
class="mr-2 text-sm"
|
||||
:class="{
|
||||
'text-green-500': backup.status === 'completed',
|
||||
'text-yellow-500': backup.status === 'in-progress',
|
||||
'text-red-500': backup.status === 'failed'
|
||||
}"
|
||||
:name="backup.status === 'completed' ? 'ic:outline-check-circle' :
|
||||
backup.status === 'in-progress' ? 'ic:outline-hourglass-empty' :
|
||||
'ic:outline-error'"
|
||||
></Icon>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ backup.name }}</p>
|
||||
<p class="text-xs text-gray-500">{{ backup.size }} • {{ backup.timestamp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@click="downloadBackup(backup)"
|
||||
:disabled="backup.status !== 'completed'"
|
||||
>
|
||||
<Icon class="text-xs" name="ic:outline-download"></Icon>
|
||||
</rs-button>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@click="restoreBackup(backup)"
|
||||
:disabled="backup.status !== 'completed'"
|
||||
>
|
||||
<Icon class="text-xs" name="ic:outline-restore"></Icon>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recovery Test -->
|
||||
<div class="p-3 bg-yellow-50 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-yellow-800">Recovery Test</p>
|
||||
<p class="text-sm text-yellow-600">Last test: {{ recoveryTest.lastTest }}</p>
|
||||
</div>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="runRecoveryTest"
|
||||
>
|
||||
Run Test
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Queue Recovery Status -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-restore"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">Queue Recovery Status</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-600">Last System Restart: {{ lastSystemRestart }}</span>
|
||||
<rs-button variant="outline" size="sm" @click="showRecoveryDetails = true">
|
||||
<Icon class="mr-1" name="ic:outline-info"></Icon>
|
||||
View Details
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Recovery Statistics -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-medium text-gray-700">Recovery Statistics</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Jobs Recovered</span>
|
||||
<span class="font-medium text-green-600">{{ recoveryStats.jobsRecovered.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Jobs Lost</span>
|
||||
<span class="font-medium text-red-600">{{ recoveryStats.jobsLost }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Recovery Time</span>
|
||||
<span class="font-medium">{{ recoveryStats.recoveryTime }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-600">Success Rate</span>
|
||||
<span class="font-medium text-blue-600">{{ recoveryStats.successRate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recovery Timeline -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-medium text-gray-700">Recovery Timeline</h4>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(event, index) in recoveryTimeline"
|
||||
:key="index"
|
||||
class="flex items-start"
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 rounded-full mt-1 mr-3"
|
||||
:class="{
|
||||
'bg-green-500': event.status === 'completed',
|
||||
'bg-yellow-500': event.status === 'in-progress',
|
||||
'bg-red-500': event.status === 'failed'
|
||||
}"
|
||||
></div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{{ event.action }}</p>
|
||||
<p class="text-xs text-gray-500">{{ event.timestamp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue State -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-medium text-gray-700">Current Queue State</h4>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(queue, index) in queueStates"
|
||||
:key="index"
|
||||
class="p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium">{{ queue.name }}</span>
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': queue.status === 'healthy',
|
||||
'bg-yellow-100 text-yellow-800': queue.status === 'recovering',
|
||||
'bg-red-100 text-red-800': queue.status === 'error'
|
||||
}"
|
||||
>
|
||||
{{ queue.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-600">
|
||||
<span>{{ queue.count }} jobs</span>
|
||||
<span>{{ queue.lastProcessed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Persistence Configuration -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-settings"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">Persistence Settings</h3>
|
||||
</div>
|
||||
<rs-button @click="savePersistenceConfig">
|
||||
<Icon class="mr-1" name="ic:outline-save"></Icon>
|
||||
Save Configuration
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- General Settings -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-medium text-gray-700">General Settings</h4>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Persistence Mode</label>
|
||||
<select v-model="persistenceConfig.mode" class="w-full p-2 border border-gray-300 rounded-md">
|
||||
<option value="immediate">Immediate (Every job)</option>
|
||||
<option value="batch">Batch (Every N jobs)</option>
|
||||
<option value="interval">Interval (Every N seconds)</option>
|
||||
<option value="hybrid">Hybrid (Immediate + Batch)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="persistenceConfig.mode === 'batch'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Size</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="persistenceConfig.batchSize"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="persistenceConfig.mode === 'interval'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Interval (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="persistenceConfig.interval"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Data Retention (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="persistenceConfig.retentionDays"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">How long to keep completed job data</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="persistenceConfig.compressData"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Enable data compression</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recovery Settings -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-medium text-gray-700">Recovery Settings</h4>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Recovery Strategy</label>
|
||||
<select v-model="persistenceConfig.recoveryStrategy" class="w-full p-2 border border-gray-300 rounded-md">
|
||||
<option value="full">Full Recovery (All jobs)</option>
|
||||
<option value="priority">Priority Recovery (High priority first)</option>
|
||||
<option value="recent">Recent Recovery (Last N hours)</option>
|
||||
<option value="selective">Selective Recovery (Manual selection)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="persistenceConfig.recoveryStrategy === 'recent'">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Recovery Window (hours)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="persistenceConfig.recoveryWindow"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Max Recovery Time (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="persistenceConfig.maxRecoveryTime"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">Maximum time allowed for recovery process</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="persistenceConfig.autoRecovery"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Enable automatic recovery on startup</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="persistenceConfig.validateRecovery"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="text-sm text-gray-700">Validate recovered jobs before processing</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Storage Configuration Modal -->
|
||||
<rs-modal v-model="showStorageModal" title="Storage Configuration">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Storage Type</label>
|
||||
<select v-model="storageConfig.type" class="w-full p-2 border border-gray-300 rounded-md">
|
||||
<option value="Redis">Redis</option>
|
||||
<option value="PostgreSQL">PostgreSQL</option>
|
||||
<option value="MongoDB">MongoDB</option>
|
||||
<option value="MySQL">MySQL</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Connection String</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="storageConfig.connectionString"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
placeholder="redis://localhost:6379"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Max Connections</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="storageConfig.maxConnections"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Connection Timeout (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="storageConfig.connectionTimeout"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="100"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<rs-button variant="outline" @click="showStorageModal = false">
|
||||
Cancel
|
||||
</rs-button>
|
||||
<rs-button @click="saveStorageConfig">
|
||||
Save Configuration
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Queue Persistence",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue & Scheduler",
|
||||
path: "/notification/queue-scheduler",
|
||||
},
|
||||
{
|
||||
name: "Persistence",
|
||||
path: "/notification/queue-scheduler/persistence",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const showStorageModal = ref(false);
|
||||
const showRecoveryDetails = ref(false);
|
||||
|
||||
// Persistence metrics
|
||||
const persistenceMetrics = ref([
|
||||
{
|
||||
title: "Storage Health",
|
||||
value: "Healthy",
|
||||
icon: "ic:outline-health-and-safety",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-600",
|
||||
valueColor: "text-green-600",
|
||||
status: "healthy"
|
||||
},
|
||||
{
|
||||
title: "Persisted Jobs",
|
||||
value: "847,293",
|
||||
icon: "ic:outline-storage",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-600",
|
||||
valueColor: "text-blue-600"
|
||||
},
|
||||
{
|
||||
title: "Recovery Rate",
|
||||
value: "99.97%",
|
||||
icon: "ic:outline-restore",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-600",
|
||||
valueColor: "text-purple-600",
|
||||
status: "healthy"
|
||||
},
|
||||
{
|
||||
title: "Storage Usage",
|
||||
value: "67%",
|
||||
icon: "ic:outline-pie-chart",
|
||||
bgColor: "bg-yellow-100",
|
||||
iconColor: "text-yellow-600",
|
||||
valueColor: "text-yellow-600",
|
||||
status: "warning"
|
||||
}
|
||||
]);
|
||||
|
||||
// Storage configuration
|
||||
const storageConfig = ref({
|
||||
type: "Redis",
|
||||
status: "connected",
|
||||
connectionPool: 8,
|
||||
maxConnections: 20,
|
||||
responseTime: 2.3,
|
||||
usedSpace: "2.4 GB",
|
||||
totalSpace: "10 GB",
|
||||
usagePercentage: 67,
|
||||
lastBackup: "2 hours ago",
|
||||
connectionString: "redis://localhost:6379",
|
||||
connectionTimeout: 5000
|
||||
});
|
||||
|
||||
// Backup configuration
|
||||
const backupConfig = ref({
|
||||
autoBackupEnabled: true,
|
||||
frequency: "Every 6 hours",
|
||||
retention: 30
|
||||
});
|
||||
|
||||
// Recent backups
|
||||
const recentBackups = ref([
|
||||
{
|
||||
name: "queue-backup-2024-01-15-14-30",
|
||||
size: "1.2 GB",
|
||||
timestamp: "2 hours ago",
|
||||
status: "completed"
|
||||
},
|
||||
{
|
||||
name: "queue-backup-2024-01-15-08-30",
|
||||
size: "1.1 GB",
|
||||
timestamp: "8 hours ago",
|
||||
status: "completed"
|
||||
},
|
||||
{
|
||||
name: "queue-backup-2024-01-15-02-30",
|
||||
size: "1.0 GB",
|
||||
timestamp: "14 hours ago",
|
||||
status: "completed"
|
||||
},
|
||||
{
|
||||
name: "queue-backup-2024-01-14-20-30",
|
||||
size: "987 MB",
|
||||
timestamp: "20 hours ago",
|
||||
status: "completed"
|
||||
}
|
||||
]);
|
||||
|
||||
// Recovery test
|
||||
const recoveryTest = ref({
|
||||
lastTest: "3 days ago",
|
||||
status: "passed"
|
||||
});
|
||||
|
||||
// System restart info
|
||||
const lastSystemRestart = ref("5 days ago");
|
||||
|
||||
// Recovery statistics
|
||||
const recoveryStats = ref({
|
||||
jobsRecovered: 15847,
|
||||
jobsLost: 3,
|
||||
recoveryTime: "2.3 seconds",
|
||||
successRate: 99.97
|
||||
});
|
||||
|
||||
// Recovery timeline
|
||||
const recoveryTimeline = ref([
|
||||
{
|
||||
action: "System startup detected",
|
||||
timestamp: "5 days ago, 09:15:23",
|
||||
status: "completed"
|
||||
},
|
||||
{
|
||||
action: "Storage connection established",
|
||||
timestamp: "5 days ago, 09:15:24",
|
||||
status: "completed"
|
||||
},
|
||||
{
|
||||
action: "Queue data recovery initiated",
|
||||
timestamp: "5 days ago, 09:15:25",
|
||||
status: "completed"
|
||||
},
|
||||
{
|
||||
action: "15,847 jobs recovered successfully",
|
||||
timestamp: "5 days ago, 09:15:27",
|
||||
status: "completed"
|
||||
},
|
||||
{
|
||||
action: "Queue processing resumed",
|
||||
timestamp: "5 days ago, 09:15:28",
|
||||
status: "completed"
|
||||
}
|
||||
]);
|
||||
|
||||
// Queue states
|
||||
const queueStates = ref([
|
||||
{
|
||||
name: "High Priority",
|
||||
count: 234,
|
||||
status: "healthy",
|
||||
lastProcessed: "2 seconds ago"
|
||||
},
|
||||
{
|
||||
name: "Medium Priority",
|
||||
count: 1847,
|
||||
status: "healthy",
|
||||
lastProcessed: "1 second ago"
|
||||
},
|
||||
{
|
||||
name: "Low Priority",
|
||||
count: 3421,
|
||||
status: "healthy",
|
||||
lastProcessed: "5 seconds ago"
|
||||
},
|
||||
{
|
||||
name: "Bulk Operations",
|
||||
count: 2502,
|
||||
status: "recovering",
|
||||
lastProcessed: "30 seconds ago"
|
||||
}
|
||||
]);
|
||||
|
||||
// Persistence configuration
|
||||
const persistenceConfig = ref({
|
||||
mode: "hybrid",
|
||||
batchSize: 100,
|
||||
interval: 30,
|
||||
retentionDays: 30,
|
||||
compressData: true,
|
||||
recoveryStrategy: "priority",
|
||||
recoveryWindow: 24,
|
||||
maxRecoveryTime: 300,
|
||||
autoRecovery: true,
|
||||
validateRecovery: true
|
||||
});
|
||||
|
||||
// Methods
|
||||
const testPersistence = () => {
|
||||
console.log('Running persistence test...');
|
||||
// Simulate persistence test
|
||||
};
|
||||
|
||||
const createBackup = () => {
|
||||
console.log('Creating backup...');
|
||||
// Add new backup to the list
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
recentBackups.value.unshift({
|
||||
name: `queue-backup-${timestamp}`,
|
||||
size: "1.3 GB",
|
||||
timestamp: "Just now",
|
||||
status: "in-progress"
|
||||
});
|
||||
|
||||
// Simulate completion after 3 seconds
|
||||
setTimeout(() => {
|
||||
recentBackups.value[0].status = "completed";
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const downloadBackup = (backup) => {
|
||||
console.log('Downloading backup:', backup.name);
|
||||
// Simulate download
|
||||
};
|
||||
|
||||
const restoreBackup = (backup) => {
|
||||
console.log('Restoring backup:', backup.name);
|
||||
// Simulate restore
|
||||
};
|
||||
|
||||
const runRecoveryTest = () => {
|
||||
console.log('Running recovery test...');
|
||||
recoveryTest.value.lastTest = "Just now";
|
||||
// Simulate test
|
||||
};
|
||||
|
||||
const updateBackupConfig = () => {
|
||||
console.log('Updating backup configuration:', backupConfig.value);
|
||||
// Save backup config
|
||||
};
|
||||
|
||||
const savePersistenceConfig = () => {
|
||||
console.log('Saving persistence configuration:', persistenceConfig.value);
|
||||
// Save persistence config
|
||||
};
|
||||
|
||||
const saveStorageConfig = () => {
|
||||
console.log('Saving storage configuration:', storageConfig.value);
|
||||
showStorageModal.value = false;
|
||||
// Save storage config
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
665
pages/notification/queue/priority.vue
Normal file
665
pages/notification/queue/priority.vue
Normal file
@@ -0,0 +1,665 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-priority-high"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Priority Queue Management</h1>
|
||||
</div>
|
||||
<rs-button @click="showCreatePriorityModal = true">
|
||||
<Icon class="mr-1" name="ic:outline-add"></Icon>
|
||||
Create Priority Level
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Manage different priority levels for notifications to ensure critical messages are processed first.
|
||||
Higher priority notifications will be processed before lower priority ones in the queue.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Priority Level Statistics -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(stat, index) in priorityStats"
|
||||
:key="index"
|
||||
class="transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-4 flex justify-center items-center rounded-2xl"
|
||||
:class="stat.bgColor"
|
||||
>
|
||||
<Icon class="text-2xl" :class="stat.iconColor" :name="stat.icon"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-2xl leading-tight" :class="stat.valueColor">
|
||||
{{ stat.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ stat.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Priority Levels Configuration -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-settings"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">Priority Levels</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<rs-button variant="outline" size="sm" @click="refreshPriorityLevels">
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
<rs-button variant="outline" size="sm" @click="showBulkEditModal = true">
|
||||
<Icon class="mr-1" name="ic:outline-edit"></Icon>
|
||||
Bulk Edit
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="(priority, index) in priorityLevels"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-4 border rounded-lg"
|
||||
:class="{
|
||||
'border-red-200 bg-red-50': priority.level === 'critical',
|
||||
'border-orange-200 bg-orange-50': priority.level === 'high',
|
||||
'border-yellow-200 bg-yellow-50': priority.level === 'medium',
|
||||
'border-blue-200 bg-blue-50': priority.level === 'low',
|
||||
'border-gray-200 bg-gray-50': priority.level === 'bulk'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-4 h-4 rounded-full"
|
||||
:class="{
|
||||
'bg-red-500': priority.level === 'critical',
|
||||
'bg-orange-500': priority.level === 'high',
|
||||
'bg-yellow-500': priority.level === 'medium',
|
||||
'bg-blue-500': priority.level === 'low',
|
||||
'bg-gray-500': priority.level === 'bulk'
|
||||
}"
|
||||
></div>
|
||||
<span class="font-medium text-lg">{{ priority.name }}</span>
|
||||
<span class="text-sm text-gray-500">({{ priority.level }})</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Weight</p>
|
||||
<p class="font-bold">{{ priority.weight }}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Queue Count</p>
|
||||
<p class="font-bold">{{ priority.queueCount.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Avg Processing</p>
|
||||
<p class="font-bold">{{ priority.avgProcessingTime }}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">Status</p>
|
||||
<span
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': priority.status === 'active',
|
||||
'bg-red-100 text-red-800': priority.status === 'paused',
|
||||
'bg-yellow-100 text-yellow-800': priority.status === 'throttled'
|
||||
}"
|
||||
>
|
||||
{{ priority.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="editPriority(priority)"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-edit"></Icon>
|
||||
Edit
|
||||
</rs-button>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="priority.status === 'active' ? 'text-red-600' : 'text-green-600'"
|
||||
@click="togglePriorityStatus(priority)"
|
||||
>
|
||||
<Icon
|
||||
class="mr-1"
|
||||
:name="priority.status === 'active' ? 'ic:outline-pause' : 'ic:outline-play-arrow'"
|
||||
></Icon>
|
||||
{{ priority.status === 'active' ? 'Pause' : 'Resume' }}
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Queue Processing Order -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-sort"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">Processing Order Visualization</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<p class="text-gray-600 mb-4">
|
||||
Notifications are processed in the following order based on priority weights:
|
||||
</p>
|
||||
|
||||
<!-- Processing Flow -->
|
||||
<div class="flex items-center justify-between bg-gray-50 p-4 rounded-lg">
|
||||
<div
|
||||
v-for="(level, index) in sortedPriorityLevels"
|
||||
:key="index"
|
||||
class="flex flex-col items-center"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-full flex items-center justify-center text-white font-bold text-lg mb-2"
|
||||
:class="{
|
||||
'bg-red-500': level.level === 'critical',
|
||||
'bg-orange-500': level.level === 'high',
|
||||
'bg-yellow-500': level.level === 'medium',
|
||||
'bg-blue-500': level.level === 'low',
|
||||
'bg-gray-500': level.level === 'bulk'
|
||||
}"
|
||||
>
|
||||
{{ level.weight }}
|
||||
</div>
|
||||
<span class="text-sm font-medium">{{ level.name }}</span>
|
||||
<span class="text-xs text-gray-500">{{ level.queueCount }} jobs</span>
|
||||
|
||||
<!-- Arrow -->
|
||||
<Icon
|
||||
v-if="index < sortedPriorityLevels.length - 1"
|
||||
class="text-gray-400 mt-2"
|
||||
name="ic:outline-arrow-forward"
|
||||
></Icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Rules -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<h4 class="font-medium text-blue-800 mb-2">Processing Rules</h4>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• Higher weight = Higher priority</li>
|
||||
<li>• Critical jobs always processed first</li>
|
||||
<li>• Same priority jobs use FIFO order</li>
|
||||
<li>• Bulk jobs processed during low traffic</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<h4 class="font-medium text-green-800 mb-2">Performance Impact</h4>
|
||||
<ul class="text-sm text-green-700 space-y-1">
|
||||
<li>• Critical: < 1 second processing</li>
|
||||
<li>• High: < 5 seconds processing</li>
|
||||
<li>• Medium: < 30 seconds processing</li>
|
||||
<li>• Low/Bulk: Best effort processing</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Recent Priority Queue Activity -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
|
||||
<h3 class="text-lg font-semibold text-primary">Recent Priority Queue Activity</h3>
|
||||
</div>
|
||||
<rs-button variant="outline" size="sm" @click="navigateTo('/notification/queue-scheduler/monitor')">
|
||||
View Full Monitor
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Job ID
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Priority
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Queue Time
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Processing Time
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="(job, index) in recentJobs" :key="index">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ job.id }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-red-100 text-red-800': job.priority === 'critical',
|
||||
'bg-orange-100 text-orange-800': job.priority === 'high',
|
||||
'bg-yellow-100 text-yellow-800': job.priority === 'medium',
|
||||
'bg-blue-100 text-blue-800': job.priority === 'low',
|
||||
'bg-gray-100 text-gray-800': job.priority === 'bulk'
|
||||
}"
|
||||
>
|
||||
{{ job.priority }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ job.type }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': job.status === 'completed',
|
||||
'bg-yellow-100 text-yellow-800': job.status === 'processing',
|
||||
'bg-red-100 text-red-800': job.status === 'failed',
|
||||
'bg-blue-100 text-blue-800': job.status === 'queued'
|
||||
}"
|
||||
>
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ job.queueTime }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ job.processingTime }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Create Priority Level Modal -->
|
||||
<rs-modal v-model="showCreatePriorityModal" title="Create Priority Level">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Priority Name</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="newPriority.name"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
placeholder="e.g., Emergency Alerts"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Priority Level</label>
|
||||
<select v-model="newPriority.level" class="w-full p-2 border border-gray-300 rounded-md">
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="bulk">Bulk</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Weight (1-100)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="newPriority.weight"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
max="100"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">Higher weight = Higher priority</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea
|
||||
v-model="newPriority.description"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
rows="3"
|
||||
placeholder="Describe when this priority level should be used..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Max Processing Time (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="newPriority.maxProcessingTime"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-1">Maximum time allowed for processing jobs of this priority</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<rs-button variant="outline" @click="showCreatePriorityModal = false">
|
||||
Cancel
|
||||
</rs-button>
|
||||
<rs-button @click="createPriorityLevel">
|
||||
Create Priority Level
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
|
||||
<!-- Edit Priority Modal -->
|
||||
<rs-modal v-model="showEditPriorityModal" title="Edit Priority Level">
|
||||
<div class="space-y-6" v-if="editingPriority">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Priority Name</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="editingPriority.name"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Weight (1-100)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="editingPriority.weight"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
max="100"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Max Processing Time (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="editingPriority.maxProcessingTime"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||
<select v-model="editingPriority.status" class="w-full p-2 border border-gray-300 rounded-md">
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="throttled">Throttled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<rs-button variant="outline" @click="showEditPriorityModal = false">
|
||||
Cancel
|
||||
</rs-button>
|
||||
<rs-button @click="savePriorityChanges">
|
||||
Save Changes
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Priority Queue Management",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue & Scheduler",
|
||||
path: "/notification/queue-scheduler",
|
||||
},
|
||||
{
|
||||
name: "Priority Management",
|
||||
path: "/notification/queue-scheduler/priority",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const showCreatePriorityModal = ref(false);
|
||||
const showEditPriorityModal = ref(false);
|
||||
const showBulkEditModal = ref(false);
|
||||
const editingPriority = ref(null);
|
||||
|
||||
// Priority statistics
|
||||
const priorityStats = ref([
|
||||
{
|
||||
title: "Critical Jobs",
|
||||
value: "47",
|
||||
icon: "ic:outline-priority-high",
|
||||
bgColor: "bg-red-100",
|
||||
iconColor: "text-red-600",
|
||||
valueColor: "text-red-600"
|
||||
},
|
||||
{
|
||||
title: "High Priority",
|
||||
value: "234",
|
||||
icon: "ic:outline-trending-up",
|
||||
bgColor: "bg-orange-100",
|
||||
iconColor: "text-orange-600",
|
||||
valueColor: "text-orange-600"
|
||||
},
|
||||
{
|
||||
title: "Medium Priority",
|
||||
value: "1,847",
|
||||
icon: "ic:outline-remove",
|
||||
bgColor: "bg-yellow-100",
|
||||
iconColor: "text-yellow-600",
|
||||
valueColor: "text-yellow-600"
|
||||
},
|
||||
{
|
||||
title: "Low/Bulk Priority",
|
||||
value: "5,923",
|
||||
icon: "ic:outline-trending-down",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-600",
|
||||
valueColor: "text-blue-600"
|
||||
}
|
||||
]);
|
||||
|
||||
// Priority levels configuration
|
||||
const priorityLevels = ref([
|
||||
{
|
||||
name: "Emergency Alerts",
|
||||
level: "critical",
|
||||
weight: 100,
|
||||
queueCount: 47,
|
||||
avgProcessingTime: "0.8s",
|
||||
status: "active",
|
||||
maxProcessingTime: 5,
|
||||
description: "System emergencies and critical security alerts"
|
||||
},
|
||||
{
|
||||
name: "Real-time Notifications",
|
||||
level: "high",
|
||||
weight: 80,
|
||||
queueCount: 234,
|
||||
avgProcessingTime: "2.1s",
|
||||
status: "active",
|
||||
maxProcessingTime: 10,
|
||||
description: "Time-sensitive notifications like OTP, payment confirmations"
|
||||
},
|
||||
{
|
||||
name: "Standard Notifications",
|
||||
level: "medium",
|
||||
weight: 50,
|
||||
queueCount: 1847,
|
||||
avgProcessingTime: "5.3s",
|
||||
status: "active",
|
||||
maxProcessingTime: 30,
|
||||
description: "Regular app notifications and updates"
|
||||
},
|
||||
{
|
||||
name: "Marketing Messages",
|
||||
level: "low",
|
||||
weight: 30,
|
||||
queueCount: 3421,
|
||||
avgProcessingTime: "12.7s",
|
||||
status: "active",
|
||||
maxProcessingTime: 60,
|
||||
description: "Promotional content and marketing campaigns"
|
||||
},
|
||||
{
|
||||
name: "Bulk Operations",
|
||||
level: "bulk",
|
||||
weight: 10,
|
||||
queueCount: 2502,
|
||||
avgProcessingTime: "45.2s",
|
||||
status: "throttled",
|
||||
maxProcessingTime: 300,
|
||||
description: "Large batch operations and system maintenance"
|
||||
}
|
||||
]);
|
||||
|
||||
// New priority form
|
||||
const newPriority = ref({
|
||||
name: "",
|
||||
level: "medium",
|
||||
weight: 50,
|
||||
description: "",
|
||||
maxProcessingTime: 30
|
||||
});
|
||||
|
||||
// Computed sorted priority levels
|
||||
const sortedPriorityLevels = computed(() => {
|
||||
return [...priorityLevels.value].sort((a, b) => b.weight - a.weight);
|
||||
});
|
||||
|
||||
// Recent jobs data
|
||||
const recentJobs = ref([
|
||||
{
|
||||
id: "job-001",
|
||||
priority: "critical",
|
||||
type: "Security Alert",
|
||||
status: "completed",
|
||||
queueTime: "0.1s",
|
||||
processingTime: "0.8s"
|
||||
},
|
||||
{
|
||||
id: "job-002",
|
||||
priority: "high",
|
||||
type: "OTP SMS",
|
||||
status: "completed",
|
||||
queueTime: "0.3s",
|
||||
processingTime: "1.2s"
|
||||
},
|
||||
{
|
||||
id: "job-003",
|
||||
priority: "medium",
|
||||
type: "App Notification",
|
||||
status: "processing",
|
||||
queueTime: "2.1s",
|
||||
processingTime: "3.4s"
|
||||
},
|
||||
{
|
||||
id: "job-004",
|
||||
priority: "low",
|
||||
type: "Newsletter",
|
||||
status: "queued",
|
||||
queueTime: "15.2s",
|
||||
processingTime: "-"
|
||||
},
|
||||
{
|
||||
id: "job-005",
|
||||
priority: "bulk",
|
||||
type: "Data Export",
|
||||
status: "queued",
|
||||
queueTime: "45.7s",
|
||||
processingTime: "-"
|
||||
}
|
||||
]);
|
||||
|
||||
// Methods
|
||||
const refreshPriorityLevels = () => {
|
||||
console.log('Refreshing priority levels...');
|
||||
// Simulate data refresh
|
||||
};
|
||||
|
||||
const createPriorityLevel = () => {
|
||||
console.log('Creating priority level:', newPriority.value);
|
||||
|
||||
// Add to priority levels
|
||||
priorityLevels.value.push({
|
||||
...newPriority.value,
|
||||
queueCount: 0,
|
||||
avgProcessingTime: "0s",
|
||||
status: "active"
|
||||
});
|
||||
|
||||
// Reset form
|
||||
newPriority.value = {
|
||||
name: "",
|
||||
level: "medium",
|
||||
weight: 50,
|
||||
description: "",
|
||||
maxProcessingTime: 30
|
||||
};
|
||||
|
||||
showCreatePriorityModal.value = false;
|
||||
};
|
||||
|
||||
const editPriority = (priority) => {
|
||||
editingPriority.value = { ...priority };
|
||||
showEditPriorityModal.value = true;
|
||||
};
|
||||
|
||||
const savePriorityChanges = () => {
|
||||
const index = priorityLevels.value.findIndex(p => p.name === editingPriority.value.name);
|
||||
if (index !== -1) {
|
||||
priorityLevels.value[index] = { ...editingPriority.value };
|
||||
}
|
||||
showEditPriorityModal.value = false;
|
||||
editingPriority.value = null;
|
||||
};
|
||||
|
||||
const togglePriorityStatus = (priority) => {
|
||||
const newStatus = priority.status === 'active' ? 'paused' : 'active';
|
||||
priority.status = newStatus;
|
||||
console.log(`Priority ${priority.name} status changed to ${newStatus}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
768
pages/notification/queue/rate-limit.vue
Normal file
768
pages/notification/queue/rate-limit.vue
Normal file
@@ -0,0 +1,768 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-speed"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Rate Limiting</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Throttles how many messages/jobs can be processed per second/minute/hour.
|
||||
Avoid hitting API limits (Twilio, SendGrid) and prevent spammy behavior that can trigger blacklisting.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Rate Limit Statistics -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(stat, index) in rateLimitStats"
|
||||
:key="index"
|
||||
class="transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-4 flex justify-center items-center rounded-2xl"
|
||||
:class="stat.bgColor"
|
||||
>
|
||||
<Icon class="text-2xl" :class="stat.iconColor" :name="stat.icon"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-xl leading-tight" :class="stat.textColor">
|
||||
{{ stat.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ stat.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Current Usage Overview -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-primary">Current Usage Overview</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
|
||||
<span class="text-sm text-gray-600">Live Updates</span>
|
||||
</div>
|
||||
<rs-button variant="outline" size="sm" @click="refreshUsage">
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="(usage, index) in currentUsage"
|
||||
:key="index"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" :name="usage.icon"></Icon>
|
||||
<h4 class="font-semibold">{{ usage.service }}</h4>
|
||||
</div>
|
||||
<rs-badge :variant="getUsageVariant(usage.percentage)">
|
||||
{{ usage.percentage }}%
|
||||
</rs-badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Current Rate:</span>
|
||||
<span class="font-medium">{{ usage.currentRate }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Limit:</span>
|
||||
<span class="font-medium">{{ usage.limit }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Window:</span>
|
||||
<span class="font-medium">{{ usage.window }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<rs-progress-bar
|
||||
:value="usage.percentage"
|
||||
:variant="usage.percentage > 80 ? 'danger' : usage.percentage > 60 ? 'warning' : 'success'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
Resets in {{ usage.resetTime }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Rate Limit Configuration -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-primary">Rate Limit Configuration</h3>
|
||||
<rs-button variant="outline" size="sm" @click="showConfigModal = true">
|
||||
<Icon class="mr-1" name="ic:outline-settings"></Icon>
|
||||
Configure Limits
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<rs-table
|
||||
:field="configTableFields"
|
||||
:data="rateLimitConfigs"
|
||||
:options="{ striped: true, hover: true }"
|
||||
:optionsAdvanced="{ sortable: true, filterable: false }"
|
||||
advanced
|
||||
/>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Rate Limit Violations -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-primary">Recent Rate Limit Violations</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<select v-model="violationFilter" class="p-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">All Services</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
</select>
|
||||
<rs-button variant="outline" size="sm" @click="refreshViolations">
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="filteredViolations.length === 0" class="text-center py-8 text-gray-500">
|
||||
<Icon class="text-4xl mb-2" name="ic:outline-check-circle"></Icon>
|
||||
<p>No rate limit violations in the selected timeframe</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(violation, index) in filteredViolations"
|
||||
:key="index"
|
||||
class="border border-red-200 bg-red-50 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-red-600" name="ic:outline-warning"></Icon>
|
||||
<span class="font-semibold text-red-800">{{ violation.service }} Rate Limit Exceeded</span>
|
||||
</div>
|
||||
<span class="text-sm text-red-600">{{ violation.timestamp }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-red-700">Attempted Rate:</span>
|
||||
<span class="ml-1 font-medium">{{ violation.attemptedRate }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-red-700">Limit:</span>
|
||||
<span class="ml-1 font-medium">{{ violation.limit }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-red-700">Messages Dropped:</span>
|
||||
<span class="ml-1 font-medium">{{ violation.droppedMessages }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-red-700 mt-2">{{ violation.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Rate Limit Analytics -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-primary">Rate Limit Analytics</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Usage Trends -->
|
||||
<div>
|
||||
<h4 class="font-semibold mb-4">Usage Trends (Last 24 Hours)</h4>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(trend, index) in usageTrends"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" :name="trend.icon"></Icon>
|
||||
<div>
|
||||
<p class="font-medium">{{ trend.service }}</p>
|
||||
<p class="text-sm text-gray-600">Peak: {{ trend.peak }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-medium">{{ trend.average }}</p>
|
||||
<p class="text-sm text-gray-600">Average</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Efficiency Metrics -->
|
||||
<div>
|
||||
<h4 class="font-semibold mb-4">Efficiency Metrics</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded p-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<Icon class="mr-2 text-green-600" name="ic:outline-trending-up"></Icon>
|
||||
<span class="font-medium text-green-800">Throughput Optimization</span>
|
||||
</div>
|
||||
<p class="text-sm text-green-700">
|
||||
Current efficiency: {{ efficiencyMetrics.throughputOptimization }}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded p-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<Icon class="mr-2 text-blue-600" name="ic:outline-schedule"></Icon>
|
||||
<span class="font-medium text-blue-800">Queue Utilization</span>
|
||||
</div>
|
||||
<p class="text-sm text-blue-700">
|
||||
Average queue utilization: {{ efficiencyMetrics.queueUtilization }}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-purple-50 border border-purple-200 rounded p-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<Icon class="mr-2 text-purple-600" name="ic:outline-timer"></Icon>
|
||||
<span class="font-medium text-purple-800">Response Time</span>
|
||||
</div>
|
||||
<p class="text-sm text-purple-700">
|
||||
Average response time: {{ efficiencyMetrics.responseTime }}ms
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-50 border border-orange-200 rounded p-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<Icon class="mr-2 text-orange-600" name="ic:outline-error"></Icon>
|
||||
<span class="font-medium text-orange-800">Error Rate</span>
|
||||
</div>
|
||||
<p class="text-sm text-orange-700">
|
||||
Rate limit errors: {{ efficiencyMetrics.errorRate }}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Rate Limit Testing -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-primary">Rate Limit Testing</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Service to Test</label>
|
||||
<select v-model="testConfig.service" class="w-full p-2 border border-gray-300 rounded-md">
|
||||
<option value="">Select service</option>
|
||||
<option value="email">Email (SendGrid)</option>
|
||||
<option value="sms">SMS (Twilio)</option>
|
||||
<option value="push">Push (Firebase)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Messages per Second</label>
|
||||
<input
|
||||
v-model.number="testConfig.rate"
|
||||
type="number"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
placeholder="10"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Duration (seconds)</label>
|
||||
<input
|
||||
v-model.number="testConfig.duration"
|
||||
type="number"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
placeholder="60"
|
||||
min="1"
|
||||
max="300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Test Message</label>
|
||||
<textarea
|
||||
v-model="testConfig.message"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
rows="3"
|
||||
placeholder="Test message content"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<rs-button
|
||||
@click="startRateLimitTest"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
:disabled="testRunning"
|
||||
>
|
||||
<Icon class="mr-1" :name="testRunning ? 'ic:outline-stop' : 'ic:outline-play-arrow'"></Icon>
|
||||
{{ testRunning ? 'Test Running...' : 'Start Rate Limit Test' }}
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold">Test Results</h4>
|
||||
<div v-if="testResults.length === 0" class="text-center text-gray-500 py-8">
|
||||
<Icon class="text-4xl mb-2" name="ic:outline-science"></Icon>
|
||||
<p>Run a test to see results</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3 max-h-60 overflow-y-auto">
|
||||
<div
|
||||
v-for="(result, index) in testResults"
|
||||
:key="index"
|
||||
class="border border-gray-200 rounded p-3"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="font-medium">{{ result.service }}</span>
|
||||
<span :class="{
|
||||
'text-green-600': result.status === 'success',
|
||||
'text-red-600': result.status === 'rate_limited',
|
||||
'text-yellow-600': result.status === 'warning'
|
||||
}" class="text-sm font-medium">{{ result.status }}</span>
|
||||
</div>
|
||||
<div class="text-sm space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Messages Sent:</span>
|
||||
<span>{{ result.messagesSent }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Rate Achieved:</span>
|
||||
<span>{{ result.rateAchieved }}/sec</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Errors:</span>
|
||||
<span>{{ result.errors }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">{{ result.timestamp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Configuration Modal -->
|
||||
<rs-modal v-model="showConfigModal" size="lg">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Rate Limit Configuration</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
v-for="(config, index) in editableConfigs"
|
||||
:key="index"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center mb-4">
|
||||
<Icon class="mr-2 text-primary" :name="config.icon"></Icon>
|
||||
<h4 class="font-semibold">{{ config.service }} Configuration</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Messages per Second</label>
|
||||
<input
|
||||
v-model.number="config.perSecond"
|
||||
type="number"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Messages per Minute</label>
|
||||
<input
|
||||
v-model.number="config.perMinute"
|
||||
type="number"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Messages per Hour</label>
|
||||
<input
|
||||
v-model.number="config.perHour"
|
||||
type="number"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Burst Limit</label>
|
||||
<input
|
||||
v-model.number="config.burstLimit"
|
||||
type="number"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="config.enabled"
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700">Enable rate limiting for this service</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<rs-button variant="outline" @click="showConfigModal = false">Cancel</rs-button>
|
||||
<rs-button @click="saveRateLimitConfig" variant="primary">Save Configuration</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Rate Limiting",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue & Scheduler",
|
||||
path: "/notification/queue-scheduler",
|
||||
},
|
||||
{
|
||||
name: "Rate Limiting",
|
||||
path: "/notification/queue-scheduler/rate-limit",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const showConfigModal = ref(false);
|
||||
const violationFilter = ref('');
|
||||
const testRunning = ref(false);
|
||||
const testResults = ref([]);
|
||||
|
||||
// Statistics
|
||||
const rateLimitStats = ref([
|
||||
{
|
||||
title: "Active Limits",
|
||||
value: "12",
|
||||
icon: "ic:outline-speed",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-600",
|
||||
textColor: "text-blue-600"
|
||||
},
|
||||
{
|
||||
title: "Messages/Hour",
|
||||
value: "45.2K",
|
||||
icon: "ic:outline-trending-up",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-600",
|
||||
textColor: "text-green-600"
|
||||
},
|
||||
{
|
||||
title: "Violations Today",
|
||||
value: "3",
|
||||
icon: "ic:outline-warning",
|
||||
bgColor: "bg-red-100",
|
||||
iconColor: "text-red-600",
|
||||
textColor: "text-red-600"
|
||||
},
|
||||
{
|
||||
title: "Efficiency",
|
||||
value: "96.8%",
|
||||
icon: "ic:outline-check-circle",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-600",
|
||||
textColor: "text-purple-600"
|
||||
}
|
||||
]);
|
||||
|
||||
// Current usage
|
||||
const currentUsage = ref([
|
||||
{
|
||||
service: 'Email (SendGrid)',
|
||||
icon: 'ic:outline-email',
|
||||
currentRate: '850/hour',
|
||||
limit: '1000/hour',
|
||||
percentage: 85,
|
||||
window: '1 hour',
|
||||
resetTime: '23 minutes'
|
||||
},
|
||||
{
|
||||
service: 'SMS (Twilio)',
|
||||
icon: 'ic:outline-sms',
|
||||
currentRate: '45/minute',
|
||||
limit: '100/minute',
|
||||
percentage: 45,
|
||||
window: '1 minute',
|
||||
resetTime: '32 seconds'
|
||||
},
|
||||
{
|
||||
service: 'Push (Firebase)',
|
||||
icon: 'ic:outline-notifications',
|
||||
currentRate: '1200/hour',
|
||||
limit: '5000/hour',
|
||||
percentage: 24,
|
||||
window: '1 hour',
|
||||
resetTime: '45 minutes'
|
||||
},
|
||||
{
|
||||
service: 'Webhook',
|
||||
icon: 'ic:outline-webhook',
|
||||
currentRate: '15/second',
|
||||
limit: '20/second',
|
||||
percentage: 75,
|
||||
window: '1 second',
|
||||
resetTime: '0.5 seconds'
|
||||
}
|
||||
]);
|
||||
|
||||
// Configuration table fields
|
||||
const configTableFields = ref([
|
||||
{ key: 'service', label: 'Service', sortable: true },
|
||||
{ key: 'perSecond', label: 'Per Second', sortable: true },
|
||||
{ key: 'perMinute', label: 'Per Minute', sortable: true },
|
||||
{ key: 'perHour', label: 'Per Hour', sortable: true },
|
||||
{ key: 'burstLimit', label: 'Burst Limit', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: true },
|
||||
{ key: 'actions', label: 'Actions', sortable: false }
|
||||
]);
|
||||
|
||||
// Rate limit configurations
|
||||
const rateLimitConfigs = ref([
|
||||
{
|
||||
service: 'Email (SendGrid)',
|
||||
icon: 'ic:outline-email',
|
||||
perSecond: 10,
|
||||
perMinute: 600,
|
||||
perHour: 1000,
|
||||
burstLimit: 50,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
service: 'SMS (Twilio)',
|
||||
icon: 'ic:outline-sms',
|
||||
perSecond: 5,
|
||||
perMinute: 100,
|
||||
perHour: 2000,
|
||||
burstLimit: 20,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
service: 'Push (Firebase)',
|
||||
icon: 'ic:outline-notifications',
|
||||
perSecond: 50,
|
||||
perMinute: 1000,
|
||||
perHour: 5000,
|
||||
burstLimit: 200,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
service: 'Webhook',
|
||||
icon: 'ic:outline-webhook',
|
||||
perSecond: 20,
|
||||
perMinute: 500,
|
||||
perHour: 10000,
|
||||
burstLimit: 100,
|
||||
enabled: true
|
||||
}
|
||||
].map(config => ({
|
||||
...config,
|
||||
status: h('span', {
|
||||
class: `px-2 py-1 rounded text-xs font-medium ${
|
||||
config.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`
|
||||
}, config.enabled ? 'Active' : 'Disabled'),
|
||||
actions: h('button', {
|
||||
class: 'text-blue-600 hover:text-blue-800 text-sm',
|
||||
onClick: () => editRateLimit(config)
|
||||
}, 'Edit')
|
||||
})));
|
||||
|
||||
// Editable configs for modal
|
||||
const editableConfigs = ref(JSON.parse(JSON.stringify(rateLimitConfigs.value.map(c => ({
|
||||
service: c.service,
|
||||
icon: c.icon,
|
||||
perSecond: c.perSecond,
|
||||
perMinute: c.perMinute,
|
||||
perHour: c.perHour,
|
||||
burstLimit: c.burstLimit,
|
||||
enabled: c.enabled
|
||||
})))));
|
||||
|
||||
// Rate limit violations
|
||||
const violations = ref([
|
||||
{
|
||||
service: 'Email',
|
||||
timestamp: '2024-01-15 14:30:00',
|
||||
attemptedRate: '1200/hour',
|
||||
limit: '1000/hour',
|
||||
droppedMessages: 45,
|
||||
description: 'Newsletter campaign exceeded hourly limit during peak hours'
|
||||
},
|
||||
{
|
||||
service: 'SMS',
|
||||
timestamp: '2024-01-15 12:15:00',
|
||||
attemptedRate: '150/minute',
|
||||
limit: '100/minute',
|
||||
droppedMessages: 23,
|
||||
description: 'OTP verification burst exceeded per-minute limit'
|
||||
},
|
||||
{
|
||||
service: 'Webhook',
|
||||
timestamp: '2024-01-15 10:45:00',
|
||||
attemptedRate: '25/second',
|
||||
limit: '20/second',
|
||||
droppedMessages: 12,
|
||||
description: 'Order webhook notifications exceeded per-second limit'
|
||||
}
|
||||
]);
|
||||
|
||||
// Usage trends
|
||||
const usageTrends = ref([
|
||||
{
|
||||
service: 'Email',
|
||||
icon: 'ic:outline-email',
|
||||
peak: '950/hour',
|
||||
average: '650/hour'
|
||||
},
|
||||
{
|
||||
service: 'SMS',
|
||||
icon: 'ic:outline-sms',
|
||||
peak: '85/minute',
|
||||
average: '45/minute'
|
||||
},
|
||||
{
|
||||
service: 'Push',
|
||||
icon: 'ic:outline-notifications',
|
||||
peak: '2100/hour',
|
||||
average: '1200/hour'
|
||||
},
|
||||
{
|
||||
service: 'Webhook',
|
||||
icon: 'ic:outline-webhook',
|
||||
peak: '18/second',
|
||||
average: '12/second'
|
||||
}
|
||||
]);
|
||||
|
||||
// Efficiency metrics
|
||||
const efficiencyMetrics = ref({
|
||||
throughputOptimization: 96.8,
|
||||
queueUtilization: 78.5,
|
||||
responseTime: 245,
|
||||
errorRate: 0.3
|
||||
});
|
||||
|
||||
// Test configuration
|
||||
const testConfig = ref({
|
||||
service: '',
|
||||
rate: 10,
|
||||
duration: 60,
|
||||
message: 'This is a rate limit test message'
|
||||
});
|
||||
|
||||
// Computed filtered violations
|
||||
const filteredViolations = computed(() => {
|
||||
if (!violationFilter.value) {
|
||||
return violations.value;
|
||||
}
|
||||
return violations.value.filter(v => v.service.toLowerCase() === violationFilter.value);
|
||||
});
|
||||
|
||||
// Methods
|
||||
function getUsageVariant(percentage) {
|
||||
if (percentage > 80) return 'danger';
|
||||
if (percentage > 60) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
function refreshUsage() {
|
||||
// Mock refresh
|
||||
console.log('Refreshing usage data...');
|
||||
}
|
||||
|
||||
function refreshViolations() {
|
||||
// Mock refresh
|
||||
console.log('Refreshing violations...');
|
||||
}
|
||||
|
||||
function editRateLimit(config) {
|
||||
// Find and update the editable config
|
||||
const editableConfig = editableConfigs.value.find(c => c.service === config.service);
|
||||
if (editableConfig) {
|
||||
Object.assign(editableConfig, config);
|
||||
}
|
||||
showConfigModal.value = true;
|
||||
}
|
||||
|
||||
function saveRateLimitConfig() {
|
||||
// Mock save
|
||||
console.log('Saving rate limit configuration...', editableConfigs.value);
|
||||
showConfigModal.value = false;
|
||||
}
|
||||
|
||||
function startRateLimitTest() {
|
||||
if (!testConfig.value.service) {
|
||||
return;
|
||||
}
|
||||
|
||||
testRunning.value = true;
|
||||
|
||||
// Mock test execution
|
||||
setTimeout(() => {
|
||||
const result = {
|
||||
service: testConfig.value.service,
|
||||
messagesSent: Math.floor(testConfig.value.rate * testConfig.value.duration * 0.9),
|
||||
rateAchieved: Math.floor(testConfig.value.rate * 0.9),
|
||||
errors: Math.floor(Math.random() * 5),
|
||||
status: Math.random() > 0.7 ? 'rate_limited' : 'success',
|
||||
timestamp: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
testResults.value.unshift(result);
|
||||
testRunning.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
563
pages/notification/queue/retry.vue
Normal file
563
pages/notification/queue/retry.vue
Normal file
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-refresh"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Failed Jobs</h1>
|
||||
</div>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshJobs"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">Handle and retry failed notification jobs.</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<rs-alert v-if="error" variant="danger" class="mb-6">
|
||||
{{ error }}
|
||||
</rs-alert>
|
||||
|
||||
<!-- Basic Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<div v-if="isLoading" class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-bold text-red-600">{{ stats.failed }}</h3>
|
||||
<p class="text-sm text-gray-600">Failed Jobs</p>
|
||||
</template>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<div v-if="isLoading" class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-bold text-yellow-600">
|
||||
{{ stats.retrying }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Retrying</p>
|
||||
</template>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<div v-if="isLoading" class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-bold text-green-600">
|
||||
{{ stats.recovered }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Recovered</p>
|
||||
</template>
|
||||
</div>
|
||||
</rs-card>
|
||||
<rs-card>
|
||||
<div class="p-4 text-center">
|
||||
<div v-if="isLoading" class="animate-pulse">
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-bold text-gray-600">
|
||||
{{ stats.deadLetter }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">Dead Letter</p>
|
||||
</template>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Failed Jobs -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-primary">Failed Jobs</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="confirmRetryAll"
|
||||
:loading="isRetryingAll"
|
||||
:disabled="isRetryingAll || failedJobs.length === 0"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Retry All
|
||||
</rs-button>
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshJobs"
|
||||
:loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-sync"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="isLoading" class="flex justify-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
|
||||
<span class="ml-2">Loading...</span>
|
||||
</div>
|
||||
<div v-else-if="failedJobs.length === 0" class="text-center py-8 text-gray-500">
|
||||
<Icon name="ic:outline-check-circle" class="text-3xl text-gray-400 mb-2" />
|
||||
<p>No failed jobs found</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(job, index) in failedJobs"
|
||||
:key="index"
|
||||
class="border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full mr-3 bg-red-500"></div>
|
||||
<div>
|
||||
<h4 class="font-semibold">{{ job.type }} - {{ truncateId(job.id) }}</h4>
|
||||
<p class="text-sm text-gray-600">{{ job.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="confirmRetryJob(job)"
|
||||
:loading="retryingJobs[job.id]"
|
||||
:disabled="retryingJobs[job.id]"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Retry
|
||||
</rs-button>
|
||||
<rs-button variant="outline" size="sm" @click="viewError(job)">
|
||||
<Icon class="mr-1" name="ic:outline-visibility"></Icon>
|
||||
View
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<span class="font-medium">Attempts:</span> {{ job.attempts }}/{{
|
||||
job.maxAttempts
|
||||
}}
|
||||
</div>
|
||||
<div><span class="font-medium">Error Type:</span> {{ job.errorType }}</div>
|
||||
<div>
|
||||
<span class="font-medium">Failed At:</span>
|
||||
{{ formatDateTime(job.failedAt) }}
|
||||
</div>
|
||||
<div><span class="font-medium">Next Retry:</span> {{ job.nextRetry }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="failedJobs.length > 0" class="mt-4 flex justify-center">
|
||||
<button
|
||||
class="px-3 py-1 border border-gray-300 rounded-l-md disabled:opacity-50"
|
||||
:disabled="pagination.page === 1"
|
||||
@click="changePage(pagination.page - 1)"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div class="px-3 py-1 border-t border-b border-gray-300">
|
||||
{{ pagination.page }} / {{ pagination.totalPages }}
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 border border-gray-300 rounded-r-md disabled:opacity-50"
|
||||
:disabled="pagination.page === pagination.totalPages || !pagination.hasMore"
|
||||
@click="changePage(pagination.page + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Error Details Modal -->
|
||||
<rs-modal v-model="showErrorModal" title="Job Error Details">
|
||||
<div v-if="selectedJob" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Job ID</label>
|
||||
<p class="text-sm text-gray-900">{{ selectedJob.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Type</label>
|
||||
<p class="text-sm text-gray-900">{{ selectedJob.type }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Error Message</label
|
||||
>
|
||||
<div class="bg-red-50 border border-red-200 rounded p-3 max-h-48 overflow-y-auto">
|
||||
<pre class="text-sm text-red-800 whitespace-pre-wrap">{{ selectedJob.errorMessage }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedJob.attempts && selectedJob.maxAttempts">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Retry Attempts</label>
|
||||
<div class="bg-gray-50 p-3 rounded">
|
||||
<div class="relative h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-2 rounded-full"
|
||||
:class="selectedJob.attempts >= selectedJob.maxAttempts ? 'bg-red-500' : 'bg-yellow-500'"
|
||||
:style="{width: `${(selectedJob.attempts / selectedJob.maxAttempts) * 100}%`}"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-sm mt-1 text-gray-600">
|
||||
{{ selectedJob.attempts }} of {{ selectedJob.maxAttempts }} attempts used
|
||||
({{ selectedJob.attempts >= selectedJob.maxAttempts ? 'Max attempts reached' : `${selectedJob.maxAttempts - selectedJob.attempts} remaining` }})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<rs-button variant="outline" @click="showErrorModal = false">Close</rs-button>
|
||||
<rs-button
|
||||
@click="confirmRetrySelectedJob"
|
||||
:loading="retryingJobs[selectedJob?.id]"
|
||||
:disabled="retryingJobs[selectedJob?.id]"
|
||||
>
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Retry Job
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Failed Jobs",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue",
|
||||
path: "/notification/queue",
|
||||
},
|
||||
{
|
||||
name: "Failed Jobs",
|
||||
path: "/notification/queue/retry",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Basic stats
|
||||
const stats = ref({
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
recovered: 0,
|
||||
deadLetter: 0,
|
||||
});
|
||||
|
||||
// Modal state
|
||||
const showErrorModal = ref(false);
|
||||
const selectedJob = ref(null);
|
||||
|
||||
// Loading states
|
||||
const isLoading = ref(false);
|
||||
const isRetrying = ref(false);
|
||||
const isRetryingAll = ref(false);
|
||||
const retryingJobs = ref({});
|
||||
|
||||
// Error state
|
||||
const error = ref(null);
|
||||
|
||||
// Failed jobs with pagination
|
||||
const failedJobs = ref([]);
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalItems: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Fetch stats from API
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await useFetch("/api/notifications/queue/retry/stats");
|
||||
|
||||
if (!response.data.value) {
|
||||
throw new Error("Failed to fetch statistics");
|
||||
}
|
||||
|
||||
if (response.data.value?.success) {
|
||||
stats.value = response.data.value.data;
|
||||
} else {
|
||||
throw new Error(response.data.value.statusMessage || "Failed to fetch statistics");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching stats:", err);
|
||||
error.value = "Failed to load statistics";
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch jobs from API
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await useFetch("/api/notifications/queue/retry/jobs", {
|
||||
query: {
|
||||
page: pagination.value.page,
|
||||
limit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data.value) {
|
||||
throw new Error("Failed to fetch jobs");
|
||||
}
|
||||
|
||||
if (response.data.value?.success) {
|
||||
failedJobs.value = response.data.value.data.jobs;
|
||||
pagination.value = response.data.value.data.pagination;
|
||||
} else {
|
||||
throw new Error(response.data.value.statusMessage || "Failed to fetch jobs");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching jobs:", err);
|
||||
error.value = "Failed to load failed jobs";
|
||||
failedJobs.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Change pagination page
|
||||
const changePage = (page) => {
|
||||
pagination.value.page = page;
|
||||
fetchJobs();
|
||||
};
|
||||
|
||||
// Refresh jobs and stats
|
||||
const refreshJobs = async () => {
|
||||
await Promise.all([fetchStats(), fetchJobs()]);
|
||||
};
|
||||
|
||||
// Format date/time
|
||||
const formatDateTime = (timestamp) => {
|
||||
if (!timestamp) return "N/A";
|
||||
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return "Invalid Date";
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins} minutes ago`;
|
||||
} else if (diffMins < 1440) {
|
||||
return `${Math.floor(diffMins / 60)} hours ago`;
|
||||
} else {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error formatting date:", err);
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
|
||||
// Truncate long IDs
|
||||
const truncateId = (id) => {
|
||||
if (!id) return "Unknown";
|
||||
if (id.length > 8) {
|
||||
return id.substring(0, 8) + '...';
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
// Show confirmation dialog for retry all
|
||||
const confirmRetryAll = async () => {
|
||||
const { $swal } = useNuxtApp();
|
||||
const result = await $swal.fire({
|
||||
title: "Retry All Jobs",
|
||||
text: `Are you sure you want to retry all ${stats.value.failed} failed jobs?`,
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, retry all",
|
||||
cancelButtonText: "Cancel",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
retryAll();
|
||||
}
|
||||
};
|
||||
|
||||
// Show confirmation dialog for retry single job
|
||||
const confirmRetryJob = async (job) => {
|
||||
const { $swal } = useNuxtApp();
|
||||
const result = await $swal.fire({
|
||||
title: "Retry Job",
|
||||
text: `Are you sure you want to retry this job?`,
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, retry",
|
||||
cancelButtonText: "Cancel",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
retryJob(job);
|
||||
}
|
||||
};
|
||||
|
||||
// Retry all failed jobs
|
||||
const retryAll = async () => {
|
||||
try {
|
||||
isRetryingAll.value = true;
|
||||
error.value = null;
|
||||
|
||||
const { data } = await useFetch("/api/notifications/queue/retry/all", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!data.value?.success) {
|
||||
throw new Error(data.value?.statusMessage || "Failed to retry all jobs");
|
||||
}
|
||||
|
||||
await refreshJobs();
|
||||
|
||||
// Show success message
|
||||
const { $swal } = useNuxtApp();
|
||||
await $swal.fire({
|
||||
title: "Success",
|
||||
text: data.value.data.message || `${data.value.data.count} jobs queued for retry`,
|
||||
icon: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error retrying all jobs:", err);
|
||||
error.value = err.message || "Failed to retry all jobs";
|
||||
|
||||
// Show error message
|
||||
const { $swal } = useNuxtApp();
|
||||
await $swal.fire({
|
||||
title: "Error",
|
||||
text: err.message || "Failed to retry all jobs. Please try again.",
|
||||
icon: "error",
|
||||
});
|
||||
} finally {
|
||||
isRetryingAll.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Retry a specific job
|
||||
const retryJob = async (job) => {
|
||||
try {
|
||||
// Set loading state for this specific job
|
||||
retryingJobs.value = {
|
||||
...retryingJobs.value,
|
||||
[job.id]: true
|
||||
};
|
||||
|
||||
error.value = null;
|
||||
|
||||
const { data } = await useFetch(`/api/notifications/queue/retry/${job.id}`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!data.value?.success) {
|
||||
throw new Error(data.value?.statusMessage || "Failed to retry job");
|
||||
}
|
||||
|
||||
await refreshJobs();
|
||||
|
||||
// Show success message
|
||||
const { $swal } = useNuxtApp();
|
||||
await $swal.fire({
|
||||
title: "Success",
|
||||
text: data.value.data?.message || "Job queued for retry",
|
||||
icon: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error retrying job:", err);
|
||||
error.value = err.message || "Failed to retry job";
|
||||
|
||||
// Show error message
|
||||
const { $swal } = useNuxtApp();
|
||||
await $swal.fire({
|
||||
title: "Error",
|
||||
text: err.message || "Failed to retry job. Please try again.",
|
||||
icon: "error",
|
||||
});
|
||||
} finally {
|
||||
retryingJobs.value = {
|
||||
...retryingJobs.value,
|
||||
[job.id]: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// View error details for a job
|
||||
const viewError = (job) => {
|
||||
selectedJob.value = job;
|
||||
showErrorModal.value = true;
|
||||
};
|
||||
|
||||
// Retry selected job from modal
|
||||
const confirmRetrySelectedJob = async () => {
|
||||
if (selectedJob.value) {
|
||||
await confirmRetryJob(selectedJob.value);
|
||||
}
|
||||
showErrorModal.value = false;
|
||||
};
|
||||
|
||||
// Initialize data
|
||||
onMounted(async () => {
|
||||
await refreshJobs();
|
||||
});
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
let refreshInterval;
|
||||
onMounted(() => {
|
||||
refreshInterval = setInterval(refreshJobs, 30000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
707
pages/notification/queue/timezone.vue
Normal file
707
pages/notification/queue/timezone.vue
Normal file
@@ -0,0 +1,707 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Header Section -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-schedule"></Icon>
|
||||
<h1 class="text-xl font-bold text-primary">Timezone Handling</h1>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-gray-600">
|
||||
Ensures messages are delivered at the right local time for each recipient.
|
||||
Schedule birthday messages at 9AM local time and avoid 2AM push alerts across timezones.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Current Time Display -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(timezone, index) in majorTimezones"
|
||||
:key="index"
|
||||
class="transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 text-center">
|
||||
<div class="mb-2">
|
||||
<Icon class="text-primary text-2xl" name="ic:outline-access-time"></Icon>
|
||||
</div>
|
||||
<h3 class="font-semibold text-lg">{{ timezone.name }}</h3>
|
||||
<p class="text-2xl font-bold text-primary">{{ timezone.time }}</p>
|
||||
<p class="text-sm text-gray-600">{{ timezone.zone }}</p>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Timezone Statistics -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<rs-card
|
||||
v-for="(stat, index) in timezoneStats"
|
||||
:key="index"
|
||||
class="transition-all duration-300 hover:shadow-lg"
|
||||
>
|
||||
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
|
||||
<div
|
||||
class="p-4 flex justify-center items-center rounded-2xl"
|
||||
:class="stat.bgColor"
|
||||
>
|
||||
<Icon class="text-2xl" :class="stat.iconColor" :name="stat.icon"></Icon>
|
||||
</div>
|
||||
<div class="flex-1 truncate">
|
||||
<span class="block font-bold text-xl leading-tight" :class="stat.textColor">
|
||||
{{ stat.value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{{ stat.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Timezone Configuration -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-primary">Timezone Configuration</h3>
|
||||
<rs-button variant="outline" size="sm" @click="showConfigModal = true">
|
||||
<Icon class="mr-1" name="ic:outline-settings"></Icon>
|
||||
Configure
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold mb-3 flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-schedule"></Icon>
|
||||
Default Delivery Times
|
||||
</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Morning Messages:</span>
|
||||
<span class="font-medium">{{ config.morningTime }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Afternoon Messages:</span>
|
||||
<span class="font-medium">{{ config.afternoonTime }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Evening Messages:</span>
|
||||
<span class="font-medium">{{ config.eveningTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold mb-3 flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-block"></Icon>
|
||||
Quiet Hours
|
||||
</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Start Time:</span>
|
||||
<span class="font-medium">{{ config.quietHours.start }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">End Time:</span>
|
||||
<span class="font-medium">{{ config.quietHours.end }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Emergency Override:</span>
|
||||
<span class="font-medium">{{ config.quietHours.allowEmergency ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold mb-3 flex items-center">
|
||||
<Icon class="mr-2 text-primary" name="ic:outline-public"></Icon>
|
||||
Timezone Detection
|
||||
</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Auto-detect:</span>
|
||||
<span class="font-medium">{{ config.autoDetect ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Fallback Timezone:</span>
|
||||
<span class="font-medium">{{ config.fallbackTimezone }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Update Frequency:</span>
|
||||
<span class="font-medium">{{ config.updateFrequency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Scheduled Messages by Timezone -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-primary">Scheduled Messages by Timezone</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<select v-model="selectedTimezone" class="p-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">All Timezones</option>
|
||||
<option v-for="tz in availableTimezones" :key="tz.value" :value="tz.value">
|
||||
{{ tz.label }}
|
||||
</option>
|
||||
</select>
|
||||
<rs-button variant="outline" size="sm" @click="refreshScheduledMessages">
|
||||
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
||||
Refresh
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<rs-table
|
||||
:field="scheduledMessagesFields"
|
||||
:data="filteredScheduledMessages"
|
||||
:options="{ striped: true, hover: true }"
|
||||
:optionsAdvanced="{ sortable: true, filterable: false }"
|
||||
advanced
|
||||
/>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Timezone Distribution Chart -->
|
||||
<rs-card class="mb-6">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-primary">User Distribution by Timezone</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Chart would go here in a real implementation -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(distribution, index) in timezoneDistribution"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 rounded mr-3" :style="{ backgroundColor: distribution.color }"></div>
|
||||
<div>
|
||||
<p class="font-medium">{{ distribution.timezone }}</p>
|
||||
<p class="text-sm text-gray-600">{{ distribution.users }} users</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-medium">{{ distribution.percentage }}%</p>
|
||||
<p class="text-sm text-gray-600">{{ distribution.currentTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold">Delivery Optimization</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-green-50 border border-green-200 rounded p-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<Icon class="mr-2 text-green-600" name="ic:outline-check-circle"></Icon>
|
||||
<span class="font-medium text-green-800">Optimal Delivery Windows</span>
|
||||
</div>
|
||||
<p class="text-sm text-green-700">
|
||||
{{ optimizationStats.optimalWindows }} messages scheduled during optimal hours
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded p-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<Icon class="mr-2 text-yellow-600" name="ic:outline-warning"></Icon>
|
||||
<span class="font-medium text-yellow-800">Quiet Hours Conflicts</span>
|
||||
</div>
|
||||
<p class="text-sm text-yellow-700">
|
||||
{{ optimizationStats.quietHoursConflicts }} messages would be sent during quiet hours
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded p-3">
|
||||
<div class="flex items-center mb-2">
|
||||
<Icon class="mr-2 text-blue-600" name="ic:outline-info"></Icon>
|
||||
<span class="font-medium text-blue-800">Timezone Coverage</span>
|
||||
</div>
|
||||
<p class="text-sm text-blue-700">
|
||||
Messages will be delivered across {{ optimizationStats.timezoneCoverage }} timezones
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Timezone Testing Tool -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-primary">Timezone Testing Tool</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Test Message</label>
|
||||
<textarea
|
||||
v-model="testMessage.content"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
rows="3"
|
||||
placeholder="Enter test message content"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Scheduled Time (UTC)</label>
|
||||
<input
|
||||
v-model="testMessage.scheduledTime"
|
||||
type="datetime-local"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Message Type</label>
|
||||
<select v-model="testMessage.type" class="w-full p-2 border border-gray-300 rounded-md">
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="push">Push Notification</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<rs-button @click="testTimezoneDelivery" variant="primary" class="w-full">
|
||||
<Icon class="mr-1" name="ic:outline-play-arrow"></Icon>
|
||||
Test Timezone Delivery
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold">Delivery Preview</h4>
|
||||
<div v-if="deliveryPreview.length > 0" class="space-y-2 max-h-60 overflow-y-auto">
|
||||
<div
|
||||
v-for="(preview, index) in deliveryPreview"
|
||||
:key="index"
|
||||
class="border border-gray-200 rounded p-3"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<span class="font-medium">{{ preview.timezone }}</span>
|
||||
<span class="text-sm text-gray-600">{{ preview.users }} users</span>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<p class="text-gray-600">Local delivery time: <span class="font-medium">{{ preview.localTime }}</span></p>
|
||||
<p class="text-gray-600">Status:
|
||||
<span :class="{
|
||||
'text-green-600': preview.status === 'optimal',
|
||||
'text-yellow-600': preview.status === 'suboptimal',
|
||||
'text-red-600': preview.status === 'blocked'
|
||||
}" class="font-medium">{{ preview.status }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center text-gray-500 py-8">
|
||||
<Icon class="text-4xl mb-2" name="ic:outline-schedule"></Icon>
|
||||
<p>Run a test to see delivery preview</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Configuration Modal -->
|
||||
<rs-modal v-model="showConfigModal" size="lg">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">Timezone Configuration</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Default Delivery Times</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Morning Messages</label>
|
||||
<input
|
||||
v-model="config.morningTime"
|
||||
type="time"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Afternoon Messages</label>
|
||||
<input
|
||||
v-model="config.afternoonTime"
|
||||
type="time"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Evening Messages</label>
|
||||
<input
|
||||
v-model="config.eveningTime"
|
||||
type="time"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Quiet Hours</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Start Time</label>
|
||||
<input
|
||||
v-model="config.quietHours.start"
|
||||
type="time"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">End Time</label>
|
||||
<input
|
||||
v-model="config.quietHours.end"
|
||||
type="time"
|
||||
class="w-full p-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="config.quietHours.allowEmergency"
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700">Allow emergency messages during quiet hours</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-semibold mb-3">Timezone Detection</h4>
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
v-model="config.autoDetect"
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700">Auto-detect user timezones</span>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Fallback Timezone</label>
|
||||
<select v-model="config.fallbackTimezone" class="w-full p-2 border border-gray-300 rounded-md">
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Update Frequency</label>
|
||||
<select v-model="config.updateFrequency" class="w-full p-2 border border-gray-300 rounded-md">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<rs-button variant="outline" @click="showConfigModal = false">Cancel</rs-button>
|
||||
<rs-button @click="saveConfiguration" variant="primary">Save Configuration</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Timezone Handling",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "Queue & Scheduler",
|
||||
path: "/notification/queue-scheduler",
|
||||
},
|
||||
{
|
||||
name: "Timezone Handling",
|
||||
path: "/notification/queue-scheduler/timezone",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const showConfigModal = ref(false);
|
||||
const selectedTimezone = ref('');
|
||||
const deliveryPreview = ref([]);
|
||||
|
||||
// Current time for major timezones
|
||||
const majorTimezones = ref([
|
||||
{
|
||||
name: 'New York',
|
||||
zone: 'America/New_York',
|
||||
time: new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York' })
|
||||
},
|
||||
{
|
||||
name: 'London',
|
||||
zone: 'Europe/London',
|
||||
time: new Date().toLocaleTimeString('en-US', { timeZone: 'Europe/London' })
|
||||
},
|
||||
{
|
||||
name: 'Tokyo',
|
||||
zone: 'Asia/Tokyo',
|
||||
time: new Date().toLocaleTimeString('en-US', { timeZone: 'Asia/Tokyo' })
|
||||
}
|
||||
]);
|
||||
|
||||
// Update times every second
|
||||
setInterval(() => {
|
||||
majorTimezones.value.forEach(tz => {
|
||||
tz.time = new Date().toLocaleTimeString('en-US', { timeZone: tz.zone });
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Statistics
|
||||
const timezoneStats = ref([
|
||||
{
|
||||
title: "Active Timezones",
|
||||
value: "24",
|
||||
icon: "ic:outline-public",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-600",
|
||||
textColor: "text-blue-600"
|
||||
},
|
||||
{
|
||||
title: "Scheduled Messages",
|
||||
value: "1,847",
|
||||
icon: "ic:outline-schedule",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-600",
|
||||
textColor: "text-green-600"
|
||||
},
|
||||
{
|
||||
title: "Optimal Deliveries",
|
||||
value: "94.2%",
|
||||
icon: "ic:outline-trending-up",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-600",
|
||||
textColor: "text-purple-600"
|
||||
},
|
||||
{
|
||||
title: "Quiet Hours Respected",
|
||||
value: "99.8%",
|
||||
icon: "ic:outline-nights-stay",
|
||||
bgColor: "bg-orange-100",
|
||||
iconColor: "text-orange-600",
|
||||
textColor: "text-orange-600"
|
||||
}
|
||||
]);
|
||||
|
||||
// Configuration
|
||||
const config = ref({
|
||||
morningTime: '09:00',
|
||||
afternoonTime: '14:00',
|
||||
eveningTime: '18:00',
|
||||
quietHours: {
|
||||
start: '22:00',
|
||||
end: '07:00',
|
||||
allowEmergency: true
|
||||
},
|
||||
autoDetect: true,
|
||||
fallbackTimezone: 'UTC',
|
||||
updateFrequency: 'daily'
|
||||
});
|
||||
|
||||
// Available timezones
|
||||
const availableTimezones = ref([
|
||||
{ value: 'America/New_York', label: 'America/New_York (EST/EDT)' },
|
||||
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' },
|
||||
{ value: 'Europe/London', label: 'Europe/London (GMT/BST)' },
|
||||
{ value: 'Europe/Paris', label: 'Europe/Paris (CET/CEST)' },
|
||||
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
|
||||
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (CST)' },
|
||||
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' },
|
||||
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEST/AEDT)' }
|
||||
]);
|
||||
|
||||
// Table fields for scheduled messages
|
||||
const scheduledMessagesFields = ref([
|
||||
{ key: 'id', label: 'Message ID', sortable: true },
|
||||
{ key: 'type', label: 'Type', sortable: true },
|
||||
{ key: 'timezone', label: 'Timezone', sortable: true },
|
||||
{ key: 'scheduledUTC', label: 'Scheduled (UTC)', sortable: true },
|
||||
{ key: 'localTime', label: 'Local Time', sortable: true },
|
||||
{ key: 'recipients', label: 'Recipients', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: true }
|
||||
]);
|
||||
|
||||
// Mock scheduled messages data
|
||||
const scheduledMessages = ref([
|
||||
{
|
||||
id: 'msg_001',
|
||||
type: 'email',
|
||||
timezone: 'America/New_York',
|
||||
scheduledUTC: '2024-01-15 14:00:00',
|
||||
localTime: '2024-01-15 09:00:00',
|
||||
recipients: 1250,
|
||||
status: 'scheduled'
|
||||
},
|
||||
{
|
||||
id: 'msg_002',
|
||||
type: 'push',
|
||||
timezone: 'Europe/London',
|
||||
scheduledUTC: '2024-01-15 09:00:00',
|
||||
localTime: '2024-01-15 09:00:00',
|
||||
recipients: 890,
|
||||
status: 'scheduled'
|
||||
},
|
||||
{
|
||||
id: 'msg_003',
|
||||
type: 'sms',
|
||||
timezone: 'Asia/Tokyo',
|
||||
scheduledUTC: '2024-01-15 00:00:00',
|
||||
localTime: '2024-01-15 09:00:00',
|
||||
recipients: 2100,
|
||||
status: 'scheduled'
|
||||
}
|
||||
]);
|
||||
|
||||
// Timezone distribution
|
||||
const timezoneDistribution = ref([
|
||||
{
|
||||
timezone: 'America/New_York',
|
||||
users: 15420,
|
||||
percentage: 32.5,
|
||||
color: '#3B82F6',
|
||||
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York' })
|
||||
},
|
||||
{
|
||||
timezone: 'Europe/London',
|
||||
users: 12890,
|
||||
percentage: 27.2,
|
||||
color: '#10B981',
|
||||
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'Europe/London' })
|
||||
},
|
||||
{
|
||||
timezone: 'Asia/Tokyo',
|
||||
users: 9650,
|
||||
percentage: 20.3,
|
||||
color: '#F59E0B',
|
||||
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'Asia/Tokyo' })
|
||||
},
|
||||
{
|
||||
timezone: 'Australia/Sydney',
|
||||
users: 5840,
|
||||
percentage: 12.3,
|
||||
color: '#EF4444',
|
||||
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'Australia/Sydney' })
|
||||
},
|
||||
{
|
||||
timezone: 'Others',
|
||||
users: 3700,
|
||||
percentage: 7.7,
|
||||
color: '#8B5CF6',
|
||||
currentTime: '-'
|
||||
}
|
||||
]);
|
||||
|
||||
// Optimization stats
|
||||
const optimizationStats = ref({
|
||||
optimalWindows: 1654,
|
||||
quietHoursConflicts: 23,
|
||||
timezoneCoverage: 18
|
||||
});
|
||||
|
||||
// Test message
|
||||
const testMessage = ref({
|
||||
content: '',
|
||||
scheduledTime: '',
|
||||
type: 'email'
|
||||
});
|
||||
|
||||
// Computed filtered scheduled messages
|
||||
const filteredScheduledMessages = computed(() => {
|
||||
let filtered = scheduledMessages.value;
|
||||
|
||||
if (selectedTimezone.value) {
|
||||
filtered = filtered.filter(msg => msg.timezone === selectedTimezone.value);
|
||||
}
|
||||
|
||||
return filtered.map(msg => ({
|
||||
...msg,
|
||||
status: h('span', {
|
||||
class: `px-2 py-1 rounded text-xs font-medium ${
|
||||
msg.status === 'scheduled' ? 'bg-blue-100 text-blue-800' :
|
||||
msg.status === 'sent' ? 'bg-green-100 text-green-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`
|
||||
}, msg.status)
|
||||
}));
|
||||
});
|
||||
|
||||
// Methods
|
||||
function refreshScheduledMessages() {
|
||||
// Mock refresh
|
||||
console.log('Refreshing scheduled messages...');
|
||||
}
|
||||
|
||||
function testTimezoneDelivery() {
|
||||
if (!testMessage.value.content || !testMessage.value.scheduledTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock delivery preview generation
|
||||
deliveryPreview.value = [
|
||||
{
|
||||
timezone: 'America/New_York',
|
||||
users: 1250,
|
||||
localTime: '09:00 AM',
|
||||
status: 'optimal'
|
||||
},
|
||||
{
|
||||
timezone: 'Europe/London',
|
||||
localTime: '02:00 AM',
|
||||
users: 890,
|
||||
status: 'blocked'
|
||||
},
|
||||
{
|
||||
timezone: 'Asia/Tokyo',
|
||||
localTime: '11:00 AM',
|
||||
users: 2100,
|
||||
status: 'optimal'
|
||||
},
|
||||
{
|
||||
timezone: 'Australia/Sydney',
|
||||
localTime: '01:00 AM',
|
||||
users: 580,
|
||||
status: 'blocked'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function saveConfiguration() {
|
||||
// Mock save
|
||||
console.log('Saving timezone configuration...');
|
||||
showConfigModal.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
1357
pages/notification/templates/create_template/index.vue
Normal file
1357
pages/notification/templates/create_template/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
764
pages/notification/templates/edit/[id].vue
Normal file
764
pages/notification/templates/edit/[id].vue
Normal file
@@ -0,0 +1,764 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Info Card -->
|
||||
<rs-card class="mb-5">
|
||||
<template #header>
|
||||
<div class="flex">
|
||||
<Icon
|
||||
class="mr-2 flex justify-center"
|
||||
name="material-symbols:edit-outline"
|
||||
></Icon>
|
||||
Edit Template
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="mb-4">
|
||||
Edit and update your notification template. Modify content, settings, and
|
||||
channel configurations to improve effectiveness.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-12">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon name="ic:outline-refresh" size="2rem" class="text-primary animate-spin" />
|
||||
</div>
|
||||
<p class="text-gray-600">Loading template details...</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Form Card -->
|
||||
<rs-card v-else>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">Edit Notification Template</h2>
|
||||
<div class="flex gap-3">
|
||||
<rs-button @click="$router.push('/notification/templates')" variant="outline">
|
||||
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
|
||||
Back to Templates
|
||||
</rs-button>
|
||||
<rs-button
|
||||
@click="previewTemplate"
|
||||
variant="info-outline"
|
||||
:disabled="!templateForm.content || isSubmitting"
|
||||
>
|
||||
<Icon name="material-symbols:preview-outline" class="mr-1"></Icon>
|
||||
Preview
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="pt-2">
|
||||
<FormKit
|
||||
type="form"
|
||||
@submit="updateTemplate"
|
||||
:actions="false"
|
||||
class="w-full max-w-6xl mx-auto"
|
||||
>
|
||||
<!-- Basic Information Section -->
|
||||
<div class="space-y-6 mb-8">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Basic Information
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure the basic template settings and metadata
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
name="title"
|
||||
label="Template Name"
|
||||
placeholder="e.g., Welcome Email Template"
|
||||
validation="required"
|
||||
v-model="templateForm.title"
|
||||
help="Internal name for identifying this template"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="textarea"
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Brief description of template purpose and usage"
|
||||
v-model="templateForm.description"
|
||||
rows="3"
|
||||
help="Optional description to help team members understand the template's use"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="category"
|
||||
label="Category"
|
||||
placeholder="Select category"
|
||||
:options="categoryOptions"
|
||||
validation="required"
|
||||
v-model="templateForm.category"
|
||||
help="Organize templates by category for better management"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
name="channels"
|
||||
label="Supported Channels"
|
||||
:options="channelOptions"
|
||||
validation="required|min:1"
|
||||
v-model="templateForm.channels"
|
||||
decorator-icon="material-symbols:check"
|
||||
options-class="grid grid-cols-2 gap-x-3 gap-y-2 pt-2"
|
||||
help="Select which channels this template supports"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="select"
|
||||
name="status"
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
:options="statusOptions"
|
||||
validation="required"
|
||||
v-model="templateForm.status"
|
||||
help="Template status - only active templates can be used"
|
||||
outer-class="mb-0"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="text"
|
||||
name="version"
|
||||
label="Version"
|
||||
placeholder="1.0"
|
||||
v-model="templateForm.version"
|
||||
help="Version number for tracking template changes"
|
||||
outer-class="mb-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Configuration Section -->
|
||||
<div class="space-y-6 mb-8">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Content Configuration
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Define subject lines and content for different channels
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Subject/Title Section -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
name="subject"
|
||||
label="Subject Line / Notification Title"
|
||||
placeholder="e.g., Welcome to {{company_name}}, {{first_name}}!"
|
||||
validation="required"
|
||||
v-model="templateForm.subject"
|
||||
help="Subject for emails or title for push notifications. Use {{variable}} for dynamic content"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="text"
|
||||
name="preheader"
|
||||
label="Email Preheader Text (Optional)"
|
||||
placeholder="Additional preview text for emails"
|
||||
v-model="templateForm.preheader"
|
||||
help="Preview text shown in email clients alongside the subject line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content Editor Section -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="textarea"
|
||||
name="content"
|
||||
label="Template Content"
|
||||
validation="required"
|
||||
v-model="templateForm.content"
|
||||
help="Main content body. Use HTML for rich formatting and {{variable}} for dynamic content"
|
||||
rows="10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel-Specific Settings -->
|
||||
<div class="space-y-6 mb-8">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Channel-Specific Settings
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure settings specific to each channel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<rs-tab fill>
|
||||
<!-- Email Settings Tab -->
|
||||
<rs-tab-item
|
||||
title="Email Settings"
|
||||
v-if="templateForm.channels.includes('email')"
|
||||
>
|
||||
<div class="space-y-4 pt-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
name="fromName"
|
||||
label="From Name"
|
||||
placeholder="Your Company Name"
|
||||
v-model="templateForm.fromName"
|
||||
help="Name shown as sender in email clients"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="email"
|
||||
name="replyTo"
|
||||
label="Reply-To Email"
|
||||
placeholder="noreply@yourcompany.com"
|
||||
v-model="templateForm.replyTo"
|
||||
help="Email address for replies"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
name="trackOpens"
|
||||
label="Track Email Opens"
|
||||
v-model="templateForm.trackOpens"
|
||||
help="Enable tracking of email opens for analytics"
|
||||
/>
|
||||
</div>
|
||||
</rs-tab-item>
|
||||
|
||||
<!-- Push Notification Settings Tab -->
|
||||
<rs-tab-item
|
||||
title="Push Settings"
|
||||
v-if="templateForm.channels.includes('push')"
|
||||
>
|
||||
<div class="space-y-4 pt-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
name="pushTitle"
|
||||
label="Push Notification Title"
|
||||
placeholder="Custom push title (optional)"
|
||||
v-model="templateForm.pushTitle"
|
||||
help="Leave empty to use the main subject line"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="url"
|
||||
name="pushIcon"
|
||||
label="Push Notification Icon URL"
|
||||
placeholder="https://yoursite.com/icon.png"
|
||||
v-model="templateForm.pushIcon"
|
||||
help="URL to icon for push notifications"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="url"
|
||||
name="pushUrl"
|
||||
label="Push Notification Action URL"
|
||||
placeholder="https://yoursite.com/action"
|
||||
v-model="templateForm.pushUrl"
|
||||
help="URL to open when push notification is clicked"
|
||||
/>
|
||||
</div>
|
||||
</rs-tab-item>
|
||||
|
||||
<!-- SMS Settings Tab -->
|
||||
<rs-tab-item
|
||||
title="SMS Settings"
|
||||
v-if="templateForm.channels.includes('sms')"
|
||||
>
|
||||
<div class="space-y-4 pt-4">
|
||||
<FormKit
|
||||
type="textarea"
|
||||
name="smsContent"
|
||||
label="SMS Content"
|
||||
placeholder="SMS version of your message (160 characters max)"
|
||||
v-model="templateForm.smsContent"
|
||||
help="Text-only version for SMS. Variables like {{name}} are supported"
|
||||
rows="4"
|
||||
maxlength="160"
|
||||
/>
|
||||
<p class="text-sm text-gray-500">
|
||||
Characters: {{ templateForm.smsContent?.length || 0 }}/160
|
||||
</p>
|
||||
</div>
|
||||
</rs-tab-item>
|
||||
</rs-tab>
|
||||
</div>
|
||||
|
||||
<!-- Additional Settings -->
|
||||
<div class="space-y-6 mb-8">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Additional Settings
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure additional template options
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormKit
|
||||
type="text"
|
||||
name="tags"
|
||||
label="Tags (comma-separated)"
|
||||
placeholder="welcome, onboarding, email"
|
||||
v-model="templateForm.tags"
|
||||
help="Tags for organizing and filtering templates"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
name="isPersonal"
|
||||
label="Personal Template"
|
||||
v-model="templateForm.isPersonal"
|
||||
help="Mark as personal template (visible only to you)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div
|
||||
class="flex gap-3 justify-end pt-6 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<rs-button
|
||||
@click="$router.push('/notification/templates')"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</rs-button>
|
||||
<rs-button
|
||||
@click="testTemplate"
|
||||
variant="info-outline"
|
||||
:disabled="!isFormValid || isSubmitting"
|
||||
>
|
||||
<Icon name="material-symbols:send" class="mr-1"></Icon>
|
||||
Send Test
|
||||
</rs-button>
|
||||
<rs-button
|
||||
@click="updateTemplate"
|
||||
:disabled="!isFormValid || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
>
|
||||
<Icon name="material-symbols:save" class="mr-1"></Icon>
|
||||
{{ isSubmitting ? "Updating..." : "Update Template" }}
|
||||
</rs-button>
|
||||
</div>
|
||||
</FormKit>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<rs-modal v-model="showPreview" title="Template Preview" size="lg">
|
||||
<div class="space-y-4 p-1">
|
||||
<div class="flex gap-4 mb-4">
|
||||
<FormKit
|
||||
type="select"
|
||||
name="previewChannel"
|
||||
label="Preview Channel"
|
||||
:options="
|
||||
channelOptions.filter(
|
||||
(c) => c.value !== '' && templateForm.channels.includes(c.value)
|
||||
)
|
||||
"
|
||||
v-model="previewChannel"
|
||||
outer-class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
|
||||
<h3 class="font-semibold mb-2 text-gray-800 dark:text-gray-200">
|
||||
{{ templateForm.subject }}
|
||||
</h3>
|
||||
<div
|
||||
class="prose prose-sm max-w-none dark:prose-invert"
|
||||
v-html="templateForm.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<rs-button @click="showPreview = false">Close</rs-button>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Edit Template",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
const { $swal } = useNuxtApp();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// Get template ID from route params
|
||||
const templateId = computed(() => route.params.id);
|
||||
|
||||
// Reactive data
|
||||
const isLoading = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const showPreview = ref(false);
|
||||
const previewChannel = ref("email");
|
||||
|
||||
// Form data
|
||||
const templateForm = ref({
|
||||
title: "",
|
||||
description: "",
|
||||
subject: "",
|
||||
preheader: "",
|
||||
category: "",
|
||||
channels: [],
|
||||
status: "Draft",
|
||||
version: "1.0",
|
||||
content: "",
|
||||
tags: "",
|
||||
isPersonal: false,
|
||||
// Email settings
|
||||
fromName: "",
|
||||
replyTo: "",
|
||||
trackOpens: true,
|
||||
// Push settings
|
||||
pushTitle: "",
|
||||
pushIcon: "",
|
||||
pushUrl: "",
|
||||
// SMS settings
|
||||
smsContent: "",
|
||||
});
|
||||
|
||||
// Form options
|
||||
const categoryOptions = [
|
||||
{ label: "User Management", value: "user_management" },
|
||||
{ label: "Orders & Transactions", value: "orders" },
|
||||
{ label: "Security & Authentication", value: "security" },
|
||||
{ label: "Marketing & Promotions", value: "marketing" },
|
||||
{ label: "System Updates", value: "system" },
|
||||
{ label: "General Information", value: "general" },
|
||||
];
|
||||
|
||||
const channelOptions = [
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "SMS", value: "sms" },
|
||||
{ label: "Push Notification", value: "push" },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "Draft", value: "Draft" },
|
||||
{ label: "Active", value: "Active" },
|
||||
{ label: "Archived", value: "Archived" },
|
||||
];
|
||||
|
||||
// Computed properties
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
templateForm.value.title &&
|
||||
templateForm.value.subject &&
|
||||
templateForm.value.content &&
|
||||
templateForm.value.category &&
|
||||
templateForm.value.channels.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
// Load template data
|
||||
const loadTemplate = async () => {
|
||||
if (!templateId.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
console.log("Loading template for editing:", templateId.value);
|
||||
|
||||
const response = await $fetch(`/api/notifications/templates/${templateId.value}`);
|
||||
|
||||
if (response.success) {
|
||||
const template = response.data.template;
|
||||
console.log("Template loaded:", template);
|
||||
|
||||
// Map API response to form data
|
||||
Object.assign(templateForm.value, {
|
||||
title: template.title || "",
|
||||
description: template.description || "",
|
||||
subject: template.subject || "",
|
||||
preheader: template.preheader || "",
|
||||
category: template.category || "",
|
||||
channels: template.channels || [],
|
||||
status: template.status || "Draft",
|
||||
version: template.version || "1.0",
|
||||
content: template.content || "",
|
||||
tags: template.tags || "",
|
||||
isPersonal: template.isPersonal || false,
|
||||
fromName: template.fromName || "",
|
||||
replyTo: template.replyTo || "",
|
||||
trackOpens: template.trackOpens !== false,
|
||||
pushTitle: template.pushTitle || "",
|
||||
pushIcon: template.pushIcon || "",
|
||||
pushUrl: template.pushUrl || "",
|
||||
smsContent: template.smsContent || "",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading template:", error);
|
||||
|
||||
let errorMessage = "Failed to load template data";
|
||||
if (error.data?.error) {
|
||||
errorMessage = error.data.error;
|
||||
}
|
||||
|
||||
await $swal.fire({
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
icon: "error",
|
||||
});
|
||||
|
||||
// Navigate back to templates list
|
||||
router.push("/notification/templates");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Preview template
|
||||
const previewTemplate = () => {
|
||||
showPreview.value = true;
|
||||
};
|
||||
|
||||
// Test template
|
||||
const testTemplate = async () => {
|
||||
if (!isFormValid.value) {
|
||||
$swal.fire("Error", "Please fill in all required fields", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const { value: email } = await $swal.fire({
|
||||
title: "Send Test Notification",
|
||||
text: "Enter email address to send test notification:",
|
||||
input: "email",
|
||||
inputPlaceholder: "your-email@example.com",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Send Test",
|
||||
cancelButtonText: "Cancel",
|
||||
inputValidator: (value) => {
|
||||
if (!value) {
|
||||
return "Please enter a valid email address";
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return "Please enter a valid email address";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (email) {
|
||||
// Show loading state
|
||||
const loadingSwal = $swal.fire({
|
||||
title: "Sending Test...",
|
||||
text: "Please wait while we send your test notification",
|
||||
allowOutsideClick: false,
|
||||
allowEscapeKey: false,
|
||||
showConfirmButton: false,
|
||||
didOpen: () => {
|
||||
$swal.showLoading();
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const testData = {
|
||||
subject: templateForm.value.subject,
|
||||
emailContent: templateForm.value.content,
|
||||
pushTitle: templateForm.value.pushTitle || templateForm.value.subject,
|
||||
pushBody: templateForm.value.content
|
||||
? templateForm.value.content.replace(/<[^>]*>/g, "").substring(0, 300)
|
||||
: "",
|
||||
callToActionText: "",
|
||||
callToActionUrl: "",
|
||||
};
|
||||
|
||||
console.log("Sending test notification with data:", testData);
|
||||
|
||||
const response = await $fetch("/api/notifications/test-send", {
|
||||
method: "POST",
|
||||
body: {
|
||||
email: email,
|
||||
testData: testData,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Test API Response:", response);
|
||||
|
||||
loadingSwal.close();
|
||||
|
||||
let successMessage = `Test notification sent to ${email}`;
|
||||
if (response.data?.results) {
|
||||
const results = response.data.results;
|
||||
const successfulChannels = results
|
||||
.filter((r) => r.status === "sent")
|
||||
.map((r) => r.channel);
|
||||
const failedChannels = results.filter((r) => r.status === "failed");
|
||||
|
||||
if (successfulChannels.length > 0) {
|
||||
successMessage += `\n\nSuccessfully sent via: ${successfulChannels.join(", ")}`;
|
||||
}
|
||||
|
||||
if (failedChannels.length > 0) {
|
||||
successMessage += `\n\nFailed channels: ${failedChannels
|
||||
.map((f) => `${f.channel} (${f.message})`)
|
||||
.join(", ")}`;
|
||||
}
|
||||
}
|
||||
|
||||
await $swal.fire({
|
||||
title: "Test Sent!",
|
||||
text: successMessage,
|
||||
icon: "success",
|
||||
timer: 4000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error sending test notification:", error);
|
||||
|
||||
loadingSwal.close();
|
||||
|
||||
let errorMessage = "Failed to send test notification. Please try again.";
|
||||
if (error.data?.error) {
|
||||
errorMessage = error.data.error;
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
await $swal.fire({
|
||||
title: "Test Failed",
|
||||
text: errorMessage,
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Update template
|
||||
const updateTemplate = async () => {
|
||||
if (!isFormValid.value) {
|
||||
$swal.fire("Error", "Please fill in all required fields", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubmitting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
|
||||
// Show loading state
|
||||
const loadingSwal = $swal.fire({
|
||||
title: "Updating Template...",
|
||||
text: "Please wait while we update your template",
|
||||
allowOutsideClick: false,
|
||||
allowEscapeKey: false,
|
||||
showConfirmButton: false,
|
||||
didOpen: () => {
|
||||
$swal.showLoading();
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Prepare the data for API submission
|
||||
const apiData = {
|
||||
title: templateForm.value.title,
|
||||
description: templateForm.value.description || "",
|
||||
subject: templateForm.value.subject,
|
||||
preheader: templateForm.value.preheader || "",
|
||||
category: templateForm.value.category,
|
||||
channels: templateForm.value.channels,
|
||||
status: templateForm.value.status,
|
||||
version: templateForm.value.version,
|
||||
content: templateForm.value.content,
|
||||
tags: templateForm.value.tags || "",
|
||||
isPersonal: templateForm.value.isPersonal,
|
||||
// Email specific settings
|
||||
fromName: templateForm.value.fromName || "",
|
||||
replyTo: templateForm.value.replyTo || "",
|
||||
trackOpens: templateForm.value.trackOpens,
|
||||
// Push notification specific settings
|
||||
pushTitle: templateForm.value.pushTitle || "",
|
||||
pushIcon: templateForm.value.pushIcon || "",
|
||||
pushUrl: templateForm.value.pushUrl || "",
|
||||
// SMS specific settings
|
||||
smsContent: templateForm.value.smsContent || "",
|
||||
};
|
||||
|
||||
console.log("Updating Template Data:", apiData);
|
||||
|
||||
const response = await $fetch(`/api/notifications/templates/${templateId.value}`, {
|
||||
method: "PUT",
|
||||
body: apiData,
|
||||
});
|
||||
|
||||
console.log("API Response:", response);
|
||||
|
||||
loadingSwal.close();
|
||||
isSubmitting.value = false;
|
||||
|
||||
await $swal.fire({
|
||||
title: "Template Updated!",
|
||||
text: `Template "${templateForm.value.title}" has been successfully updated.`,
|
||||
icon: "success",
|
||||
timer: 3000,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
|
||||
// Navigate back to templates list
|
||||
router.push("/notification/templates");
|
||||
} catch (error) {
|
||||
console.error("Error updating template:", error);
|
||||
|
||||
loadingSwal.close();
|
||||
isSubmitting.value = false;
|
||||
|
||||
let errorMessage = "Failed to update template. Please try again.";
|
||||
let errorDetails = "";
|
||||
|
||||
if (error.data) {
|
||||
if (error.data.errors && Array.isArray(error.data.errors)) {
|
||||
errorMessage = "Please fix the following errors:";
|
||||
errorDetails = error.data.errors.join("\n• ");
|
||||
} else if (error.data.error) {
|
||||
errorMessage = error.data.error;
|
||||
}
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
await $swal.fire({
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
html: errorDetails
|
||||
? `<p>${errorMessage}</p><ul style="text-align: left; margin-top: 10px;"><li>${errorDetails.replace(
|
||||
/\n• /g,
|
||||
"</li><li>"
|
||||
)}</li></ul>`
|
||||
: errorMessage,
|
||||
icon: "error",
|
||||
confirmButtonText: "OK",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Load template when component mounts
|
||||
onMounted(async () => {
|
||||
await loadTemplate();
|
||||
});
|
||||
</script>
|
||||
749
pages/notification/templates/index.vue
Normal file
749
pages/notification/templates/index.vue
Normal file
@@ -0,0 +1,749 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
<rs-card class="mb-5">
|
||||
<template #header>
|
||||
<div class="flex">
|
||||
<span title="Info"
|
||||
><Icon class="mr-2 flex justify-center" name="ic:outline-info"></Icon
|
||||
></span>
|
||||
Info
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="mb-4">
|
||||
Manage your notification templates here. You can create, edit, preview, and
|
||||
manage versions of templates for various channels like Email, SMS, Push, and
|
||||
In-App messages.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Notification Templates</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="pt-2">
|
||||
<rs-tab fill>
|
||||
<rs-tab-item title="All Templates">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<FormKit
|
||||
type="select"
|
||||
name="category"
|
||||
placeholder="Filter by Category"
|
||||
:options="categories"
|
||||
v-model="filters.category"
|
||||
@input="filterByCategory"
|
||||
:disabled="isLoading"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 disabled:opacity-50"
|
||||
outer-class="flex-grow sm:flex-grow-0 min-w-[180px] mb-0"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
name="status"
|
||||
placeholder="Filter by Status"
|
||||
:options="statusOptions"
|
||||
v-model="filters.status"
|
||||
@input="filterByStatus"
|
||||
:disabled="isLoading"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 disabled:opacity-50"
|
||||
outer-class="flex-grow sm:flex-grow-0 min-w-[180px] mb-0"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
name="channel"
|
||||
placeholder="Filter by Channel"
|
||||
:options="channels"
|
||||
v-model="filters.channel"
|
||||
@input="filterByChannel"
|
||||
:disabled="isLoading"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 disabled:opacity-50"
|
||||
outer-class="flex-grow sm:flex-grow-0 min-w-[180px] mb-0"
|
||||
/>
|
||||
</div>
|
||||
<rs-button
|
||||
@click="$router.push('/notification/templates/create_template')"
|
||||
class="ml-auto"
|
||||
>
|
||||
<Icon name="material-symbols:add" class="mr-1"></Icon>
|
||||
Create Template
|
||||
</rs-button>
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-12">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon
|
||||
name="ic:outline-refresh"
|
||||
size="2rem"
|
||||
class="text-primary animate-spin"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading templates...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="!templateList || templateList.length === 0"
|
||||
class="text-center py-12"
|
||||
>
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon
|
||||
name="material-symbols:inbox-outline"
|
||||
size="3rem"
|
||||
class="text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
No templates found
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{
|
||||
filters.category ||
|
||||
filters.channel ||
|
||||
filters.status ||
|
||||
filters.search
|
||||
? "No templates match your current filters."
|
||||
: "Get started by creating your first notification template."
|
||||
}}
|
||||
</p>
|
||||
<rs-button
|
||||
@click="$router.push('/notification/templates/create_template')"
|
||||
>
|
||||
<Icon name="material-symbols:add" class="mr-1"></Icon>
|
||||
Create Your First Template
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<!-- Templates Table -->
|
||||
<rs-table
|
||||
v-else
|
||||
:data="templateList"
|
||||
:columns="columns"
|
||||
:options="{
|
||||
variant: 'default',
|
||||
striped: true,
|
||||
borderless: true,
|
||||
class: 'align-middle',
|
||||
}"
|
||||
advanced
|
||||
>
|
||||
<template v-slot:channel="{ value }">
|
||||
<div
|
||||
class="flex items-center justify-start gap-1 flex-wrap"
|
||||
style="max-width: 100px"
|
||||
>
|
||||
<template v-if="value.channel && value.channel.length">
|
||||
<template v-for="channel_item in value.channel" :key="channel_item">
|
||||
<span :title="channel_item">
|
||||
<Icon
|
||||
:name="getChannelIcon(channel_item)"
|
||||
class="text-gray-700 dark:text-gray-300"
|
||||
size="18"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:version="{ text }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400"
|
||||
>v{{ text }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-slot:status="{ text }">
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-medium"
|
||||
:class="getStatusClass(text)"
|
||||
>
|
||||
{{ text == 1 ? "Active" : text == 0 ? "Inactive" : "Draft" }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-slot:action="data">
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<span title="Edit">
|
||||
<Icon
|
||||
name="material-symbols:edit-outline-rounded"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="20"
|
||||
@click="editTemplate(data.value)"
|
||||
/>
|
||||
</span>
|
||||
<!-- <span title="Preview">
|
||||
<Icon
|
||||
name="material-symbols:preview-outline"
|
||||
class="text-blue-500 hover:text-blue-600 cursor-pointer"
|
||||
size="20"
|
||||
@click="previewTemplate(data.value)"
|
||||
/>
|
||||
</span> -->
|
||||
<span title="Delete">
|
||||
<Icon
|
||||
name="material-symbols:delete-outline"
|
||||
class="text-red-500 hover:text-red-600 cursor-pointer"
|
||||
size="20"
|
||||
@click="openModalDelete(data.value)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</rs-table>
|
||||
</rs-tab-item>
|
||||
</rs-tab>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<rs-modal v-model="showPreview" title="Template Preview" size="lg">
|
||||
<div class="space-y-4 p-1">
|
||||
<div class="flex gap-4 mb-4">
|
||||
<FormKit
|
||||
type="select"
|
||||
name="previewChannel"
|
||||
label="Preview Channel"
|
||||
:options="channels.filter((c) => c.value !== '')"
|
||||
v-model="previewChannel"
|
||||
outer-class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
|
||||
<h3 class="font-semibold mb-2 text-gray-800 dark:text-gray-200">
|
||||
{{ selectedTemplate?.notificationTitle }}
|
||||
</h3>
|
||||
<div
|
||||
class="prose prose-sm max-w-none dark:prose-invert"
|
||||
v-html="selectedTemplate?.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<rs-button @click="showPreview = false">Close</rs-button>
|
||||
</template>
|
||||
</rs-modal>
|
||||
|
||||
<!-- Version History Modal -->
|
||||
<rs-modal v-model="showVersions" title="Version History" size="lg">
|
||||
<div class="space-y-4 p-1">
|
||||
<div v-if="versionHistory.length">
|
||||
<div
|
||||
v-for="version in versionHistory"
|
||||
:key="version.id"
|
||||
class="border rounded-lg p-4 mb-3 last:mb-0 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 ease-in-out"
|
||||
:class="{
|
||||
'border-primary-300 bg-primary-50 dark:bg-primary-900/20':
|
||||
version.isCurrent,
|
||||
}"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-300"
|
||||
>Version {{ version.version }}</span
|
||||
>
|
||||
<span
|
||||
v-if="version.isCurrent"
|
||||
class="px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full dark:bg-green-900 dark:text-green-300"
|
||||
>
|
||||
Current
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
:class="getStatusClass(version.status)"
|
||||
>
|
||||
{{ version.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{
|
||||
version.updatedAt
|
||||
}}</span>
|
||||
<rs-button
|
||||
size="sm"
|
||||
@click="restoreVersion(version)"
|
||||
variant="outline"
|
||||
:disabled="version.isCurrent"
|
||||
>Restore</rs-button
|
||||
>
|
||||
<rs-button
|
||||
size="sm"
|
||||
@click="deleteVersion(version)"
|
||||
variant="danger-outline"
|
||||
:disabled="version.isCurrent"
|
||||
>Delete</rs-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ version.name }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ version.subject }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ version.changeDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-4">
|
||||
No version history available for this template.
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<rs-button @click="showVersions = false">Close</rs-button>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Notification Templates",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
const { $swal } = useNuxtApp();
|
||||
const router = useRouter();
|
||||
|
||||
// Reactive data
|
||||
const isLoading = ref(false);
|
||||
const templateList = ref([]);
|
||||
const totalCount = ref(0);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
category: "",
|
||||
channel: "",
|
||||
status: "",
|
||||
search: "",
|
||||
});
|
||||
|
||||
const categories = ref([
|
||||
{ label: "All Categories", value: "" },
|
||||
{ label: "User Management", value: "user_management" },
|
||||
{ label: "Orders & Transactions", value: "orders" },
|
||||
{ label: "Security & Authentication", value: "security" },
|
||||
{ label: "Marketing & Promotions", value: "marketing" },
|
||||
{ label: "System Updates", value: "system" },
|
||||
{ label: "General Information", value: "general" },
|
||||
]);
|
||||
|
||||
const statusOptions = ref([
|
||||
{ label: "All Status", value: "" },
|
||||
{ label: "Active", value: "Active" },
|
||||
{ label: "Inactive", value: "Inactive" },
|
||||
{ label: "Draft", value: "Draft" },
|
||||
{ label: "Archived", value: "Archived" },
|
||||
]);
|
||||
|
||||
const channels = ref([
|
||||
{ label: "All Channels", value: "" },
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "SMS", value: "sms" },
|
||||
{ label: "Push Notification", value: "push" },
|
||||
]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: "Title",
|
||||
key: "title",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
label: "Category",
|
||||
key: "category",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
label: "Channels",
|
||||
key: "channel",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
label: "Status",
|
||||
key: "status",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
label: "Action",
|
||||
key: "action",
|
||||
align: "center",
|
||||
},
|
||||
];
|
||||
|
||||
// API functions
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
page: currentPage.value.toString(),
|
||||
limit: pageSize.value.toString(),
|
||||
sortBy: "created_at",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
|
||||
// Add filters if they have values
|
||||
if (filters.value.category) queryParams.append("category", filters.value.category);
|
||||
if (filters.value.channel) queryParams.append("channel", filters.value.channel);
|
||||
if (filters.value.status) queryParams.append("status", filters.value.status);
|
||||
if (filters.value.search) queryParams.append("search", filters.value.search);
|
||||
|
||||
console.log("Fetching templates with params:", queryParams.toString());
|
||||
|
||||
const response = await $fetch(`/api/notifications/templates?${queryParams}`);
|
||||
|
||||
console.log("API Response:", response);
|
||||
|
||||
// Check if response has the expected structure
|
||||
if (
|
||||
response &&
|
||||
response.success &&
|
||||
response.data &&
|
||||
Array.isArray(response.data.templates)
|
||||
) {
|
||||
// Transform API data to match frontend format
|
||||
templateList.value = response.data.templates.map((template) => ({
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
category: template.category,
|
||||
status: template.is_active, // This is now the correct string status from DB
|
||||
createdAt: formatDate(template.created_at),
|
||||
updatedAt: formatDate(template.updated_at),
|
||||
action: null,
|
||||
}));
|
||||
|
||||
totalCount.value = response.data.templates?.length || 0;
|
||||
console.log(`Loaded ${templateList.value.length} templates`);
|
||||
} else {
|
||||
// Handle unexpected response structure
|
||||
console.warn("Unexpected API response structure:", response);
|
||||
templateList.value = [];
|
||||
totalCount.value = 0;
|
||||
|
||||
if (response && !response.success) {
|
||||
const errorMessage =
|
||||
response.data?.error || response.error || "Unknown error occurred";
|
||||
$swal.fire("Error", errorMessage, "error");
|
||||
} else {
|
||||
$swal.fire(
|
||||
"Warning",
|
||||
"Received unexpected response format from server.",
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching templates:", error);
|
||||
|
||||
// Initialize empty state on error
|
||||
templateList.value = [];
|
||||
totalCount.value = 0;
|
||||
|
||||
// Show user-friendly error message
|
||||
let errorMessage = "Failed to load templates. Please try again.";
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
errorMessage = "Authentication required. Please log in again.";
|
||||
} else if (error.response?.status === 403) {
|
||||
errorMessage = "You don't have permission to access this resource.";
|
||||
} else if (error.response?.status >= 500) {
|
||||
errorMessage = "Server error occurred. Please try again later.";
|
||||
} else if (error.data?.error) {
|
||||
errorMessage = error.data.error;
|
||||
}
|
||||
|
||||
$swal.fire("Error", errorMessage, "error");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format dates
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "";
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const showPreview = ref(false);
|
||||
const selectedTemplate = ref(null);
|
||||
const previewChannel = ref("email");
|
||||
|
||||
const showVersions = ref(false);
|
||||
const versionHistory = ref([]);
|
||||
|
||||
const getChannelIcon = (channel_item) => {
|
||||
const icons = {
|
||||
email: "material-symbols:mail-outline-rounded",
|
||||
sms: "material-symbols:sms-outline-rounded",
|
||||
push: "material-symbols:notifications-active-outline-rounded",
|
||||
"in-app": "material-symbols:chat-bubble-outline-rounded",
|
||||
};
|
||||
return icons[channel_item] || "material-symbols:help-outline-rounded";
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const classes = {
|
||||
1: "bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100",
|
||||
0: "bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100",
|
||||
2: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200",
|
||||
};
|
||||
return (
|
||||
classes[status] || "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
||||
);
|
||||
};
|
||||
|
||||
// Filter functions
|
||||
const filterByCategory = async (value) => {
|
||||
filters.value.category = value;
|
||||
currentPage.value = 1; // Reset to first page
|
||||
await fetchTemplates();
|
||||
};
|
||||
|
||||
const filterByChannel = async (value) => {
|
||||
filters.value.channel = value;
|
||||
currentPage.value = 1; // Reset to first page
|
||||
await fetchTemplates();
|
||||
};
|
||||
|
||||
const filterByStatus = async (value) => {
|
||||
filters.value.status = value;
|
||||
currentPage.value = 1; // Reset to first page
|
||||
await fetchTemplates();
|
||||
};
|
||||
|
||||
// Action functions
|
||||
const editTemplate = (template) => {
|
||||
router.push(`/notification/templates/edit/${template.id}`);
|
||||
};
|
||||
|
||||
const previewTemplate = (template) => {
|
||||
selectedTemplate.value = template;
|
||||
showPreview.value = true;
|
||||
};
|
||||
|
||||
const restoreVersion = async (version) => {
|
||||
const result = await $swal.fire({
|
||||
title: "Restore Version?",
|
||||
text: `Are you sure you want to restore version ${version.version} for "${selectedTemplate.value?.title}"? Current content will be overwritten.`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Restore",
|
||||
cancelButtonText: "Cancel",
|
||||
confirmButtonColor: "#3085d6",
|
||||
cancelButtonColor: "#d33",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
// Show loading
|
||||
const loadingSwal = $swal.fire({
|
||||
title: "Restoring Version...",
|
||||
text: "Please wait while we restore the version",
|
||||
allowOutsideClick: false,
|
||||
allowEscapeKey: false,
|
||||
showConfirmButton: false,
|
||||
didOpen: () => {
|
||||
$swal.showLoading();
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Restoring version:", version.id);
|
||||
|
||||
const response = await $fetch(
|
||||
`/api/notifications/templates/${selectedTemplate.value.id}/versions/${version.id}/restore`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
loadingSwal.close();
|
||||
|
||||
if (response.success) {
|
||||
await $swal.fire({
|
||||
title: "Restored!",
|
||||
text: response.data.message,
|
||||
icon: "success",
|
||||
timer: 3000,
|
||||
});
|
||||
|
||||
// Close the version history modal
|
||||
showVersions.value = false;
|
||||
|
||||
// Refresh the templates list to show updated version
|
||||
await fetchTemplates();
|
||||
} else {
|
||||
throw new Error(response.data?.error || "Failed to restore version");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error restoring version:", error);
|
||||
|
||||
let errorMessage = "Failed to restore version. Please try again.";
|
||||
if (error.data?.error) {
|
||||
errorMessage = error.data.error;
|
||||
}
|
||||
|
||||
await $swal.fire({
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteVersion = async (version) => {
|
||||
const result = await $swal.fire({
|
||||
title: "Delete Version?",
|
||||
text: `Are you sure you want to delete version ${version.version} for "${selectedTemplate.value?.title}"? This action cannot be undone.`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, delete it!",
|
||||
cancelButtonText: "Cancel",
|
||||
confirmButtonColor: "#d33",
|
||||
cancelButtonColor: "#3085d6",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
// Show loading
|
||||
const loadingSwal = $swal.fire({
|
||||
title: "Deleting Version...",
|
||||
text: "Please wait while we delete the version",
|
||||
allowOutsideClick: false,
|
||||
allowEscapeKey: false,
|
||||
showConfirmButton: false,
|
||||
didOpen: () => {
|
||||
$swal.showLoading();
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Deleting version:", version.id);
|
||||
|
||||
const response = await $fetch(
|
||||
`/api/notifications/templates/${selectedTemplate.value.id}/versions/${version.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
loadingSwal.close();
|
||||
|
||||
if (response.success) {
|
||||
// Remove the deleted version from the local array
|
||||
versionHistory.value = versionHistory.value.filter((v) => v.id !== version.id);
|
||||
|
||||
await $swal.fire({
|
||||
title: "Deleted!",
|
||||
text: response.data.message,
|
||||
icon: "success",
|
||||
timer: 3000,
|
||||
});
|
||||
|
||||
// If no versions left, close the modal
|
||||
if (versionHistory.value.length === 0) {
|
||||
showVersions.value = false;
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data?.error || "Failed to delete version");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting version:", error);
|
||||
|
||||
let errorMessage = "Failed to delete version. Please try again.";
|
||||
if (error.data?.error) {
|
||||
errorMessage = error.data.error;
|
||||
}
|
||||
|
||||
await $swal.fire({
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openModalDelete = async (templateToDelete) => {
|
||||
const result = await $swal.fire({
|
||||
title: "Delete Template",
|
||||
text: `Are you sure you want to delete "${templateToDelete.title}"? This action cannot be undone.`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, delete it!",
|
||||
cancelButtonText: "Cancel",
|
||||
confirmButtonColor: "#d33",
|
||||
cancelButtonColor: "#3085d6",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
await deleteTemplate(templateToDelete);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTemplate = async (templateToDelete) => {
|
||||
try {
|
||||
// Show loading
|
||||
const loadingSwal = $swal.fire({
|
||||
title: "Deleting Template...",
|
||||
text: "Please wait while we delete the template",
|
||||
allowOutsideClick: false,
|
||||
allowEscapeKey: false,
|
||||
showConfirmButton: false,
|
||||
didOpen: () => {
|
||||
$swal.showLoading();
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Deleting template:", templateToDelete.title);
|
||||
|
||||
const response = await $fetch(`/api/notifications/templates/${templateToDelete.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
loadingSwal.close();
|
||||
|
||||
if (response.success) {
|
||||
await $swal.fire({
|
||||
position: "center",
|
||||
icon: "success",
|
||||
title: "Deleted!",
|
||||
text: response.data.message,
|
||||
timer: 3000,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
|
||||
// Refresh the templates list
|
||||
await fetchTemplates();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting template:", error);
|
||||
|
||||
let errorMessage = "Failed to delete template. Please try again.";
|
||||
if (error.data?.error) {
|
||||
errorMessage = error.data.error;
|
||||
}
|
||||
|
||||
await $swal.fire({
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Load templates when component mounts
|
||||
onMounted(async () => {
|
||||
await fetchTemplates();
|
||||
});
|
||||
</script>
|
||||
314
pages/notification/triggers-rule/index.vue
Normal file
314
pages/notification/triggers-rule/index.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
definePageMeta({
|
||||
title: "Notification Triggers & Rules",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
const activeTab = ref('triggers'); // 'triggers', 'segments'
|
||||
|
||||
// Mock data
|
||||
const mockEvents = ref([
|
||||
{ id: 'user_registered', name: 'User Registered' },
|
||||
{ id: 'payment_completed', name: 'Payment Completed' },
|
||||
{ id: 'password_reset_request', name: 'Password Reset Request' },
|
||||
]);
|
||||
|
||||
const mockTemplates = ref([
|
||||
{ id: 'welcome_email', name: 'Welcome Email' },
|
||||
{ id: 'payment_receipt', name: 'Payment Receipt' },
|
||||
{ id: 'reset_password_instructions', name: 'Reset Password Instructions' },
|
||||
]);
|
||||
|
||||
const triggers = ref([
|
||||
{
|
||||
id: 'trg_001',
|
||||
name: 'Welcome New Users',
|
||||
description: 'Sends a welcome email upon user registration.',
|
||||
type: 'event',
|
||||
eventType: 'user_registered',
|
||||
actionTemplateId: 'welcome_email',
|
||||
priority: 'medium',
|
||||
status: 'active',
|
||||
conditions: [],
|
||||
targetSegments: [],
|
||||
dependencies: null,
|
||||
},
|
||||
{
|
||||
id: 'trg_002',
|
||||
name: 'Daily Sales Summary',
|
||||
description: 'Sends a summary of sales daily at 8 AM.',
|
||||
type: 'time',
|
||||
schedule: '0 8 * * *', // Cron for 8 AM daily
|
||||
actionTemplateId: 'payment_receipt', // Placeholder, should be a summary template
|
||||
priority: 'low',
|
||||
status: 'inactive',
|
||||
conditions: [],
|
||||
targetSegments: [],
|
||||
dependencies: null,
|
||||
}
|
||||
]);
|
||||
|
||||
const showAddEditModal = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const currentTrigger = ref(null);
|
||||
|
||||
const newTriggerData = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'event',
|
||||
eventType: mockEvents.value.length > 0 ? mockEvents.value[0].id : null,
|
||||
schedule: '',
|
||||
webhookUrl: 'https://api.example.com/webhook/generated_id', // Placeholder
|
||||
actionTemplateId: mockTemplates.value.length > 0 ? mockTemplates.value[0].id : null,
|
||||
priority: 'medium',
|
||||
status: 'active',
|
||||
conditions: [],
|
||||
targetSegments: [],
|
||||
dependencies: null,
|
||||
});
|
||||
|
||||
const priorities = ['low', 'medium', 'high'];
|
||||
const triggerTypes = [
|
||||
{ id: 'event', name: 'Event-Based' },
|
||||
{ id: 'time', name: 'Time-Based' },
|
||||
{ id: 'api', name: 'External API/Webhook' }
|
||||
];
|
||||
|
||||
|
||||
const openAddModal = () => {
|
||||
isEditing.value = false;
|
||||
currentTrigger.value = null;
|
||||
newTriggerData.value = {
|
||||
id: `trg_${Date.now().toString().slice(-3)}`, // Simple unique ID for mock
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'event',
|
||||
eventType: mockEvents.value.length > 0 ? mockEvents.value[0].id : null,
|
||||
schedule: '',
|
||||
webhookUrl: `https://api.example.com/webhook/trg_${Date.now().toString().slice(-3)}`,
|
||||
actionTemplateId: mockTemplates.value.length > 0 ? mockTemplates.value[0].id : null,
|
||||
priority: 'medium',
|
||||
status: 'active',
|
||||
conditions: [],
|
||||
targetSegments: [],
|
||||
dependencies: null,
|
||||
};
|
||||
showAddEditModal.value = true;
|
||||
};
|
||||
|
||||
const openEditModal = (trigger) => {
|
||||
isEditing.value = true;
|
||||
currentTrigger.value = trigger;
|
||||
newTriggerData.value = { ...trigger };
|
||||
showAddEditModal.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showAddEditModal.value = false;
|
||||
currentTrigger.value = null;
|
||||
};
|
||||
|
||||
const saveTrigger = () => {
|
||||
if (isEditing.value && currentTrigger.value) {
|
||||
const index = triggers.value.findIndex(t => t.id === currentTrigger.value.id);
|
||||
if (index !== -1) {
|
||||
triggers.value[index] = { ...newTriggerData.value };
|
||||
}
|
||||
} else {
|
||||
triggers.value.push({ ...newTriggerData.value, id: newTriggerData.value.id || `trg_${Date.now().toString().slice(-3)}` });
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const deleteTrigger = (triggerId) => {
|
||||
if (confirm('Are you sure you want to delete this trigger?')) {
|
||||
triggers.value = triggers.value.filter(t => t.id !== triggerId);
|
||||
}
|
||||
};
|
||||
|
||||
const testTrigger = (trigger) => {
|
||||
alert(`Simulating test for trigger: ${trigger.name} (Not implemented yet)`);
|
||||
};
|
||||
|
||||
const getEventName = (eventId) => mockEvents.value.find(e => e.id === eventId)?.name || eventId;
|
||||
const getTemplateName = (templateId) => mockTemplates.value.find(t => t.id === templateId)?.name || templateId;
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-xl font-semibold">Notification Triggers & Rules</h1>
|
||||
<button @click="openAddModal" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
|
||||
Add New Trigger
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<!-- Tabs (Simplified for now, can be expanded later if needed) -->
|
||||
<!-- <div class="mb-4 border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<button @click="activeTab = 'triggers'"
|
||||
:class="['whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm', activeTab === 'triggers' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300']">
|
||||
Triggers & Rules
|
||||
</button>
|
||||
<button @click="activeTab = 'segments'"
|
||||
:class="['whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm', activeTab === 'segments' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300']">
|
||||
User Segments (Coming Soon)
|
||||
</button>
|
||||
</nav>
|
||||
</div> -->
|
||||
|
||||
<div v-if="activeTab === 'triggers'">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Priority</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-if="triggers.length === 0">
|
||||
<td colspan="6" class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">No triggers defined yet.</td>
|
||||
</tr>
|
||||
<tr v-for="trigger in triggers" :key="trigger.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ trigger.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ trigger.description }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">{{ trigger.type }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div v-if="trigger.type === 'event'">Event: {{ getEventName(trigger.eventType) }}</div>
|
||||
<div v-if="trigger.type === 'time'">Schedule: {{ trigger.schedule }}</div>
|
||||
<div v-if="trigger.type === 'api'" class="truncate max-w-xs" :title="trigger.webhookUrl">Webhook: {{ trigger.webhookUrl }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">{{ trigger.priority }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="['px-2 inline-flex text-xs leading-5 font-semibold rounded-full', trigger.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
|
||||
{{ trigger.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
<button @click="openEditModal(trigger)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
|
||||
<button @click="testTrigger(trigger)" class="text-yellow-600 hover:text-yellow-900">Test</button>
|
||||
<button @click="deleteTrigger(trigger.id)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="activeTab === 'segments'">
|
||||
<p class="text-gray-600 p-4">User Segments management will be available here soon.</p>
|
||||
</div> -->
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showAddEditModal" class="fixed inset-0 z-50 overflow-y-auto bg-gray-600 bg-opacity-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">{{ isEditing ? 'Edit' : 'Add New' }} Trigger/Rule</h3>
|
||||
<button @click="closeModal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveTrigger" class="space-y-4">
|
||||
<div>
|
||||
<label for="triggerName" class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input type="text" v-model="newTriggerData.name" id="triggerName" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="triggerDescription" class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="newTriggerData.description" id="triggerDescription" rows="2" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="triggerStatus" class="block text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="newTriggerData.status" id="triggerStatus" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="triggerPriority" class="block text-sm font-medium text-gray-700">Priority</label>
|
||||
<select v-model="newTriggerData.priority" id="triggerPriority" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
<option v-for="p in priorities" :key="p" :value="p" class="capitalize">{{ p }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="triggerType" class="block text-sm font-medium text-gray-700">Trigger Type</label>
|
||||
<select v-model="newTriggerData.type" id="triggerType" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
<option v-for="tt in triggerTypes" :key="tt.id" :value="tt.id">{{ tt.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Conditional Fields -->
|
||||
<div v-if="newTriggerData.type === 'event'">
|
||||
<label for="eventType" class="block text-sm font-medium text-gray-700">Event</label>
|
||||
<select v-model="newTriggerData.eventType" id="eventType" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
<option v-for="event in mockEvents" :key="event.id" :value="event.id">{{ event.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="newTriggerData.type === 'time'">
|
||||
<label for="triggerSchedule" class="block text-sm font-medium text-gray-700">Schedule (Cron Expression or Description)</label>
|
||||
<input type="text" v-model="newTriggerData.schedule" id="triggerSchedule" placeholder="e.g., 0 9 * * * OR Daily at 9 AM" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
</div>
|
||||
<div v-if="newTriggerData.type === 'api'">
|
||||
<label class="block text-sm font-medium text-gray-700">Webhook URL</label>
|
||||
<input type="text" :value="newTriggerData.webhookUrl" readonly class="mt-1 block w-full rounded-md border-gray-300 shadow-sm bg-gray-100 sm:text-sm cursor-not-allowed">
|
||||
<p class="text-xs text-gray-500 mt-1">This URL is automatically generated. Send a POST request here to trigger.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="actionTemplate" class="block text-sm font-medium text-gray-700">Action: Send Notification Template</label>
|
||||
<select v-model="newTriggerData.actionTemplateId" id="actionTemplate" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
<option v-for="template in mockTemplates" :key="template.id" :value="template.id">{{ template.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-3 bg-gray-50 rounded-md">
|
||||
<h4 class="text-sm font-medium text-gray-600 mb-2">Advanced Configuration (Coming Soon)</h4>
|
||||
<p class="text-xs text-gray-500">- Conditional Logic (IF/THEN Rules)</p>
|
||||
<p class="text-xs text-gray-500">- User Segmentation Targeting</p>
|
||||
<p class="text-xs text-gray-500">- Rule Dependencies</p>
|
||||
<p class="text-xs text-gray-500">- Rule Testing & Simulation</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-5">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="closeModal" class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700">
|
||||
{{ isEditing ? 'Save Changes' : 'Add Trigger' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles if needed */
|
||||
.max-h-\[90vh\] {
|
||||
max-height: 90vh;
|
||||
}
|
||||
</style>
|
||||
519
pages/notification/view/[id].vue
Normal file
519
pages/notification/view/[id].vue
Normal file
@@ -0,0 +1,519 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Info Card -->
|
||||
<rs-card class="mb-5">
|
||||
<template #header>
|
||||
<div class="flex">
|
||||
<Icon class="mr-2 flex justify-center" name="ic:outline-info"></Icon>
|
||||
Notification Details
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="mb-4">
|
||||
View detailed information about this notification including content, delivery
|
||||
settings, performance metrics, and recipient targeting.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-12">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon name="ic:outline-refresh" size="2rem" class="text-primary animate-spin" />
|
||||
</div>
|
||||
<p class="text-gray-600">Loading notification details...</p>
|
||||
</div>
|
||||
|
||||
<!-- Notification Not Found -->
|
||||
<rs-card v-else-if="!notification">
|
||||
<template #body>
|
||||
<div class="text-center py-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon name="ic:outline-error" size="4rem" class="text-red-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-500 mb-2">Notification Not Found</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
The notification you're looking for doesn't exist or has been deleted.
|
||||
</p>
|
||||
<rs-button @click="$router.push('/notification/list')" variant="primary">
|
||||
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
|
||||
Back to List
|
||||
</rs-button>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Notification Details -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Header Actions -->
|
||||
<rs-card>
|
||||
<template #body>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<Icon name="ic:outline-notifications" class="text-primary" size="32" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ notification.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<rs-badge :variant="getStatusVariant(notification.status)">
|
||||
{{ notification.status }}
|
||||
</rs-badge>
|
||||
<rs-badge :variant="getPriorityVariant(notification.priority)">
|
||||
{{ notification.priority }} Priority
|
||||
</rs-badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<rs-button @click="$router.push('/notification/list')" variant="primary">
|
||||
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
|
||||
Back to List
|
||||
</rs-button>
|
||||
<rs-button
|
||||
@click="editNotification"
|
||||
v-if="
|
||||
notification.status === 'draft' || notification.status === 'scheduled'
|
||||
"
|
||||
>
|
||||
<Icon name="material-symbols:edit" class="mr-1"></Icon>
|
||||
Edit
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Basic Information -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Basic Information</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Title</label
|
||||
>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ notification.title }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Category</label
|
||||
>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ notification.category.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Priority</label
|
||||
>
|
||||
<rs-badge :variant="getPriorityVariant(notification.priority)">
|
||||
{{ notification.priority }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Status</label
|
||||
>
|
||||
<rs-badge :variant="getStatusVariant(notification.status)">
|
||||
{{ notification.status }}
|
||||
</rs-badge>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Delivery Type</label
|
||||
>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100 capitalize">
|
||||
{{ notification.deliveryType }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Recipients</label
|
||||
>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(notification.analytics.totalRecipients) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Delivery Channels -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Delivery Channels</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div
|
||||
v-for="channel in notification.channels"
|
||||
:key="channel"
|
||||
class="flex items-center gap-2 p-3 border rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<Icon :name="getChannelIcon(channel)" size="20" class="text-primary" />
|
||||
<span class="font-medium capitalize">{{ channel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Performance Metrics -->
|
||||
<rs-card v-if="notification.status === 'sent' || notification.status === 'sending'">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Performance Metrics</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="text-center p-4 border rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
{{ formatNumber(notification.analytics.deliveredCount) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">Successfully Delivered</div>
|
||||
</div>
|
||||
<div class="text-center p-4 border rounded-lg">
|
||||
<div class="text-2xl font-bold text-red-600">
|
||||
{{ formatNumber(notification.analytics.failedCount) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">Failed</div>
|
||||
</div>
|
||||
<div class="text-center p-4 border rounded-lg">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
{{ notification.analytics.successRate }}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">Success Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Rate Progress Bar -->
|
||||
<div class="mt-6">
|
||||
<div class="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Success Rate</span>
|
||||
<span>{{ notification.analytics.successRate }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
class="h-3 rounded-full transition-all duration-300"
|
||||
:class="
|
||||
notification.analytics.successRate >= 95
|
||||
? 'bg-green-500'
|
||||
: notification.analytics.successRate >= 80
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
"
|
||||
:style="{ width: notification.analytics.successRate + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Metrics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<div class="text-center p-4 border rounded-lg">
|
||||
<div class="text-2xl font-bold text-blue-600">
|
||||
{{ notification.analytics.openRate }}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">Open Rate</div>
|
||||
</div>
|
||||
<div class="text-center p-4 border rounded-lg">
|
||||
<div class="text-2xl font-bold text-purple-600">
|
||||
{{ notification.analytics.clickRate }}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">Click Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Scheduling Information -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Scheduling Information</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Created At</label
|
||||
>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ formatDateTime(notification.createdAt) }}
|
||||
<span class="text-sm text-gray-500"
|
||||
>({{ formatTimeAgo(notification.createdAt) }})</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="notification.scheduledAt">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Scheduled At</label
|
||||
>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ formatDateTime(notification.scheduledAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="notification.sentAt">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Sent At</label
|
||||
>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ formatDateTime(notification.sentAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Content Information -->
|
||||
<rs-card v-if="notification.contentType === 'template' && notification.template">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Template Information</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Template Name
|
||||
</label>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ notification.template.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="notification.template.subject">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Email Subject
|
||||
</label>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ notification.template.subject }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="notification.template.pushTitle">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Push Title
|
||||
</label>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ notification.template.pushTitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Custom Content -->
|
||||
<template v-else>
|
||||
<rs-card v-if="notification.emailSubject || notification.emailContent">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Email Content</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div v-if="notification.emailSubject">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Subject
|
||||
</label>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ notification.emailSubject }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="notification.emailContent">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Content
|
||||
</label>
|
||||
<div
|
||||
class="prose dark:prose-invert max-w-none"
|
||||
v-html="notification.emailContent"
|
||||
></div>
|
||||
</div>
|
||||
<div v-if="notification.callToActionText && notification.callToActionUrl">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Call to Action
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<rs-button as="a" :href="notification.callToActionUrl" target="_blank">
|
||||
{{ notification.callToActionText }}
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<rs-card v-if="notification.pushTitle || notification.pushBody">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Push Notification Content</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div v-if="notification.pushTitle">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ notification.pushTitle }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="notification.pushBody">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Body
|
||||
</label>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100">
|
||||
{{ notification.pushBody }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="notification.pushImageUrl">
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Image
|
||||
</label>
|
||||
<img
|
||||
:src="notification.pushImageUrl"
|
||||
alt="Push notification image"
|
||||
class="max-w-sm rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
|
||||
definePageMeta({
|
||||
title: "Notification Details",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
// Get route params
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const notificationId = route.params.id;
|
||||
|
||||
// Reactive data
|
||||
const isLoading = ref(true);
|
||||
const notification = ref(null);
|
||||
|
||||
// Get notifications composable
|
||||
const { getNotificationById } = useNotifications();
|
||||
|
||||
// Helper functions
|
||||
const getChannelIcon = (channel) => {
|
||||
const icons = {
|
||||
email: "material-symbols:mail-outline-rounded",
|
||||
push: "material-symbols:notifications-active-outline-rounded",
|
||||
sms: "material-symbols:sms-outline-rounded",
|
||||
"in-app": "material-symbols:chat-bubble-outline-rounded",
|
||||
};
|
||||
return icons[channel] || "material-symbols:help-outline-rounded";
|
||||
};
|
||||
|
||||
const getPriorityVariant = (priority) => {
|
||||
const variants = {
|
||||
critical: "danger",
|
||||
high: "warning",
|
||||
medium: "info",
|
||||
low: "secondary",
|
||||
};
|
||||
return variants[priority] || "secondary";
|
||||
};
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
const variants = {
|
||||
draft: "secondary",
|
||||
scheduled: "info",
|
||||
sending: "warning",
|
||||
sent: "success",
|
||||
failed: "danger",
|
||||
cancelled: "secondary",
|
||||
};
|
||||
return variants[status] || "secondary";
|
||||
};
|
||||
|
||||
const formatNumber = (num) => {
|
||||
return new Intl.NumberFormat().format(num);
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString) => {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffInHours = Math.floor((now - date) / (1000 * 60 * 60));
|
||||
|
||||
if (diffInHours < 1) return "Just now";
|
||||
if (diffInHours < 24) return `${diffInHours}h ago`;
|
||||
if (diffInHours < 48) return "Yesterday";
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
return `${diffInDays}d ago`;
|
||||
};
|
||||
|
||||
// Methods
|
||||
const editNotification = () => {
|
||||
router.push(`/notification/edit/${notificationId}`);
|
||||
};
|
||||
|
||||
const loadNotification = async () => {
|
||||
isLoading.value = true;
|
||||
|
||||
console.log("Notification ID:", notificationId);
|
||||
|
||||
try {
|
||||
const data = await getNotificationById(notificationId);
|
||||
notification.value = data;
|
||||
} catch (error) {
|
||||
console.error("Error loading notification:", error);
|
||||
notification.value = null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadNotification();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user