2025-05-27 10:04:59 +08:00

509 lines
16 KiB
Vue

<template>
<div class="notification-logs">
<div class="mb-4 flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900">Notification Logs</h3>
<div class="flex space-x-2">
<RsButton @click="refreshLogs" variant="tertiary" size="sm">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
<RsButton @click="exportLogs" variant="secondary" size="sm" :disabled="logs.length === 0">
<Icon name="material-symbols:download" class="mr-1" />
Export
</RsButton>
</div>
</div>
<!-- Filter Controls -->
<div class="bg-gray-50 border border-gray-200 rounded-md p-3 mb-4">
<div class="flex flex-wrap gap-3">
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
v-model="filters.status"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
>
<option value="all">All</option>
<option value="delivered">Delivered</option>
<option value="read">Read</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
<select
v-model="filters.type"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
>
<option value="all">All</option>
<option value="info">Information</option>
<option value="success">Success</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-gray-700 mb-1">Channel</label>
<select
v-model="filters.channel"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
>
<option value="all">All Channels</option>
<option value="in-app">In-App</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-gray-700 mb-1">Date Range</label>
<div class="flex space-x-2">
<input
v-model="filters.dateFrom"
type="date"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
/>
<input
v-model="filters.dateTo"
type="date"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
/>
</div>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-gray-700 mb-1">Search</label>
<input
v-model="filters.search"
type="text"
class="w-full p-2 border rounded-md shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 text-sm"
placeholder="Search by recipient or subject..."
/>
</div>
<div class="flex items-end">
<RsButton @click="resetFilters" variant="tertiary" size="sm">
Reset
</RsButton>
</div>
</div>
</div>
<!-- Log Items Table -->
<div class="overflow-x-auto">
<table class="min-w-full bg-white border border-gray-200 rounded-md">
<thead>
<tr class="bg-gray-50">
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Timestamp
</th>
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Recipient
</th>
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Subject
</th>
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Channel
</th>
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="py-2 px-3 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-if="filteredLogs.length === 0">
<td colspan="8" class="py-4 px-3 text-center text-sm text-gray-500">
No notification logs found matching your filters.
</td>
</tr>
<tr v-for="log in paginatedLogs" :key="log.id" class="hover:bg-gray-50">
<td class="py-2 px-3 text-sm">
<span class="font-mono text-xs">{{ log.id.substring(0, 8) }}</span>
</td>
<td class="py-2 px-3 text-sm">
{{ formatDate(log.timestamp) }}
</td>
<td class="py-2 px-3 text-sm">
<div class="flex items-center">
<div :class="`text-${getTypeColor(log.type)}-500 mr-2`">
<Icon :name="getTypeIcon(log.type)" />
</div>
<span class="capitalize">{{ log.type }}</span>
</div>
</td>
<td class="py-2 px-3 text-sm">
{{ log.recipient }}
</td>
<td class="py-2 px-3 text-sm">
{{ log.subject }}
</td>
<td class="py-2 px-3 text-sm">
<span class="capitalize">{{ log.channel }}</span>
</td>
<td class="py-2 px-3 text-sm">
<span
:class="getStatusClass(log.status)"
class="px-2 py-1 text-xs font-medium rounded-full"
>
{{ log.status }}
</span>
</td>
<td class="py-2 px-3 text-sm">
<div class="flex space-x-2">
<button
@click="viewLog(log)"
class="text-blue-600 hover:text-blue-800"
title="View Details"
>
<Icon name="material-symbols:visibility-outline" />
</button>
<button
v-if="log.status === 'failed'"
@click="resendNotification(log)"
class="text-green-600 hover:text-green-800"
title="Resend"
>
<Icon name="material-symbols:refresh" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-4 flex justify-between items-center">
<div class="text-sm text-gray-500">
Showing {{ paginationStart }} to {{ paginationEnd }} of {{ filteredLogs.length }} items
</div>
<div class="flex space-x-2">
<RsButton
@click="prevPage"
variant="tertiary"
size="sm"
:disabled="currentPage === 1"
>
Previous
</RsButton>
<RsButton
@click="nextPage"
variant="tertiary"
size="sm"
:disabled="currentPage >= totalPages"
>
Next
</RsButton>
</div>
</div>
<!-- View Log Modal -->
<RsModal
v-model="showViewModal"
title="Notification Log Details"
size="lg"
position="center"
:cancelCallback="closeViewModal"
>
<div class="p-4">
<div v-if="selectedLog" class="space-y-4">
<div class="flex justify-between items-start">
<div>
<h3 class="text-lg font-medium text-gray-900">{{ selectedLog.subject }}</h3>
<p class="text-sm text-gray-500">{{ formatDate(selectedLog.timestamp) }}</p>
</div>
<span
:class="getStatusClass(selectedLog.status)"
class="px-2 py-1 text-xs font-medium rounded-full"
>
{{ selectedLog.status }}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="text-sm font-medium text-gray-700 mb-1">Type</h4>
<div class="flex items-center">
<div :class="`text-${getTypeColor(selectedLog.type)}-500 mr-2`">
<Icon :name="getTypeIcon(selectedLog.type)" />
</div>
<span class="capitalize">{{ selectedLog.type }}</span>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-1">Channel</h4>
<div class="flex items-center">
<div class="text-blue-500 mr-2">
<Icon :name="getChannelIcon(selectedLog.channel)" />
</div>
<span class="capitalize">{{ selectedLog.channel }}</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="text-sm font-medium text-gray-700 mb-1">Recipient</h4>
<p>{{ selectedLog.recipient }}</p>
</div>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-1">Template</h4>
<p>{{ selectedLog.templateName || 'Custom notification' }}</p>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-1">Message</h4>
<div class="bg-gray-50 p-3 rounded-md whitespace-pre-wrap">
{{ selectedLog.message }}
</div>
</div>
<div v-if="selectedLog.status === 'failed'">
<h4 class="text-sm font-medium text-gray-700 mb-1">Error</h4>
<div class="bg-red-50 p-3 rounded-md text-red-700 whitespace-pre-wrap">
{{ selectedLog.error || 'Unknown error occurred' }}
</div>
</div>
<div v-if="selectedLog.readAt">
<h4 class="text-sm font-medium text-gray-700 mb-1">Read Information</h4>
<div class="bg-green-50 p-3 rounded-md">
<p class="text-green-700">Read at: {{ formatDate(selectedLog.readAt) }}</p>
</div>
</div>
<div v-if="selectedLog.metadata">
<h4 class="text-sm font-medium text-gray-700 mb-1">Additional Information</h4>
<div class="bg-gray-50 p-3 rounded-md font-mono text-xs">
<pre>{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</RsModal>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { Icon } from '#components';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
});
const emit = defineEmits(['update:modelValue', 'resend', 'export']);
// Log data
const logs = ref(props.modelValue || []);
const showViewModal = ref(false);
const selectedLog = ref(null);
// Filters
const filters = ref({
status: 'all',
type: 'all',
channel: 'all',
dateFrom: '',
dateTo: '',
search: ''
});
// Pagination
const itemsPerPage = 10;
const currentPage = ref(1);
// Watch for changes to modelValue prop
watch(() => props.modelValue, (value) => {
logs.value = value || [];
}, { deep: true });
// Computed properties
const filteredLogs = computed(() => {
let items = [...logs.value];
// Apply status filter
if (filters.value.status !== 'all') {
items = items.filter(item => item.status === filters.value.status);
}
// Apply type filter
if (filters.value.type !== 'all') {
items = items.filter(item => item.type === filters.value.type);
}
// Apply channel filter
if (filters.value.channel !== 'all') {
items = items.filter(item => item.channel === filters.value.channel);
}
// Apply date range filter
if (filters.value.dateFrom) {
const fromDate = new Date(filters.value.dateFrom);
fromDate.setHours(0, 0, 0, 0);
items = items.filter(item => new Date(item.timestamp) >= fromDate);
}
if (filters.value.dateTo) {
const toDate = new Date(filters.value.dateTo);
toDate.setHours(23, 59, 59, 999);
items = items.filter(item => new Date(item.timestamp) <= toDate);
}
// Apply search filter
if (filters.value.search) {
const searchTerm = filters.value.search.toLowerCase();
items = items.filter(item =>
(item.subject && item.subject.toLowerCase().includes(searchTerm)) ||
(item.recipient && item.recipient.toLowerCase().includes(searchTerm)) ||
(item.message && item.message.toLowerCase().includes(searchTerm))
);
}
// Sort by timestamp (newest first)
return items.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
});
const paginatedLogs = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredLogs.value.slice(start, end);
});
const totalPages = computed(() => {
return Math.ceil(filteredLogs.value.length / itemsPerPage) || 1;
});
const paginationStart = computed(() => {
if (filteredLogs.value.length === 0) return 0;
return (currentPage.value - 1) * itemsPerPage + 1;
});
const paginationEnd = computed(() => {
if (filteredLogs.value.length === 0) return 0;
return Math.min(currentPage.value * itemsPerPage, filteredLogs.value.length);
});
// Methods
const refreshLogs = () => {
// In a real implementation, this would fetch updated log data from the server
// For this demo, we'll just emit an event that the parent can handle
emit('refresh-logs');
};
const exportLogs = () => {
// In a real implementation, this would trigger an export on the server
// For this demo, we'll just emit an event that the parent can handle
emit('export', filteredLogs.value);
};
const resetFilters = () => {
filters.value = {
status: 'all',
type: 'all',
channel: 'all',
dateFrom: '',
dateTo: '',
search: ''
};
currentPage.value = 1;
};
const viewLog = (log) => {
selectedLog.value = log;
showViewModal.value = true;
};
const closeViewModal = () => {
showViewModal.value = false;
selectedLog.value = null;
};
const resendNotification = (log) => {
// Emit event for resending
emit('resend', log);
};
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
// Helper methods
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date);
};
const getTypeIcon = (type) => {
const icons = {
info: 'material-symbols:info-outline',
success: 'material-symbols:check-circle-outline',
warning: 'material-symbols:warning-outline',
error: 'material-symbols:error-outline'
};
return icons[type] || icons.info;
};
const getTypeColor = (type) => {
const colors = {
info: 'blue',
success: 'green',
warning: 'yellow',
error: 'red'
};
return colors[type] || colors.info;
};
const getChannelIcon = (channel) => {
const icons = {
'in-app': 'material-symbols:notifications-outline',
'email': 'material-symbols:mail-outline',
'sms': 'material-symbols:smartphone-outline'
};
return icons[channel] || 'material-symbols:notifications-outline';
};
const getStatusClass = (status) => {
const classes = {
delivered: 'bg-green-100 text-green-800',
read: 'bg-blue-100 text-blue-800',
failed: 'bg-red-100 text-red-800',
pending: 'bg-yellow-100 text-yellow-800'
};
return classes[status] || 'bg-gray-100 text-gray-800';
};
</script>
<style scoped>
.notification-logs {
@apply bg-white rounded-md;
}
</style>