Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
acf8b48867 | |||
![]() |
03272df08b | ||
![]() |
5275289942 |
1
.node-version
Normal file
1
.node-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
20.11.1
|
180
components/JobDetailModal.vue
Normal file
180
components/JobDetailModal.vue
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
<script setup>
|
||||||
|
import { defineProps, defineEmits, computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
job: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
function formatDate(timestamp) {
|
||||||
|
if (!timestamp) return 'N/A';
|
||||||
|
const date = new Date(Number(timestamp));
|
||||||
|
if (isNaN(date.getTime())) return 'Invalid Date';
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobDuration = computed(() => {
|
||||||
|
if (props.job.duration) {
|
||||||
|
return (props.job.duration / 1000).toFixed(2) + 's';
|
||||||
|
}
|
||||||
|
if (props.job.processedOn && props.job.finishedOn) {
|
||||||
|
return ((props.job.finishedOn - props.job.processedOn) / 1000).toFixed(2) + 's';
|
||||||
|
}
|
||||||
|
return 'N/A';
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataEntries = computed(() => {
|
||||||
|
if (props.job.data && typeof props.job.data === 'object') {
|
||||||
|
return Object.entries(props.job.data);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition name="modal-fade">
|
||||||
|
<div v-if="show"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 backdrop-blur-sm p-4"
|
||||||
|
@click.self="emit('close')">
|
||||||
|
<rs-card class="w-full max-w-2xl bg-white shadow-xl rounded-lg max-h-[90vh] flex flex-col">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-between items-center px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800">
|
||||||
|
Job Details: <span class="text-primary">#{{ job.id }}</span>
|
||||||
|
</h3>
|
||||||
|
<button @click="emit('close')" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||||
|
<Icon name="mdi:close" size="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="p-6 space-y-5 overflow-y-auto flex-grow">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Name</p>
|
||||||
|
<p class="font-medium text-gray-800">{{ job.name || 'N/A' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Queue</p>
|
||||||
|
<p class="font-medium text-gray-800">{{ job.queue }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Status</p>
|
||||||
|
<rs-badge
|
||||||
|
:variant="job.state === 'completed' ? 'success' :
|
||||||
|
job.state === 'failed' ? 'danger' :
|
||||||
|
job.state === 'active' ? 'primary' : 'info'"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
>
|
||||||
|
{{ job.state }}
|
||||||
|
</rs-badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Priority</p>
|
||||||
|
<p class="font-medium text-gray-800">{{ job.priority }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Attempts Made</p>
|
||||||
|
<p class="font-medium text-gray-800">{{ job.attemptsMade }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Duration</p>
|
||||||
|
<p class="font-medium text-gray-800">{{ jobDuration }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 pt-4">
|
||||||
|
<h4 class="text-md font-semibold text-gray-700 mb-2">Timestamps</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Created At</p>
|
||||||
|
<p class="font-medium text-gray-800">{{ formatDate(job.timestamp) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Processed At</p>
|
||||||
|
<p class="font-medium text-gray-800">{{ formatDate(job.processedOn) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500">Finished At</p>
|
||||||
|
<p class="font-medium text-gray-800">{{ formatDate(job.finishedOn) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="job.state === 'active' && job.progress > 0" class="border-t border-gray-200 pt-4">
|
||||||
|
<h4 class="text-md font-semibold text-gray-700 mb-1">Progress</h4>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
|
<div class="bg-blue-600 h-2.5 rounded-full transition-all duration-300 ease-out"
|
||||||
|
:style="{ width: job.progress + '%' }">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">{{ job.progress }}% complete</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="job.state === 'failed' && job.failedReason" class="border-t border-gray-200 pt-4">
|
||||||
|
<h4 class="text-md font-semibold text-red-600 mb-1">Failure Reason</h4>
|
||||||
|
<div class="p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700 whitespace-pre-wrap">
|
||||||
|
{{ job.failedReason }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dataEntries.length > 0" class="border-t border-gray-200 pt-4">
|
||||||
|
<h4 class="text-md font-semibold text-gray-700 mb-2">Job Data</h4>
|
||||||
|
<div class="p-3 bg-gray-50 border border-gray-200 rounded-md max-h-60 overflow-y-auto text-xs">
|
||||||
|
<pre class="whitespace-pre-wrap break-all">{{ JSON.stringify(job.data, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="job.data" class="border-t border-gray-200 pt-4">
|
||||||
|
<h4 class="text-md font-semibold text-gray-700 mb-2">Job Data</h4>
|
||||||
|
<div class="p-3 bg-gray-50 border border-gray-200 rounded-md text-xs text-gray-500">
|
||||||
|
No data or data is not an object.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="px-6 py-3 bg-gray-50 border-t border-gray-200 flex justify-end">
|
||||||
|
<rs-button variant="secondary" @click="emit('close')">Close</rs-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</rs-card>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-fade-enter-active,
|
||||||
|
.modal-fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-fade-enter-from,
|
||||||
|
.modal-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
pre::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1; /* cool-gray-300 */
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
pre::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8; /* cool-gray-400 */
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,88 +1,22 @@
|
|||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
"header": "Utama",
|
header: "Utama",
|
||||||
"description": "",
|
description: "",
|
||||||
"child": [
|
child: [
|
||||||
{
|
{
|
||||||
"title": "Dashboard",
|
title: "Dashboard",
|
||||||
"path": "/dashboard",
|
path: "/dashboard",
|
||||||
"icon": "ic:outline-dashboard",
|
icon: "ic:outline-dashboard",
|
||||||
"child": [],
|
child: [],
|
||||||
"meta": {}
|
meta: {},
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
title: "Queue Dashboard",
|
||||||
|
icon: "mdi:view-dashboard",
|
||||||
|
path: "/admin",
|
||||||
|
child: [],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"meta": {}
|
meta: {},
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
"header": "Pentadbiran",
|
|
||||||
"description": "Urus aplikasi anda",
|
|
||||||
"child": [
|
|
||||||
{
|
|
||||||
"title": "Konfigurasi",
|
|
||||||
"icon": "ic:outline-settings",
|
|
||||||
"child": [
|
|
||||||
{
|
|
||||||
"title": "Persekitaran",
|
|
||||||
"path": "/devtool/config/environment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Site Settings",
|
|
||||||
"path": "/devtool/config/site-settings"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Penyunting Menu",
|
|
||||||
"icon": "ci:menu-alt-03",
|
|
||||||
"path": "/devtool/menu-editor",
|
|
||||||
"child": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Urus Pengguna",
|
|
||||||
"path": "/devtool/user-management",
|
|
||||||
"icon": "ph:user-circle-gear",
|
|
||||||
"child": [
|
|
||||||
{
|
|
||||||
"title": "Senarai Pengguna",
|
|
||||||
"path": "/devtool/user-management/user",
|
|
||||||
"icon": "",
|
|
||||||
"child": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Senarai Peranan",
|
|
||||||
"path": "/devtool/user-management/role",
|
|
||||||
"icon": "",
|
|
||||||
"child": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Kandungan",
|
|
||||||
"icon": "mdi:pencil-ruler",
|
|
||||||
"child": [
|
|
||||||
{
|
|
||||||
"title": "Penyunting",
|
|
||||||
"path": "/devtool/content-editor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Templat",
|
|
||||||
"path": "/devtool/content-editor/template"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Penyunting API",
|
|
||||||
"path": "/devtool/api-editor",
|
|
||||||
"icon": "material-symbols:api-rounded",
|
|
||||||
"child": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"meta": {
|
|
||||||
"auth": {
|
|
||||||
"role": [
|
|
||||||
"Developer"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
@ -1,674 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue';
|
|
||||||
import { useAsnafMockData } from '~/composables/useAsnafMockData';
|
|
||||||
import { useToast } from 'vue-toastification';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
const { getProfileById } = useAsnafMockData();
|
|
||||||
const profile = ref(null);
|
|
||||||
const loading = ref(true);
|
|
||||||
const isLoadingAnalysis = ref(false);
|
|
||||||
const confirmDelete = ref(false);
|
|
||||||
const activeTab = ref('personal');
|
|
||||||
|
|
||||||
// Load profile data
|
|
||||||
onMounted(async () => {
|
|
||||||
const id = route.params.id;
|
|
||||||
loading.value = true;
|
|
||||||
const fetchedProfile = getProfileById(id);
|
|
||||||
|
|
||||||
if (fetchedProfile) {
|
|
||||||
profile.value = { ...fetchedProfile, analysis: null };
|
|
||||||
loading.value = false;
|
|
||||||
} else {
|
|
||||||
toast.error('Profil tidak dijumpai');
|
|
||||||
navigateTo('/BF-PRF/AS/LIST');
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// New function to be called by a button
|
|
||||||
async function fetchAIAnalysis() {
|
|
||||||
if (!profile.value) {
|
|
||||||
toast.error('Profil data tidak tersedia untuk analisis.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingAnalysis.value = true;
|
|
||||||
try {
|
|
||||||
const requestBody = {
|
|
||||||
monthlyIncome: profile.value.monthlyIncome,
|
|
||||||
otherIncome: profile.value.otherIncome,
|
|
||||||
totalIncome: profile.value.totalIncome,
|
|
||||||
occupation: profile.value.occupation,
|
|
||||||
maritalStatus: profile.value.maritalStatus,
|
|
||||||
dependents: profile.value.dependents,
|
|
||||||
// Add other fields you want to send for analysis here
|
|
||||||
};
|
|
||||||
|
|
||||||
const analysisResponse = await $fetch('/api/analyze-asnaf', {
|
|
||||||
method: 'POST',
|
|
||||||
body: requestBody,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (profile.value) {
|
|
||||||
profile.value.analysis = analysisResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching AI Analysis from /api/analyze-asnaf:", error);
|
|
||||||
toast.error('Gagal memuat analisis AI dari server.');
|
|
||||||
if (profile.value) {
|
|
||||||
profile.value.analysis = {
|
|
||||||
hadKifayahPercentage: 'Ralat',
|
|
||||||
kategoriAsnaf: 'Ralat Server',
|
|
||||||
kategoriKeluarga: 'Ralat Server',
|
|
||||||
cadanganKategori: 'Ralat Server',
|
|
||||||
statusKelayakan: 'Ralat Server',
|
|
||||||
cadanganBantuan: [{ nama: 'Tidak dapat memuat cadangan bantuan', peratusan: 'Ralat' }],
|
|
||||||
ramalanJangkaMasaPulih: 'Ralat Server',
|
|
||||||
rumusan: 'Ralat Server'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoadingAnalysis.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Computed status color
|
|
||||||
const statusColor = computed(() => {
|
|
||||||
if (!profile.value) return '';
|
|
||||||
|
|
||||||
switch (profile.value.status) {
|
|
||||||
case 'Aktif': return 'success';
|
|
||||||
case 'Tidak Aktif': return 'danger';
|
|
||||||
case 'Dalam Semakan': return 'warning';
|
|
||||||
default: return 'secondary';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Computed category color
|
|
||||||
const categoryColor = computed(() => {
|
|
||||||
if (!profile.value) return '';
|
|
||||||
|
|
||||||
switch (profile.value.kategori) {
|
|
||||||
case 'Fakir': return 'danger';
|
|
||||||
case 'Miskin': return 'warning';
|
|
||||||
case 'Mualaf': return 'info';
|
|
||||||
case 'Fi-sabilillah': return 'primary';
|
|
||||||
case 'Gharimin': return 'secondary';
|
|
||||||
case 'Ibnu Sabil': return 'success';
|
|
||||||
default: return 'primary';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Page metadata
|
|
||||||
definePageMeta({
|
|
||||||
title: "Maklumat Asnaf",
|
|
||||||
middleware: ["auth"],
|
|
||||||
requiresAuth: true,
|
|
||||||
breadcrumb: [
|
|
||||||
{
|
|
||||||
name: "Dashboard",
|
|
||||||
path: "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "BF-PRF",
|
|
||||||
path: "/BF-PRF",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Asnaf",
|
|
||||||
path: "/BF-PRF/AS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Senarai",
|
|
||||||
path: "/BF-PRF/AS/LIST",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Maklumat",
|
|
||||||
path: "/BF-PRF/AS/DETAIL",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigation functions
|
|
||||||
function navigateToList() {
|
|
||||||
navigateTo('/BF-PRF/AS/LIST');
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToEdit() {
|
|
||||||
navigateTo(`/BF-PRF/AS/UP/01?id=${profile.value.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
confirmDelete.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDeleteProfile() {
|
|
||||||
toast.success('Profil telah dipadamkan');
|
|
||||||
navigateTo('/BF-PRF/AS/LIST');
|
|
||||||
confirmDelete.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDelete() {
|
|
||||||
confirmDelete.value = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<LayoutsBreadcrumb />
|
|
||||||
|
|
||||||
<!-- Loading state -->
|
|
||||||
<div v-if="loading" class="flex justify-center items-center py-20">
|
|
||||||
<div class="text-center">
|
|
||||||
<Loading />
|
|
||||||
<p class="mt-4 text-gray-600">Memuat maklumat asnaf...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else>
|
|
||||||
<!-- Header with actions -->
|
|
||||||
<div class="flex flex-col md:flex-row md:justify-between md:items-center gap-4 mb-6">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<h1 class="text-2xl font-bold text-primary">{{ profile.nama }}</h1>
|
|
||||||
<rs-badge :variant="statusColor">{{ profile.status }}</rs-badge>
|
|
||||||
<rs-badge :variant="categoryColor">{{ profile.kategori }}</rs-badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<rs-button variant="secondary-outline" @click="navigateToList">
|
|
||||||
<Icon name="mdi:arrow-left" size="18" class="mr-1" />
|
|
||||||
Kembali
|
|
||||||
</rs-button>
|
|
||||||
|
|
||||||
<rs-button variant="primary" @click="navigateToEdit">
|
|
||||||
<Icon name="mdi:pencil" size="18" class="mr-1" />
|
|
||||||
Kemaskini
|
|
||||||
</rs-button>
|
|
||||||
|
|
||||||
<rs-button variant="danger" @click="handleDelete">
|
|
||||||
<Icon name="mdi:delete" size="18" class="mr-1" />
|
|
||||||
Padam
|
|
||||||
</rs-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Profile Overview -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
|
||||||
<!-- Profile Photo and Basic Info -->
|
|
||||||
<rs-card class="lg:col-span-1">
|
|
||||||
<div class="p-6 flex flex-col items-center">
|
|
||||||
<div class="w-32 h-32 rounded-full bg-gray-200 flex items-center justify-center mb-4 overflow-hidden">
|
|
||||||
<Icon name="mdi:account" size="64" class="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold text-center">{{ profile.nama }}</h2>
|
|
||||||
<p class="text-gray-500 text-center mb-4">{{ profile.id }}</p>
|
|
||||||
|
|
||||||
<div class="w-full text-center">
|
|
||||||
<rs-badge :variant="categoryColor" class="mb-2">{{ profile.kategori }}</rs-badge>
|
|
||||||
<p class="text-sm text-gray-600">Didaftarkan pada {{ new Date(profile.tarikhDaftar).toLocaleDateString('ms-MY') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rs-card>
|
|
||||||
|
|
||||||
<!-- Personal Information -->
|
|
||||||
<rs-card class="lg:col-span-2">
|
|
||||||
<template #header>
|
|
||||||
<div class="px-4 py-3">
|
|
||||||
<h3 class="text-lg font-semibold text-primary flex items-center">
|
|
||||||
<Icon name="mdi:account-details" size="20" class="mr-2" />
|
|
||||||
Maklumat Peribadi
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">No. Kad Pengenalan</h4>
|
|
||||||
<p>{{ profile.idNumber }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Jantina</h4>
|
|
||||||
<p>{{ profile.gender }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Tarikh Lahir</h4>
|
|
||||||
<p>{{ new Date(profile.birthDate).toLocaleDateString('ms-MY') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Status Perkahwinan</h4>
|
|
||||||
<p>{{ profile.maritalStatus }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Pekerjaan</h4>
|
|
||||||
<p>{{ profile.occupation }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Pendapatan Bulanan</h4>
|
|
||||||
<p>RM {{ profile.monthlyIncome }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</rs-card>
|
|
||||||
|
|
||||||
<!-- Contact Information -->
|
|
||||||
<rs-card class="lg:col-span-3">
|
|
||||||
<template #header>
|
|
||||||
<div class="px-4 py-3">
|
|
||||||
<h3 class="text-lg font-semibold text-primary flex items-center">
|
|
||||||
<Icon name="mdi:contacts" size="20" class="mr-2" />
|
|
||||||
Maklumat Perhubungan
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-6 gap-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Alamat</h4>
|
|
||||||
<p>{{ profile.alamat || 'Tiada' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">No. Telefon</h4>
|
|
||||||
<p>{{ profile.telefon }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Emel</h4>
|
|
||||||
<p>{{ profile.email }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</rs-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabbed Details -->
|
|
||||||
<rs-card>
|
|
||||||
<template #header>
|
|
||||||
<div class="px-4 py-3 border-b">
|
|
||||||
<div class="flex overflow-x-auto space-x-4">
|
|
||||||
<button
|
|
||||||
@click="activeTab = 'personal'"
|
|
||||||
class="py-2 px-1 border-b-2 font-medium text-sm whitespace-nowrap transition-colors"
|
|
||||||
:class="activeTab === 'personal' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:account-group" size="18" class="mr-1" />
|
|
||||||
Maklumat Keluarga
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="activeTab = 'income'"
|
|
||||||
class="py-2 px-1 border-b-2 font-medium text-sm whitespace-nowrap transition-colors"
|
|
||||||
:class="activeTab === 'income' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:cash" size="18" class="mr-1" />
|
|
||||||
Maklumat Pendapatan
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="activeTab = 'aid'"
|
|
||||||
class="py-2 px-1 border-b-2 font-medium text-sm whitespace-nowrap transition-colors"
|
|
||||||
:class="activeTab === 'aid' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:gift" size="18" class="mr-1" />
|
|
||||||
Maklumat Bantuan
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="activeTab = 'documents'"
|
|
||||||
class="py-2 px-1 border-b-2 font-medium text-sm whitespace-nowrap transition-colors"
|
|
||||||
:class="activeTab === 'documents' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:file-document" size="18" class="mr-1" />
|
|
||||||
Dokumen
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="activeTab = 'analysis'"
|
|
||||||
class="py-2 px-1 border-b-2 font-medium text-sm whitespace-nowrap transition-colors"
|
|
||||||
:class="activeTab === 'analysis' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:chart-bar" size="18" class="mr-1" />
|
|
||||||
Analisis Data
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<!-- Family Information Tab -->
|
|
||||||
<div v-if="activeTab === 'personal'" class="p-6">
|
|
||||||
<div v-if="profile.spouse" class="mb-8">
|
|
||||||
<h3 class="text-lg font-semibold text-primary mb-4">Maklumat Pasangan</h3>
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Nama</h4>
|
|
||||||
<p>{{ profile.spouse.name }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">No. Kad Pengenalan</h4>
|
|
||||||
<p>{{ profile.spouse.idNumber }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-primary mb-4">Tanggungan</h3>
|
|
||||||
|
|
||||||
<div v-if="profile.dependents && profile.dependents.length > 0">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bil.</th>
|
|
||||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Nama</th>
|
|
||||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Umur</th>
|
|
||||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Hubungan</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<tr v-for="(dependent, index) in profile.dependents" :key="index" class="hover:bg-gray-50">
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">{{ index + 1 }}</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">{{ dependent.name }}</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">{{ dependent.age }}</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">{{ dependent.relationship }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="bg-gray-50 p-6 rounded-lg text-center">
|
|
||||||
<Icon name="mdi:account-off" size="48" class="text-gray-400 mb-2" />
|
|
||||||
<p class="text-gray-500">Tiada tanggungan</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Income Information Tab -->
|
|
||||||
<div v-if="activeTab === 'income'" class="p-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<rs-card>
|
|
||||||
<div class="p-4 text-center">
|
|
||||||
<div class="mb-2">
|
|
||||||
<Icon name="mdi:cash-multiple" size="36" class="text-primary" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-500">Pendapatan Bulanan</p>
|
|
||||||
<p class="text-xl font-bold text-primary">RM {{ profile.monthlyIncome }}</p>
|
|
||||||
</div>
|
|
||||||
</rs-card>
|
|
||||||
|
|
||||||
<rs-card>
|
|
||||||
<div class="p-4 text-center">
|
|
||||||
<div class="mb-2">
|
|
||||||
<Icon name="mdi:cash-plus" size="36" class="text-primary" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-500">Pendapatan Lain</p>
|
|
||||||
<p class="text-xl font-bold text-primary">RM {{ profile.otherIncome }}</p>
|
|
||||||
</div>
|
|
||||||
</rs-card>
|
|
||||||
|
|
||||||
<rs-card>
|
|
||||||
<div class="p-4 text-center">
|
|
||||||
<div class="mb-2">
|
|
||||||
<Icon name="mdi:cash-register" size="36" class="text-primary" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-500">Jumlah Pendapatan</p>
|
|
||||||
<p class="text-xl font-bold text-primary">RM {{ profile.totalIncome }}</p>
|
|
||||||
</div>
|
|
||||||
</rs-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<h3 class="text-lg font-semibold text-primary mb-4">Butiran Pendapatan</h3>
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Pekerjaan</h4>
|
|
||||||
<p>{{ profile.occupation }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Pendapatan Bulanan</h4>
|
|
||||||
<p>RM {{ profile.monthlyIncome }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Pendapatan Lain</h4>
|
|
||||||
<p>RM {{ profile.otherIncome }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Jumlah Pendapatan</h4>
|
|
||||||
<p>RM {{ profile.totalIncome }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aid Information Tab -->
|
|
||||||
<div v-if="activeTab === 'aid'" class="p-6">
|
|
||||||
<div class="bg-gray-50 p-6 rounded-lg text-center">
|
|
||||||
<Icon name="mdi:gift-off" size="48" class="text-gray-400 mb-2" />
|
|
||||||
<p class="text-gray-500">Tiada maklumat bantuan</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Documents Tab -->
|
|
||||||
<div v-if="activeTab === 'documents'" class="p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-primary mb-4">Dokumen Sokongan</h3>
|
|
||||||
|
|
||||||
<div v-if="profile.documents && profile.documents.length > 0">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div v-for="(doc, index) in profile.documents" :key="index" class="border rounded-lg overflow-hidden">
|
|
||||||
<div class="bg-gray-50 p-4 flex items-center">
|
|
||||||
<Icon name="mdi:file-document" size="24" class="text-primary mr-3" />
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium">{{ doc.name }}</h4>
|
|
||||||
<p class="text-sm text-gray-500">{{ doc.size }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 flex justify-end">
|
|
||||||
<rs-button variant="primary-text" size="sm">
|
|
||||||
<Icon name="mdi:download" size="16" class="mr-1" />
|
|
||||||
Muat Turun
|
|
||||||
</rs-button>
|
|
||||||
<rs-button variant="secondary-text" size="sm">
|
|
||||||
<Icon name="mdi:eye" size="16" class="mr-1" />
|
|
||||||
Papar
|
|
||||||
</rs-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="bg-gray-50 p-6 rounded-lg text-center">
|
|
||||||
<Icon name="mdi:file-document-off" size="48" class="text-gray-400 mb-2" />
|
|
||||||
<p class="text-gray-500">Tiada dokumen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Analysis Tab -->
|
|
||||||
<div v-if="activeTab === 'analysis'" class="p-6">
|
|
||||||
<!-- Button to trigger AI Analysis -->
|
|
||||||
<div v-if="!profile.analysis && !isLoadingAnalysis" class="text-center mb-6">
|
|
||||||
<rs-button variant="primary" @click="fetchAIAnalysis" size="lg">
|
|
||||||
<Icon name="mdi:brain" size="20" class="mr-2" />
|
|
||||||
Jalankan Analisis AI
|
|
||||||
</rs-button>
|
|
||||||
<p class="text-sm text-gray-500 mt-2">Klik untuk mendapatkan penilaian berdasarkan data profil.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State for AI Analysis -->
|
|
||||||
<div v-if="isLoadingAnalysis" class="text-center py-10">
|
|
||||||
<Loading />
|
|
||||||
<p class="mt-4 text-gray-600">Analisis AI sedang dijalankan...</p>
|
|
||||||
<p class="text-sm text-gray-500">Sila tunggu sebentar.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Display Analysis Results -->
|
|
||||||
<div v-if="profile.analysis && !isLoadingAnalysis" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
|
|
||||||
<!-- AI Analysis Main Column (takes 2/3 on lg screens) -->
|
|
||||||
<div class="lg:col-span-2 space-y-6">
|
|
||||||
<!-- Card 1: Analisis Had Kifayah & Kelayakan -->
|
|
||||||
<rs-card>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-semibold text-primary p-4 border-b">Analisis Had Kifayah & Kelayakan (AI)</h3>
|
|
||||||
</template>
|
|
||||||
<template #body>
|
|
||||||
<div class="p-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500 mb-1">Peratusan Had Kifayah</h4>
|
|
||||||
<div v-if="profile.analysis.hadKifayahPercentage === 'N/A' || profile.analysis.hadKifayahPercentage === 'Ralat'" class="text-gray-500">
|
|
||||||
{{ profile.analysis.hadKifayahPercentage }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="relative pt-1">
|
|
||||||
<div class="overflow-hidden h-4 text-xs flex rounded bg-gray-200">
|
|
||||||
<div
|
|
||||||
:style="{ width: profile.analysis.hadKifayahPercentage }"
|
|
||||||
:class="{
|
|
||||||
'bg-red-500': parseInt(profile.analysis.hadKifayahPercentage) < 60,
|
|
||||||
'bg-yellow-500': parseInt(profile.analysis.hadKifayahPercentage) >= 60 && parseInt(profile.analysis.hadKifayahPercentage) < 80,
|
|
||||||
'bg-green-500': parseInt(profile.analysis.hadKifayahPercentage) >= 80 && parseInt(profile.analysis.hadKifayahPercentage) <= 100,
|
|
||||||
'bg-blue-500': parseInt(profile.analysis.hadKifayahPercentage) > 100
|
|
||||||
}"
|
|
||||||
class="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center"
|
|
||||||
>
|
|
||||||
{{ profile.analysis.hadKifayahPercentage }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Kategori Asnaf (AI)</h4>
|
|
||||||
<p>{{ profile.analysis.kategoriAsnaf }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Kategori Keluarga (AI)</h4>
|
|
||||||
<p>{{ profile.analysis.kategoriKeluarga }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Cadangan Kategori (AI)</h4>
|
|
||||||
<p>{{ profile.analysis.cadanganKategori }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Status Kelayakan (AI)</h4>
|
|
||||||
<p>{{ profile.analysis.statusKelayakan }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</rs-card>
|
|
||||||
|
|
||||||
<!-- Card 2: Cadangan & Rumusan AI -->
|
|
||||||
<rs-card>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-semibold text-primary p-4 border-b">Cadangan & Rumusan (AI)</h3>
|
|
||||||
</template>
|
|
||||||
<template #body>
|
|
||||||
<div class="p-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Cadangan Bantuan (AI)</h4>
|
|
||||||
<ul v-if="profile.analysis.cadanganBantuan && profile.analysis.cadanganBantuan.length > 0" class="list-disc list-inside space-y-1 mt-1">
|
|
||||||
<li v-for="(bantuan, index) in profile.analysis.cadanganBantuan" :key="index" class="text-gray-700">
|
|
||||||
{{ bantuan.nama }}
|
|
||||||
<span v-if="bantuan.peratusan && bantuan.peratusan !== 'Ralat'" class="font-semibold text-blue-600">({{ bantuan.peratusan }})</span>
|
|
||||||
<span v-else-if="bantuan.peratusan === 'Ralat'" class="text-red-500 text-xs">({{ bantuan.peratusan }})</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p v-else class="text-gray-500 mt-1">Tiada cadangan bantuan spesifik.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Ramalan Jangka Masa Taraf Hidup Pulih (AI)</h4>
|
|
||||||
<p>{{ profile.analysis.ramalanJangkaMasaPulih }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Rumusan Keseluruhan (AI)</h4>
|
|
||||||
<div class="mt-1 p-3 bg-blue-50 border-l-4 border-blue-500 rounded-r-md">
|
|
||||||
<p class="whitespace-pre-line text-gray-700 text-sm">{{ profile.analysis.rumusan }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</rs-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Original Data Column (takes 1/3 on lg screens) -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<rs-card>
|
|
||||||
<template #header>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-700 p-4 border-b">Ringkasan Profil (Data Asal)</h3>
|
|
||||||
</template>
|
|
||||||
<template #body>
|
|
||||||
<div class="p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Jenis Kategori (Asal)</h4>
|
|
||||||
<rs-badge :variant="categoryColor" class="mt-1">{{ profile.kategori }}</rs-badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Status Semasa (Asal)</h4>
|
|
||||||
<rs-badge :variant="statusColor" class="mt-1">{{ profile.status }}</rs-badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Jumlah Pendapatan (Asal)</h4>
|
|
||||||
<p>RM {{ profile.totalIncome }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-500">Jumlah Tanggungan (Asal)</h4>
|
|
||||||
<p>{{ profile.dependents.length }} orang</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</rs-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</rs-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<rs-modal v-model="confirmDelete">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Icon name="mdi:alert-circle" size="24" class="text-red-500 mr-2" />
|
|
||||||
<h3 class="text-lg font-medium">Padam Profil</h3>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #default>
|
|
||||||
<div class="p-4">
|
|
||||||
<p class="mb-4">Adakah anda pasti ingin memadam profil ini?</p>
|
|
||||||
<p class="text-sm text-gray-500 mb-2">Nama: <span class="font-medium">{{ profile?.nama }}</span></p>
|
|
||||||
<p class="text-sm text-gray-500">ID: <span class="font-medium">{{ profile?.id }}</span></p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<rs-button variant="secondary-outline" @click="cancelDelete">
|
|
||||||
Batal
|
|
||||||
</rs-button>
|
|
||||||
<rs-button variant="danger" @click="confirmDeleteProfile">
|
|
||||||
Padam
|
|
||||||
</rs-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</rs-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
@ -1,337 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue';
|
|
||||||
import { useAsnafMockData } from '~/composables/useAsnafMockData';
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
title: "Senarai Asnaf",
|
|
||||||
middleware: ["auth"],
|
|
||||||
requiresAuth: true,
|
|
||||||
breadcrumb: [
|
|
||||||
{
|
|
||||||
name: "Dashboard",
|
|
||||||
path: "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "BF-PRF",
|
|
||||||
path: "/BF-PRF",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Asnaf",
|
|
||||||
path: "/BF-PRF/AS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Senarai",
|
|
||||||
path: "/BF-PRF/AS/LIST",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get asnaf data from the composable
|
|
||||||
const { asnafProfiles, statistics, filterProfiles, categories, statuses } = useAsnafMockData();
|
|
||||||
|
|
||||||
// Table reactivity control
|
|
||||||
const tableKey = ref(0);
|
|
||||||
const currentPage = ref(1);
|
|
||||||
const pageSize = ref(10);
|
|
||||||
const totalProfiles = ref(0);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
|
|
||||||
// Search and filter variables
|
|
||||||
const searchQuery = ref('');
|
|
||||||
const selectedStatus = ref('All');
|
|
||||||
const selectedCategory = ref('All');
|
|
||||||
|
|
||||||
// Table data and fields
|
|
||||||
const tableData = computed(() => {
|
|
||||||
return filterProfiles(searchQuery.value, selectedStatus.value, selectedCategory.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const tableFields = [
|
|
||||||
{ field: 'no', label: 'No.' },
|
|
||||||
{ field: 'id', label: 'ID' },
|
|
||||||
{ field: 'nama', label: 'Nama' },
|
|
||||||
{ field: 'idNumber', label: 'No. ID' },
|
|
||||||
{ field: 'kategori', label: 'Kategori' },
|
|
||||||
{ field: 'status', label: 'Status' },
|
|
||||||
{ field: 'tindakan', label: 'Tindakan' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Generate table field and data mapping
|
|
||||||
const formattedTableData = computed(() => {
|
|
||||||
return tableData.value.map((profile, index) => ({
|
|
||||||
no: index + 1,
|
|
||||||
id: profile.id,
|
|
||||||
nama: profile.nama,
|
|
||||||
idNumber: profile.idNumber,
|
|
||||||
kategori: profile.kategori,
|
|
||||||
status: profile.status,
|
|
||||||
tindakan: profile.id
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
function getBadgeVariantForCategory(category) {
|
|
||||||
switch (category) {
|
|
||||||
case 'Fakir': return 'danger';
|
|
||||||
case 'Miskin': return 'warning';
|
|
||||||
case 'Mualaf': return 'info';
|
|
||||||
case 'Fi-sabilillah': return 'primary';
|
|
||||||
case 'Gharimin': return 'secondary';
|
|
||||||
case 'Ibnu Sabil': return 'success';
|
|
||||||
default: return 'primary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBadgeVariantForStatus(status) {
|
|
||||||
switch (status) {
|
|
||||||
case 'Aktif': return 'success';
|
|
||||||
case 'Tidak Aktif': return 'danger';
|
|
||||||
case 'Dalam Semakan': return 'warning';
|
|
||||||
default: return 'secondary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToDetail(id) {
|
|
||||||
console.log("Attempting to navigate to detail for ID:", id);
|
|
||||||
if (id) {
|
|
||||||
navigateTo(`/BF-PRF/AS/DETAIL/${id}`);
|
|
||||||
} else {
|
|
||||||
console.error("Navigation failed: ID is undefined or null");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToRegistration() {
|
|
||||||
navigateTo('/BF-PRF/AS/FR/01');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const handlePageChange = (newPage) => {
|
|
||||||
currentPage.value = newPage;
|
|
||||||
fetchProfiles();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch data
|
|
||||||
async function fetchProfiles() {
|
|
||||||
isLoading.value = true;
|
|
||||||
// Simulate API delay
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
totalProfiles.value = tableData.value.length;
|
|
||||||
isLoading.value = false;
|
|
||||||
tableKey.value++; // Force table re-render
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle hooks
|
|
||||||
onMounted(() => {
|
|
||||||
fetchProfiles();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<LayoutsBreadcrumb />
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<h1 class="text-2xl font-bold text-primary">Senarai Asnaf</h1>
|
|
||||||
<rs-button
|
|
||||||
variant="primary"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
@click="navigateToRegistration"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:plus" size="18" />
|
|
||||||
<span>Tambah Asnaf</span>
|
|
||||||
</rs-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<rs-card class="transition-all duration-300 hover:shadow-lg">
|
|
||||||
<div class="p-4 flex items-center gap-4">
|
|
||||||
<div class="p-4 flex justify-center items-center bg-blue-100 rounded-xl">
|
|
||||||
<Icon name="mdi:account-group" size="24" class="text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="block text-2xl font-bold text-primary">{{ statistics.total }}</span>
|
|
||||||
<span class="text-sm text-gray-600">Jumlah Asnaf</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rs-card>
|
|
||||||
|
|
||||||
<rs-card class="transition-all duration-300 hover:shadow-lg">
|
|
||||||
<div class="p-4 flex items-center gap-4">
|
|
||||||
<div class="p-4 flex justify-center items-center bg-green-100 rounded-xl">
|
|
||||||
<Icon name="mdi:check-circle" size="24" class="text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="block text-2xl font-bold text-green-600">{{ statistics.active }}</span>
|
|
||||||
<span class="text-sm text-gray-600">Aktif</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rs-card>
|
|
||||||
|
|
||||||
<rs-card class="transition-all duration-300 hover:shadow-lg">
|
|
||||||
<div class="p-4 flex items-center gap-4">
|
|
||||||
<div class="p-4 flex justify-center items-center bg-yellow-100 rounded-xl">
|
|
||||||
<Icon name="mdi:clock-time-four" size="24" class="text-yellow-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="block text-2xl font-bold text-yellow-600">{{ statistics.review }}</span>
|
|
||||||
<span class="text-sm text-gray-600">Dalam Semakan</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rs-card>
|
|
||||||
|
|
||||||
<rs-card class="transition-all duration-300 hover:shadow-lg">
|
|
||||||
<div class="p-4 flex items-center gap-4">
|
|
||||||
<div class="p-4 flex justify-center items-center bg-red-100 rounded-xl">
|
|
||||||
<Icon name="mdi:close-circle" size="24" class="text-red-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="block text-2xl font-bold text-red-600">{{ statistics.inactive }}</span>
|
|
||||||
<span class="text-sm text-gray-600">Tidak Aktif</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rs-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search and Filters -->
|
|
||||||
<rs-card>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Carian</label>
|
|
||||||
<div class="relative rounded-md shadow-sm">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<Icon name="mdi:magnify" size="18" class="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
class="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
|
|
||||||
placeholder="Cari dengan nama atau ID..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
||||||
<select
|
|
||||||
v-model="selectedStatus"
|
|
||||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
|
|
||||||
>
|
|
||||||
<option value="All">Semua Status</option>
|
|
||||||
<option v-for="status in statuses" :key="status" :value="status">
|
|
||||||
{{ status }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Kategori</label>
|
|
||||||
<select
|
|
||||||
v-model="selectedCategory"
|
|
||||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
|
|
||||||
>
|
|
||||||
<option value="All">Semua Kategori</option>
|
|
||||||
<option v-for="category in categories" :key="category" :value="category">
|
|
||||||
{{ category }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rs-card>
|
|
||||||
|
|
||||||
<!-- Data Table -->
|
|
||||||
<rs-card>
|
|
||||||
<template #header>
|
|
||||||
<div class="px-4 py-3 flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold text-primary">Senarai Asnaf</h2>
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
{{ tableData.length }} asnaf dijumpai
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<div v-if="isLoading && tableData.length === 0" class="py-8 text-center">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<Icon name="mdi:loading" size="2rem" class="text-blue-500 animate-spin" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-gray-600">Memuat data...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<rs-table
|
|
||||||
v-else
|
|
||||||
class="mt-4"
|
|
||||||
:key="tableKey"
|
|
||||||
:data="formattedTableData"
|
|
||||||
:columns="tableFields"
|
|
||||||
:pageSize="pageSize"
|
|
||||||
:showNoColumn="true"
|
|
||||||
:options="{
|
|
||||||
variant: 'default',
|
|
||||||
hover: true,
|
|
||||||
striped: true,
|
|
||||||
bordered: true
|
|
||||||
}"
|
|
||||||
:current-page="currentPage"
|
|
||||||
:total-items="totalProfiles"
|
|
||||||
@page-change="handlePageChange"
|
|
||||||
>
|
|
||||||
<template v-slot:no="data">
|
|
||||||
{{ data.text }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:id="data">
|
|
||||||
{{ data.text }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:nama="data">
|
|
||||||
<div class="font-medium">{{ data.text }}</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:idNumber="data">
|
|
||||||
{{ data.text }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:kategori="data">
|
|
||||||
<rs-badge :variant="getBadgeVariantForCategory(data.text)">{{ data.text }}</rs-badge>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:status="data">
|
|
||||||
<rs-badge :variant="getBadgeVariantForStatus(data.text)">
|
|
||||||
{{ data.text }}
|
|
||||||
</rs-badge>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:tindakan="data">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<rs-button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
class="!px-2 !py-1"
|
|
||||||
@click="() => {
|
|
||||||
navigateToDetail(data.value.tindakan);
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:eye" size="1rem" class="mr-1" />
|
|
||||||
Lihat
|
|
||||||
</rs-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</rs-table>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="!isLoading && tableData.length === 0" class="text-center py-8">
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<Icon name="mdi:magnify" size="4rem" class="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-medium text-gray-500">Tiada Profil Ditemui</h3>
|
|
||||||
<p class="text-gray-500 mt-2">Sila cuba carian lain atau reset penapis.</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</rs-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
921
pages/admin/index.vue
Normal file
921
pages/admin/index.vue
Normal file
@ -0,0 +1,921 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
|
import { useToast } from 'vue-toastification';
|
||||||
|
import JobDetailModal from '@/components/JobDetailModal.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: "BullMQ Dashboard",
|
||||||
|
middleware: ["auth"],
|
||||||
|
requiresAuth: true,
|
||||||
|
breadcrumb: [
|
||||||
|
{
|
||||||
|
name: "admin",
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BullMQ Dashboard",
|
||||||
|
path: "/admin",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// --- Mock Data Definitions ---
|
||||||
|
const dashboardData = ref({
|
||||||
|
totalJobs: 12847 + 25, // Adjusted for new jobs
|
||||||
|
activeJobs: 23, // Will be dynamically updated by queueSpecificData sum later if needed
|
||||||
|
waitingJobs: 156, // Will be dynamically updated
|
||||||
|
completedJobs: 12384, // Will be dynamically updated
|
||||||
|
failedJobs: 284, // Will be dynamically updated
|
||||||
|
successRate: 97.8,
|
||||||
|
avgProcessingTime: "2.3s",
|
||||||
|
throughputPerHour: 1250,
|
||||||
|
queues: ["email-queue", "image-processing", "data-sync", "notifications"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseMockJobs = [
|
||||||
|
{
|
||||||
|
id: "job_001",
|
||||||
|
name: "Send Welcome Email",
|
||||||
|
queue: "email-queue",
|
||||||
|
state: "completed",
|
||||||
|
timestamp: new Date("2025-05-31T10:30:00Z").getTime(),
|
||||||
|
processedOn: new Date("2025-05-31T10:30:02Z").getTime(),
|
||||||
|
finishedOn: new Date("2025-05-31T10:30:05Z").getTime(),
|
||||||
|
duration: 5200,
|
||||||
|
priority: 5,
|
||||||
|
attemptsMade: 1,
|
||||||
|
data: { userId: 12345, template: "welcome" },
|
||||||
|
progress: 100,
|
||||||
|
failedReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "job_002",
|
||||||
|
name: "Resize Product Image",
|
||||||
|
queue: "image-processing",
|
||||||
|
state: "active",
|
||||||
|
timestamp: new Date("2025-05-31T11:15:00Z").getTime(),
|
||||||
|
processedOn: new Date("2025-05-31T11:15:02Z").getTime(),
|
||||||
|
finishedOn: null,
|
||||||
|
duration: null,
|
||||||
|
priority: 8,
|
||||||
|
attemptsMade: 1,
|
||||||
|
progress: 65,
|
||||||
|
data: { imageId: "img_product_abc.jpg", sizes: [100, 300, 600] },
|
||||||
|
failedReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "job_003",
|
||||||
|
name: "Sync User Data",
|
||||||
|
queue: "data-sync",
|
||||||
|
state: "failed",
|
||||||
|
timestamp: new Date("2025-05-31T09:45:00Z").getTime(),
|
||||||
|
processedOn: new Date("2025-05-31T09:45:03Z").getTime(),
|
||||||
|
finishedOn: new Date("2025-05-31T09:45:10Z").getTime(),
|
||||||
|
duration: 10000,
|
||||||
|
priority: 3,
|
||||||
|
attemptsMade: 3,
|
||||||
|
failedReason: "Database connection timeout. Attempted 3 times.",
|
||||||
|
data: { source: "crm", destination: "analytics_db" },
|
||||||
|
progress: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "job_004",
|
||||||
|
name: "Send Newsletter Batch",
|
||||||
|
queue: "email-queue",
|
||||||
|
state: "waiting",
|
||||||
|
timestamp: new Date("2025-05-31T12:00:00Z").getTime(),
|
||||||
|
processedOn: null,
|
||||||
|
finishedOn: null,
|
||||||
|
duration: null,
|
||||||
|
priority: 2,
|
||||||
|
attemptsMade: 0,
|
||||||
|
data: { campaignId: "newsletter_june", segment: "active_users" },
|
||||||
|
progress: 0,
|
||||||
|
failedReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "job_005",
|
||||||
|
name: "Generate Sales Report",
|
||||||
|
queue: "notifications",
|
||||||
|
state: "completed",
|
||||||
|
timestamp: new Date("2025-05-30T17:00:00Z").getTime(),
|
||||||
|
processedOn: new Date("2025-05-30T17:00:05Z").getTime(),
|
||||||
|
finishedOn: new Date("2025-05-30T17:05:00Z").getTime(),
|
||||||
|
duration: 300000,
|
||||||
|
priority: 1,
|
||||||
|
attemptsMade: 1,
|
||||||
|
data: { period: "2025-Q2", type: "executive_summary" },
|
||||||
|
progress: 100,
|
||||||
|
failedReason: null,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate more jobs for email-queue
|
||||||
|
const additionalEmailJobs = [];
|
||||||
|
const jobStates = ["completed", "waiting", "active", "failed"];
|
||||||
|
const emailJobNames = [
|
||||||
|
"Password Reset Request",
|
||||||
|
"Subscription Confirmation",
|
||||||
|
"Order Shipped Notification",
|
||||||
|
"Feature Update Announcement",
|
||||||
|
"Daily Digest Email"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
const jobState = jobStates[i % jobStates.length];
|
||||||
|
const jobName = emailJobNames[i % emailJobNames.length];
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const randomPastTime = now - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 5); // Within last 5 days
|
||||||
|
const randomFutureTime = now + Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 2); // Within next 2 days
|
||||||
|
|
||||||
|
let timestamp, processedOn = null, finishedOn = null, progress = 0, failedReason = null, duration = null;
|
||||||
|
const priority = Math.floor(Math.random() * 10) + 1;
|
||||||
|
|
||||||
|
switch (jobState) {
|
||||||
|
case "completed":
|
||||||
|
timestamp = randomPastTime - 120000; // Created 2 mins before processing
|
||||||
|
processedOn = randomPastTime - 60000; // Processed 1 min before finishing
|
||||||
|
finishedOn = randomPastTime;
|
||||||
|
progress = 100;
|
||||||
|
duration = (finishedOn - processedOn) + Math.floor(Math.random() * 1000);
|
||||||
|
break;
|
||||||
|
case "active":
|
||||||
|
timestamp = randomPastTime;
|
||||||
|
processedOn = new Date().getTime(); // Started now or recently
|
||||||
|
progress = Math.floor(Math.random() * 80) + 10; // 10-90%
|
||||||
|
break;
|
||||||
|
case "waiting":
|
||||||
|
timestamp = (i % 5 === 0 && priority <=3) ? randomFutureTime : randomPastTime; // High prio waiting jobs can be scheduled for future
|
||||||
|
break;
|
||||||
|
case "failed":
|
||||||
|
timestamp = randomPastTime - 120000;
|
||||||
|
processedOn = randomPastTime - 60000;
|
||||||
|
finishedOn = randomPastTime;
|
||||||
|
failedReason = "SMTP server unreachable after 2 attempts.";
|
||||||
|
duration = (finishedOn - processedOn) + Math.floor(Math.random() * 500);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
additionalEmailJobs.push({
|
||||||
|
id: `job_email_${String(i + 1).padStart(3, '0')}`,
|
||||||
|
name: `${jobName} #${i + 1}`,
|
||||||
|
queue: "email-queue",
|
||||||
|
state: jobState,
|
||||||
|
timestamp: timestamp,
|
||||||
|
processedOn: processedOn,
|
||||||
|
finishedOn: finishedOn,
|
||||||
|
duration: duration,
|
||||||
|
priority: priority,
|
||||||
|
attemptsMade: jobState === 'failed' ? 2 : (jobState === 'completed' || jobState === 'active' ? 1 : 0),
|
||||||
|
data: { recipient: `user${i + 1}@example.com`, subject: jobName },
|
||||||
|
progress: progress,
|
||||||
|
failedReason: failedReason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockJobs = ref([...baseMockJobs, ...additionalEmailJobs]);
|
||||||
|
|
||||||
|
// Re-calculate sums for dashboardData based on the full mockJobs list
|
||||||
|
dashboardData.value.totalJobs = mockJobs.value.length;
|
||||||
|
dashboardData.value.activeJobs = mockJobs.value.filter(j => j.state === 'active').length;
|
||||||
|
dashboardData.value.waitingJobs = mockJobs.value.filter(j => j.state === 'waiting').length;
|
||||||
|
dashboardData.value.completedJobs = mockJobs.value.filter(j => j.state === 'completed').length;
|
||||||
|
dashboardData.value.failedJobs = mockJobs.value.filter(j => j.state === 'failed').length;
|
||||||
|
|
||||||
|
|
||||||
|
const queueSpecificData = ref([
|
||||||
|
{
|
||||||
|
name: "email-queue",
|
||||||
|
status: "active",
|
||||||
|
workers: 5,
|
||||||
|
concurrency: 10,
|
||||||
|
metrics: { // Metrics are now calculated from the final mockJobs list
|
||||||
|
processed: mockJobs.value.filter(j => j.queue === 'email-queue' && j.state === 'completed').length,
|
||||||
|
failed: mockJobs.value.filter(j => j.queue === 'email-queue' && j.state === 'failed').length,
|
||||||
|
waiting: mockJobs.value.filter(j => j.queue === 'email-queue' && j.state === 'waiting').length,
|
||||||
|
active: mockJobs.value.filter(j => j.queue === 'email-queue' && j.state === 'active').length,
|
||||||
|
},
|
||||||
|
paused: false,
|
||||||
|
processingRate: "150 jobs/min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "image-processing",
|
||||||
|
status: "active",
|
||||||
|
workers: 3,
|
||||||
|
concurrency: 5,
|
||||||
|
metrics: {
|
||||||
|
processed: mockJobs.value.filter(j => j.queue === 'image-processing' && j.state === 'completed').length,
|
||||||
|
failed: mockJobs.value.filter(j => j.queue === 'image-processing' && j.state === 'failed').length,
|
||||||
|
waiting: mockJobs.value.filter(j => j.queue === 'image-processing' && j.state === 'waiting').length,
|
||||||
|
active: mockJobs.value.filter(j => j.queue === 'image-processing' && j.state === 'active').length,
|
||||||
|
},
|
||||||
|
paused: false,
|
||||||
|
processingRate: "45 jobs/min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data-sync",
|
||||||
|
status: "paused",
|
||||||
|
workers: 2,
|
||||||
|
concurrency: 2,
|
||||||
|
metrics: {
|
||||||
|
processed: mockJobs.value.filter(j => j.queue === 'data-sync' && j.state === 'completed').length,
|
||||||
|
failed: mockJobs.value.filter(j => j.queue === 'data-sync' && j.state === 'failed').length,
|
||||||
|
waiting: mockJobs.value.filter(j => j.queue === 'data-sync' && j.state === 'waiting').length,
|
||||||
|
active: mockJobs.value.filter(j => j.queue === 'data-sync' && j.state === 'active').length,
|
||||||
|
},
|
||||||
|
paused: true,
|
||||||
|
processingRate: "25 jobs/min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notifications",
|
||||||
|
status: "active",
|
||||||
|
workers: 4,
|
||||||
|
concurrency: 8,
|
||||||
|
metrics: {
|
||||||
|
processed: mockJobs.value.filter(j => j.queue === 'notifications' && j.state === 'completed').length,
|
||||||
|
failed: mockJobs.value.filter(j => j.queue === 'notifications' && j.state === 'failed').length,
|
||||||
|
waiting: mockJobs.value.filter(j => j.queue === 'notifications' && j.state === 'waiting').length,
|
||||||
|
active: mockJobs.value.filter(j => j.queue === 'notifications' && j.state === 'active').length,
|
||||||
|
},
|
||||||
|
paused: false,
|
||||||
|
processingRate: "100 jobs/min"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
// --- End of Mock Data Definitions ---
|
||||||
|
|
||||||
|
const jobs = ref(mockJobs.value); // This should now use the expanded mockJobs
|
||||||
|
const error = ref(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
const selectedQueueName = ref(dashboardData.value.queues[0]); // Default to 'email-queue'
|
||||||
|
const availableQueues = ref(dashboardData.value.queues);
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const selectedStatusFilter = ref('all');
|
||||||
|
const selectedPriorityGroup = ref('all'); // New filter for priority
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const itemsPerPage = ref(10);
|
||||||
|
const selectedJob = ref(null);
|
||||||
|
|
||||||
|
// For Queue Drag and Drop
|
||||||
|
const draggedQueueInfo = ref(null); // { queue: Object, originalIndex: Number }
|
||||||
|
const dragOverQueueIndex = ref(null); // Index of the queue card being dragged over
|
||||||
|
|
||||||
|
// Priority Grouping & Colors
|
||||||
|
const priorityGroups = {
|
||||||
|
high: { label: 'High (1-3)', range: [1, 3], colorClass: 'red', badgeClass: 'bg-red-500 text-white', borderClass: 'border-red-500' },
|
||||||
|
medium: { label: 'Medium (4-7)', range: [4, 7], colorClass: 'orange', badgeClass: 'bg-orange-500 text-white', borderClass: 'border-orange-500' },
|
||||||
|
low: { label: 'Low (8+)', range: [8, Infinity], colorClass: 'blue', badgeClass: 'bg-sky-500 text-white', borderClass: 'border-sky-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPriorityGroup(priority) {
|
||||||
|
for (const groupKey in priorityGroups) {
|
||||||
|
const group = priorityGroups[groupKey];
|
||||||
|
if (priority >= group.range[0] && priority <= group.range[1]) {
|
||||||
|
return groupKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'low'; // Default to low if somehow out of defined ranges (e.g. priority 0 or very high)
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityGroupTabs = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: 'All Priorities', value: 'all' },
|
||||||
|
...Object.keys(priorityGroups).map(key => ({
|
||||||
|
label: priorityGroups[key].label,
|
||||||
|
value: key,
|
||||||
|
badgeClass: priorityGroups[key].badgeClass
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function getJobPriorityClass(priority) {
|
||||||
|
const groupKey = getPriorityGroup(priority);
|
||||||
|
return priorityGroups[groupKey]?.badgeClass || 'bg-gray-400 text-white';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJobPriorityBorderClass(priority) {
|
||||||
|
const groupKey = getPriorityGroup(priority);
|
||||||
|
return priorityGroups[groupKey]?.borderClass || 'border-gray-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp) {
|
||||||
|
if (!timestamp) return 'N/A';
|
||||||
|
const date = new Date(Number(timestamp));
|
||||||
|
if (isNaN(date.getTime())) return 'Invalid Date';
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJobsDisplay() {
|
||||||
|
isLoading.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
let currentJobsToDisplay = []; // Changed variable name for clarity
|
||||||
|
if (selectedQueueName.value && selectedQueueName.value !== 'all') {
|
||||||
|
currentJobsToDisplay = mockJobs.value.filter(job => job.queue === selectedQueueName.value);
|
||||||
|
} else {
|
||||||
|
currentJobsToDisplay = [...mockJobs.value]; // Use a copy for 'All Queues'
|
||||||
|
}
|
||||||
|
// The jobs ref is used by filteredAndSortedJobs, so we don't need to set jobs.value directly here.
|
||||||
|
// Instead, filteredAndSortedJobs will react to changes in mockJobs, selectedQueueName, etc.
|
||||||
|
// However, to ensure reactivity if `jobs` itself is directly used elsewhere (though it shouldn't be if paginatedJobs is the source):
|
||||||
|
jobs.value = [...currentJobsToDisplay]; // This line might be redundant if UI only uses paginatedJobs.
|
||||||
|
// For safety and consistency, ensure `filteredAndSortedJobs` is the primary source for `paginatedJobs`.
|
||||||
|
isLoading.value = false;
|
||||||
|
currentPage.value = 1;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryJob(jobId) {
|
||||||
|
const job = mockJobs.value.find(j => j.id === jobId);
|
||||||
|
if (job && job.state === 'failed') {
|
||||||
|
job.state = 'waiting';
|
||||||
|
job.attemptsMade = (job.attemptsMade || 0) + 1;
|
||||||
|
job.failedReason = null;
|
||||||
|
toast.success(`Job #${jobId} sent for retry.`);
|
||||||
|
updateJobsDisplay(); // This will refresh the view based on current filters
|
||||||
|
} else {
|
||||||
|
toast.error(`Job #${jobId} not found or cannot be retried.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeJob(jobId) {
|
||||||
|
const index = mockJobs.value.findIndex(j => j.id === jobId);
|
||||||
|
if (index !== -1) {
|
||||||
|
mockJobs.value.splice(index, 1);
|
||||||
|
// Also update dashboardData counts
|
||||||
|
dashboardData.value.totalJobs = mockJobs.value.length;
|
||||||
|
dashboardData.value.activeJobs = mockJobs.value.filter(j => j.state === 'active').length;
|
||||||
|
dashboardData.value.waitingJobs = mockJobs.value.filter(j => j.state === 'waiting').length;
|
||||||
|
dashboardData.value.completedJobs = mockJobs.value.filter(j => j.state === 'completed').length;
|
||||||
|
dashboardData.value.failedJobs = mockJobs.value.filter(j => j.state === 'failed').length;
|
||||||
|
toast.success(`Job #${jobId} removed.`);
|
||||||
|
updateJobsDisplay();
|
||||||
|
} else {
|
||||||
|
toast.error(`Job #${jobId} not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanQueue(queueName) {
|
||||||
|
const qData = queueSpecificData.value.find(q => q.name === queueName);
|
||||||
|
if (!qData) {
|
||||||
|
toast.error(`Queue ${queueName} not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const initialJobCount = mockJobs.value.length;
|
||||||
|
mockJobs.value = mockJobs.value.filter(job =>
|
||||||
|
!(job.queue === queueName && (job.state === 'completed' || job.state === 'failed'))
|
||||||
|
);
|
||||||
|
const removedCount = initialJobCount - mockJobs.value.length;
|
||||||
|
if (removedCount > 0) {
|
||||||
|
// Update dashboardData counts
|
||||||
|
dashboardData.value.totalJobs = mockJobs.value.length;
|
||||||
|
dashboardData.value.completedJobs -= mockJobs.value.filter(j => j.queue === queueName && j.state === 'completed').length; // this is tricky, better re-calculate all
|
||||||
|
dashboardData.value.failedJobs -= mockJobs.value.filter(j => j.queue === queueName && j.state === 'failed').length; // same here
|
||||||
|
// Recalculate all global counts for simplicity and accuracy after clean
|
||||||
|
dashboardData.value.activeJobs = mockJobs.value.filter(j => j.state === 'active').length;
|
||||||
|
dashboardData.value.waitingJobs = mockJobs.value.filter(j => j.state === 'waiting').length;
|
||||||
|
dashboardData.value.completedJobs = mockJobs.value.filter(j => j.state === 'completed').length;
|
||||||
|
dashboardData.value.failedJobs = mockJobs.value.filter(j => j.state === 'failed').length;
|
||||||
|
|
||||||
|
toast.success(`Cleaned ${removedCount} completed/failed jobs from ${queueName}.`);
|
||||||
|
} else {
|
||||||
|
toast.info(`No completed or failed jobs to clean from ${queueName}.`);
|
||||||
|
}
|
||||||
|
updateJobsDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pauseQueue(queueName) {
|
||||||
|
const qData = queueSpecificData.value.find(q => q.name === queueName);
|
||||||
|
if (qData) {
|
||||||
|
if(qData.status === 'paused'){
|
||||||
|
toast.info(`Queue ${queueName} is already paused.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qData.status = 'paused';
|
||||||
|
qData.paused = true;
|
||||||
|
toast.success(`Queue ${queueName} paused.`);
|
||||||
|
} else {
|
||||||
|
toast.error(`Queue ${queueName} not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeQueue(queueName) {
|
||||||
|
const qData = queueSpecificData.value.find(q => q.name === queueName);
|
||||||
|
if (qData) {
|
||||||
|
if(qData.status === 'active'){
|
||||||
|
toast.info(`Queue ${queueName} is already active/resumed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qData.status = 'active';
|
||||||
|
qData.paused = false;
|
||||||
|
toast.success(`Queue ${queueName} resumed.`);
|
||||||
|
} else {
|
||||||
|
toast.error(`Queue ${queueName} not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag and Drop Handlers for Queues
|
||||||
|
function onQueueDragStart(event, queue, index) {
|
||||||
|
draggedQueueInfo.value = { queue: { ...queue }, originalIndex: index };
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', queue.name); // Necessary for Firefox
|
||||||
|
// Optional: Add a class to the dragged element for styling
|
||||||
|
event.target.classList.add('dragging-queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueueDragOver(event, targetIndex) {
|
||||||
|
event.preventDefault(); // Necessary to allow dropping
|
||||||
|
dragOverQueueIndex.value = targetIndex;
|
||||||
|
// Optional: Add a class to the drop target for styling
|
||||||
|
// event.target.closest('.queue-card-draggable').classList.add('drag-over-queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueueDragLeave(event) {
|
||||||
|
// Optional: Remove drag over styling if not dropping on this element
|
||||||
|
// event.target.closest('.queue-card-draggable').classList.remove('drag-over-queue');
|
||||||
|
if (dragOverQueueIndex.value === Array.from(event.target.closest('.queue-card-draggable').parentElement.children).indexOf(event.target.closest('.queue-card-draggable'))) {
|
||||||
|
dragOverQueueIndex.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueueDrop(event, targetIndex) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!draggedQueueInfo.value || draggedQueueInfo.value.originalIndex === targetIndex) {
|
||||||
|
dragOverQueueIndex.value = null;
|
||||||
|
return; // No drop if nothing is dragged or dropped on itself
|
||||||
|
}
|
||||||
|
|
||||||
|
const { queue: draggedItem, originalIndex } = draggedQueueInfo.value;
|
||||||
|
|
||||||
|
// Remove the item from its original position
|
||||||
|
queueSpecificData.value.splice(originalIndex, 1);
|
||||||
|
// Insert the item at the new position
|
||||||
|
queueSpecificData.value.splice(targetIndex, 0, draggedItem);
|
||||||
|
|
||||||
|
toast.info(`Queue '${draggedItem.name}' moved.`);
|
||||||
|
dragOverQueueIndex.value = null;
|
||||||
|
// Clean up classes on drag end
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueueDragEnd(event) {
|
||||||
|
// Clean up: remove dragging class and reset refs
|
||||||
|
event.target.classList.remove('dragging-queue');
|
||||||
|
// const cards = document.querySelectorAll('.drag-over-queue');
|
||||||
|
// cards.forEach(card => card.classList.remove('drag-over-queue'));
|
||||||
|
draggedQueueInfo.value = null;
|
||||||
|
dragOverQueueIndex.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueOptions = computed(() => {
|
||||||
|
return [{ label: 'All Queues', value: 'all' }, ...availableQueues.value.map(queue => ({
|
||||||
|
label: queue,
|
||||||
|
value: queue
|
||||||
|
}))];
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobStatusOptions = computed(() => {
|
||||||
|
const statuses = new Set(['all']);
|
||||||
|
mockJobs.value.forEach(job => statuses.add(job.state));
|
||||||
|
return Array.from(statuses).map(status => ({
|
||||||
|
label: status.charAt(0).toUpperCase() + status.slice(1),
|
||||||
|
value: status
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredAndSortedJobs = computed(() => {
|
||||||
|
let result = [];
|
||||||
|
if (selectedQueueName.value && selectedQueueName.value !== 'all') {
|
||||||
|
result = mockJobs.value.filter(job => job.queue === selectedQueueName.value);
|
||||||
|
} else {
|
||||||
|
result = [...mockJobs.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by priority group
|
||||||
|
if (selectedPriorityGroup.value !== 'all') {
|
||||||
|
const groupInfo = priorityGroups[selectedPriorityGroup.value];
|
||||||
|
if (groupInfo) {
|
||||||
|
result = result.filter(job => job.priority >= groupInfo.range[0] && job.priority <= groupInfo.range[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedStatusFilter.value !== 'all') {
|
||||||
|
result = result.filter(job => job.state === selectedStatusFilter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.value.trim() !== '') {
|
||||||
|
const lowerSearchQuery = searchQuery.value.toLowerCase().trim();
|
||||||
|
result = result.filter(job => {
|
||||||
|
const jobIdMatch = String(job.id).toLowerCase().includes(lowerSearchQuery);
|
||||||
|
const jobNameMatch = job.name ? job.name.toLowerCase().includes(lowerSearchQuery) : false;
|
||||||
|
const jobDataString = job.data ? JSON.stringify(job.data).toLowerCase() : '';
|
||||||
|
const jobDataMatch = jobDataString.includes(lowerSearchQuery);
|
||||||
|
return jobIdMatch || jobNameMatch || jobDataMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.sort((a, b) => Number(b.timestamp) - Number(a.timestamp));
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
return Math.ceil(filteredAndSortedJobs.value.length / itemsPerPage.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedJobs = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage.value;
|
||||||
|
const end = start + itemsPerPage.value;
|
||||||
|
return filteredAndSortedJobs.value.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(selectedQueueName, (newQueue) => {
|
||||||
|
updateJobsDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([searchQuery, selectedStatusFilter, selectedPriorityGroup], () => {
|
||||||
|
currentPage.value = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(mockJobs, (newJobs) => {
|
||||||
|
// Update queueSpecificData metrics whenever mockJobs changes
|
||||||
|
queueSpecificData.value.forEach(q => {
|
||||||
|
q.metrics.processed = newJobs.filter(j => j.queue === q.name && j.state === 'completed').length;
|
||||||
|
q.metrics.failed = newJobs.filter(j => j.queue === q.name && j.state === 'failed').length;
|
||||||
|
q.metrics.waiting = newJobs.filter(j => j.queue === q.name && j.state === 'waiting').length;
|
||||||
|
q.metrics.active = newJobs.filter(j => j.queue === q.name && j.state === 'active').length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update dashboardData counts
|
||||||
|
dashboardData.value.totalJobs = newJobs.length;
|
||||||
|
dashboardData.value.activeJobs = newJobs.filter(j => j.state === 'active').length;
|
||||||
|
dashboardData.value.waitingJobs = newJobs.filter(j => j.state === 'waiting').length;
|
||||||
|
dashboardData.value.completedJobs = newJobs.filter(j => j.state === 'completed').length;
|
||||||
|
dashboardData.value.failedJobs = newJobs.filter(j => j.state === 'failed').length;
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateJobsDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-8 p-4 md:p-6 lg:p-8">
|
||||||
|
<LayoutsBreadcrumb />
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center">
|
||||||
|
<h1 class="text-3xl font-bold text-primary mb-4 sm:mb-0">BullMQ Dashboard</h1>
|
||||||
|
|
||||||
|
<!-- Global Actions (Placeholder for Add Job, Pause All, etc.) -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<rs-button variant="primary" size="sm">
|
||||||
|
<Icon name="mdi:plus-box-outline" class="mr-1" /> Add Job
|
||||||
|
</rs-button>
|
||||||
|
<rs-button variant="warning" size="sm">
|
||||||
|
<Icon name="mdi:pause-octagon-outline" class="mr-1" /> Pause All Queues
|
||||||
|
</rs-button>
|
||||||
|
<rs-button variant="danger" size="sm">
|
||||||
|
<Icon name="mdi:delete-sweep-outline" class="mr-1" /> Clear All Failed
|
||||||
|
</rs-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Overview Metrics -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
|
||||||
|
<div class="bg-slate-800 text-white p-4 rounded-lg shadow-lg">
|
||||||
|
<div class="text-sm font-medium text-slate-300">Total Jobs</div>
|
||||||
|
<div class="text-3xl font-bold">{{ dashboardData.totalJobs }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-sky-600 text-white p-4 rounded-lg shadow-lg">
|
||||||
|
<div class="text-sm font-medium text-slate-100">Active</div>
|
||||||
|
<div class="text-3xl font-bold">{{ dashboardData.activeJobs }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-amber-500 text-white p-4 rounded-lg shadow-lg">
|
||||||
|
<div class="text-sm font-medium text-slate-100">Waiting</div>
|
||||||
|
<div class="text-3xl font-bold">{{ dashboardData.waitingJobs }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-600 text-white p-4 rounded-lg shadow-lg">
|
||||||
|
<div class="text-sm font-medium text-slate-100">Completed</div>
|
||||||
|
<div class="text-3xl font-bold">{{ dashboardData.completedJobs }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-red-600 text-white p-4 rounded-lg shadow-lg">
|
||||||
|
<div class="text-sm font-medium text-slate-100">Failed</div>
|
||||||
|
<div class="text-3xl font-bold">{{ dashboardData.failedJobs }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-indigo-600 text-white p-4 rounded-lg shadow-lg">
|
||||||
|
<div class="text-sm font-medium text-slate-100">Success Rate</div>
|
||||||
|
<div class="text-3xl font-bold">{{ dashboardData.successRate }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue Management Cards -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-800 mb-4">Queue Overview</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<rs-card v-for="(q, index) in queueSpecificData" :key="q.name"
|
||||||
|
:class="{
|
||||||
|
'border-blue-500': q.status === 'active',
|
||||||
|
'border-orange-400': q.status === 'paused',
|
||||||
|
'queue-drop-target-active': dragOverQueueIndex === index && draggedQueueInfo && draggedQueueInfo.originalIndex !== index
|
||||||
|
}"
|
||||||
|
class="border-t-4 shadow-lg hover:shadow-xl transition-all duration-200 ease-in-out queue-card-draggable cursor-grab"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onQueueDragStart($event, q, index)"
|
||||||
|
@dragover.prevent="onQueueDragOver($event, index)"
|
||||||
|
@dragleave="onQueueDragLeave($event)"
|
||||||
|
@drop="onQueueDrop($event, index)"
|
||||||
|
@dragend="onQueueDragEnd($event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-700">{{ q.name }}</h3>
|
||||||
|
<rs-badge :variant="q.status === 'active' ? 'success' : 'warning'">{{ q.status }}</rs-badge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Active:</span> <strong class="text-gray-800">{{ q.metrics.active }}</strong></div>
|
||||||
|
<div><span class="text-gray-500">Waiting:</span> <strong class="text-gray-800">{{ q.metrics.waiting }}</strong></div>
|
||||||
|
<div><span class="text-gray-500">Completed:</span> <strong class="text-green-600">{{ q.metrics.processed }}</strong></div>
|
||||||
|
<div><span class="text-gray-500">Failed:</span> <strong class="text-red-600">{{ q.metrics.failed }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
Workers: {{q.workers}} | Concurrency: {{q.concurrency}} | Rate: {{q.processingRate}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="px-4 py-2 border-t bg-gray-50 flex justify-end space-x-2">
|
||||||
|
<rs-button size="xs" :variant="q.status === 'active' ? 'warning' : 'success'"
|
||||||
|
@click="q.status === 'active' ? pauseQueue(q.name) : resumeQueue(q.name)">
|
||||||
|
<Icon :name="q.status === 'active' ? 'mdi:pause' : 'mdi:play'" size="16" class="mr-1" />
|
||||||
|
{{ q.status === 'active' ? 'Pause' : 'Resume' }}
|
||||||
|
</rs-button>
|
||||||
|
<rs-button size="xs" variant="danger" @click="cleanQueue(q.name)">
|
||||||
|
<Icon name="mdi:broom" size="16" class="mr-1" /> Clean
|
||||||
|
</rs-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</rs-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter and Job List Section -->
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-800">Job Management</h2>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="w-72">
|
||||||
|
<rs-select v-model="selectedQueueName"
|
||||||
|
:options="queueOptions"
|
||||||
|
placeholder="Select Queue to View Jobs"
|
||||||
|
:disabled="isLoading || !!error" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority Group Tabs -->
|
||||||
|
<div class="mb-4 border-b border-gray-200">
|
||||||
|
<nav class="-mb-px flex space-x-4" aria-label="Tabs">
|
||||||
|
<button v-for="tab in priorityGroupTabs" :key="tab.value"
|
||||||
|
@click="selectedPriorityGroup = tab.value"
|
||||||
|
:class="[
|
||||||
|
tab.value === selectedPriorityGroup
|
||||||
|
? (priorityGroups[tab.value]?.borderClass ? priorityGroups[tab.value].borderClass.replace('border-', 'border-b-2 border-') + ' text-' + priorityGroups[tab.value].colorClass + '-600' : 'border-b-2 border-indigo-500 text-indigo-600')
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||||
|
'whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm focus:outline-none transition-colors duration-150 ease-in-out'
|
||||||
|
]"
|
||||||
|
:aria-current="tab.value === selectedPriorityGroup ? 'page' : undefined">
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<rs-card v-if="selectedQueueName">
|
||||||
|
<template #header>
|
||||||
|
<div class="px-4 py-3 border-b">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between md:items-center mb-3">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-700 mb-2 md:mb-0">
|
||||||
|
Jobs in <span class="text-primary">{{ selectedQueueName === 'all' ? 'All Queues' : selectedQueueName }}</span>
|
||||||
|
<span v-if="selectedPriorityGroup !== 'all'" class="text-sm font-normal text-gray-500 ml-2">
|
||||||
|
(Priority: {{ priorityGroups[selectedPriorityGroup]?.label }})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center space-x-2" v-if="selectedQueueName !== 'all'">
|
||||||
|
<rs-button variant="success" size="sm"
|
||||||
|
@click="resumeQueue(selectedQueueName)"
|
||||||
|
:disabled="isLoading || !!error || queueSpecificData.find(q=>q.name === selectedQueueName)?.status === 'active'">
|
||||||
|
<Icon name="mdi:play" size="18" class="mr-1" /> Resume Queue
|
||||||
|
</rs-button>
|
||||||
|
<rs-button variant="warning" size="sm"
|
||||||
|
@click="pauseQueue(selectedQueueName)"
|
||||||
|
:disabled="isLoading || !!error || queueSpecificData.find(q=>q.name === selectedQueueName)?.status === 'paused'">
|
||||||
|
<Icon name="mdi:pause" size="18" class="mr-1" /> Pause Queue
|
||||||
|
</rs-button>
|
||||||
|
<rs-button variant="danger" size="sm"
|
||||||
|
@click="cleanQueue(selectedQueueName)"
|
||||||
|
:disabled="isLoading || !!error">
|
||||||
|
<Icon name="mdi:broom" size="18" class="mr-1" /> Clean Queue
|
||||||
|
</rs-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4">
|
||||||
|
<div class="flex-grow w-full md:w-auto">
|
||||||
|
<rs-input type="text" v-model="searchQuery" placeholder="Search Jobs by ID, Name, or Data..." class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-48">
|
||||||
|
<rs-select v-model="selectedStatusFilter"
|
||||||
|
:options="jobStatusOptions"
|
||||||
|
placeholder="Filter by Status"
|
||||||
|
:disabled="isLoading || !!error || paginatedJobs.length === 0 && filteredAndSortedJobs.length === 0" />
|
||||||
|
</div>
|
||||||
|
<rs-button variant="secondary" size="sm"
|
||||||
|
@click="updateJobsDisplay"
|
||||||
|
:disabled="isLoading || !!error"
|
||||||
|
class="w-full md:w-auto">
|
||||||
|
<Icon name="mdi:refresh" size="18"
|
||||||
|
:class="{ 'animate-spin': isLoading }"
|
||||||
|
class="mr-1" />
|
||||||
|
{{ isLoading ? 'Refreshing...' : 'Refresh Jobs' }}
|
||||||
|
</rs-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="p-4">
|
||||||
|
<div v-if="isLoading" class="text-center py-8">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p class="mt-2 text-gray-500">Loading jobs...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" class="bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<Icon name="mdi:alert-circle-outline" class="h-5 w-5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-red-700">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="paginatedJobs.length > 0" class="space-y-4">
|
||||||
|
<div v-for="job in paginatedJobs" :key="job.id"
|
||||||
|
class="bg-white p-4 rounded-lg shadow hover:shadow-lg transition-all duration-200 ease-in-out cursor-pointer border-l-4 flex flex-col"
|
||||||
|
:class="getJobPriorityBorderClass(job.priority)"
|
||||||
|
@click="selectedJob = job">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start flex-grow">
|
||||||
|
<div class="mb-2 sm:mb-0 flex-grow">
|
||||||
|
<div class="flex items-center space-x-2 mb-1">
|
||||||
|
<span class="font-semibold text-primary hover:underline">#{{ job.id }}</span>
|
||||||
|
<span class="text-md font-medium text-gray-700">- {{ job.name }}</span>
|
||||||
|
<rs-badge
|
||||||
|
:variant="job.state === 'completed' ? 'success' :
|
||||||
|
job.state === 'failed' ? 'danger' :
|
||||||
|
job.state === 'active' ? 'primary' : 'info'"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
{{ job.state }}
|
||||||
|
</rs-badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Queue: <span class="font-medium">{{job.queue}}</span>
|
||||||
|
| Priority: <span :class="getJobPriorityClass(job.priority)" class="px-1.5 py-0.5 rounded-full text-xs font-semibold">{{job.priority}}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">
|
||||||
|
Created: {{ formatDate(job.timestamp) }}
|
||||||
|
<span v-if="job.processedOn">| Started: {{ formatDate(job.processedOn) }}</span>
|
||||||
|
<span v-if="job.finishedOn">| Finished: {{ formatDate(job.finishedOn) }}</span>
|
||||||
|
<span v-if="job.duration">| Duration: {{(job.duration / 1000).toFixed(1)}}s</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-2 mt-2 sm:mt-0 self-start sm:self-center flex-shrink-0">
|
||||||
|
<rs-button v-if="job.state === 'failed'"
|
||||||
|
variant="warning" size="xs"
|
||||||
|
@click.stop="retryJob(job.id)"
|
||||||
|
:disabled="isLoading || !!error">
|
||||||
|
<Icon name="mdi:refresh" size="14" class="mr-1" /> Retry
|
||||||
|
</rs-button>
|
||||||
|
<rs-button variant="danger" size="xs"
|
||||||
|
@click.stop="removeJob(job.id)"
|
||||||
|
:disabled="isLoading || !!error">
|
||||||
|
<Icon name="mdi:delete-outline" size="14" class="mr-1" /> Remove
|
||||||
|
</rs-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="job.state === 'active' && job.progress > 0" class="mt-3 border-t border-gray-100 pt-2">
|
||||||
|
<div class="flex justify-between text-xs text-gray-600 mb-1">
|
||||||
|
<span>Progress: {{ job.progress }}%</span>
|
||||||
|
<span>Attempts: {{ job.attemptsMade }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-1.5">
|
||||||
|
<div class="bg-blue-600 h-1.5 rounded-full"
|
||||||
|
:style="{ width: job.progress + '%' }">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="job.state === 'failed' && job.failedReason"
|
||||||
|
class="mt-3 p-2 bg-red-50 border-l-4 border-red-400 rounded text-xs text-red-700 whitespace-pre-wrap">
|
||||||
|
<strong>Error:</strong> {{ job.failedReason }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="job.data && Object.keys(job.data).length > 0" class="mt-2 pt-2 border-t border-gray-100">
|
||||||
|
<details class="text-xs">
|
||||||
|
<summary class="cursor-pointer text-gray-500 hover:text-gray-700">View Data</summary>
|
||||||
|
<pre class="mt-1 p-2 bg-gray-50 rounded text-gray-600 text-[11px] max-h-32 overflow-auto">{{ JSON.stringify(job.data, null, 2) }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filteredAndSortedJobs.length === 0 && jobs.length > 0 && (searchQuery || selectedStatusFilter !== 'all' || selectedPriorityGroup !== 'all')" class="text-center py-10">
|
||||||
|
<Icon name="mdi:filter-variant-remove" size="48" class="text-gray-400 mx-auto mb-2" />
|
||||||
|
<p class="text-gray-500">No jobs match your current filters in <strong class="text-gray-700">{{selectedQueueName === 'all' ? 'any queue' : selectedQueueName}}</strong>.</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">Try adjusting your search or filters.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-10">
|
||||||
|
<Icon name="mdi:playlist-remove" size="48" class="text-gray-400 mx-auto mb-2" />
|
||||||
|
<p class="text-gray-500">No jobs found in <strong class="text-gray-700">{{selectedQueueName === 'all' ? 'any queue' : selectedQueueName}}</strong>.</p>
|
||||||
|
<p v-if="selectedQueueName !== 'all'" class="text-sm text-gray-400 mt-1">This queue is currently empty.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer v-if="totalPages > 1">
|
||||||
|
<div class="px-4 py-3 border-t bg-gray-50 flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0">
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
Showing {{ paginatedJobs.length }} of {{ filteredAndSortedJobs.length }} jobs (Page {{ currentPage }} of {{ totalPages }})
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-1">
|
||||||
|
<rs-button size="xs" variant="secondary" :disabled="currentPage <= 1" @click="currentPage = 1">
|
||||||
|
<Icon name="mdi:page-first" size="16" />
|
||||||
|
</rs-button>
|
||||||
|
<rs-button size="xs" variant="secondary" :disabled="currentPage <= 1" @click="currentPage--">
|
||||||
|
<Icon name="mdi:chevron-left" size="16" /> Previous
|
||||||
|
</rs-button>
|
||||||
|
<rs-button size="xs" variant="secondary" :disabled="currentPage >= totalPages" @click="currentPage++">
|
||||||
|
Next <Icon name="mdi:chevron-right" size="16" />
|
||||||
|
</rs-button>
|
||||||
|
<rs-button size="xs" variant="secondary" :disabled="currentPage >= totalPages" @click="currentPage = totalPages">
|
||||||
|
<Icon name="mdi:page-last" size="16" />
|
||||||
|
</rs-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</rs-card>
|
||||||
|
|
||||||
|
<div v-else-if="!selectedQueueName && !isLoading" class="text-center py-12">
|
||||||
|
<Icon name="mdi:information-outline" size="48" class="text-gray-400 mx-auto mb-2" />
|
||||||
|
<p class="text-gray-500">Please select a queue from the dropdown above to view its jobs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<JobDetailModal v-if="selectedJob" :job="selectedJob" :show="!!selectedJob" @close="selectedJob = null" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Add any page-specific styles if needed - Tailwind should cover most */
|
||||||
|
.text-primary {
|
||||||
|
color: #3B82F6; /* Example primary color from your design spec */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for pre tag if needed */
|
||||||
|
pre::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
pre::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1; /* cool-gray-300 */
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
pre::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8; /* cool-gray-400 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging-queue {
|
||||||
|
opacity: 0.5;
|
||||||
|
border: 2px dashed #3B82F6; /* primary color for border */
|
||||||
|
transform: scale(0.95);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-lg */
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-drop-target-active {
|
||||||
|
/* Basic style for when an item is dragged over a potential drop target */
|
||||||
|
outline: 2px dashed #10B981; /* success color */
|
||||||
|
outline-offset: 2px;
|
||||||
|
background-color: #f0fdf4; /* Light green background, Tailwind green-50 */
|
||||||
|
transform: scale(1.02); /* Slightly enlarge the drop target */
|
||||||
|
transition: transform 0.2s ease-out, background-color 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced styling for the specific drop target queue card */
|
||||||
|
.queue-card-draggable.queue-drop-target-active {
|
||||||
|
border-color: #10B981 !important; /* Ensure border color overrides others */
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-card-draggable {
|
||||||
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
50
server/api/queue/asnaf-analysis/index.get.js
Normal file
50
server/api/queue/asnaf-analysis/index.get.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// import Queue from 'bull';
|
||||||
|
|
||||||
|
// // Create a Bull queue instance
|
||||||
|
// const asnafAnalysisQueue = new Queue('asnaf-analysis', {
|
||||||
|
// redis: {
|
||||||
|
// host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
// port: process.env.REDIS_PORT || 6379,
|
||||||
|
// password: process.env.REDIS_PASSWORD
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// // Get all jobs from different states
|
||||||
|
// const [waiting, active, completed, failed] = await Promise.all([
|
||||||
|
// asnafAnalysisQueue.getWaiting(),
|
||||||
|
// asnafAnalysisQueue.getActive(),
|
||||||
|
// asnafAnalysisQueue.getCompleted(),
|
||||||
|
// asnafAnalysisQueue.getFailed()
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// // Combine all jobs and format them
|
||||||
|
// const allJobs = [...waiting, ...active, ...completed, ...failed]
|
||||||
|
// .map(job => ({
|
||||||
|
// id: job.id,
|
||||||
|
// status: job.finishedOn ? 'completed' :
|
||||||
|
// job.failedReason ? 'failed' :
|
||||||
|
// job.processedOn ? 'active' : 'waiting',
|
||||||
|
// data: job.data,
|
||||||
|
// timestamp: job.timestamp,
|
||||||
|
// processedOn: job.processedOn,
|
||||||
|
// finishedOn: job.finishedOn,
|
||||||
|
// failedReason: job.failedReason
|
||||||
|
// }))
|
||||||
|
// .sort((a, b) => b.timestamp - a.timestamp); // Sort by timestamp, newest first
|
||||||
|
|
||||||
|
// return allJobs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Queue data fetched successfully',
|
||||||
|
data: []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching queue data:', error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to fetch queue data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user