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:
Haqeem Solehan
2025-10-16 16:05:39 +08:00
commit b124ff8092
336 changed files with 94392 additions and 0 deletions

View 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>

View 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>

View 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.&lt;region&gt;.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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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: &lt; 1 second processing</li>
<li> High: &lt; 5 seconds processing</li>
<li> Medium: &lt; 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>