Files

750 lines
24 KiB
Vue

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