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:
Haqeem Solehan
2025-10-16 16:05:39 +08:00
commit b124ff8092
336 changed files with 94392 additions and 0 deletions

338
pages/dashboard/index.vue Normal file
View File

@@ -0,0 +1,338 @@
<script setup>
definePageMeta({
title: "Dashboard",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/",
},
],
});
// Data baru untuk lapangan terbang teratas
const topAirports = ref([
{
rank: 1,
name: "Lapangan Terbang Antarabangsa Kuala Lumpur (KLIA)",
visitors: 62000000,
},
{
rank: 2,
name: "Lapangan Terbang Antarabangsa Kota Kinabalu",
visitors: 9000000,
},
{ rank: 3, name: "Lapangan Terbang Antarabangsa Penang", visitors: 8000000 },
{ rank: 4, name: "Lapangan Terbang Antarabangsa Kuching", visitors: 5500000 },
{
rank: 5,
name: "Lapangan Terbang Antarabangsa Langkawi",
visitors: 3000000,
},
]);
// Data baru untuk kad ringkasan pantas
const quickSummary = ref([
{ title: "Jumlah Pelawat", value: "10.5 Juta", icon: "ic:outline-people" },
{
title: "Pendapatan Pelancongan",
value: "RM 86.14 Bilion",
icon: "ic:outline-attach-money",
},
{
title: "Tempoh Penginapan Purata",
value: "6.1 Hari",
icon: "ic:outline-hotel",
},
{
title: "Kepuasan Pelancong",
value: "92%",
icon: "ic:outline-sentiment-satisfied",
},
]);
// Data Pelawat Malaysia
const visitorData = ref([
{
name: "Pelawat Tempatan",
data: [5000000, 5500000, 6000000, 6500000, 7000000, 7500000],
},
{
name: "Pelawat Asing",
data: [3000000, 3500000, 4000000, 4500000, 5000000, 5500000],
},
]);
// Data Pelawat Asing mengikut Negeri
const foreignVisitorsByState = ref([
{ state: "Selangor", visitors: 1500000 },
{ state: "Pulau Pinang", visitors: 1200000 },
{ state: "Johor", visitors: 1000000 },
{ state: "Sabah", visitors: 800000 },
{ state: "Sarawak", visitors: 600000 },
{ state: "Melaka", visitors: 500000 },
{ state: "Kedah", visitors: 400000 },
{ state: "Negeri Sembilan", visitors: 300000 },
{ state: "Perak", visitors: 250000 },
{ state: "Terengganu", visitors: 200000 },
{ state: "Kelantan", visitors: 150000 },
{ state: "Pahang", visitors: 100000 },
{ state: "Perlis", visitors: 50000 },
]);
// Lapangan Terbang Keberangkatan Teratas
const departureData = ref([
{ airport: "JFK", departures: 1500 },
{ airport: "LHR", departures: 1200 },
{ airport: "CDG", departures: 1000 },
{ airport: "DXB", departures: 800 },
{ airport: "SIN", departures: 600 },
]);
// Data Pelancong Berulang
const repeatVisitorsData = ref([
{ category: "1-2 kali", percentage: 45 },
{ category: "3-5 kali", percentage: 30 },
{ category: "6-10 kali", percentage: 15 },
{ category: ">10 kali", percentage: 10 },
]);
// Data Negara Asal Pelancong Asing Teratas
const topVisitorCountries = ref([
{ country: "Singapura", visitors: 1500000 },
{ country: "Indonesia", visitors: 1200000 },
{ country: "China", visitors: 1000000 },
{ country: "Thailand", visitors: 800000 },
{ country: "India", visitors: 600000 },
]);
const chartOptionsVisitors = computed(() => ({
chart: { height: 350, type: "line" },
stroke: { curve: "smooth", width: 2 },
xaxis: { categories: ["2018", "2019", "2020", "2021", "2022", "2023"] },
yaxis: { title: { text: "Bilangan Pelawat" } },
}));
const chartOptionsForeignVisitors = computed(() => ({
chart: { type: "bar" },
plotOptions: { bar: { horizontal: true } },
xaxis: { categories: foreignVisitorsByState.value.map((item) => item.state) },
}));
const chartOptionsDeparture = computed(() => ({
chart: { type: "bar" },
plotOptions: { bar: { horizontal: true } },
xaxis: { categories: departureData.value.map((item) => item.airport) },
}));
const chartOptionsRepeatVisitors = computed(() => ({
chart: { type: "pie" },
labels: repeatVisitorsData.value.map((item) => item.category),
responsive: [
{
breakpoint: 480,
options: {
chart: {
width: 200,
},
legend: {
position: "bottom",
},
},
},
],
}));
const chartOptionsTopCountries = computed(() => ({
chart: { type: "bar" },
plotOptions: {
bar: { horizontal: false, columnWidth: "55%", endingShape: "rounded" },
},
dataLabels: { enabled: false },
stroke: { show: true, width: 2, colors: ["transparent"] },
xaxis: { categories: topVisitorCountries.value.map((item) => item.country) },
yaxis: { title: { text: "Bilangan Pelawat" } },
fill: { opacity: 1 },
tooltip: {
y: {
formatter: function (val) {
return val.toLocaleString() + " pelawat";
},
},
},
}));
onMounted(() => {
// Sebarang logik yang diperlukan semasa pemasangan
});
</script>
<template>
<div>
<LayoutsBreadcrumb />
<!-- Kad Ringkasan Pantas -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6">
<rs-card
v-for="(item, index) in quickSummary"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl transition-all duration-300 hover:bg-primary/30"
>
<Icon class="text-primary text-3xl" :name="item.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight text-primary">
{{ item.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ item.title }}
</span>
</div>
</div>
</rs-card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Gambaran Keseluruhan Pelawat Malaysia -->
<rs-card class="col-span-1 lg:col-span-2">
<template #header>
<h2 class="text-xl font-bold text-primary">
Gambaran Keseluruhan Pelawat
</h2>
</template>
<template #body>
<client-only>
<VueApexCharts
width="100%"
height="350"
type="line"
:options="chartOptionsVisitors"
:series="visitorData"
></VueApexCharts>
</client-only>
</template>
</rs-card>
<!-- Pelawat Asing mengikut Negeri -->
<rs-card>
<template #header>
<h2 class="text-lg font-semibold text-primary">
Pelawat Asing mengikut Negeri
</h2>
</template>
<template #body>
<client-only>
<VueApexCharts
width="100%"
height="300"
type="bar"
:options="chartOptionsForeignVisitors"
:series="[
{ data: foreignVisitorsByState.map((item) => item.visitors) },
]"
></VueApexCharts>
</client-only>
</template>
</rs-card>
<!-- Pelancong Berulang -->
<rs-card>
<template #header>
<h2 class="text-lg font-semibold text-primary">
Kekerapan Lawatan Pelancong
</h2>
</template>
<template #body>
<client-only>
<VueApexCharts
width="100%"
height="300"
type="pie"
:options="chartOptionsRepeatVisitors"
:series="repeatVisitorsData.map((item) => item.percentage)"
></VueApexCharts>
</client-only>
</template>
</rs-card>
</div>
<!-- Negara Asal Pelancong Asing Teratas -->
<rs-card class="mb-6">
<template #header>
<h2 class="text-xl font-bold text-primary">
Negara Asal Pelancong Asing Teratas
</h2>
</template>
<template #body>
<client-only>
<VueApexCharts
width="100%"
height="350"
type="bar"
:options="chartOptionsTopCountries"
:series="[
{
name: 'Pelawat',
data: topVisitorCountries.map((item) => item.visitors),
},
]"
></VueApexCharts>
</client-only>
</template>
</rs-card>
<rs-card class="mb-6">
<template #header>
<h2 class="text-xl font-bold text-primary">
Lapangan Terbang Teratas dengan Pelawat Terbanyak
</h2>
</template>
<template #body>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Kedudukan
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Nama Lapangan Terbang
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Jumlah Pelawat
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="airport in topAirports"
:key="airport.rank"
class="hover:bg-gray-50 transition-colors duration-200"
>
<td class="px-6 py-4 whitespace-nowrap font-medium">
{{ airport.rank }}
</td>
<td class="px-6 py-4 whitespace-nowrap">{{ airport.name }}</td>
<td
class="px-6 py-4 whitespace-nowrap font-semibold text-primary"
>
{{ airport.visitors.toLocaleString() }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
</rs-card>
</div>
</template>

View File

@@ -0,0 +1,244 @@
<script setup>
// import pinia store
import { useThemeStore } from "~/stores/theme";
definePageMeta({
title: "API Code Editor",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal, $router } = useNuxtApp();
const route = useRoute();
const router = useRouter();
const fileCode = ref("");
const fileCodeConstant = ref("");
const componentKey = ref(0);
const hasError = ref(false);
const error = ref("");
const themeStore = useThemeStore();
const editorTheme = ref({
label: themeStore.codeTheme,
value: themeStore.codeTheme,
});
const dropdownThemes = ref([]);
const linterError = ref(false);
const linterErrorText = ref("");
const linterErrorColumn = ref(0);
const linterErrorLine = ref(0);
// Add new ref for loading state
const isLinterChecking = ref(false);
// Get all themes
const themes = codemirrorThemes();
// map the themes to the dropdown
dropdownThemes.value = themes.map((theme) => {
return {
label: theme.name,
value: theme.name,
};
});
// watch for changes in the theme
watch(editorTheme, (theme) => {
themeStore.setCodeTheme(theme.value);
forceRerender();
});
// Call API to get the code
const { data } = await useFetch("/api/devtool/api/file-code", {
initialCache: false,
method: "GET",
query: {
path: route.query?.path,
},
});
if (data.value.statusCode === 200) {
fileCode.value = data.value.data;
fileCodeConstant.value = data.value.data;
} else {
$swal
.fire({
title: "Error",
text: "The API you are trying to edit is not found. Please choose a API to edit.",
icon: "error",
confirmButtonText: "Ok",
})
.then(async (result) => {
if (result.isConfirmed) {
await $router.push("/devtool/api-editor");
}
});
}
async function formatCode() {
// Call API to get the code
const { data } = await useFetch("/api/devtool/api/prettier-format", {
initialCache: false,
method: "POST",
body: JSON.stringify({
code: fileCode.value,
}),
});
forceRerender();
if (data.value.statusCode === 200) {
fileCode.value = data.value.data;
}
}
async function checkLinterVue() {
isLinterChecking.value = true;
try {
// Call API to get the code
const { data } = await useFetch("/api/devtool/api/linter", {
initialCache: false,
method: "POST",
body: JSON.stringify({
code: fileCode.value,
}),
});
if (data.value.statusCode === 200) {
linterError.value = false;
linterErrorText.value = "";
linterErrorColumn.value = 0;
linterErrorLine.value = 0;
} else if (data.value.statusCode === 400) {
linterError.value = true;
linterErrorText.value = data.value.data.message;
linterErrorColumn.value = data.value.data.column;
linterErrorLine.value = data.value.data.line;
}
} finally {
isLinterChecking.value = false;
}
}
const forceRerender = () => {
componentKey.value += 1;
};
const keyPress = (key) => {
console.log(key);
const event = new KeyboardEvent("keydown", {
key: key,
ctrlKey: true,
});
console.log(event);
document.dispatchEvent(event);
};
const saveCode = async () => {
// Check Linter Vue
await checkLinterVue();
if (linterError.value) {
$swal.fire({
title: "Error",
text: "There is an error in your code. Please fix it before saving.",
icon: "error",
confirmButtonText: "Ok",
});
return;
}
const { data } = await useFetch("/api/devtool/api/save", {
initialCache: false,
method: "POST",
body: {
path: route.query?.path,
code: fileCode.value,
type: "update",
},
});
if (data.value.statusCode === 200) {
$swal.fire({
title: "Success",
text: "The code has been saved successfully.",
icon: "success",
confirmButtonText: "Ok",
timer: 1000,
});
setTimeout(() => {
$router.go();
}, 1000);
}
};
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-alert v-if="hasError" class="mb-4" variant="danger">{{
error
}}</rs-alert>
<rs-card>
<rs-tab fill>
<rs-tab-item title="Editor">
<div class="flex justify-end gap-2 mb-4">
<rs-button
class="!p-2"
@click="saveCode"
:disabled="isLinterChecking"
>
<div class="flex items-center">
<Icon
v-if="!isLinterChecking"
name="material-symbols:save-outline-rounded"
size="20px"
class="mr-1"
/>
<Icon
v-else
name="eos-icons:loading"
size="20px"
class="mr-1 animate-spin"
/>
{{ isLinterChecking ? "Checking..." : "Save API" }}
</div>
</rs-button>
</div>
<Transition>
<rs-alert v-if="linterError" variant="danger" class="mb-4">
<div class="flex gap-2">
<Icon
name="material-symbols:error-outline-rounded"
size="20px"
/>
<div>
<div class="font-bold">ESLint Error</div>
<div class="text-sm">
{{ linterErrorText }}
</div>
<div class="text-xs mt-2">
Line: {{ linterErrorLine }} Column: {{ linterErrorColumn }}
</div>
</div>
</div>
</rs-alert>
</Transition>
<rs-code-mirror
:key="componentKey"
v-model="fileCode"
mode="javascript"
/>
</rs-tab-item>
<rs-tab-item title="API Tester">
<rs-api-tester :url="route.query?.path" />
</rs-tab-item>
</rs-tab>
</rs-card>
</div>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,327 @@
<script setup>
definePageMeta({
title: "API Editor",
middleware: ["auth"],
requiresAuth: true,
});
const nuxtApp = useNuxtApp();
const searchText = ref("");
const showModalAdd = ref(false);
const showModalAddForm = ref({
apiURL: "",
});
const showModalEdit = ref(false);
const showModalEditForm = ref({
apiURL: "",
oldApiURL: "",
});
const openModalAdd = () => {
showModalAddForm.value = {
apiURL: "",
method: "all",
};
showModalAdd.value = true;
};
const openModalEdit = (url, method = "all") => {
const apiURL = url.replace("/api/", "");
showModalEditForm.value = {
apiURL: apiURL,
oldApiURL: apiURL,
method: method,
};
showModalEdit.value = true;
};
const { data: apiList, refresh } = await useFetch("/api/devtool/api/list");
const searchApi = () => {
if (!apiList.value || !apiList.value.data) return [];
return apiList.value.data.filter((api) => {
return (
api.name.toLowerCase().includes(searchText.value.toLowerCase()) ||
api.url.toLowerCase().includes(searchText.value.toLowerCase())
);
});
};
const kebabCasetoTitleCase = (str) => {
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
const redirectToApiCode = (api) => {
window.location.href = `/devtool/api-editor/code?path=${api}`;
};
const saveAddAPI = async () => {
const { data } = await useFetch("/api/devtool/api/save", {
initialCache: false,
method: "POST",
body: {
path: "/api/" + showModalAddForm.value.apiURL,
type: "add",
},
});
if (data.value.statusCode === 200) {
nuxtApp.$swal.fire({
title: "Success",
text: "The code has been saved successfully.",
icon: "success",
confirmButtonText: "Ok",
timer: 1000,
});
// Close modal and refresh list
showModalAdd.value = false;
refresh();
}
};
const saveEditAPI = async () => {
const { data } = await useFetch("/api/devtool/api/save", {
initialCache: false,
method: "POST",
body: {
path: "/api/" + showModalEditForm.value.apiURL,
oldPath: "/api/" + showModalEditForm.value.oldApiURL,
type: "edit",
},
});
if (data.value.statusCode === 200) {
nuxtApp.$swal.fire({
title: "Success",
text: "The code has been saved successfully.",
icon: "success",
confirmButtonText: "Ok",
timer: 1000,
});
// Close modal and refresh list
showModalEdit.value = false;
refresh();
}
};
const deleteAPI = async (apiURL) => {
nuxtApp.$swal
.fire({
title: "Are you sure to delete this API?",
text: "You won't be able to revert this!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes, delete it!",
})
.then(async (result) => {
if (result.isConfirmed) {
const { data } = await useFetch("/api/devtool/api/save", {
initialCache: false,
method: "POST",
body: {
path: apiURL,
type: "delete",
},
});
if (data.value.statusCode === 200) {
nuxtApp.$swal.fire({
title: "Success",
text: "The code has been saved successfully.",
icon: "success",
confirmButtonText: "Ok",
timer: 1000,
});
// Refresh list after deletion
refresh();
}
}
});
};
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<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">
This page is used to edit the api for the server side. You can edit
the api by choosing the api to edit from the card list below.
</p>
</template>
</rs-card>
<rs-card>
<div class="p-4">
<div class="flex justify-end items-center mb-4">
<rs-button @click="openModalAdd">
<Icon name="material-symbols:add" class="mr-1"></Icon>
Add API
</rs-button>
</div>
<!-- Search Button -->
<FormKit
v-model="searchText"
placeholder="Search Title..."
type="search"
class="mb-4"
/>
<div v-auto-animate>
<div
class="shadow-md shadow-black/5 ring-1 ring-slate-700/10 rounded-lg mb-4"
v-for="api in searchApi()"
>
<div class="relative p-4 border-l-8 border-primary rounded-lg">
<div class="flex justify-between items-center">
<div class="block">
<span class="font-semibold text-lg">{{
kebabCasetoTitleCase(api.name)
}}</span>
<br />
<span class=""> {{ api.url }}</span>
</div>
<div class="flex gap-4">
<rs-button
variant="primary-outline"
@click="redirectToApiCode(api.url)"
>
<Icon
name="material-symbols:code-blocks-outline-rounded"
class="mr-2"
/>
Code Editor
</rs-button>
<div class="flex gap-2">
<rs-button @click="openModalEdit(api.url)">
<Icon name="material-symbols:edit-outline-rounded" />
</rs-button>
<rs-button @click="deleteAPI(api.url)">
<Icon name="carbon:trash-can" />
</rs-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</rs-card>
<rs-modal title="Add API" v-model="showModalAdd" :overlay-close="false">
<template #body>
<FormKit type="form" :actions="false" @submit="saveAddAPI">
<FormKit
type="text"
label="URL"
:validation="[['required'], ['matches', '/^[a-z0-9/-]+$/']]"
:validation-messages="{
required: 'URL is required',
matches:
'URL contains invalid characters. Only letters, numbers, dashes, and forward slashes are allowed.',
}"
v-model="showModalAddForm.apiURL"
>
<template #prefix>
<div
class="bg-slate-100 dark:bg-slate-700 h-full rounded-l-md p-3"
>
/api/
</div>
</template>
</FormKit>
<!-- <FormKit
type="select"
label="Request Method"
:options="requestMethods"
validation="required"
placeholder="Select a method"
v-model="showModalAddForm.method"
/> -->
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showModalAdd = false">
Cancel
</rs-button>
<rs-button btnType="submit">
<Icon
name="material-symbols:save-outline"
class="mr-2 !w-4 !h-4"
/>
Save
</rs-button>
</div>
</FormKit>
</template>
<template #footer></template>
</rs-modal>
<rs-modal title="Edit API" v-model="showModalEdit" :overlay-close="false">
<template #body>
<FormKit type="form" :actions="false" @submit="saveEditAPI">
<FormKit
type="text"
label="URL"
:validation="[['required'], ['matches', '/^[a-z0-9/-]+$/']]"
:validation-messages="{
required: 'URL is required',
matches:
'URL contains invalid characters. Only letters, numbers, dashes, and forward slashes are allowed.',
}"
v-model="showModalEditForm.apiURL"
>
<template #prefix>
<div
class="bg-slate-100 dark:bg-slate-700 h-full rounded-l-md p-3"
>
/api/
</div>
</template>
</FormKit>
<!-- <FormKit
type="select"
label="Request Method"
:options="requestMethods"
validation="required"
placeholder="Select a method"
v-model="showModalEditForm.method"
/> -->
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showModalEdit = false">
Cancel
</rs-button>
<rs-button btnType="submit">
<Icon
name="material-symbols:save-outline"
class="mr-2 !w-4 !h-4"
/>
Save
</rs-button>
</div>
</FormKit>
</template>
<template #footer></template>
</rs-modal>
</div>
</template>

View File

@@ -0,0 +1,35 @@
import RsAlert from "../../../components/RsAlert.vue";
import RsBadge from "../../../components/RsBadge.vue";
import RsButton from "../../../components/RsButton.vue";
import RsCard from "../../../components/RsCard.vue";
import RsCodeMirror from "../../../components/RsCodeMirror.vue";
import RsCollapse from "../../../components/RsCollapse.vue";
import RsCollapseItem from "../../../components/RsCollapseItem.vue";
import RsDropdown from "../../../components/RsDropdown.vue";
import RsDropdownItem from "../../../components/RsDropdownItem.vue";
import RsFieldset from "../../../components/RsFieldset.vue";
import RsModal from "../../../components/RsModal.vue";
import RsProgressBar from "../../../components/RsProgressBar.vue";
import RsTab from "../../../components/RsTab.vue";
import RsTabItem from "../../../components/RsTabItem.vue";
import RsTable from "../../../components/RsTable.vue";
import RsWizard from "../../../components/RsWizard.vue";
export {
RsAlert,
RsBadge,
RsButton,
RsCard,
RsCodeMirror,
RsCollapse,
RsCollapseItem,
RsDropdown,
RsDropdownItem,
RsFieldset,
RsModal,
RsProgressBar,
RsTab,
RsTabItem,
RsTable,
RsWizard,
};

View File

@@ -0,0 +1,422 @@
<script setup>
import { parse } from "@vue/compiler-sfc";
import { watchDebounced } from "@vueuse/core";
import {
RsAlert,
RsBadge,
RsButton,
RsCard,
RsCodeMirror,
RsCollapse,
RsCollapseItem,
RsDropdown,
RsDropdownItem,
RsFieldset,
RsModal,
RsProgressBar,
RsTab,
RsTabItem,
RsTable,
RsWizard,
} from "./index.js";
// Import pinia store
import { useThemeStore } from "~/stores/theme";
definePageMeta({
title: "AI SFC Playground",
description: "AI SFC Playground page",
layout: "empty",
middleware: ["auth"],
requiresAuth: true,
});
const CODE_STORAGE_KEY = "playground-code";
const code = ref(
localStorage.getItem(CODE_STORAGE_KEY) ||
`<template>
<rs-card>
<template #header>SFC Playground Demo</template>
<template #body>
<div class="space-y-4">
<rs-alert variant="info">{{ msg }}</rs-alert>
<rs-button @click="count++">Clicked {{ count }} times</rs-button>
<rs-badge>{{ count > 5 ? 'High' : 'Low' }}</rs-badge>
</div>
</template>
</rs-card>
</template>
<script setup>
const msg = 'Hello from SFC Playground';
const count = ref(0);
<\/script>`
);
const compiledCode = ref(null);
const componentKey = ref(0);
const compilationError = ref(null);
const previewSizes = [
{ name: "Mobile", width: "320px", icon: "ph:device-mobile-camera" },
{ name: "Tablet", width: "768px", icon: "ph:device-tablet-camera" },
{ name: "Desktop", width: "1024px", icon: "ph:desktop" },
{ name: "Full", width: "100%", icon: "material-symbols:fullscreen" },
];
const currentPreviewSize = ref(previewSizes[3]); // Default to Full
// Theme-related code
const themeStore = useThemeStore();
const editorTheme = ref({
label: themeStore.codeTheme,
value: themeStore.codeTheme,
});
const dropdownThemes = ref([]);
// Get all themes
const themes = codemirrorThemes();
// map the themes to the dropdown
dropdownThemes.value = themes.map((theme) => {
return {
label: theme.name,
value: theme.name,
};
});
// watch for changes in the theme
watch(editorTheme, (theme) => {
themeStore.setCodeTheme(theme.value);
});
const compileCode = async (newCode) => {
try {
const { descriptor, errors } = parse(newCode);
if (errors && errors.length > 0) {
compilationError.value = {
message: errors[0].message,
location: errors[0].loc,
};
return;
}
if (descriptor.template && descriptor.scriptSetup) {
const template = descriptor.template.content;
const scriptSetup = descriptor.scriptSetup.content;
// Dynamically import FormKit components
const {
FormKit,
FormKitSchema,
FormKitSchemaNode,
FormKitSchemaCondition,
FormKitSchemaValidation,
} = await import("@formkit/vue");
const component = defineComponent({
components: {
RsAlert,
RsBadge,
RsButton,
RsCard,
RsCodeMirror,
RsCollapse,
RsCollapseItem,
RsDropdown,
RsDropdownItem,
RsFieldset,
RsModal,
RsProgressBar,
RsTab,
RsTabItem,
RsTable,
RsWizard,
FormKit,
FormKitSchema,
FormKitSchemaNode,
FormKitSchemaCondition,
FormKitSchemaValidation,
},
template,
setup() {
const setupContext = reactive({});
try {
// Extract top-level declarations
const declarations =
scriptSetup.match(/const\s+(\w+)\s*=\s*([^;]+)/g) || [];
declarations.forEach((decl) => {
const [, varName, varValue] = decl.match(
/const\s+(\w+)\s*=\s*(.+)/
);
if (
varValue.trim().startsWith("'") ||
varValue.trim().startsWith('"')
) {
// It's a string literal, use it directly
setupContext[varName] = varValue.trim().slice(1, -1);
} else if (varValue.trim().startsWith("ref(")) {
// It's already a ref, use ref
setupContext[varName] = ref(null);
} else {
// For other cases, wrap in ref
setupContext[varName] = ref(null);
}
});
const setupFunction = new Function(
"ctx",
"ref",
"reactive",
"computed",
"watch",
"onMounted",
"onUnmounted",
"useFetch",
"fetch",
"useAsyncData",
"useNuxtApp",
"useRuntimeConfig",
"useRoute",
"useRouter",
"useState",
"FormKit",
"FormKitSchema",
"FormKitSchemaNode",
"FormKitSchemaCondition",
"FormKitSchemaValidation",
`
with (ctx) {
${scriptSetup}
}
return ctx;
`
);
const result = setupFunction(
setupContext,
ref,
reactive,
computed,
watch,
onMounted,
onUnmounted,
useFetch,
fetch,
useAsyncData,
useNuxtApp,
useRuntimeConfig,
useRoute,
useRouter,
useState,
FormKit,
FormKitSchema,
FormKitSchemaNode,
FormKitSchemaCondition,
FormKitSchemaValidation
);
// Merge the result back into setupContext
Object.assign(setupContext, result);
return setupContext;
} catch (error) {
console.error("Error in setup function:", error);
compilationError.value = {
message: `Error in setup function: ${error.message}`,
location: { start: 0, end: 0 },
};
// Return an empty object to prevent breaking the component
return {};
}
},
});
compiledCode.value = markRaw(component);
componentKey.value++;
compilationError.value = null;
} else {
compiledCode.value = null;
compilationError.value = {
message: "Invalid SFC format.",
location: { start: 0, end: 0 },
};
}
} catch (error) {
console.error("Compilation error:", error);
compiledCode.value = null;
compilationError.value = {
message: `Compilation error: ${error.message}`,
location: { start: 0, end: 0 },
};
}
};
watchDebounced(
code,
async (newCode) => {
await compileCode(newCode);
},
{ debounce: 300, immediate: true }
);
const handleFormatCode = () => {
// Recompile the code after formatting
setTimeout(() => compileCode(code.value), 100);
};
onMounted(async () => {
await compileCode(code.value);
});
const defaultCode = `<template>
<rs-card>
<template #header>SFC Playground Demo</template>
<template #body>
<div class="space-y-4">
<rs-alert variant="info">{{ msg }}</rs-alert>
<rs-button @click="count++">Clicked {{ count }} times</rs-button>
<rs-badge>{{ count > 5 ? 'High' : 'Low' }}</rs-badge>
</div>
</template>
</rs-card>
</template>
<script setup>
const msg = 'Hello from SFC Playground';
const count = ref(0);
<\/script>`;
const resetCode = () => {
code.value = defaultCode;
localStorage.setItem(CODE_STORAGE_KEY, defaultCode);
compileCode(code.value);
};
// Add a watch effect to save code changes to localStorage
watch(
code,
(newCode) => {
localStorage.setItem(CODE_STORAGE_KEY, newCode);
},
{ deep: true }
);
</script>
<template>
<div class="flex flex-col h-screen bg-gray-900">
<!-- Header -->
<header
class="bg-gray-800 p-2 flex flex-wrap items-center justify-between text-white"
>
<div class="flex items-center mb-2 sm:mb-0 gap-4">
<Icon
@click="navigateTo('/')"
name="ph:arrow-circle-left-duotone"
class="cursor-pointer"
/>
<img
src="@/assets/img/logo/logo-word-white.svg"
alt="Vue Logo"
class="h-8 block mr-2"
/>
</div>
<div class="flex flex-wrap items-center space-x-2">
<rs-button @click="resetCode" class="mr-2">
<Icon name="material-symbols:refresh" class="mr-2" />
Reset Code
</rs-button>
<h1 class="text-lg font-semibold">Code Playground</h1>
</div>
</header>
<!-- Main content -->
<div class="flex flex-col sm:flex-row flex-1 overflow-hidden">
<!-- Editor section -->
<div
class="w-full sm:w-1/2 flex flex-col border-b sm:border-b-0 sm:border-r border-gray-900"
>
<div class="flex-grow overflow-hidden">
<rs-code-mirror
v-model="code"
mode="javascript"
class="h-full"
@format-code="handleFormatCode"
/>
</div>
</div>
<!-- Preview section -->
<div class="w-full sm:w-1/2 bg-white overflow-auto flex flex-col">
<div
class="bg-gray-800 p-2 flex justify-between items-center text-white"
>
<h2 class="text-sm font-semibold">Preview</h2>
<div class="flex space-x-2">
<rs-button
v-for="size in previewSizes"
:key="size.name"
@click="currentPreviewSize = size"
:class="{
'bg-blue-600': currentPreviewSize === size,
'bg-gray-600': currentPreviewSize !== size,
}"
class="px-2 py-1 text-xs rounded"
>
<Icon v-if="size.icon" :name="size.icon" class="!w-5 !h-5 mr-2" />
{{ size.name }}
</rs-button>
</div>
</div>
<div class="flex-grow overflow-auto p-4 flex justify-center">
<div
:style="{
width: currentPreviewSize.width,
height: '100%',
overflow: 'auto',
}"
class="border border-gray-300 transition-all duration-300 ease-in-out"
>
<component
:key="componentKey"
v-if="compiledCode && !compilationError"
:is="compiledCode"
/>
<div v-else-if="compilationError?.message">
<div class="flex justify-center items-center p-5">
<div class="text-center">
<Icon name="ph:warning" class="text-6xl" />
<p class="text-lg font-semibold mt-4">
Something went wrong. Please refer the error in the editor.
</p>
</div>
</div>
</div>
<div v-else class="text-gray-500">Waiting for code changes...</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.device-frame {
background-color: #f0f0f0;
border-radius: 16px;
padding: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
@media (max-width: 640px) {
.device-frame {
padding: 8px;
border-radius: 8px;
}
}
:deep(.cm-editor) {
height: 100%;
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<div></div>
</template>
<script setup></script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,27 @@
<script setup>
definePageMeta({
title: "Environment",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal, $router } = useNuxtApp();
const env = ref({});
const { data: envData } = await useFetch("/api/devtool/config/env", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
env.value = envData.value.data;
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-code-mirror mode="javascript" v-model="env" disabled />
</div>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<div></div>
</template>
<script setup></script>
<style lang="scss" scoped></style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,226 @@
<script setup>
definePageMeta({
title: "Code Editor",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal, $router } = useNuxtApp();
const route = useRoute();
const router = useRouter();
const fileCode = ref("");
const fileCodeConstant = ref("");
const componentKey = ref(0);
const hasError = ref(false);
const error = ref("");
const linterError = ref(false);
const linterErrorText = ref("");
const linterErrorColumn = ref(0);
const linterErrorLine = ref(0);
const isLinterChecking = ref(false);
const page = router.getRoutes().find((page) => {
return page.name === route.query?.page;
});
if (!route.query.page || !page) {
$swal
.fire({
title: "Error",
text: "The page you are trying to edit is not found. Please choose a page to edit.",
icon: "error",
confirmButtonText: "Ok",
})
.then(async (result) => {
if (result.isConfirmed) {
await $router.push("/devtool/content-editor");
}
});
}
if (page?.path)
page.path = page.path.replace(/:(\w+)/g, "[$1]").replace(/\(\)/g, "");
// Call API to get the code
const { data } = await useFetch("/api/devtool/content/code/file-code", {
initialCache: false,
method: "GET",
query: {
path: page.path,
},
});
// console.log(data.value);
if (data.value.statusCode === 200) {
fileCode.value = data.value.data;
fileCodeConstant.value = data.value.data;
// If its index append the path
if (data.value?.mode == "index") page.path = page.path + "/index";
} else {
$swal.fire({
title: "Error",
text: "The page you are trying to edit is not found. Please choose a page to edit. You will be redirected to the content editor page.",
icon: "error",
confirmButtonText: "Ok",
timer: 3000,
});
setTimeout(() => {
$router.push("/devtool/content-editor");
}, 3000);
}
async function formatCode() {
// Call API to get the code
const { data } = await useFetch("/api/devtool/content/code/prettier-format", {
initialCache: false,
method: "POST",
body: JSON.stringify({
code: fileCode.value,
}),
});
forceRerender();
if (data.value.statusCode === 200) {
fileCode.value = data.value.data;
}
}
async function checkLinterVue() {
isLinterChecking.value = true;
try {
// Call API to get the code
const { data } = await useFetch("/api/devtool/content/code/linter", {
initialCache: false,
method: "POST",
body: JSON.stringify({
code: fileCode.value,
}),
});
if (data.value.statusCode === 200) {
linterError.value = false;
linterErrorText.value = "";
linterErrorColumn.value = 0;
linterErrorLine.value = 0;
} else if (data.value.statusCode === 400) {
linterError.value = true;
linterErrorText.value = data.value.data.message;
linterErrorColumn.value = data.value.data.column;
linterErrorLine.value = data.value.data.line;
}
} finally {
isLinterChecking.value = false;
}
}
const forceRerender = () => {
componentKey.value += 1;
};
const keyPress = (key) => {
// console.log(key);
const event = new KeyboardEvent("keydown", {
key: key,
ctrlKey: true,
});
// console.log(event);
document.dispatchEvent(event);
};
const saveCode = async () => {
// Check Linter Vue
await checkLinterVue();
if (linterError.value) {
$swal.fire({
title: "Error",
text: "There is an error in your code. Please fix it before saving.",
icon: "error",
confirmButtonText: "Ok",
});
return;
}
const { data } = await useFetch("/api/devtool/content/code/save", {
initialCache: false,
method: "POST",
body: {
path: page.path,
code: fileCode.value,
},
});
if (data.value.statusCode === 200) {
$swal.fire({
title: "Success",
text: "The code has been saved successfully.",
icon: "success",
confirmButtonText: "Ok",
timer: 1000,
});
setTimeout(() => {
$router.go();
}, 1000);
}
};
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-alert v-if="hasError" variant="danger" class="mb-4">{{
error
}}</rs-alert>
<rs-card class="mb-0">
<div class="p-4">
<div class="flex justify-end gap-2 mb-4">
<rs-button
class="!p-2"
@click="saveCode"
:disabled="isLinterChecking"
>
<div class="flex items-center">
<Icon
v-if="!isLinterChecking"
name="material-symbols:save-outline-rounded"
size="20px"
class="mr-1"
/>
<Icon
v-else
name="eos-icons:loading"
size="20px"
class="mr-1 animate-spin"
/>
{{ isLinterChecking ? "Checking..." : "Save Code" }}
</div>
</rs-button>
</div>
<Transition>
<rs-alert v-if="linterError" variant="danger" class="mb-4">
<div class="flex gap-2">
<Icon name="material-symbols:error-outline-rounded" size="20px" />
<div>
<div class="font-bold">ESLint Error</div>
<div class="text-sm">
{{ linterErrorText }}
</div>
<div class="text-xs mt-2">
Line: {{ linterErrorLine }} Column: {{ linterErrorColumn }}
</div>
</div>
</div>
</rs-alert>
</Transition>
</div>
<rs-code-mirror :key="componentKey" v-model="fileCode" />
</rs-card>
</div>
</template>

View File

@@ -0,0 +1,247 @@
<script setup>
definePageMeta({
title: "Content Editor",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal, $router } = useNuxtApp();
const router = useRouter();
const getPages = router.getRoutes();
const pages = getPages.filter((page) => {
// Filter out pages in the devtool path
if (page.path.includes("/devtool")) {
return false;
}
// Use page.name if page.meta.title doesn't exist
const pageTitle = page.meta?.title || page.name;
return pageTitle && pageTitle !== "Home" && page.name;
});
const searchText = ref("");
const showModal = ref(false);
const modalData = ref({
name: "",
path: "",
});
const searchPages = () => {
return pages.filter((page) => {
const pageTitle = page.meta?.title || page.name;
return pageTitle.toLowerCase().includes(searchText.value.toLowerCase());
});
};
const capitalizeSentence = (sentence) => {
return sentence
.split(" ")
.map((word) => {
return word[0].toUpperCase() + word.slice(1);
})
.join(" ");
};
const templateOptions = ref([{ label: "Select Template", value: "" }]);
const selectTemplate = ref("");
const { data: templates } = await useFetch(
"/api/devtool/content/template/list",
{
method: "GET",
}
);
templateOptions.value.push(
...templates?.value.data.map((template) => {
return {
label: `${template.title} - ${template.id}`,
value: template.id,
};
})
);
const importTemplate = (pageName) => {
showModal.value = true;
modalData.value.name = pageName;
modalData.value.path = router.getRoutes().find((page) => {
return page.name === pageName;
}).path;
};
const confirmModal = async () => {
$swal
.fire({
title: "Are you sure you want to import this template?",
text: "This action cannot be undone.",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes",
})
.then(async (result) => {
if (result.isConfirmed) {
const { data: res } = await useFetch(
"/api/devtool/content/template/import",
{
initialCache: false,
method: "GET",
query: {
path: modalData.value.path + "/index",
templateId: selectTemplate.value,
},
}
);
if (res.value.statusCode == 200) {
$swal.fire({
title: "Success",
text: res.value.message,
icon: "success",
confirmButtonText: "Ok",
timer: 1000,
});
setTimeout(() => {
$router.go();
}, 1000);
}
}
})
.finally(() => {
showModal.value = false;
});
};
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<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">
This page is used to edit the content of a page. You can edit the
content of the page by choosing the page to edit from the card list
below.
</p>
</template>
</rs-card>
<rs-card>
<div class="p-4">
<!-- Search Button -->
<FormKit
v-model="searchText"
placeholder="Search Title..."
type="search"
/>
<div
class="page-wrapper grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"
v-auto-animate
>
<!-- <div
class="page border-2 border-gray-400 border-dashed rounded-lg"
style="min-height: 250px"
>
Add New Page
</div> -->
<div
v-for="page in searchPages()"
:key="page.path"
class="page shadow-md shadow-black/5 p-5 ring-1 ring-slate-700/10 rounded-lg"
>
<div class="pb-4">
<h4 class="font-semibold">
{{ capitalizeSentence(page.meta?.title || page.name) }}
</h4>
<nuxt-link :to="page.path">
<div
class="flex items-center text-primary hover:text-primary/70"
>
<Icon
class="mr-2"
name="ic:outline-link"
style="font-size: 1.2rem"
></Icon>
<p class="text-sm">{{ page.path }}</p>
</div>
</nuxt-link>
</div>
<div
class="button-list flex justify-between border-t pt-4 border-gray-300"
>
<div class="flex gap-x-2">
<!-- <nuxt-link
:to="`/devtool/content-editor/canvas?page=${page.name}`"
>
<rs-button variant="primary" class="!py-2 !px-3">
<Icon name="ph:paint-brush-broad"></Icon>
</rs-button>
</nuxt-link> -->
<nuxt-link
:to="`/devtool/content-editor/code?page=${page.name}`"
>
<rs-button variant="primary" class="!py-2 !px-3">
<Icon
name="material-symbols:code-blocks-outline-rounded"
></Icon>
</rs-button>
</nuxt-link>
</div>
<rs-button
variant="primary-text"
class="!py-2 !px-3"
@click="importTemplate(page.name)"
>
<Icon name="mdi:import"></Icon>
</rs-button>
</div>
</div>
</div>
</div>
</rs-card>
<rs-modal :title="`Import (${modalData.name})`" v-model="showModal">
<FormKit
v-model="selectTemplate"
type="select"
label="Content Template"
:options="templateOptions"
validation="required"
validation-visibility="dirty"
help="Please choose carefully the template that you want to import. This action cannot be undone."
/>
<template #footer>
<rs-button @click="showModal = false" variant="primary-text">
Cancel
</rs-button>
<rs-button @click="confirmModal" :disabled="!selectTemplate"
>Confirm</rs-button
>
</template>
</rs-modal>
</div>
</template>
<style lang="scss" scoped>
.thumbnail::before {
content: "";
display: block;
height: 100%;
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: rgba(255, 255, 255, 0.8);
}
</style>

View File

@@ -0,0 +1,127 @@
<script setup>
definePageMeta({
title: "Template Editor",
middleware: ["auth"],
requiresAuth: true,
});
const searchText = ref("");
const { data: templateList } = await useFetch(
"/api/devtool/content/template/list",
{
method: "GET",
}
);
// Search function that can search the template by title and tags if tags is available after data is fetched
const searchTemplate = () => {
return templateList?.value.data.filter((template) => {
return template.title
.toLowerCase()
.includes(searchText.value.toLowerCase());
});
};
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<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">
This webpage serves as a platform for template management, enabling
users to select and utilize templates for rendering pages according to
their chosen design.
</p>
</template>
</rs-card>
<rs-card>
<div class="p-4">
<!-- Search Button -->
<FormKit
v-model="searchText"
placeholder="Search Title..."
type="search"
/>
<div
class="page-wrapper grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"
v-auto-animate
>
<!-- <div
class="page border-2 border-gray-400 border-dashed rounded-lg"
style="min-height: 250px"
>
Add New Page
</div> -->
<div
v-for="val in searchTemplate()"
class="page shadow-md shadow-black/5 ring-1 ring-slate-700/10 rounded-lg"
style="min-height: 250px"
>
<div class="thumbnail-wrapper relative">
<div class="button-list absolute bottom-3 right-3 flex z-10">
<nuxt-link :to="val.img" target="_blank">
<rs-button class="!py-2 !px-3 rounded-r-none">
<Icon name="material-symbols:fullscreen-rounded"></Icon>
</rs-button>
</nuxt-link>
<nuxt-link
:to="`/devtool/content-editor/template/view/${val.id}`"
>
<rs-button class="!py-2 !px-3 rounded-l-none">
<Icon name="material-symbols:preview"></Icon>
</rs-button>
</nuxt-link>
</div>
<img
class="thumbnail rounded-tl-lg rounded-tr-lg bg-[#F3F4F6]"
style="height: 250px; width: 100%; object-fit: contain"
:src="val.img"
alt=""
/>
<div
class="overlay-img opacity-10 bg-black text-black before:content-['Hello_World'] absolute top-0 left-0 w-full h-full rounded-tl-lg rounded-tr-lg"
></div>
</div>
<div class="p-4">
<h4 class="font-semibold">{{ val.title }} ({{ val.id }})</h4>
<div class="flex items-center mb-4">
<p class="text-sm">{{ val.description }}</p>
</div>
<div
class="tag h-10 flex justify-start items-center overflow-x-auto gap-x-2"
>
<rs-badge v-for="val2 in val.tag">
{{ val2 }}
</rs-badge>
</div>
</div>
</div>
</div>
</div>
</rs-card>
</div>
</template>
<style lang="scss" scoped>
.thumbnail::before {
content: "";
display: block;
height: 100%;
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: rgba(255, 255, 255, 0.8);
}
</style>

View File

@@ -0,0 +1,33 @@
<script setup>
definePageMeta({
title: "Template Viewer",
middleware: ["auth"],
requiresAuth: true,
});
const id = useRoute().params.id;
const { data: template } = await useFetch(
`/api/devtool/content/template/get-list`,
{
method: "GET",
params: {
id: id,
},
}
);
console.log(template.value.data);
const templateComponent = defineAsyncComponent(
() => import(`../../../../../templates/${template.value.data.filename}.vue`)
);
</script>
<template>
<div>
<LayoutsBreadcrumb />
<component :is="templateComponent" />
</div>
</template>

View File

@@ -0,0 +1,761 @@
<script setup>
import Menu from "~/navigation/index.js";
definePageMeta({
title: "Menu Editor",
middleware: ["auth"],
requiresAuth: true,
});
const nuxtApp = useNuxtApp();
const sideMenuList = ref(Menu);
const router = useRouter();
const getRoutes = router.getRoutes();
const getNavigation = Menu ? ref(Menu) : ref([]);
const allMenus = reactive([]);
const showCode = ref(false);
let i = 1;
const searchInput = ref("");
const showModal = ref(false);
const showModalEl = ref(null);
const dropdownMenu = ref([]);
const dropdownMenuValue = ref(null);
const showModalEdit = ref(false);
const showModalEditPath = ref(null);
const showModalEditForm = ref({
title: "",
name: "",
path: "",
guardType: "",
});
// const showModalEditEl = ref(null);
const showModalAdd = ref(false);
const showModalAddForm = ref({
title: "",
name: "",
path: "",
});
const systemPages = [
"/devtool",
"/dashboard",
"/login",
"/logout",
"/register",
"/reset-password",
"/forgot-password",
];
const kebabtoTitle = (str) => {
if (!str) return str;
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
// Sort the routes into menus
getRoutes.sort((a, b) => {
return a.path.localeCompare(b.path);
});
//----------------------------------------------------------------------------
//-------------------------FIRST CHILD TAB ITEM (END)-------------------------
//----------------------------------------------------------------------------
// Loop through the routes and add them to the menus
getRoutes.map((menu) => {
let visibleMenu = false;
// Check if the menu is visible
for (let i = 0; i < getNavigation.value.length; i++) {
if (getNavigation.value[i].child) {
for (let j = 0; j < getNavigation.value[i].child.length; j++) {
if (getNavigation.value[i].child[j].path === menu.path)
visibleMenu = true;
else if (getNavigation.value[i].child[j].child) {
for (
let k = 0;
k < getNavigation.value[i].child[j].child.length;
k++
) {
if (getNavigation.value[i].child[j].child[k].path === menu.path)
visibleMenu = true;
}
}
}
}
}
if (menu.name)
allMenus.push({
id: i++,
title:
menu.meta && menu.meta.title
? menu.meta.title
: kebabtoTitle(menu.name),
parentMenu: menu.path.split("/")[1],
name: menu.name,
path: menu.path,
visible: visibleMenu,
action: "",
});
});
const openModalEdit = (menu) => {
showModalEditForm.value.title = menu.title;
showModalEditForm.value.name = menu.name;
// If there is a slash in the beggining of the path, remove it
if (menu.path.charAt(0) === "/") {
showModalEditForm.value.path = menu.path.slice(1);
} else {
showModalEditForm.value.path = menu.path;
}
showModalEditPath.value = menu.path;
showModalEdit.value = true;
};
const saveEditMenu = async () => {
// Check title regex to ensure no weird symbol only letters, numbers, spaces, underscores and dashes
if (!/^[a-zA-Z0-9\s_-]+$/.test(showModalEditForm.value.title)) {
nuxtApp.$swal.fire({
title: "Error",
text: "Title contains invalid characters. Only letters, numbers, spaces, underscores and dashes are allowed.",
icon: "error",
});
return;
}
// Clean the name and title ensure not spacing at the beginning or end
showModalEditForm.value.title = showModalEditForm.value.title.trim();
showModalEditForm.value.name = showModalEditForm.value.name.trim();
const res = await useFetch("/api/devtool/menu/edit", {
method: "POST",
initialCache: false,
body: JSON.stringify({
filePath: showModalEditPath.value,
formData: {
title: showModalEditForm.value.title || "",
name: showModalEditForm.value.name || "",
path: "/" + showModalEditForm.value.path || "",
},
// formData: showModalEditForm.value,
}),
});
const data = res.data.value;
if (data.statusCode === 200) {
showModalEdit.value = false;
nuxtApp.$swal.fire({
title: "Success",
text: data.message,
icon: "success",
timer: 2000,
showConfirmButton: false,
});
window.location.reload();
}
};
const openModalAdd = () => {
showModalAddForm.value.title = "";
showModalAddForm.value.name = "";
showModalAddForm.value.path = "";
showModalAdd.value = true;
};
const saveAddMenu = async () => {
// Check title regex to ensure no weird symbol only letters, numbers, spaces, underscores and dashes
if (!/^[a-zA-Z0-9\s_-]+$/.test(showModalAddForm.value.title)) {
nuxtApp.$swal.fire({
title: "Error",
text: "Title contains invalid characters. Only letters, numbers, spaces, underscores and dashes are allowed.",
icon: "error",
});
return;
}
// Clean the name and title ensure not spacing at the beginning or end
showModalAddForm.value.title = showModalAddForm.value.title.trim();
showModalAddForm.value.name = showModalAddForm.value.name.trim();
const res = await useFetch("/api/devtool/menu/add", {
method: "POST",
initialCache: false,
body: JSON.stringify({
formData: {
title: showModalAddForm.value.title || "",
name: showModalAddForm.value.name || "",
path: "/" + showModalAddForm.value.path || "",
},
// formData: showModalAddForm.value
}),
});
const data = res.data.value;
if (data.statusCode === 200) {
showModalAdd.value = false;
nuxtApp.$swal.fire({
title: "Success",
text: data.message,
icon: "success",
timer: 2000,
showConfirmButton: false,
});
window.location.reload();
} else {
nuxtApp.$swal.fire({
title: "Error",
text: data.message,
icon: "error",
timer: 2000,
showConfirmButton: false,
});
}
};
const deleteMenu = async (menu) => {
nuxtApp.$swal
.fire({
title: "Are you sure?",
text: "You won't be able to revert this!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes, delete it!",
})
.then(async (result) => {
if (result.isConfirmed) {
const res = await useFetch("/api/devtool/menu/delete", {
method: "POST",
initialCache: false,
body: JSON.stringify({
filePath: menu.path,
}),
});
const data = res.data.value;
if (data.statusCode === 200) {
nuxtApp.$swal.fire({
title: "Deleted!",
text: data.message,
icon: "success",
timer: 2000,
showConfirmButton: false,
});
window.location.reload();
}
}
});
};
//----------------------------------------------------------------------------
//-------------------------FIRST CHILD TAB ITEM (END)-------------------------
//----------------------------------------------------------------------------
//-----------------------------------------------------------------------
//-------------------------SECOND CHILD TAB ITEM-------------------------
//-----------------------------------------------------------------------
const checkExistSideMenuList = (path) => {
let exist = false;
sideMenuList.value.map((menu) => {
// Search child path
if (menu.child) {
menu.child.map((child) => {
if (child.path == path) {
exist = true;
}
if (child.child) {
child.child.map((child2) => {
if (child2.path == path) {
exist = true;
}
});
}
});
}
});
return exist;
};
const menuList = computed(() => {
// If the search input is empty, return all menus
if (searchInput.value === "") {
return allMenus;
} else {
// If the search input is not empty, filter the menus
return allMenus.filter((menu) => {
return menu.name.toLowerCase().includes(searchInput.value.toLowerCase());
});
}
});
// Clone draggable item
const clone = (obj) => {
return JSON.parse(
JSON.stringify({
title: obj.title,
path: obj.path,
icon: obj.icon ? obj.icon : "",
child: [],
})
);
};
// Add Header
const addNewHeader = () => {
// Push index = 1
sideMenuList.value.splice(1, 0, {
header: "New Header",
description: "New Description",
child: [],
});
};
// changeSideMenuList
const changeSideMenuList = (menus) => {
sideMenuList.value = menus;
};
// Save the menu
const overwriteJsonFileLocal = async (menus) => {
const res = await useFetch("/api/devtool/menu/overwrite-navigation", {
method: "POST",
initialCache: false,
body: JSON.stringify({
menuData: menus,
}),
});
const data = res.data.value;
if (data.statusCode === 200) {
nuxtApp.$swal.fire({
title: "Success",
text: data.message,
icon: "success",
timer: 2000,
showConfirmButton: false,
});
}
};
// open modal
const openModal = (menu) => {
showModalEl.value = menu;
// Get All Menu includes child and assign to dropdownMenu in one array
let i = 0;
dropdownMenu.value = [
{
label: "Choose Menu",
value: null,
attrs: {
disabled: true,
},
},
];
sideMenuList.value.map((menu) => {
if (menu.header || menu.description) {
dropdownMenu.value.push({
label: `${menu.header} (Header)`,
value: `header|${i}`,
});
} else if (menu.hasOwnProperty("header")) {
dropdownMenu.value.push({
label: `<No Header Name> (Header)`,
value: `header|${i}`,
});
}
if (menu.child) {
menu.child.map((child) => {
dropdownMenu.value.push({
label: `${child.title} (Menu)`,
value: `menu|${child.path}`,
});
});
}
i++;
});
showModal.value = true;
};
// Add new menu from list
const addMenuFromList = () => {
if (dropdownMenuValue.value) {
const menuType = dropdownMenuValue.value.split("|")[0];
const menuValue = dropdownMenuValue.value.split("|")[1];
if (menuType === "header") {
// Add Header
sideMenuList.value[menuValue].child.push(clone(showModalEl.value));
} else if (menuType === "menu") {
// Add Menu
sideMenuList.value.map((menu) => {
if (menu.child) {
menu.child.map((child) => {
if (child.path == menuValue) {
child.child.push(clone(showModalEl.value));
}
});
}
});
}
showModal.value = false;
}
};
//-----------------------------------------------------------------------------
//-------------------------SECOND CHILD TAB ITEM (END)-------------------------
//-----------------------------------------------------------------------------
// Add this watcher after the showModalEditForm ref declaration
watch(
() => showModalEditForm.value,
(newTitle) => {
showModalEditForm.value.name = newTitle.toLowerCase().replace(/\s+/g, "-");
}
);
watch(
() => showModalAddForm.value.title,
(newTitle) => {
showModalAddForm.value.name = newTitle.toLowerCase().replace(/\s+/g, "-");
}
);
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<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">
This page is used to edit the menu of the website. You can add, edit,
and delete menu items. You can also change the order of the menu items
by dragging and dropping them.
</p>
</template>
</rs-card>
<rs-card>
<div class="pt-2">
<rs-tab fill>
<rs-tab-item title="All Menu">
<div class="flex justify-end items-center mb-4">
<rs-button @click="openModalAdd">
<Icon name="material-symbols:add" class="mr-1"></Icon>
Add Menu
</rs-button>
</div>
<!-- Table All Menu -->
<rs-table
:data="allMenus"
:options="{
variant: 'default',
striped: true,
borderless: true,
}"
:options-advanced="{
sortable: true,
filterable: false,
responsive: false,
}"
advanced
>
<template v-slot:name="data">
<NuxtLink
class="text-blue-700 hover:underline"
:to="data.value.path"
target="_blank"
>{{ data.text }}</NuxtLink
>
</template>
<template v-slot:visible="data">
<div class="flex items-center">
<Icon
name="mdi:eye-outline"
class="text-primary"
size="22"
v-if="data.value.visible"
/>
<Icon
name="mdi:eye-off-outline"
class="text-primary/20"
size="22"
v-else
/>
</div>
</template>
<template v-slot:action="data">
<div class="flex items-center">
<template
v-if="
!systemPages.some((path) =>
data.value.path.startsWith(path)
) && data.value.parentMenu != 'admin'
"
>
<Icon
name="material-symbols:edit-outline-rounded"
class="text-primary hover:text-primary/90 cursor-pointer mr-1"
size="22"
@click="openModalEdit(data.value)"
></Icon>
<Icon
name="material-symbols:close-rounded"
class="text-primary hover:text-primary/90 cursor-pointer"
size="22"
@click="deleteMenu(data.value)"
></Icon>
</template>
<div v-else class="text-gray-400">-</div>
</div>
</template>
</rs-table>
</rs-tab-item>
<rs-tab-item title="Manage Side Menu">
<div class="flex justify-end items-center mb-4">
<rs-button
class="mr-2"
@click="showCode ? (showCode = false) : (showCode = true)"
>
<Icon name="ic:baseline-code" class="mr-2"></Icon>
{{ showCode ? "Hide" : "Show" }} JSON Code
</rs-button>
<rs-button @click="overwriteJsonFileLocal(sideMenuList)">
<Icon name="mdi:content-save-outline" class="mr-2"></Icon>
Save Menu
</rs-button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 gap-5">
<div>
<FormKit
type="search"
placeholder="Search Menu..."
outer-class="mb-5"
v-model="searchInput"
/>
<NuxtScrollbar
style="height: 735px"
class="px-5 pt-5 border border-[rgb(var(--border-color))] bg-[rgb(var(--bg-1))] rounded-md"
>
<draggable
item-key="id"
v-model="menuList"
:group="{ name: 'menu', pull: 'clone', put: false }"
:clone="clone"
:sort="false"
>
<template #item="{ element }">
<rs-card
class="p-4 mb-4 border-2 border-[rgb(var(--border-color))] !shadow-none"
>
<div class="flex justify-between items-center">
<p>
{{ kebabtoTitle(element.name) }} (
<NuxtLink
class="text-primary hover:underline"
:to="element.path"
target="_blank"
>
{{ element.path }}
</NuxtLink>
)
</p>
<Icon
v-if="checkExistSideMenuList(element.path) == false"
name="ic:baseline-arrow-circle-right"
class="text-primary cursor-pointer transition-all duration-150 hover:scale-110"
@click="openModal(element)"
></Icon>
</div>
</rs-card>
</template>
</draggable>
</NuxtScrollbar>
</div>
<NuxtScrollbar v-if="!showCode" style="height: 825px">
<rs-card
class="p-4 border border-[rgb(var(--border-color))] bg-[rgb(var(--bg-1))] rounded-md"
>
<div class="flex justify-end items-center">
<rs-button
class="!p-2 mt-3 justify-center items-center"
@click="addNewHeader"
>
<Icon
class="mr-1"
name="material-symbols:docs-add-on"
size="18"
></Icon>
Add Header
</rs-button>
</div>
<DraggableSideMenuNested
:menus="sideMenuList"
@changeSideMenu="changeSideMenuList"
/>
</rs-card>
</NuxtScrollbar>
<pre v-else v-html="JSON.stringify(sideMenuList, null, 2)"></pre>
</div>
</rs-tab-item>
</rs-tab>
</div>
</rs-card>
<rs-modal
title="Select Menu"
v-model="showModal"
ok-title="Confirm"
:ok-callback="addMenuFromList"
>
<FormKit
label="Please Select Menu or Header"
help="Select menu or header to add as their child menu"
type="select"
v-model="dropdownMenuValue"
:options="dropdownMenu"
></FormKit>
</rs-modal>
<rs-modal title="Edit Menu" v-model="showModalEdit" :overlay-close="false">
<template #body>
<FormKit type="form" :actions="false" @submit="saveEditMenu">
<FormKit
type="text"
label="Title"
:validation="[['required']]"
:validation-messages="{
required: 'Title is required',
}"
v-model="showModalEditForm.title"
/>
<FormKit
type="text"
label="Path"
help="If the last path name is '/', the name of the file will be from its name property. While if the last path name is not '/', the name of the file will be from its path property."
:validation="[['required'], ['matches', '/^[a-z0-9/-]+$/']]"
:validation-messages="{
required: 'Path is required',
matches:
'Path contains invalid characters or spacing before or after. Only letters, numbers, dashes, and underscores are allowed.',
}"
v-model="showModalEditForm.path"
>
<template #prefix>
<div
class="bg-slate-100 dark:bg-slate-700 h-full rounded-l-md p-3"
>
/
</div>
</template>
</FormKit>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showModalEdit = false">
Cancel
</rs-button>
<rs-button btnType="submit">
<Icon
name="material-symbols:save-outline"
class="mr-2 !w-4 !h-4"
/>
Save Changes
</rs-button>
</div>
</FormKit>
</template>
<template #footer> </template>
</rs-modal>
<rs-modal title="Add Menu" v-model="showModalAdd" :overlay-close="false">
<template #body>
<FormKit type="form" :actions="false" @submit="saveAddMenu">
<FormKit
type="text"
label="Title"
:validation="[['required']]"
:validation-messages="{
required: 'Title is required',
}"
v-model="showModalAddForm.title"
/>
<FormKit
type="text"
label="Path"
help="If the last path name is '/', the name of the file will be from its name property. While if the last path name is not '/', the name of the file will be from its path property."
:validation="[['required'], ['matches', '/^[a-z0-9/-]+$/']]"
:validation-messages="{
required: 'Path is required',
matches:
'Path contains invalid characters or spacing before or after. Only letters, numbers, dashes, and underscores are allowed.',
}"
v-model="showModalAddForm.path"
>
<template #prefix>
<div
class="bg-slate-100 dark:bg-slate-700 h-full rounded-l-md p-3"
>
/
</div>
</template>
</FormKit>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showModalAdd = false">
Cancel
</rs-button>
<rs-button btnType="submit">
<Icon
name="material-symbols:add-circle-outline-rounded"
class="mr-2 !w-4 !h-4"
/>
Add Menu
</rs-button>
</div>
</FormKit>
</template>
<template #footer> </template>
</rs-modal>
</div>
</template>

169
pages/devtool/orm/index.vue Normal file
View File

@@ -0,0 +1,169 @@
<script setup>
definePageMeta({
title: "Database (ORM)",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal } = useNuxtApp();
const searchText = ref("");
const tableList = ref([]);
const { data } = await useFetch("/api/devtool/orm/schema", {
method: "GET",
query: {
type: "table",
},
});
if (data.value.statusCode === 200) {
tableList.value = data.value.data;
}
const deleteTable = async (tableName) => {
try {
const result = await $swal.fire({
title: "Are you sure?",
text: `You are about to delete the table '${tableName}'. This action cannot be undone!`,
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes, delete it!",
});
if (result.isConfirmed) {
const { data } = await useFetch(
`/api/devtool/orm/table/delete/${tableName}`,
{
method: "DELETE",
}
);
if (data.value.statusCode === 200) {
await $swal.fire("Deleted!", data.value.message, "success");
// Remove the deleted table from the list
tableList.value = tableList.value.filter(
(table) => table.name !== tableName
);
} else {
throw new Error(data.value.message);
}
}
} catch (error) {
console.error("Error deleting table:", error);
await $swal.fire(
"Error!",
`Failed to delete table: ${error.message}`,
"error"
);
}
};
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<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">
This page is used to edit the ORM schema. You can add, edit, and
delete the model and its fields. The changes will be saved to the
database.
</p>
</template>
</rs-card>
<rs-card>
<div class="p-4">
<div class="flex justify-end items-center mb-4">
<nuxt-link to="/devtool/orm/table/create">
<rs-button>
<Icon name="material-symbols:add" class="mr-1"></Icon>
Add Table
</rs-button>
</nuxt-link>
</div>
<!-- Search Button -->
<FormKit
v-model="searchText"
placeholder="Search Title..."
type="search"
/>
<div v-if="tableList" class="grid grid-cols-1 gap-5">
<div v-for="tbl in tableList" class="p-5 border rounded-md">
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center text-primary gap-2">
<Icon name="ph:table-fill" />
<h4>
{{ tbl.name }}
</h4>
</div>
<div class="flex flex-wrap gap-3">
<span
v-for="field in tbl.fields"
class="text-xs py-1 px-3 inline-block bg-slate-100 rounded-lg ring-1 ring-slate-200"
>
{{ field }}
</span>
</div>
</div>
<div class="flex justify-between mt-5">
<NuxtLink
:to="`/devtool/orm/view/${tbl.name}`"
class="flex justify-center items-center"
>
<rs-button
variant="primary"
class="flex justify-center items-center"
>
View Data
</rs-button>
</NuxtLink>
<div v-if="!tbl.disabled" class="flex justify-between gap-3">
<NuxtLink
:to="`/devtool/orm/table/modify/${tbl.name}`"
class="flex justify-center items-center"
>
<rs-button
variant="secondary"
class="flex justify-center items-center"
>
<Icon name="ph:note-pencil-bold" class="w-4 h-4 mr-1" />
Modify
</rs-button>
</NuxtLink>
<rs-button
variant="danger-outline"
class="flex justify-center items-center"
@click="deleteTable(tbl.name)"
>
<Icon name="ph:trash-simple-bold" class="w-4 h-4 mr-1" />
Delete
</rs-button>
</div>
<div
class="flex items-end text-xs py-1 px-3 text-slate-400 cursor-not-allowed"
v-else
>
Cannot Modify System Table
</div>
</div>
</div>
</div>
</div>
</rs-card>
</div>
</template>

View File

@@ -0,0 +1,315 @@
<script setup>
definePageMeta({
title: "Database Create Table",
middleware: ["auth"],
requiresAuth: true,
});
const tableName = ref("");
const tableKey = ref(0);
const tableData = ref([]);
const columnTypes = ref([]);
const { $swal } = useNuxtApp();
const { data: dbConfiguration } = await useFetch(
"/api/devtool/orm/table/config"
);
if (dbConfiguration.value && dbConfiguration.value.statusCode === 200) {
let tempObject = {};
dbConfiguration.value.data.tableField.forEach((field) => {
tempObject[field] = "";
});
tableData.value.push(tempObject);
// Update columnTypes to use the new structure
columnTypes.value = dbConfiguration.value.data.columnTypes.flatMap((group) =>
group.options.map((option) =>
typeof option === "string" ? { label: option, value: option } : option
)
);
}
const addNewField = () => {
let tempObject = {};
// Add new field after the last field
dbConfiguration.value.data.tableField.forEach((field) => {
tempObject[field] = "";
});
tableData.value.push(tempObject);
tableKey.value++;
};
const removeField = (index) => {
tableData.value.splice(index, 1);
};
const sortField = (index, direction) => {
if (direction === "up") {
if (index === 0) return;
let temp = tableData.value[index];
tableData.value[index] = tableData.value[index - 1];
tableData.value[index - 1] = temp;
tableKey.value++;
} else {
if (index === tableData.value.length - 1) return;
let temp = tableData.value[index];
tableData.value[index] = tableData.value[index + 1];
tableData.value[index + 1] = temp;
tableKey.value++;
}
};
const autoIcrementColumn = ref("");
const computedAutoIncrementColumn = computed(() => {
return tableData.value.map((data) => {
return {
label: data.name,
value: data.name,
};
});
});
const checkRadioButton = async (index, event) => {
try {
// change tableData[index].primaryKey value to true
tableData.value[index].primaryKey = event.target.checked;
// change all other tableData[index].primaryKey value to false
tableData.value.forEach((data, i) => {
if (i !== index) {
tableData.value[i].primaryKey = "";
}
});
} catch (error) {
console.log(error);
}
};
const resetData = () => {
$swal
.fire({
title: "Are you sure?",
text: "You will lose all the data you have entered.",
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, reset it!",
cancelButtonText: "No, cancel!",
})
.then((result) => {
if (result.isConfirmed) {
tableName.value = "";
tableData.value = [];
tableKey.value = 0;
let tempObject = {};
dbConfiguration.value.data.tableField.forEach((field) => {
tempObject[field] = "";
});
tableData.value.push(tempObject);
}
});
};
const submitCreateTable = async () => {
try {
const { data } = await useFetch("/api/devtool/orm/table/create", {
method: "POST",
body: {
tableName: tableName.value,
tableSchema: tableData.value,
autoIncrementColumn: autoIcrementColumn.value,
},
});
if (data.value.statusCode == 200) {
$swal.fire({
title: "Success",
text: data.value.message,
icon: "success",
});
navigateTo("/devtool/orm");
} else {
$swal.fire({
title: "Error",
text: data.value.message,
icon: "error",
});
}
} catch (error) {
console.log(error);
}
};
</script>
<template>
<div>
<rs-card class="py-5">
<FormKit
type="form"
:classes="{
messages: 'px-5',
}"
:actions="false"
@submit="submitCreateTable"
>
<div
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-y-2 mb-5 px-5"
>
<div>
<h5 class="font-semibold">Create Table</h5>
<span class="text-sm text-gray-500">
Create a new table in the database.
</span>
</div>
<div class="flex gap-3">
<rs-button @click="resetData" variant="primary-outline">
Reset Table
</rs-button>
<rs-button btnType="submit" class="mb-4 w-[100px]">
Save
</rs-button>
</div>
</div>
<section class="px-5">
<FormKit
v-model="tableName"
type="text"
label="Table name"
placeholder="Enter table name"
validation="required|length:3,64"
:classes="{ outer: 'mb-8' }"
:validation-messages="{
required: 'Table name is required',
length:
'Table name must be at least 3 characters and at most 64 characters',
}"
/>
</section>
<rs-table
v-if="tableData && tableData.length > 0"
:data="tableData"
:key="tableKey"
:disableSort="true"
class="mb-8"
>
<template v-slot:name="data">
<FormKit
v-model="data.value.name"
:classes="{
outer: 'mb-0 w-[200px]',
}"
placeholder="Enter column name"
type="text"
validation="required|length:3,64"
:validation-messages="{
required: 'Column name is required',
length:
'Column name must be at least 3 characters and at most 64 characters',
}"
/>
</template>
<template v-slot:type="data">
<FormKit
v-if="columnTypes && columnTypes.length > 0"
v-model="data.value.type"
:classes="{ outer: 'mb-0 w-[100px]' }"
:options="columnTypes"
type="select"
placeholder="Select type"
validation="required"
:validation-messages="{
required: 'Column type is required',
}"
/>
</template>
<template v-slot:length="data">
<FormKit
v-model="data.value.length"
:classes="{ outer: 'mb-0 w-[150px]' }"
placeholder="Enter length"
type="number"
/>
</template>
<template v-slot:defaultValue="data">
<FormKit
v-model="data.value.defaultValue"
:classes="{ outer: 'mb-0 w-[150px]' }"
placeholder="Optional"
type="text"
/>
</template>
<template v-slot:nullable="data">
<FormKit
v-model="data.value.nullable"
:classes="{ wrapper: 'mb-0', outer: 'mb-0' }"
type="checkbox"
/>
</template>
<template v-slot:primaryKey="data">
<FormKit
v-model="data.value.primaryKey"
:classes="{
wrapper: 'mb-0',
outer: 'mb-0',
input: 'icon-check rounded-full',
}"
type="checkbox"
@change="checkRadioButton(data.index, $event)"
/>
</template>
<template v-slot:actions="data">
<div class="flex justify-center items-center gap-2">
<rs-button @click="addNewField" type="button" class="p-1 w-6 h-6">
<Icon name="ph:plus" />
</rs-button>
<rs-button
@click="sortField(data.index, 'up')"
type="button"
class="p-1 w-6 h-6"
:disabled="data.index === 0"
>
<Icon name="ph:arrow-up" />
</rs-button>
<rs-button
@click="sortField(data.index, 'down')"
type="button"
class="p-1 w-6 h-6"
:disabled="data.index === tableData.length - 1"
>
<Icon name="ph:arrow-down" />
</rs-button>
<rs-button
@click="removeField(data.index)"
type="button"
class="p-1 w-6 h-6"
variant="danger"
:disabled="data.index === 0 && tableData.length === 1"
>
<Icon name="ph:x" />
</rs-button>
</div>
</template>
</rs-table>
</FormKit>
</rs-card>
</div>
</template>

View File

@@ -0,0 +1,360 @@
<script setup>
import { v4 as uuidv4 } from "uuid";
definePageMeta({
title: "Database Modify Table",
middleware: ["auth"],
requiresAuth: true,
});
const tableName = ref("");
const tableKey = ref(0);
const tableData = ref([]);
const columnTypes = ref([]);
const { table } = useRoute().params;
const { $swal } = useNuxtApp();
const { data: dbConfiguration } = await useFetch(
"/api/devtool/orm/table/config"
);
if (dbConfiguration.value && dbConfiguration.value.statusCode === 200) {
let tempObject = {};
dbConfiguration.value.data.tableField.forEach((field) => {
tempObject[field] = "";
});
tableData.value.push(tempObject);
// Update columnTypes to use the new structure
columnTypes.value = dbConfiguration.value.data.columnTypes.flatMap((group) =>
group.options.map((option) =>
typeof option === "string" ? { label: option, value: option } : option
)
);
}
const { data: tableDetail } = await useFetch(
`/api/devtool/orm/table/modify/get`,
{
method: "GET",
params: {
tableName: table,
},
}
);
// console.log(tableDetail.value);
if (tableDetail.value.statusCode === 200) {
tableData.value = tableDetail.value.data.map((item) => ({
...item,
actions: {
...item.actions,
id: uuidv4(), // Add a unique id to each item's actions
},
}));
tableName.value = table;
}
const addNewField = (index) => {
let tempObject = {
actions: {
id: uuidv4(), // Add a unique id to the new field's actions
},
};
dbConfiguration.value.data.tableField.forEach((field) => {
tempObject[field] = "";
});
tableData.value.splice(index + 1, 0, tempObject);
tableKey.value++;
};
const sortField = (id, direction) => {
const index = tableData.value.findIndex((item) => item.actions.id === id);
if (direction === "up" && index > 0) {
// Move the current field up
const temp = tableData.value[index];
tableData.value[index] = tableData.value[index - 1];
tableData.value[index - 1] = temp;
} else if (direction === "down" && index < tableData.value.length - 1) {
// Move the current field down
const temp = tableData.value[index];
tableData.value[index] = tableData.value[index + 1];
tableData.value[index + 1] = temp;
}
tableKey.value++;
};
const removeField = (id) => {
if (tableData.value.length > 1) {
const index = tableData.value.findIndex((item) => item.actions.id === id);
tableData.value.splice(index, 1);
tableKey.value++;
} else {
$swal.fire({
title: "Error",
text: "Cannot remove the last field.",
icon: "error",
});
}
};
const autoIcrementColumn = ref("");
const computedAutoIncrementColumn = computed(() => {
return tableData.value.map((data) => {
return {
label: data.name,
value: data.name,
};
});
});
const checkRadioButton = async (index, event) => {
try {
// change tableData[index].primaryKey value to true
tableData.value[index].primaryKey = event.target.checked;
// change all other tableData[index].primaryKey value to false
tableData.value.forEach((data, i) => {
if (i !== index) {
tableData.value[i].primaryKey = "";
}
});
} catch (error) {
console.log(error);
}
};
const resetData = () => {
$swal
.fire({
title: "Are you sure?",
text: "You will lose all the data you have entered.",
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, reset it!",
cancelButtonText: "No, cancel!",
})
.then((result) => {
if (result.isConfirmed) {
tableName.value = "";
tableData.value = [];
tableKey.value = 0;
let tempObject = {};
dbConfiguration.value.data.tableField.forEach((field) => {
tempObject[field] = "";
});
tableData.value.push(tempObject);
}
});
};
const submitModifyTable = async () => {
try {
// console.log({
// tableName: tableName.value,
// tableSchema: tableData.value,
// autoIncrementColumn: autoIcrementColumn.value,
// });
// return;
const { data } = await useFetch("/api/devtool/orm/table/modify", {
method: "POST",
body: {
tableName: tableName.value,
tableSchema: tableData.value,
autoIncrementColumn: autoIcrementColumn.value,
},
});
if (data.value.statusCode == 200) {
$swal.fire({
title: "Success",
text: data.value.message,
icon: "success",
});
} else {
$swal.fire({
title: "Error",
text: data.value.message,
icon: "error",
});
}
} catch (error) {
console.log(error);
}
};
</script>
<template>
<div>
<rs-card class="py-5">
<FormKit
type="form"
:classes="{
messages: 'px-5',
}"
:actions="false"
@submit="submitModifyTable"
>
<div
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-y-2 mb-5 px-5"
>
<div>
<h5 class="font-semibold">Modify Table</h5>
<span class="text-sm text-gray-500">
Modify a new table in the database.
</span>
</div>
<div class="flex gap-3">
<rs-button @click="resetData" variant="primary-outline">
Reset Table
</rs-button>
<rs-button btnType="submit" class="mb-4 w-[100px]">
Save
</rs-button>
</div>
</div>
<section class="px-5">
<FormKit
v-model="tableName"
type="text"
label="Table name"
placeholder="Enter table name"
validation="required|length:3,64"
:classes="{ outer: 'mb-8' }"
:validation-messages="{
required: 'Table name is required',
length:
'Table name must be at least 3 characters and at most 64 characters',
}"
/>
</section>
<rs-table
v-if="tableData && tableData.length > 0"
:data="tableData"
:key="tableKey"
:disableSort="true"
:pageSize="100"
class="mb-8"
>
<template v-slot:name="data">
<FormKit
v-model="data.value.name"
:classes="{
outer: 'mb-0 w-[200px]',
}"
placeholder="Enter column name"
type="text"
validation="required|length:3,64"
:validation-messages="{
required: 'Column name is required',
length:
'Column name must be at least 3 characters and at most 64 characters',
}"
/>
</template>
<template v-slot:type="data">
<FormKit
v-if="columnTypes && columnTypes.length > 0"
v-model="data.value.type"
:classes="{ outer: 'mb-0 w-[100px]' }"
:options="columnTypes"
type="select"
placeholder="Select type"
validation="required"
:validation-messages="{
required: 'Column type is required',
}"
/>
</template>
<template v-slot:length="data">
<FormKit
v-model="data.value.length"
:classes="{ outer: 'mb-0 w-[150px]' }"
placeholder="Enter length"
type="number"
/>
</template>
<template v-slot:defaultValue="data">
<FormKit
v-model="data.value.defaultValue"
:classes="{ outer: 'mb-0 w-[150px]' }"
placeholder="Optional"
type="text"
/>
</template>
<template v-slot:nullable="data">
<FormKit
v-model="data.value.nullable"
:classes="{ wrapper: 'mb-0', outer: 'mb-0' }"
type="checkbox"
/>
</template>
<template v-slot:primaryKey="data">
<FormKit
v-model="data.value.primaryKey"
:classes="{
wrapper: 'mb-0',
outer: 'mb-0',
input: 'icon-check rounded-full',
}"
type="checkbox"
@change="checkRadioButton(data.index, $event)"
/>
</template>
<template v-slot:actions="data">
<div class="flex justify-center items-center gap-2">
<rs-button
@click="addNewField(tableData.indexOf(data.value))"
type="button"
class="p-1 w-6 h-6"
>
<Icon name="ph:plus" />
</rs-button>
<rs-button
@click="sortField(data.value.actions.id, 'up')"
type="button"
class="p-1 w-6 h-6"
:disabled="tableData.indexOf(data.value) === 0"
>
<Icon name="ph:arrow-up" />
</rs-button>
<rs-button
@click="sortField(data.value.actions.id, 'down')"
type="button"
class="p-1 w-6 h-6"
:disabled="
tableData.indexOf(data.value) === tableData.length - 1
"
>
<Icon name="ph:arrow-down" />
</rs-button>
<rs-button
@click="removeField(data.value.actions.id)"
type="button"
class="p-1 w-6 h-6"
variant="danger"
:disabled="tableData.length === 1"
>
<Icon name="ph:x" />
</rs-button>
</div>
</template>
</rs-table>
</FormKit>
</rs-card>
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup>
definePageMeta({
title: "ORM Table Editor",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal } = useNuxtApp();
const { table } = useRoute().params;
const { data: tableData } = await useFetch("/api/devtool/orm/data/get", {
method: "GET",
query: {
tableName: table,
},
});
const openPrismaStudio = async () => {
const { data } = await useFetch("/api/devtool/orm/studio", {
method: "GET",
query: {
tableName: table,
},
});
if (data.value.statusCode === 200) {
$swal.fire({
title: "Prisma Studio",
text: data.value.message,
icon: "success",
});
} else {
$swal.fire({
title: "Prisma Studio",
text: data.value.message,
icon: "error",
});
}
};
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card class="py-5">
<div
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-y-2 px-5"
>
<div>
<h5 class="font-semibold">Table - {{ table }}</h5>
<span class="text-sm text-gray-500">
Below is the data of the table.
</span>
</div>
<rs-button @click="openPrismaStudio" class="mb-4">
Open Prisma Studio
</rs-button>
</div>
<rs-table
v-if="tableData.data && tableData.data.length > 0"
:key="tableData.data"
:data="tableData.data"
advanced
/>
<div v-else class="flex justify-center my-3">
<div class="text-center">
<h6 class="font-semibold">Data Not Found</h6>
<span class="text-sm text-gray-500">
There is no data available for this table.
</span>
</div>
</div>
</rs-card>
</div>
</template>

View File

@@ -0,0 +1,499 @@
<script setup>
definePageMeta({
title: "Role List",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal, $router } = useNuxtApp();
const roleList = ref([]);
const roleUserList = ref([]);
const showModal = ref(false);
const showModalForm = ref({
id: "",
name: "",
description: "",
users: [],
status: "",
});
const modalType = ref("edit");
const showModalUser = ref(false);
const showModalUserForm = ref({
username: "",
fullname: "",
email: "",
phone: "",
password: "",
role: "",
status: "",
});
const showModalDelete = ref(false);
const showModalDeleteForm = ref({
id: "",
name: "",
});
const statusDropdown = ref([
{ label: "Active", value: "ACTIVE" },
{ label: "Inactive", value: "INACTIVE" },
]);
const roleListbyUser = ref([]);
const checkAllUser = ref(false);
// Call API
// onMounted(async () => {
// await getUserList();
// await getRoleList();
// });
getRoleList();
getUserList();
async function getRoleList() {
const { data } = await useFetch("/api/devtool/role/list", {
initialCache: false,
});
// Rename the key
if (data.value?.statusCode === 200) {
roleList.value = data.value.data.map((role) => ({
id: role.roleID,
name: role.roleName,
description: role.roleDescription,
users: role.users.map((u) => {
return {
label: u.user.userUsername,
value: u.user.userUsername,
};
}),
status: role.roleStatus,
createdDate: role.roleCreatedDate,
action: null,
}));
groupRoleByUser();
}
}
async function getUserList() {
const { data } = await useFetch("/api/devtool/user/list", {
initialCache: false,
});
if (data.value.statusCode === 200) {
roleUserList.value = data.value.data.map((user) => ({
label: user.userUsername,
value: user.userUsername,
}));
}
}
function usersWithCommans(users) {
// Limit the number of users to 4 and add "..." if there are more than 4 users
const userList = users.map((u) => u.label);
return userList.length > 4
? userList.slice(0, 4).join(", ") + "..."
: userList.join(", ");
}
// Watch checkAllUser value
watch(
checkAllUser,
async (value) => {
if (value) {
showModalForm.value.users = roleUserList.value;
} else {
if (showModalForm.value.users.length === roleUserList.value.length) {
showModalForm.value.users = [];
}
}
},
{ immediate: true }
);
// Watch showModalForm.users value
watch(
showModalForm,
async (value) => {
if (value.users.length === roleUserList.value.length) {
checkAllUser.value = true;
} else {
checkAllUser.value = false;
}
},
{ deep: true }
);
// Open Modal Add or Edit User
const openModal = async (value, type) => {
modalType.value = type;
if (type == "edit") {
showModalForm.value = {
id: value.id,
name: value.name,
description: value.description,
users: value.users,
status: value.status,
};
} else {
showModalForm.value = {
id: "",
name: "",
description: "",
users: "",
status: "",
};
}
showModalUser.value = false;
showModal.value = true;
};
// Open Modal Add Role
const openModalUser = async () => {
showModalUserForm.value = {
username: "",
fullname: "",
email: "",
phone: "",
password: "",
role: "",
status: "",
};
showModal.value = false;
showModalUser.value = true;
};
// Close Modal Role
const closeModalUser = () => {
showModalUser.value = false;
showModal.value = true;
};
// Open Modal Delete User
const openModalDelete = async (value) => {
showModalDeleteForm.value.id = value.id;
showModalDeleteForm.value.name = value.name;
showModalDelete.value = true;
};
const saveUser = async () => {
const { data } = await useFetch("/api/devtool/user/add", {
initialCache: false,
method: "POST",
body: JSON.stringify({
...showModalUserForm.value,
module: "role",
}),
});
if (data.value.statusCode === 200) {
$swal.fire({
icon: "success",
title: "Success",
text: "User has been added successfully",
});
await getUserList();
showModalUser.value = false;
showModal.value = true;
} else {
$swal.fire({
icon: "error",
title: "Error",
text: data.value.message,
});
}
};
const saveRole = async () => {
if (modalType.value == "edit") {
const { data } = await useFetch("/api/devtool/role/edit", {
initialCache: false,
method: "POST",
body: JSON.stringify(showModalForm.value),
});
if (data.value.statusCode === 200) {
$swal.fire({
position: "center",
icon: "success",
title: "Success",
text: "Role has been updated successfully",
timer: 1000,
showConfirmButton: false,
});
await getRoleList();
showModal.value = false;
} else {
$swal.fire({
icon: "error",
title: "Error",
text: data.value.message,
});
}
} else {
const { data } = await useFetch("/api/devtool/role/add", {
initialCache: false,
method: "POST",
body: JSON.stringify({ ...showModalForm.value, module: "role" }),
});
if (data.value.statusCode === 200) {
$swal.fire({
position: "center",
icon: "success",
title: "Success",
text: "Role has been added",
timer: 1000,
showConfirmButton: false,
});
await getRoleList();
showModal.value = false;
} else {
$swal.fire({
icon: "error",
title: "Error",
text: data.value.message,
});
}
}
};
const deleteRole = async () => {
const { data } = await useFetch("/api/devtool/role/delete", {
initialCache: false,
method: "POST",
body: JSON.stringify({ id: showModalDeleteForm.value.id }),
});
if (data.value.statusCode === 200) {
$swal.fire({
position: "center",
icon: "success",
title: "Success",
text: "Role has been deleted",
timer: 1000,
showConfirmButton: false,
});
await getRoleList();
showModalDelete.value = false;
} else {
$swal.fire({
position: "center",
icon: "error",
title: "Error",
text: data.value.message,
});
}
};
function groupRoleByUser() {
roleListbyUser.value = roleList.value.reduce((acc, role) => {
const users = role.users.map((user) => user.userUsername);
if (acc[users]) {
acc[users].push(role);
} else {
acc[users] = [role];
}
return acc;
}, {});
}
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<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">
This page is only accessible by admin users. You can manage users
here. You can also add new users. You can also change user roles.
</p>
</template>
</rs-card>
<rs-card>
<div class="pt-2">
<rs-tab fill>
<rs-tab-item title="All Role">
<div class="flex justify-end items-center mb-4">
<rs-button @click="openModal(null, 'add')">
<Icon name="material-symbols:add" class="mr-1"></Icon>
Add Role
</rs-button>
</div>
<rs-table
v-if="roleList && roleList.length > 0"
:data="roleList"
:options="{
variant: 'default',
striped: true,
borderless: true,
}"
advanced
>
<template v-slot:users="data">
{{ usersWithCommans(data.text) }}
</template>
<template v-slot:action="data">
<div
class="flex justify-center items-center"
v-if="data.value.role?.value != '1'"
>
<Icon
name="material-symbols:edit-outline-rounded"
class="text-primary hover:text-primary/90 cursor-pointer mr-1"
size="22"
@click="openModal(data.value, 'edit')"
></Icon>
<Icon
name="material-symbols:close-rounded"
class="text-primary hover:text-primary/90 cursor-pointer"
size="22"
@click="openModalDelete(data.value)"
></Icon>
</div>
<div class="flex justify-center items-center" v-else>-</div>
</template>
</rs-table>
</rs-tab-item>
</rs-tab>
<!-- <rs-tab-item title="Role Tree">
<div v-for="(value, index) in roleListbyUser">
{{ value }}
</div>
</rs-tab-item> -->
</div>
</rs-card>
<rs-modal
:title="modalType == 'edit' ? 'Edit Role' : 'Add Role'"
ok-title="Save"
:ok-callback="saveRole"
v-model="showModal"
:overlay-close="false"
>
<FormKit
type="text"
v-model="showModalForm.name"
label="Name"
validation="required"
validation-visibility="live"
/>
<FormKit
type="textarea"
v-model="showModalForm.description"
label="Description"
/>
<div class="flex justify-between items-center mb-2">
<label
class="formkit-label font-semibold text-gray-700 dark:text-gray-200 blockfont-semibold text-sm formkit-invalid:text-red-500 dark:formkit-invalid:text-danger"
for="input_4"
>
Users
</label>
<rs-button size="sm" @click="openModalUser"> Add User </rs-button>
</div>
<v-select
class="formkit-vselect"
:options="roleUserList"
v-model="showModalForm.users"
multiple
></v-select>
<FormKit
type="checkbox"
v-model="checkAllUser"
label="All Users"
input-class="icon-check"
/>
<FormKit
type="select"
:options="statusDropdown"
v-model="showModalForm.status"
name="status"
label="Status"
/>
</rs-modal>
<!-- Modal Role -->
<rs-modal
title="Add User"
ok-title="Save"
cancel-title="Back"
:cancel-callback="closeModalUser"
:ok-callback="saveUser"
v-model="showModalUser"
:overlay-close="false"
>
<FormKit
type="text"
v-model="showModalUserForm.username"
name="username"
label="Username"
/>
<FormKit
type="text"
v-model="showModalUserForm.fullname"
name="fullname"
label="Fullname"
/>
<FormKit
type="text"
v-model="showModalUserForm.email"
name="email"
label="Email"
validation="email"
validation-visibility="dirty"
/>
<FormKit
type="mask"
v-model="showModalUserForm.phone"
name="phone"
label="Phone"
mask="###########"
/>
<FormKit
type="select"
:options="statusDropdown"
v-model="showModalUserForm.status"
name="status"
label="Status"
/>
</rs-modal>
<!-- Modal Delete Confirmation -->
<rs-modal
title="Delete Confirmation"
ok-title="Yes"
cancel-title="No"
:ok-callback="deleteRole"
v-model="showModalDelete"
:overlay-close="false"
>
<p>
Are you sure want to delete this role ({{ showModalDeleteForm.name }})?
</p>
</rs-modal>
</div>
</template>

View File

@@ -0,0 +1,497 @@
<script setup>
definePageMeta({
title: "User List",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal } = useNuxtApp();
const userList = ref([]);
const userRoleList = ref([]);
const showModal = ref(false);
const showModalForm = ref({
username: "",
fullname: "",
email: "",
phone: "",
password: "",
role: "",
status: "",
});
const modalType = ref("");
const showModalRole = ref(false);
const showModalRoleForm = ref({
role: "",
description: "",
});
const showModalDelete = ref(false);
const showModalDeleteForm = ref({
username: "",
});
const statusDropdown = ref([
{ label: "Active", value: "ACTIVE" },
{ label: "Inactive", value: "INACTIVE" },
]);
const checkAllRole = ref(false);
const userListbyRole = ref([]);
// Call API
// onMounted(async () => {
// await getUserList();
// await getRoleList();
// });
await getUserList();
await getRoleList();
async function getUserList() {
const { data } = await useFetch("/api/devtool/user/list", {
initialCache: false,
});
// Rename the key
if (data.value?.statusCode === 200) {
userList.value = data.value.data.map((user) => ({
username: user.userUsername,
fullname: user.userFullName,
email: user.userEmail,
phone: user.userPhone,
role: user.roles.map((r) => {
return {
label: r.role.roleName,
value: r.role.roleID,
};
}),
status: user.userStatus,
action: null,
}));
groupUserByRole();
}
}
async function getRoleList() {
const { data } = await useFetch("/api/devtool/role/list", {
initialCache: false,
});
if (data.value.statusCode === 200) {
userRoleList.value = data.value.data.map((role) => ({
label: role.roleName,
value: role.roleID,
}));
}
}
function roleWithComma(role) {
// Limit the number of role to 4 and add "..." if there are more than 4 role
const roleList = role.map((r) => r.label);
return roleList.length > 4
? roleList.slice(0, 4).join(", ") + "..."
: roleList.join(", ");
}
// Watch checkAllRole value
watch(
checkAllRole,
async (value) => {
if (value) {
showModalForm.value.role = userRoleList.value;
} else {
if (showModalForm.value.role.length === userRoleList.value.length) {
showModalForm.value.role = [];
}
}
},
{ immediate: true }
);
// Watch showModalForm.role value
watch(
showModalForm,
async (value) => {
if (value.role.length === userRoleList.value.length) {
checkAllRole.value = true;
} else {
checkAllRole.value = false;
}
},
{ deep: true }
);
// Open Modal Add or Edit User
const openModal = async (value, type) => {
modalType.value = type;
if (type == "edit") {
showModalForm.value.username = value.username;
showModalForm.value.fullname = value.fullname;
showModalForm.value.phone = value.phone;
showModalForm.value.email = value.email;
showModalForm.value.role = value.role;
showModalForm.value.status = value.status;
} else {
showModalForm.value.username = "";
showModalForm.value.fullname = "";
showModalForm.value.phone = "";
showModalForm.value.email = "";
showModalForm.value.role = "";
showModalForm.value.status = "";
}
showModalRole.value = false;
showModal.value = true;
};
// Open Modal Add Role
const openModalRole = async () => {
showModalRoleForm.value.role = "";
showModalRoleForm.value.description = "";
showModal.value = false;
showModalRole.value = true;
};
// Close Modal Role
const closeModalRole = () => {
showModalRole.value = false;
showModal.value = true;
};
// Open Modal Delete User
const openModalDelete = async (value) => {
showModalDeleteForm.value.username = value.username;
showModalDelete.value = true;
};
const checkDeveloperRole = (role) => {
return role.some((r) => r.label === "Developer");
};
const saveUser = async () => {
if (modalType.value == "add") {
const { data } = await useFetch("/api/devtool/user/add", {
initialCache: false,
method: "POST",
body: JSON.stringify({
...showModalForm.value,
module: "user",
}),
});
if (data.value.statusCode === 200) {
$swal.fire({
position: "center",
icon: "success",
title: "Success",
text: "User has been added",
timer: 1000,
showConfirmButton: false,
});
await getUserList();
showModal.value = false;
} else {
$swal.fire({
position: "center",
icon: "error",
title: "Error",
text: data.value.message,
});
}
} else {
const { data } = await useFetch("/api/devtool/user/edit", {
initialCache: false,
method: "POST",
body: JSON.stringify(showModalForm.value),
});
if (data.value.statusCode === 200) {
$swal.fire({
position: "center",
icon: "success",
title: "Success",
text: "User has been updated",
timer: 1000,
showConfirmButton: false,
});
await getUserList();
showModal.value = false;
} else {
$swal.fire({
position: "center",
icon: "error",
title: "Error",
text: data.value.message,
});
}
}
};
const deleteUser = async () => {
const { data } = await useFetch("/api/devtool/user/delete", {
initialCache: false,
method: "POST",
body: JSON.stringify({ username: showModalDeleteForm.value.username }),
});
if (data.value.statusCode === 200) {
$swal.fire({
position: "center",
icon: "success",
title: "Success",
text: "User has been deleted",
timer: 1000,
showConfirmButton: false,
});
await getUserList();
showModalDelete.value = false;
} else {
$swal.fire({
position: "center",
icon: "error",
title: "Error",
text: data.value.message,
});
}
};
const saveRole = async () => {
if (
showModalRoleForm.value.role == "" ||
showModalRoleForm.value.role == " "
) {
return false;
}
const { data } = await useFetch("/api/devtool/role/add", {
method: "POST",
body: JSON.stringify({
name: showModalRoleForm.value.role,
description: showModalRoleForm.value.description,
module: "user",
}),
});
if (data.value.statusCode === 200) {
$swal.fire({
position: "center",
title: "Success",
text: data.value.message,
icon: "success",
timer: 1000,
showConfirmButton: false,
});
await getRoleList();
closeModalRole();
} else {
$swal.fire({
position: "center",
title: "Error",
text: data.value.message,
icon: "error",
});
}
};
function groupUserByRole() {
userListbyRole.value = userList.value.reduce((acc, cur) => {
const { role } = cur;
if (!acc[role.value]) {
acc[role.value] = {
role: role,
users: [],
};
}
acc[role.value].users.push(cur);
return acc;
}, {});
}
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<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">
This page is only accessible by admin users. You can manage users
here. You can also add new users. You can also change user roles.
</p>
</template>
</rs-card>
<rs-card>
<div class="pt-2">
<rs-tab fill>
<rs-tab-item title="All User">
<div class="flex justify-end items-center mb-4">
<rs-button @click="openModal(null, 'add')">
<Icon name="material-symbols:add" class="mr-1"></Icon>
Add User
</rs-button>
</div>
<rs-table
v-if="userList && userList.length > 0"
:data="userList"
:options="{
variant: 'default',
striped: true,
borderless: true,
}"
advanced
>
<template v-slot:role="data">
<!-- {{ data.text?.label }} -->
{{ roleWithComma(data.text) }}
</template>
<template v-slot:action="data">
<div
class="flex justify-center items-center"
v-if="!checkDeveloperRole(data.value.role)"
>
<Icon
name="material-symbols:edit-outline-rounded"
class="text-primary hover:text-primary/90 cursor-pointer mr-1"
size="22"
@click="openModal(data.value, 'edit')"
></Icon>
<Icon
name="material-symbols:close-rounded"
class="text-primary hover:text-primary/90 cursor-pointer"
size="22"
@click="openModalDelete(data.value)"
></Icon>
</div>
<div class="flex justify-center items-center" v-else>-</div>
</template>
</rs-table>
</rs-tab-item>
</rs-tab>
<!-- <rs-tab-item title="User Tree">
<div v-for="(value, index) in userListbyRole">
{{ value }}
</div>
</rs-tab-item> -->
</div>
</rs-card>
<rs-modal
:title="modalType == 'edit' ? 'Edit User' : 'Add User'"
ok-title="Save"
:ok-callback="saveUser"
v-model="showModal"
:overlay-close="false"
>
<FormKit
type="text"
v-model="showModalForm.username"
name="username"
label="Username"
:disabled="modalType == 'edit' ? true : false"
/>
<FormKit
type="text"
v-model="showModalForm.fullname"
name="fullname"
label="Fullname"
/>
<FormKit
type="text"
v-model="showModalForm.email"
name="email"
label="Email"
validation="email"
validation-visibility="dirty"
/>
<FormKit
type="mask"
v-model="showModalForm.phone"
name="phone"
label="Phone"
mask="###########"
/>
<div class="flex justify-between items-center mb-2">
<label
class="formkit-label flex items-center gap-x-4 font-semibold text-gray-700 dark:text-gray-200 blockfont-semibold text-sm formkit-invalid:text-red-500 dark:formkit-invalid:text-danger"
for="input_4"
>
Role
</label>
<rs-button size="sm" @click="openModalRole"> Add Role </rs-button>
</div>
<v-select
class="formkit-vselect"
:options="userRoleList"
v-model="showModalForm.role"
multiple
></v-select>
<FormKit
type="checkbox"
v-model="checkAllRole"
label="All Role"
input-class="icon-check"
/>
<FormKit
type="select"
:options="statusDropdown"
v-model="showModalForm.status"
name="status"
label="Status"
/>
</rs-modal>
<!-- Modal Role -->
<rs-modal
title="Add Role"
ok-title="Save"
cancel-title="Back"
:cancel-callback="closeModalRole"
:ok-callback="saveRole"
v-model="showModalRole"
:overlay-close="false"
>
<FormKit
type="text"
v-model="showModalRoleForm.role"
label="Name"
validation="required"
validation-visibility="live"
/>
<FormKit
type="textarea"
v-model="showModalRoleForm.description"
label="Description"
/>
</rs-modal>
<!-- Modal Delete Confirmation -->
<rs-modal
title="Delete Confirmation"
ok-title="Yes"
cancel-title="No"
:ok-callback="deleteUser"
v-model="showModalDelete"
:overlay-close="false"
>
<p>
Are you sure want to delete this user ({{
showModalDeleteForm.username
}})?
</p>
</rs-modal>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup>
const { siteSettings, loading: siteSettingsLoading } = useSiteSettings();
definePageMeta({
title: "Reset Password",
layout: "empty",
middleware: ["dashboard"],
});
const email = ref("");
// Get login logo with fallback
const getLoginLogo = () => {
if (siteSettingsLoading.value) {
return '/img/logo/corradAF-logo.svg';
}
return siteSettings.value?.siteLoginLogo || '/img/logo/corradAF-logo.svg';
};
// Get site name with fallback
const getSiteName = () => {
if (siteSettingsLoading.value) {
return 'Login Logo';
}
return siteSettings.value?.siteName || 'Login Logo';
};
const changePassword = () => {
// Simulate password change request without API call
console.log("Password change requested for email:", email.value);
// Add your password change logic here
};
</script>
<template>
<div
class="flex-none md:flex justify-center text-center items-center h-screen"
>
<div class="w-full md:w-3/4 lg:w-1/2 xl:w-2/6 relative">
<rs-card class="h-screen md:h-auto px-10 md:px-16 py-12 md:py-20 mb-0">
<div class="text-center mb-8">
<div class="img-container flex justify-center items-center mb-5">
<img
:src="getLoginLogo()"
:alt="getSiteName()"
class="max-w-[180px] max-h-[60px] object-contain"
@error="$event.target.src = '/img/logo/corradAF-logo.svg'"
/>
</div>
<h2 class="mt-4 text-2xl font-bold text-gray-700">
Tukar kata laluan
</h2>
<p class="text-sm text-gray-500">
Kata laluan sementara akan dihantar ke emel anda.
</p>
</div>
<FormKit type="form" :actions="false" @submit="changePassword">
<FormKit
type="email"
name="email"
placeholder="Sila masukkan emel anda"
validation="required|email"
:validation-messages="{
required: 'Emel wajib diisi',
email: 'Format emel tidak sah',
}"
>
<template #prefix>
<Icon
name="ph:envelope"
class="!w-5 !h-5 ml-3 text-gray-500"
></Icon>
</template>
</FormKit>
</FormKit>
<rs-button @click="navigateTo('reset-password')" class="w-full mt-6">
<Icon name="ph:lock-fill" class="mr-2" />
Tukar kata laluan
</rs-button>
<div class="mt-4">
Kembali ke
<nuxt-link to="/login" class="text-sm text-blue-500">
log masuk
</nuxt-link>
</div>
</rs-card>
</div>
</div>
</template>
<style scoped>
/* Add any additional component-specific styles here */
</style>

10
pages/index.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup>
definePageMeta({
title: "Main",
middleware: ["main"],
});
</script>
<template>
<div>Redirect Dashboard</div>
</template>

View File

@@ -0,0 +1,702 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useDebounceFn } from '~/composables/useDebounceFn';
definePageMeta({
title: "Form Input Standards",
breadcrumb: [
{
name: "Kitchen Sink",
path: "/kitchen-sink",
},
{
name: "Form Input Standards",
type: "current",
},
],
});
// Calendar examples state
const singleDate = ref(null);
const startDate = ref(null);
const endDate = ref(null);
const disabledDate = ref(null);
const workingDaysOnly = ref(null);
const errorState = ref(null);
// Restrict dates for end date
const minEndDate = computed(() => {
return startDate.value ? startDate.value : null;
});
// Function to disable weekend dates
const disableWeekends = (date) => {
const day = new Date(date).getDay();
return day === 0 || day === 6; // 0 is Sunday, 6 is Saturday
};
// Set a date to make the range invalid for demonstration
const setInvalidRange = () => {
startDate.value = new Date(new Date().setDate(new Date().getDate() + 5));
endDate.value = new Date(new Date().setDate(new Date().getDate() + 2));
errorState.value = "Tarikh tamat tidak boleh sebelum tarikh mula";
};
// Reset calendar inputs
const resetCalendars = () => {
singleDate.value = null;
startDate.value = null;
endDate.value = null;
disabledDate.value = null;
workingDaysOnly.value = null;
errorState.value = null;
};
// Search examples state
const basicSearchTerm = ref('');
const validatedSearchTerm = ref('');
const isSearching = ref(false);
const searchResults = ref([]);
const noResultsFound = ref(false);
const searchError = ref('');
// Debounced search function
const debouncedSearch = useDebounceFn(() => {
performSearch();
}, 300);
// Perform search with validation
const performSearch = () => {
// Reset states
searchError.value = '';
isSearching.value = true;
noResultsFound.value = false;
// Validate search term
if (validatedSearchTerm.value.length < 3) {
searchError.value = 'Sila masukkan sekurang-kurangnya 3 aksara';
isSearching.value = false;
searchResults.value = [];
return;
}
// Regex validation - only allow alphanumeric and some symbols
const validPattern = /^[a-zA-Z0-9\-\s]+$/;
if (!validPattern.test(validatedSearchTerm.value)) {
searchError.value = 'Hanya aksara abjad angka dan tanda sempang dibenarkan';
isSearching.value = false;
searchResults.value = [];
return;
}
// Simulate search delay
setTimeout(() => {
// Mock results based on search term
if (validatedSearchTerm.value.toLowerCase().includes('ahmad')) {
searchResults.value = [
{ id: 'APP-001', name: 'Ahmad bin Hassan', status: 'LULUS' },
{ id: 'APP-008', name: 'Ahmad Zulkifli', status: 'MENUNGGU' },
];
} else if (validatedSearchTerm.value.toLowerCase().includes('app')) {
searchResults.value = [
{ id: 'APP-001', name: 'Ahmad bin Hassan', status: 'LULUS' },
{ id: 'APP-002', name: 'Siti Aminah', status: 'MENUNGGU' },
];
} else {
searchResults.value = [];
noResultsFound.value = true;
}
isSearching.value = false;
}, 800);
};
// Execute search on button click
const executeSearch = () => {
performSearch();
};
// Clear search
const clearSearch = () => {
validatedSearchTerm.value = '';
searchResults.value = [];
noResultsFound.value = false;
searchError.value = '';
};
// For controlled search input, watch for changes and apply debounce
watch(validatedSearchTerm, (newVal) => {
if (newVal.length >= 3) {
debouncedSearch();
} else {
searchResults.value = [];
noResultsFound.value = false;
}
});
</script>
<template>
<div class="container mx-auto px-4 py-6">
<div class="mb-8">
<h1 class="text-2xl font-bold text-primary mb-2">Form Input Standards</h1>
<p class="text-gray-600">Documentation for calendar inputs and search field standards</p>
</div>
<!-- Calendar Input Standards Section -->
<div class="mb-12">
<div class="border-b border-gray-200 pb-2 mb-6">
<h2 class="text-xl font-semibold text-gray-800">5.4 Calendar Input Standards</h2>
</div>
<!-- Calendar Display and Layout -->
<div class="mb-8">
<h3 class="text-lg font-medium text-gray-700 mb-4">5.4.1 Calendar Display and Layout</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Default Calendar -->
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="mb-3">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Default Calendar</h4>
<p class="text-xs text-gray-500 mb-3">Displays current month with today highlighted</p>
</div>
<div class="form-control">
<label class="form-label">Pilih Tarikh</label>
<input
type="date"
v-model="singleDate"
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
placeholder="dd/mm/yyyy"
/>
</div>
</div>
<!-- Disabled Dates Calendar -->
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="mb-3">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Disabled Dates</h4>
<p class="text-xs text-gray-500 mb-3">Past dates are disabled (future-only selection)</p>
</div>
<div class="form-control">
<label class="form-label">Pilih Tarikh Masa Hadapan</label>
<input
type="date"
v-model="disabledDate"
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
:min="new Date()"
/>
</div>
</div>
<!-- Working Days Only Calendar -->
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="mb-3">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Working Days Only</h4>
<p class="text-xs text-gray-500 mb-3">Weekend dates (Saturday & Sunday) are disabled</p>
</div>
<div class="form-control">
<label class="form-label">Pilih Hari Bekerja</label>
<input
type="date"
v-model="workingDaysOnly"
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<div class="text-xs text-gray-500 mt-1">(HTML date inputs don't support disabling specific dates)</div>
</div>
</div>
<!-- Error State Calendar -->
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="mb-3">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Error State</h4>
<p class="text-xs text-gray-500 mb-3">Calendar displaying validation error</p>
</div>
<div class="form-control">
<label class="form-label">Pilih Tarikh</label>
<input
type="date"
v-model="singleDate"
class="border border-red-500 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-red-500/50"
placeholder="dd/mm/yyyy"
/>
<div class="text-red-500 text-xs mt-1">Sila pilih tarikh yang sah</div>
</div>
</div>
</div>
</div>
<!-- Start and End Date Usage -->
<div class="mb-8">
<h3 class="text-lg font-medium text-gray-700 mb-4">5.4.2 Start and End Date Usage</h3>
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="mb-4">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Date Range Selection</h4>
<p class="text-xs text-gray-500 mb-3">End date is restricted based on start date selection</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Start Date -->
<div class="form-control">
<label class="form-label">Tarikh Mula</label>
<input
type="date"
v-model="startDate"
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
</div>
<!-- End Date -->
<div class="form-control">
<label class="form-label">Tarikh Tamat</label>
<input
type="date"
v-model="endDate"
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
:min="minEndDate"
:disabled="!startDate"
/>
<div v-if="!startDate" class="text-xs text-gray-500 mt-1">Sila pilih tarikh mula terlebih dahulu</div>
</div>
</div>
<!-- Invalid Range Example -->
<div class="mt-6">
<div class="mb-3">
<h4 class="text-sm font-semibold text-gray-600">Invalid Date Range Example</h4>
<p class="text-xs text-gray-500 mb-2">Demonstrates error state when end date precedes start date</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="form-control">
<label class="form-label">Tarikh Mula</label>
<input
type="text"
:value="startDate ? new Date(startDate).toLocaleDateString('ms-MY') : ''"
class="border border-gray-300 rounded-md px-3 py-2 w-full bg-gray-50"
readonly
/>
</div>
<div class="form-control">
<label class="form-label">Tarikh Tamat</label>
<input
type="text"
:value="endDate ? new Date(endDate).toLocaleDateString('ms-MY') : ''"
class="border border-red-500 rounded-md px-3 py-2 w-full bg-gray-50"
readonly
/>
<div class="text-red-500 text-xs mt-1">{{ errorState }}</div>
</div>
</div>
<div class="mt-4 flex space-x-2">
<button @click="setInvalidRange" class="px-3 py-1.5 bg-red-100 text-red-600 rounded-md text-sm">
Tunjuk Ralat Julat
</button>
<button @click="resetCalendars" class="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-md text-sm">
Set Semula
</button>
</div>
</div>
</div>
</div>
<!-- Visual States and Responsiveness -->
<div class="mb-8">
<h3 class="text-lg font-medium text-gray-700 mb-4">5.4.3 Visual States and Responsiveness</h3>
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="mb-4">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Calendar Visual States</h4>
<p class="text-xs text-gray-500 mb-3">Different visual states for calendar inputs</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Default State -->
<div class="form-control">
<label class="form-label">Default (Placeholder)</label>
<input
type="text"
placeholder="dd/mm/yyyy"
class="border border-gray-300 rounded-md px-3 py-2 w-full"
/>
<div class="text-xs text-gray-500 mt-1">Placeholder visible, no value selected</div>
</div>
<!-- Selected State -->
<div class="form-control">
<label class="form-label">Selected</label>
<input
type="text"
value="12/05/2023"
class="border border-gray-300 rounded-md px-3 py-2 w-full bg-white"
/>
<div class="text-xs text-gray-500 mt-1">Date is selected and displayed</div>
</div>
<!-- Disabled State -->
<div class="form-control">
<label class="form-label">Disabled</label>
<input
type="text"
placeholder="dd/mm/yyyy"
class="border border-gray-200 rounded-md px-3 py-2 w-full bg-gray-100 text-gray-400 cursor-not-allowed"
disabled
/>
<div class="text-xs text-gray-500 mt-1">Input is disabled, no interaction</div>
</div>
<!-- Focused State -->
<div class="form-control">
<label class="form-label">Focused</label>
<input
type="text"
placeholder="dd/mm/yyyy"
class="border-2 border-primary rounded-md px-3 py-2 w-full"
/>
<div class="text-xs text-gray-500 mt-1">Input is focused, calendar would open</div>
</div>
<!-- Error State -->
<div class="form-control">
<label class="form-label">Error</label>
<input
type="text"
value="31/02/2023"
class="border border-red-500 rounded-md px-3 py-2 w-full"
/>
<div class="text-red-500 text-xs mt-1">Tarikh tidak sah</div>
</div>
<!-- Mobile View Simulation -->
<div class="form-control">
<label class="form-label">Mobile View</label>
<div class="border border-dashed border-gray-300 rounded-md p-3 bg-gray-50 text-center">
<div class="text-sm text-gray-500 mb-2">Sistem kalendar natif</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<div class="text-xs text-gray-500 mt-2">Paparan modal penuh skrin pada peranti mudah alih</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Search Field Behavior Standards Section -->
<div class="mb-12">
<div class="border-b border-gray-200 pb-2 mb-6">
<h2 class="text-xl font-semibold text-gray-800">5.5 Search Field Behavior Standards</h2>
</div>
<!-- Controlled Input Trigger -->
<div class="mb-8">
<h3 class="text-lg font-medium text-gray-700 mb-4">5.5.1 Controlled Input Trigger</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Button Triggered Search -->
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="mb-3">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Button Triggered Search</h4>
<p class="text-xs text-gray-500 mb-3">Search executed only on button click</p>
</div>
<div class="flex">
<input
type="text"
v-model="basicSearchTerm"
placeholder="Cari nama, emel atau ID permohonan"
class="border border-gray-300 rounded-l-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<button
@click="executeSearch"
class="bg-primary text-white px-4 py-2 rounded-r-md hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<Icon name="ic:baseline-search" class="text-lg" />
</button>
</div>
<div class="text-xs text-gray-500 mt-2">Carian hanya dilaksanakan apabila butang diklik</div>
</div>
<!-- Debounced Search -->
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="mb-3">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Debounced Search (300ms)</h4>
<p class="text-xs text-gray-500 mb-3">Auto-triggers after 3 characters with 300ms debounce</p>
</div>
<div class="relative">
<div class="flex">
<div class="relative flex-grow">
<input
type="text"
v-model="validatedSearchTerm"
placeholder="Cari nama, emel atau ID permohonan"
class="border border-gray-300 rounded-l-md pl-3 pr-8 py-2 w-full focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
:class="{ 'border-red-500': searchError }"
/>
<button
v-if="validatedSearchTerm"
@click="clearSearch"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<Icon name="ic:baseline-close" class="text-lg" />
</button>
</div>
<button
class="bg-primary text-white px-4 py-2 rounded-r-md hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<Icon v-if="!isSearching" name="ic:baseline-search" class="text-lg" />
<span v-else class="animate-spin block h-5 w-5">
<Icon name="ic:baseline-refresh" class="text-lg" />
</span>
</button>
</div>
<div v-if="searchError" class="text-red-500 text-xs mt-1">{{ searchError }}</div>
<div v-else class="text-xs text-gray-500 mt-1">Carian bermula selepas 3 aksara dimasukkan</div>
</div>
<!-- Results display -->
<div v-if="searchResults.length > 0" class="mt-4 border rounded-md divide-y">
<div v-for="result in searchResults" :key="result.id" class="p-2 hover:bg-gray-50">
<div class="flex justify-between items-center">
<div>
<div class="font-medium">{{ result.name }}</div>
<div class="text-xs text-gray-500">{{ result.id }}</div>
</div>
<div>
<rs-badge :type="result.status.toLowerCase()">{{ result.status }}</rs-badge>
</div>
</div>
</div>
</div>
<div v-else-if="noResultsFound" class="mt-4 p-3 text-center bg-gray-50 rounded-md">
<Icon name="ic:baseline-search-off" class="text-2xl text-gray-400" />
<div class="text-gray-500 mt-1">Tiada padanan ditemui</div>
</div>
<div v-else-if="isSearching" class="mt-4 p-3 text-center bg-gray-50 rounded-md">
<div class="animate-pulse">
<div class="h-4 bg-gray-200 rounded-full mb-2.5 w-3/4 mx-auto"></div>
<div class="h-3 bg-gray-200 rounded-full mb-2.5 w-1/2 mx-auto"></div>
<div class="h-3 bg-gray-200 rounded-full w-2/3 mx-auto"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Pattern Matching and Validation -->
<div class="mb-8">
<h3 class="text-lg font-medium text-gray-700 mb-4">5.5.2 Pattern Matching and Validation</h3>
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="mb-4">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Search Validation Rules</h4>
<p class="text-xs text-gray-500 mb-3">Pattern matching and validation for search inputs</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Validation: Minimum Characters -->
<div class="form-control border border-gray-200 rounded-md p-3">
<h5 class="text-sm font-medium text-gray-600 mb-2">Minimum 3 Aksara</h5>
<div class="flex">
<input
type="text"
value="ab"
class="border border-red-500 rounded-l-md px-3 py-2 w-full"
/>
<button
class="bg-primary text-white px-4 py-2 rounded-r-md opacity-70 cursor-not-allowed"
disabled
>
<Icon name="ic:baseline-search" class="text-lg" />
</button>
</div>
<div class="text-red-500 text-xs mt-1">Sila masukkan sekurang-kurangnya 3 aksara</div>
</div>
<!-- Validation: Valid Pattern -->
<div class="form-control border border-gray-200 rounded-md p-3">
<h5 class="text-sm font-medium text-gray-600 mb-2">Abjad Angka Sahaja</h5>
<div class="flex">
<input
type="text"
value="app@123"
class="border border-red-500 rounded-l-md px-3 py-2 w-full"
/>
<button
class="bg-primary text-white px-4 py-2 rounded-r-md opacity-70 cursor-not-allowed"
disabled
>
<Icon name="ic:baseline-search" class="text-lg" />
</button>
</div>
<div class="text-red-500 text-xs mt-1">Hanya aksara abjad angka dan tanda sempang dibenarkan</div>
</div>
<!-- Valid Search Examples -->
<div class="form-control border border-gray-200 rounded-md p-3 col-span-1 md:col-span-2">
<h5 class="text-sm font-medium text-gray-600 mb-2">Contoh Carian Sah</h5>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<div class="text-xs font-medium text-gray-500 mb-1">Nama Penuh</div>
<div class="border border-gray-300 rounded-md px-3 py-2 bg-gray-50 text-sm">
Ahmad bin Hassan
</div>
</div>
<div>
<div class="text-xs font-medium text-gray-500 mb-1">ID Aplikasi</div>
<div class="border border-gray-300 rounded-md px-3 py-2 bg-gray-50 text-sm">
APP-2023-001
</div>
</div>
<div>
<div class="text-xs font-medium text-gray-500 mb-1">Nombor Kad Pengenalan</div>
<div class="border border-gray-300 rounded-md px-3 py-2 bg-gray-50 text-sm">
890512-14-5566
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Interface Feedback and UX -->
<div class="mb-8">
<h3 class="text-lg font-medium text-gray-700 mb-4">5.5.3 Interface Feedback and UX</h3>
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="mb-4">
<h4 class="text-sm font-semibold text-gray-600 mb-1">Search Feedback States</h4>
<p class="text-xs text-gray-500 mb-3">Visual feedback for different search states</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Loading State -->
<div class="border border-gray-200 rounded-md p-3">
<h5 class="text-sm font-medium text-gray-600 mb-2">Loading State</h5>
<div class="flex mb-3">
<input
type="text"
value="Ahmad"
class="border border-gray-300 rounded-l-md px-3 py-2 w-full"
readonly
/>
<button
class="bg-primary text-white px-4 py-2 rounded-r-md"
>
<span class="animate-spin block h-5 w-5">
<Icon name="ic:baseline-refresh" class="text-lg" />
</span>
</button>
</div>
<div class="p-4 bg-gray-50 rounded-md">
<div class="animate-pulse flex space-x-4">
<div class="flex-1 space-y-3 py-1">
<div class="h-2.5 bg-gray-200 rounded-full"></div>
<div class="h-2 bg-gray-200 rounded-full w-3/4"></div>
</div>
</div>
</div>
</div>
<!-- No Results State -->
<div class="border border-gray-200 rounded-md p-3">
<h5 class="text-sm font-medium text-gray-600 mb-2">No Results State</h5>
<div class="flex mb-3">
<div class="relative flex-grow">
<input
type="text"
value="XYZ123"
class="border border-gray-300 rounded-l-md pl-3 pr-8 py-2 w-full"
readonly
/>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<Icon name="ic:baseline-close" class="text-lg" />
</button>
</div>
<button
class="bg-primary text-white px-4 py-2 rounded-r-md"
>
<Icon name="ic:baseline-search" class="text-lg" />
</button>
</div>
<div class="p-4 bg-gray-50 rounded-md text-center">
<Icon name="ic:baseline-search-off" class="text-2xl text-gray-400" />
<div class="text-gray-500 mt-1">Tiada padanan ditemui</div>
</div>
</div>
<!-- Results State -->
<div class="border border-gray-200 rounded-md p-3 col-span-1 md:col-span-2">
<h5 class="text-sm font-medium text-gray-600 mb-2">Results Display</h5>
<div class="flex mb-3">
<div class="relative flex-grow">
<input
type="text"
value="Ahmad"
class="border border-gray-300 rounded-l-md pl-3 pr-8 py-2 w-full"
readonly
/>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<Icon name="ic:baseline-close" class="text-lg" />
</button>
</div>
<button
class="bg-primary text-white px-4 py-2 rounded-r-md"
>
<Icon name="ic:baseline-search" class="text-lg" />
</button>
</div>
<div class="border rounded-md divide-y">
<div class="p-2 hover:bg-gray-50">
<div class="flex justify-between items-center">
<div>
<div class="font-medium">Ahmad bin Hassan</div>
<div class="text-xs text-gray-500">APP-001</div>
</div>
<div>
<rs-badge type="lulus">LULUS</rs-badge>
</div>
</div>
</div>
<div class="p-2 hover:bg-gray-50">
<div class="flex justify-between items-center">
<div>
<div class="font-medium">Ahmad Zulkifli</div>
<div class="text-xs text-gray-500">APP-008</div>
</div>
<div>
<rs-badge type="menunggu">MENUNGGU</rs-badge>
</div>
</div>
</div>
<div class="p-2 hover:bg-gray-50">
<div class="flex justify-between items-center">
<div>
<div class="font-medium">Nurul Ahmad</div>
<div class="text-xs text-gray-500">APP-015</div>
</div>
<div>
<rs-badge type="ditolak">DITOLAK</rs-badge>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

3255
pages/kitchen-sink/index.vue Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

179
pages/login/index.vue Normal file
View File

@@ -0,0 +1,179 @@
<script setup>
import { useUserStore } from "~/stores/user";
import { RecaptchaV2 } from "vue3-recaptcha-v2";
definePageMeta({
title: "Login",
layout: "empty",
middleware: ["dashboard"],
});
const { $swal } = useNuxtApp();
const { siteSettings, loading: siteSettingsLoading } = useSiteSettings();
const username = ref("");
const password = ref("");
const userStore = useUserStore();
const togglePasswordVisibility = ref(false);
// Get login logo with fallback
const getLoginLogo = () => {
if (siteSettingsLoading.value) {
return "/img/logo/corradAF-logo.svg";
}
return siteSettings.value?.siteLoginLogo || "/img/logo/corradAF-logo.svg";
};
// Get site name with fallback
const getSiteName = () => {
if (siteSettingsLoading.value) {
return "Login Logo";
}
return siteSettings.value?.siteName || "Login Logo";
};
const login = async () => {
try {
const res = await useFetch("/api/auth/login", {
method: "POST",
initialCache: false,
body: JSON.stringify({
username: username.value,
password: password.value,
}),
});
const data = res.data.value;
if (data.statusCode === 200) {
// Save token to pinia store
userStore.setUsername(data.data.username);
userStore.setRoles(data.data.roles);
userStore.setIsAuthenticated(true);
$swal.fire({
position: "center",
title: "Success",
text: "Login Success",
icon: "success",
timer: 2000,
showConfirmButton: false,
});
window.location.href = "/dashboard";
} else {
$swal.fire({
title: "Error!",
text: data.message,
icon: "error",
});
}
} catch (e) {
console.log(e);
}
};
const handleWidgetId = (widgetId) => {
console.log("Widget ID: ", widgetId);
};
const handleErrorCalback = () => {
console.log("Error callback");
};
const handleExpiredCallback = () => {
console.log("Expired callback");
};
const handleLoadCallback = (response) => {
console.log("Load callback", response);
};
</script>
<template>
<div class="flex-none md:flex justify-center text-center items-center h-screen">
<div class="w-full md:w-3/4 lg:w-1/2 xl:w-2/6 relative">
<rs-card class="h-screen md:h-auto px-10 md:px-16 py-12 md:py-20 mb-0">
<div class="img-container flex justify-center items-center mb-5">
<img
src="@/assets/img/logo/lzs-logo.png"
class="max-w-[180px] max-h-[60px] object-contain"
/>
</div>
<p class="text-slate-500 text-lg mb-6">Log masuk ke akaun anda</p>
<div class="grid grid-cols-2">
<FormKit
type="text"
v-model="username"
validation="required"
placeholder="Masukkan ID Pengguna"
:classes="{
outer: 'col-span-2',
label: 'text-left',
messages: 'text-left',
}"
:validation-messages="{
required: 'ID Pengguna wajib diisi.',
}"
>
<template #prefixIcon>
<Icon name="ph:user-fill" class="!w-5 !h-5 ml-3 text-gray-500"></Icon>
</template>
</FormKit>
<FormKit
:type="togglePasswordVisibility ? 'text' : 'password'"
v-model="password"
validation="required"
placeholder="Masukkan Kata Laluan"
:classes="{
outer: 'col-span-2',
label: 'text-left',
messages: 'text-left',
}"
:validation-messages="{
required: 'Kata Laluan wajib diisi.',
}"
>
<template #prefixIcon>
<Icon name="ph:lock-key-fill" class="!w-5 !h-5 ml-3 text-gray-500"></Icon>
</template>
<template #suffix>
<div
class="bg-gray-100 hover:bg-slate-200 dark:bg-slate-700 hover:dark:bg-slate-900 h-full rounded-r-md p-3 flex justify-center items-center cursor-pointer"
@click="togglePasswordVisibility = !togglePasswordVisibility"
>
<Icon
v-if="!togglePasswordVisibility"
name="ion:eye-outline"
size="19"
></Icon>
<Icon v-else name="ion:eye-off-outline" size="19"></Icon>
</div>
</template>
</FormKit>
<div class="col-span-2 mb-4">
<RecaptchaV2
@widget-id="handleWidgetId"
@error-callback="handleErrorCalback"
@expired-callback="handleExpiredCallback"
@load-callback="handleLoadCallback"
/>
</div>
<NuxtLink
class="col-span-2 flex items-center justify-end h-5 mt-1 text-primary hover:underline mb-5"
to="forgot-password"
>
Lupa Kata Laluan?
</NuxtLink>
<FormKit
type="button"
input-class="w-full"
outer-class="col-span-2"
@click="login"
>
Log Masuk
<Icon name="ph:caret-circle-right" class="!w-5 !h-5 ml-1"></Icon>
</FormKit>
</div>
</rs-card>
</div>
</div>
</template>

28
pages/logout/index.vue Normal file
View File

@@ -0,0 +1,28 @@
<script setup>
import { useUserStore } from "~/stores/user";
definePageMeta({
title: "Logout",
layout: "empty",
});
const userStore = useUserStore();
await useFetch("/api/auth/logout", {
method: "GET",
});
if (process.client) {
userStore.setUsername("");
userStore.setRoles([]);
userStore.setIsAuthenticated(false);
navigateTo("/login");
}
</script>
<template>
<div>
<h1>Logout</h1>
</div>
</template>

View File

@@ -0,0 +1,898 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Info Card -->
<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>
Create Notification
</div>
</template>
<template #body>
<p class="mb-4">
Create and send notifications to your audience. Configure basic settings and
choose from multiple channels including email and push notifications.
</p>
</template>
</rs-card>
<!-- Main Form Card -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">
{{ isEditMode ? "Edit" : "Create" }} Notification
</h2>
<div class="text-sm text-gray-600 dark:text-gray-400">
Step {{ currentStep }} of {{ totalSteps }}
</div>
</div>
</template>
<template #body>
<div class="pt-2">
<!-- Step Progress Indicator -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div
v-for="(step, index) in steps"
:key="index"
class="flex items-center"
:class="{ 'flex-1': index < steps.length - 1 }"
>
<div class="flex items-center">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
:class="{
'bg-primary text-white': index + 1 <= currentStep,
'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400':
index + 1 > currentStep,
}"
>
<Icon
v-if="index + 1 < currentStep"
name="material-symbols:check"
class="text-sm"
/>
<span v-else>{{ index + 1 }}</span>
</div>
<span
class="ml-2 text-sm font-medium"
:class="{
'text-primary': index + 1 === currentStep,
'text-gray-900 dark:text-gray-100': index + 1 < currentStep,
'text-gray-500 dark:text-gray-400': index + 1 > currentStep,
}"
>
{{ step.title }}
</span>
</div>
<div
v-if="index < steps.length - 1"
class="flex-1 h-0.5 mx-4"
:class="{
'bg-primary': index + 1 < currentStep,
'bg-gray-200 dark:bg-gray-700': index + 1 >= currentStep,
}"
></div>
</div>
</div>
</div>
<FormKit
type="form"
@submit="submitNotification"
:actions="false"
class="w-full"
>
<!-- Step 1: Basic Settings -->
<div v-show="currentStep === 1" class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<FormKit
type="text"
name="title"
label="Notification Title"
placeholder="Enter notification title"
validation="required"
v-model="notificationForm.title"
help="This is for internal identification purposes"
/>
<FormKit
type="select"
name="type"
label="Notification Type"
:options="notificationTypes"
validation="required"
v-model="notificationForm.type"
help="Choose notification type"
/>
<FormKit
type="select"
name="priority"
label="Priority Level"
:options="priorityLevels"
validation="required"
v-model="notificationForm.priority"
help="Set the importance level of this notification"
/>
<FormKit
type="select"
name="category"
label="Category"
:options="categoryOptions"
validation="required"
v-model="notificationForm.category"
help="Categorize your notification for better organization"
/>
</div>
<!-- Right Column -->
<div class="space-y-4">
<FormKit
type="checkbox"
name="channels"
label="Delivery Channels"
:options="channelOptions"
validation="required|min:1"
v-model="notificationForm.channels"
decorator-icon="material-symbols:check"
options-class="grid grid-cols-1 gap-y-2 pt-1"
help="Select one or more delivery channels"
/>
<FormKit
v-if="notificationForm.channels.includes('email')"
type="text"
name="emailSubject"
label="Email Subject Line"
placeholder="Enter email subject"
validation="required"
v-model="notificationForm.emailSubject"
help="This will be the email subject line"
/>
<FormKit
type="radio"
name="deliveryType"
label="Delivery Schedule"
:options="deliveryTypes"
validation="required"
v-model="notificationForm.deliveryType"
decorator-icon="material-symbols:radio-button-checked"
options-class="space-y-3 pt-1"
/>
<FormKit
v-if="notificationForm.deliveryType === 'scheduled'"
type="datetime-local"
name="scheduledAt"
label="Scheduled Date & Time"
validation="required"
v-model="notificationForm.scheduledAt"
help="When should this notification be sent?"
/>
</div>
</div>
</div>
<!-- Step 2: Target Audience -->
<div v-show="currentStep === 2" class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<FormKit
type="radio"
name="audienceType"
label="Audience Selection"
:options="audienceTypes"
validation="required"
v-model="notificationForm.audienceType"
decorator-icon="material-symbols:radio-button-checked"
options-class="space-y-3 pt-1"
/>
<div
v-if="notificationForm.audienceType === 'specific'"
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
>
<FormKit
type="textarea"
name="specificUsers"
label="User IDs or Email Addresses"
placeholder="Enter user IDs or emails, one per line"
rows="4"
v-model="notificationForm.specificUsers"
help="Enter user IDs or email addresses, one per line"
/>
</div>
<div
v-if="notificationForm.audienceType === 'segmented'"
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
>
<FormKit
type="checkbox"
name="userSegments"
label="User Segments"
:options="userSegmentOptions"
v-model="notificationForm.userSegments"
decorator-icon="material-symbols:check"
options-class="grid grid-cols-1 gap-y-2 pt-1"
/>
</div>
</div>
<div class="space-y-4">
<div class="p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/20">
<h3 class="font-semibold text-blue-800 dark:text-blue-200 mb-2">
Audience Preview
</h3>
<p class="text-sm text-blue-600 dark:text-blue-300">
Estimated reach:
<span class="font-bold">{{ estimatedReach }}</span>
users
</p>
</div>
<FormKit
type="checkbox"
name="excludeUnsubscribed"
label="Exclude Unsubscribed Users"
v-model="notificationForm.excludeUnsubscribed"
help="Automatically exclude users who have unsubscribed"
/>
</div>
</div>
</div>
<!-- Step 3: Content -->
<div v-show="currentStep === 3" class="space-y-6">
<FormKit
type="radio"
name="contentType"
label="Content Source"
:options="contentTypes"
validation="required"
v-model="notificationForm.contentType"
decorator-icon="material-symbols:radio-button-checked"
options-class="flex gap-8 pt-1"
/>
<!-- Content Section -->
<div
v-if="notificationForm.contentType === 'template'"
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
>
<div class="flex justify-between items-center">
<h3 class="font-semibold">Select Notification Template</h3>
<rs-button
variant="outline"
size="sm"
@click="$router.push('/notification/templates')"
type="button"
>
<Icon name="material-symbols:library-books-outline" class="mr-1" />
Browse All Templates
</rs-button>
</div>
<div class="grid grid-cols-1 gap-4">
<!-- Template Selection -->
<div>
<FormKit
type="select"
name="selectedTemplate"
label="Select Template"
:options="templateOptions"
validation="required"
v-model="notificationForm.selectedTemplate"
help="Choose from existing notification templates"
:disabled="isLoadingTemplates"
/>
</div>
</div>
</div>
<!-- Create New Content Section -->
<div v-if="notificationForm.contentType === 'new'" class="space-y-4">
<div
v-if="notificationForm.channels.includes('push')"
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
>
<h3 class="font-semibold">Push Notification Content</h3>
<FormKit
type="text"
name="pushTitle"
label="Push Title"
placeholder="Enter push notification title"
validation="required|length:0,50"
v-model="notificationForm.pushTitle"
help="Maximum 50 characters"
/>
<FormKit
type="textarea"
name="pushBody"
label="Push Message"
placeholder="Enter push notification message"
validation="required|length:0,150"
rows="3"
v-model="notificationForm.pushBody"
help="Maximum 150 characters"
/>
</div>
<div
v-if="notificationForm.channels.includes('email')"
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
>
<h3 class="font-semibold">Email Content</h3>
<FormKit
type="textarea"
name="emailContent"
label="Email Body"
validation="required"
v-model="notificationForm.emailContent"
rows="8"
help="You can use HTML formatting"
/>
<FormKit
type="text"
name="callToActionText"
label="Call-to-Action Button Text (Optional)"
placeholder="e.g., Learn More, Get Started"
v-model="notificationForm.callToActionText"
/>
<FormKit
type="url"
name="callToActionUrl"
label="Call-to-Action URL (Optional)"
placeholder="https://example.com"
v-model="notificationForm.callToActionUrl"
/>
</div>
</div>
<!-- Preview -->
<div class="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
<h4 class="font-semibold mb-3">Send Test Notification</h4>
<div class="flex gap-4 items-end">
<FormKit
type="email"
name="testEmail"
label="Test Email Address"
placeholder="test@example.com"
v-model="testEmail"
outer-class="flex-1 mb-0"
/>
<rs-button
@click="sendTestNotification"
variant="outline"
type="button"
:disabled="isSending"
>
<Icon name="material-symbols:send" class="mr-1" />
Send Test
</rs-button>
</div>
</div>
</div>
<!-- Step Navigation -->
<div class="flex justify-between items-center mt-8 pt-6 border-t">
<div class="flex gap-3">
<rs-button
v-if="currentStep > 1"
type="button"
variant="outline"
@click="previousStep"
>
<Icon name="material-symbols:arrow-back" class="mr-1" />
Previous
</rs-button>
<rs-button
v-if="currentStep < totalSteps"
type="button"
variant="outline"
@click="saveDraftNotification"
:disabled="isSaving"
>
<Icon name="material-symbols:save-as-outline" class="mr-1" />
Save as Draft
</rs-button>
</div>
<div class="flex gap-3">
<rs-button
type="button"
variant="outline"
@click="$router.push('/notification/list')"
>
Cancel
</rs-button>
<rs-button
v-if="currentStep < totalSteps"
type="button"
@click="nextStep"
:disabled="!isCurrentStepValid"
:class="{
'opacity-50 cursor-not-allowed': !isCurrentStepValid,
}"
>
Next
<Icon name="material-symbols:arrow-forward" class="ml-1" />
</rs-button>
<rs-button
v-if="currentStep === totalSteps"
type="submit"
:disabled="!isFormValid || isSending"
:class="{ 'opacity-50 cursor-not-allowed': !isFormValid || isSending }"
@click="submitNotification"
>
<Icon name="material-symbols:send" class="mr-1" />
{{
notificationForm.deliveryType === "immediate"
? "Send Now"
: "Schedule Notification"
}}
</rs-button>
</div>
</div>
</FormKit>
</div>
</template>
</rs-card>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useNotifications } from "~/composables/useNotifications";
import { useDebounceFn } from "~/composables/useDebounceFn";
definePageMeta({
title: "Create Notification",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal } = useNuxtApp();
const router = useRouter();
const route = useRoute();
// Initialize notifications composable
const {
isLoading: notificationLoading,
createNotification,
updateNotification,
getNotificationById,
saveDraft,
testSendNotification,
getAudiencePreview,
} = useNotifications();
// Step management
const currentStep = ref(1);
const totalSteps = ref(3);
const steps = [
{ title: "Basic Settings", key: "basic" },
{ title: "Target Audience", key: "audience" },
{ title: "Content", key: "content" },
];
// Reactive data
const isEditMode = ref(!!route.query.id);
const testEmail = ref("");
const estimatedReachCount = ref(0);
// Loading states
const isLoadingTemplates = ref(false);
const isSaving = ref(false);
const isSending = ref(false);
// Form data
const notificationForm = ref({
title: "",
type: "single",
priority: "medium",
category: "",
channels: [],
emailSubject: "",
deliveryType: "immediate",
scheduledAt: "",
timezone: "UTC",
audienceType: "all",
specificUsers: "",
userSegments: [],
excludeUnsubscribed: true,
contentType: "new",
selectedTemplate: "",
pushTitle: "",
pushBody: "",
emailContent: "",
callToActionText: "",
callToActionUrl: "",
});
// Options for form fields
const notificationTypes = [
{ label: "Single Notification", value: "single" },
{ label: "Bulk Notification", value: "bulk" },
];
const priorityLevels = [
{ label: "Low", value: "low" },
{ label: "Medium", value: "medium" },
{ label: "High", value: "high" },
{ label: "Critical", value: "critical" },
];
// Dynamic options loaded from API
const categoryOptions = ref([
{ label: "System", value: "system" },
{ label: "Marketing", value: "marketing" },
{ label: "Transactional", value: "transactional" },
{ label: "Alerts", value: "alerts" },
{ label: "Updates", value: "updates" },
]);
const templateOptions = ref([]);
const channelOptions = [
{ label: "Email", value: "email" },
{ label: "Push Notification", value: "push" },
{ label: "SMS", value: "sms" },
];
const deliveryTypes = [
{ label: "Send Immediately", value: "immediate" },
{ label: "Schedule for Later", value: "scheduled" },
];
const audienceTypes = [
{ label: "All Users", value: "all" },
{ label: "Specific Users", value: "specific" },
{ label: "Segmented Users", value: "segmented" },
];
const userSegmentOptions = [
{ label: "New Users (< 30 days)", value: "new_users" },
{ label: "Active Users", value: "active_users" },
{ label: "Premium Subscribers", value: "premium_users" },
{ label: "Inactive Users", value: "inactive_users" },
];
const contentTypes = [
{ label: "Create New Content", value: "new" },
{ label: "Use Existing Template", value: "template" },
];
// Computed properties
const estimatedReach = computed(() => {
if (estimatedReachCount.value > 0) {
return estimatedReachCount.value.toLocaleString();
}
// Fallback calculation while API data loads
if (notificationForm.value.audienceType === "all") {
return "15,000";
} else if (notificationForm.value.audienceType === "specific") {
const lines = notificationForm.value.specificUsers
.split("\n")
.filter((line) => line.trim());
return lines.length.toLocaleString();
} else if (notificationForm.value.audienceType === "segmented") {
return "5,000";
}
return "0";
});
const isFormValid = computed(() => {
return (
notificationForm.value.title &&
notificationForm.value.type &&
notificationForm.value.priority &&
notificationForm.value.category &&
notificationForm.value.channels.length > 0 &&
notificationForm.value.audienceType &&
(notificationForm.value.contentType === "template"
? notificationForm.value.selectedTemplate
: (notificationForm.value.channels.includes("push")
? notificationForm.value.pushTitle && notificationForm.value.pushBody
: true) &&
(notificationForm.value.channels.includes("email")
? notificationForm.value.emailSubject && notificationForm.value.emailContent
: true))
);
});
// Computed properties for step validation
const isCurrentStepValid = computed(() => {
switch (currentStep.value) {
case 1: // Basic Settings
return (
notificationForm.value.title &&
notificationForm.value.type &&
notificationForm.value.priority &&
notificationForm.value.category &&
notificationForm.value.channels.length > 0 &&
(!notificationForm.value.channels.includes("email") ||
notificationForm.value.emailSubject) &&
(notificationForm.value.deliveryType === "immediate" ||
notificationForm.value.scheduledAt)
);
case 2: // Target Audience
return (
notificationForm.value.audienceType &&
(notificationForm.value.audienceType !== "specific" ||
notificationForm.value.specificUsers.trim())
);
case 3: // Content
return (
notificationForm.value.contentType &&
(notificationForm.value.contentType === "template"
? notificationForm.value.selectedTemplate
: (!notificationForm.value.channels.includes("push") ||
(notificationForm.value.pushTitle && notificationForm.value.pushBody)) &&
(!notificationForm.value.channels.includes("email") ||
notificationForm.value.emailContent))
);
default:
return false;
}
});
// API Methods
const loadTemplates = async () => {
try {
isLoadingTemplates.value = true;
const response = await $fetch("/api/notifications/templates");
if (response.success) {
templateOptions.value = response.data.templates.map((template) => ({
label: template.title,
value: template.value,
id: template.id,
subject: template.subject,
emailContent: template.email_content,
pushTitle: template.push_title,
pushBody: template.push_body,
}));
}
} catch (error) {
console.error("Error loading templates:", error);
$swal.fire("Error", "Failed to load templates", "error");
} finally {
isLoadingTemplates.value = false;
}
};
// Debounced function for audience calculation
const debouncedCalculateReach = useDebounceFn(calculateEstimatedReach, 500);
async function calculateEstimatedReach() {
try {
const requestBody = {
audienceType: notificationForm.value.audienceType,
specificUsers: notificationForm.value.specificUsers,
userSegments: notificationForm.value.userSegments,
excludeUnsubscribed: notificationForm.value.excludeUnsubscribed,
};
const response = await getAudiencePreview(requestBody);
if (response.success) {
estimatedReachCount.value = response.data.totalCount;
}
} catch (error) {
console.error("Error calculating estimated reach:", error);
// Don't show error to user for background calculation
}
}
// Methods
const sendTestNotification = async () => {
if (!testEmail.value) {
$swal.fire("Error", "Please enter a test email address", "error");
return;
}
try {
isSending.value = true;
const testData = {
email: testEmail.value,
testData: {
title: notificationForm.value.title,
channels: notificationForm.value.channels,
emailSubject: notificationForm.value.emailSubject,
emailContent: notificationForm.value.emailContent,
pushTitle: notificationForm.value.pushTitle,
pushBody: notificationForm.value.pushBody,
callToActionText: notificationForm.value.callToActionText,
callToActionUrl: notificationForm.value.callToActionUrl,
},
};
const response = await testSendNotification(testData);
if (response.success) {
$swal.fire("Success", `Test notification sent to ${testEmail.value}`, "success");
}
} catch (error) {
console.error("Error sending test notification:", error);
$swal.fire("Error", "Failed to send test notification", "error");
} finally {
isSending.value = false;
}
};
const saveDraftNotification = async () => {
try {
isSaving.value = true;
const response = await saveDraft(notificationForm.value);
if (response.success) {
$swal
.fire({
title: "Success",
text: "Notification saved as draft",
icon: "success",
confirmButtonText: "Continue Editing",
showCancelButton: true,
cancelButtonText: "Go to List",
})
.then((result) => {
if (!result.isConfirmed) {
router.push("/notification/list");
}
});
}
} catch (error) {
console.error("Error saving draft:", error);
$swal.fire("Error", "Failed to save draft", "error");
} finally {
isSaving.value = false;
}
};
const submitNotification = async () => {
if (!isFormValid.value) {
$swal.fire("Validation Error", "Please complete all required fields", "error");
return;
}
try {
isSending.value = true;
const response = isEditMode.value
? await updateNotification(route.query.id, notificationForm.value)
: await createNotification(notificationForm.value);
if (response.success) {
const actionText =
notificationForm.value.deliveryType === "immediate" ? "sent" : "scheduled";
$swal
.fire({
title: "Success!",
text: `Notification has been ${actionText} successfully.`,
icon: "success",
confirmButtonText: "View List",
})
.then((result) => {
if (result.isConfirmed) {
router.push("/notification/list");
}
});
}
} catch (error) {
console.error("Error creating notification:", error);
const errorMessage = error.data?.message || "Failed to process notification";
$swal.fire("Error", errorMessage, "error");
} finally {
isSending.value = false;
}
};
// Load notification data if in edit mode
const loadNotificationData = async (id) => {
try {
const notification = await getNotificationById(id);
if (notification) {
// Map notification data to form
notificationForm.value = {
title: notification.title,
type: notification.type,
priority: notification.priority,
category: notification.category_value,
channels: notification.channels.map((c) => c.channel_type),
emailSubject: notification.email_subject,
deliveryType: notification.delivery_type,
scheduledAt: notification.scheduled_at,
timezone: notification.timezone || "UTC",
audienceType: notification.audience_type,
specificUsers: notification.specific_users,
userSegments: notification.user_segments?.map((s) => s.value) || [],
excludeUnsubscribed: notification.exclude_unsubscribed,
contentType: notification.content_type,
selectedTemplate: notification.template_value,
pushTitle: notification.push_title,
pushBody: notification.push_body,
emailContent: notification.email_content,
callToActionText: notification.call_to_action_text,
callToActionUrl: notification.call_to_action_url,
};
// Update estimated reach
estimatedReachCount.value = notification.estimated_reach;
}
} catch (error) {
console.error("Error loading notification data:", error);
$swal.fire("Error", "Failed to load notification data", "error");
}
};
// Step navigation methods
const nextStep = () => {
if (currentStep.value < totalSteps.value && isCurrentStepValid.value) {
currentStep.value++;
window.scrollTo({ top: 0, behavior: "smooth" });
} else if (!isCurrentStepValid.value) {
$swal.fire(
"Incomplete Information",
"Please complete all required fields before proceeding",
"warning"
);
}
};
const previousStep = () => {
if (currentStep.value > 1) {
currentStep.value--;
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
// Watch for audience changes to calculate estimated reach
watch(
() => [
notificationForm.value.audienceType,
notificationForm.value.specificUsers,
notificationForm.value.userSegments,
notificationForm.value.excludeUnsubscribed,
],
() => {
// Use debounced function to avoid too many API calls
debouncedCalculateReach();
},
{ deep: true }
);
onMounted(async () => {
// Load templates
await loadTemplates();
// Load data if editing existing notification
if (isEditMode.value && route.query.id) {
await loadNotificationData(route.query.id);
}
});
</script>
<style scoped>
/* Add any custom styles if needed */
/* .formkit-input {
@apply 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;
} */
</style>

View File

@@ -0,0 +1,391 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Header -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-dashboard"></Icon>
<h1 class="text-xl font-bold text-primary">Notification Dashboard</h1>
</div>
<rs-button size="sm" @click="refreshData">
<Icon :name="isRefreshing ? 'ic:outline-refresh' : 'ic:outline-refresh'" :class="{ 'animate-spin': isRefreshing }" class="mr-1"></Icon>
Refresh
</rs-button>
</div>
</template>
<template #body>
<p class="text-gray-600">
Overview of your notification system performance and statistics.
</p>
</template>
</rs-card>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="32" />
</div>
<div v-else>
<!-- Overview Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<!-- Total Notifications -->
<rs-card class="transition-all duration-300 hover:shadow-lg">
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div class="p-5 flex justify-center items-center rounded-2xl bg-blue-100">
<Icon class="text-3xl text-blue-600" name="ic:outline-notifications"></Icon>
</div>
<div class="flex-1">
<span class="block font-bold text-2xl text-blue-600">
{{ overview.total || 0 }}
</span>
<span class="text-sm font-medium text-gray-600">Total Notifications</span>
</div>
</div>
</rs-card>
<!-- Sent Notifications -->
<rs-card class="transition-all duration-300 hover:shadow-lg">
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div class="p-5 flex justify-center items-center rounded-2xl bg-green-100">
<Icon class="text-3xl text-green-600" name="ic:outline-send"></Icon>
</div>
<div class="flex-1">
<span class="block font-bold text-2xl text-green-600">
{{ overview.sent || 0 }}
</span>
<span class="text-sm font-medium text-gray-600">Sent</span>
<div class="text-xs text-gray-500 mt-1">
<span v-if="overview.growthRate > 0" class="text-green-600">
{{ overview.growthRate }}%
</span>
<span v-else-if="overview.growthRate < 0" class="text-red-600">
{{ Math.abs(overview.growthRate) }}%
</span>
<span v-else class="text-gray-600"> 0%</span>
vs last week
</div>
</div>
</div>
</rs-card>
<!-- Scheduled Notifications -->
<rs-card class="transition-all duration-300 hover:shadow-lg">
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div class="p-5 flex justify-center items-center rounded-2xl bg-orange-100">
<Icon class="text-3xl text-orange-600" name="ic:outline-schedule"></Icon>
</div>
<div class="flex-1">
<span class="block font-bold text-2xl text-orange-600">
{{ overview.scheduled || 0 }}
</span>
<span class="text-sm font-medium text-gray-600">Scheduled</span>
</div>
</div>
</rs-card>
<!-- Delivery Rate -->
<rs-card class="transition-all duration-300 hover:shadow-lg">
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div class="p-5 flex justify-center items-center rounded-2xl bg-purple-100">
<Icon class="text-3xl text-purple-600" name="ic:outline-check-circle"></Icon>
</div>
<div class="flex-1">
<span class="block font-bold text-2xl text-purple-600">
{{ delivery.deliveryRate || 0 }}%
</span>
<span class="text-sm font-medium text-gray-600">Delivery Rate</span>
</div>
</div>
</rs-card>
</div>
<!-- Delivery Stats Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<!-- Total Recipients -->
<rs-card>
<div class="pt-5 pb-3 px-5">
<div class="flex items-center justify-between mb-2">
<span class="text-gray-600 text-sm">Total Recipients</span>
<Icon name="ic:outline-people" class="text-gray-400"></Icon>
</div>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-bold">{{ delivery.totalRecipients || 0 }}</span>
</div>
</div>
</rs-card>
<!-- Successful Deliveries -->
<rs-card>
<div class="pt-5 pb-3 px-5">
<div class="flex items-center justify-between mb-2">
<span class="text-gray-600 text-sm">Successful</span>
<Icon name="ic:outline-check" class="text-green-500"></Icon>
</div>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-bold text-green-600">{{ delivery.successful || 0 }}</span>
</div>
</div>
</rs-card>
<!-- Failed Deliveries -->
<rs-card>
<div class="pt-5 pb-3 px-5">
<div class="flex items-center justify-between mb-2">
<span class="text-gray-600 text-sm">Failed</span>
<Icon name="ic:outline-error" class="text-red-500"></Icon>
</div>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-bold text-red-600">{{ delivery.failed || 0 }}</span>
</div>
</div>
</rs-card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Channel Performance -->
<rs-card>
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-bar-chart"></Icon>
<h2 class="text-lg font-semibold text-primary">Channel Performance</h2>
</div>
</template>
<template #body>
<div v-if="channelPerformance.length === 0" class="text-center py-8 text-gray-500">
No channel data available
</div>
<div v-else class="space-y-4">
<div v-for="channel in channelPerformance" :key="channel.channel" class="border border-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<Icon :name="getChannelIcon(channel.channel)" class="text-xl"></Icon>
<span class="font-semibold capitalize">{{ channel.channel }}</span>
</div>
<rs-badge :variant="channel.successRate >= 90 ? 'success' : channel.successRate >= 70 ? 'warning' : 'danger'" size="sm">
{{ channel.successRate }}% Success
</rs-badge>
</div>
<div class="grid grid-cols-3 gap-3 text-sm">
<div>
<div class="text-gray-600">Total</div>
<div class="font-semibold">{{ channel.totalSent }}</div>
</div>
<div>
<div class="text-gray-600">Successful</div>
<div class="font-semibold text-green-600">{{ channel.successful }}</div>
</div>
<div>
<div class="text-gray-600">Failed</div>
<div class="font-semibold text-red-600">{{ channel.failed }}</div>
</div>
</div>
<!-- Progress Bar -->
<div class="mt-3">
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" :style="{ width: channel.successRate + '%' }"></div>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Top Categories -->
<rs-card>
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-category"></Icon>
<h2 class="text-lg font-semibold text-primary">Top Categories</h2>
</div>
</template>
<template #body>
<div v-if="topCategories.length === 0" class="text-center py-8 text-gray-500">
No category data available
</div>
<div v-else class="space-y-3">
<div v-for="(cat, index) in topCategories" :key="cat.categoryId" class="flex items-center justify-between p-3 border border-gray-200 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-8 h-8 flex items-center justify-center rounded-full bg-primary text-white font-bold text-sm">
{{ index + 1 }}
</div>
<span class="font-medium">{{ cat.categoryName }}</span>
</div>
<rs-badge variant="primary">{{ cat.count }}</rs-badge>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Recent Notifications -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
<h2 class="text-lg font-semibold text-primary">Recent Notifications</h2>
</div>
<NuxtLink to="/notification/list">
<rs-button size="sm" variant="outline">
View All
</rs-button>
</NuxtLink>
</div>
</template>
<template #body>
<div v-if="recentNotifications.length === 0" class="text-center py-8 text-gray-500">
No recent notifications
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Category</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Channels</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Recipients</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="notif in recentNotifications" :key="notif.id" class="hover:bg-gray-50">
<td class="px-4 py-3">
<NuxtLink :to="`/notification/view/${notif.id}`" class="text-primary hover:underline font-medium">
{{ notif.title }}
</NuxtLink>
</td>
<td class="px-4 py-3">
<span class="text-sm">{{ notif.category }}</span>
</td>
<td class="px-4 py-3">
<div class="flex gap-1">
<Icon v-for="channel in notif.channels" :key="channel" :name="getChannelIcon(channel)" class="text-gray-600" size="18"></Icon>
</div>
</td>
<td class="px-4 py-3">
<rs-badge :variant="getStatusVariant(notif.status)" size="sm">
{{ notif.status }}
</rs-badge>
</td>
<td class="px-4 py-3">
<span class="text-sm">{{ notif.recipientCount }}</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-600">{{ formatDate(notif.createdAt) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</rs-card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
definePageMeta({
title: 'Notification Dashboard',
middleware: ['auth'],
requiresAuth: true,
breadcrumb: [
{
name: 'Notification',
path: '/notification',
},
{
name: 'Dashboard',
path: '/notification/dashboard',
type: 'current',
},
],
});
const isLoading = ref(false);
const isRefreshing = ref(false);
const overview = ref({});
const delivery = ref({});
const channelPerformance = ref([]);
const topCategories = ref([]);
const recentNotifications = ref([]);
async function fetchDashboardData() {
try {
isLoading.value = true;
const [overviewData, recentData, channelData] = await Promise.all([
$fetch('/api/notifications/dashboard/overview'),
$fetch('/api/notifications/dashboard/recent?limit=10'),
$fetch('/api/notifications/dashboard/channel-performance'),
]);
if (overviewData.success) {
overview.value = overviewData.data.overview;
delivery.value = overviewData.data.delivery;
topCategories.value = overviewData.data.topCategories;
}
if (recentData.success) {
recentNotifications.value = recentData.data;
}
if (channelData.success) {
channelPerformance.value = channelData.data;
}
} catch (error) {
console.error('Error fetching dashboard data:', error);
} finally {
isLoading.value = false;
}
}
async function refreshData() {
isRefreshing.value = true;
await fetchDashboardData();
isRefreshing.value = false;
}
function getChannelIcon(channel) {
const icons = {
email: 'ic:outline-email',
push: 'ic:outline-notifications',
sms: 'ic:outline-sms',
};
return icons[channel] || 'ic:outline-circle';
}
function getStatusVariant(status) {
const variants = {
draft: 'secondary',
scheduled: 'warning',
sending: 'primary',
sent: 'success',
failed: 'danger',
};
return variants[status] || 'secondary';
}
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
onMounted(() => {
fetchDashboardData();
// Auto-refresh every 60 seconds
const interval = setInterval(fetchDashboardData, 60000);
// Cleanup on unmount
onUnmounted(() => clearInterval(interval));
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,643 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-send"></Icon>
<h1 class="text-xl font-bold text-primary">Delivery Settings</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Configure email, push notification, and SMS delivery settings.
</p>
</template>
</rs-card>
<!-- Loading Overlay -->
<div
v-if="isLoading"
class="fixed inset-0 blur-lg bg-opacity-50 z-50 flex items-center justify-center"
>
<div class="bg-white rounded-lg p-6 flex items-center space-x-4">
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="24" />
<span class="text-gray-700">Loading...</span>
</div>
</div>
<!-- Error Alert -->
<rs-alert
v-if="error"
variant="danger"
class="mb-6"
dismissible
@dismiss="error = null"
>
<template #icon>
<Icon name="ic:outline-error" />
</template>
{{ error }}
</rs-alert>
<!-- Channel Configuration -->
<div class="space-y-8 mb-6">
<!-- Email Configuration -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-email"></Icon>
<h2 class="text-lg font-semibold text-primary">Email Configuration</h2>
</div>
<rs-badge :variant="emailConfig.enabled ? 'success' : 'secondary'">
{{ emailConfig.enabled ? "Active" : "Disabled" }}
</rs-badge>
</div>
</template>
<template #body>
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="24" />
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="font-medium">Enable Email Delivery</span>
<FormKit type="toggle" v-model="emailConfig.enabled" />
</div>
<FormKit
type="select"
label="Email Provider"
v-model="emailConfig.provider"
:options="emailProviders"
:disabled="!emailConfig.enabled"
class="w-full"
/>
<!-- Provider Configuration -->
<div class="space-y-4">
<!-- Mailtrap Configuration -->
<div v-if="emailConfig.provider === 'mailtrap'">
<!-- Mailtrap Info Banner -->
<div class="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
<div class="flex items-start gap-3">
<Icon name="ic:outline-info" class="text-blue-600 text-xl mt-0.5"></Icon>
<div class="text-sm">
<p class="font-semibold text-blue-900 mb-1">Mailtrap SMTP Configuration</p>
<p class="text-blue-700">
Use <code class="px-1 py-0.5 bg-blue-100 rounded">live.smtp.mailtrap.io</code> for production sending.
Port 587 (recommended) with STARTTLS.
</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="text"
label="SMTP Host"
v-model="emailConfig.config.host"
:disabled="!emailConfig.enabled"
help="Use: live.smtp.mailtrap.io"
/>
<FormKit
type="number"
label="SMTP Port"
v-model="emailConfig.config.port"
:disabled="!emailConfig.enabled"
help="Recommended: 587"
/>
<FormKit
type="text"
label="SMTP Username"
v-model="emailConfig.config.user"
:disabled="!emailConfig.enabled"
help="Usually: apismtp@mailtrap.io"
/>
<FormKit
type="password"
label="SMTP Password / API Token"
v-model="emailConfig.config.pass"
:disabled="!emailConfig.enabled"
help="Your Mailtrap API token"
validation="required"
/>
<FormKit
type="email"
label="Sender Email"
v-model="emailConfig.config.senderEmail"
:disabled="!emailConfig.enabled"
help="Email address that will appear as the sender of notifications"
validation="required|email"
/>
<FormKit
type="text"
label="Sender Name (Optional)"
v-model="emailConfig.config.senderName"
:disabled="!emailConfig.enabled"
help="Name that will appear as the sender"
/>
</div>
</div>
<!-- AWS SES Configuration -->
<div v-if="emailConfig.provider === 'aws-ses'">
<!-- AWS SES Info Banner -->
<div class="p-4 bg-orange-50 border border-orange-200 rounded-lg mb-4">
<div class="flex items-start gap-3">
<Icon name="ic:outline-info" class="text-orange-600 text-xl mt-0.5"></Icon>
<div class="text-sm">
<p class="font-semibold text-orange-900 mb-1">AWS SES SMTP Configuration</p>
<p class="text-orange-700">
Use region-specific SMTP endpoint: <code class="px-1 py-0.5 bg-orange-100 rounded">email-smtp.&lt;region&gt;.amazonaws.com</code>
(e.g., email-smtp.us-east-1.amazonaws.com). Port 587 with STARTTLS.
</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="text"
label="SMTP Host"
v-model="emailConfig.config.host"
:disabled="!emailConfig.enabled"
help="e.g., email-smtp.us-east-1.amazonaws.com"
placeholder="email-smtp.us-east-1.amazonaws.com"
/>
<FormKit
type="number"
label="SMTP Port"
v-model="emailConfig.config.port"
:disabled="!emailConfig.enabled"
help="Use 587 (STARTTLS) or 465 (TLS)"
/>
<FormKit
type="text"
label="SMTP Username"
v-model="emailConfig.config.user"
:disabled="!emailConfig.enabled"
help="Your AWS SES SMTP username (not IAM user)"
placeholder="AKIAIOSFODNN7EXAMPLE"
/>
<FormKit
type="password"
label="SMTP Password"
v-model="emailConfig.config.pass"
:disabled="!emailConfig.enabled"
help="Your AWS SES SMTP password (not secret key)"
validation="required"
/>
<FormKit
type="email"
label="Sender Email"
v-model="emailConfig.config.senderEmail"
:disabled="!emailConfig.enabled"
help="Must be verified in AWS SES"
validation="required|email"
/>
<FormKit
type="text"
label="Sender Name (Optional)"
v-model="emailConfig.config.senderName"
:disabled="!emailConfig.enabled"
help="Name that will appear as the sender"
/>
<FormKit
type="text"
label="AWS Region"
v-model="emailConfig.config.region"
:disabled="!emailConfig.enabled"
help="AWS region where SES is configured"
placeholder="us-east-1"
/>
<FormKit
type="text"
label="Configuration Set (Optional)"
v-model="emailConfig.config.configurationSet"
:disabled="!emailConfig.enabled"
help="AWS SES configuration set for tracking"
/>
</div>
</div>
</div>
<!-- Add more providers as needed -->
<div class="mt-6 p-4 bg-gray-50 rounded-lg flex flex-wrap gap-8">
<div>
<span class="text-gray-600">Status:</span>
<span class="ml-2 font-medium">{{ emailConfig.status }}</span>
</div>
<div>
<span class="text-gray-600">Success Rate:</span>
<span class="ml-2 font-medium">{{ emailConfig.successRate }}%</span>
</div>
</div>
<div class="flex justify-end">
<rs-button @click="saveEmailConfig" :disabled="isLoadingEmail">
<Icon
:name="isLoadingEmail ? 'ic:outline-refresh' : 'ic:outline-save'"
class="mr-1"
:class="{ 'animate-spin': isLoadingEmail }"
/>
{{ isLoadingEmail ? "Saving..." : "Save" }}
</rs-button>
</div>
</div>
</template>
</rs-card>
<!-- Push Configuration -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-notifications"></Icon>
<h2 class="text-lg font-semibold text-primary">
Push Notification Configuration
</h2>
</div>
<rs-badge :variant="pushConfig.enabled ? 'success' : 'secondary'">
{{ pushConfig.enabled ? "Active" : "Disabled" }}
</rs-badge>
</div>
</template>
<template #body>
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="24" />
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="font-medium">Enable Push Notifications</span>
<FormKit type="toggle" v-model="pushConfig.enabled" />
</div>
<FormKit
type="select"
label="Push Provider"
v-model="pushConfig.provider"
:options="pushProviders"
:disabled="!pushConfig.enabled"
class="w-full"
/>
<!-- Provider-specific fields -->
<div
v-if="pushConfig.provider === 'firebase'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<FormKit
type="text"
label="API Key"
v-model="pushConfig.config.apiKey"
:disabled="!pushConfig.enabled"
/>
<FormKit
type="text"
label="Project ID"
v-model="pushConfig.config.projectId"
:disabled="!pushConfig.enabled"
/>
</div>
<!-- Add more providers as needed -->
<div class="mt-6 p-4 bg-gray-50 rounded-lg flex flex-wrap gap-8">
<div>
<span class="text-gray-600">Status:</span>
<span class="ml-2 font-medium">{{ pushConfig.status }}</span>
</div>
<div>
<span class="text-gray-600">Success Rate:</span>
<span class="ml-2 font-medium">{{ pushConfig.successRate }}%</span>
</div>
</div>
<div class="flex justify-end">
<rs-button @click="savePushConfig" :disabled="isLoadingPush">
<Icon
:name="isLoadingPush ? 'ic:outline-refresh' : 'ic:outline-save'"
class="mr-1"
:class="{ 'animate-spin': isLoadingPush }"
/>
{{ isLoadingPush ? "Saving..." : "Save" }}
</rs-button>
</div>
</div>
</template>
</rs-card>
<!-- SMS Configuration -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-sms"></Icon>
<h2 class="text-lg font-semibold text-primary">SMS Configuration</h2>
</div>
<rs-badge :variant="smsConfig.enabled ? 'success' : 'secondary'">
{{ smsConfig.enabled ? "Active" : "Disabled" }}
</rs-badge>
</div>
</template>
<template #body>
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Icon name="ic:outline-refresh" class="text-primary animate-spin" size="24" />
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="font-medium">Enable SMS Delivery</span>
<FormKit type="toggle" v-model="smsConfig.enabled" />
</div>
<FormKit
type="select"
label="SMS Provider"
v-model="smsConfig.provider"
:options="smsProviders"
:disabled="!smsConfig.enabled"
class="w-full"
/>
<!-- Provider-specific fields -->
<div
v-if="smsConfig.provider === 'twilio'"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<FormKit
type="text"
label="Account SID"
v-model="smsConfig.config.accountSid"
:disabled="!smsConfig.enabled"
/>
<FormKit
type="password"
label="Auth Token"
v-model="smsConfig.config.authToken"
:disabled="!smsConfig.enabled"
/>
<FormKit
type="text"
label="From Number"
v-model="smsConfig.config.from"
:disabled="!smsConfig.enabled"
/>
</div>
<!-- Add more providers as needed -->
<div class="mt-6 p-4 bg-gray-50 rounded-lg flex flex-wrap gap-8">
<div>
<span class="text-gray-600">Status:</span>
<span class="ml-2 font-medium">{{ smsConfig.status }}</span>
</div>
<div>
<span class="text-gray-600">Success Rate:</span>
<span class="ml-2 font-medium">{{ smsConfig.successRate }}%</span>
</div>
</div>
<div class="flex justify-end">
<rs-button @click="saveSmsConfig" :disabled="isLoadingSms">
<Icon
:name="isLoadingSms ? 'ic:outline-refresh' : 'ic:outline-save'"
class="mr-1"
:class="{ 'animate-spin': isLoadingSms }"
/>
{{ isLoadingSms ? "Saving..." : "Save" }}
</rs-button>
</div>
</div>
</template>
</rs-card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
import { useToast } from "@/composables/useToast";
import { useNotificationDelivery } from "@/composables/useNotificationDelivery";
definePageMeta({
title: "Notification Delivery",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Delivery",
path: "/notification/delivery",
type: "current",
},
],
});
// Email Configuration
const emailConfig = ref({
enabled: true,
provider: "mailtrap",
config: {
host: "live.smtp.mailtrap.io",
port: 587,
user: "apismtp@mailtrap.io",
pass: "",
senderEmail: "",
senderName: "",
},
status: "Connected",
successRate: 99.2,
});
const emailProviders = [
{ label: "Mailtrap", value: "mailtrap" },
{ label: "AWS SES", value: "aws-ses" }
];
// Push Configuration
const pushConfig = ref({
enabled: true,
provider: "firebase",
config: {
apiKey: "",
projectId: "",
},
status: "Connected",
successRate: 95.8,
});
const pushProviders = [{ label: "Firebase FCM", value: "firebase" }];
// Add SMS config and providers
const smsConfig = ref({
enabled: false,
provider: "twilio",
config: {
accountSid: "",
authToken: "",
from: "",
},
status: "Not Configured",
successRate: 0,
});
const smsProviders = [
{ label: "Twilio", value: "twilio" },
// Add more providers as needed
];
const isLoadingEmail = ref(false);
const isLoadingPush = ref(false);
const isLoadingSms = ref(false);
const toast = useToast();
const {
isLoading,
error,
fetchDeliveryStats,
fetchEmailConfig,
fetchPushConfig,
fetchSmsConfig,
fetchDeliverySettings,
updateEmailConfig,
updatePushConfig,
updateSmsConfig,
updateDeliverySettings,
} = useNotificationDelivery();
// Store provider-specific configs
const providerConfigs = ref({
mailtrap: null,
'aws-ses': null
});
// Methods
async function refreshData() {
try {
isLoading.value = true;
const [emailData, pushData, smsData] = await Promise.all([
fetchEmailConfig(),
fetchPushConfig(),
fetchSmsConfig(),
]);
// Store all provider configs
if (emailData.providers) {
providerConfigs.value = emailData.providers;
}
// Update email config with active provider
const activeProviderKey = emailData.activeProvider || emailData.provider;
const activeProviderData = emailData.providers?.[activeProviderKey] || emailData;
emailConfig.value = {
enabled: activeProviderData.enabled,
provider: activeProviderKey,
config: activeProviderData.config || {},
status: activeProviderData.status,
successRate: activeProviderData.successRate,
};
// Update push config
pushConfig.value = {
enabled: pushData.enabled,
provider: pushData.provider,
config: pushData.config || {},
status: pushData.status,
successRate: pushData.successRate,
};
// Update SMS config
smsConfig.value = {
enabled: smsData.enabled,
provider: smsData.provider,
config: smsData.config || {},
status: smsData.status,
successRate: smsData.successRate,
};
} catch (err) {
console.error("Error refreshing data:", err);
toast.error(err.message || "Failed to refresh data");
} finally {
isLoading.value = false;
}
}
async function saveEmailConfig() {
try {
isLoadingEmail.value = true;
await updateEmailConfig({
enabled: emailConfig.value.enabled,
provider: emailConfig.value.provider,
config: emailConfig.value.config,
});
toast.success("Email settings saved!");
} catch (err) {
toast.error(err.message || "Failed to save email settings");
} finally {
isLoadingEmail.value = false;
}
}
async function savePushConfig() {
try {
isLoadingPush.value = true;
await updatePushConfig({
enabled: pushConfig.value.enabled,
provider: pushConfig.value.provider,
config: pushConfig.value.config,
});
toast.success("Push settings saved!");
} catch (err) {
toast.error(err.message || "Failed to save push settings");
} finally {
isLoadingPush.value = false;
}
}
async function saveSmsConfig() {
try {
isLoadingSms.value = true;
await updateSmsConfig({
enabled: smsConfig.value.enabled,
provider: smsConfig.value.provider,
config: smsConfig.value.config,
});
toast.success("SMS settings saved!");
} catch (err) {
toast.error(err.message || "Failed to save SMS settings");
} finally {
isLoadingSms.value = false;
}
}
// Watch for provider changes and load saved config
watch(() => emailConfig.value.provider, (newProvider) => {
if (providerConfigs.value[newProvider]) {
// Load saved config for this provider
const savedConfig = providerConfigs.value[newProvider];
emailConfig.value.config = savedConfig.config || {};
emailConfig.value.status = savedConfig.status;
emailConfig.value.successRate = savedConfig.successRate;
} else {
// Load default config for new provider
if (newProvider === 'mailtrap') {
emailConfig.value.config = {
host: 'live.smtp.mailtrap.io',
port: 587,
user: 'apismtp@mailtrap.io',
pass: '',
senderEmail: '',
senderName: '',
};
} else if (newProvider === 'aws-ses') {
emailConfig.value.config = {
host: '',
port: 587,
user: '',
pass: '',
senderEmail: '',
senderName: '',
region: 'us-east-1',
configurationSet: '',
};
}
}
});
// Load initial data
onMounted(() => {
refreshData();
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,661 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-monitor"></Icon>
<h1 class="text-xl font-bold text-primary">Delivery Monitor</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Real-time monitoring of notification deliveries across all channels. Track individual messages,
monitor batch progress, and analyze delivery performance with detailed metrics.
</p>
</template>
</rs-card>
<!-- Real-time Metrics -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(metric, index) in realTimeMetrics"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-5 flex justify-center items-center rounded-2xl transition-all duration-300"
:class="metric.bgColor"
>
<Icon class="text-3xl" :name="metric.icon" :class="metric.iconColor"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight" :class="metric.textColor">
{{ metric.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ metric.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Message Search & Filter -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-primary">Message Tracking</h2>
<div class="flex gap-2">
<rs-button size="sm" variant="primary-outline" @click="refreshMessages">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
<rs-button size="sm" variant="primary-outline" @click="showFilters = !showFilters">
<Icon class="mr-1" name="ic:outline-filter-list"></Icon>
Filters
</rs-button>
</div>
</div>
</template>
<template #body>
<!-- Search and Filters -->
<div class="mb-6 space-y-4">
<div class="flex gap-4">
<div class="flex-1">
<FormKit
type="text"
v-model="searchQuery"
placeholder="Search by message ID, recipient, or content..."
prefix-icon="search"
/>
</div>
<div class="w-48">
<FormKit
type="select"
v-model="selectedChannel"
:options="channelFilterOptions"
placeholder="All Channels"
/>
</div>
</div>
<!-- Advanced Filters -->
<div v-if="showFilters" class="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-gray-50 rounded-lg">
<FormKit
type="select"
v-model="filters.status"
:options="statusFilterOptions"
label="Status"
/>
<FormKit
type="select"
v-model="filters.priority"
:options="priorityFilterOptions"
label="Priority"
/>
<FormKit
type="date"
v-model="filters.dateFrom"
label="From Date"
/>
<FormKit
type="date"
v-model="filters.dateTo"
label="To Date"
/>
</div>
</div>
<!-- Messages Table -->
<rs-table
:field="messageTableFields"
:data="filteredMessages"
:advanced="true"
:options="{ striped: true, hover: true }"
:optionsAdvanced="{
sortable: true,
filterable: true,
responsive: true,
}"
:pageSize="20"
>
<template #messageId="{ row }">
<button
@click="viewMessageDetails(row)"
class="text-primary hover:underline font-mono text-sm"
>
{{ row.messageId }}
</button>
</template>
<template #status="{ row }">
<rs-badge :variant="getStatusVariant(row.status)" size="sm">
{{ row.status }}
</rs-badge>
</template>
<template #channel="{ row }">
<div class="flex items-center">
<Icon class="mr-2" :name="getChannelIcon(row.channel)"></Icon>
{{ row.channel }}
</div>
</template>
<template #priority="{ row }">
<rs-badge
:variant="getPriorityVariant(row.priority)"
size="sm"
>
{{ row.priority }}
</rs-badge>
</template>
<template #progress="{ row }">
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: row.progress + '%' }"
></div>
</div>
<div class="text-xs text-gray-500 mt-1">{{ row.progress }}%</div>
</template>
<template #actions="{ row }">
<div class="flex gap-2">
<rs-button
size="sm"
variant="primary-outline"
@click="viewMessageDetails(row)"
>
<Icon name="ic:outline-visibility"></Icon>
</rs-button>
<rs-button
size="sm"
variant="secondary-outline"
@click="retryMessage(row)"
v-if="row.status === 'failed'"
>
<Icon name="ic:outline-refresh"></Icon>
</rs-button>
</div>
</template>
</rs-table>
</template>
</rs-card>
<!-- Live Activity Feed -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Real-time Activity -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-live-tv"></Icon>
<h2 class="text-lg font-semibold text-primary">Live Activity</h2>
</div>
<rs-badge variant="success" size="sm">Live</rs-badge>
</div>
</template>
<template #body>
<div class="space-y-3 max-h-96 overflow-y-auto">
<div
v-for="activity in liveActivities"
:key="activity.id"
class="flex items-start gap-3 p-3 border border-gray-200 rounded-lg"
>
<Icon
class="mt-1 flex-shrink-0"
:name="activity.icon"
:class="activity.iconColor"
></Icon>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm">{{ activity.message }}</div>
<div class="text-xs text-gray-500">{{ activity.timestamp }}</div>
<div v-if="activity.details" class="text-xs text-gray-600 mt-1">
{{ activity.details }}
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Channel Performance -->
<rs-card>
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-speed"></Icon>
<h2 class="text-lg font-semibold text-primary">Channel Performance</h2>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="channel in channelPerformance"
:key="channel.name"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<Icon class="mr-2" :name="channel.icon"></Icon>
<span class="font-semibold">{{ channel.name }}</span>
</div>
<rs-badge :variant="channel.statusVariant" size="sm">
{{ channel.status }}
</rs-badge>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-gray-600">Throughput</div>
<div class="font-semibold">{{ channel.throughput }}/min</div>
</div>
<div>
<div class="text-gray-600">Success Rate</div>
<div class="font-semibold">{{ channel.successRate }}%</div>
</div>
<div>
<div class="text-gray-600">Avg Latency</div>
<div class="font-semibold">{{ channel.avgLatency }}ms</div>
</div>
<div>
<div class="text-gray-600">Queue Size</div>
<div class="font-semibold">{{ channel.queueSize }}</div>
</div>
</div>
<!-- Performance Chart -->
<div class="mt-3">
<div class="text-xs text-gray-500 mb-1">Performance Trend</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="channel.performanceClass"
:style="{ width: channel.performanceScore + '%' }"
></div>
</div>
</div>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Message Details Modal -->
<rs-modal v-model="showMessageModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">Message Details</h3>
</template>
<template #body>
<div v-if="selectedMessage" class="space-y-6">
<!-- Message Info -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Message ID</label>
<div class="font-mono text-sm">{{ selectedMessage.messageId }}</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Status</label>
<rs-badge :variant="getStatusVariant(selectedMessage.status)">
{{ selectedMessage.status }}
</rs-badge>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Channel</label>
<div class="flex items-center">
<Icon class="mr-2" :name="getChannelIcon(selectedMessage.channel)"></Icon>
{{ selectedMessage.channel }}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Priority</label>
<rs-badge :variant="getPriorityVariant(selectedMessage.priority)">
{{ selectedMessage.priority }}
</rs-badge>
</div>
</div>
<!-- Delivery Timeline -->
<div>
<label class="block text-sm font-medium text-gray-500 mb-3">Delivery Timeline</label>
<div class="space-y-3">
<div
v-for="event in selectedMessage.timeline"
:key="event.id"
class="flex items-center gap-3"
>
<div class="w-3 h-3 rounded-full" :class="event.statusColor"></div>
<div class="flex-1">
<div class="font-medium text-sm">{{ event.status }}</div>
<div class="text-xs text-gray-500">{{ event.timestamp }}</div>
<div v-if="event.details" class="text-xs text-gray-600">{{ event.details }}</div>
</div>
</div>
</div>
</div>
<!-- Message Content -->
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Content</label>
<div class="bg-gray-50 rounded-lg p-3 text-sm">
<div><strong>To:</strong> {{ selectedMessage.recipient }}</div>
<div><strong>Subject:</strong> {{ selectedMessage.subject }}</div>
<div class="mt-2"><strong>Body:</strong></div>
<div class="mt-1">{{ selectedMessage.content }}</div>
</div>
</div>
<!-- Provider Response -->
<div v-if="selectedMessage.providerResponse">
<label class="block text-sm font-medium text-gray-500 mb-1">Provider Response</label>
<div class="bg-gray-50 rounded-lg p-3">
<pre class="text-xs">{{ JSON.stringify(selectedMessage.providerResponse, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showMessageModal = false">Close</rs-button>
<rs-button
variant="primary"
@click="retryMessage(selectedMessage)"
v-if="selectedMessage?.status === 'failed'"
>
Retry Message
</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Delivery Monitor",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "Delivery Engine",
path: "/notification/delivery",
},
{
name: "Monitor",
path: "/notification/delivery/monitor",
type: "current",
},
],
});
import { ref, computed, onMounted, onUnmounted } from "vue";
// State
const showFilters = ref(false);
const showMessageModal = ref(false);
const selectedMessage = ref(null);
const searchQuery = ref("");
const selectedChannel = ref("");
// Filters
const filters = ref({
status: "",
priority: "",
dateFrom: "",
dateTo: "",
});
// Real-time metrics
const realTimeMetrics = ref([
{
title: "Messages/Min",
value: "1,247",
icon: "ic:outline-speed",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
textColor: "text-blue-600",
},
{
title: "Success Rate",
value: "98.7%",
icon: "ic:outline-check-circle",
bgColor: "bg-green-100",
iconColor: "text-green-600",
textColor: "text-green-600",
},
{
title: "Failed/Retrying",
value: "23",
icon: "ic:outline-error",
bgColor: "bg-red-100",
iconColor: "text-red-600",
textColor: "text-red-600",
},
{
title: "Avg Latency",
value: "1.2s",
icon: "ic:outline-timer",
bgColor: "bg-purple-100",
iconColor: "text-purple-600",
textColor: "text-purple-600",
},
]);
// Filter options
const channelFilterOptions = [
{ label: "All Channels", value: "" },
{ label: "Email", value: "email" },
{ label: "SMS", value: "sms" },
{ label: "Push", value: "push" },
{ label: "In-App", value: "inapp" },
];
const statusFilterOptions = [
{ label: "All Status", value: "" },
{ label: "Queued", value: "queued" },
{ label: "Sent", value: "sent" },
{ label: "Delivered", value: "delivered" },
{ label: "Opened", value: "opened" },
{ label: "Failed", value: "failed" },
{ label: "Bounced", value: "bounced" },
];
const priorityFilterOptions = [
{ label: "All Priorities", value: "" },
{ label: "Critical", value: "critical" },
{ label: "High", value: "high" },
{ label: "Medium", value: "medium" },
{ label: "Low", value: "low" },
];
// Table configuration
const messageTableFields = ref([
{ key: "messageId", label: "Message ID", sortable: true },
{ key: "recipient", label: "Recipient", sortable: true },
{ key: "channel", label: "Channel", sortable: true },
{ key: "status", label: "Status", sortable: true },
{ key: "priority", label: "Priority", sortable: true },
{ key: "createdAt", label: "Created", sortable: true },
{ key: "progress", label: "Progress", sortable: false },
{ key: "actions", label: "Actions", sortable: false },
]);
// Sample messages data
const messages = ref([
{
messageId: "msg_001",
recipient: "user@example.com",
channel: "Email",
status: "delivered",
priority: "high",
createdAt: "2024-01-15 10:30:00",
progress: 100,
subject: "Welcome to our platform",
content: "Thank you for joining us...",
timeline: [
{ id: 1, status: "Queued", timestamp: "2024-01-15 10:30:00", statusColor: "bg-blue-500", details: "Message queued for processing" },
{ id: 2, status: "Sent", timestamp: "2024-01-15 10:30:15", statusColor: "bg-yellow-500", details: "Sent via SendGrid" },
{ id: 3, status: "Delivered", timestamp: "2024-01-15 10:30:18", statusColor: "bg-green-500", details: "Successfully delivered" },
],
providerResponse: { messageId: "sg_abc123", status: "delivered" },
},
// Add more sample data...
]);
// Live activities
const liveActivities = ref([
{
id: 1,
message: "Email batch completed",
timestamp: "Just now",
icon: "ic:outline-email",
iconColor: "text-green-500",
details: "1,250 emails sent successfully",
},
{
id: 2,
message: "SMS delivery in progress",
timestamp: "2 seconds ago",
icon: "ic:outline-sms",
iconColor: "text-blue-500",
details: "89/120 messages delivered",
},
// Add more activities...
]);
// Channel performance
const channelPerformance = ref([
{
name: "Email",
icon: "ic:outline-email",
status: "Healthy",
statusVariant: "success",
throughput: "1,200",
successRate: 99.2,
avgLatency: 800,
queueSize: 45,
performanceScore: 95,
performanceClass: "bg-green-500",
},
{
name: "SMS",
icon: "ic:outline-sms",
status: "Warning",
statusVariant: "warning",
throughput: "450",
successRate: 97.8,
avgLatency: 2100,
queueSize: 123,
performanceScore: 78,
performanceClass: "bg-yellow-500",
},
// Add more channels...
]);
// Computed
const filteredMessages = computed(() => {
let filtered = messages.value;
if (searchQuery.value) {
filtered = filtered.filter(msg =>
msg.messageId.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
msg.recipient.toLowerCase().includes(searchQuery.value.toLowerCase())
);
}
if (selectedChannel.value) {
filtered = filtered.filter(msg => msg.channel.toLowerCase() === selectedChannel.value);
}
if (filters.value.status) {
filtered = filtered.filter(msg => msg.status === filters.value.status);
}
if (filters.value.priority) {
filtered = filtered.filter(msg => msg.priority === filters.value.priority);
}
return filtered;
});
// Methods
function getStatusVariant(status) {
const variants = {
queued: "info",
sent: "warning",
delivered: "success",
opened: "success",
failed: "danger",
bounced: "danger",
};
return variants[status] || "secondary";
}
function getChannelIcon(channel) {
const icons = {
Email: "ic:outline-email",
SMS: "ic:outline-sms",
Push: "ic:outline-notifications",
"In-App": "ic:outline-app-registration",
};
return icons[channel] || "ic:outline-help";
}
function getPriorityVariant(priority) {
const variants = {
critical: "danger",
high: "warning",
medium: "info",
low: "secondary",
};
return variants[priority] || "secondary";
}
function viewMessageDetails(message) {
selectedMessage.value = message;
showMessageModal.value = true;
}
function retryMessage(message) {
console.log("Retrying message:", message.messageId);
// Implementation for retrying failed messages
}
function refreshMessages() {
console.log("Refreshing messages...");
// Implementation for refreshing message list
}
// Auto-refresh every 10 seconds
let refreshInterval;
onMounted(() => {
refreshInterval = setInterval(() => {
refreshMessages();
}, 10000);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,805 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-extension"></Icon>
<h1 class="text-xl font-bold text-primary">Provider Management</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Configure and manage third-party notification service providers. Set up
credentials, fallback rules, and monitor provider performance across all
channels.
</p>
</template>
</rs-card>
<!-- Provider Overview Stats -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(stat, index) in providerStats"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-5 flex justify-center items-center rounded-2xl transition-all duration-300"
:class="stat.bgColor"
>
<Icon class="text-3xl" :name="stat.icon" :class="stat.iconColor"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight" :class="stat.textColor">
{{ stat.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ stat.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Providers by Channel -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Email Providers -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-email"></Icon>
<h2 class="text-lg font-semibold text-primary">Email Providers</h2>
</div>
<rs-button size="sm" variant="primary-outline" @click="addProvider('email')">
<Icon class="mr-1" name="ic:outline-add"></Icon>
Add Provider
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="provider in emailProviders"
:key="provider.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<Icon class="mr-2 text-2xl" :name="provider.icon"></Icon>
<div>
<div class="font-semibold">{{ provider.name }}</div>
<div class="text-sm text-gray-600">{{ provider.description }}</div>
</div>
</div>
<div class="flex items-center gap-2">
<rs-badge
:variant="provider.status === 'active' ? 'success' : 'danger'"
size="sm"
>
{{ provider.status }}
</rs-badge>
<rs-button
size="sm"
variant="secondary-outline"
@click="configureProvider(provider)"
>
<Icon name="ic:outline-settings"></Icon>
</rs-button>
</div>
</div>
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<div class="text-gray-600">Success Rate</div>
<div class="font-semibold">{{ provider.successRate }}%</div>
</div>
<div>
<div class="text-gray-600">Quota Used</div>
<div class="font-semibold">{{ provider.quotaUsed }}%</div>
</div>
<div>
<div class="text-gray-600">Priority</div>
<div class="font-semibold">{{ provider.priority }}</div>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- SMS Providers -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-sms"></Icon>
<h2 class="text-lg font-semibold text-primary">SMS Providers</h2>
</div>
<rs-button size="sm" variant="primary-outline" @click="addProvider('sms')">
<Icon class="mr-1" name="ic:outline-add"></Icon>
Add Provider
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="provider in smsProviders"
:key="provider.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<Icon class="mr-2 text-2xl" :name="provider.icon"></Icon>
<div>
<div class="font-semibold">{{ provider.name }}</div>
<div class="text-sm text-gray-600">{{ provider.description }}</div>
</div>
</div>
<div class="flex items-center gap-2">
<rs-badge
:variant="provider.status === 'active' ? 'success' : 'danger'"
size="sm"
>
{{ provider.status }}
</rs-badge>
<rs-button
size="sm"
variant="secondary-outline"
@click="configureProvider(provider)"
>
<Icon name="ic:outline-settings"></Icon>
</rs-button>
</div>
</div>
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<div class="text-gray-600">Success Rate</div>
<div class="font-semibold">{{ provider.successRate }}%</div>
</div>
<div>
<div class="text-gray-600">Quota Used</div>
<div class="font-semibold">{{ provider.quotaUsed }}%</div>
</div>
<div>
<div class="text-gray-600">Priority</div>
<div class="font-semibold">{{ provider.priority }}</div>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Push Providers -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-notifications"></Icon>
<h2 class="text-lg font-semibold text-primary">Push Providers</h2>
</div>
<rs-button size="sm" variant="primary-outline" @click="addProvider('push')">
<Icon class="mr-1" name="ic:outline-add"></Icon>
Add Provider
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="provider in pushProviders"
:key="provider.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<Icon class="mr-2 text-2xl" :name="provider.icon"></Icon>
<div>
<div class="font-semibold">{{ provider.name }}</div>
<div class="text-sm text-gray-600">{{ provider.description }}</div>
</div>
</div>
<div class="flex items-center gap-2">
<rs-badge
:variant="provider.status === 'active' ? 'success' : 'danger'"
size="sm"
>
{{ provider.status }}
</rs-badge>
<rs-button
size="sm"
variant="secondary-outline"
@click="configureProvider(provider)"
>
<Icon name="ic:outline-settings"></Icon>
</rs-button>
</div>
</div>
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<div class="text-gray-600">Success Rate</div>
<div class="font-semibold">{{ provider.successRate }}%</div>
</div>
<div>
<div class="text-gray-600">Quota Used</div>
<div class="font-semibold">{{ provider.quotaUsed }}%</div>
</div>
<div>
<div class="text-gray-600">Priority</div>
<div class="font-semibold">{{ provider.priority }}</div>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Webhook Providers -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-webhook"></Icon>
<h2 class="text-lg font-semibold text-primary">Webhook Endpoints</h2>
</div>
<rs-button
size="sm"
variant="primary-outline"
@click="addProvider('webhook')"
>
<Icon class="mr-1" name="ic:outline-add"></Icon>
Add Webhook
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="provider in webhookProviders"
:key="provider.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<Icon class="mr-2 text-2xl" :name="provider.icon"></Icon>
<div>
<div class="font-semibold">{{ provider.name }}</div>
<div class="text-sm text-gray-600 font-mono">{{ provider.url }}</div>
</div>
</div>
<div class="flex items-center gap-2">
<rs-badge
:variant="provider.status === 'active' ? 'success' : 'danger'"
size="sm"
>
{{ provider.status }}
</rs-badge>
<rs-button
size="sm"
variant="secondary-outline"
@click="configureProvider(provider)"
>
<Icon name="ic:outline-settings"></Icon>
</rs-button>
</div>
</div>
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<div class="text-gray-600">Success Rate</div>
<div class="font-semibold">{{ provider.successRate }}%</div>
</div>
<div>
<div class="text-gray-600">Avg Response</div>
<div class="font-semibold">{{ provider.avgResponse }}ms</div>
</div>
<div>
<div class="text-gray-600">Last Used</div>
<div class="font-semibold">{{ provider.lastUsed }}</div>
</div>
</div>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Fallback Configuration -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-alt-route"></Icon>
<h2 class="text-lg font-semibold text-primary">Fallback Configuration</h2>
</div>
<rs-button
size="sm"
variant="primary-outline"
@click="showFallbackModal = true"
>
<Icon class="mr-1" name="ic:outline-add"></Icon>
Add Rule
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="rule in fallbackRules"
:key="rule.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div>
<div class="font-semibold">{{ rule.name }}</div>
<div class="text-sm text-gray-600">{{ rule.description }}</div>
</div>
<div class="flex items-center gap-2">
<rs-badge :variant="rule.enabled ? 'success' : 'secondary'" size="sm">
{{ rule.enabled ? "Active" : "Disabled" }}
</rs-badge>
<rs-button
size="sm"
variant="secondary-outline"
@click="editFallbackRule(rule)"
>
<Icon name="ic:outline-edit"></Icon>
</rs-button>
</div>
</div>
<div class="text-sm space-y-2">
<div class="flex items-center gap-2">
<span class="text-gray-600">Primary:</span>
<span class="font-medium">{{ rule.primary }}</span>
<Icon name="ic:outline-arrow-forward" class="text-gray-400"></Icon>
<span class="text-gray-600">Fallback:</span>
<span class="font-medium">{{ rule.fallback }}</span>
</div>
<div>
<span class="text-gray-600">Trigger:</span>
<span class="font-medium">{{ rule.trigger }}</span>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Provider Configuration Modal -->
<rs-modal v-model="showProviderModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">
{{ selectedProvider ? "Configure" : "Add" }} Provider
</h3>
</template>
<template #body>
<div v-if="selectedProvider" class="space-y-6">
<!-- Basic Info -->
<div class="grid grid-cols-2 gap-4">
<FormKit
type="text"
v-model="providerForm.name"
label="Provider Name"
placeholder="Enter provider name"
/>
<FormKit
type="select"
v-model="providerForm.channel"
label="Channel"
:options="channelOptions"
/>
</div>
<!-- Provider-specific Configuration -->
<div v-if="providerForm.channel === 'email'">
<h4 class="font-semibold mb-3">Email Configuration</h4>
<div class="grid grid-cols-2 gap-4">
<FormKit
type="select"
v-model="providerForm.provider"
label="Provider Type"
:options="emailProviderOptions"
/>
<FormKit
type="password"
v-model="providerForm.apiKey"
label="API Key"
placeholder="Enter API key"
/>
<FormKit
type="password"
v-model="providerForm.apiSecret"
label="API Secret"
placeholder="Enter API secret"
/>
<FormKit
type="text"
v-model="providerForm.fromEmail"
label="From Email"
placeholder="noreply@example.com"
/>
</div>
</div>
<div v-if="providerForm.channel === 'sms'">
<h4 class="font-semibold mb-3">SMS Configuration</h4>
<div class="grid grid-cols-2 gap-4">
<FormKit
type="select"
v-model="providerForm.provider"
label="Provider Type"
:options="smsProviderOptions"
/>
<FormKit
type="text"
v-model="providerForm.accountSid"
label="Account SID"
placeholder="Enter account SID"
/>
<FormKit
type="password"
v-model="providerForm.authToken"
label="Auth Token"
placeholder="Enter auth token"
/>
<FormKit
type="text"
v-model="providerForm.fromNumber"
label="From Number"
placeholder="+1234567890"
/>
</div>
</div>
<!-- Priority & Limits -->
<div>
<h4 class="font-semibold mb-3">Priority & Limits</h4>
<div class="grid grid-cols-3 gap-4">
<FormKit
type="number"
v-model="providerForm.priority"
label="Priority"
placeholder="1-10"
min="1"
max="10"
/>
<FormKit
type="number"
v-model="providerForm.rateLimit"
label="Rate Limit (per minute)"
placeholder="100"
/>
<FormKit
type="number"
v-model="providerForm.dailyQuota"
label="Daily Quota"
placeholder="10000"
/>
</div>
</div>
<!-- Test Connection -->
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="font-semibold mb-3">Test Connection</h4>
<div class="flex items-center gap-3">
<rs-button variant="secondary" @click="testProviderConnection">
<Icon class="mr-1" name="ic:outline-wifi-tethering"></Icon>
Test Connection
</rs-button>
<div v-if="connectionTestResult" class="flex items-center gap-2">
<Icon
:name="
connectionTestResult.success
? 'ic:outline-check-circle'
: 'ic:outline-error'
"
:class="
connectionTestResult.success ? 'text-green-500' : 'text-red-500'
"
></Icon>
<span
:class="
connectionTestResult.success ? 'text-green-600' : 'text-red-600'
"
>
{{ connectionTestResult.message }}
</span>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showProviderModal = false"
>Cancel</rs-button
>
<rs-button variant="primary" @click="saveProvider">Save Provider</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Provider Management",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "Delivery Engine",
path: "/notification/delivery",
},
{
name: "Providers",
path: "/notification/delivery/providers",
type: "current",
},
],
});
import { ref, reactive } from "vue";
// Modal states
const showProviderModal = ref(false);
const showFallbackModal = ref(false);
const selectedProvider = ref(null);
const connectionTestResult = ref(null);
// Provider form
const providerForm = reactive({
name: "",
channel: "",
provider: "",
apiKey: "",
apiSecret: "",
accountSid: "",
authToken: "",
fromEmail: "",
fromNumber: "",
priority: 5,
rateLimit: 100,
dailyQuota: 10000,
});
// Statistics
const providerStats = ref([
{
title: "Active Providers",
value: "12",
icon: "ic:outline-verified",
bgColor: "bg-green-100",
iconColor: "text-green-600",
textColor: "text-green-600",
},
{
title: "Total Messages Today",
value: "28.5K",
icon: "ic:outline-send",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
textColor: "text-blue-600",
},
{
title: "Avg Success Rate",
value: "98.2%",
icon: "ic:outline-trending-up",
bgColor: "bg-purple-100",
iconColor: "text-purple-600",
textColor: "text-purple-600",
},
{
title: "Failed Providers",
value: "1",
icon: "ic:outline-error",
bgColor: "bg-red-100",
iconColor: "text-red-600",
textColor: "text-red-600",
},
]);
// Provider data
const emailProviders = ref([
{
id: 1,
name: "Mailtrap",
description: "Primary email delivery service (SMTP)",
icon: "ic:outline-email",
status: "active",
successRate: 99.5,
quotaUsed: 45,
priority: 1,
},
{
id: 2,
name: "AWS SES",
description: "Scalable email service from AWS",
icon: "ic:outline-cloud",
status: "inactive",
successRate: 99.0,
quotaUsed: 0,
priority: 2,
},
]);
const smsProviders = ref([
{
id: 4,
name: "Twilio",
description: "Primary SMS service",
icon: "ic:outline-sms",
status: "active",
successRate: 97.8,
quotaUsed: 78,
priority: 1,
},
{
id: 5,
name: "Nexmo",
description: "International SMS fallback",
icon: "ic:outline-sms",
status: "active",
successRate: 96.5,
quotaUsed: 34,
priority: 2,
},
]);
const pushProviders = ref([
{
id: 6,
name: "Firebase FCM",
description: "Android push notifications",
icon: "ic:outline-notifications",
status: "active",
successRate: 95.4,
quotaUsed: 45,
priority: 1,
},
{
id: 7,
name: "Apple APNs",
description: "iOS push notifications",
icon: "ic:outline-phone-iphone",
status: "active",
successRate: 94.8,
quotaUsed: 52,
priority: 1,
},
]);
const webhookProviders = ref([
{
id: 8,
name: "CRM Webhook",
url: "https://api.crm.com/webhooks/delivery",
icon: "ic:outline-webhook",
status: "active",
successRate: 99.1,
avgResponse: 150,
lastUsed: "2 min ago",
},
{
id: 9,
name: "Analytics Webhook",
url: "https://analytics.example.com/webhook",
icon: "ic:outline-analytics",
status: "active",
successRate: 98.7,
avgResponse: 89,
lastUsed: "5 min ago",
},
]);
// Fallback rules
const fallbackRules = ref([
{
id: 1,
name: "Email Provider Failover",
description: "Switch from SendGrid to Mailgun on failure",
primary: "SendGrid",
fallback: "Mailgun",
trigger: "API error or rate limit exceeded",
enabled: true,
},
{
id: 2,
name: "SMS Provider Failover",
description: "Switch from Twilio to Nexmo on failure",
primary: "Twilio",
fallback: "Nexmo",
trigger: "Delivery failure or timeout",
enabled: true,
},
{
id: 3,
name: "Email to SMS Fallback",
description: "Send SMS if email delivery fails",
primary: "Email Channel",
fallback: "SMS Channel",
trigger: "Hard bounce or 30s timeout",
enabled: false,
},
]);
// Form options
const channelOptions = [
{ label: "Email", value: "email" },
{ label: "SMS", value: "sms" },
{ label: "Push Notification", value: "push" },
{ label: "Webhook", value: "webhook" },
];
const emailProviderOptions = [
{ label: "Mailtrap", value: "mailtrap" },
{ label: "AWS SES", value: "aws-ses" },
];
const smsProviderOptions = [
{ label: "Twilio", value: "twilio" },
{ label: "Nexmo", value: "nexmo" },
{ label: "CM.com", value: "cm" },
];
// Methods
function addProvider(channel) {
selectedProvider.value = null;
providerForm.channel = channel;
providerForm.name = "";
providerForm.provider = "";
showProviderModal.value = true;
}
function configureProvider(provider) {
selectedProvider.value = provider;
// Populate form with provider data
providerForm.name = provider.name;
showProviderModal.value = true;
}
function saveProvider() {
console.log("Saving provider:", providerForm);
showProviderModal.value = false;
// Reset form
Object.keys(providerForm).forEach((key) => {
if (typeof providerForm[key] === "string") providerForm[key] = "";
if (typeof providerForm[key] === "number") providerForm[key] = 0;
});
}
function testProviderConnection() {
console.log("Testing provider connection...");
connectionTestResult.value = null;
// Simulate connection test
setTimeout(() => {
connectionTestResult.value = {
success: Math.random() > 0.3,
message:
Math.random() > 0.3
? "Connection successful"
: "Connection failed - Check credentials",
};
}, 2000);
}
function editFallbackRule(rule) {
console.log("Editing fallback rule:", rule);
// Implementation for editing fallback rules
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,822 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-webhook"></Icon>
<h1 class="text-xl font-bold text-primary">Webhook Management</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Configure and manage webhook endpoints for delivery status updates. Monitor webhook performance,
manage retry policies, and view delivery logs.
</p>
</template>
</rs-card>
<!-- Webhook Statistics -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(stat, index) in webhookStats"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-5 flex justify-center items-center rounded-2xl transition-all duration-300"
:class="stat.bgColor"
>
<Icon class="text-3xl" :name="stat.icon" :class="stat.iconColor"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight" :class="stat.textColor">
{{ stat.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ stat.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Webhook Endpoints -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-webhook"></Icon>
<h2 class="text-lg font-semibold text-primary">Webhook Endpoints</h2>
</div>
<rs-button variant="primary" @click="showAddWebhookModal = true">
<Icon class="mr-1" name="ic:outline-add"></Icon>
Add Webhook
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="webhook in webhooks"
:key="webhook.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-lg">{{ webhook.name }}</h3>
<rs-badge :variant="webhook.enabled ? 'success' : 'secondary'" size="sm">
{{ webhook.enabled ? 'Active' : 'Disabled' }}
</rs-badge>
<rs-badge :variant="getHealthVariant(webhook.health)" size="sm">
{{ webhook.health }}
</rs-badge>
</div>
<div class="text-sm text-gray-600 mb-2">{{ webhook.description }}</div>
<div class="font-mono text-sm bg-gray-50 p-2 rounded">{{ webhook.url }}</div>
</div>
<div class="flex gap-2 ml-4">
<rs-button size="sm" variant="secondary-outline" @click="editWebhook(webhook)">
<Icon name="ic:outline-edit"></Icon>
</rs-button>
<rs-button size="sm" variant="secondary-outline" @click="testWebhook(webhook)">
<Icon name="ic:outline-send"></Icon>
</rs-button>
<rs-button size="sm" variant="danger-outline" @click="deleteWebhook(webhook)">
<Icon name="ic:outline-delete"></Icon>
</rs-button>
</div>
</div>
<!-- Webhook Details -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Events</label>
<div class="flex flex-wrap gap-1">
<rs-badge
v-for="event in webhook.events"
:key="event"
variant="info"
size="xs"
>
{{ event }}
</rs-badge>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Success Rate</label>
<div class="font-semibold">{{ webhook.successRate }}%</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Avg Response Time</label>
<div class="font-semibold">{{ webhook.avgResponseTime }}ms</div>
</div>
</div>
<!-- Performance Chart -->
<div class="mb-4">
<label class="block text-xs font-medium text-gray-500 mb-2">Performance Trend (24h)</label>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="getPerformanceClass(webhook.performance)"
:style="{ width: webhook.performance + '%' }"
></div>
</div>
<div class="text-xs text-gray-500 mt-1">{{ webhook.performance }}% performance score</div>
</div>
<!-- Recent Deliveries -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-2">Recent Deliveries</label>
<div class="space-y-2">
<div
v-for="delivery in webhook.recentDeliveries"
:key="delivery.id"
class="flex items-center justify-between p-2 bg-gray-50 rounded text-sm"
>
<div class="flex items-center gap-2">
<Icon
:name="delivery.success ? 'ic:outline-check-circle' : 'ic:outline-error'"
:class="delivery.success ? 'text-green-500' : 'text-red-500'"
></Icon>
<span>{{ delivery.event }}</span>
<span class="text-gray-500">{{ delivery.timestamp }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-gray-500">{{ delivery.responseTime }}ms</span>
<span :class="delivery.success ? 'text-green-600' : 'text-red-600'">
{{ delivery.status }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Delivery Logs -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
<h2 class="text-lg font-semibold text-primary">Delivery Logs</h2>
</div>
<div class="flex gap-2">
<rs-button size="sm" variant="secondary-outline" @click="refreshLogs">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
<rs-button size="sm" variant="secondary-outline" @click="exportLogs">
<Icon class="mr-1" name="ic:outline-download"></Icon>
Export
</rs-button>
</div>
</div>
</template>
<template #body>
<!-- Filters -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<FormKit
type="select"
v-model="logFilters.webhook"
:options="webhookFilterOptions"
placeholder="All Webhooks"
label="Webhook"
/>
<FormKit
type="select"
v-model="logFilters.event"
:options="eventFilterOptions"
placeholder="All Events"
label="Event"
/>
<FormKit
type="select"
v-model="logFilters.status"
:options="statusFilterOptions"
placeholder="All Statuses"
label="Status"
/>
<FormKit
type="date"
v-model="logFilters.date"
label="Date"
/>
</div>
<!-- Logs Table -->
<rs-table
:field="logTableFields"
:data="filteredLogs"
:advanced="true"
:options="{ striped: true, hover: true }"
:optionsAdvanced="{
sortable: true,
filterable: true,
responsive: true,
}"
:pageSize="20"
>
<template #webhook="{ row }">
<div class="font-medium">{{ row.webhookName }}</div>
<div class="text-xs text-gray-500">{{ row.url }}</div>
</template>
<template #event="{ row }">
<rs-badge variant="info" size="sm">
{{ row.event }}
</rs-badge>
</template>
<template #status="{ row }">
<rs-badge :variant="row.success ? 'success' : 'danger'" size="sm">
{{ row.status }}
</rs-badge>
</template>
<template #responseTime="{ row }">
<span :class="row.responseTime > 1000 ? 'text-red-600' : 'text-green-600'">
{{ row.responseTime }}ms
</span>
</template>
<template #actions="{ row }">
<div class="flex gap-2">
<rs-button
size="sm"
variant="primary-outline"
@click="viewLogDetails(row)"
>
<Icon name="ic:outline-visibility"></Icon>
</rs-button>
<rs-button
size="sm"
variant="secondary-outline"
@click="retryWebhook(row)"
v-if="!row.success"
>
<Icon name="ic:outline-refresh"></Icon>
</rs-button>
</div>
</template>
</rs-table>
</template>
</rs-card>
<!-- Add/Edit Webhook Modal -->
<rs-modal v-model="showAddWebhookModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">{{ editingWebhook ? 'Edit' : 'Add' }} Webhook</h3>
</template>
<template #body>
<div class="space-y-6">
<!-- Basic Information -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="text"
v-model="webhookForm.name"
label="Webhook Name"
placeholder="Enter webhook name"
validation="required"
/>
<FormKit
type="url"
v-model="webhookForm.url"
label="Endpoint URL"
placeholder="https://api.example.com/webhook"
validation="required|url"
/>
</div>
<FormKit
type="textarea"
v-model="webhookForm.description"
label="Description"
placeholder="Describe what this webhook is used for"
rows="2"
/>
<!-- Events Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Events to Subscribe</label>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div
v-for="event in availableEvents"
:key="event.value"
class="flex items-center"
>
<FormKit
type="checkbox"
v-model="webhookForm.events"
:value="event.value"
:label="event.label"
/>
</div>
</div>
</div>
<!-- Security -->
<div>
<h4 class="font-semibold mb-3">Security Settings</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="password"
v-model="webhookForm.secret"
label="Secret Key"
placeholder="Optional secret for signature verification"
help="Used to sign webhook payloads for verification"
/>
<FormKit
type="select"
v-model="webhookForm.authType"
label="Authentication Type"
:options="authTypeOptions"
/>
</div>
<div v-if="webhookForm.authType === 'bearer'" class="mt-4">
<FormKit
type="password"
v-model="webhookForm.bearerToken"
label="Bearer Token"
placeholder="Enter bearer token"
/>
</div>
<div v-if="webhookForm.authType === 'basic'" class="grid grid-cols-2 gap-4 mt-4">
<FormKit
type="text"
v-model="webhookForm.username"
label="Username"
placeholder="Enter username"
/>
<FormKit
type="password"
v-model="webhookForm.password"
label="Password"
placeholder="Enter password"
/>
</div>
</div>
<!-- Retry Settings -->
<div>
<h4 class="font-semibold mb-3">Retry Settings</h4>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<FormKit
type="number"
v-model="webhookForm.maxRetries"
label="Max Retries"
placeholder="3"
min="0"
max="10"
/>
<FormKit
type="number"
v-model="webhookForm.retryDelay"
label="Retry Delay (seconds)"
placeholder="60"
min="1"
max="3600"
/>
<FormKit
type="number"
v-model="webhookForm.timeout"
label="Timeout (seconds)"
placeholder="30"
min="1"
max="300"
/>
</div>
</div>
<!-- Test Webhook -->
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="font-semibold mb-3">Test Webhook</h4>
<div class="flex items-center gap-3">
<rs-button variant="secondary" @click="testWebhookEndpoint">
<Icon class="mr-1" name="ic:outline-send"></Icon>
Send Test
</rs-button>
<div v-if="testResult" class="flex items-center gap-2">
<Icon
:name="testResult.success ? 'ic:outline-check-circle' : 'ic:outline-error'"
:class="testResult.success ? 'text-green-500' : 'text-red-500'"
></Icon>
<span :class="testResult.success ? 'text-green-600' : 'text-red-600'">
{{ testResult.message }}
</span>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showAddWebhookModal = false">Cancel</rs-button>
<rs-button variant="primary" @click="saveWebhook">
{{ editingWebhook ? 'Update' : 'Create' }} Webhook
</rs-button>
</div>
</template>
</rs-modal>
<!-- Log Details Modal -->
<rs-modal v-model="showLogModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">Webhook Delivery Details</h3>
</template>
<template #body>
<div v-if="selectedLog" class="space-y-6">
<!-- Basic Info -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Webhook</label>
<div class="font-medium">{{ selectedLog.webhookName }}</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Event</label>
<rs-badge variant="info">{{ selectedLog.event }}</rs-badge>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Status</label>
<rs-badge :variant="selectedLog.success ? 'success' : 'danger'">
{{ selectedLog.status }}
</rs-badge>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Response Time</label>
<div class="font-medium">{{ selectedLog.responseTime }}ms</div>
</div>
</div>
<!-- Request Details -->
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Request Payload</label>
<div class="bg-gray-50 rounded-lg p-3">
<pre class="text-xs overflow-x-auto">{{ JSON.stringify(selectedLog.payload, null, 2) }}</pre>
</div>
</div>
<!-- Response Details -->
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Response</label>
<div class="bg-gray-50 rounded-lg p-3">
<div class="text-sm mb-2">
<strong>Status Code:</strong> {{ selectedLog.responseCode }}
</div>
<div class="text-sm mb-2">
<strong>Headers:</strong>
</div>
<pre class="text-xs overflow-x-auto mb-2">{{ JSON.stringify(selectedLog.responseHeaders, null, 2) }}</pre>
<div class="text-sm mb-2">
<strong>Body:</strong>
</div>
<pre class="text-xs overflow-x-auto">{{ selectedLog.responseBody }}</pre>
</div>
</div>
<!-- Error Details (if any) -->
<div v-if="selectedLog.error">
<label class="block text-sm font-medium text-gray-500 mb-1">Error Details</label>
<div class="bg-red-50 border border-red-200 rounded-lg p-3">
<div class="text-sm text-red-800">{{ selectedLog.error }}</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showLogModal = false">Close</rs-button>
<rs-button
variant="primary"
@click="retryWebhook(selectedLog)"
v-if="!selectedLog?.success"
>
Retry Delivery
</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Webhook Management",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "Delivery Engine",
path: "/notification/delivery",
},
{
name: "Webhooks",
path: "/notification/delivery/webhooks",
type: "current",
},
],
});
import { ref, reactive, computed } from "vue";
// Modal states
const showAddWebhookModal = ref(false);
const showLogModal = ref(false);
const editingWebhook = ref(null);
const selectedLog = ref(null);
const testResult = ref(null);
// Form data
const webhookForm = reactive({
name: "",
url: "",
description: "",
events: [],
secret: "",
authType: "none",
bearerToken: "",
username: "",
password: "",
maxRetries: 3,
retryDelay: 60,
timeout: 30,
enabled: true,
});
// Filter states
const logFilters = reactive({
webhook: "",
event: "",
status: "",
date: "",
});
// Statistics
const webhookStats = ref([
{
title: "Active Webhooks",
value: "8",
icon: "ic:outline-webhook",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
textColor: "text-blue-600",
},
{
title: "Deliveries Today",
value: "1.2K",
icon: "ic:outline-send",
bgColor: "bg-green-100",
iconColor: "text-green-600",
textColor: "text-green-600",
},
{
title: "Success Rate",
value: "98.5%",
icon: "ic:outline-trending-up",
bgColor: "bg-purple-100",
iconColor: "text-purple-600",
textColor: "text-purple-600",
},
{
title: "Failed Deliveries",
value: "18",
icon: "ic:outline-error",
bgColor: "bg-red-100",
iconColor: "text-red-600",
textColor: "text-red-600",
},
]);
// Webhook data
const webhooks = ref([
{
id: 1,
name: "CRM Integration",
description: "Delivery status updates for CRM system",
url: "https://api.crm.com/webhooks/delivery",
enabled: true,
health: "Healthy",
events: ["delivered", "opened", "bounced"],
successRate: 99.2,
avgResponseTime: 150,
performance: 95,
recentDeliveries: [
{ id: 1, event: "delivered", timestamp: "2 min ago", success: true, status: "200", responseTime: 140 },
{ id: 2, event: "opened", timestamp: "5 min ago", success: true, status: "200", responseTime: 160 },
{ id: 3, event: "bounced", timestamp: "8 min ago", success: false, status: "500", responseTime: 0 },
],
},
{
id: 2,
name: "Analytics Platform",
description: "Send delivery metrics to analytics dashboard",
url: "https://analytics.example.com/webhook",
enabled: true,
health: "Warning",
events: ["sent", "delivered", "failed"],
successRate: 87.5,
avgResponseTime: 2100,
performance: 78,
recentDeliveries: [
{ id: 4, event: "sent", timestamp: "1 min ago", success: true, status: "200", responseTime: 1900 },
{ id: 5, event: "delivered", timestamp: "3 min ago", success: false, status: "timeout", responseTime: 0 },
{ id: 6, event: "failed", timestamp: "6 min ago", success: true, status: "200", responseTime: 2300 },
],
},
]);
// Delivery logs
const deliveryLogs = ref([
{
id: 1,
webhookId: 1,
webhookName: "CRM Integration",
url: "https://api.crm.com/webhooks/delivery",
event: "delivered",
success: true,
status: "200 OK",
responseTime: 140,
timestamp: "2024-01-15 10:30:00",
payload: {
messageId: "msg_001",
event: "delivered",
timestamp: "2024-01-15T10:30:00Z",
channel: "email",
recipient: "user@example.com"
},
responseCode: 200,
responseHeaders: { "content-type": "application/json" },
responseBody: '{"status": "received"}',
error: null,
},
// Add more logs...
]);
// Form options
const availableEvents = [
{ label: "Message Queued", value: "queued" },
{ label: "Message Sent", value: "sent" },
{ label: "Message Delivered", value: "delivered" },
{ label: "Message Opened", value: "opened" },
{ label: "Message Failed", value: "failed" },
{ label: "Message Bounced", value: "bounced" },
];
const authTypeOptions = [
{ label: "None", value: "none" },
{ label: "Bearer Token", value: "bearer" },
{ label: "Basic Auth", value: "basic" },
];
// Filter options
const webhookFilterOptions = computed(() => [
{ label: "All Webhooks", value: "" },
...webhooks.value.map(w => ({ label: w.name, value: w.id }))
]);
const eventFilterOptions = [
{ label: "All Events", value: "" },
...availableEvents,
];
const statusFilterOptions = [
{ label: "All Statuses", value: "" },
{ label: "Success", value: "success" },
{ label: "Failed", value: "failed" },
];
// Table configuration
const logTableFields = ref([
{ key: "timestamp", label: "Timestamp", sortable: true },
{ key: "webhook", label: "Webhook", sortable: true },
{ key: "event", label: "Event", sortable: true },
{ key: "status", label: "Status", sortable: true },
{ key: "responseTime", label: "Response Time", sortable: true },
{ key: "actions", label: "Actions", sortable: false },
]);
// Computed
const filteredLogs = computed(() => {
let filtered = deliveryLogs.value;
if (logFilters.webhook) {
filtered = filtered.filter(log => log.webhookId === logFilters.webhook);
}
if (logFilters.event) {
filtered = filtered.filter(log => log.event === logFilters.event);
}
if (logFilters.status) {
const isSuccess = logFilters.status === "success";
filtered = filtered.filter(log => log.success === isSuccess);
}
return filtered;
});
// Methods
function getHealthVariant(health) {
const variants = {
"Healthy": "success",
"Warning": "warning",
"Critical": "danger",
};
return variants[health] || "secondary";
}
function getPerformanceClass(performance) {
if (performance >= 90) return "bg-green-500";
if (performance >= 70) return "bg-yellow-500";
return "bg-red-500";
}
function editWebhook(webhook) {
editingWebhook.value = webhook;
// Populate form with webhook data
Object.keys(webhookForm).forEach(key => {
if (webhook[key] !== undefined) {
webhookForm[key] = webhook[key];
}
});
showAddWebhookModal.value = true;
}
function testWebhook(webhook) {
console.log("Testing webhook:", webhook.name);
// Implementation for testing webhook
}
function deleteWebhook(webhook) {
console.log("Deleting webhook:", webhook.name);
// Implementation for deleting webhook
}
function saveWebhook() {
console.log("Saving webhook:", webhookForm);
showAddWebhookModal.value = false;
// Reset form
Object.keys(webhookForm).forEach(key => {
if (typeof webhookForm[key] === 'string') webhookForm[key] = '';
if (typeof webhookForm[key] === 'number') webhookForm[key] = 0;
if (Array.isArray(webhookForm[key])) webhookForm[key] = [];
if (typeof webhookForm[key] === 'boolean') webhookForm[key] = true;
});
}
function testWebhookEndpoint() {
console.log("Testing webhook endpoint...");
testResult.value = null;
// Simulate test
setTimeout(() => {
testResult.value = {
success: Math.random() > 0.3,
message: Math.random() > 0.3 ? "Test successful" : "Connection failed - Check URL and credentials"
};
}, 2000);
}
function viewLogDetails(log) {
selectedLog.value = log;
showLogModal.value = true;
}
function retryWebhook(log) {
console.log("Retrying webhook delivery:", log);
// Implementation for retrying webhook delivery
}
function refreshLogs() {
console.log("Refreshing logs...");
// Implementation for refreshing logs
}
function exportLogs() {
console.log("Exporting logs...");
// Implementation for exporting logs
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,661 @@
<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>
Edit Notification
</div>
</template>
<template #body>
<p class="mb-4">
Edit and update your notification settings. Modify delivery options, content,
and targeting to improve your notification campaign effectiveness.
</p>
</template>
</rs-card>
<!-- 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 notification details...</p>
</div>
<!-- Error State -->
<rs-card v-else-if="error" class="mb-5">
<template #body>
<div class="text-center py-8">
<div class="flex justify-center mb-4">
<Icon name="ic:outline-error" size="4rem" class="text-red-400" />
</div>
<h3 class="text-lg font-medium text-gray-500 mb-2">
Error Loading Notification
</h3>
<p class="text-gray-500 mb-4">
{{ error }}
</p>
<rs-button @click="$router.push('/notification/list')">
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
Back to List
</rs-button>
</div>
</template>
</rs-card>
<!-- Notification Not Found -->
<rs-card v-else-if="!notification">
<template #body>
<div class="text-center py-8">
<div class="flex justify-center mb-4">
<Icon name="ic:outline-error" size="4rem" class="text-red-400" />
</div>
<h3 class="text-lg font-medium text-gray-500 mb-2">Notification Not Found</h3>
<p class="text-gray-500 mb-4">
The notification you're trying to edit doesn't exist or has been deleted.
</p>
<rs-button @click="$router.push('/notification/list')">
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
Back to List
</rs-button>
</div>
</template>
</rs-card>
<!-- Edit Form -->
<rs-card v-else>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">Edit Notification</h2>
<div class="flex gap-3">
<rs-button @click="$router.push('/notification/list')" variant="outline">
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
Back to List
</rs-button>
<rs-button @click="viewNotification" variant="primary">
<Icon name="ic:outline-visibility" class="mr-1"></Icon>
View Details
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="pt-2">
<FormKit
type="form"
@submit="handleUpdateNotification"
:actions="false"
class="w-full"
>
<!-- Basic Information -->
<div class="space-y-6 mb-8">
<h3
class="text-lg font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700 pb-2"
>
Basic Information
</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<FormKit
type="text"
name="title"
label="Notification Title"
placeholder="Enter notification title"
validation="required"
v-model="notificationForm.title"
help="This is for internal identification purposes"
/>
<FormKit
type="select"
name="type"
label="Notification Type"
:options="notificationTypes"
validation="required"
v-model="notificationForm.type"
help="Choose between single targeted notification or bulk notification"
/>
<FormKit
type="select"
name="priority"
label="Priority Level"
:options="priorityLevels"
validation="required"
v-model="notificationForm.priority"
help="Set the importance level of this notification"
/>
<FormKit
type="select"
name="category"
label="Category"
:options="categoryOptions"
validation="required"
v-model="notificationForm.category"
help="Categorize your notification for better organization"
/>
</div>
<!-- Right Column -->
<div class="space-y-4">
<FormKit
type="checkbox"
name="channels"
label="Delivery Channels"
:options="channelOptions"
validation="required|min:1"
v-model="notificationForm.channels"
decorator-icon="material-symbols:check"
options-class="grid grid-cols-1 gap-y-2 pt-1"
help="Select one or more delivery channels"
/>
<FormKit
v-if="notificationForm.channels.includes('email')"
type="text"
name="emailSubject"
label="Email Subject Line"
placeholder="Enter email subject"
validation="required"
v-model="notificationForm.emailSubject"
help="This will be the email subject line"
/>
<FormKit
type="datetime-local"
name="expiresAt"
label="Expiration Date & Time (Optional)"
v-model="notificationForm.expiresAt"
help="Set when this notification should expire"
/>
</div>
</div>
</div>
<!-- Scheduling -->
<div class="space-y-6 mb-8">
<h3
class="text-lg font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700 pb-2"
>
Scheduling
</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<FormKit
type="radio"
name="deliveryType"
label="Delivery Schedule"
:options="deliveryTypes"
validation="required"
v-model="notificationForm.deliveryType"
decorator-icon="material-symbols:radio-button-checked"
options-class="space-y-3 pt-1"
/>
<div
v-if="notificationForm.deliveryType === 'scheduled'"
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
>
<FormKit
type="datetime-local"
name="scheduledAt"
label="Scheduled Date & Time"
validation="required"
v-model="notificationForm.scheduledAt"
help="When should this notification be sent?"
/>
<FormKit
type="select"
name="timezone"
label="Timezone"
:options="timezoneOptions"
validation="required"
v-model="notificationForm.timezone"
help="Select the timezone for scheduling"
/>
</div>
</div>
<div
class="space-y-4"
v-if="notificationForm.deliveryType === 'scheduled'"
>
<div class="p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/20">
<h4 class="font-semibold text-blue-800 dark:text-blue-200 mb-2">
Scheduling Information
</h4>
<div class="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<p v-if="notificationForm.scheduledAt">
<strong>Scheduled for:</strong>
{{ formatScheduledTime() }}
</p>
<p>
<strong>Timezone:</strong>
{{ notificationForm.timezone }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Target Audience -->
<div class="space-y-6 mb-8">
<h3
class="text-lg font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700 pb-2"
>
Target Audience
</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<FormKit
type="radio"
name="audienceType"
label="Target Audience"
:options="audienceTypeOptions"
validation="required"
v-model="notificationForm.audienceType"
decorator-icon="material-symbols:radio-button-checked"
options-class="space-y-3 pt-1"
/>
<div
v-if="notificationForm.audienceType === 'specific'"
class="space-y-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800"
>
<FormKit
type="textarea"
name="specificUsers"
label="Specific Users"
placeholder="Enter email addresses or user IDs (one per line)"
validation="required"
v-model="notificationForm.specificUsers"
rows="6"
help="Enter one email address or user ID per line"
/>
</div>
</div>
<div class="space-y-4">
<div class="p-4 border rounded-lg bg-green-50 dark:bg-green-900/20">
<h4 class="font-semibold text-green-800 dark:text-green-200 mb-2">
Estimated Reach
</h4>
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ estimatedReach.toLocaleString() }} users
</div>
<p class="text-sm text-green-700 dark:text-green-300 mt-1">
Based on your audience selection
</p>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div
class="flex justify-between items-center pt-6 border-t border-gray-200 dark:border-gray-700"
>
<div class="flex gap-3">
<rs-button @click="handleSaveDraft" variant="outline">
<Icon name="material-symbols:save" class="mr-1"></Icon>
Save as Draft
</rs-button>
</div>
<div class="flex gap-3">
<rs-button @click="$router.push('/notification/list')" variant="outline">
Cancel
</rs-button>
<rs-button btnType="submit">
<Icon name="material-symbols:update" class="mr-1"></Icon>
Update Notification
</rs-button>
</div>
</div>
</FormKit>
</div>
</template>
</rs-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { useNotifications } from "~/composables/useNotifications";
const router = useRouter();
const nuxtApp = useNuxtApp();
definePageMeta({
title: "Edit Notification",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "List",
path: "/notification/list",
},
{
name: "Edit",
path: "",
type: "current",
},
],
});
// Get route params
const route = useRoute();
const notificationId = route.params.id;
// Use the notifications composable
const {
isLoading,
error,
getNotificationById,
updateNotification,
saveDraft,
testSendNotification,
getAudiencePreview,
} = useNotifications();
// Reactive data
const notification = ref(null);
const testEmail = ref("");
const estimatedReach = ref(0);
// Form data
const notificationForm = ref({
title: "",
type: "single",
priority: "medium",
category: "",
channels: [],
emailSubject: "",
expiresAt: "",
deliveryType: "immediate",
scheduledAt: "",
timezone: "UTC",
audienceType: "all",
specificUsers: "",
userSegments: [],
userStatus: "",
registrationPeriod: "",
excludeUnsubscribed: true,
});
// Form options
const notificationTypes = [
{ label: "Single Notification", value: "single" },
{ label: "Bulk Notification", value: "bulk" },
];
const priorityLevels = [
{ label: "Low", value: "low" },
{ label: "Medium", value: "medium" },
{ label: "High", value: "high" },
{ label: "Critical", value: "critical" },
];
const categoryOptions = [
{ 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 channelOptions = [
{ label: "Email", value: "email" },
{ label: "Push Notification", value: "push" },
];
const deliveryTypes = [
{ label: "Send Immediately", value: "immediate" },
{ label: "Schedule for Later", value: "scheduled" },
];
const timezoneOptions = [
{ label: "UTC", value: "UTC" },
{ label: "Asia/Kuala_Lumpur", value: "Asia/Kuala_Lumpur" },
{ label: "America/New_York", value: "America/New_York" },
{ label: "Europe/London", value: "Europe/London" },
{ label: "Asia/Tokyo", value: "Asia/Tokyo" },
];
const audienceTypeOptions = [
{ label: "All Users", value: "all" },
{ label: "Specific Users", value: "specific" },
];
// Methods
const formatScheduledTime = () => {
if (!notificationForm.value.scheduledAt) return "";
return new Date(notificationForm.value.scheduledAt).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const viewNotification = () => {
router.push(`/notification/view/${notificationId}`);
};
const handleSaveDraft = async () => {
try {
await saveDraft(notificationForm.value);
nuxtApp.$swal.fire("Success", "Notification saved as draft", "success");
} catch (error) {
console.error("Error saving draft:", error);
nuxtApp.$swal.fire("Error", "Failed to save draft", "error");
}
};
const handleUpdateNotification = async () => {
try {
// Validate channels data
if (
!Array.isArray(notificationForm.value.channels) ||
notificationForm.value.channels.length === 0
) {
throw new Error("Please select at least one delivery channel");
}
// Validate that all selected channels are valid
const validChannels = channelOptions.map((option) => option.value);
const hasInvalidChannel = notificationForm.value.channels.some(
(channel) => !validChannels.includes(channel)
);
if (hasInvalidChannel) {
throw new Error("Invalid delivery channel selected");
}
// Show loading indicator
const loadingSwal = nuxtApp.$swal.fire({
title: "Updating...",
text: "Please wait while we update the notification",
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
nuxtApp.$swal.showLoading();
},
});
// Transform data to match API expectations (camelCase to snake_case)
const formData = {
title: notificationForm.value.title,
type: notificationForm.value.type,
priority: notificationForm.value.priority,
// Send null for category_id instead of the value
category_id: null, // We'll omit this field and let the server keep the existing value
status: notificationForm.value.status || "active",
delivery_type: notificationForm.value.deliveryType,
scheduled_at: notificationForm.value.scheduledAt,
timezone: notificationForm.value.timezone,
expires_at: notificationForm.value.expiresAt,
audience_type: notificationForm.value.audienceType,
specific_users: notificationForm.value.specificUsers,
user_segments: notificationForm.value.userSegments || [],
user_status: notificationForm.value.userStatus,
registration_period: notificationForm.value.registrationPeriod,
exclude_unsubscribed: notificationForm.value.excludeUnsubscribed,
email_subject: notificationForm.value.emailSubject,
// Transform channels to API format
channels: notificationForm.value.channels.map((channel) => ({
type: channel,
is_enabled: true,
})),
};
console.log("Sending update with data:", formData);
const response = await updateNotification(notificationId, formData);
console.log("Update response:", response);
// Close loading indicator
loadingSwal.close();
if (response && response.success) {
nuxtApp.$swal
.fire({
title: "Success!",
text: "Notification has been updated successfully.",
icon: "success",
confirmButtonText: "Back to List",
})
.then((result) => {
if (result.isConfirmed) {
router.push("/notification/list");
}
});
} else {
throw new Error(response?.message || "Update failed with unknown error");
}
} catch (error) {
console.error("Error updating notification:", error);
const errorMessage =
error.data?.message || error.message || "Failed to update notification";
nuxtApp.$swal.fire("Error", errorMessage, "error");
}
};
const loadNotification = async () => {
try {
const data = await getNotificationById(notificationId);
notification.value = data;
console.log("Retrieved notification data:", data);
// Transform channels data from backend format to frontend format
let channels = [];
if (Array.isArray(data.channels)) {
channels = data.channels;
} else if (Array.isArray(data.notification_channels)) {
channels = data.notification_channels.map((c) => c.channel_type);
}
// Populate form with existing data
notificationForm.value = {
title: data.title,
type: data.type || "single",
priority: data.priority,
category: data.category?.value,
channels: channels,
emailSubject: data.emailSubject,
expiresAt: data.expiresAt,
deliveryType: data.deliveryType,
scheduledAt: data.scheduledAt,
timezone: data.timezone || "UTC",
audienceType: data.audienceType,
specificUsers: data.specificUsers,
userSegments: data.userSegments || [],
userStatus: data.userStatus,
registrationPeriod: data.registrationPeriod,
excludeUnsubscribed: data.excludeUnsubscribed !== false,
};
console.log("Populated form:", notificationForm.value);
// Update estimated reach
estimatedReach.value = data.analytics?.estimatedReach || 0;
} catch (error) {
console.error("Error loading notification:", error);
notification.value = null;
nuxtApp.$swal
.fire({
title: "Error",
text: "Failed to load notification details",
icon: "error",
confirmButtonText: "Back to List",
})
.then((result) => {
if (result.isConfirmed) {
router.push("/notification/list");
}
});
}
};
// Watch for audience type changes to update estimated reach
watch(
() => [
notificationForm.value.audienceType,
notificationForm.value.specificUsers,
notificationForm.value.userSegments,
notificationForm.value.userStatus,
notificationForm.value.registrationPeriod,
notificationForm.value.excludeUnsubscribed,
notificationForm.value.channels,
],
async () => {
try {
// Skip if audience type is not set
if (!notificationForm.value.audienceType) return;
const response = await getAudiencePreview({
audienceType: notificationForm.value.audienceType,
specificUsers: notificationForm.value.specificUsers,
userSegments: notificationForm.value.userSegments || [],
userStatus: notificationForm.value.userStatus,
registrationPeriod: notificationForm.value.registrationPeriod,
excludeUnsubscribed: notificationForm.value.excludeUnsubscribed,
channels: notificationForm.value.channels,
});
if (response.success) {
estimatedReach.value = response.data.totalCount;
}
} catch (error) {
console.error("Error getting audience preview:", error);
// Don't show error to user for background calculation
estimatedReach.value = 0;
}
},
{ deep: true }
);
// Lifecycle
onMounted(() => {
loadNotification();
});
</script>

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

View File

@@ -0,0 +1,329 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-bar-chart"></Icon>
<h1 class="text-xl font-bold text-primary">Analytics Dashboard</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
View metrics for notification performance, delivery rates, and user engagement.
</p>
</template>
</rs-card>
<!-- Key Metrics Summary -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(metric, index) in keyMetrics"
:key="index"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl"
>
<Icon class="text-primary text-3xl" :name="metric.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight text-primary">
{{ metric.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ metric.title }}
</span>
<div class="flex items-center mt-1" v-if="metric.change">
<Icon
:name="metric.trend === 'up' ? 'ic:outline-trending-up' : 'ic:outline-trending-down'"
:class="metric.trend === 'up' ? 'text-green-500' : 'text-red-500'"
class="text-sm mr-1"
/>
<span
:class="metric.trend === 'up' ? 'text-green-600' : 'text-red-600'"
class="text-xs font-medium"
>
{{ metric.change }}
</span>
</div>
</div>
</div>
</rs-card>
</div>
<!-- Time Range Filter -->
<rs-card class="mb-6">
<template #body>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h3 class="text-lg font-semibold text-primary">Analytics Period</h3>
<FormKit
type="select"
v-model="selectedPeriod"
:options="periodOptions"
outer-class="mb-0"
@input="updateAnalytics"
/>
</div>
<rs-button variant="primary-outline" size="sm" @click="refreshAnalytics">
<Icon name="ic:outline-refresh" class="mr-1"/> Refresh
</rs-button>
</div>
</template>
</rs-card>
<div v-if="analyticsLoading" class="flex justify-center py-16">
<Loading />
</div>
<template v-else>
<!-- Main Analytics Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<rs-card>
<template #header>
<div class="flex items-center">
<Icon name="ic:outline-bar-chart" class="mr-2 text-primary"/>
<h3 class="text-lg font-semibold text-primary">Delivery Rate Analysis</h3>
</div>
</template>
<template #body>
<div class="h-64 bg-gray-100 dark:bg-gray-800 flex items-center justify-center rounded">
<div class="text-center">
<p class="text-gray-500">Delivery success over time</p>
</div>
</div>
<div class="mt-4 grid grid-cols-3 gap-4 text-center">
<div>
<div class="text-2xl font-bold text-green-600">{{ channelPerformance[0]?.successRate || '0' }}%</div>
<div class="text-sm text-gray-600">Success Rate</div>
</div>
<div>
<div class="text-2xl font-bold text-red-600">{{ channelPerformance[0]?.failureRate || '0' }}%</div>
<div class="text-sm text-gray-600">Failed Rate</div>
</div>
<div>
<div class="text-2xl font-bold text-yellow-600">{{ channelPerformance[0]?.bounceRate || '0' }}%</div>
<div class="text-sm text-gray-600">Bounce Rate</div>
</div>
</div>
</template>
</rs-card>
<rs-card>
<template #header>
<div class="flex items-center">
<Icon name="ic:outline-device-hub" class="mr-2 text-primary"/>
<h3 class="text-lg font-semibold text-primary">Channel Performance</h3>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="channel in channelPerformance"
:key="channel.name"
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Icon :name="channel.icon" class="mr-2 text-primary"/>
<span class="font-medium">{{ channel.name }}</span>
</div>
<span class="text-sm font-bold text-primary">{{ channel.successRate }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-primary h-2 rounded-full"
:style="{ width: channel.successRate + '%' }"
></div>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>{{ channel.sent }} sent</span>
<span>{{ channel.failed }} failed</span>
</div>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Recent Analytics Events -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon name="ic:outline-insights" class="mr-2 text-primary"/>
<h3 class="text-lg font-semibold text-primary">Recent Events</h3>
</div>
<rs-button variant="outline" size="sm" @click="navigateTo('/notification/log-audit/logs')">
View All Logs
</rs-button>
</div>
</template>
<template #body>
<div v-if="recentEvents.length > 0" class="space-y-3">
<div
v-for="(event, index) in recentEvents"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center">
<div
class="w-3 h-3 rounded-full mr-3"
:class="{
'bg-green-500': event.type === 'success',
'bg-yellow-500': event.type === 'warning',
'bg-red-500': event.type === 'error',
'bg-blue-500': event.type === 'info',
}"
></div>
<div>
<p class="font-medium">{{ event.title }}</p>
<p class="text-sm text-gray-600">{{ event.description }}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium">{{ event.value }}</p>
<p class="text-xs text-gray-500">{{ event.time }}</p>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-gray-500">
<Icon name="ic:outline-search-off" class="text-4xl mb-2 mx-auto" />
<p>No recent events found.</p>
</div>
</template>
</rs-card>
</template>
</div>
</template>
<script setup>
definePageMeta({
title: "Analytics Dashboard",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "Logs & Audit Trail",
path: "/notification/log-audit",
},
{
name: "Analytics",
path: "/notification/log-audit/analytics",
type: "current"
},
],
});
import { ref, computed, onMounted } from 'vue'
// Use the notification logs composable
const {
analyticsData,
analyticsLoading,
analyticsError,
fetchAnalytics,
formatDate
} = useNotificationLogs()
// Time period selection
const selectedPeriod = ref('7d')
const periodOptions = [
{ label: 'Last 24 Hours', value: '1d' },
{ label: 'Last 7 Days', value: '7d' },
{ label: 'Last 30 Days', value: '30d' },
{ label: 'Last 90 Days', value: '90d' },
{ label: 'Last 12 Months', value: '12m' },
]
// Key metrics data - will be updated from API
const keyMetrics = computed(() => analyticsData.value?.keyMetrics || [
{
title: "Total Sent",
value: "0",
icon: "ic:outline-send",
trend: "up",
change: "+0.0%"
},
{
title: "Success Rate",
value: "0.0%",
icon: "ic:outline-check-circle",
trend: "up",
change: "+0.0%"
},
{
title: "Open Rate",
value: "0.0%",
icon: "ic:outline-open-in-new",
trend: "up",
change: "+0.0%"
},
{
title: "Click Rate",
value: "0.0%",
icon: "ic:outline-touch-app",
trend: "up",
change: "+0.0%"
},
])
// Channel performance data - will be updated from API
const channelPerformance = computed(() => analyticsData.value?.channelPerformance || [
{
name: "Email",
icon: "ic:outline-email",
successRate: "0",
sent: "0",
failed: "0",
bounceRate: "0",
failureRate: "0"
},
])
// Recent analytics events from API
const recentEvents = computed(() => analyticsData.value?.recentEvents || [])
// Methods
const updateAnalytics = async () => {
await fetchAnalytics(selectedPeriod.value)
}
const refreshAnalytics = async () => {
await fetchAnalytics(selectedPeriod.value)
}
// Load initial data
onMounted(async () => {
await fetchAnalytics(selectedPeriod.value)
})
</script>
<style lang="scss" scoped>
:deep(.formkit-outer) {
margin-bottom: 0;
}
:deep(.formkit-label) {
font-weight: 500;
color: rgb(107 114 128);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
:deep(.formkit-input) {
border-radius: 0.5rem;
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-assessment"></Icon>
<h1 class="text-xl font-bold text-primary">Notification Logs & Audit Trail</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Track notification activities, monitor performance, and ensure compliance with
detailed audit trails.
</p>
</template>
</rs-card>
<!-- Summary Stats -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card v-for="(item, index) in summaryStats" :key="index">
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl">
<Icon class="text-primary text-3xl" :name="item.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight text-primary">
{{ item.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ item.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Navigation Cards -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6 mb-6">
<rs-card
v-for="(feature, index) in features"
:key="index"
class="cursor-pointer"
@click="navigateTo(feature.path)"
>
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" :name="feature.icon"></Icon>
<h3 class="text-lg font-semibold text-primary">{{ feature.title }}</h3>
</div>
</template>
<template #body>
<p class="text-gray-600 mb-4">{{ feature.description }}</p>
</template>
<template #footer>
<div class="flex justify-end">
<rs-button variant="outline" size="sm">
<Icon class="mr-1" name="ic:outline-arrow-forward"></Icon>
Open
</rs-button>
</div>
</template>
</rs-card>
</div>
<!-- Recent Logs Section -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
<h3 class="text-lg font-semibold text-primary">Recent Logs</h3>
</div>
<rs-button
variant="outline"
size="sm"
@click="navigateTo('/notification/log-audit/logs')"
>
View All
</rs-button>
</div>
</template>
<template #body>
<div v-if="loading" class="flex justify-center py-8">
<Loading />
</div>
<rs-table
v-else-if="logs && logs.length > 0"
:data="logs"
:field="logTableFields"
:options="{
variant: 'default',
striped: true,
borderless: false,
hover: true,
}"
:options-advanced="{
sortable: true,
responsive: true,
outsideBorder: true,
}"
advanced
>
<template #timestamp="{ value }">
<div class="text-sm">
<div class="font-medium">{{ formatDate(value.created_at, true) }}</div>
<div class="text-gray-500 text-xs">
{{ new Date(value.created_at).toLocaleTimeString() }}
</div>
</div>
</template>
<template #status="{ value }">
<rs-badge
:variant="
value.status === 'Sent'
? 'success'
: value.status === 'Failed'
? 'danger'
: value.status === 'Opened'
? 'info'
: value.status === 'Queued'
? 'warning'
: 'secondary'
"
size="sm"
>
{{ value.status }}
</rs-badge>
</template>
<template #actions="{ value }">
<rs-button variant="primary-text" size="sm" @click="viewLogDetails(value)">
<Icon name="ic:outline-visibility" class="mr-1" /> View
</rs-button>
</template>
</rs-table>
<div v-else class="text-center py-8 text-gray-500">
<Icon name="ic:outline-search-off" class="text-4xl mb-2 mx-auto" />
<p>No log entries found.</p>
</div>
</template>
</rs-card>
<!-- Log Details Modal -->
<rs-modal
v-model="isLogDetailModalOpen"
title="Log Entry Details"
size="lg"
:overlay-close="true"
>
<template #body>
<div v-if="selectedLog" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Log ID</label>
<p class="font-mono text-sm">{{ selectedLog.id }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1"
>Timestamp</label
>
<p class="text-sm">{{ formatDate(selectedLog.created_at, true) }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Action</label>
<p class="text-sm">{{ selectedLog.action }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Actor</label>
<p class="text-sm">{{ selectedLog.actor }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Channel</label>
<p class="text-sm">{{ selectedLog.channel_type }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Status</label>
<rs-badge
:variant="
selectedLog.status === 'Sent'
? 'success'
: selectedLog.status === 'Failed'
? 'danger'
: selectedLog.status === 'Opened'
? 'info'
: selectedLog.status === 'Queued'
? 'warning'
: 'secondary'
"
size="sm"
>
{{ selectedLog.status }}
</rs-badge>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1"
>Source IP</label
>
<p class="text-sm font-mono">{{ selectedLog.source_ip }}</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Details</label>
<p class="text-sm p-3 bg-gray-50 dark:bg-gray-800 rounded">
{{ selectedLog.details }}
</p>
</div>
<template v-if="selectedLog.status === 'Failed'">
<div class="border-t pt-4">
<h4 class="text-lg font-medium text-red-600 dark:text-red-400 mb-3">
Error Information
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500 mb-1"
>Error Code</label
>
<p class="text-sm font-mono text-red-600 dark:text-red-400">
{{ selectedLog.error_code }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1"
>Error Message</label
>
<p class="text-sm text-red-600 dark:text-red-400">
{{ selectedLog.error_message }}
</p>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer>
<rs-button @click="isLogDetailModalOpen = false" variant="primary-outline">
Close
</rs-button>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Notification Logs & Audit Trail",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "Logs & Audit Trail",
path: "/notification/log-audit",
type: "current",
},
],
});
import { ref, computed, onMounted } from "vue";
// Use the notification logs composable
const {
logs,
loading,
error,
summaryStats: rawSummaryStats,
fetchLogs,
formatDate,
} = useNotificationLogs();
// Transform summary stats from object to array format for display
const summaryStats = computed(() => {
if (!rawSummaryStats.value) return [];
return [
{
title: "Total Logs",
value: rawSummaryStats.value.totalLogs || 0,
icon: "ic:outline-article",
},
{
title: "Successful Deliveries",
value: rawSummaryStats.value.successfulDeliveries || 0,
icon: "ic:outline-mark-email-read",
},
{
title: "Failed Deliveries",
value: rawSummaryStats.value.failedDeliveries || 0,
icon: "ic:outline-error-outline",
},
{
title: "Success Rate",
value: `${rawSummaryStats.value.successRate || 0}%`,
icon: "ic:outline-insights",
},
];
});
// Navigation features
const features = ref([
{
title: "Analytics Dashboard",
description:
"View metrics and trends for notification performance and delivery rates.",
icon: "ic:outline-bar-chart",
path: "/notification/log-audit/analytics",
},
{
title: "Audit Logs",
description:
"Detailed logs of all notification activities with filtering capabilities.",
icon: "ic:outline-list-alt",
path: "/notification/log-audit/logs",
},
{
title: "Reports",
description: "Generate and export reports for compliance and analysis purposes.",
icon: "ic:outline-file-download",
path: "/notification/log-audit/reports",
},
]);
// Fields for RsTable
const logTableFields = [
"timestamp",
"action",
"actor",
"channel_type",
"status",
"actions",
];
// Log detail modal
const isLogDetailModalOpen = ref(false);
const selectedLog = ref(null);
const viewLogDetails = (log) => {
selectedLog.value = log;
isLogDetailModalOpen.value = true;
};
// Fetch logs on component mount
onMounted(async () => {
try {
await fetchLogs();
} catch (err) {
console.error("Error loading logs:", err);
}
});
</script>
<style lang="scss" scoped>
:deep(.formkit-outer) {
margin-bottom: 0;
}
:deep(.formkit-label) {
font-weight: 500;
color: rgb(107 114 128);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
:deep(.formkit-input) {
border-radius: 0.5rem;
}
</style>

View File

@@ -0,0 +1,410 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-list-alt"></Icon>
<h1 class="text-xl font-bold text-primary">Audit Logs</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
View notification logs and filter by date, channel, status, or content.
</p>
</template>
</rs-card>
<!-- Summary Stats -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(item, index) in summaryStats"
:key="index"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl"
>
<Icon class="text-primary text-3xl" :name="item.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight text-primary">
{{ item.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ item.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Main Content Card -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
<h3 class="text-lg font-semibold text-primary">All Audit Logs</h3>
</div>
<div class="flex items-center gap-2">
<rs-button variant="primary-outline" size="sm" @click="refreshLogs">
<Icon name="ic:outline-refresh" class="mr-1"/> Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<!-- Filters Section -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<FormKit
type="text"
label="Actor"
v-model="filters.actor"
placeholder="Enter user or actor"
outer-class="mb-0"
/>
<FormKit
type="select"
label="Channel"
v-model="filters.channel"
placeholder="Select channel"
:options="availableChannels"
outer-class="mb-0"
/>
<FormKit
type="select"
label="Status"
v-model="filters.status"
placeholder="Select status"
:options="availableStatuses"
outer-class="mb-0"
/>
<FormKit
type="search"
label="Search"
v-model="filters.keyword"
placeholder="Search in logs..."
outer-class="mb-0"
/>
</div>
<div class="flex justify-between items-center mt-4">
<div class="text-sm text-gray-600" v-if="!loading">
Showing {{ logs.length }} entries
</div>
<div class="flex gap-2">
<rs-button @click="clearFilters" variant="secondary-outline" size="sm">
<Icon name="ic:outline-clear" class="mr-1"/> Clear
</rs-button>
<rs-button @click="applyFilters(filters)" variant="primary">
<Icon name="ic:outline-search" class="mr-1"/> Apply
</rs-button>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex justify-center py-8">
<Loading />
</div>
<!-- Log Table -->
<rs-table
v-else-if="logs && logs.length > 0"
:data="logs"
:field="logTableFields"
:options="{
variant: 'default',
striped: true,
borderless: false,
hover: true
}"
:options-advanced="{
sortable: true,
responsive: true,
outsideBorder: true
}"
advanced
>
<template #timestamp="{ value }">
<div class="text-sm">
<div class="font-medium">{{ formatDate(value.created_at, true) }}</div>
<div class="text-gray-500 text-xs">{{ new Date(value.created_at).toLocaleTimeString() }}</div>
</div>
</template>
<template #status="{ value }">
<rs-badge
:variant="value.status === 'Sent' ? 'success' :
value.status === 'Failed' ? 'danger' :
value.status === 'Opened' ? 'info' :
value.status === 'Queued' ? 'warning' : 'secondary'"
size="sm"
>
{{ value.status }}
</rs-badge>
</template>
<template #actions="{ value }">
<rs-button variant="primary-text" size="sm" @click="viewLogDetails(value)">
<Icon name="ic:outline-visibility" class="mr-1"/> View
</rs-button>
</template>
</rs-table>
<!-- Empty State -->
<div v-else class="text-center py-8 text-gray-500">
<Icon name="ic:outline-search-off" class="text-4xl mb-2 mx-auto"/>
<p>No log entries found matching your filters.</p>
<rs-button variant="primary-outline" @click="clearFilters" class="mt-4">
Clear Filters
</rs-button>
</div>
<!-- Pagination -->
<div v-if="logs && logs.length > 0" class="flex justify-center mt-6">
<div class="flex items-center gap-1">
<rs-button
variant="primary-text"
size="sm"
:disabled="pagination.page === 1"
@click="changePage(pagination.page - 1)"
>
<Icon name="ic:outline-chevron-left"/>
</rs-button>
<div class="flex gap-1">
<rs-button
v-for="p in paginationButtons"
:key="p"
variant="primary-text"
size="sm"
:class="pagination.page === p ? 'bg-primary/10' : ''"
@click="changePage(p)"
>
{{ p }}
</rs-button>
</div>
<rs-button
variant="primary-text"
size="sm"
:disabled="pagination.page === pagination.pages"
@click="changePage(pagination.page + 1)"
>
<Icon name="ic:outline-chevron-right"/>
</rs-button>
</div>
</div>
</template>
</rs-card>
<!-- Log Details Modal -->
<rs-modal
v-model="isLogDetailModalOpen"
title="Log Entry Details"
size="lg"
:overlay-close="true"
>
<template #body>
<div v-if="selectedLog" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Log ID</label>
<p class="font-mono text-sm">{{ selectedLog.id }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Timestamp</label>
<p class="text-sm">{{ formatDate(selectedLog.created_at, true) }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Action</label>
<p class="text-sm">{{ selectedLog.action }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Actor</label>
<p class="text-sm">{{ selectedLog.actor }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Channel</label>
<p class="text-sm">{{ selectedLog.channel_type }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Status</label>
<rs-badge
:variant="selectedLog.status === 'Sent' ? 'success' :
selectedLog.status === 'Failed' ? 'danger' :
selectedLog.status === 'Opened' ? 'info' :
selectedLog.status === 'Queued' ? 'warning' : 'secondary'"
size="sm"
>
{{ selectedLog.status }}
</rs-badge>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Source IP</label>
<p class="text-sm font-mono">{{ selectedLog.source_ip }}</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Details</label>
<p class="text-sm p-3 bg-gray-50 dark:bg-gray-800 rounded">{{ selectedLog.details }}</p>
</div>
<template v-if="selectedLog.status === 'Failed'">
<div class="border-t pt-4">
<h4 class="text-lg font-medium text-red-600 dark:text-red-400 mb-3">Error Information</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Error Code</label>
<p class="text-sm font-mono text-red-600 dark:text-red-400">{{ selectedLog.error_code }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Error Message</label>
<p class="text-sm text-red-600 dark:text-red-400">{{ selectedLog.error_message }}</p>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer>
<rs-button @click="isLogDetailModalOpen = false" variant="primary-outline">
Close
</rs-button>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Audit Logs",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "Logs & Audit Trail",
path: "/notification/log-audit",
},
{
name: "Audit Logs",
path: "/notification/log-audit/logs",
type: "current"
},
],
});
import { ref, computed, onMounted } from 'vue'
// Use the notification logs composable
const {
logs,
loading,
error,
pagination,
summaryStats,
filters,
fetchLogs,
applyFilters,
clearFilters,
changePage,
formatDate,
availableActions,
availableChannels,
availableStatuses
} = useNotificationLogs()
// Computed property for pagination buttons
const paginationButtons = computed(() => {
const current = pagination.value.page
const total = pagination.value.pages
const buttons = []
if (total <= 7) {
// Less than 7 pages, show all
for (let i = 1; i <= total; i++) {
buttons.push(i)
}
} else {
// Always show first page
buttons.push(1)
if (current > 3) {
buttons.push('...')
}
// Pages around current
const start = Math.max(2, current - 1)
const end = Math.min(current + 1, total - 1)
for (let i = start; i <= end; i++) {
buttons.push(i)
}
if (current < total - 2) {
buttons.push('...')
}
// Always show last page
buttons.push(total)
}
return buttons
})
// Fields for RsTable
const logTableFields = ['timestamp', 'action', 'actor', 'channel_type', 'status', 'actions']
// Log detail modal
const isLogDetailModalOpen = ref(false)
const selectedLog = ref(null)
const viewLogDetails = (log) => {
selectedLog.value = log
isLogDetailModalOpen.value = true
}
const refreshLogs = async () => {
await fetchLogs()
}
// Fetch logs on component mount
onMounted(async () => {
await fetchLogs()
})
</script>
<style lang="scss" scoped>
:deep(.formkit-outer) {
margin-bottom: 0;
}
:deep(.formkit-label) {
font-weight: 500;
color: rgb(107 114 128);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
:deep(.formkit-input) {
border-radius: 0.5rem;
}
</style>

View File

@@ -0,0 +1,689 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-monitor"></Icon>
<h1 class="text-xl font-bold text-primary">Real-Time Monitoring</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Live monitoring of notification system performance with real-time alerts and
system health indicators. Track ongoing activities, monitor system load, and
receive immediate notifications about issues.
</p>
</template>
</rs-card>
<!-- System Status Overview -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(status, index) in systemStatus"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-5 flex justify-center items-center rounded-2xl transition-all duration-300"
:class="
status.status === 'healthy'
? 'bg-green-100'
: status.status === 'warning'
? 'bg-yellow-100'
: 'bg-red-100'
"
>
<Icon
class="text-3xl"
:class="
status.status === 'healthy'
? 'text-green-600'
: status.status === 'warning'
? 'text-yellow-600'
: 'text-red-600'
"
:name="status.icon"
/>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight text-primary">
{{ status.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ status.title }}
</span>
<div class="flex items-center mt-1">
<div
class="w-2 h-2 rounded-full mr-2"
:class="
status.status === 'healthy'
? 'bg-green-500'
: status.status === 'warning'
? 'bg-yellow-500'
: 'bg-red-500'
"
></div>
<span
class="text-xs font-medium capitalize"
:class="
status.status === 'healthy'
? 'text-green-600'
: status.status === 'warning'
? 'text-yellow-600'
: 'text-red-600'
"
>
{{ status.status }}
</span>
</div>
</div>
</div>
</rs-card>
</div>
<!-- Real-Time Controls -->
<rs-card class="mb-6">
<template #body>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h3 class="text-lg font-semibold text-primary">Monitoring Controls</h3>
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full animate-pulse"
:class="isMonitoring ? 'bg-green-500' : 'bg-gray-400'"
></div>
<span class="text-sm font-medium">
{{ isMonitoring ? "Live" : "Paused" }}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<FormKit
type="select"
v-model="refreshInterval"
:options="refreshOptions"
outer-class="mb-0"
@input="updateRefreshInterval"
/>
<rs-button
:variant="isMonitoring ? 'danger-outline' : 'primary'"
size="sm"
@click="toggleMonitoring"
>
<Icon
:name="isMonitoring ? 'ic:outline-pause' : 'ic:outline-play-arrow'"
class="mr-1"
/>
{{ isMonitoring ? "Pause" : "Start" }}
</rs-button>
<rs-button variant="primary-outline" size="sm" @click="refreshData">
<Icon name="ic:outline-refresh" class="mr-1" /> Refresh
</rs-button>
</div>
</div>
</template>
</rs-card>
<!-- System Performance Dashboard -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon name="ic:outline-speed" class="mr-2 text-primary" />
<h3 class="text-lg font-semibold text-primary">System Performance</h3>
</div>
<rs-button variant="outline" size="sm" @click="exportPerformanceData">
<Icon name="ic:outline-file-download" class="mr-1" /> Export Data
</rs-button>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<!-- CPU Usage -->
<div class="text-center">
<div class="relative mx-auto w-32 h-32 mb-4">
<div
class="w-full h-full bg-gray-200 rounded-full flex items-center justify-center"
>
<div class="text-center">
<div class="text-2xl font-bold text-primary">
{{ performanceMetrics.cpu }}%
</div>
<div class="text-xs text-gray-600">CPU</div>
</div>
</div>
</div>
</div>
<!-- Memory Usage -->
<div class="text-center">
<div class="relative mx-auto w-32 h-32 mb-4">
<div
class="w-full h-full bg-gray-200 rounded-full flex items-center justify-center"
>
<div class="text-center">
<div class="text-2xl font-bold text-primary">
{{ performanceMetrics.memory }}%
</div>
<div class="text-xs text-gray-600">Memory</div>
</div>
</div>
</div>
</div>
<!-- Queue Load -->
<div class="text-center">
<div class="relative mx-auto w-32 h-32 mb-4">
<div
class="w-full h-full bg-gray-200 rounded-full flex items-center justify-center"
>
<div class="text-center">
<div class="text-2xl font-bold text-primary">
{{ performanceMetrics.queueLoad }}%
</div>
<div class="text-xs text-gray-600">Queue Load</div>
</div>
</div>
</div>
</div>
</div>
<!-- Performance Chart Placeholder -->
<div
class="h-64 bg-gray-100 dark:bg-gray-800 flex items-center justify-center rounded"
>
<div class="text-center">
<Icon name="ic:outline-show-chart" class="text-4xl text-gray-400 mb-2" />
<p class="text-gray-500">Real-time Performance Chart</p>
<p class="text-sm text-gray-400 mt-1">
Implementation pending for live performance metrics
</p>
</div>
</div>
</template>
</rs-card>
<!-- Live Activity & Alerts Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Live Activity Feed -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon name="ic:outline-notifications-active" class="mr-2 text-primary" />
<h3 class="text-lg font-semibold text-primary">Live Activity Feed</h3>
</div>
<div class="flex items-center gap-2">
<rs-button variant="outline" size="sm" @click="clearActivityFeed">
<Icon name="ic:outline-clear" class="mr-1" /> Clear
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="h-96 overflow-y-auto space-y-3">
<div
v-for="(activity, index) in liveActivityFeed"
:key="index"
class="flex items-start p-3 bg-gray-50 dark:bg-gray-800 rounded-lg transition-all duration-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<div
class="w-3 h-3 rounded-full mr-3 mt-2 flex-shrink-0"
:class="{
'bg-green-500': activity.type === 'success',
'bg-blue-500': activity.type === 'info',
'bg-yellow-500': activity.type === 'warning',
'bg-red-500': activity.type === 'error',
}"
></div>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm">{{ activity.action }}</p>
<p class="text-xs text-gray-600 mt-1">{{ activity.details }}</p>
<div class="flex items-center mt-2 text-xs text-gray-500">
<Icon name="ic:outline-access-time" class="mr-1" />
<span>{{ activity.timestamp }}</span>
<span class="mx-2"></span>
<span>{{ activity.source }}</span>
</div>
</div>
</div>
<div
v-if="liveActivityFeed.length === 0"
class="text-center py-8 text-gray-500"
>
<Icon name="ic:outline-wifi-tethering" class="text-3xl mb-2 mx-auto" />
<p>Waiting for live activity...</p>
</div>
</div>
</template>
</rs-card>
<!-- Error Alerts -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon name="ic:outline-warning" class="mr-2 text-primary" />
<h3 class="text-lg font-semibold text-primary">Error Alerts</h3>
</div>
<div class="flex items-center gap-2">
<rs-badge
:variant="
errorAlerts.filter((a) => a.severity === 'critical').length > 0
? 'danger'
: 'secondary'
"
size="sm"
>
{{ errorAlerts.length }} Active
</rs-badge>
<rs-button variant="outline" size="sm" @click="acknowledgeAllAlerts">
<Icon name="ic:outline-check" class="mr-1" /> Acknowledge All
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="h-96 overflow-y-auto space-y-3">
<div
v-for="(alert, index) in errorAlerts"
:key="index"
class="p-3 rounded-lg border-l-4 transition-all duration-300 hover:shadow-sm"
:class="{
'bg-red-50 border-red-400': alert.severity === 'critical',
'bg-yellow-50 border-yellow-400': alert.severity === 'warning',
'bg-blue-50 border-blue-400': alert.severity === 'info',
}"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center">
<Icon
:name="
alert.severity === 'critical'
? 'ic:outline-error'
: alert.severity === 'warning'
? 'ic:outline-warning'
: 'ic:outline-info'
"
:class="{
'text-red-600': alert.severity === 'critical',
'text-yellow-600': alert.severity === 'warning',
'text-blue-600': alert.severity === 'info',
}"
class="mr-2"
/>
<span class="font-medium text-sm">{{ alert.title }}</span>
</div>
<p class="text-xs text-gray-600 mt-1">{{ alert.description }}</p>
<div class="flex items-center mt-2 text-xs text-gray-500">
<span>{{ alert.timestamp }}</span>
<span class="mx-2"></span>
<span>{{ alert.component }}</span>
</div>
</div>
<rs-button
variant="outline"
size="sm"
@click="acknowledgeAlert(index)"
class="ml-2"
>
<Icon name="ic:outline-check" />
</rs-button>
</div>
</div>
<div v-if="errorAlerts.length === 0" class="text-center py-8 text-gray-500">
<Icon
name="ic:outline-check-circle"
class="text-3xl mb-2 mx-auto text-green-500"
/>
<p>No active alerts</p>
<p class="text-sm text-gray-400 mt-1">All systems operating normally</p>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Queue Status -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon name="ic:outline-queue" class="mr-2 text-primary" />
<h3 class="text-lg font-semibold text-primary">Queue Status</h3>
</div>
<rs-button
variant="outline"
size="sm"
@click="navigateTo('/notification/queue-scheduler/monitor')"
>
View Queue Monitor
</rs-button>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="queue in queueStatus"
:key="queue.name"
class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center justify-between mb-2">
<span class="font-medium">{{ queue.name }}</span>
<rs-badge
:variant="
queue.status === 'active'
? 'success'
: queue.status === 'warning'
? 'warning'
: 'danger'
"
size="sm"
>
{{ queue.status }}
</rs-badge>
</div>
<div class="text-2xl font-bold text-primary mb-1">{{ queue.count }}</div>
<div class="text-sm text-gray-600">{{ queue.description }}</div>
<div class="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
class="h-2 rounded-full transition-all duration-300"
:class="
queue.status === 'active'
? 'bg-green-500'
: queue.status === 'warning'
? 'bg-yellow-500'
: 'bg-red-500'
"
:style="{ width: queue.utilization + '%' }"
></div>
</div>
<div class="text-xs text-gray-500 mt-1">
{{ queue.utilization }}% utilized
</div>
</div>
</div>
</template>
</rs-card>
<!-- Recent Logs -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon name="ic:outline-history" class="mr-2 text-primary" />
<h3 class="text-lg font-semibold text-primary">Recent Activity Logs</h3>
</div>
<rs-button
variant="outline"
size="sm"
@click="navigateTo('/notification/log-audit/logs')"
>
View All Logs
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-3">
<div
v-for="(log, index) in recentLogs"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center">
<div
class="w-3 h-3 rounded-full mr-3"
:class="{
'bg-green-500': log.status === 'sent' || log.status === 'created',
'bg-yellow-500': log.status === 'queued',
'bg-red-500': log.status === 'failed',
'bg-blue-500': log.status === 'opened',
}"
></div>
<div>
<p class="font-medium">{{ log.action }}</p>
<p class="text-sm text-gray-600">{{ log.description }}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium capitalize">{{ log.status }}</p>
<p class="text-xs text-gray-500">{{ log.time }}</p>
</div>
</div>
</div>
</template>
</rs-card>
</div>
</template>
<script setup>
definePageMeta({
title: "Real-Time Monitoring",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "Logs & Audit Trail",
path: "/notification/log-audit",
},
{
name: "Monitoring",
path: "/notification/log-audit/monitoring",
type: "current",
},
],
});
import { ref, computed, onMounted, onUnmounted } from "vue";
// Use the notification logs composable
const {
monitoringData,
monitoringLoading,
monitoringError,
fetchMonitoringData,
formatTimeAgo,
} = useNotificationLogs();
// Monitoring state
const isMonitoring = ref(true);
const refreshInterval = ref("5s");
const refreshIntervalId = ref(null);
const refreshOptions = [
{ label: "1 second", value: "1s" },
{ label: "5 seconds", value: "5s" },
{ label: "10 seconds", value: "10s" },
{ label: "30 seconds", value: "30s" },
{ label: "1 minute", value: "1m" },
];
// System status data - will be updated from API
const systemStatus = computed(
() =>
monitoringData.value?.systemStatus || [
{
title: "System Health",
value: "Healthy",
icon: "ic:outline-favorite",
status: "healthy",
},
{
title: "Throughput",
value: "0/hr",
icon: "ic:outline-speed",
status: "healthy",
},
{
title: "Error Rate",
value: "0.00%",
icon: "ic:outline-error-outline",
status: "healthy",
},
{
title: "Response Time",
value: "0ms",
icon: "ic:outline-timer",
status: "healthy",
},
]
);
// Performance metrics - will be updated from API
const performanceMetrics = computed(
() =>
monitoringData.value?.performanceMetrics || {
cpu: 0,
memory: 0,
queueLoad: 0,
}
);
// Live activity feed - will be updated from API
const liveActivityFeed = computed(() => monitoringData.value?.recentActivity || []);
// Error alerts - will be updated from API
const errorAlerts = computed(() => monitoringData.value?.errorAlerts || []);
// Queue status - will be updated from API
const queueStatus = computed(() => monitoringData.value?.queueStatus || []);
// Recent logs - using the same activity feed
const recentLogs = computed(() => liveActivityFeed.value);
// Methods
const toggleMonitoring = () => {
isMonitoring.value = !isMonitoring.value;
if (isMonitoring.value) {
startMonitoring();
} else {
stopMonitoring();
}
};
const updateRefreshInterval = () => {
if (isMonitoring.value) {
stopMonitoring();
startMonitoring();
}
};
const startMonitoring = () => {
const intervalMs =
{
"1s": 1000,
"5s": 5000,
"10s": 10000,
"30s": 30000,
"1m": 60000,
}[refreshInterval.value] || 5000;
// Fetch immediately
fetchMonitoringData();
// Then set up the interval
refreshIntervalId.value = setInterval(async () => {
await fetchMonitoringData();
}, intervalMs);
};
const stopMonitoring = () => {
if (refreshIntervalId.value) {
clearInterval(refreshIntervalId.value);
refreshIntervalId.value = null;
}
};
const refreshData = async () => {
await fetchMonitoringData();
};
const clearActivityFeed = () => {
// For now, just refresh the data - in a real app, you might have an API endpoint to clear the feed
fetchMonitoringData();
};
const acknowledgeAlert = (index) => {
// In a real app, you would call an API to acknowledge the alert
errorAlerts.value.splice(index, 1);
};
const acknowledgeAllAlerts = () => {
// In a real app, you would call an API to acknowledge all alerts
errorAlerts.value = [];
};
const exportPerformanceData = () => {
console.log("Exporting performance data...");
alert("Exporting performance data. (Implementation pending)");
};
// Lifecycle
onMounted(() => {
if (isMonitoring.value) {
startMonitoring();
}
});
onUnmounted(() => {
stopMonitoring();
});
</script>
<style lang="scss" scoped>
// Custom styles for FormKit consistency
:deep(.formkit-outer) {
margin-bottom: 0;
}
:deep(.formkit-label) {
font-weight: 500;
color: rgb(107 114 128);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
:deep(.formkit-input) {
border-radius: 0.5rem;
}
// Badge component styles (if RsBadge doesn't exist, these can be adjusted)
.rs-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.rs-badge.variant-success {
@apply bg-green-100 text-green-800;
}
.rs-badge.variant-danger {
@apply bg-red-100 text-red-800;
}
.rs-badge.variant-warning {
@apply bg-yellow-100 text-yellow-800;
}
.rs-badge.variant-info {
@apply bg-blue-100 text-blue-800;
}
.rs-badge.variant-secondary {
@apply bg-gray-100 text-gray-800;
}
</style>

View File

@@ -0,0 +1,439 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Page Info Card -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-file-download"></Icon>
<h1 class="text-xl font-bold text-primary">Reports & Export</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Generate reports and export log data for compliance, auditing, and analysis.
</p>
</template>
</rs-card>
<!-- Quick Export Actions -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(action, index) in quickExportActions"
:key="index"
class="cursor-pointer"
@click="quickExport(action.type)"
>
<div class="pt-5 pb-3 px-5 text-center">
<div
class="p-5 flex justify-center items-center bg-primary/20 rounded-2xl mx-auto mb-4 w-fit"
>
<Icon class="text-primary text-3xl" :name="action.icon"></Icon>
</div>
<span class="block font-bold text-lg leading-tight text-primary mb-2">
{{ action.title }}
</span>
<span class="text-sm text-gray-600">
{{ action.description }}
</span>
</div>
</rs-card>
</div>
<!-- Custom Report Builder -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon name="ic:outline-build" class="mr-2 text-primary" />
<h3 class="text-lg font-semibold text-primary">Custom Report Builder</h3>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Report Configuration -->
<div class="space-y-4">
<FormKit
type="text"
label="Report Name"
v-model="customReport.name"
placeholder="Enter report name"
/>
<FormKit
type="select"
label="Report Type"
v-model="customReport.type"
:options="reportTypeOptions"
placeholder="Select report type"
/>
<FormKit
type="select"
label="Date Range"
v-model="customReport.dateRange"
:options="dateRangeOptions"
placeholder="Select date range"
/>
<FormKit
type="select"
label="Export Format"
v-model="customReport.format"
:options="exportFormatOptions"
placeholder="Select format"
/>
<FormKit
type="checkbox"
label="Include Channels"
v-model="customReport.channels"
:options="channelOptions"
/>
<FormKit
type="checkbox"
label="Include Status"
v-model="customReport.statuses"
:options="statusOptions"
/>
</div>
<!-- Report Preview -->
<div class="space-y-4">
<h4 class="text-lg font-semibold">Report Preview</h4>
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg min-h-64">
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="font-medium">Report Name:</span>
<span class="text-primary">{{
customReport.name || "Untitled Report"
}}</span>
</div>
<div class="flex justify-between items-center">
<span class="font-medium">Type:</span>
<span>{{ getReportTypeLabel(customReport.type) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="font-medium">Date Range:</span>
<span>{{ getDateRangeLabel(customReport.dateRange) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="font-medium">Format:</span>
<span>{{ getFormatLabel(customReport.format) }}</span>
</div>
<div class="flex justify-between items-start">
<span class="font-medium">Channels:</span>
<div class="text-right">
<div v-if="customReport.channels.length === 0" class="text-gray-500">
All channels
</div>
<div v-else class="space-y-1">
<div
v-for="channel in customReport.channels"
:key="channel"
class="text-sm"
>
{{ channel }}
</div>
</div>
</div>
</div>
<div class="flex justify-between items-start">
<span class="font-medium">Status:</span>
<div class="text-right">
<div v-if="customReport.statuses.length === 0" class="text-gray-500">
All statuses
</div>
<div v-else class="space-y-1">
<div
v-for="status in customReport.statuses"
:key="status"
class="text-sm"
>
{{ status }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-2">
<rs-button
@click="generateCustomReport"
variant="primary"
:disabled="!customReport.name || !customReport.type"
class="flex-1"
>
<Icon name="ic:outline-play-arrow" class="mr-1" /> Generate Report
</rs-button>
<rs-button @click="saveReportTemplate" variant="secondary-outline">
<Icon name="ic:outline-save" class="mr-1" /> Save Template
</rs-button>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Export History -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon name="ic:outline-history" class="mr-2 text-primary" />
<h3 class="text-lg font-semibold text-primary">Export History</h3>
</div>
</div>
</template>
<template #body>
<div class="space-y-3">
<div
v-for="(export_, index) in exportHistory"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div class="flex items-center">
<Icon
:name="
export_.format === 'pdf'
? 'ic:outline-picture-as-pdf'
: export_.format === 'csv'
? 'ic:outline-table-chart'
: 'ic:outline-grid-on'
"
class="mr-3 text-primary text-xl"
/>
<div>
<p class="font-medium">{{ export_.name }}</p>
<p class="text-sm text-gray-600">{{ export_.description }}</p>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-right">
<p class="text-sm font-medium">{{ export_.size }}</p>
<p class="text-xs text-gray-500">{{ export_.timestamp }}</p>
</div>
<rs-badge
:variant="
export_.status === 'completed'
? 'success'
: export_.status === 'processing'
? 'warning'
: 'danger'
"
size="sm"
>
{{ export_.status }}
</rs-badge>
<div class="flex gap-1">
<rs-button
v-if="export_.status === 'completed'"
variant="primary-text"
size="sm"
@click="downloadExport(export_)"
>
<Icon name="ic:outline-download" />
</rs-button>
<rs-button variant="danger-text" size="sm" @click="deleteExport(index)">
<Icon name="ic:outline-delete" />
</rs-button>
</div>
</div>
</div>
<div v-if="exportHistory.length === 0" class="text-center py-8 text-gray-500">
<Icon name="ic:outline-folder-open" class="text-4xl mb-2 mx-auto" />
<p>No export history available</p>
</div>
</div>
</template>
</rs-card>
</div>
</template>
<script setup>
definePageMeta({
title: "Reports & Export",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Dashboard",
path: "/dashboard",
},
{
name: "Notification",
path: "/notification",
},
{
name: "Logs & Audit Trail",
path: "/notification/log-audit",
},
{
name: "Reports",
path: "/notification/log-audit/reports",
type: "current",
},
],
});
import { ref, computed } from "vue";
// Quick export actions
const quickExportActions = ref([
{
title: "CSV Export",
description: "Export current data to CSV format",
icon: "ic:outline-table-chart",
type: "csv",
},
{
title: "PDF Report",
description: "Generate comprehensive PDF report",
icon: "ic:outline-picture-as-pdf",
type: "pdf",
},
{
title: "Excel Export",
description: "Export data to Excel spreadsheet",
icon: "ic:outline-grid-on",
type: "excel",
},
{
title: "JSON Export",
description: "Export raw data in JSON format",
icon: "ic:outline-code",
type: "json",
},
]);
// Custom report builder
const customReport = ref({
name: "",
type: "",
dateRange: "",
format: "",
channels: [],
statuses: [],
});
const reportTypeOptions = [
{ label: "Delivery Report", value: "delivery" },
{ label: "Performance Analytics", value: "performance" },
{ label: "Error Analysis", value: "errors" },
{ label: "User Engagement", value: "engagement" },
{ label: "Channel Comparison", value: "channels" },
{ label: "Audit Trail", value: "audit" },
];
const dateRangeOptions = [
{ label: "Last 24 Hours", value: "1d" },
{ label: "Last 7 Days", value: "7d" },
{ label: "Last 30 Days", value: "30d" },
{ label: "Last 90 Days", value: "90d" },
{ label: "Last 12 Months", value: "12m" },
{ label: "Custom Range", value: "custom" },
];
const exportFormatOptions = [
{ label: "CSV", value: "csv" },
{ label: "PDF", value: "pdf" },
{ label: "Excel", value: "excel" },
{ label: "JSON", value: "json" },
];
const channelOptions = [
{ label: "Email", value: "Email" },
{ label: "SMS", value: "SMS" },
{ label: "Push Notification", value: "Push Notification" },
{ label: "Webhook", value: "Webhook" },
];
const statusOptions = [
{ label: "Sent", value: "Sent" },
{ label: "Failed", value: "Failed" },
{ label: "Bounced", value: "Bounced" },
{ label: "Opened", value: "Opened" },
{ label: "Queued", value: "Queued" },
];
// Export history
const exportHistory = ref([
{
name: "Notification Analytics Report",
description: "Monthly performance analysis",
format: "pdf",
size: "2.4 MB",
timestamp: "2 hours ago",
status: "completed",
},
{
name: "Delivery Logs Export",
description: "Last 30 days delivery data",
format: "csv",
size: "856 KB",
timestamp: "1 day ago",
status: "completed",
},
]);
// Helper functions
const getReportTypeLabel = (value) => {
const option = reportTypeOptions.find((opt) => opt.value === value);
return option ? option.label : "Not selected";
};
const getDateRangeLabel = (value) => {
const option = dateRangeOptions.find((opt) => opt.value === value);
return option ? option.label : "Not selected";
};
const getFormatLabel = (value) => {
const option = exportFormatOptions.find((opt) => opt.value === value);
return option ? option.label : "Not selected";
};
// Methods
const quickExport = (type) => {
console.log(`Quick export: ${type}`);
alert(`Exporting data in ${type} format. (Implementation pending)`);
};
const generateCustomReport = () => {
console.log("Generating custom report:", customReport.value);
alert(`Generating custom report: ${customReport.value.name}. (Implementation pending)`);
};
const saveReportTemplate = () => {
console.log("Saving report template:", customReport.value);
alert(`Saving report template: ${customReport.value.name}. (Implementation pending)`);
};
const downloadExport = (export_) => {
console.log("Downloading export:", export_);
alert(`Downloading ${export_.name}. (Implementation pending)`);
};
const deleteExport = (index) => {
exportHistory.value.splice(index, 1);
};
</script>
<style lang="scss" scoped>
:deep(.formkit-outer) {
margin-bottom: 1rem;
}
:deep(.formkit-label) {
font-weight: 500;
color: rgb(107 114 128);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
:deep(.formkit-input) {
border-radius: 0.5rem;
}
</style>

View File

@@ -0,0 +1,642 @@
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
title: "Admin: User Preferences & System Settings",
middleware: ["auth"],
requiresAuth: true,
});
const currentTab = ref('categories'); // categories, channels, frequencies, globalQuietHours, userAudit, bulkOps
// --- Admin: Notification Categories ---
const adminNotificationCategories = ref([
{ id: 'cat_promo', name: 'Promotions & Offers', description: 'Updates on new promotions, discounts, etc.', defaultSubscribed: true, isActive: true },
{ id: 'cat_alerts', name: 'Critical Alerts', description: 'Important security or account issue alerts.', defaultSubscribed: true, isActive: true },
{ id: 'cat_updates', name: 'Product Updates', description: 'New features, improvements, system maintenance.', defaultSubscribed: true, isActive: true },
{ id: 'cat_newsletter', name: 'Newsletter', description: 'Regular news and tips.', defaultSubscribed: false, isActive: true },
{ id: 'cat_surveys', name: 'Feedback Surveys', description: 'Occasional surveys to improve our service.', defaultSubscribed: false, isActive: false },
]);
const showCategoryModal = ref(false);
const editingCategory = ref(null);
const categoryForm = ref({ id: null, name: '', description: '', defaultSubscribed: false, isActive: true });
function openAddCategoryModal() {
editingCategory.value = null;
categoryForm.value = { id: `cat_${Date.now().toString().slice(-4)}`, name: '', description: '', defaultSubscribed: false, isActive: true };
showCategoryModal.value = true;
}
function openEditCategoryModal(category) {
editingCategory.value = { ...category };
categoryForm.value = { ...category };
showCategoryModal.value = true;
}
function saveCategory() {
if (editingCategory.value && editingCategory.value.id) {
const index = adminNotificationCategories.value.findIndex(c => c.id === editingCategory.value.id);
if (index !== -1) {
adminNotificationCategories.value[index] = { ...categoryForm.value };
}
} else {
adminNotificationCategories.value.push({ ...categoryForm.value, id: categoryForm.value.id || `cat_${Date.now().toString().slice(-4)}` });
}
showCategoryModal.value = false;
}
function toggleCategoryStatus(category) {
category.isActive = !category.isActive;
}
// --- Admin: Channels ---
const adminChannels = ref([
{ id: 'email', name: 'Email', isEnabled: true, defaultFrequencyCap: 'No Limit', supportedMessageTypes: ['cat_promo', 'cat_alerts', 'cat_updates', 'cat_newsletter'] },
{ id: 'sms', name: 'SMS', isEnabled: true, defaultFrequencyCap: '5 per day', supportedMessageTypes: ['cat_alerts'] },
{ id: 'push', name: 'Push Notifications', isEnabled: false, defaultFrequencyCap: '10 per day', supportedMessageTypes: ['cat_alerts', 'cat_updates'] },
]);
// --- Admin: Frequencies ---
const adminFrequencies = ref([
{ id: 'freq_immediate', label: 'Immediate', value: 'immediate', isUserSelectable: true, isDefault: true },
{ id: 'freq_hourly', label: 'Hourly Digest', value: 'hourly', isUserSelectable: true, isDefault: false },
{ id: 'freq_daily', label: 'Daily Digest', value: 'daily', isUserSelectable: true, isDefault: false },
{ id: 'freq_weekly', label: 'Weekly Digest', value: 'weekly', isUserSelectable: true, isDefault: false }, // Made user selectable for demo
{ id: 'freq_monthly', label: 'Monthly Summary', value: 'monthly', isUserSelectable: false, isDefault: false },
]);
const showFrequencyModal = ref(false);
const editingFrequency = ref(null);
const frequencyForm = ref({ id: null, label: '', value: '', isUserSelectable: true, isDefault: false });
function openAddFrequencyModal() {
editingFrequency.value = null;
frequencyForm.value = { id: `freq_${Date.now().toString().slice(-4)}`, label: '', value: '', isUserSelectable: true, isDefault: false };
showFrequencyModal.value = true;
}
function openEditFrequencyModal(freq) {
editingFrequency.value = { ...freq };
frequencyForm.value = { ...freq };
showFrequencyModal.value = true;
}
function saveFrequency() {
if (editingFrequency.value && editingFrequency.value.id) {
const index = adminFrequencies.value.findIndex(f => f.id === editingFrequency.value.id);
if (index !== -1) {
adminFrequencies.value[index] = { ...frequencyForm.value };
}
} else {
adminFrequencies.value.push({ ...frequencyForm.value, id: frequencyForm.value.id || `freq_${Date.now().toString().slice(-4)}` });
}
showFrequencyModal.value = false;
}
function deleteFrequency(freqId) {
if (confirm(`Are you sure you want to delete frequency option with ID: ${freqId}?`)) {
adminFrequencies.value = adminFrequencies.value.filter(f => f.id !== freqId);
}
}
// --- Admin: Global Quiet Hours ---
const adminGlobalQuietHours = ref({
enabled: false,
startTime: '22:00',
endTime: '07:00',
allowUserOverride: true,
});
// --- Admin: User Preference Audit ---
const userAuditSearchQuery = ref('');
const searchedUser = ref(null); // Will hold structure like: { id: 'user123', name: 'John Doe', preferences: { defaultChannel: 'email', subscriptions: { 'cat_promo': { subscribed: true, channel: 'email', frequency: 'weekly' } }, quietHours: { enabled: false, startTime: '22:00', endTime: '07:00'} } }
const isSearchingUser = ref(false);
const userPreferencesForm = ref(null); // For editing the searched user's prefs
// Mock user data - in a real app, this would come from an API
const mockUsers = [
{
id: 'user001',
name: 'Alice Wonderland',
email: 'alice@example.com',
preferences: {
defaultPreferredChannel: 'email',
subscriptions: {
'cat_promo': { subscribed: true, channel: 'email', frequency: 'freq_weekly' },
'cat_alerts': { subscribed: true, channel: 'sms', frequency: 'freq_immediate' },
'cat_updates': { subscribed: false, channel: 'email', frequency: 'freq_daily' },
'cat_newsletter': { subscribed: true, channel: 'email', frequency: 'freq_monthly' }
},
quietHours: { enabled: false, startTime: '22:00', endTime: '08:00' }
}
},
{
id: 'user002',
name: 'Bob The Builder',
email: 'bob@example.com',
preferences: {
defaultPreferredChannel: 'sms',
subscriptions: {
'cat_promo': { subscribed: false, channel: 'email', frequency: 'freq_weekly' },
'cat_alerts': { subscribed: true, channel: 'sms', frequency: 'freq_immediate' },
'cat_updates': { subscribed: true, channel: 'push', frequency: 'freq_daily' }, // Assuming push is a configured channel id
'cat_newsletter': { subscribed: false, channel: 'email', frequency: 'freq_monthly' }
},
quietHours: { enabled: true, startTime: '23:00', endTime: '07:30' }
}
}
];
function handleUserSearch() {
if (!userAuditSearchQuery.value.trim()) {
searchedUser.value = null;
userPreferencesForm.value = null;
return;
}
isSearchingUser.value = true;
setTimeout(() => { // Simulate API call
const found = mockUsers.find(u => u.id.includes(userAuditSearchQuery.value.trim()) || u.name.toLowerCase().includes(userAuditSearchQuery.value.trim().toLowerCase()) || u.email.toLowerCase().includes(userAuditSearchQuery.value.trim().toLowerCase()));
if (found) {
searchedUser.value = JSON.parse(JSON.stringify(found)); // Deep copy
// Initialize form data by ensuring all admin-defined categories are present
const prefsCopy = JSON.parse(JSON.stringify(found.preferences));
adminNotificationCategories.value.forEach(adminCat => {
if (!prefsCopy.subscriptions[adminCat.id]) {
prefsCopy.subscriptions[adminCat.id] = { subscribed: false, channel: found.preferences.defaultPreferredChannel, frequency: adminFrequencies.value.find(f=>f.isDefault)?.id || adminFrequencies.value[0]?.id };
}
});
userPreferencesForm.value = prefsCopy;
} else {
searchedUser.value = null;
userPreferencesForm.value = null;
alert('User not found.');
}
isSearchingUser.value = false;
}, 1000);
}
function saveUserPreferences() {
if (!searchedUser.value || !userPreferencesForm.value) return;
// In a real app, send userPreferencesForm.value to the backend to save.
// For this mock, update the mockUsers array or searchedUser directly.
const userIndex = mockUsers.findIndex(u => u.id === searchedUser.value.id);
if (userIndex !== -1) {
mockUsers[userIndex].preferences = JSON.parse(JSON.stringify(userPreferencesForm.value));
}
searchedUser.value.preferences = JSON.parse(JSON.stringify(userPreferencesForm.value)); // Update current view
alert(`Preferences for ${searchedUser.value.name} saved (mock).`);
}
function cancelUserEdit() {
if (searchedUser.value) {
// Re-initialize form from original searchedUser data if needed, or just clear
const prefsCopy = JSON.parse(JSON.stringify(searchedUser.value.preferences));
adminNotificationCategories.value.forEach(adminCat => {
if (!prefsCopy.subscriptions[adminCat.id]) {
prefsCopy.subscriptions[adminCat.id] = { subscribed: false, channel: searchedUser.value.preferences.defaultPreferredChannel, frequency: adminFrequencies.value.find(f=>f.isDefault)?.id || adminFrequencies.value[0]?.id };
}
});
userPreferencesForm.value = prefsCopy;
} else {
userPreferencesForm.value = null;
}
}
// --- Admin: Bulk Operations ---
const handleAdminImport = () => {
alert('Admin bulk import initiated (not implemented).');
};
const handleAdminExport = () => {
alert('Admin bulk export initiated (not implemented).');
};
const tabs = [
{ key: 'categories', label: 'Notification Categories' },
{ key: 'channels', label: 'Channels & Governance' },
{ key: 'frequencies', label: 'Frequency Options' },
{ key: 'globalQuietHours', label: 'Global Quiet Hours' },
{ key: 'userAudit', label: 'User Preference Audit' },
{ key: 'bulkOps', label: 'Bulk Operations' },
];
const getChannelName = (channelId) => {
const channel = adminChannels.value.find(c => c.id === channelId);
return channel ? channel.name : channelId;
};
const getFrequencyLabel = (frequencyId) => {
const freq = adminFrequencies.value.find(f => f.id === frequencyId);
return freq ? freq.label : frequencyId;
};
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<template #header>
<div class="text-xl font-semibold text-gray-900">
Admin: User Preferences & System Settings
</div>
</template>
<template #body>
<div class="p-4 md:p-6">
<!-- Tab Navigation -->
<div class="mb-6 border-b border-gray-200">
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Tabs">
<button
v-for="tab in tabs"
:key="tab.key"
@click="currentTab = tab.key; searchedUser = null; userPreferencesForm = null; userAuditSearchQuery = ''"
:class="[
currentTab === tab.key
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
'whitespace-nowrap py-4 px-3 border-b-2 font-medium text-sm transition-colors duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50'
]"
:aria-current="currentTab === tab.key ? 'page' : undefined"
>
{{ tab.label }}
</button>
</nav>
</div>
<!-- Tab Content -->
<div :key="currentTab">
<!-- == Section: Notification Categories (Admin CRUD) == -->
<section v-if="currentTab === 'categories'" aria-labelledby="categories-heading">
<div class="flex justify-between items-center mb-4">
<div>
<h2 id="categories-heading" class="text-lg font-semibold text-gray-800">Manage Notification Categories</h2>
<p class="text-sm text-gray-600">Define categories users can subscribe to. Inactive categories are hidden from users.</p>
</div>
<button @click="openAddCategoryModal" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Add New Category
</button>
</div>
<div class="overflow-x-auto shadow border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Default Subscribed</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="category in adminNotificationCategories" :key="category.id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ category.name }}</td>
<td class="px-6 py-4 whitespace-normal text-sm text-gray-500 max-w-xs truncate" :title="category.description">{{ category.description }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ category.defaultSubscribed ? 'Yes' : 'No' }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="['px-2 inline-flex text-xs leading-5 font-semibold rounded-full', category.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
{{ category.isActive ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button @click="openEditCategoryModal(category)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
<button @click="toggleCategoryStatus(category)" :class="[category.isActive ? 'text-red-600 hover:text-red-900' : 'text-green-600 hover:text-green-900']">
{{ category.isActive ? 'Deactivate' : 'Activate' }}
</button>
</td>
</tr>
<tr v-if="adminNotificationCategories.length === 0">
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">No categories defined yet.</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- == Section: Channels & Governance (Admin) == -->
<section v-if="currentTab === 'channels'" aria-labelledby="channels-heading">
<h2 id="channels-heading" class="text-lg font-semibold text-gray-800 mb-3">Manage Channels & Governance</h2>
<p class="text-sm text-gray-600 mb-4">Enable/disable communication channels and set global rules.</p>
<div class="space-y-6">
<div v-for="channel in adminChannels" :key="channel.id" class="p-4 border rounded-lg shadow-sm">
<div class="flex items-center justify-between">
<h3 class="text-md font-medium text-gray-900">{{ channel.name }}</h3>
<label :for="`channel-enabled-${channel.id}`" class="flex items-center cursor-pointer">
<span class="mr-2 text-sm text-gray-700">{{ channel.isEnabled ? 'Enabled' : 'Disabled' }}</span>
<div class="relative">
<input type="checkbox" :id="`channel-enabled-${channel.id}`" class="sr-only peer" v-model="channel.isEnabled">
<div class="w-10 h-4 bg-gray-300 rounded-full shadow-inner peer-checked:bg-blue-500 transition-colors"></div>
<div class="absolute left-0 top-[-4px] w-6 h-6 bg-white border-2 border-gray-300 rounded-full shadow transform peer-checked:translate-x-full peer-checked:border-blue-500 transition-transform"></div>
</div>
</label>
</div>
<div v-if="channel.isEnabled" class="mt-4 pt-4 border-t">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label :for="`channel-cap-${channel.id}`" class="block text-sm font-medium text-gray-700">Default Frequency Cap</label>
<input type="text" :id="`channel-cap-${channel.id}`" v-model="channel.defaultFrequencyCap" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="e.g., 5 per day">
</div>
<div>
<label :for="`channel-types-${channel.id}`" class="block text-sm font-medium text-gray-700">Supported Message Types (Category IDs)</label>
<input type="text" :id="`channel-types-${channel.id}`" :value="channel.supportedMessageTypes.join(', ')" @change="channel.supportedMessageTypes = $event.target.value.split(',').map(s => s.trim()).filter(Boolean)" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" placeholder="e.g., cat_alerts, cat_updates">
<p class="text-xs text-gray-500 mt-1">Comma-separated category IDs that can use this channel.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- == Section: Frequency Options (Admin) == -->
<section v-if="currentTab === 'frequencies'" aria-labelledby="frequencies-heading">
<div class="flex justify-between items-center mb-4">
<div>
<h2 id="frequencies-heading" class="text-lg font-semibold text-gray-800">Manage Frequency Options</h2>
<p class="text-sm text-gray-600">Define frequency choices available to users or for system defaults.</p>
</div>
<button @click="openAddFrequencyModal" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Add New Frequency
</button>
</div>
<div class="overflow-x-auto shadow border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Label</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value (System ID)</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User Selectable</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Default for New Users</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="freq in adminFrequencies" :key="freq.id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ freq.label }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ freq.value }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ freq.isUserSelectable ? 'Yes' : 'No' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ freq.isDefault ? 'Yes' : 'No' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button @click="openEditFrequencyModal(freq)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
<button @click="deleteFrequency(freq.id)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
<tr v-if="adminFrequencies.length === 0">
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">No frequency options defined yet.</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- == Section: Global Quiet Hours (Admin) == -->
<section v-if="currentTab === 'globalQuietHours'" aria-labelledby="globalqh-heading">
<h2 id="globalqh-heading" class="text-lg font-semibold text-gray-800 mb-3">Configure Global Quiet Hours</h2>
<p class="text-sm text-gray-600 mb-4">Set system-wide default "Do Not Disturb" periods. These can potentially be overridden by users if allowed.</p>
<div class="space-y-4 max-w-lg p-4 border rounded-lg shadow-sm">
<div class="flex items-center">
<input id="admin-qh-enabled" type="checkbox" class="form-checkbox h-5 w-5 text-blue-600 rounded focus:ring-blue-500" v-model="adminGlobalQuietHours.enabled">
<label for="admin-qh-enabled" class="ml-3 block text-sm font-medium text-gray-700 cursor-pointer">Enable Global Quiet Hours</label>
</div>
<div v-if="adminGlobalQuietHours.enabled" class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3 mt-3">
<div>
<label for="admin-qh-start" class="block text-sm font-medium text-gray-700 mb-1">Start Time:</label>
<input type="time" id="admin-qh-start" class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" v-model="adminGlobalQuietHours.startTime">
</div>
<div>
<label for="admin-qh-end" class="block text-sm font-medium text-gray-700 mb-1">End Time:</label>
<input type="time" id="admin-qh-end" class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" v-model="adminGlobalQuietHours.endTime">
</div>
<div class="sm:col-span-2 flex items-center">
<input id="admin-qh-override" type="checkbox" class="form-checkbox h-5 w-5 text-blue-600 rounded focus:ring-blue-500" v-model="adminGlobalQuietHours.allowUserOverride">
<label for="admin-qh-override" class="ml-3 block text-sm font-medium text-gray-700 cursor-pointer">Allow users to override global quiet hours</label>
</div>
</div>
<button @click="alert('Save Global Quiet Hours clicked (mock)')" class="mt-4 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Save Global Quiet Hours</button>
</div>
</section>
<!-- == Section: User Preference Audit (Admin) == -->
<section v-if="currentTab === 'userAudit'" aria-labelledby="useraudit-heading">
<h2 id="useraudit-heading" class="text-lg font-semibold text-gray-800 mb-3">User Preference Audit & Management</h2>
<p class="text-sm text-gray-600 mb-4">Search for a user by ID, name, or email to view or modify their notification preferences.</p>
<div class="flex space-x-3 mb-6 max-w-xl">
<input type="text" v-model="userAuditSearchQuery" placeholder="Enter User ID, Name, or Email" class="form-input flex-grow block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<button @click="handleUserSearch" :disabled="isSearchingUser || !userAuditSearchQuery.trim()" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50">
{{ isSearchingUser ? 'Searching...' : 'Search User' }}
</button>
</div>
<div v-if="isSearchingUser" class="text-center py-6">
<p class="text-gray-500">Loading user data...</p> <!-- Add a spinner later -->
</div>
<div v-if="!isSearchingUser && searchedUser && userPreferencesForm" class="mt-6 p-6 border rounded-lg shadow-lg">
<h3 class="text-xl font-semibold text-gray-800 mb-2">Editing Preferences for: <span class="font-normal">{{ searchedUser.name }} ({{ searchedUser.id }})</span></h3>
<p class="text-sm text-gray-500 mb-6">Email: {{ searchedUser.email }}</p>
<form @submit.prevent="saveUserPreferences">
<div class="space-y-8">
<!-- Default Preferred Channel for User -->
<div>
<label for="userDefaultChannel" class="block text-sm font-medium text-gray-700 mb-1">User's Default Notification Channel</label>
<select id="userDefaultChannel" v-model="userPreferencesForm.defaultPreferredChannel" class="form-select mt-1 block w-full md:w-1/2 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option v-for="channel in adminChannels.filter(c => c.isEnabled)" :key="channel.id" :value="channel.id">{{ channel.name }}</option>
<option value="none">None (Mute All)</option>
</select>
</div>
<!-- User Subscriptions to Categories -->
<div>
<h4 class="text-md font-semibold text-gray-700 mb-3">Category Subscriptions & Overrides</h4>
<div class="space-y-6">
<div v-for="adminCat in adminNotificationCategories.filter(ac => ac.isActive)" :key="adminCat.id" class="p-4 border rounded-md bg-gray-50">
<div class="flex items-start justify-between mb-3">
<div>
<h5 class="font-medium text-gray-800">{{ adminCat.name }}</h5>
<p class="text-xs text-gray-500">{{ adminCat.description }}</p>
</div>
<input :id="`user-cat-sub-${adminCat.id}`" type="checkbox" v-model="userPreferencesForm.subscriptions[adminCat.id].subscribed" class="form-checkbox h-5 w-5 text-blue-600 rounded focus:ring-blue-500 cursor-pointer">
</div>
<div v-if="userPreferencesForm.subscriptions[adminCat.id].subscribed" class="mt-3 pt-3 border-t grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<div>
<label :for="`user-cat-channel-${adminCat.id}`" class="block text-xs font-medium text-gray-600 mb-0.5">Channel Override:</label>
<select :id="`user-cat-channel-${adminCat.id}`" v-model="userPreferencesForm.subscriptions[adminCat.id].channel" class="form-select mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-xs">
<option v-for="channel in adminChannels.filter(c => c.isEnabled && c.supportedMessageTypes.includes(adminCat.id))" :key="channel.id" :value="channel.id">{{ channel.name }}</option>
<option :value="userPreferencesForm.defaultPreferredChannel">(User Default: {{ getChannelName(userPreferencesForm.defaultPreferredChannel) }})</option>
<option value="none">None (Mute This Category)</option>
</select>
</div>
<div>
<label :for="`user-cat-freq-${adminCat.id}`" class="block text-xs font-medium text-gray-600 mb-0.5">Frequency Override:</label>
<select :id="`user-cat-freq-${adminCat.id}`" v-model="userPreferencesForm.subscriptions[adminCat.id].frequency" class="form-select mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-xs">
<option v-for="freq in adminFrequencies.filter(f => f.isUserSelectable)" :key="freq.id" :value="freq.id">{{ freq.label }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- User Quiet Hours -->
<div>
<h4 class="text-md font-semibold text-gray-700 mb-3">User Quiet Hours Override</h4>
<div class="space-y-3 p-4 border rounded-md bg-gray-50 max-w-md">
<div class="flex items-center">
<input id="user-qh-enabled" type="checkbox" v-model="userPreferencesForm.quietHours.enabled" class="form-checkbox h-5 w-5 text-blue-600 rounded focus:ring-blue-500">
<label for="user-qh-enabled" class="ml-3 block text-sm font-medium text-gray-700 cursor-pointer">Enable Quiet Hours for this User</label>
</div>
<div v-if="userPreferencesForm.quietHours.enabled" class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 mt-2">
<div>
<label for="user-qh-start" class="block text-xs font-medium text-gray-600 mb-0.5">Start Time:</label>
<input type="time" id="user-qh-start" v-model="userPreferencesForm.quietHours.startTime" class="form-input mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="user-qh-end" class="block text-xs font-medium text-gray-600 mb-0.5">End Time:</label>
<input type="time" id="user-qh-end" v-model="userPreferencesForm.quietHours.endTime" class="form-input mt-0.5 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
</div>
</div>
</div>
</div>
<!-- Save/Cancel User Prefs -->
<div class="mt-8 pt-5 border-t">
<div class="flex justify-end space-x-3">
<button type="button" @click="cancelUserEdit" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Reset / Cancel Edit
</button>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
Save Changes for {{ searchedUser.name }}
</button>
</div>
</div>
</div>
</form>
</div>
<div v-if="!isSearchingUser && !searchedUser && userAuditSearchQuery" class="text-center py-6">
<p class="text-gray-500">No user found matching "{{ userAuditSearchQuery }}". Try a different search term.</p>
</div>
<div v-if="!isSearchingUser && !searchedUser && !userAuditSearchQuery" class="text-center py-6">
<p class="text-gray-500">Enter a user ID, name, or email to search.</p>
</div>
</section>
<section v-if="currentTab === 'bulkOps'" aria-labelledby="bulkops-heading">
<h2 id="bulkops-heading" class="text-lg font-semibold text-gray-800 mb-3">Bulk User Preference Operations</h2>
<p class="text-sm text-gray-600 mb-4">Import or export user preference data for backup, migration, or system-wide updates.</p>
<div class="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-3 mt-4">
<button @click="handleAdminImport" class="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition ease-in-out duration-150">
Import User Preferences (Bulk)
</button>
<button @click="handleAdminExport" class="w-full sm:w-auto px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition ease-in-out duration-150">
Export User Preferences (Bulk)
</button>
</div>
</section>
</div>
</div>
</template>
</rs-card>
<!-- Modal for Add/Edit Notification Category -->
<div v-if="showCategoryModal" class="fixed inset-0 z-50 overflow-y-auto bg-gray-600 bg-opacity-75 flex items-center justify-center p-4">
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-auto my-8 max-h-[90vh] overflow-y-auto">
<form @submit.prevent="saveCategory" class="p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
{{ editingCategory ? 'Edit' : 'Add New' }} Notification Category
</h3>
<div class="space-y-4">
<div>
<label for="catName" class="block text-sm font-medium text-gray-700">Category Name</label>
<input type="text" v-model="categoryForm.name" id="catName" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="catDesc" class="block text-sm font-medium text-gray-700">Description</label>
<textarea v-model="categoryForm.description" id="catDesc" rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="catDefaultSubscribed" v-model="categoryForm.defaultSubscribed" type="checkbox" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
<div class="ml-3 text-sm">
<label for="catDefaultSubscribed" class="font-medium text-gray-700">Default Subscribed</label>
<p class="text-gray-500">Users will be subscribed to this category by default.</p>
</div>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="catIsActive" v-model="categoryForm.isActive" type="checkbox" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
<div class="ml-3 text-sm">
<label for="catIsActive" class="font-medium text-gray-700">Active</label>
<p class="text-gray-500">Inactive categories are not visible or configurable by users.</p>
</div>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3 sticky bottom-0 bg-white py-4 px-6 -mx-6 -mb-6 border-t border-gray-200 rounded-b-lg">
<button type="button" @click="showCategoryModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</button>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{{ editingCategory ? 'Save Changes' : 'Create Category' }}
</button>
</div>
</form>
</div>
</div>
<!-- Modal for Add/Edit Frequency Option -->
<div v-if="showFrequencyModal" class="fixed inset-0 z-50 overflow-y-auto bg-gray-600 bg-opacity-75 flex items-center justify-center p-4">
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-auto my-8 max-h-[90vh] overflow-y-auto">
<form @submit.prevent="saveFrequency" class="p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
{{ editingFrequency ? 'Edit' : 'Add New' }} Frequency Option
</h3>
<div class="space-y-4">
<div>
<label for="freqLabel" class="block text-sm font-medium text-gray-700">Label (User-facing)</label>
<input type="text" v-model="frequencyForm.label" id="freqLabel" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="freqValue" class="block text-sm font-medium text-gray-700">Value (System ID)</label>
<input type="text" v-model="frequencyForm.value" id="freqValue" required :disabled="editingFrequency !== null" placeholder="e.g., immediate, daily_digest" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm disabled:bg-gray-100">
<p v-if="editingFrequency" class="text-xs text-gray-500 mt-1">System value cannot be changed after creation.</p>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="freqUserSelectable" v-model="frequencyForm.isUserSelectable" type="checkbox" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
<div class="ml-3 text-sm">
<label for="freqUserSelectable" class="font-medium text-gray-700">User Selectable</label>
<p class="text-gray-500">Can users choose this frequency option for their preferences?</p>
</div>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="freqIsDefault" v-model="frequencyForm.isDefault" type="checkbox" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
</div>
<div class="ml-3 text-sm">
<label for="freqIsDefault" class="font-medium text-gray-700">Default for New Users</label>
<p class="text-gray-500">Is this a default frequency for new users or new subscriptions?</p>
</div>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3 sticky bottom-0 bg-white py-4 px-6 -mx-6 -mb-6 border-t border-gray-200 rounded-b-lg">
<button type="button" @click="showFrequencyModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</button>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{{ editingFrequency ? 'Save Changes' : 'Create Frequency' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
/* Using Tailwind utility classes. */
/* Ensure @tailwindcss/forms plugin is installed for nice form styling. */
.form-checkbox:focus, .form-input:focus, .form-select:focus, .form-textarea:focus {
/* You might want to ensure focus rings are consistent if not using the plugin */
/* Example: ring-2 ring-offset-2 ring-indigo-500 */
}
</style>

View File

@@ -0,0 +1,537 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-batch-prediction"></Icon>
<h1 class="text-xl font-bold text-primary">Batch Processing</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Process notifications in batches for better efficiency.
</p>
</template>
</rs-card>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<rs-card
class="cursor-pointer hover:shadow-md transition-shadow"
@click="showCreateModal = true"
>
<div class="p-4 flex items-center gap-4">
<div class="p-3 bg-blue-100 rounded-lg">
<Icon class="text-blue-600 text-xl" name="ic:outline-add"></Icon>
</div>
<div>
<h3 class="font-semibold text-blue-600">Create New Batch</h3>
<p class="text-sm text-gray-600">Start a new batch processing job</p>
</div>
</div>
</rs-card>
<rs-card class="cursor-pointer hover:shadow-md transition-shadow">
<div class="p-4 flex items-center gap-4">
<div class="p-3 bg-green-100 rounded-lg">
<Icon class="text-green-600 text-xl" name="ic:outline-schedule"></Icon>
</div>
<div>
<h3 class="font-semibold text-green-600">Schedule Batch</h3>
<p class="text-sm text-gray-600">Schedule for later execution</p>
</div>
</div>
</rs-card>
</div>
<!-- Batch Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-blue-600">{{ stats.pending }}</h3>
<p class="text-sm text-gray-600">Pending</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-yellow-600">
{{ stats.processing }}
</h3>
<p class="text-sm text-gray-600">Processing</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-green-600">
{{ stats.completed }}
</h3>
<p class="text-sm text-gray-600">Completed</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-red-600">{{ stats.failed }}</h3>
<p class="text-sm text-gray-600">Failed</p>
</div>
</rs-card>
</div>
<!-- Active Batches -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Active Batches</h3>
<rs-button
variant="outline"
size="sm"
@click="refreshBatches"
:loading="isLoading"
>
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8">
<rs-spinner size="lg" />
</div>
<!-- Empty State -->
<div v-else-if="batches.length === 0" class="text-center py-8">
<Icon name="ic:outline-inbox" class="text-4xl text-gray-400 mb-2" />
<p class="text-gray-600">No batches found</p>
</div>
<!-- Batches List -->
<div v-else class="space-y-4">
<div
v-for="batch in batches"
:key="batch.id"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<div
class="w-3 h-3 rounded-full mr-3"
:class="getStatusColor(batch.status)"
></div>
<div>
<h4 class="font-semibold">{{ batch.name }}</h4>
<p class="text-sm text-gray-600">{{ batch.description }}</p>
</div>
</div>
<div class="text-right">
<span class="text-sm font-medium capitalize">{{ batch.status }}</span>
<p class="text-xs text-gray-500">
{{ formatDate(batch.time) }}
</p>
</div>
</div>
<!-- Progress Info -->
<div class="space-y-2">
<div class="text-sm text-gray-600">
Progress: {{ batch.processed }}/{{ batch.total }} ({{
Math.round((batch.processed / batch.total) * 100)
}}%)
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full"
:style="{
width: `${Math.round((batch.processed / batch.total) * 100)}%`,
}"
></div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div
v-if="pagination.totalPages > 1"
class="flex justify-center items-center space-x-2 mt-4"
>
<rs-button
variant="outline"
size="sm"
:disabled="pagination.page === 1"
@click="pagination.page--"
>
Previous
</rs-button>
<span class="text-sm text-gray-600">
Page {{ pagination.page }} of {{ pagination.totalPages }}
</span>
<rs-button
variant="outline"
size="sm"
:disabled="pagination.page === pagination.totalPages"
@click="pagination.page++"
>
Next
</rs-button>
</div>
</div>
</template>
</rs-card>
<!-- Create Batch Modal -->
<rs-modal v-model="showCreateModal" title="Create New Batch">
<div class="space-y-4">
<!-- Basic Information -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Name</label>
<input
v-model="newBatch.name"
type="text"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="Enter batch name"
/>
</div>
<!-- Message Type -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Message Type</label>
<select
v-model="newBatch.type"
class="w-full p-2 border border-gray-300 rounded-md"
>
<option value="">Select type</option>
<option value="email">Email</option>
<option value="push">Push Notification</option>
</select>
</div>
<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority</label>
<select
v-model="newBatch.priority"
class="w-full p-2 border border-gray-300 rounded-md"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<!-- Template Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Template (Optional)</label
>
<select
v-model="newBatch.template"
class="w-full p-2 border border-gray-300 rounded-md"
>
<option value="">No template</option>
<option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.name }}
</option>
</select>
</div>
<!-- User Segment -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>User Segment (Optional)</label
>
<select
v-model="newBatch.segment"
class="w-full p-2 border border-gray-300 rounded-md"
>
<option value="">All Users</option>
<option v-for="segment in segments" :key="segment.id" :value="segment.value">
{{ segment.name }}
</option>
</select>
</div>
<!-- Scheduled Time -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Schedule For (Optional)</label
>
<input
v-model="newBatch.scheduledAt"
type="datetime-local"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Description</label>
<textarea
v-model="newBatch.description"
class="w-full p-2 border border-gray-300 rounded-md"
rows="3"
placeholder="Describe this batch"
></textarea>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button
variant="outline"
@click="showCreateModal = false"
:disabled="isCreating"
>Cancel</rs-button
>
<rs-button @click="createBatch" :loading="isCreating" :disabled="isCreating">
{{ isCreating ? "Creating..." : "Create Batch" }}
</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
import { onMounted, watch } from "vue";
import { useToast } from "vue-toastification";
definePageMeta({
title: "Batch Processing",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Batch Processing",
path: "/notification/queue-scheduler/batch",
},
],
});
// Basic stats
const stats = ref({
pending: 0,
processing: 0,
completed: 0,
failed: 0,
});
// Pagination
const pagination = ref({
page: 1,
limit: 10,
total: 0,
totalPages: 0,
});
// Batches list
const batches = ref([]);
// Loading states
const isLoading = ref(false);
const isCreating = ref(false);
// Modal state
const showCreateModal = ref(false);
// Form data
const newBatch = ref({
name: "",
type: "",
description: "",
scheduledAt: "",
template: "",
segment: "",
priority: "medium",
});
// Templates and segments (will be fetched)
const templates = ref([]);
const segments = ref([]);
// Fetch data on mount
onMounted(async () => {
await Promise.all([fetchStats(), fetchBatches(), fetchTemplates(), fetchSegments()]);
});
// Watch for page changes
watch(() => pagination.value.page, fetchBatches);
// Fetch functions
async function fetchStats() {
try {
const response = await $fetch("/api/notifications/batch/stats");
if (response.success) {
stats.value = response.data;
} else {
throw new Error(response.statusMessage || "Failed to fetch statistics");
}
} catch (error) {
console.error("Error fetching stats:", error);
// Show error notification
useToast().error("Failed to fetch statistics");
}
}
async function fetchBatches() {
try {
isLoading.value = true;
const response = await $fetch("/api/notifications/batch", {
params: {
page: pagination.value.page,
limit: pagination.value.limit,
},
});
if (response.success) {
batches.value = response.data.batches;
pagination.value = {
...pagination.value,
total: response.data.pagination.total,
totalPages: response.data.pagination.totalPages,
};
} else {
throw new Error(response.statusMessage || "Failed to fetch batches");
}
} catch (error) {
console.error("Error fetching batches:", error);
useToast().error("Failed to fetch batches");
batches.value = [];
} finally {
isLoading.value = false;
}
}
async function fetchTemplates() {
try {
const response = await $fetch("/api/notifications/templates");
if (Array.isArray(response)) {
templates.value = response;
} else if (response && response.success && Array.isArray(response.data)) {
templates.value = response.data;
} else {
templates.value = [];
}
} catch (error) {
console.error("Error fetching templates:", error);
templates.value = [];
}
}
async function fetchSegments() {
try {
const response = await $fetch("/api/notifications/segments");
if (Array.isArray(response)) {
segments.value = response;
} else if (response && response.success && Array.isArray(response.data)) {
segments.value = response.data;
} else {
segments.value = [];
}
} catch (error) {
console.error("Error fetching segments:", error);
segments.value = [];
}
}
// Create batch
async function createBatch() {
try {
isCreating.value = true;
// Validate form
if (!newBatch.value.name || !newBatch.value.type) {
useToast().error("Please fill in all required fields");
return;
}
// Create a copy of the batch data for submission
const batchData = { ...newBatch.value };
// Format scheduledAt if it exists
if (batchData.scheduledAt) {
try {
const date = new Date(batchData.scheduledAt);
if (isNaN(date.getTime())) {
useToast().error("Invalid scheduled date");
return;
}
batchData.scheduledAt = date.toISOString();
} catch (error) {
useToast().error("Invalid scheduled date");
return;
}
}
const response = await $fetch("/api/notifications/batch", {
method: "POST",
body: batchData,
});
if (!response || !response.success) {
throw new Error(response?.statusMessage || "Failed to create batch");
}
// Show success message
useToast().success("Batch created successfully");
// Reset form
newBatch.value = {
name: "",
type: "",
description: "",
scheduledAt: "",
template: "",
segment: "",
priority: "medium",
};
// Close modal
showCreateModal.value = false;
// Refresh data
await Promise.all([fetchStats(), fetchBatches()]);
} catch (error) {
console.error("Error creating batch:", error);
useToast().error(error.message || "Failed to create batch");
} finally {
isCreating.value = false;
}
}
// Utility function for status color
const getStatusColor = (status) => {
const colors = {
draft: "bg-gray-500",
scheduled: "bg-blue-500",
sending: "bg-yellow-500",
sent: "bg-green-500",
failed: "bg-red-500",
};
return colors[status] || "bg-gray-500";
};
// Format date
const formatDate = (date) => {
return new Date(date).toLocaleString();
};
// Refresh data
const refreshBatches = async () => {
await Promise.all([fetchStats(), fetchBatches()]);
};
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,192 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-schedule"></Icon>
<h1 class="text-xl font-bold text-primary">Queue</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">Manage notification queues and scheduled tasks.</p>
</template>
</rs-card>
<!-- Basic Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<rs-card v-for="(stat, index) in queueStats" :key="index">
<div class="p-4 text-center">
<div v-if="isLoading" class="animate-pulse">
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
</div>
<template v-else>
<h3 :class="['text-2xl font-bold', stat.colorClass]">
{{ stat.value }}
</h3>
<p class="text-sm text-gray-600">{{ stat.label }}</p>
</template>
</div>
</rs-card>
</div>
<!-- Main Features -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<rs-card
v-for="(feature, index) in features"
:key="index"
class="cursor-pointer hover:shadow-md transition-shadow"
@click="navigateTo(feature.path)"
>
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" :name="feature.icon"></Icon>
<h3 class="font-semibold text-primary">{{ feature.title }}</h3>
</div>
</template>
<template #body>
<p class="text-gray-600 text-sm">{{ feature.description }}</p>
</template>
<template #footer>
<rs-button variant="outline" size="sm" class="w-full"> Open </rs-button>
</template>
</rs-card>
</div>
<!-- Error Alert -->
<rs-alert v-if="error" variant="danger" class="mt-4">
{{ error }}
</rs-alert>
</div>
</template>
<script setup>
definePageMeta({
title: "Queue & Scheduler",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue",
path: "/notification/queue",
},
],
});
const { $swal } = useNuxtApp();
// Reactive state
const isLoading = ref(true);
const error = ref(null);
const queueStats = ref([]);
// Hardcoded features
const features = ref([
{
title: "Queue Monitor",
description: "View and manage current notification queues",
icon: "ic:outline-monitor",
path: "/notification/queue/monitor",
},
{
title: "Performance",
description: "Check system performance and metrics",
icon: "ic:outline-speed",
path: "/notification/queue/performance",
},
// {
// title: "Batch Processing",
// description: "Process notifications in batches",
// icon: "ic:outline-batch-prediction",
// path: "/notification/queue/batch",
// },
{
title: "Failed Jobs",
description: "Handle and retry failed notifications",
icon: "ic:outline-refresh",
path: "/notification/queue/retry",
},
]);
// Fetch queue statistics
async function fetchQueueStats() {
try {
const { data } = await useFetch("/api/notifications/queue/stats", {
method: "GET",
});
console.log(data.value);
if (data.value?.success) {
queueStats.value = [
{
value: data.value.data.pending,
label: "Pending Jobs",
colorClass: "text-primary",
},
{
value: data.value.data.completed,
label: "Completed Today",
colorClass: "text-green-600",
},
{
value: data.value.data.failed,
label: "Failed Jobs",
colorClass: "text-red-600",
},
];
} else {
throw new Error(data.value?.message || "Failed to fetch queue statistics");
}
} catch (err) {
console.error("Error fetching queue stats:", err);
error.value = "Failed to load queue statistics. Please try again later.";
// Set default values for stats
queueStats.value = [
{ value: "-", label: "Pending Jobs", colorClass: "text-primary" },
{ value: "-", label: "Completed Today", colorClass: "text-green-600" },
{ value: "-", label: "Failed Jobs", colorClass: "text-red-600" },
];
} finally {
isLoading.value = false;
}
}
// Initialize data
onMounted(async () => {
try {
isLoading.value = true;
error.value = null;
await fetchQueueStats();
} catch (err) {
console.error("Error initializing queue page:", err);
error.value = "Failed to initialize the page. Please refresh to try again.";
} finally {
isLoading.value = false;
}
});
// Auto-refresh stats every 30 seconds
let refreshInterval;
onMounted(() => {
refreshInterval = setInterval(async () => {
await fetchQueueStats();
}, 30000);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,339 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-monitor"></Icon>
<h1 class="text-xl font-bold text-primary">Queue Monitor</h1>
</div>
<rs-button
variant="outline"
size="sm"
@click="refreshData"
:loading="isLoading"
>
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</template>
<template #body>
<p class="text-gray-600">Monitor current notification queues and job statuses.</p>
</template>
</rs-card>
<!-- Error Alert -->
<rs-alert v-if="error" variant="danger" class="mb-6">
{{ error }}
</rs-alert>
<!-- Loading State -->
<div v-if="isLoading" class="mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<rs-card v-for="i in 4" :key="i">
<div class="p-4 text-center">
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2 animate-pulse"></div>
<div class="h-4 bg-gray-200 rounded w-32 mx-auto animate-pulse"></div>
</div>
</rs-card>
</div>
</div>
<!-- Basic Stats -->
<div v-else class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-blue-600">{{ stats.pending }}</h3>
<p class="text-sm text-gray-600">Pending</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-yellow-600">
{{ stats.processing || 0 }}
</h3>
<p class="text-sm text-gray-600">Processing</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-green-600">
{{ stats.completed }}
</h3>
<p class="text-sm text-gray-600">Completed</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-red-600">{{ stats.failed }}</h3>
<p class="text-sm text-gray-600">Failed</p>
</div>
</rs-card>
</div>
<!-- Job List -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Recent Jobs</h3>
<div>
<select
v-model="statusFilter"
class="px-2 py-1 border border-gray-300 rounded-md text-sm mr-2"
@change="fetchJobs()"
>
<option value="">All Statuses</option>
<option value="queued">Pending</option>
<option value="processing">Processing</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
</template>
<template #body>
<div v-if="isLoadingJobs" class="p-8 text-center">
<div
class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"
></div>
<p class="mt-2 text-gray-600">Loading jobs...</p>
</div>
<div v-else-if="jobs.length === 0" class="p-8 text-center">
<Icon name="ic:outline-info" class="text-3xl text-gray-400 mb-2" />
<p class="text-gray-600">No jobs found</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(job, index) in jobs"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center">
<div
class="w-3 h-3 rounded-full mr-3"
:class="getStatusColor(job.status)"
></div>
<div>
<p class="font-medium">{{ job.type }} - {{ truncateId(job.id) }}</p>
<p class="text-sm text-gray-600">{{ job.description }}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium capitalize">{{ job.status }}</p>
<p class="text-xs text-gray-500">{{ formatTime(job.createdAt) }}</p>
</div>
</div>
<!-- Pagination -->
<div v-if="pagination.totalPages > 1" class="flex justify-center mt-4">
<button
class="px-3 py-1 border border-gray-300 rounded-l-md disabled:opacity-50"
:disabled="pagination.page === 1"
@click="changePage(pagination.page - 1)"
>
Previous
</button>
<div class="px-3 py-1 border-t border-b border-gray-300 text-sm">
{{ pagination.page }} / {{ pagination.totalPages }}
</div>
<button
class="px-3 py-1 border border-gray-300 rounded-r-md disabled:opacity-50"
:disabled="pagination.page === pagination.totalPages"
@click="changePage(pagination.page + 1)"
>
Next
</button>
</div>
</div>
</template>
</rs-card>
</div>
</template>
<script setup>
definePageMeta({
title: "Queue Monitor",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue",
path: "/notification/queue",
},
{
name: "Monitor",
path: "/notification/queue/monitor",
},
],
});
// Loading and error state
const isLoading = ref(false);
const isLoadingJobs = ref(false);
const error = ref(null);
const statusFilter = ref("");
// Stats data
const stats = ref({
pending: 0,
processing: 0,
completed: 0,
failed: 0,
});
// Jobs data with pagination
const jobs = ref([]);
const pagination = ref({
page: 1,
totalPages: 1,
totalItems: 0,
hasMore: false,
});
// Fetch stats from API
const fetchStats = async () => {
try {
isLoading.value = true;
error.value = null;
const { data } = await useFetch("/api/notifications/queue/stats");
if (!data.value) throw new Error("Failed to fetch statistics");
if (data.value?.success) {
stats.value = {
pending: data.value.data.pending || 0,
processing: data.value.data.processing || 0,
completed: data.value.data.completed || 0,
failed: data.value.data.failed || 0,
};
} else {
throw new Error(data.value.statusMessage || "Failed to fetch statistics");
}
} catch (error) {
console.error("Error fetching stats:", error);
error.value = `Failed to load statistics: ${error.message}`;
} finally {
isLoading.value = false;
}
};
// Fetch jobs from API
const fetchJobs = async () => {
try {
isLoadingJobs.value = true;
const params = {
page: pagination.value.page,
limit: 10,
};
if (statusFilter.value) {
params.status = statusFilter.value;
}
const { data } = await useFetch("/api/notifications/queue/jobs", {
query: params,
});
if (!data.value) throw new Error("Failed to fetch jobs");
if (data.value?.success) {
jobs.value = data.value.data.jobs;
pagination.value = data.value.data.pagination;
} else {
throw new Error(data.value.statusMessage || "Failed to fetch jobs");
}
} catch (error) {
console.error("Error fetching jobs:", error);
error.value = `Failed to load jobs: ${error.message}`;
jobs.value = [];
} finally {
isLoadingJobs.value = false;
}
};
// Change page
const changePage = (page) => {
pagination.value.page = page;
fetchJobs();
};
// Status color mapping
const getStatusColor = (status) => {
const colors = {
queued: "bg-blue-500",
pending: "bg-blue-500",
processing: "bg-yellow-500",
completed: "bg-green-500",
sent: "bg-green-500",
failed: "bg-red-500",
};
return colors[status] || "bg-gray-500";
};
// Format time for display
const formatTime = (timestamp) => {
if (!timestamp) return "Unknown";
try {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (isNaN(diffMins)) return "Unknown";
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins} min ago`;
if (diffMins < 1440) {
const hours = Math.floor(diffMins / 60);
return `${hours} hour${hours > 1 ? "s" : ""} ago`;
}
return date.toLocaleString();
} catch (e) {
console.error("Error formatting time:", e);
return "Unknown";
}
};
// Truncate long IDs
const truncateId = (id) => {
if (!id) return "Unknown";
if (id.length > 8) {
return id.substring(0, 8) + "...";
}
return id;
};
// Refresh data
const refreshData = async () => {
await Promise.all([fetchStats(), fetchJobs()]);
};
// Auto-refresh every 30 seconds
let refreshInterval;
onMounted(() => {
refreshData();
refreshInterval = setInterval(refreshData, 30000);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,548 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-speed"></Icon>
<h1 class="text-xl font-bold text-primary">Performance</h1>
</div>
<rs-button
variant="outline"
size="sm"
@click="refreshMetrics"
:loading="isLoading"
:disabled="isLoading"
>
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</template>
<template #body>
<p class="text-gray-600">Monitor system performance and queue processing metrics.</p>
</template>
</rs-card>
<!-- Error Alert -->
<rs-alert v-if="error" variant="danger" class="mb-6">
{{ error }}
</rs-alert>
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-12 text-gray-600">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
<span class="ml-2">Loading metrics...</span>
</div>
<div v-else>
<!-- Key Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-blue-600">
{{ metrics.throughput }}
</h3>
<p class="text-sm text-gray-600">Messages/min</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-green-600">{{ metrics.uptime }}%</h3>
<p class="text-sm text-gray-600">System Uptime</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-purple-600">
{{ metrics.workers }}
</h3>
<p class="text-sm text-gray-600">Active Workers</p>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<h3 class="text-2xl font-bold text-orange-600">{{ metrics.queueLoad }}%</h3>
<p class="text-sm text-gray-600">Queue Load</p>
</div>
</rs-card>
</div>
<!-- Performance Summary -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Throughput Summary -->
<rs-card>
<template #header>
<h3 class="text-lg font-semibold text-primary">Throughput Summary</h3>
</template>
<template #body>
<div class="space-y-4">
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Current Rate</span>
<span class="font-bold">{{ throughput.current }}/min</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Peak Today</span>
<span class="font-bold">{{ throughput.peak }}/min</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Average</span>
<span class="font-bold">{{ throughput.average }}/min</span>
</div>
</div>
</template>
</rs-card>
<!-- System Status -->
<rs-card>
<template #header>
<h3 class="text-lg font-semibold text-primary">System Status</h3>
</template>
<template #body>
<div class="space-y-4">
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Uptime Today</span>
<span class="font-bold text-green-600">
{{ systemStatus.uptimeToday }}%
</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Response Time</span>
<span class="font-bold">{{ systemStatus.responseTime }}ms</span>
</div>
<div class="flex justify-between items-center p-3 bg-gray-50 rounded">
<span class="text-gray-600">Error Rate</span>
<span class="font-bold text-red-600">{{ systemStatus.errorRate }}%</span>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Historical Performance Chart -->
<rs-card class="mt-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Historical Performance</h3>
<div class="flex items-center space-x-2">
<select
v-model="historyFilter.metric"
class="px-2 py-1 text-sm border border-gray-300 rounded"
@change="fetchHistoricalData"
>
<option value="throughput">Throughput</option>
<option value="error_rate">Error Rate</option>
<option value="response_time">Response Time</option>
</select>
<select
v-model="historyFilter.period"
class="px-2 py-1 text-sm border border-gray-300 rounded"
@change="fetchHistoricalData"
>
<option value="hour">Last Hour</option>
<option value="day">Last 24 Hours</option>
<option value="week">Last Week</option>
<option value="month">Last Month</option>
</select>
</div>
</div>
</template>
<template #body>
<div v-if="isLoadingHistory" class="flex flex-col items-center justify-center h-64 bg-gray-50">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
<p class="mt-4 text-gray-500">Loading historical data...</p>
</div>
<div v-else-if="historyError" class="flex flex-col items-center justify-center h-64 bg-red-50">
<Icon name="ic:outline-error" class="text-6xl text-red-300" />
<p class="mt-4 text-red-500">{{ historyError }}</p>
<rs-button variant="outline" size="sm" class="mt-4" @click="fetchHistoricalData">
<Icon name="ic:outline-refresh" class="mr-1" />
Retry
</rs-button>
</div>
<div v-else-if="!historicalData.dataPoints || historicalData.dataPoints.length === 0" class="flex flex-col items-center justify-center h-64 bg-gray-50">
<Icon name="ic:outline-insert-chart" class="text-6xl text-gray-300" />
<p class="mt-4 text-gray-500">No historical data available for the selected period</p>
</div>
<div v-else>
<!-- Chart Summary -->
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="bg-green-50 p-2 rounded text-center">
<p class="text-sm text-green-700">Maximum</p>
<p class="text-lg font-bold text-green-600">{{ historicalData.summary.max }}{{ getMetricUnit() }}</p>
</div>
<div class="bg-blue-50 p-2 rounded text-center">
<p class="text-sm text-blue-700">Average</p>
<p class="text-lg font-bold text-blue-600">{{ historicalData.summary.avg }}{{ getMetricUnit() }}</p>
</div>
<div class="bg-purple-50 p-2 rounded text-center">
<p class="text-sm text-purple-700">Trend</p>
<p class="text-lg font-bold text-purple-600">
<Icon
:name="historicalData.summary.trend === 'increasing' ? 'ic:outline-trending-up' : 'ic:outline-trending-down'"
class="inline-block mr-1"
/>
{{ historicalData.summary.trend }}
</p>
</div>
</div>
<!-- Simple Line Chart -->
<div class="h-64 w-full relative">
<!-- Y-axis labels -->
<div class="absolute top-0 left-0 h-full flex flex-col justify-between text-xs text-gray-500">
<div>{{ Math.ceil(historicalData.summary.max * 1.1) }}{{ getMetricUnit() }}</div>
<div>{{ Math.ceil(historicalData.summary.max * 0.75) }}{{ getMetricUnit() }}</div>
<div>{{ Math.ceil(historicalData.summary.max * 0.5) }}{{ getMetricUnit() }}</div>
<div>{{ Math.ceil(historicalData.summary.max * 0.25) }}{{ getMetricUnit() }}</div>
<div>0{{ getMetricUnit() }}</div>
</div>
<!-- Chart area -->
<div class="ml-10 h-full relative">
<!-- Horizontal grid lines -->
<div class="absolute top-0 left-0 w-full h-full border-b border-gray-200">
<div class="border-t border-gray-200 h-1/4"></div>
<div class="border-t border-gray-200 h-1/4"></div>
<div class="border-t border-gray-200 h-1/4"></div>
<div class="border-t border-gray-200 h-1/4"></div>
</div>
<!-- Line chart -->
<svg class="absolute top-0 left-0 w-full h-full overflow-visible">
<path
:d="getChartPath()"
fill="none"
stroke="#3b82f6"
stroke-width="2"
></path>
<!-- Data points -->
<circle
v-for="(point, i) in chartPoints"
:key="i"
:cx="point.x"
:cy="point.y"
r="3"
fill="#3b82f6"
></circle>
</svg>
</div>
</div>
<!-- X-axis labels (show some key timestamps) -->
<div class="mt-2 ml-10 flex justify-between text-xs text-gray-500">
<div v-for="(label, i) in getXAxisLabels()" :key="i">
{{ label }}
</div>
</div>
</div>
</template>
</rs-card>
</div>
</div>
</template>
<script setup>
import { onMounted } from "vue";
definePageMeta({
title: "Performance",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue",
path: "/notification/queue",
},
{
name: "Performance",
path: "/notification/queue/performance",
},
],
});
// Reactive state
const isLoading = ref(true);
const error = ref(null);
const metrics = ref({
throughput: "0",
uptime: "0",
workers: "0",
queueLoad: "0",
});
const throughput = ref({
current: "0",
peak: "0",
average: "0",
});
const systemStatus = ref({
uptimeToday: "0",
responseTime: "0",
errorRate: "0",
});
// Historical data state
const isLoadingHistory = ref(true);
const historyError = ref(null);
const historicalData = ref({
dataPoints: [],
summary: {
max: "0",
avg: "0",
trend: "stable", // 'increasing', 'decreasing', or 'stable'
},
});
const historyFilter = ref({
metric: "throughput",
period: "hour",
});
// Fetch metrics from API
const fetchMetrics = async () => {
try {
isLoading.value = true;
error.value = null;
const { data } = await useFetch("/api/notifications/queue/performance", {
method: "GET",
});
if (!data.value) {
throw new Error("Failed to fetch metrics data");
}
if (data.value?.success) {
const performanceData = data.value.data;
// Update metrics with fetched data
metrics.value = performanceData.metrics || {
throughput: "0",
uptime: "0",
workers: "0",
queueLoad: "0",
};
// Update throughput data
throughput.value = performanceData.throughput || {
current: "0",
peak: "0",
average: "0",
};
// Update system status
systemStatus.value = performanceData.systemStatus || {
uptimeToday: "0",
responseTime: "0",
errorRate: "0",
};
} else {
throw new Error(data.value?.statusMessage || "Failed to fetch metrics");
}
} catch (err) {
console.error("Error fetching metrics:", err);
error.value = err.message || "Failed to load performance metrics";
} finally {
isLoading.value = false;
}
};
// Refresh metrics
const refreshMetrics = () => {
fetchMetrics();
};
// Fetch historical data
const fetchHistoricalData = async () => {
isLoadingHistory.value = true;
historyError.value = null;
try {
const { data } = await useFetch("/api/notifications/queue/history", {
method: "GET",
query: {
metric: historyFilter.value.metric,
period: historyFilter.value.period,
},
});
if (!data.value) {
throw new Error("Failed to fetch historical data");
}
if (data.value?.success) {
historicalData.value = data.value.data;
} else {
throw new Error(data.value?.statusMessage || "Failed to fetch historical data");
}
} catch (err) {
console.error("Error fetching historical data:", err);
historyError.value = err.message || "Failed to load historical data";
} finally {
isLoadingHistory.value = false;
}
};
// Get chart path for the selected metric
const getChartPath = () => {
if (!historicalData.value.dataPoints || historicalData.value.dataPoints.length < 2) {
return "M 0 0"; // Return an empty path if no data points
}
const points = historicalData.value.dataPoints;
const maxVal = historicalData.value.summary.max || 1;
const width = 100 * (points.length - 1); // Total width based on points
const path = [];
points.forEach((point, i) => {
// Calculate x position (evenly spaced)
const x = (i / (points.length - 1)) * width;
// Calculate y position (inverted, as SVG y=0 is top)
// Scale value to fit in the chart height (0-100%)
const y = 100 - ((point.value / maxVal) * 100);
if (i === 0) {
path.push(`M ${x} ${y}`);
} else {
path.push(`L ${x} ${y}`);
}
});
return path.join(" ");
};
// Get data points for the chart
const chartPoints = computed(() => {
if (!historicalData.value.dataPoints) {
return [];
}
const points = historicalData.value.dataPoints;
const maxVal = historicalData.value.summary.max || 1;
const width = 100 * (points.length - 1); // Total width based on points
return points.map((point, i) => ({
x: (i / (points.length - 1)) * width,
y: 100 - ((point.value / maxVal) * 100),
}));
});
// Get Y-axis labels
const getYAxisLabels = () => {
if (!historicalData.value.dataPoints) {
return [];
}
const minY = Math.min(...historicalData.value.dataPoints.map(p => p.y));
const maxY = Math.max(...historicalData.value.dataPoints.map(p => p.y));
const yRange = maxY - minY;
const labels = [];
if (yRange > 0) {
const step = yRange / 4;
for (let i = 0; i <= 4; i++) {
labels.push(Math.ceil(minY + i * step));
}
} else {
labels.push(minY);
labels.push(0);
}
return labels;
};
// Get X-axis labels
const getXAxisLabels = () => {
if (!historicalData.value.dataPoints || historicalData.value.dataPoints.length === 0) {
return [];
}
const points = historicalData.value.dataPoints;
const numLabels = 5; // Number of labels to show
const step = Math.max(1, Math.floor(points.length / (numLabels - 1)));
const labels = [];
for (let i = 0; i < points.length; i += step) {
if (labels.length < numLabels) {
const date = new Date(points[i].timestamp);
// Format based on the period
let label;
switch (historyFilter.value.period) {
case 'hour':
label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
break;
case 'day':
label = date.toLocaleTimeString([], { hour: '2-digit' }) + 'h';
break;
case 'week':
case 'month':
label = date.toLocaleDateString([], { day: 'numeric', month: 'short' });
break;
}
labels.push(label);
}
}
// Make sure we add the last point
if (labels.length < numLabels) {
const date = new Date(points[points.length - 1].timestamp);
let label;
switch (historyFilter.value.period) {
case 'hour':
label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
break;
case 'day':
label = date.toLocaleTimeString([], { hour: '2-digit' }) + 'h';
break;
case 'week':
case 'month':
label = date.toLocaleDateString([], { day: 'numeric', month: 'short' });
break;
}
labels.push(label);
}
return labels;
};
// Get metric unit based on selected metric
const getMetricUnit = () => {
if (historyFilter.value.metric === "throughput") {
return "/min";
} else if (historyFilter.value.metric === "error_rate") {
return "%";
} else if (historyFilter.value.metric === "response_time") {
return "ms";
}
return "";
};
// Fetch metrics on component mount
onMounted(() => {
fetchMetrics();
fetchHistoricalData(); // Fetch initial historical data
});
// Auto-refresh every 60 seconds
let refreshInterval;
onMounted(() => {
refreshInterval = setInterval(fetchMetrics, 60000);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,803 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-storage"></Icon>
<h1 class="text-xl font-bold text-primary">Queue Persistence Configuration</h1>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center">
<div class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
<span class="text-sm text-gray-600">Persistence Active</span>
</div>
<rs-button variant="outline" size="sm" @click="testPersistence">
<Icon class="mr-1" name="ic:outline-bug-report"></Icon>
Test Recovery
</rs-button>
</div>
</div>
</template>
<template #body>
<p class="text-gray-600">
Configure queue data persistence to ensure notifications survive system restarts and failures.
Critical for maintaining queue integrity and preventing message loss during system maintenance.
</p>
</template>
</rs-card>
<!-- Persistence Status Overview -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(metric, index) in persistenceMetrics"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-4 flex justify-center items-center rounded-2xl"
:class="metric.bgColor"
>
<Icon class="text-2xl" :class="metric.iconColor" :name="metric.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight" :class="metric.valueColor">
{{ metric.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ metric.title }}
</span>
<div class="flex items-center mt-1" v-if="metric.status">
<div
class="w-2 h-2 rounded-full mr-1"
:class="{
'bg-green-500': metric.status === 'healthy',
'bg-yellow-500': metric.status === 'warning',
'bg-red-500': metric.status === 'error'
}"
></div>
<span class="text-xs capitalize" :class="{
'text-green-600': metric.status === 'healthy',
'text-yellow-600': metric.status === 'warning',
'text-red-600': metric.status === 'error'
}">
{{ metric.status }}
</span>
</div>
</div>
</div>
</rs-card>
</div>
<!-- Storage Configuration -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Primary Storage -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-database"></Icon>
<h3 class="text-lg font-semibold text-primary">Primary Storage Configuration</h3>
</div>
<rs-button variant="outline" size="sm" @click="showStorageModal = true">
<Icon class="mr-1" name="ic:outline-settings"></Icon>
Configure
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<!-- Storage Type -->
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p class="font-medium">Storage Type</p>
<p class="text-sm text-gray-600">{{ storageConfig.type }}</p>
</div>
<div class="text-right">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': storageConfig.status === 'connected',
'bg-red-100 text-red-800': storageConfig.status === 'disconnected',
'bg-yellow-100 text-yellow-800': storageConfig.status === 'reconnecting'
}"
>
{{ storageConfig.status }}
</span>
</div>
</div>
<!-- Connection Details -->
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-gray-600">Connection Pool</p>
<p class="font-bold text-blue-600">{{ storageConfig.connectionPool }}/{{ storageConfig.maxConnections }}</p>
</div>
<div class="text-center p-3 bg-green-50 rounded-lg">
<p class="text-sm text-gray-600">Response Time</p>
<p class="font-bold text-green-600">{{ storageConfig.responseTime }}ms</p>
</div>
</div>
<!-- Storage Metrics -->
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Used Space</span>
<span class="text-sm font-medium">{{ storageConfig.usedSpace }} / {{ storageConfig.totalSpace }}</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full"
:style="{ width: storageConfig.usagePercentage + '%' }"
></div>
</div>
</div>
<!-- Last Backup -->
<div class="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div>
<p class="font-medium text-green-800">Last Backup</p>
<p class="text-sm text-green-600">{{ storageConfig.lastBackup }}</p>
</div>
<Icon class="text-green-600" name="ic:outline-backup"></Icon>
</div>
</div>
</template>
</rs-card>
<!-- Backup & Recovery -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-backup"></Icon>
<h3 class="text-lg font-semibold text-primary">Backup & Recovery</h3>
</div>
<rs-button variant="outline" size="sm" @click="createBackup">
<Icon class="mr-1" name="ic:outline-backup"></Icon>
Create Backup
</rs-button>
</div>
</template>
<template #body>
<div class="space-y-4">
<!-- Backup Schedule -->
<div class="p-3 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="font-medium">Automatic Backups</p>
<div class="flex items-center">
<input
type="checkbox"
v-model="backupConfig.autoBackupEnabled"
class="mr-2"
@change="updateBackupConfig"
>
<span class="text-sm">{{ backupConfig.autoBackupEnabled ? 'Enabled' : 'Disabled' }}</span>
</div>
</div>
<p class="text-sm text-gray-600">
Frequency: {{ backupConfig.frequency }} |
Retention: {{ backupConfig.retention }} days
</p>
</div>
<!-- Recent Backups -->
<div>
<h4 class="font-medium text-gray-700 mb-2">Recent Backups</h4>
<div class="space-y-2">
<div
v-for="(backup, index) in recentBackups"
:key="index"
class="flex items-center justify-between p-2 bg-gray-50 rounded"
>
<div class="flex items-center">
<Icon
class="mr-2 text-sm"
:class="{
'text-green-500': backup.status === 'completed',
'text-yellow-500': backup.status === 'in-progress',
'text-red-500': backup.status === 'failed'
}"
:name="backup.status === 'completed' ? 'ic:outline-check-circle' :
backup.status === 'in-progress' ? 'ic:outline-hourglass-empty' :
'ic:outline-error'"
></Icon>
<div>
<p class="text-sm font-medium">{{ backup.name }}</p>
<p class="text-xs text-gray-500">{{ backup.size }} {{ backup.timestamp }}</p>
</div>
</div>
<div class="flex items-center gap-1">
<rs-button
variant="outline"
size="xs"
@click="downloadBackup(backup)"
:disabled="backup.status !== 'completed'"
>
<Icon class="text-xs" name="ic:outline-download"></Icon>
</rs-button>
<rs-button
variant="outline"
size="xs"
@click="restoreBackup(backup)"
:disabled="backup.status !== 'completed'"
>
<Icon class="text-xs" name="ic:outline-restore"></Icon>
</rs-button>
</div>
</div>
</div>
</div>
<!-- Recovery Test -->
<div class="p-3 bg-yellow-50 rounded-lg">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-yellow-800">Recovery Test</p>
<p class="text-sm text-yellow-600">Last test: {{ recoveryTest.lastTest }}</p>
</div>
<rs-button
variant="outline"
size="sm"
@click="runRecoveryTest"
>
Run Test
</rs-button>
</div>
</div>
</div>
</template>
</rs-card>
</div>
<!-- Queue Recovery Status -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-restore"></Icon>
<h3 class="text-lg font-semibold text-primary">Queue Recovery Status</h3>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600">Last System Restart: {{ lastSystemRestart }}</span>
<rs-button variant="outline" size="sm" @click="showRecoveryDetails = true">
<Icon class="mr-1" name="ic:outline-info"></Icon>
View Details
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Recovery Statistics -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">Recovery Statistics</h4>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Jobs Recovered</span>
<span class="font-medium text-green-600">{{ recoveryStats.jobsRecovered.toLocaleString() }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Jobs Lost</span>
<span class="font-medium text-red-600">{{ recoveryStats.jobsLost }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Recovery Time</span>
<span class="font-medium">{{ recoveryStats.recoveryTime }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Success Rate</span>
<span class="font-medium text-blue-600">{{ recoveryStats.successRate }}%</span>
</div>
</div>
</div>
<!-- Recovery Timeline -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">Recovery Timeline</h4>
<div class="space-y-3">
<div
v-for="(event, index) in recoveryTimeline"
:key="index"
class="flex items-start"
>
<div
class="w-3 h-3 rounded-full mt-1 mr-3"
:class="{
'bg-green-500': event.status === 'completed',
'bg-yellow-500': event.status === 'in-progress',
'bg-red-500': event.status === 'failed'
}"
></div>
<div>
<p class="text-sm font-medium">{{ event.action }}</p>
<p class="text-xs text-gray-500">{{ event.timestamp }}</p>
</div>
</div>
</div>
</div>
<!-- Queue State -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">Current Queue State</h4>
<div class="space-y-3">
<div
v-for="(queue, index) in queueStates"
:key="index"
class="p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium">{{ queue.name }}</span>
<span
class="text-xs px-2 py-1 rounded-full"
:class="{
'bg-green-100 text-green-800': queue.status === 'healthy',
'bg-yellow-100 text-yellow-800': queue.status === 'recovering',
'bg-red-100 text-red-800': queue.status === 'error'
}"
>
{{ queue.status }}
</span>
</div>
<div class="flex justify-between text-xs text-gray-600">
<span>{{ queue.count }} jobs</span>
<span>{{ queue.lastProcessed }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Persistence Configuration -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-settings"></Icon>
<h3 class="text-lg font-semibold text-primary">Persistence Settings</h3>
</div>
<rs-button @click="savePersistenceConfig">
<Icon class="mr-1" name="ic:outline-save"></Icon>
Save Configuration
</rs-button>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- General Settings -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">General Settings</h4>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Persistence Mode</label>
<select v-model="persistenceConfig.mode" class="w-full p-2 border border-gray-300 rounded-md">
<option value="immediate">Immediate (Every job)</option>
<option value="batch">Batch (Every N jobs)</option>
<option value="interval">Interval (Every N seconds)</option>
<option value="hybrid">Hybrid (Immediate + Batch)</option>
</select>
</div>
<div v-if="persistenceConfig.mode === 'batch'">
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Size</label>
<input
type="number"
v-model="persistenceConfig.batchSize"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
</div>
<div v-if="persistenceConfig.mode === 'interval'">
<label class="block text-sm font-medium text-gray-700 mb-2">Interval (seconds)</label>
<input
type="number"
v-model="persistenceConfig.interval"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Data Retention (days)</label>
<input
type="number"
v-model="persistenceConfig.retentionDays"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
<p class="text-xs text-gray-500 mt-1">How long to keep completed job data</p>
</div>
<div class="flex items-center">
<input
type="checkbox"
v-model="persistenceConfig.compressData"
class="mr-2"
>
<span class="text-sm text-gray-700">Enable data compression</span>
</div>
</div>
<!-- Recovery Settings -->
<div class="space-y-4">
<h4 class="font-medium text-gray-700">Recovery Settings</h4>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Recovery Strategy</label>
<select v-model="persistenceConfig.recoveryStrategy" class="w-full p-2 border border-gray-300 rounded-md">
<option value="full">Full Recovery (All jobs)</option>
<option value="priority">Priority Recovery (High priority first)</option>
<option value="recent">Recent Recovery (Last N hours)</option>
<option value="selective">Selective Recovery (Manual selection)</option>
</select>
</div>
<div v-if="persistenceConfig.recoveryStrategy === 'recent'">
<label class="block text-sm font-medium text-gray-700 mb-2">Recovery Window (hours)</label>
<input
type="number"
v-model="persistenceConfig.recoveryWindow"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Max Recovery Time (seconds)</label>
<input
type="number"
v-model="persistenceConfig.maxRecoveryTime"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
<p class="text-xs text-gray-500 mt-1">Maximum time allowed for recovery process</p>
</div>
<div class="flex items-center">
<input
type="checkbox"
v-model="persistenceConfig.autoRecovery"
class="mr-2"
>
<span class="text-sm text-gray-700">Enable automatic recovery on startup</span>
</div>
<div class="flex items-center">
<input
type="checkbox"
v-model="persistenceConfig.validateRecovery"
class="mr-2"
>
<span class="text-sm text-gray-700">Validate recovered jobs before processing</span>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Storage Configuration Modal -->
<rs-modal v-model="showStorageModal" title="Storage Configuration">
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Storage Type</label>
<select v-model="storageConfig.type" class="w-full p-2 border border-gray-300 rounded-md">
<option value="Redis">Redis</option>
<option value="PostgreSQL">PostgreSQL</option>
<option value="MongoDB">MongoDB</option>
<option value="MySQL">MySQL</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Connection String</label>
<input
type="text"
v-model="storageConfig.connectionString"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="redis://localhost:6379"
>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Max Connections</label>
<input
type="number"
v-model="storageConfig.maxConnections"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Connection Timeout (ms)</label>
<input
type="number"
v-model="storageConfig.connectionTimeout"
class="w-full p-2 border border-gray-300 rounded-md"
min="100"
>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<rs-button variant="outline" @click="showStorageModal = false">
Cancel
</rs-button>
<rs-button @click="saveStorageConfig">
Save Configuration
</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Queue Persistence",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Persistence",
path: "/notification/queue-scheduler/persistence",
},
],
});
// Reactive data
const showStorageModal = ref(false);
const showRecoveryDetails = ref(false);
// Persistence metrics
const persistenceMetrics = ref([
{
title: "Storage Health",
value: "Healthy",
icon: "ic:outline-health-and-safety",
bgColor: "bg-green-100",
iconColor: "text-green-600",
valueColor: "text-green-600",
status: "healthy"
},
{
title: "Persisted Jobs",
value: "847,293",
icon: "ic:outline-storage",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
valueColor: "text-blue-600"
},
{
title: "Recovery Rate",
value: "99.97%",
icon: "ic:outline-restore",
bgColor: "bg-purple-100",
iconColor: "text-purple-600",
valueColor: "text-purple-600",
status: "healthy"
},
{
title: "Storage Usage",
value: "67%",
icon: "ic:outline-pie-chart",
bgColor: "bg-yellow-100",
iconColor: "text-yellow-600",
valueColor: "text-yellow-600",
status: "warning"
}
]);
// Storage configuration
const storageConfig = ref({
type: "Redis",
status: "connected",
connectionPool: 8,
maxConnections: 20,
responseTime: 2.3,
usedSpace: "2.4 GB",
totalSpace: "10 GB",
usagePercentage: 67,
lastBackup: "2 hours ago",
connectionString: "redis://localhost:6379",
connectionTimeout: 5000
});
// Backup configuration
const backupConfig = ref({
autoBackupEnabled: true,
frequency: "Every 6 hours",
retention: 30
});
// Recent backups
const recentBackups = ref([
{
name: "queue-backup-2024-01-15-14-30",
size: "1.2 GB",
timestamp: "2 hours ago",
status: "completed"
},
{
name: "queue-backup-2024-01-15-08-30",
size: "1.1 GB",
timestamp: "8 hours ago",
status: "completed"
},
{
name: "queue-backup-2024-01-15-02-30",
size: "1.0 GB",
timestamp: "14 hours ago",
status: "completed"
},
{
name: "queue-backup-2024-01-14-20-30",
size: "987 MB",
timestamp: "20 hours ago",
status: "completed"
}
]);
// Recovery test
const recoveryTest = ref({
lastTest: "3 days ago",
status: "passed"
});
// System restart info
const lastSystemRestart = ref("5 days ago");
// Recovery statistics
const recoveryStats = ref({
jobsRecovered: 15847,
jobsLost: 3,
recoveryTime: "2.3 seconds",
successRate: 99.97
});
// Recovery timeline
const recoveryTimeline = ref([
{
action: "System startup detected",
timestamp: "5 days ago, 09:15:23",
status: "completed"
},
{
action: "Storage connection established",
timestamp: "5 days ago, 09:15:24",
status: "completed"
},
{
action: "Queue data recovery initiated",
timestamp: "5 days ago, 09:15:25",
status: "completed"
},
{
action: "15,847 jobs recovered successfully",
timestamp: "5 days ago, 09:15:27",
status: "completed"
},
{
action: "Queue processing resumed",
timestamp: "5 days ago, 09:15:28",
status: "completed"
}
]);
// Queue states
const queueStates = ref([
{
name: "High Priority",
count: 234,
status: "healthy",
lastProcessed: "2 seconds ago"
},
{
name: "Medium Priority",
count: 1847,
status: "healthy",
lastProcessed: "1 second ago"
},
{
name: "Low Priority",
count: 3421,
status: "healthy",
lastProcessed: "5 seconds ago"
},
{
name: "Bulk Operations",
count: 2502,
status: "recovering",
lastProcessed: "30 seconds ago"
}
]);
// Persistence configuration
const persistenceConfig = ref({
mode: "hybrid",
batchSize: 100,
interval: 30,
retentionDays: 30,
compressData: true,
recoveryStrategy: "priority",
recoveryWindow: 24,
maxRecoveryTime: 300,
autoRecovery: true,
validateRecovery: true
});
// Methods
const testPersistence = () => {
console.log('Running persistence test...');
// Simulate persistence test
};
const createBackup = () => {
console.log('Creating backup...');
// Add new backup to the list
const now = new Date();
const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-');
recentBackups.value.unshift({
name: `queue-backup-${timestamp}`,
size: "1.3 GB",
timestamp: "Just now",
status: "in-progress"
});
// Simulate completion after 3 seconds
setTimeout(() => {
recentBackups.value[0].status = "completed";
}, 3000);
};
const downloadBackup = (backup) => {
console.log('Downloading backup:', backup.name);
// Simulate download
};
const restoreBackup = (backup) => {
console.log('Restoring backup:', backup.name);
// Simulate restore
};
const runRecoveryTest = () => {
console.log('Running recovery test...');
recoveryTest.value.lastTest = "Just now";
// Simulate test
};
const updateBackupConfig = () => {
console.log('Updating backup configuration:', backupConfig.value);
// Save backup config
};
const savePersistenceConfig = () => {
console.log('Saving persistence configuration:', persistenceConfig.value);
// Save persistence config
};
const saveStorageConfig = () => {
console.log('Saving storage configuration:', storageConfig.value);
showStorageModal.value = false;
// Save storage config
};
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,665 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-priority-high"></Icon>
<h1 class="text-xl font-bold text-primary">Priority Queue Management</h1>
</div>
<rs-button @click="showCreatePriorityModal = true">
<Icon class="mr-1" name="ic:outline-add"></Icon>
Create Priority Level
</rs-button>
</div>
</template>
<template #body>
<p class="text-gray-600">
Manage different priority levels for notifications to ensure critical messages are processed first.
Higher priority notifications will be processed before lower priority ones in the queue.
</p>
</template>
</rs-card>
<!-- Priority Level Statistics -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(stat, index) in priorityStats"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-4 flex justify-center items-center rounded-2xl"
:class="stat.bgColor"
>
<Icon class="text-2xl" :class="stat.iconColor" :name="stat.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-2xl leading-tight" :class="stat.valueColor">
{{ stat.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ stat.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Priority Levels Configuration -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-settings"></Icon>
<h3 class="text-lg font-semibold text-primary">Priority Levels</h3>
</div>
<div class="flex items-center gap-3">
<rs-button variant="outline" size="sm" @click="refreshPriorityLevels">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
<rs-button variant="outline" size="sm" @click="showBulkEditModal = true">
<Icon class="mr-1" name="ic:outline-edit"></Icon>
Bulk Edit
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="space-y-4">
<div
v-for="(priority, index) in priorityLevels"
:key="index"
class="flex items-center justify-between p-4 border rounded-lg"
:class="{
'border-red-200 bg-red-50': priority.level === 'critical',
'border-orange-200 bg-orange-50': priority.level === 'high',
'border-yellow-200 bg-yellow-50': priority.level === 'medium',
'border-blue-200 bg-blue-50': priority.level === 'low',
'border-gray-200 bg-gray-50': priority.level === 'bulk'
}"
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<div
class="w-4 h-4 rounded-full"
:class="{
'bg-red-500': priority.level === 'critical',
'bg-orange-500': priority.level === 'high',
'bg-yellow-500': priority.level === 'medium',
'bg-blue-500': priority.level === 'low',
'bg-gray-500': priority.level === 'bulk'
}"
></div>
<span class="font-medium text-lg">{{ priority.name }}</span>
<span class="text-sm text-gray-500">({{ priority.level }})</span>
</div>
<div class="flex items-center gap-6">
<div class="text-center">
<p class="text-sm text-gray-600">Weight</p>
<p class="font-bold">{{ priority.weight }}</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Queue Count</p>
<p class="font-bold">{{ priority.queueCount.toLocaleString() }}</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Avg Processing</p>
<p class="font-bold">{{ priority.avgProcessingTime }}</p>
</div>
<div class="text-center">
<p class="text-sm text-gray-600">Status</p>
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': priority.status === 'active',
'bg-red-100 text-red-800': priority.status === 'paused',
'bg-yellow-100 text-yellow-800': priority.status === 'throttled'
}"
>
{{ priority.status }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<rs-button
variant="outline"
size="sm"
@click="editPriority(priority)"
>
<Icon class="mr-1" name="ic:outline-edit"></Icon>
Edit
</rs-button>
<rs-button
variant="outline"
size="sm"
:class="priority.status === 'active' ? 'text-red-600' : 'text-green-600'"
@click="togglePriorityStatus(priority)"
>
<Icon
class="mr-1"
:name="priority.status === 'active' ? 'ic:outline-pause' : 'ic:outline-play-arrow'"
></Icon>
{{ priority.status === 'active' ? 'Pause' : 'Resume' }}
</rs-button>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Queue Processing Order -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-sort"></Icon>
<h3 class="text-lg font-semibold text-primary">Processing Order Visualization</h3>
</div>
</template>
<template #body>
<div class="space-y-4">
<p class="text-gray-600 mb-4">
Notifications are processed in the following order based on priority weights:
</p>
<!-- Processing Flow -->
<div class="flex items-center justify-between bg-gray-50 p-4 rounded-lg">
<div
v-for="(level, index) in sortedPriorityLevels"
:key="index"
class="flex flex-col items-center"
>
<div
class="w-16 h-16 rounded-full flex items-center justify-center text-white font-bold text-lg mb-2"
:class="{
'bg-red-500': level.level === 'critical',
'bg-orange-500': level.level === 'high',
'bg-yellow-500': level.level === 'medium',
'bg-blue-500': level.level === 'low',
'bg-gray-500': level.level === 'bulk'
}"
>
{{ level.weight }}
</div>
<span class="text-sm font-medium">{{ level.name }}</span>
<span class="text-xs text-gray-500">{{ level.queueCount }} jobs</span>
<!-- Arrow -->
<Icon
v-if="index < sortedPriorityLevels.length - 1"
class="text-gray-400 mt-2"
name="ic:outline-arrow-forward"
></Icon>
</div>
</div>
<!-- Processing Rules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
<div class="bg-blue-50 p-4 rounded-lg">
<h4 class="font-medium text-blue-800 mb-2">Processing Rules</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li> Higher weight = Higher priority</li>
<li> Critical jobs always processed first</li>
<li> Same priority jobs use FIFO order</li>
<li> Bulk jobs processed during low traffic</li>
</ul>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<h4 class="font-medium text-green-800 mb-2">Performance Impact</h4>
<ul class="text-sm text-green-700 space-y-1">
<li> Critical: &lt; 1 second processing</li>
<li> High: &lt; 5 seconds processing</li>
<li> Medium: &lt; 30 seconds processing</li>
<li> Low/Bulk: Best effort processing</li>
</ul>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Recent Priority Queue Activity -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-history"></Icon>
<h3 class="text-lg font-semibold text-primary">Recent Priority Queue Activity</h3>
</div>
<rs-button variant="outline" size="sm" @click="navigateTo('/notification/queue-scheduler/monitor')">
View Full Monitor
</rs-button>
</div>
</template>
<template #body>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Job ID
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Priority
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Queue Time
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Processing Time
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(job, index) in recentJobs" :key="index">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ job.id }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-red-100 text-red-800': job.priority === 'critical',
'bg-orange-100 text-orange-800': job.priority === 'high',
'bg-yellow-100 text-yellow-800': job.priority === 'medium',
'bg-blue-100 text-blue-800': job.priority === 'low',
'bg-gray-100 text-gray-800': job.priority === 'bulk'
}"
>
{{ job.priority }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ job.type }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': job.status === 'completed',
'bg-yellow-100 text-yellow-800': job.status === 'processing',
'bg-red-100 text-red-800': job.status === 'failed',
'bg-blue-100 text-blue-800': job.status === 'queued'
}"
>
{{ job.status }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ job.queueTime }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ job.processingTime }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
</rs-card>
<!-- Create Priority Level Modal -->
<rs-modal v-model="showCreatePriorityModal" title="Create Priority Level">
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority Name</label>
<input
type="text"
v-model="newPriority.name"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="e.g., Emergency Alerts"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority Level</label>
<select v-model="newPriority.level" class="w-full p-2 border border-gray-300 rounded-md">
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="bulk">Bulk</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Weight (1-100)</label>
<input
type="number"
v-model="newPriority.weight"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
max="100"
>
<p class="text-xs text-gray-500 mt-1">Higher weight = Higher priority</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Description</label>
<textarea
v-model="newPriority.description"
class="w-full p-2 border border-gray-300 rounded-md"
rows="3"
placeholder="Describe when this priority level should be used..."
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Max Processing Time (seconds)</label>
<input
type="number"
v-model="newPriority.maxProcessingTime"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
<p class="text-xs text-gray-500 mt-1">Maximum time allowed for processing jobs of this priority</p>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<rs-button variant="outline" @click="showCreatePriorityModal = false">
Cancel
</rs-button>
<rs-button @click="createPriorityLevel">
Create Priority Level
</rs-button>
</div>
</template>
</rs-modal>
<!-- Edit Priority Modal -->
<rs-modal v-model="showEditPriorityModal" title="Edit Priority Level">
<div class="space-y-6" v-if="editingPriority">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Priority Name</label>
<input
type="text"
v-model="editingPriority.name"
class="w-full p-2 border border-gray-300 rounded-md"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Weight (1-100)</label>
<input
type="number"
v-model="editingPriority.weight"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
max="100"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Max Processing Time (seconds)</label>
<input
type="number"
v-model="editingPriority.maxProcessingTime"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<select v-model="editingPriority.status" class="w-full p-2 border border-gray-300 rounded-md">
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="throttled">Throttled</option>
</select>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<rs-button variant="outline" @click="showEditPriorityModal = false">
Cancel
</rs-button>
<rs-button @click="savePriorityChanges">
Save Changes
</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Priority Queue Management",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Priority Management",
path: "/notification/queue-scheduler/priority",
},
],
});
// Reactive data
const showCreatePriorityModal = ref(false);
const showEditPriorityModal = ref(false);
const showBulkEditModal = ref(false);
const editingPriority = ref(null);
// Priority statistics
const priorityStats = ref([
{
title: "Critical Jobs",
value: "47",
icon: "ic:outline-priority-high",
bgColor: "bg-red-100",
iconColor: "text-red-600",
valueColor: "text-red-600"
},
{
title: "High Priority",
value: "234",
icon: "ic:outline-trending-up",
bgColor: "bg-orange-100",
iconColor: "text-orange-600",
valueColor: "text-orange-600"
},
{
title: "Medium Priority",
value: "1,847",
icon: "ic:outline-remove",
bgColor: "bg-yellow-100",
iconColor: "text-yellow-600",
valueColor: "text-yellow-600"
},
{
title: "Low/Bulk Priority",
value: "5,923",
icon: "ic:outline-trending-down",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
valueColor: "text-blue-600"
}
]);
// Priority levels configuration
const priorityLevels = ref([
{
name: "Emergency Alerts",
level: "critical",
weight: 100,
queueCount: 47,
avgProcessingTime: "0.8s",
status: "active",
maxProcessingTime: 5,
description: "System emergencies and critical security alerts"
},
{
name: "Real-time Notifications",
level: "high",
weight: 80,
queueCount: 234,
avgProcessingTime: "2.1s",
status: "active",
maxProcessingTime: 10,
description: "Time-sensitive notifications like OTP, payment confirmations"
},
{
name: "Standard Notifications",
level: "medium",
weight: 50,
queueCount: 1847,
avgProcessingTime: "5.3s",
status: "active",
maxProcessingTime: 30,
description: "Regular app notifications and updates"
},
{
name: "Marketing Messages",
level: "low",
weight: 30,
queueCount: 3421,
avgProcessingTime: "12.7s",
status: "active",
maxProcessingTime: 60,
description: "Promotional content and marketing campaigns"
},
{
name: "Bulk Operations",
level: "bulk",
weight: 10,
queueCount: 2502,
avgProcessingTime: "45.2s",
status: "throttled",
maxProcessingTime: 300,
description: "Large batch operations and system maintenance"
}
]);
// New priority form
const newPriority = ref({
name: "",
level: "medium",
weight: 50,
description: "",
maxProcessingTime: 30
});
// Computed sorted priority levels
const sortedPriorityLevels = computed(() => {
return [...priorityLevels.value].sort((a, b) => b.weight - a.weight);
});
// Recent jobs data
const recentJobs = ref([
{
id: "job-001",
priority: "critical",
type: "Security Alert",
status: "completed",
queueTime: "0.1s",
processingTime: "0.8s"
},
{
id: "job-002",
priority: "high",
type: "OTP SMS",
status: "completed",
queueTime: "0.3s",
processingTime: "1.2s"
},
{
id: "job-003",
priority: "medium",
type: "App Notification",
status: "processing",
queueTime: "2.1s",
processingTime: "3.4s"
},
{
id: "job-004",
priority: "low",
type: "Newsletter",
status: "queued",
queueTime: "15.2s",
processingTime: "-"
},
{
id: "job-005",
priority: "bulk",
type: "Data Export",
status: "queued",
queueTime: "45.7s",
processingTime: "-"
}
]);
// Methods
const refreshPriorityLevels = () => {
console.log('Refreshing priority levels...');
// Simulate data refresh
};
const createPriorityLevel = () => {
console.log('Creating priority level:', newPriority.value);
// Add to priority levels
priorityLevels.value.push({
...newPriority.value,
queueCount: 0,
avgProcessingTime: "0s",
status: "active"
});
// Reset form
newPriority.value = {
name: "",
level: "medium",
weight: 50,
description: "",
maxProcessingTime: 30
};
showCreatePriorityModal.value = false;
};
const editPriority = (priority) => {
editingPriority.value = { ...priority };
showEditPriorityModal.value = true;
};
const savePriorityChanges = () => {
const index = priorityLevels.value.findIndex(p => p.name === editingPriority.value.name);
if (index !== -1) {
priorityLevels.value[index] = { ...editingPriority.value };
}
showEditPriorityModal.value = false;
editingPriority.value = null;
};
const togglePriorityStatus = (priority) => {
const newStatus = priority.status === 'active' ? 'paused' : 'active';
priority.status = newStatus;
console.log(`Priority ${priority.name} status changed to ${newStatus}`);
};
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,768 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-speed"></Icon>
<h1 class="text-xl font-bold text-primary">Rate Limiting</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Throttles how many messages/jobs can be processed per second/minute/hour.
Avoid hitting API limits (Twilio, SendGrid) and prevent spammy behavior that can trigger blacklisting.
</p>
</template>
</rs-card>
<!-- Rate Limit Statistics -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(stat, index) in rateLimitStats"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-4 flex justify-center items-center rounded-2xl"
:class="stat.bgColor"
>
<Icon class="text-2xl" :class="stat.iconColor" :name="stat.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-xl leading-tight" :class="stat.textColor">
{{ stat.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ stat.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Current Usage Overview -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Current Usage Overview</h3>
<div class="flex items-center gap-2">
<div class="flex items-center">
<div class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
<span class="text-sm text-gray-600">Live Updates</span>
</div>
<rs-button variant="outline" size="sm" @click="refreshUsage">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="(usage, index) in currentUsage"
:key="index"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<Icon class="mr-2 text-primary" :name="usage.icon"></Icon>
<h4 class="font-semibold">{{ usage.service }}</h4>
</div>
<rs-badge :variant="getUsageVariant(usage.percentage)">
{{ usage.percentage }}%
</rs-badge>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Current Rate:</span>
<span class="font-medium">{{ usage.currentRate }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Limit:</span>
<span class="font-medium">{{ usage.limit }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Window:</span>
<span class="font-medium">{{ usage.window }}</span>
</div>
</div>
<div class="mt-3">
<rs-progress-bar
:value="usage.percentage"
:variant="usage.percentage > 80 ? 'danger' : usage.percentage > 60 ? 'warning' : 'success'"
/>
</div>
<div class="mt-2 text-xs text-gray-500">
Resets in {{ usage.resetTime }}
</div>
</div>
</div>
</template>
</rs-card>
<!-- Rate Limit Configuration -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Rate Limit Configuration</h3>
<rs-button variant="outline" size="sm" @click="showConfigModal = true">
<Icon class="mr-1" name="ic:outline-settings"></Icon>
Configure Limits
</rs-button>
</div>
</template>
<template #body>
<rs-table
:field="configTableFields"
:data="rateLimitConfigs"
:options="{ striped: true, hover: true }"
:optionsAdvanced="{ sortable: true, filterable: false }"
advanced
/>
</template>
</rs-card>
<!-- Rate Limit Violations -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Recent Rate Limit Violations</h3>
<div class="flex items-center gap-2">
<select v-model="violationFilter" class="p-2 border border-gray-300 rounded-md text-sm">
<option value="">All Services</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="push">Push</option>
<option value="webhook">Webhook</option>
</select>
<rs-button variant="outline" size="sm" @click="refreshViolations">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<div v-if="filteredViolations.length === 0" class="text-center py-8 text-gray-500">
<Icon class="text-4xl mb-2" name="ic:outline-check-circle"></Icon>
<p>No rate limit violations in the selected timeframe</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(violation, index) in filteredViolations"
:key="index"
class="border border-red-200 bg-red-50 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Icon class="mr-2 text-red-600" name="ic:outline-warning"></Icon>
<span class="font-semibold text-red-800">{{ violation.service }} Rate Limit Exceeded</span>
</div>
<span class="text-sm text-red-600">{{ violation.timestamp }}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span class="text-red-700">Attempted Rate:</span>
<span class="ml-1 font-medium">{{ violation.attemptedRate }}</span>
</div>
<div>
<span class="text-red-700">Limit:</span>
<span class="ml-1 font-medium">{{ violation.limit }}</span>
</div>
<div>
<span class="text-red-700">Messages Dropped:</span>
<span class="ml-1 font-medium">{{ violation.droppedMessages }}</span>
</div>
</div>
<p class="text-sm text-red-700 mt-2">{{ violation.description }}</p>
</div>
</div>
</template>
</rs-card>
<!-- Rate Limit Analytics -->
<rs-card class="mb-6">
<template #header>
<h3 class="text-lg font-semibold text-primary">Rate Limit Analytics</h3>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Usage Trends -->
<div>
<h4 class="font-semibold mb-4">Usage Trends (Last 24 Hours)</h4>
<div class="space-y-3">
<div
v-for="(trend, index) in usageTrends"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center">
<Icon class="mr-2 text-primary" :name="trend.icon"></Icon>
<div>
<p class="font-medium">{{ trend.service }}</p>
<p class="text-sm text-gray-600">Peak: {{ trend.peak }}</p>
</div>
</div>
<div class="text-right">
<p class="font-medium">{{ trend.average }}</p>
<p class="text-sm text-gray-600">Average</p>
</div>
</div>
</div>
</div>
<!-- Efficiency Metrics -->
<div>
<h4 class="font-semibold mb-4">Efficiency Metrics</h4>
<div class="space-y-4">
<div class="bg-green-50 border border-green-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-green-600" name="ic:outline-trending-up"></Icon>
<span class="font-medium text-green-800">Throughput Optimization</span>
</div>
<p class="text-sm text-green-700">
Current efficiency: {{ efficiencyMetrics.throughputOptimization }}%
</p>
</div>
<div class="bg-blue-50 border border-blue-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-blue-600" name="ic:outline-schedule"></Icon>
<span class="font-medium text-blue-800">Queue Utilization</span>
</div>
<p class="text-sm text-blue-700">
Average queue utilization: {{ efficiencyMetrics.queueUtilization }}%
</p>
</div>
<div class="bg-purple-50 border border-purple-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-purple-600" name="ic:outline-timer"></Icon>
<span class="font-medium text-purple-800">Response Time</span>
</div>
<p class="text-sm text-purple-700">
Average response time: {{ efficiencyMetrics.responseTime }}ms
</p>
</div>
<div class="bg-orange-50 border border-orange-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-orange-600" name="ic:outline-error"></Icon>
<span class="font-medium text-orange-800">Error Rate</span>
</div>
<p class="text-sm text-orange-700">
Rate limit errors: {{ efficiencyMetrics.errorRate }}%
</p>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Rate Limit Testing -->
<rs-card>
<template #header>
<h3 class="text-lg font-semibold text-primary">Rate Limit Testing</h3>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Service to Test</label>
<select v-model="testConfig.service" class="w-full p-2 border border-gray-300 rounded-md">
<option value="">Select service</option>
<option value="email">Email (SendGrid)</option>
<option value="sms">SMS (Twilio)</option>
<option value="push">Push (Firebase)</option>
<option value="webhook">Webhook</option>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Messages per Second</label>
<input
v-model.number="testConfig.rate"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="10"
min="1"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Duration (seconds)</label>
<input
v-model.number="testConfig.duration"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
placeholder="60"
min="1"
max="300"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Test Message</label>
<textarea
v-model="testConfig.message"
class="w-full p-2 border border-gray-300 rounded-md"
rows="3"
placeholder="Test message content"
></textarea>
</div>
<rs-button
@click="startRateLimitTest"
variant="primary"
class="w-full"
:disabled="testRunning"
>
<Icon class="mr-1" :name="testRunning ? 'ic:outline-stop' : 'ic:outline-play-arrow'"></Icon>
{{ testRunning ? 'Test Running...' : 'Start Rate Limit Test' }}
</rs-button>
</div>
<div class="space-y-4">
<h4 class="font-semibold">Test Results</h4>
<div v-if="testResults.length === 0" class="text-center text-gray-500 py-8">
<Icon class="text-4xl mb-2" name="ic:outline-science"></Icon>
<p>Run a test to see results</p>
</div>
<div v-else class="space-y-3 max-h-60 overflow-y-auto">
<div
v-for="(result, index) in testResults"
:key="index"
class="border border-gray-200 rounded p-3"
>
<div class="flex justify-between items-start mb-2">
<span class="font-medium">{{ result.service }}</span>
<span :class="{
'text-green-600': result.status === 'success',
'text-red-600': result.status === 'rate_limited',
'text-yellow-600': result.status === 'warning'
}" class="text-sm font-medium">{{ result.status }}</span>
</div>
<div class="text-sm space-y-1">
<div class="flex justify-between">
<span class="text-gray-600">Messages Sent:</span>
<span>{{ result.messagesSent }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Rate Achieved:</span>
<span>{{ result.rateAchieved }}/sec</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Errors:</span>
<span>{{ result.errors }}</span>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">{{ result.timestamp }}</p>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Configuration Modal -->
<rs-modal v-model="showConfigModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">Rate Limit Configuration</h3>
</template>
<template #body>
<div class="space-y-6">
<div
v-for="(config, index) in editableConfigs"
:key="index"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center mb-4">
<Icon class="mr-2 text-primary" :name="config.icon"></Icon>
<h4 class="font-semibold">{{ config.service }} Configuration</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Messages per Second</label>
<input
v-model.number="config.perSecond"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Messages per Minute</label>
<input
v-model.number="config.perMinute"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Messages per Hour</label>
<input
v-model.number="config.perHour"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Burst Limit</label>
<input
v-model.number="config.burstLimit"
type="number"
class="w-full p-2 border border-gray-300 rounded-md"
min="1"
/>
</div>
</div>
<div class="mt-4">
<label class="flex items-center">
<input
v-model="config.enabled"
type="checkbox"
class="mr-2"
/>
<span class="text-sm font-medium text-gray-700">Enable rate limiting for this service</span>
</label>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showConfigModal = false">Cancel</rs-button>
<rs-button @click="saveRateLimitConfig" variant="primary">Save Configuration</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Rate Limiting",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Rate Limiting",
path: "/notification/queue-scheduler/rate-limit",
},
],
});
// Reactive data
const showConfigModal = ref(false);
const violationFilter = ref('');
const testRunning = ref(false);
const testResults = ref([]);
// Statistics
const rateLimitStats = ref([
{
title: "Active Limits",
value: "12",
icon: "ic:outline-speed",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
textColor: "text-blue-600"
},
{
title: "Messages/Hour",
value: "45.2K",
icon: "ic:outline-trending-up",
bgColor: "bg-green-100",
iconColor: "text-green-600",
textColor: "text-green-600"
},
{
title: "Violations Today",
value: "3",
icon: "ic:outline-warning",
bgColor: "bg-red-100",
iconColor: "text-red-600",
textColor: "text-red-600"
},
{
title: "Efficiency",
value: "96.8%",
icon: "ic:outline-check-circle",
bgColor: "bg-purple-100",
iconColor: "text-purple-600",
textColor: "text-purple-600"
}
]);
// Current usage
const currentUsage = ref([
{
service: 'Email (SendGrid)',
icon: 'ic:outline-email',
currentRate: '850/hour',
limit: '1000/hour',
percentage: 85,
window: '1 hour',
resetTime: '23 minutes'
},
{
service: 'SMS (Twilio)',
icon: 'ic:outline-sms',
currentRate: '45/minute',
limit: '100/minute',
percentage: 45,
window: '1 minute',
resetTime: '32 seconds'
},
{
service: 'Push (Firebase)',
icon: 'ic:outline-notifications',
currentRate: '1200/hour',
limit: '5000/hour',
percentage: 24,
window: '1 hour',
resetTime: '45 minutes'
},
{
service: 'Webhook',
icon: 'ic:outline-webhook',
currentRate: '15/second',
limit: '20/second',
percentage: 75,
window: '1 second',
resetTime: '0.5 seconds'
}
]);
// Configuration table fields
const configTableFields = ref([
{ key: 'service', label: 'Service', sortable: true },
{ key: 'perSecond', label: 'Per Second', sortable: true },
{ key: 'perMinute', label: 'Per Minute', sortable: true },
{ key: 'perHour', label: 'Per Hour', sortable: true },
{ key: 'burstLimit', label: 'Burst Limit', sortable: true },
{ key: 'status', label: 'Status', sortable: true },
{ key: 'actions', label: 'Actions', sortable: false }
]);
// Rate limit configurations
const rateLimitConfigs = ref([
{
service: 'Email (SendGrid)',
icon: 'ic:outline-email',
perSecond: 10,
perMinute: 600,
perHour: 1000,
burstLimit: 50,
enabled: true
},
{
service: 'SMS (Twilio)',
icon: 'ic:outline-sms',
perSecond: 5,
perMinute: 100,
perHour: 2000,
burstLimit: 20,
enabled: true
},
{
service: 'Push (Firebase)',
icon: 'ic:outline-notifications',
perSecond: 50,
perMinute: 1000,
perHour: 5000,
burstLimit: 200,
enabled: true
},
{
service: 'Webhook',
icon: 'ic:outline-webhook',
perSecond: 20,
perMinute: 500,
perHour: 10000,
burstLimit: 100,
enabled: true
}
].map(config => ({
...config,
status: h('span', {
class: `px-2 py-1 rounded text-xs font-medium ${
config.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`
}, config.enabled ? 'Active' : 'Disabled'),
actions: h('button', {
class: 'text-blue-600 hover:text-blue-800 text-sm',
onClick: () => editRateLimit(config)
}, 'Edit')
})));
// Editable configs for modal
const editableConfigs = ref(JSON.parse(JSON.stringify(rateLimitConfigs.value.map(c => ({
service: c.service,
icon: c.icon,
perSecond: c.perSecond,
perMinute: c.perMinute,
perHour: c.perHour,
burstLimit: c.burstLimit,
enabled: c.enabled
})))));
// Rate limit violations
const violations = ref([
{
service: 'Email',
timestamp: '2024-01-15 14:30:00',
attemptedRate: '1200/hour',
limit: '1000/hour',
droppedMessages: 45,
description: 'Newsletter campaign exceeded hourly limit during peak hours'
},
{
service: 'SMS',
timestamp: '2024-01-15 12:15:00',
attemptedRate: '150/minute',
limit: '100/minute',
droppedMessages: 23,
description: 'OTP verification burst exceeded per-minute limit'
},
{
service: 'Webhook',
timestamp: '2024-01-15 10:45:00',
attemptedRate: '25/second',
limit: '20/second',
droppedMessages: 12,
description: 'Order webhook notifications exceeded per-second limit'
}
]);
// Usage trends
const usageTrends = ref([
{
service: 'Email',
icon: 'ic:outline-email',
peak: '950/hour',
average: '650/hour'
},
{
service: 'SMS',
icon: 'ic:outline-sms',
peak: '85/minute',
average: '45/minute'
},
{
service: 'Push',
icon: 'ic:outline-notifications',
peak: '2100/hour',
average: '1200/hour'
},
{
service: 'Webhook',
icon: 'ic:outline-webhook',
peak: '18/second',
average: '12/second'
}
]);
// Efficiency metrics
const efficiencyMetrics = ref({
throughputOptimization: 96.8,
queueUtilization: 78.5,
responseTime: 245,
errorRate: 0.3
});
// Test configuration
const testConfig = ref({
service: '',
rate: 10,
duration: 60,
message: 'This is a rate limit test message'
});
// Computed filtered violations
const filteredViolations = computed(() => {
if (!violationFilter.value) {
return violations.value;
}
return violations.value.filter(v => v.service.toLowerCase() === violationFilter.value);
});
// Methods
function getUsageVariant(percentage) {
if (percentage > 80) return 'danger';
if (percentage > 60) return 'warning';
return 'success';
}
function refreshUsage() {
// Mock refresh
console.log('Refreshing usage data...');
}
function refreshViolations() {
// Mock refresh
console.log('Refreshing violations...');
}
function editRateLimit(config) {
// Find and update the editable config
const editableConfig = editableConfigs.value.find(c => c.service === config.service);
if (editableConfig) {
Object.assign(editableConfig, config);
}
showConfigModal.value = true;
}
function saveRateLimitConfig() {
// Mock save
console.log('Saving rate limit configuration...', editableConfigs.value);
showConfigModal.value = false;
}
function startRateLimitTest() {
if (!testConfig.value.service) {
return;
}
testRunning.value = true;
// Mock test execution
setTimeout(() => {
const result = {
service: testConfig.value.service,
messagesSent: Math.floor(testConfig.value.rate * testConfig.value.duration * 0.9),
rateAchieved: Math.floor(testConfig.value.rate * 0.9),
errors: Math.floor(Math.random() * 5),
status: Math.random() > 0.7 ? 'rate_limited' : 'success',
timestamp: new Date().toLocaleString()
};
testResults.value.unshift(result);
testRunning.value = false;
}, 3000);
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,563 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-refresh"></Icon>
<h1 class="text-xl font-bold text-primary">Failed Jobs</h1>
</div>
<rs-button
variant="outline"
size="sm"
@click="refreshJobs"
:loading="isLoading"
:disabled="isLoading"
>
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</template>
<template #body>
<p class="text-gray-600">Handle and retry failed notification jobs.</p>
</template>
</rs-card>
<!-- Error Alert -->
<rs-alert v-if="error" variant="danger" class="mb-6">
{{ error }}
</rs-alert>
<!-- Basic Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<rs-card>
<div class="p-4 text-center">
<div v-if="isLoading" class="animate-pulse">
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
</div>
<template v-else>
<h3 class="text-2xl font-bold text-red-600">{{ stats.failed }}</h3>
<p class="text-sm text-gray-600">Failed Jobs</p>
</template>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<div v-if="isLoading" class="animate-pulse">
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
</div>
<template v-else>
<h3 class="text-2xl font-bold text-yellow-600">
{{ stats.retrying }}
</h3>
<p class="text-sm text-gray-600">Retrying</p>
</template>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<div v-if="isLoading" class="animate-pulse">
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
</div>
<template v-else>
<h3 class="text-2xl font-bold text-green-600">
{{ stats.recovered }}
</h3>
<p class="text-sm text-gray-600">Recovered</p>
</template>
</div>
</rs-card>
<rs-card>
<div class="p-4 text-center">
<div v-if="isLoading" class="animate-pulse">
<div class="h-8 bg-gray-200 rounded w-24 mx-auto mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
</div>
<template v-else>
<h3 class="text-2xl font-bold text-gray-600">
{{ stats.deadLetter }}
</h3>
<p class="text-sm text-gray-600">Dead Letter</p>
</template>
</div>
</rs-card>
</div>
<!-- Failed Jobs -->
<rs-card>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Failed Jobs</h3>
<div class="flex items-center gap-2">
<rs-button
variant="outline"
size="sm"
@click="confirmRetryAll"
:loading="isRetryingAll"
:disabled="isRetryingAll || failedJobs.length === 0"
>
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Retry All
</rs-button>
<rs-button
variant="outline"
size="sm"
@click="refreshJobs"
:loading="isLoading"
:disabled="isLoading"
>
<Icon class="mr-1" name="ic:outline-sync"></Icon>
Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<div v-if="isLoading" class="flex justify-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
<span class="ml-2">Loading...</span>
</div>
<div v-else-if="failedJobs.length === 0" class="text-center py-8 text-gray-500">
<Icon name="ic:outline-check-circle" class="text-3xl text-gray-400 mb-2" />
<p>No failed jobs found</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(job, index) in failedJobs"
:key="index"
class="border border-gray-200 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<div class="w-3 h-3 rounded-full mr-3 bg-red-500"></div>
<div>
<h4 class="font-semibold">{{ job.type }} - {{ truncateId(job.id) }}</h4>
<p class="text-sm text-gray-600">{{ job.description }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<rs-button
variant="outline"
size="sm"
@click="confirmRetryJob(job)"
:loading="retryingJobs[job.id]"
:disabled="retryingJobs[job.id]"
>
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Retry
</rs-button>
<rs-button variant="outline" size="sm" @click="viewError(job)">
<Icon class="mr-1" name="ic:outline-visibility"></Icon>
View
</rs-button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm text-gray-600">
<div>
<span class="font-medium">Attempts:</span> {{ job.attempts }}/{{
job.maxAttempts
}}
</div>
<div><span class="font-medium">Error Type:</span> {{ job.errorType }}</div>
<div>
<span class="font-medium">Failed At:</span>
{{ formatDateTime(job.failedAt) }}
</div>
<div><span class="font-medium">Next Retry:</span> {{ job.nextRetry }}</div>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="failedJobs.length > 0" class="mt-4 flex justify-center">
<button
class="px-3 py-1 border border-gray-300 rounded-l-md disabled:opacity-50"
:disabled="pagination.page === 1"
@click="changePage(pagination.page - 1)"
>
Previous
</button>
<div class="px-3 py-1 border-t border-b border-gray-300">
{{ pagination.page }} / {{ pagination.totalPages }}
</div>
<button
class="px-3 py-1 border border-gray-300 rounded-r-md disabled:opacity-50"
:disabled="pagination.page === pagination.totalPages || !pagination.hasMore"
@click="changePage(pagination.page + 1)"
>
Next
</button>
</div>
</template>
</rs-card>
<!-- Error Details Modal -->
<rs-modal v-model="showErrorModal" title="Job Error Details">
<div v-if="selectedJob" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Job ID</label>
<p class="text-sm text-gray-900">{{ selectedJob.id }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Type</label>
<p class="text-sm text-gray-900">{{ selectedJob.type }}</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Error Message</label
>
<div class="bg-red-50 border border-red-200 rounded p-3 max-h-48 overflow-y-auto">
<pre class="text-sm text-red-800 whitespace-pre-wrap">{{ selectedJob.errorMessage }}</pre>
</div>
</div>
<div v-if="selectedJob.attempts && selectedJob.maxAttempts">
<label class="block text-sm font-medium text-gray-700 mb-2">Retry Attempts</label>
<div class="bg-gray-50 p-3 rounded">
<div class="relative h-2 bg-gray-200 rounded-full">
<div
class="absolute top-0 left-0 h-2 rounded-full"
:class="selectedJob.attempts >= selectedJob.maxAttempts ? 'bg-red-500' : 'bg-yellow-500'"
:style="{width: `${(selectedJob.attempts / selectedJob.maxAttempts) * 100}%`}"
></div>
</div>
<p class="text-sm mt-1 text-gray-600">
{{ selectedJob.attempts }} of {{ selectedJob.maxAttempts }} attempts used
({{ selectedJob.attempts >= selectedJob.maxAttempts ? 'Max attempts reached' : `${selectedJob.maxAttempts - selectedJob.attempts} remaining` }})
</p>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showErrorModal = false">Close</rs-button>
<rs-button
@click="confirmRetrySelectedJob"
:loading="retryingJobs[selectedJob?.id]"
:disabled="retryingJobs[selectedJob?.id]"
>
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Retry Job
</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Failed Jobs",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue",
path: "/notification/queue",
},
{
name: "Failed Jobs",
path: "/notification/queue/retry",
},
],
});
// Basic stats
const stats = ref({
failed: 0,
retrying: 0,
recovered: 0,
deadLetter: 0,
});
// Modal state
const showErrorModal = ref(false);
const selectedJob = ref(null);
// Loading states
const isLoading = ref(false);
const isRetrying = ref(false);
const isRetryingAll = ref(false);
const retryingJobs = ref({});
// Error state
const error = ref(null);
// Failed jobs with pagination
const failedJobs = ref([]);
const pagination = ref({
page: 1,
totalPages: 1,
totalItems: 0,
hasMore: false,
});
// Fetch stats from API
const fetchStats = async () => {
try {
isLoading.value = true;
error.value = null;
const response = await useFetch("/api/notifications/queue/retry/stats");
if (!response.data.value) {
throw new Error("Failed to fetch statistics");
}
if (response.data.value?.success) {
stats.value = response.data.value.data;
} else {
throw new Error(response.data.value.statusMessage || "Failed to fetch statistics");
}
} catch (err) {
console.error("Error fetching stats:", err);
error.value = "Failed to load statistics";
}
};
// Fetch jobs from API
const fetchJobs = async () => {
try {
isLoading.value = true;
error.value = null;
const response = await useFetch("/api/notifications/queue/retry/jobs", {
query: {
page: pagination.value.page,
limit: 10,
},
});
if (!response.data.value) {
throw new Error("Failed to fetch jobs");
}
if (response.data.value?.success) {
failedJobs.value = response.data.value.data.jobs;
pagination.value = response.data.value.data.pagination;
} else {
throw new Error(response.data.value.statusMessage || "Failed to fetch jobs");
}
} catch (err) {
console.error("Error fetching jobs:", err);
error.value = "Failed to load failed jobs";
failedJobs.value = [];
} finally {
isLoading.value = false;
}
};
// Change pagination page
const changePage = (page) => {
pagination.value.page = page;
fetchJobs();
};
// Refresh jobs and stats
const refreshJobs = async () => {
await Promise.all([fetchStats(), fetchJobs()]);
};
// Format date/time
const formatDateTime = (timestamp) => {
if (!timestamp) return "N/A";
try {
const date = new Date(timestamp);
if (isNaN(date.getTime())) return "Invalid Date";
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) {
return `${diffMins} minutes ago`;
} else if (diffMins < 1440) {
return `${Math.floor(diffMins / 60)} hours ago`;
} else {
return date.toLocaleString();
}
} catch (err) {
console.error("Error formatting date:", err);
return "N/A";
}
};
// Truncate long IDs
const truncateId = (id) => {
if (!id) return "Unknown";
if (id.length > 8) {
return id.substring(0, 8) + '...';
}
return id;
};
// Show confirmation dialog for retry all
const confirmRetryAll = async () => {
const { $swal } = useNuxtApp();
const result = await $swal.fire({
title: "Retry All Jobs",
text: `Are you sure you want to retry all ${stats.value.failed} failed jobs?`,
icon: "question",
showCancelButton: true,
confirmButtonText: "Yes, retry all",
cancelButtonText: "Cancel",
});
if (result.isConfirmed) {
retryAll();
}
};
// Show confirmation dialog for retry single job
const confirmRetryJob = async (job) => {
const { $swal } = useNuxtApp();
const result = await $swal.fire({
title: "Retry Job",
text: `Are you sure you want to retry this job?`,
icon: "question",
showCancelButton: true,
confirmButtonText: "Yes, retry",
cancelButtonText: "Cancel",
});
if (result.isConfirmed) {
retryJob(job);
}
};
// Retry all failed jobs
const retryAll = async () => {
try {
isRetryingAll.value = true;
error.value = null;
const { data } = await useFetch("/api/notifications/queue/retry/all", {
method: "POST",
});
if (!data.value?.success) {
throw new Error(data.value?.statusMessage || "Failed to retry all jobs");
}
await refreshJobs();
// Show success message
const { $swal } = useNuxtApp();
await $swal.fire({
title: "Success",
text: data.value.data.message || `${data.value.data.count} jobs queued for retry`,
icon: "success",
});
} catch (err) {
console.error("Error retrying all jobs:", err);
error.value = err.message || "Failed to retry all jobs";
// Show error message
const { $swal } = useNuxtApp();
await $swal.fire({
title: "Error",
text: err.message || "Failed to retry all jobs. Please try again.",
icon: "error",
});
} finally {
isRetryingAll.value = false;
}
};
// Retry a specific job
const retryJob = async (job) => {
try {
// Set loading state for this specific job
retryingJobs.value = {
...retryingJobs.value,
[job.id]: true
};
error.value = null;
const { data } = await useFetch(`/api/notifications/queue/retry/${job.id}`, {
method: "POST",
});
if (!data.value?.success) {
throw new Error(data.value?.statusMessage || "Failed to retry job");
}
await refreshJobs();
// Show success message
const { $swal } = useNuxtApp();
await $swal.fire({
title: "Success",
text: data.value.data?.message || "Job queued for retry",
icon: "success",
});
} catch (err) {
console.error("Error retrying job:", err);
error.value = err.message || "Failed to retry job";
// Show error message
const { $swal } = useNuxtApp();
await $swal.fire({
title: "Error",
text: err.message || "Failed to retry job. Please try again.",
icon: "error",
});
} finally {
retryingJobs.value = {
...retryingJobs.value,
[job.id]: false
};
}
};
// View error details for a job
const viewError = (job) => {
selectedJob.value = job;
showErrorModal.value = true;
};
// Retry selected job from modal
const confirmRetrySelectedJob = async () => {
if (selectedJob.value) {
await confirmRetryJob(selectedJob.value);
}
showErrorModal.value = false;
};
// Initialize data
onMounted(async () => {
await refreshJobs();
});
// Auto-refresh every 30 seconds
let refreshInterval;
onMounted(() => {
refreshInterval = setInterval(refreshJobs, 30000);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,707 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Header Section -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-schedule"></Icon>
<h1 class="text-xl font-bold text-primary">Timezone Handling</h1>
</div>
</template>
<template #body>
<p class="text-gray-600">
Ensures messages are delivered at the right local time for each recipient.
Schedule birthday messages at 9AM local time and avoid 2AM push alerts across timezones.
</p>
</template>
</rs-card>
<!-- Current Time Display -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<rs-card
v-for="(timezone, index) in majorTimezones"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 text-center">
<div class="mb-2">
<Icon class="text-primary text-2xl" name="ic:outline-access-time"></Icon>
</div>
<h3 class="font-semibold text-lg">{{ timezone.name }}</h3>
<p class="text-2xl font-bold text-primary">{{ timezone.time }}</p>
<p class="text-sm text-gray-600">{{ timezone.zone }}</p>
</div>
</rs-card>
</div>
<!-- Timezone Statistics -->
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
<rs-card
v-for="(stat, index) in timezoneStats"
:key="index"
class="transition-all duration-300 hover:shadow-lg"
>
<div class="pt-5 pb-3 px-5 flex items-center gap-4">
<div
class="p-4 flex justify-center items-center rounded-2xl"
:class="stat.bgColor"
>
<Icon class="text-2xl" :class="stat.iconColor" :name="stat.icon"></Icon>
</div>
<div class="flex-1 truncate">
<span class="block font-bold text-xl leading-tight" :class="stat.textColor">
{{ stat.value }}
</span>
<span class="text-sm font-medium text-gray-600">
{{ stat.title }}
</span>
</div>
</div>
</rs-card>
</div>
<!-- Timezone Configuration -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Timezone Configuration</h3>
<rs-button variant="outline" size="sm" @click="showConfigModal = true">
<Icon class="mr-1" name="ic:outline-settings"></Icon>
Configure
</rs-button>
</div>
</template>
<template #body>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="font-semibold mb-3 flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-schedule"></Icon>
Default Delivery Times
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Morning Messages:</span>
<span class="font-medium">{{ config.morningTime }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Afternoon Messages:</span>
<span class="font-medium">{{ config.afternoonTime }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Evening Messages:</span>
<span class="font-medium">{{ config.eveningTime }}</span>
</div>
</div>
</div>
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="font-semibold mb-3 flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-block"></Icon>
Quiet Hours
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Start Time:</span>
<span class="font-medium">{{ config.quietHours.start }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">End Time:</span>
<span class="font-medium">{{ config.quietHours.end }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Emergency Override:</span>
<span class="font-medium">{{ config.quietHours.allowEmergency ? 'Enabled' : 'Disabled' }}</span>
</div>
</div>
</div>
<div class="border border-gray-200 rounded-lg p-4">
<h4 class="font-semibold mb-3 flex items-center">
<Icon class="mr-2 text-primary" name="ic:outline-public"></Icon>
Timezone Detection
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Auto-detect:</span>
<span class="font-medium">{{ config.autoDetect ? 'Enabled' : 'Disabled' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Fallback Timezone:</span>
<span class="font-medium">{{ config.fallbackTimezone }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Update Frequency:</span>
<span class="font-medium">{{ config.updateFrequency }}</span>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Scheduled Messages by Timezone -->
<rs-card class="mb-6">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-primary">Scheduled Messages by Timezone</h3>
<div class="flex items-center gap-2">
<select v-model="selectedTimezone" class="p-2 border border-gray-300 rounded-md text-sm">
<option value="">All Timezones</option>
<option v-for="tz in availableTimezones" :key="tz.value" :value="tz.value">
{{ tz.label }}
</option>
</select>
<rs-button variant="outline" size="sm" @click="refreshScheduledMessages">
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
Refresh
</rs-button>
</div>
</div>
</template>
<template #body>
<rs-table
:field="scheduledMessagesFields"
:data="filteredScheduledMessages"
:options="{ striped: true, hover: true }"
:optionsAdvanced="{ sortable: true, filterable: false }"
advanced
/>
</template>
</rs-card>
<!-- Timezone Distribution Chart -->
<rs-card class="mb-6">
<template #header>
<h3 class="text-lg font-semibold text-primary">User Distribution by Timezone</h3>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Chart would go here in a real implementation -->
<div class="space-y-3">
<div
v-for="(distribution, index) in timezoneDistribution"
:key="index"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center">
<div class="w-4 h-4 rounded mr-3" :style="{ backgroundColor: distribution.color }"></div>
<div>
<p class="font-medium">{{ distribution.timezone }}</p>
<p class="text-sm text-gray-600">{{ distribution.users }} users</p>
</div>
</div>
<div class="text-right">
<p class="font-medium">{{ distribution.percentage }}%</p>
<p class="text-sm text-gray-600">{{ distribution.currentTime }}</p>
</div>
</div>
</div>
<div class="space-y-4">
<h4 class="font-semibold">Delivery Optimization</h4>
<div class="space-y-3">
<div class="bg-green-50 border border-green-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-green-600" name="ic:outline-check-circle"></Icon>
<span class="font-medium text-green-800">Optimal Delivery Windows</span>
</div>
<p class="text-sm text-green-700">
{{ optimizationStats.optimalWindows }} messages scheduled during optimal hours
</p>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-yellow-600" name="ic:outline-warning"></Icon>
<span class="font-medium text-yellow-800">Quiet Hours Conflicts</span>
</div>
<p class="text-sm text-yellow-700">
{{ optimizationStats.quietHoursConflicts }} messages would be sent during quiet hours
</p>
</div>
<div class="bg-blue-50 border border-blue-200 rounded p-3">
<div class="flex items-center mb-2">
<Icon class="mr-2 text-blue-600" name="ic:outline-info"></Icon>
<span class="font-medium text-blue-800">Timezone Coverage</span>
</div>
<p class="text-sm text-blue-700">
Messages will be delivered across {{ optimizationStats.timezoneCoverage }} timezones
</p>
</div>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Timezone Testing Tool -->
<rs-card>
<template #header>
<h3 class="text-lg font-semibold text-primary">Timezone Testing Tool</h3>
</template>
<template #body>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Test Message</label>
<textarea
v-model="testMessage.content"
class="w-full p-2 border border-gray-300 rounded-md"
rows="3"
placeholder="Enter test message content"
></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Scheduled Time (UTC)</label>
<input
v-model="testMessage.scheduledTime"
type="datetime-local"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Message Type</label>
<select v-model="testMessage.type" class="w-full p-2 border border-gray-300 rounded-md">
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="push">Push Notification</option>
</select>
</div>
</div>
<rs-button @click="testTimezoneDelivery" variant="primary" class="w-full">
<Icon class="mr-1" name="ic:outline-play-arrow"></Icon>
Test Timezone Delivery
</rs-button>
</div>
<div class="space-y-4">
<h4 class="font-semibold">Delivery Preview</h4>
<div v-if="deliveryPreview.length > 0" class="space-y-2 max-h-60 overflow-y-auto">
<div
v-for="(preview, index) in deliveryPreview"
:key="index"
class="border border-gray-200 rounded p-3"
>
<div class="flex justify-between items-start mb-1">
<span class="font-medium">{{ preview.timezone }}</span>
<span class="text-sm text-gray-600">{{ preview.users }} users</span>
</div>
<div class="text-sm">
<p class="text-gray-600">Local delivery time: <span class="font-medium">{{ preview.localTime }}</span></p>
<p class="text-gray-600">Status:
<span :class="{
'text-green-600': preview.status === 'optimal',
'text-yellow-600': preview.status === 'suboptimal',
'text-red-600': preview.status === 'blocked'
}" class="font-medium">{{ preview.status }}</span>
</p>
</div>
</div>
</div>
<div v-else class="text-center text-gray-500 py-8">
<Icon class="text-4xl mb-2" name="ic:outline-schedule"></Icon>
<p>Run a test to see delivery preview</p>
</div>
</div>
</div>
</template>
</rs-card>
<!-- Configuration Modal -->
<rs-modal v-model="showConfigModal" size="lg">
<template #header>
<h3 class="text-lg font-semibold">Timezone Configuration</h3>
</template>
<template #body>
<div class="space-y-6">
<div>
<h4 class="font-semibold mb-3">Default Delivery Times</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Morning Messages</label>
<input
v-model="config.morningTime"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Afternoon Messages</label>
<input
v-model="config.afternoonTime"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Evening Messages</label>
<input
v-model="config.eveningTime"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
</div>
</div>
<div>
<h4 class="font-semibold mb-3">Quiet Hours</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Start Time</label>
<input
v-model="config.quietHours.start"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">End Time</label>
<input
v-model="config.quietHours.end"
type="time"
class="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
</div>
<div class="mt-4">
<label class="flex items-center">
<input
v-model="config.quietHours.allowEmergency"
type="checkbox"
class="mr-2"
/>
<span class="text-sm font-medium text-gray-700">Allow emergency messages during quiet hours</span>
</label>
</div>
</div>
<div>
<h4 class="font-semibold mb-3">Timezone Detection</h4>
<div class="space-y-4">
<label class="flex items-center">
<input
v-model="config.autoDetect"
type="checkbox"
class="mr-2"
/>
<span class="text-sm font-medium text-gray-700">Auto-detect user timezones</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Fallback Timezone</label>
<select v-model="config.fallbackTimezone" class="w-full p-2 border border-gray-300 rounded-md">
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="Europe/London">Europe/London</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Update Frequency</label>
<select v-model="config.updateFrequency" class="w-full p-2 border border-gray-300 rounded-md">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showConfigModal = false">Cancel</rs-button>
<rs-button @click="saveConfiguration" variant="primary">Save Configuration</rs-button>
</div>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Timezone Handling",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{
name: "Notification",
path: "/notification",
},
{
name: "Queue & Scheduler",
path: "/notification/queue-scheduler",
},
{
name: "Timezone Handling",
path: "/notification/queue-scheduler/timezone",
},
],
});
// Reactive data
const showConfigModal = ref(false);
const selectedTimezone = ref('');
const deliveryPreview = ref([]);
// Current time for major timezones
const majorTimezones = ref([
{
name: 'New York',
zone: 'America/New_York',
time: new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York' })
},
{
name: 'London',
zone: 'Europe/London',
time: new Date().toLocaleTimeString('en-US', { timeZone: 'Europe/London' })
},
{
name: 'Tokyo',
zone: 'Asia/Tokyo',
time: new Date().toLocaleTimeString('en-US', { timeZone: 'Asia/Tokyo' })
}
]);
// Update times every second
setInterval(() => {
majorTimezones.value.forEach(tz => {
tz.time = new Date().toLocaleTimeString('en-US', { timeZone: tz.zone });
});
}, 1000);
// Statistics
const timezoneStats = ref([
{
title: "Active Timezones",
value: "24",
icon: "ic:outline-public",
bgColor: "bg-blue-100",
iconColor: "text-blue-600",
textColor: "text-blue-600"
},
{
title: "Scheduled Messages",
value: "1,847",
icon: "ic:outline-schedule",
bgColor: "bg-green-100",
iconColor: "text-green-600",
textColor: "text-green-600"
},
{
title: "Optimal Deliveries",
value: "94.2%",
icon: "ic:outline-trending-up",
bgColor: "bg-purple-100",
iconColor: "text-purple-600",
textColor: "text-purple-600"
},
{
title: "Quiet Hours Respected",
value: "99.8%",
icon: "ic:outline-nights-stay",
bgColor: "bg-orange-100",
iconColor: "text-orange-600",
textColor: "text-orange-600"
}
]);
// Configuration
const config = ref({
morningTime: '09:00',
afternoonTime: '14:00',
eveningTime: '18:00',
quietHours: {
start: '22:00',
end: '07:00',
allowEmergency: true
},
autoDetect: true,
fallbackTimezone: 'UTC',
updateFrequency: 'daily'
});
// Available timezones
const availableTimezones = ref([
{ value: 'America/New_York', label: 'America/New_York (EST/EDT)' },
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' },
{ value: 'Europe/London', label: 'Europe/London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Europe/Paris (CET/CEST)' },
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' },
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (CST)' },
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' },
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEST/AEDT)' }
]);
// Table fields for scheduled messages
const scheduledMessagesFields = ref([
{ key: 'id', label: 'Message ID', sortable: true },
{ key: 'type', label: 'Type', sortable: true },
{ key: 'timezone', label: 'Timezone', sortable: true },
{ key: 'scheduledUTC', label: 'Scheduled (UTC)', sortable: true },
{ key: 'localTime', label: 'Local Time', sortable: true },
{ key: 'recipients', label: 'Recipients', sortable: true },
{ key: 'status', label: 'Status', sortable: true }
]);
// Mock scheduled messages data
const scheduledMessages = ref([
{
id: 'msg_001',
type: 'email',
timezone: 'America/New_York',
scheduledUTC: '2024-01-15 14:00:00',
localTime: '2024-01-15 09:00:00',
recipients: 1250,
status: 'scheduled'
},
{
id: 'msg_002',
type: 'push',
timezone: 'Europe/London',
scheduledUTC: '2024-01-15 09:00:00',
localTime: '2024-01-15 09:00:00',
recipients: 890,
status: 'scheduled'
},
{
id: 'msg_003',
type: 'sms',
timezone: 'Asia/Tokyo',
scheduledUTC: '2024-01-15 00:00:00',
localTime: '2024-01-15 09:00:00',
recipients: 2100,
status: 'scheduled'
}
]);
// Timezone distribution
const timezoneDistribution = ref([
{
timezone: 'America/New_York',
users: 15420,
percentage: 32.5,
color: '#3B82F6',
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York' })
},
{
timezone: 'Europe/London',
users: 12890,
percentage: 27.2,
color: '#10B981',
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'Europe/London' })
},
{
timezone: 'Asia/Tokyo',
users: 9650,
percentage: 20.3,
color: '#F59E0B',
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'Asia/Tokyo' })
},
{
timezone: 'Australia/Sydney',
users: 5840,
percentage: 12.3,
color: '#EF4444',
currentTime: new Date().toLocaleTimeString('en-US', { timeZone: 'Australia/Sydney' })
},
{
timezone: 'Others',
users: 3700,
percentage: 7.7,
color: '#8B5CF6',
currentTime: '-'
}
]);
// Optimization stats
const optimizationStats = ref({
optimalWindows: 1654,
quietHoursConflicts: 23,
timezoneCoverage: 18
});
// Test message
const testMessage = ref({
content: '',
scheduledTime: '',
type: 'email'
});
// Computed filtered scheduled messages
const filteredScheduledMessages = computed(() => {
let filtered = scheduledMessages.value;
if (selectedTimezone.value) {
filtered = filtered.filter(msg => msg.timezone === selectedTimezone.value);
}
return filtered.map(msg => ({
...msg,
status: h('span', {
class: `px-2 py-1 rounded text-xs font-medium ${
msg.status === 'scheduled' ? 'bg-blue-100 text-blue-800' :
msg.status === 'sent' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}`
}, msg.status)
}));
});
// Methods
function refreshScheduledMessages() {
// Mock refresh
console.log('Refreshing scheduled messages...');
}
function testTimezoneDelivery() {
if (!testMessage.value.content || !testMessage.value.scheduledTime) {
return;
}
// Mock delivery preview generation
deliveryPreview.value = [
{
timezone: 'America/New_York',
users: 1250,
localTime: '09:00 AM',
status: 'optimal'
},
{
timezone: 'Europe/London',
localTime: '02:00 AM',
users: 890,
status: 'blocked'
},
{
timezone: 'Asia/Tokyo',
localTime: '11:00 AM',
users: 2100,
status: 'optimal'
},
{
timezone: 'Australia/Sydney',
localTime: '01:00 AM',
users: 580,
status: 'blocked'
}
];
}
function saveConfiguration() {
// Mock save
console.log('Saving timezone configuration...');
showConfigModal.value = false;
}
</script>
<style lang="scss" scoped></style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,764 @@
<template>
<div>
<LayoutsBreadcrumb />
<!-- Info Card -->
<rs-card class="mb-5">
<template #header>
<div class="flex">
<Icon
class="mr-2 flex justify-center"
name="material-symbols:edit-outline"
></Icon>
Edit Template
</div>
</template>
<template #body>
<p class="mb-4">
Edit and update your notification template. Modify content, settings, and
channel configurations to improve effectiveness.
</p>
</template>
</rs-card>
<!-- 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 template details...</p>
</div>
<!-- Main Form Card -->
<rs-card v-else>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">Edit Notification Template</h2>
<div class="flex gap-3">
<rs-button @click="$router.push('/notification/templates')" variant="outline">
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
Back to Templates
</rs-button>
<rs-button
@click="previewTemplate"
variant="info-outline"
:disabled="!templateForm.content || isSubmitting"
>
<Icon name="material-symbols:preview-outline" class="mr-1"></Icon>
Preview
</rs-button>
</div>
</div>
</template>
<template #body>
<div class="pt-2">
<FormKit
type="form"
@submit="updateTemplate"
:actions="false"
class="w-full max-w-6xl mx-auto"
>
<!-- Basic Information Section -->
<div class="space-y-6 mb-8">
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
Basic Information
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Configure the basic template settings and metadata
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<FormKit
type="text"
name="title"
label="Template Name"
placeholder="e.g., Welcome Email Template"
validation="required"
v-model="templateForm.title"
help="Internal name for identifying this template"
/>
<FormKit
type="textarea"
name="description"
label="Description"
placeholder="Brief description of template purpose and usage"
v-model="templateForm.description"
rows="3"
help="Optional description to help team members understand the template's use"
/>
<FormKit
type="select"
name="category"
label="Category"
placeholder="Select category"
:options="categoryOptions"
validation="required"
v-model="templateForm.category"
help="Organize templates by category for better management"
/>
</div>
<!-- Right Column -->
<div class="space-y-4">
<FormKit
type="checkbox"
name="channels"
label="Supported Channels"
:options="channelOptions"
validation="required|min:1"
v-model="templateForm.channels"
decorator-icon="material-symbols:check"
options-class="grid grid-cols-2 gap-x-3 gap-y-2 pt-2"
help="Select which channels this template supports"
/>
<div class="grid grid-cols-2 gap-4">
<FormKit
type="select"
name="status"
label="Status"
placeholder="Select status"
:options="statusOptions"
validation="required"
v-model="templateForm.status"
help="Template status - only active templates can be used"
outer-class="mb-0"
/>
<FormKit
type="text"
name="version"
label="Version"
placeholder="1.0"
v-model="templateForm.version"
help="Version number for tracking template changes"
outer-class="mb-0"
/>
</div>
</div>
</div>
</div>
<!-- Content Configuration Section -->
<div class="space-y-6 mb-8">
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
Content Configuration
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Define subject lines and content for different channels
</p>
</div>
<!-- Subject/Title Section -->
<div class="space-y-4">
<FormKit
type="text"
name="subject"
label="Subject Line / Notification Title"
placeholder="e.g., Welcome to {{company_name}}, {{first_name}}!"
validation="required"
v-model="templateForm.subject"
help="Subject for emails or title for push notifications. Use {{variable}} for dynamic content"
/>
<FormKit
type="text"
name="preheader"
label="Email Preheader Text (Optional)"
placeholder="Additional preview text for emails"
v-model="templateForm.preheader"
help="Preview text shown in email clients alongside the subject line"
/>
</div>
<!-- Content Editor Section -->
<div class="space-y-4">
<FormKit
type="textarea"
name="content"
label="Template Content"
validation="required"
v-model="templateForm.content"
help="Main content body. Use HTML for rich formatting and {{variable}} for dynamic content"
rows="10"
/>
</div>
</div>
<!-- Channel-Specific Settings -->
<div class="space-y-6 mb-8">
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
Channel-Specific Settings
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Configure settings specific to each channel
</p>
</div>
<rs-tab fill>
<!-- Email Settings Tab -->
<rs-tab-item
title="Email Settings"
v-if="templateForm.channels.includes('email')"
>
<div class="space-y-4 pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="text"
name="fromName"
label="From Name"
placeholder="Your Company Name"
v-model="templateForm.fromName"
help="Name shown as sender in email clients"
/>
<FormKit
type="email"
name="replyTo"
label="Reply-To Email"
placeholder="noreply@yourcompany.com"
v-model="templateForm.replyTo"
help="Email address for replies"
/>
</div>
<FormKit
type="checkbox"
name="trackOpens"
label="Track Email Opens"
v-model="templateForm.trackOpens"
help="Enable tracking of email opens for analytics"
/>
</div>
</rs-tab-item>
<!-- Push Notification Settings Tab -->
<rs-tab-item
title="Push Settings"
v-if="templateForm.channels.includes('push')"
>
<div class="space-y-4 pt-4">
<FormKit
type="text"
name="pushTitle"
label="Push Notification Title"
placeholder="Custom push title (optional)"
v-model="templateForm.pushTitle"
help="Leave empty to use the main subject line"
/>
<FormKit
type="url"
name="pushIcon"
label="Push Notification Icon URL"
placeholder="https://yoursite.com/icon.png"
v-model="templateForm.pushIcon"
help="URL to icon for push notifications"
/>
<FormKit
type="url"
name="pushUrl"
label="Push Notification Action URL"
placeholder="https://yoursite.com/action"
v-model="templateForm.pushUrl"
help="URL to open when push notification is clicked"
/>
</div>
</rs-tab-item>
<!-- SMS Settings Tab -->
<rs-tab-item
title="SMS Settings"
v-if="templateForm.channels.includes('sms')"
>
<div class="space-y-4 pt-4">
<FormKit
type="textarea"
name="smsContent"
label="SMS Content"
placeholder="SMS version of your message (160 characters max)"
v-model="templateForm.smsContent"
help="Text-only version for SMS. Variables like {{name}} are supported"
rows="4"
maxlength="160"
/>
<p class="text-sm text-gray-500">
Characters: {{ templateForm.smsContent?.length || 0 }}/160
</p>
</div>
</rs-tab-item>
</rs-tab>
</div>
<!-- Additional Settings -->
<div class="space-y-6 mb-8">
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
Additional Settings
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Configure additional template options
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormKit
type="text"
name="tags"
label="Tags (comma-separated)"
placeholder="welcome, onboarding, email"
v-model="templateForm.tags"
help="Tags for organizing and filtering templates"
/>
<div class="space-y-4">
<FormKit
type="checkbox"
name="isPersonal"
label="Personal Template"
v-model="templateForm.isPersonal"
help="Mark as personal template (visible only to you)"
/>
</div>
</div>
</div>
<!-- Action Buttons -->
<div
class="flex gap-3 justify-end pt-6 border-t border-gray-200 dark:border-gray-700"
>
<rs-button
@click="$router.push('/notification/templates')"
variant="outline"
>
Cancel
</rs-button>
<rs-button
@click="testTemplate"
variant="info-outline"
:disabled="!isFormValid || isSubmitting"
>
<Icon name="material-symbols:send" class="mr-1"></Icon>
Send Test
</rs-button>
<rs-button
@click="updateTemplate"
:disabled="!isFormValid || isSubmitting"
:loading="isSubmitting"
>
<Icon name="material-symbols:save" class="mr-1"></Icon>
{{ isSubmitting ? "Updating..." : "Update Template" }}
</rs-button>
</div>
</FormKit>
</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="
channelOptions.filter(
(c) => c.value !== '' && templateForm.channels.includes(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">
{{ templateForm.subject }}
</h3>
<div
class="prose prose-sm max-w-none dark:prose-invert"
v-html="templateForm.content"
></div>
</div>
</div>
<template #footer>
<rs-button @click="showPreview = false">Close</rs-button>
</template>
</rs-modal>
</div>
</template>
<script setup>
definePageMeta({
title: "Edit Template",
middleware: ["auth"],
requiresAuth: true,
});
const { $swal } = useNuxtApp();
const router = useRouter();
const route = useRoute();
// Get template ID from route params
const templateId = computed(() => route.params.id);
// Reactive data
const isLoading = ref(false);
const isSubmitting = ref(false);
const showPreview = ref(false);
const previewChannel = ref("email");
// Form data
const templateForm = ref({
title: "",
description: "",
subject: "",
preheader: "",
category: "",
channels: [],
status: "Draft",
version: "1.0",
content: "",
tags: "",
isPersonal: false,
// Email settings
fromName: "",
replyTo: "",
trackOpens: true,
// Push settings
pushTitle: "",
pushIcon: "",
pushUrl: "",
// SMS settings
smsContent: "",
});
// Form options
const categoryOptions = [
{ 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 channelOptions = [
{ label: "Email", value: "email" },
{ label: "SMS", value: "sms" },
{ label: "Push Notification", value: "push" },
];
const statusOptions = [
{ label: "Draft", value: "Draft" },
{ label: "Active", value: "Active" },
{ label: "Archived", value: "Archived" },
];
// Computed properties
const isFormValid = computed(() => {
return (
templateForm.value.title &&
templateForm.value.subject &&
templateForm.value.content &&
templateForm.value.category &&
templateForm.value.channels.length > 0
);
});
// Load template data
const loadTemplate = async () => {
if (!templateId.value) return;
isLoading.value = true;
try {
console.log("Loading template for editing:", templateId.value);
const response = await $fetch(`/api/notifications/templates/${templateId.value}`);
if (response.success) {
const template = response.data.template;
console.log("Template loaded:", template);
// Map API response to form data
Object.assign(templateForm.value, {
title: template.title || "",
description: template.description || "",
subject: template.subject || "",
preheader: template.preheader || "",
category: template.category || "",
channels: template.channels || [],
status: template.status || "Draft",
version: template.version || "1.0",
content: template.content || "",
tags: template.tags || "",
isPersonal: template.isPersonal || false,
fromName: template.fromName || "",
replyTo: template.replyTo || "",
trackOpens: template.trackOpens !== false,
pushTitle: template.pushTitle || "",
pushIcon: template.pushIcon || "",
pushUrl: template.pushUrl || "",
smsContent: template.smsContent || "",
});
}
} catch (error) {
console.error("Error loading template:", error);
let errorMessage = "Failed to load template data";
if (error.data?.error) {
errorMessage = error.data.error;
}
await $swal.fire({
title: "Error",
text: errorMessage,
icon: "error",
});
// Navigate back to templates list
router.push("/notification/templates");
} finally {
isLoading.value = false;
}
};
// Preview template
const previewTemplate = () => {
showPreview.value = true;
};
// Test template
const testTemplate = async () => {
if (!isFormValid.value) {
$swal.fire("Error", "Please fill in all required fields", "error");
return;
}
const { value: email } = await $swal.fire({
title: "Send Test Notification",
text: "Enter email address to send test notification:",
input: "email",
inputPlaceholder: "your-email@example.com",
showCancelButton: true,
confirmButtonText: "Send Test",
cancelButtonText: "Cancel",
inputValidator: (value) => {
if (!value) {
return "Please enter a valid email address";
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return "Please enter a valid email address";
}
},
});
if (email) {
// Show loading state
const loadingSwal = $swal.fire({
title: "Sending Test...",
text: "Please wait while we send your test notification",
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
$swal.showLoading();
},
});
try {
const testData = {
subject: templateForm.value.subject,
emailContent: templateForm.value.content,
pushTitle: templateForm.value.pushTitle || templateForm.value.subject,
pushBody: templateForm.value.content
? templateForm.value.content.replace(/<[^>]*>/g, "").substring(0, 300)
: "",
callToActionText: "",
callToActionUrl: "",
};
console.log("Sending test notification with data:", testData);
const response = await $fetch("/api/notifications/test-send", {
method: "POST",
body: {
email: email,
testData: testData,
},
});
console.log("Test API Response:", response);
loadingSwal.close();
let successMessage = `Test notification sent to ${email}`;
if (response.data?.results) {
const results = response.data.results;
const successfulChannels = results
.filter((r) => r.status === "sent")
.map((r) => r.channel);
const failedChannels = results.filter((r) => r.status === "failed");
if (successfulChannels.length > 0) {
successMessage += `\n\nSuccessfully sent via: ${successfulChannels.join(", ")}`;
}
if (failedChannels.length > 0) {
successMessage += `\n\nFailed channels: ${failedChannels
.map((f) => `${f.channel} (${f.message})`)
.join(", ")}`;
}
}
await $swal.fire({
title: "Test Sent!",
text: successMessage,
icon: "success",
timer: 4000,
});
} catch (error) {
console.error("Error sending test notification:", error);
loadingSwal.close();
let errorMessage = "Failed to send test notification. Please try again.";
if (error.data?.error) {
errorMessage = error.data.error;
} else if (error.message) {
errorMessage = error.message;
}
await $swal.fire({
title: "Test Failed",
text: errorMessage,
icon: "error",
confirmButtonText: "OK",
});
}
}
};
// Update template
const updateTemplate = async () => {
if (!isFormValid.value) {
$swal.fire("Error", "Please fill in all required fields", "error");
return;
}
if (isSubmitting.value) {
return;
}
isSubmitting.value = true;
// Show loading state
const loadingSwal = $swal.fire({
title: "Updating Template...",
text: "Please wait while we update your template",
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
$swal.showLoading();
},
});
try {
// Prepare the data for API submission
const apiData = {
title: templateForm.value.title,
description: templateForm.value.description || "",
subject: templateForm.value.subject,
preheader: templateForm.value.preheader || "",
category: templateForm.value.category,
channels: templateForm.value.channels,
status: templateForm.value.status,
version: templateForm.value.version,
content: templateForm.value.content,
tags: templateForm.value.tags || "",
isPersonal: templateForm.value.isPersonal,
// Email specific settings
fromName: templateForm.value.fromName || "",
replyTo: templateForm.value.replyTo || "",
trackOpens: templateForm.value.trackOpens,
// Push notification specific settings
pushTitle: templateForm.value.pushTitle || "",
pushIcon: templateForm.value.pushIcon || "",
pushUrl: templateForm.value.pushUrl || "",
// SMS specific settings
smsContent: templateForm.value.smsContent || "",
};
console.log("Updating Template Data:", apiData);
const response = await $fetch(`/api/notifications/templates/${templateId.value}`, {
method: "PUT",
body: apiData,
});
console.log("API Response:", response);
loadingSwal.close();
isSubmitting.value = false;
await $swal.fire({
title: "Template Updated!",
text: `Template "${templateForm.value.title}" has been successfully updated.`,
icon: "success",
timer: 3000,
showConfirmButton: false,
});
// Navigate back to templates list
router.push("/notification/templates");
} catch (error) {
console.error("Error updating template:", error);
loadingSwal.close();
isSubmitting.value = false;
let errorMessage = "Failed to update template. Please try again.";
let errorDetails = "";
if (error.data) {
if (error.data.errors && Array.isArray(error.data.errors)) {
errorMessage = "Please fix the following errors:";
errorDetails = error.data.errors.join("\n• ");
} else if (error.data.error) {
errorMessage = error.data.error;
}
} else if (error.message) {
errorMessage = error.message;
}
await $swal.fire({
title: "Error",
text: errorMessage,
html: errorDetails
? `<p>${errorMessage}</p><ul style="text-align: left; margin-top: 10px;"><li>${errorDetails.replace(
/\n• /g,
"</li><li>"
)}</li></ul>`
: errorMessage,
icon: "error",
confirmButtonText: "OK",
});
}
};
// Load template when component mounts
onMounted(async () => {
await loadTemplate();
});
</script>

View File

@@ -0,0 +1,749 @@
<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>

View File

@@ -0,0 +1,314 @@
<script setup>
import { ref, computed } from 'vue';
definePageMeta({
title: "Notification Triggers & Rules",
middleware: ["auth"],
requiresAuth: true,
});
const activeTab = ref('triggers'); // 'triggers', 'segments'
// Mock data
const mockEvents = ref([
{ id: 'user_registered', name: 'User Registered' },
{ id: 'payment_completed', name: 'Payment Completed' },
{ id: 'password_reset_request', name: 'Password Reset Request' },
]);
const mockTemplates = ref([
{ id: 'welcome_email', name: 'Welcome Email' },
{ id: 'payment_receipt', name: 'Payment Receipt' },
{ id: 'reset_password_instructions', name: 'Reset Password Instructions' },
]);
const triggers = ref([
{
id: 'trg_001',
name: 'Welcome New Users',
description: 'Sends a welcome email upon user registration.',
type: 'event',
eventType: 'user_registered',
actionTemplateId: 'welcome_email',
priority: 'medium',
status: 'active',
conditions: [],
targetSegments: [],
dependencies: null,
},
{
id: 'trg_002',
name: 'Daily Sales Summary',
description: 'Sends a summary of sales daily at 8 AM.',
type: 'time',
schedule: '0 8 * * *', // Cron for 8 AM daily
actionTemplateId: 'payment_receipt', // Placeholder, should be a summary template
priority: 'low',
status: 'inactive',
conditions: [],
targetSegments: [],
dependencies: null,
}
]);
const showAddEditModal = ref(false);
const isEditing = ref(false);
const currentTrigger = ref(null);
const newTriggerData = ref({
id: null,
name: '',
description: '',
type: 'event',
eventType: mockEvents.value.length > 0 ? mockEvents.value[0].id : null,
schedule: '',
webhookUrl: 'https://api.example.com/webhook/generated_id', // Placeholder
actionTemplateId: mockTemplates.value.length > 0 ? mockTemplates.value[0].id : null,
priority: 'medium',
status: 'active',
conditions: [],
targetSegments: [],
dependencies: null,
});
const priorities = ['low', 'medium', 'high'];
const triggerTypes = [
{ id: 'event', name: 'Event-Based' },
{ id: 'time', name: 'Time-Based' },
{ id: 'api', name: 'External API/Webhook' }
];
const openAddModal = () => {
isEditing.value = false;
currentTrigger.value = null;
newTriggerData.value = {
id: `trg_${Date.now().toString().slice(-3)}`, // Simple unique ID for mock
name: '',
description: '',
type: 'event',
eventType: mockEvents.value.length > 0 ? mockEvents.value[0].id : null,
schedule: '',
webhookUrl: `https://api.example.com/webhook/trg_${Date.now().toString().slice(-3)}`,
actionTemplateId: mockTemplates.value.length > 0 ? mockTemplates.value[0].id : null,
priority: 'medium',
status: 'active',
conditions: [],
targetSegments: [],
dependencies: null,
};
showAddEditModal.value = true;
};
const openEditModal = (trigger) => {
isEditing.value = true;
currentTrigger.value = trigger;
newTriggerData.value = { ...trigger };
showAddEditModal.value = true;
};
const closeModal = () => {
showAddEditModal.value = false;
currentTrigger.value = null;
};
const saveTrigger = () => {
if (isEditing.value && currentTrigger.value) {
const index = triggers.value.findIndex(t => t.id === currentTrigger.value.id);
if (index !== -1) {
triggers.value[index] = { ...newTriggerData.value };
}
} else {
triggers.value.push({ ...newTriggerData.value, id: newTriggerData.value.id || `trg_${Date.now().toString().slice(-3)}` });
}
closeModal();
};
const deleteTrigger = (triggerId) => {
if (confirm('Are you sure you want to delete this trigger?')) {
triggers.value = triggers.value.filter(t => t.id !== triggerId);
}
};
const testTrigger = (trigger) => {
alert(`Simulating test for trigger: ${trigger.name} (Not implemented yet)`);
};
const getEventName = (eventId) => mockEvents.value.find(e => e.id === eventId)?.name || eventId;
const getTemplateName = (templateId) => mockTemplates.value.find(t => t.id === templateId)?.name || templateId;
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<template #header>
<div class="flex justify-between items-center">
<h1 class="text-xl font-semibold">Notification Triggers & Rules</h1>
<button @click="openAddModal" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm">
Add New Trigger
</button>
</div>
</template>
<template #body>
<!-- Tabs (Simplified for now, can be expanded later if needed) -->
<!-- <div class="mb-4 border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button @click="activeTab = 'triggers'"
:class="['whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm', activeTab === 'triggers' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300']">
Triggers & Rules
</button>
<button @click="activeTab = 'segments'"
:class="['whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm', activeTab === 'segments' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300']">
User Segments (Coming Soon)
</button>
</nav>
</div> -->
<div v-if="activeTab === 'triggers'">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Priority</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-if="triggers.length === 0">
<td colspan="6" class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">No triggers defined yet.</td>
</tr>
<tr v-for="trigger in triggers" :key="trigger.id">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ trigger.name }}</div>
<div class="text-xs text-gray-500">{{ trigger.description }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">{{ trigger.type }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div v-if="trigger.type === 'event'">Event: {{ getEventName(trigger.eventType) }}</div>
<div v-if="trigger.type === 'time'">Schedule: {{ trigger.schedule }}</div>
<div v-if="trigger.type === 'api'" class="truncate max-w-xs" :title="trigger.webhookUrl">Webhook: {{ trigger.webhookUrl }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">{{ trigger.priority }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="['px-2 inline-flex text-xs leading-5 font-semibold rounded-full', trigger.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800']">
{{ trigger.status }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button @click="openEditModal(trigger)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
<button @click="testTrigger(trigger)" class="text-yellow-600 hover:text-yellow-900">Test</button>
<button @click="deleteTrigger(trigger.id)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- <div v-if="activeTab === 'segments'">
<p class="text-gray-600 p-4">User Segments management will be available here soon.</p>
</div> -->
<!-- Add/Edit Modal -->
<div v-if="showAddEditModal" class="fixed inset-0 z-50 overflow-y-auto bg-gray-600 bg-opacity-50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium leading-6 text-gray-900">{{ isEditing ? 'Edit' : 'Add New' }} Trigger/Rule</h3>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form @submit.prevent="saveTrigger" class="space-y-4">
<div>
<label for="triggerName" class="block text-sm font-medium text-gray-700">Name</label>
<input type="text" v-model="newTriggerData.name" id="triggerName" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
</div>
<div>
<label for="triggerDescription" class="block text-sm font-medium text-gray-700">Description</label>
<textarea v-model="newTriggerData.description" id="triggerDescription" rows="2" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="triggerStatus" class="block text-sm font-medium text-gray-700">Status</label>
<select v-model="newTriggerData.status" id="triggerStatus" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div>
<label for="triggerPriority" class="block text-sm font-medium text-gray-700">Priority</label>
<select v-model="newTriggerData.priority" id="triggerPriority" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option v-for="p in priorities" :key="p" :value="p" class="capitalize">{{ p }}</option>
</select>
</div>
</div>
<div>
<label for="triggerType" class="block text-sm font-medium text-gray-700">Trigger Type</label>
<select v-model="newTriggerData.type" id="triggerType" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option v-for="tt in triggerTypes" :key="tt.id" :value="tt.id">{{ tt.name }}</option>
</select>
</div>
<!-- Conditional Fields -->
<div v-if="newTriggerData.type === 'event'">
<label for="eventType" class="block text-sm font-medium text-gray-700">Event</label>
<select v-model="newTriggerData.eventType" id="eventType" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option v-for="event in mockEvents" :key="event.id" :value="event.id">{{ event.name }}</option>
</select>
</div>
<div v-if="newTriggerData.type === 'time'">
<label for="triggerSchedule" class="block text-sm font-medium text-gray-700">Schedule (Cron Expression or Description)</label>
<input type="text" v-model="newTriggerData.schedule" id="triggerSchedule" placeholder="e.g., 0 9 * * * OR Daily at 9 AM" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
</div>
<div v-if="newTriggerData.type === 'api'">
<label class="block text-sm font-medium text-gray-700">Webhook URL</label>
<input type="text" :value="newTriggerData.webhookUrl" readonly class="mt-1 block w-full rounded-md border-gray-300 shadow-sm bg-gray-100 sm:text-sm cursor-not-allowed">
<p class="text-xs text-gray-500 mt-1">This URL is automatically generated. Send a POST request here to trigger.</p>
</div>
<div>
<label for="actionTemplate" class="block text-sm font-medium text-gray-700">Action: Send Notification Template</label>
<select v-model="newTriggerData.actionTemplateId" id="actionTemplate" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option v-for="template in mockTemplates" :key="template.id" :value="template.id">{{ template.name }}</option>
</select>
</div>
<div class="mt-6 p-3 bg-gray-50 rounded-md">
<h4 class="text-sm font-medium text-gray-600 mb-2">Advanced Configuration (Coming Soon)</h4>
<p class="text-xs text-gray-500">- Conditional Logic (IF/THEN Rules)</p>
<p class="text-xs text-gray-500">- User Segmentation Targeting</p>
<p class="text-xs text-gray-500">- Rule Dependencies</p>
<p class="text-xs text-gray-500">- Rule Testing & Simulation</p>
</div>
<div class="pt-5">
<div class="flex justify-end space-x-3">
<button type="button" @click="closeModal" class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">Cancel</button>
<button type="submit" class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700">
{{ isEditing ? 'Save Changes' : 'Add Trigger' }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
</rs-card>
</div>
</template>
<style scoped>
/* Scoped styles if needed */
.max-h-\[90vh\] {
max-height: 90vh;
}
</style>

View File

@@ -0,0 +1,519 @@
<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>
Notification Details
</div>
</template>
<template #body>
<p class="mb-4">
View detailed information about this notification including content, delivery
settings, performance metrics, and recipient targeting.
</p>
</template>
</rs-card>
<!-- 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 notification details...</p>
</div>
<!-- Notification Not Found -->
<rs-card v-else-if="!notification">
<template #body>
<div class="text-center py-8">
<div class="flex justify-center mb-4">
<Icon name="ic:outline-error" size="4rem" class="text-red-400" />
</div>
<h3 class="text-lg font-medium text-gray-500 mb-2">Notification Not Found</h3>
<p class="text-gray-500 mb-4">
The notification you're looking for doesn't exist or has been deleted.
</p>
<rs-button @click="$router.push('/notification/list')" variant="primary">
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
Back to List
</rs-button>
</div>
</template>
</rs-card>
<!-- Notification Details -->
<div v-else class="space-y-6">
<!-- Header Actions -->
<rs-card>
<template #body>
<div class="flex justify-between items-center">
<div class="flex items-center gap-4">
<Icon name="ic:outline-notifications" class="text-primary" size="32" />
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ notification.title }}
</h1>
<div class="flex items-center gap-2 mt-1">
<rs-badge :variant="getStatusVariant(notification.status)">
{{ notification.status }}
</rs-badge>
<rs-badge :variant="getPriorityVariant(notification.priority)">
{{ notification.priority }} Priority
</rs-badge>
</div>
</div>
</div>
<div class="flex gap-3">
<rs-button @click="$router.push('/notification/list')" variant="primary">
<Icon name="ic:outline-arrow-back" class="mr-1"></Icon>
Back to List
</rs-button>
<rs-button
@click="editNotification"
v-if="
notification.status === 'draft' || notification.status === 'scheduled'
"
>
<Icon name="material-symbols:edit" class="mr-1"></Icon>
Edit
</rs-button>
</div>
</div>
</template>
</rs-card>
<!-- Basic Information -->
<rs-card>
<template #header>
<h2 class="text-xl font-semibold">Basic Information</h2>
</template>
<template #body>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Title</label
>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ notification.title }}
</p>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Category</label
>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ notification.category.name }}
</p>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Priority</label
>
<rs-badge :variant="getPriorityVariant(notification.priority)">
{{ notification.priority }}
</rs-badge>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Status</label
>
<rs-badge :variant="getStatusVariant(notification.status)">
{{ notification.status }}
</rs-badge>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Delivery Type</label
>
<p class="text-base text-gray-900 dark:text-gray-100 capitalize">
{{ notification.deliveryType }}
</p>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Recipients</label
>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ formatNumber(notification.analytics.totalRecipients) }}
</p>
</div>
</div>
</template>
</rs-card>
<!-- Delivery Channels -->
<rs-card>
<template #header>
<h2 class="text-xl font-semibold">Delivery Channels</h2>
</template>
<template #body>
<div class="flex flex-wrap gap-4">
<div
v-for="channel in notification.channels"
:key="channel"
class="flex items-center gap-2 p-3 border rounded-lg bg-gray-50 dark:bg-gray-800"
>
<Icon :name="getChannelIcon(channel)" size="20" class="text-primary" />
<span class="font-medium capitalize">{{ channel }}</span>
</div>
</div>
</template>
</rs-card>
<!-- Performance Metrics -->
<rs-card v-if="notification.status === 'sent' || notification.status === 'sending'">
<template #header>
<h2 class="text-xl font-semibold">Performance Metrics</h2>
</template>
<template #body>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center p-4 border rounded-lg">
<div class="text-2xl font-bold text-green-600">
{{ formatNumber(notification.analytics.deliveredCount) }}
</div>
<div class="text-sm text-gray-600">Successfully Delivered</div>
</div>
<div class="text-center p-4 border rounded-lg">
<div class="text-2xl font-bold text-red-600">
{{ formatNumber(notification.analytics.failedCount) }}
</div>
<div class="text-sm text-gray-600">Failed</div>
</div>
<div class="text-center p-4 border rounded-lg">
<div class="text-2xl font-bold text-primary">
{{ notification.analytics.successRate }}%
</div>
<div class="text-sm text-gray-600">Success Rate</div>
</div>
</div>
<!-- Success Rate Progress Bar -->
<div class="mt-6">
<div class="flex justify-between text-sm text-gray-600 mb-2">
<span>Success Rate</span>
<span>{{ notification.analytics.successRate }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-3">
<div
class="h-3 rounded-full transition-all duration-300"
:class="
notification.analytics.successRate >= 95
? 'bg-green-500'
: notification.analytics.successRate >= 80
? 'bg-yellow-500'
: 'bg-red-500'
"
:style="{ width: notification.analytics.successRate + '%' }"
></div>
</div>
</div>
<!-- Additional Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div class="text-center p-4 border rounded-lg">
<div class="text-2xl font-bold text-blue-600">
{{ notification.analytics.openRate }}%
</div>
<div class="text-sm text-gray-600">Open Rate</div>
</div>
<div class="text-center p-4 border rounded-lg">
<div class="text-2xl font-bold text-purple-600">
{{ notification.analytics.clickRate }}%
</div>
<div class="text-sm text-gray-600">Click Rate</div>
</div>
</div>
</template>
</rs-card>
<!-- Scheduling Information -->
<rs-card>
<template #header>
<h2 class="text-xl font-semibold">Scheduling Information</h2>
</template>
<template #body>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Created At</label
>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ formatDateTime(notification.createdAt) }}
<span class="text-sm text-gray-500"
>({{ formatTimeAgo(notification.createdAt) }})</span
>
</p>
</div>
<div v-if="notification.scheduledAt">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Scheduled At</label
>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ formatDateTime(notification.scheduledAt) }}
</p>
</div>
<div v-if="notification.sentAt">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Sent At</label
>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ formatDateTime(notification.sentAt) }}
</p>
</div>
</div>
</template>
</rs-card>
<!-- Content Information -->
<rs-card v-if="notification.contentType === 'template' && notification.template">
<template #header>
<h2 class="text-xl font-semibold">Template Information</h2>
</template>
<template #body>
<div class="space-y-4">
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Template Name
</label>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ notification.template.name }}
</p>
</div>
<div v-if="notification.template.subject">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Subject
</label>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ notification.template.subject }}
</p>
</div>
<div v-if="notification.template.pushTitle">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Push Title
</label>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ notification.template.pushTitle }}
</p>
</div>
</div>
</template>
</rs-card>
<!-- Custom Content -->
<template v-else>
<rs-card v-if="notification.emailSubject || notification.emailContent">
<template #header>
<h2 class="text-xl font-semibold">Email Content</h2>
</template>
<template #body>
<div class="space-y-4">
<div v-if="notification.emailSubject">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Subject
</label>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ notification.emailSubject }}
</p>
</div>
<div v-if="notification.emailContent">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Content
</label>
<div
class="prose dark:prose-invert max-w-none"
v-html="notification.emailContent"
></div>
</div>
<div v-if="notification.callToActionText && notification.callToActionUrl">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Call to Action
</label>
<div class="flex items-center gap-2">
<rs-button as="a" :href="notification.callToActionUrl" target="_blank">
{{ notification.callToActionText }}
</rs-button>
</div>
</div>
</div>
</template>
</rs-card>
<rs-card v-if="notification.pushTitle || notification.pushBody">
<template #header>
<h2 class="text-xl font-semibold">Push Notification Content</h2>
</template>
<template #body>
<div class="space-y-4">
<div v-if="notification.pushTitle">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Title
</label>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ notification.pushTitle }}
</p>
</div>
<div v-if="notification.pushBody">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Body
</label>
<p class="text-base text-gray-900 dark:text-gray-100">
{{ notification.pushBody }}
</p>
</div>
<div v-if="notification.pushImageUrl">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Image
</label>
<img
:src="notification.pushImageUrl"
alt="Push notification image"
class="max-w-sm rounded-lg"
/>
</div>
</div>
</template>
</rs-card>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
definePageMeta({
title: "Notification Details",
middleware: ["auth"],
requiresAuth: true,
});
// Get route params
const route = useRoute();
const router = useRouter();
const notificationId = route.params.id;
// Reactive data
const isLoading = ref(true);
const notification = ref(null);
// Get notifications composable
const { getNotificationById } = useNotifications();
// 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 = {
critical: "danger",
high: "warning",
medium: "info",
low: "secondary",
};
return variants[priority] || "secondary";
};
const getStatusVariant = (status) => {
const variants = {
draft: "secondary",
scheduled: "info",
sending: "warning",
sent: "success",
failed: "danger",
cancelled: "secondary",
};
return variants[status] || "secondary";
};
const formatNumber = (num) => {
return new Intl.NumberFormat().format(num);
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatTimeAgo = (dateString) => {
const now = new Date();
const date = new Date(dateString);
const diffInHours = Math.floor((now - date) / (1000 * 60 * 60));
if (diffInHours < 1) return "Just now";
if (diffInHours < 24) return `${diffInHours}h ago`;
if (diffInHours < 48) return "Yesterday";
const diffInDays = Math.floor(diffInHours / 24);
return `${diffInDays}d ago`;
};
// Methods
const editNotification = () => {
router.push(`/notification/edit/${notificationId}`);
};
const loadNotification = async () => {
isLoading.value = true;
console.log("Notification ID:", notificationId);
try {
const data = await getNotificationById(notificationId);
notification.value = data;
} catch (error) {
console.error("Error loading notification:", error);
notification.value = null;
} finally {
isLoading.value = false;
}
};
// Lifecycle
onMounted(() => {
loadNotification();
});
</script>

241
pages/register/index.vue Normal file
View File

@@ -0,0 +1,241 @@
<script setup>
import { ref } from "vue";
import { RecaptchaV2 } from "vue3-recaptcha-v2";
import { useSiteSettings } from "@/composables/useSiteSettings";
const { siteSettings, loading: siteSettingsLoading } = useSiteSettings();
definePageMeta({
title: "Register",
layout: "empty",
middleware: ["dashboard"],
});
const formData = ref({
fullName: "",
idNumber: "",
phoneNumber: "",
password: "",
confirmPassword: "",
email: "",
confirmEmail: "",
subscribeNewsletter: false,
agreeTerms: false,
});
const register = () => {
// Simulate registration without API call
console.log("Registration attempted with:", formData.value);
// Add your registration logic here
};
const handleRecaptcha = (response) => {
console.log("reCAPTCHA response:", response);
};
// Get login logo with fallback
const getLoginLogo = () => {
if (siteSettingsLoading.value) {
return '/img/logo/corradAF-logo.svg';
}
return siteSettings.value?.siteLoginLogo || '/img/logo/corradAF-logo.svg';
};
// Get site name with fallback
const getSiteName = () => {
if (siteSettingsLoading.value) {
return 'Login Logo';
}
return siteSettings.value?.siteName || 'Login Logo';
};
</script>
<template>
<div
class="flex-none md:flex justify-center text-center items-center h-screen"
>
<div class="w-full md:w-3/4 lg:w-1/2 xl:w-2/6 relative">
<rs-card class="h-screen md:h-auto px-10 md:px-16 py-12 md:py-20 mb-0">
<div class="text-center mb-8">
<div class="img-container flex justify-center items-center mb-5">
<img
:src="getLoginLogo()"
:alt="getSiteName()"
class="max-w-[180px] max-h-[60px] object-contain"
@error="$event.target.src = '/img/logo/corradAF-logo.svg'"
/>
</div>
<h2 class="mt-4 text-2xl font-bold text-gray-700">Daftar Akaun</h2>
<p class="text-sm text-gray-500">Semua medan adalah wajib</p>
</div>
<FormKit type="form" :actions="false" @submit="register">
<FormKit
type="text"
name="fullName"
placeholder="Nama Penuh"
validation="required"
:validation-messages="{
required: 'Nama Penuh wajib diisi',
}"
>
<template #prefixIcon>
<Icon
name="ph:user-fill"
class="!w-5 !h-5 ml-3 text-gray-500"
></Icon>
</template>
</FormKit>
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 md:gap-4">
<FormKit
type="text"
name="idNumber"
placeholder="Nombor Mykad / Nombor Pasport"
validation="required"
:validation-messages="{
required: 'Nombor Mykad / Nombor Pasport wajib diisi',
}"
>
<template #prefixIcon>
<Icon
name="ph:identification-card-fill"
class="!w-5 !h-5 ml-3 text-gray-500"
></Icon>
</template>
</FormKit>
<FormKit
type="tel"
name="phoneNumber"
placeholder="Nombor Telefon"
validation="required"
:validation-messages="{
required: 'Nombor Telefon wajib diisi',
}"
>
<template #prefixIcon>
<Icon
name="ph:device-mobile-camera-fill"
class="!w-5 !h-5 ml-3 text-gray-500"
></Icon>
</template>
</FormKit>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 md:gap-4">
<FormKit
type="password"
name="password"
placeholder="Kata Laluan"
validation="required"
:validation-messages="{
required: 'Kata Laluan wajib diisi',
}"
>
<template #prefixIcon>
<Icon
name="ph:lock-key-fill"
class="!w-5 !h-5 ml-3 text-gray-500"
></Icon>
</template>
</FormKit>
<FormKit
type="password"
name="confirmPassword"
placeholder="Pengesahan Kata Laluan"
validation="required|confirm"
:validation-messages="{
required: 'Pengesahan Kata Laluan wajib diisi',
confirm: 'Kata Laluan tidak sepadan',
}"
:validation-rules="{
confirm: (value) => value === value.password,
}"
>
<template #prefixIcon>
<Icon
name="ph:lock-key-fill"
class="!w-5 !h-5 ml-3 text-gray-500"
></Icon>
</template>
</FormKit>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 md:gap-4">
<FormKit
type="email"
name="email"
placeholder="Emel"
validation="required|email"
:validation-messages="{
required: 'Emel wajib diisi',
email: 'Format emel tidak sah',
}"
>
<template #prefixIcon>
<Icon
name="ph:envelope-fill"
class="!w-5 !h-5 ml-3 text-gray-500"
></Icon>
</template>
</FormKit>
<FormKit
type="email"
name="confirmEmail"
placeholder="Pengesahan Emel"
validation="required|confirm"
:validation-messages="{
required: 'Pengesahan Emel wajib diisi',
confirm: 'Emel tidak sepadan',
}"
:validation-rules="{
confirm: (value) => value === value.email,
}"
>
<template #prefixIcon>
<Icon
name="ph:envelope-fill"
class="!w-5 !h-5 ml-3 text-gray-500"
></Icon>
</template>
</FormKit>
</div>
<div class="flex justify-start mb-4 mt-2">
<RecaptchaV2 @verify="handleRecaptcha" />
</div>
<FormKit
type="checkbox"
name="subscribeNewsletter"
label="Melanggan ke newsletter bulanan"
/>
<FormKit
type="checkbox"
name="agreeTerms"
label="Setuju dengan terma perkhidmatan"
validation="accepted"
:validation-messages="{
accepted: 'Anda mesti bersetuju dengan terma perkhidmatan',
}"
>
<template #label>
Setuju dengan
<a href="#" class="text-blue-600 ml-1">terma perkhidmatan</a>
</template>
</FormKit>
<rs-button btn-type="submit" class="w-full"> Daftar Akaun </rs-button>
</FormKit>
<div class="text-center mt-4">
<p class="text-sm text-gray-500">
Sudah mempunyai akaun?
<nuxt-link to="/login" class="text-blue-600">Log Masuk</nuxt-link>
</p>
</div>
</rs-card>
</div>
</div>
</template>