Files

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>