657 lines
20 KiB
Vue
657 lines
20 KiB
Vue
<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>
|