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