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:
656
pages/notification/list/index.vue
Normal file
656
pages/notification/list/index.vue
Normal file
@@ -0,0 +1,656 @@
|
||||
<template>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<!-- Info Card -->
|
||||
<rs-card class="mb-5">
|
||||
<template #header>
|
||||
<div class="flex">
|
||||
<Icon class="mr-2 flex justify-center" name="ic:outline-info"></Icon>
|
||||
Info
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="mb-4">
|
||||
View and manage all created notifications. Monitor their status, delivery
|
||||
progress, and performance metrics. You can create new notifications, edit
|
||||
existing ones, or view detailed analytics.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Notification List</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="pt-2">
|
||||
<!-- Create Button -->
|
||||
<div class="flex justify-end items-center mb-6">
|
||||
<rs-button @click="$router.push('/notification/create')">
|
||||
<Icon name="material-symbols:add" class="mr-1"></Icon>
|
||||
Create Notification
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
v-model="filters.status"
|
||||
class="w-full border border-gray-300 dark:border-gray-700 rounded-md p-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option
|
||||
v-for="option in statusOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
v-model="filters.priority"
|
||||
class="w-full border border-gray-300 dark:border-gray-700 rounded-md p-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
v-model="filters.category"
|
||||
class="w-full border border-gray-300 dark:border-gray-700 rounded-md p-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option
|
||||
v-for="option in categoryOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Search
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
v-model="filters.search"
|
||||
placeholder="Search notifications..."
|
||||
class="w-full border border-gray-300 dark:border-gray-700 rounded-md p-2 pl-9 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:search"
|
||||
class="text-gray-500"
|
||||
size="16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<rs-button variant="outline" @click="clearFilters" class="mr-2">
|
||||
<Icon name="material-symbols:refresh" class="mr-1" />
|
||||
Clear
|
||||
</rs-button>
|
||||
<rs-button @click="applyFilters">
|
||||
<Icon name="material-symbols:filter-alt" class="mr-1" />
|
||||
Apply Filters
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="mb-6">
|
||||
<rs-alert variant="danger" :dismissible="true" @dismiss="error = null">
|
||||
<template #title>Error</template>
|
||||
{{ error }}
|
||||
</rs-alert>
|
||||
</div>
|
||||
|
||||
<!-- Notifications Table -->
|
||||
<rs-table
|
||||
v-if="notificationList && notificationList.length > 0"
|
||||
:data="notificationList"
|
||||
:fields="tableFields"
|
||||
:options="{
|
||||
variant: 'default',
|
||||
striped: true,
|
||||
borderless: true,
|
||||
class: 'align-middle',
|
||||
serverSort: true,
|
||||
sortBy: sortBy,
|
||||
sortDesc: sortOrder === 'desc',
|
||||
noSort: false
|
||||
}"
|
||||
advanced
|
||||
@sort-changed="handleSort"
|
||||
>
|
||||
<!-- Title column with icon -->
|
||||
<template v-slot:title="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<div class="font-semibold">{{ value.title }}</div>
|
||||
<div class="text-xs text-gray-500">{{ value.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Channels column with icons -->
|
||||
<template v-slot:channels="{ value }">
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<template v-for="channel in value.channels" :key="channel">
|
||||
<span :title="channel" class="flex items-center gap-1">
|
||||
<Icon
|
||||
:name="getChannelIcon(channel)"
|
||||
class="text-gray-700 dark:text-gray-300"
|
||||
size="16"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Priority column with badges -->
|
||||
<template v-slot:priority="{ text }">
|
||||
<rs-badge :variant="getPriorityVariant(text)">
|
||||
{{ text }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<!-- Status column with badges -->
|
||||
<template v-slot:status="{ text }">
|
||||
<rs-badge :variant="getStatusVariant(text)">
|
||||
{{ text }}
|
||||
</rs-badge>
|
||||
</template>
|
||||
|
||||
<!-- Recipients column with formatting -->
|
||||
<template v-slot:recipients="{ text }">
|
||||
<span class="font-medium">{{ formatNumber(text) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Created At column with relative time -->
|
||||
<template v-slot:createdAt="{ text }">
|
||||
<div>
|
||||
<div class="text-sm">{{ formatDate(text) }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatTimeAgo(text) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Actions column -->
|
||||
<template v-slot:action="{ value }">
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<span title="View Details">
|
||||
<Icon
|
||||
name="ic:outline-visibility"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="20"
|
||||
@click="viewNotification(value)"
|
||||
/>
|
||||
</span>
|
||||
<span title="Edit">
|
||||
<Icon
|
||||
name="material-symbols:edit-outline-rounded"
|
||||
class="text-blue-600 hover:text-blue-700 cursor-pointer"
|
||||
size="20"
|
||||
@click="editNotification(value)"
|
||||
/>
|
||||
</span>
|
||||
<span title="Delete">
|
||||
<Icon
|
||||
name="material-symbols:delete-outline-rounded"
|
||||
class="text-red-500 hover:text-red-600 cursor-pointer"
|
||||
size="20"
|
||||
@click="handleDeleteNotification(value)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</rs-table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="pagination && pagination.totalPages > 1"
|
||||
class="flex justify-center mt-6"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="handlePageChange(1)"
|
||||
:disabled="pagination.page === 1"
|
||||
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<Icon name="material-symbols:first-page" size="16" />
|
||||
</button>
|
||||
<button
|
||||
@click="handlePageChange(pagination.page - 1)"
|
||||
:disabled="!pagination.hasPrevPage"
|
||||
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<Icon name="material-symbols:chevron-left" size="16" />
|
||||
</button>
|
||||
|
||||
<template v-for="p in pagination.totalPages" :key="p">
|
||||
<button
|
||||
v-if="
|
||||
p === 1 ||
|
||||
p === pagination.totalPages ||
|
||||
(p >= pagination.page - 1 && p <= pagination.page + 1)
|
||||
"
|
||||
@click="handlePageChange(p)"
|
||||
:class="[
|
||||
'px-3 py-1 rounded',
|
||||
pagination.page === p
|
||||
? 'bg-primary text-white'
|
||||
: 'border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||
]"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="
|
||||
(p === pagination.page - 2 && p > 1) ||
|
||||
(p === pagination.page + 2 && p < pagination.totalPages)
|
||||
"
|
||||
class="px-1"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<button
|
||||
@click="handlePageChange(pagination.page + 1)"
|
||||
:disabled="!pagination.hasNextPage"
|
||||
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<Icon name="material-symbols:chevron-right" size="16" />
|
||||
</button>
|
||||
<button
|
||||
@click="handlePageChange(pagination.totalPages)"
|
||||
:disabled="pagination.page === pagination.totalPages"
|
||||
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<Icon name="material-symbols:last-page" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="notificationList && notificationList.length === 0 && !isLoading"
|
||||
class="text-center py-12"
|
||||
>
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon
|
||||
name="ic:outline-notifications-none"
|
||||
size="4rem"
|
||||
class="text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-500 mb-2">No Notifications Found</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
Create your first notification to get started.
|
||||
</p>
|
||||
<rs-button @click="$router.push('/notification/create')">
|
||||
<Icon name="material-symbols:add" class="mr-1"></Icon>
|
||||
Create First Notification
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-12">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Icon
|
||||
name="ic:outline-refresh"
|
||||
size="2rem"
|
||||
class="text-primary animate-spin"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-gray-600">Loading notifications...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useNotifications } from "~/composables/useNotifications";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
definePageMeta({
|
||||
title: "Notification List",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/dashboard",
|
||||
},
|
||||
{
|
||||
name: "Notification",
|
||||
path: "/notification",
|
||||
},
|
||||
{
|
||||
name: "List",
|
||||
path: "/notification/list",
|
||||
type: "current",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Use the notifications composable
|
||||
const {
|
||||
isLoading,
|
||||
notifications,
|
||||
pagination,
|
||||
error,
|
||||
fetchNotifications,
|
||||
deleteNotification,
|
||||
} = useNotifications();
|
||||
|
||||
// Use computed for the notification list to maintain reactivity
|
||||
const notificationList = computed(() => notifications.value);
|
||||
|
||||
// Filter states
|
||||
const filters = ref({
|
||||
status: "",
|
||||
priority: "",
|
||||
category: "",
|
||||
search: "",
|
||||
});
|
||||
|
||||
const currentPage = ref(1);
|
||||
const itemsPerPage = ref(10);
|
||||
// Current variables and default values for sorting
|
||||
const sortBy = ref("createdAt"); // Use camelCase to match frontend data format
|
||||
const sortOrder = ref("desc");
|
||||
|
||||
// Table field configuration
|
||||
const tableFields = [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Notification',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'channels',
|
||||
label: 'Channels',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
label: 'Priority',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'recipients',
|
||||
label: 'Recipients',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Actions',
|
||||
sortable: false
|
||||
}
|
||||
];
|
||||
|
||||
// Options for filters
|
||||
const statusOptions = [
|
||||
{ label: "All Statuses", value: "" },
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Scheduled", value: "scheduled" },
|
||||
{ label: "Sending", value: "sending" },
|
||||
{ label: "Sent", value: "sent" },
|
||||
{ label: "Failed", value: "failed" },
|
||||
{ label: "Cancelled", value: "cancelled" },
|
||||
];
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: "All Priorities", value: "" },
|
||||
{ label: "Low", value: "low" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Critical", value: "critical" },
|
||||
];
|
||||
|
||||
const categoryOptions = ref([]);
|
||||
|
||||
// Fetch categories
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await $fetch("/api/notifications/categories");
|
||||
if (response.success) {
|
||||
categoryOptions.value = [
|
||||
{ label: "All Categories", value: "" },
|
||||
...response.data.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.value,
|
||||
})),
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching categories:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const getChannelIcon = (channel) => {
|
||||
const icons = {
|
||||
email: "material-symbols:mail-outline-rounded",
|
||||
push: "material-symbols:notifications-active-outline-rounded",
|
||||
sms: "material-symbols:sms-outline-rounded",
|
||||
"in-app": "material-symbols:chat-bubble-outline-rounded",
|
||||
};
|
||||
return icons[channel] || "material-symbols:help-outline-rounded";
|
||||
};
|
||||
|
||||
const getPriorityVariant = (priority) => {
|
||||
const variants = {
|
||||
low: "info",
|
||||
medium: "primary",
|
||||
high: "warning",
|
||||
critical: "danger",
|
||||
};
|
||||
return variants[priority] || "primary";
|
||||
};
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
const variants = {
|
||||
draft: "secondary",
|
||||
scheduled: "info",
|
||||
sending: "warning",
|
||||
sent: "success",
|
||||
failed: "danger",
|
||||
cancelled: "dark",
|
||||
};
|
||||
return variants[status] || "secondary";
|
||||
};
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num === undefined || num === null) return "0";
|
||||
return Number(num).toLocaleString();
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString) => {
|
||||
if (!dateString) return "";
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
const diffHour = Math.round(diffMin / 60);
|
||||
const diffDay = Math.round(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return `${diffSec} sec ago`;
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
if (diffHour < 24) return `${diffHour} hr ago`;
|
||||
if (diffDay < 30) return `${diffDay} days ago`;
|
||||
|
||||
return formatDate(dateString);
|
||||
};
|
||||
|
||||
// Actions
|
||||
const viewNotification = (notification) => {
|
||||
router.push(`/notification/view/${notification.id}`);
|
||||
};
|
||||
|
||||
const editNotification = (notification) => {
|
||||
router.push(`/notification/edit/${notification.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteNotification = async (notification) => {
|
||||
const { $swal } = useNuxtApp();
|
||||
|
||||
try {
|
||||
const result = await $swal.fire({
|
||||
title: "Are you sure?",
|
||||
text: "You won't be able to revert this!",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, delete it!",
|
||||
cancelButtonText: "Cancel",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
await deleteNotification(notification.id);
|
||||
await $swal.fire("Deleted!", "The notification has been deleted.", "success");
|
||||
}
|
||||
} catch (err) {
|
||||
// Get the specific error message from the server
|
||||
const errorMessage =
|
||||
err.data?.message ||
|
||||
err.data?.statusMessage ||
|
||||
err.message ||
|
||||
"Failed to delete notification";
|
||||
await $swal.fire("Error", errorMessage, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch data with filters
|
||||
const loadData = async () => {
|
||||
try {
|
||||
await fetchNotifications({
|
||||
page: currentPage.value,
|
||||
limit: itemsPerPage.value,
|
||||
status: filters.value.status,
|
||||
priority: filters.value.priority,
|
||||
category: filters.value.category,
|
||||
search: filters.value.search,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error loading notifications:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = (page) => {
|
||||
currentPage.value = page;
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Handle filter changes
|
||||
const applyFilters = () => {
|
||||
currentPage.value = 1; // Reset to first page
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Handle sort changes
|
||||
const handleSort = (sortInfo) => {
|
||||
// Handle both direct column string and sort event object
|
||||
if (typeof sortInfo === 'string') {
|
||||
// Direct column name (legacy)
|
||||
if (sortBy.value === sortInfo) {
|
||||
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortBy.value = sortInfo;
|
||||
sortOrder.value = "desc"; // Default to descending
|
||||
}
|
||||
} else if (sortInfo && sortInfo.sortBy) {
|
||||
// Sort event object from table component
|
||||
sortBy.value = sortInfo.sortBy;
|
||||
sortOrder.value = sortInfo.sortDesc ? "desc" : "asc";
|
||||
}
|
||||
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
filters.value = {
|
||||
status: "",
|
||||
priority: "",
|
||||
category: "",
|
||||
search: "",
|
||||
};
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
// Life cycle hooks
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchCategories(), loadData()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom styles if needed */
|
||||
</style>
|
||||
Reference in New Issue
Block a user