Compare commits

...

2 Commits

9 changed files with 1241 additions and 108 deletions

View File

@ -12,16 +12,49 @@ const refreshPage = () => {
window.location.reload(true);
};
// Fast loading logo - fetch during SSR to prevent hydration flash
const { data: quickLoadingData } = await useLazyFetch("/api/devtool/config/loading-logo", {
default: () => ({
data: {
siteLoadingLogo: '',
siteName: 'Loading...'
}
}),
transform: (response) => response.data || {
siteLoadingLogo: '',
siteName: 'Loading...'
}
});
const loadingLogoSrc = computed(() => {
return 'http://localhost:3003/uploads/site-settings/loading-logo.png';
// First priority: Quick loading data if available
if (quickLoadingData.value?.siteLoadingLogo) {
return quickLoadingData.value.siteLoadingLogo;
}
// Second priority: Full site settings if loaded
if (!siteSettingsLoading.value && siteSettings.value.siteLoadingLogo) {
return siteSettings.value.siteLoadingLogo;
}
// Fallback: Default logo
return '/img/logo/corradAF-logo.svg';
});
// Get site name with fallback
const getSiteName = () => {
if (siteSettingsLoading.value) {
return 'Loading Logo';
// First priority: Quick loading data
if (quickLoadingData.value?.siteName) {
return quickLoadingData.value.siteName;
}
return siteSettings.value?.siteName || 'Loading Logo';
// Second priority: Full site settings
if (!siteSettingsLoading.value && siteSettings.value.siteName) {
return siteSettings.value.siteName;
}
// Fallback
return 'Loading...';
};
</script>

View File

@ -4,12 +4,12 @@ export default [
description: "",
child: [
{
title: "Dashboard",
path: "/dashboard",
icon: "ic:outline-dashboard",
child: [],
meta: {},
},
"title": "Dashboard",
"path": "/dashboard",
"icon": "ic:outline-dashboard",
"child": [],
"meta": {}
}
],
meta: {},
},

View File

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

View File

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

View File

@ -5,7 +5,7 @@ definePageMeta({
requiresAuth: true,
});
const { $swal, $toast } = useNuxtApp();
const { $swal } = useNuxtApp();
const { siteSettings, updateSiteSettings, applyThemeSettings, updateGlobalMeta } = useSiteSettings();
// Reactive data
@ -139,7 +139,7 @@ const loadSettings = async () => {
}
} catch (error) {
console.error("Error loading settings:", error);
$toast.error("Failed to load site settings");
alert("Failed to load site settings");
} finally {
loading.value = false;
}
@ -148,7 +148,7 @@ const loadSettings = async () => {
// Save settings
const saveSettings = async () => {
if (!validateForm()) {
$toast.error("Please fix the validation errors");
alert("Please fix the validation errors");
return;
}
@ -160,7 +160,7 @@ const saveSettings = async () => {
if (result && result.success) {
originalSettings.value = { ...settings.value };
$toast.success("Settings saved successfully");
alert("Settings saved successfully");
// Apply changes
// applyChanges(); // Temporarily commented out to isolate the error source
@ -175,7 +175,7 @@ const saveSettings = async () => {
console.error("[SiteSettingsPage] 'result' from updateSiteSettings was undefined.");
}
$toast.error(errorMsg);
alert(errorMsg);
if (result && result.error && result.error.details) {
console.error("[SiteSettingsPage] Save settings error details:", result.error.details);
@ -189,7 +189,7 @@ const saveSettings = async () => {
// This catch block is for unexpected errors during the saveSettings execution itself,
// or if updateSiteSettings somehow re-throws an error not caught by its own try-catch.
console.error("Critical error saving settings:", error);
$toast.error("A critical error occurred. Failed to save settings.");
alert("A critical error occurred. Failed to save settings.");
} finally {
saving.value = false;
}
@ -220,7 +220,7 @@ const applyFontFromSource = () => {
}
}
$toast.success('Font applied successfully');
alert('Font applied successfully');
}
};
@ -241,7 +241,7 @@ const uploadFile = async (file, type) => {
return response.data.url;
} catch (error) {
console.error(`Error uploading ${type}:`, error);
$toast.error(`Failed to upload ${type}`);
alert(`Failed to upload ${type}`);
return null;
}
};
@ -253,7 +253,7 @@ const handleLogoUpload = async (event) => {
const url = await uploadFile(file, 'logo');
if (url) {
settings.value.siteLogo = url;
$toast.success('Logo uploaded successfully');
alert('Logo uploaded successfully');
}
}
};
@ -264,7 +264,7 @@ const handleLoadingLogoUpload = async (event) => {
const url = await uploadFile(file, 'loading-logo');
if (url) {
settings.value.siteLoadingLogo = url;
$toast.success('Loading logo uploaded successfully');
alert('Loading logo uploaded successfully');
}
}
};
@ -275,7 +275,7 @@ const handleFaviconUpload = async (event) => {
const url = await uploadFile(file, 'favicon');
if (url) {
settings.value.siteFavicon = url;
$toast.success('Favicon uploaded successfully');
alert('Favicon uploaded successfully');
}
}
};
@ -286,7 +286,7 @@ const handleLoginLogoUpload = async (event) => {
const url = await uploadFile(file, 'login-logo');
if (url) {
settings.value.siteLoginLogo = url;
$toast.success('Login logo uploaded successfully');
alert('Login logo uploaded successfully');
}
}
};
@ -295,14 +295,14 @@ const handleCSSUpload = async (event) => {
const file = event.target.files[0];
if (file) {
if (!file.name.endsWith('.css')) {
$toast.error('Please upload a valid CSS file');
alert('Please upload a valid CSS file');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
settings.value.customCSS = e.target.result;
$toast.success('CSS file loaded successfully');
alert('CSS file loaded successfully');
};
reader.readAsText(file);
}
@ -314,7 +314,7 @@ const handleOgImageUpload = async (event) => {
const url = await uploadFile(file, 'og-image');
if (url) {
settings.value.seoOgImage = url;
$toast.success('OG image uploaded successfully');
alert('OG image uploaded successfully');
}
}
};
@ -332,7 +332,7 @@ const resetSettings = () => {
settings.value = { ...originalSettings.value };
errors.value = {};
applyChanges();
$toast.info('Settings reset to last saved state');
alert('Settings reset to last saved state');
};
// Check for changes
@ -364,7 +364,7 @@ const applyGoogleFont = (font) => {
settings.value.fontSource = googleFontUrl;
settings.value.currentFont = font.name;
applyFontFromSource();
$toast.success(`${font.name} font applied successfully`);
alert(`${font.name} font applied successfully`);
// Reset the dropdown after selection
selectedGoogleFont.value = '';
}
@ -451,7 +451,7 @@ watch(() => settings.value.showSiteNameInHeader, (newValue) => {
<div v-else>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-8">
<nav class="flex space-x-8" role="tablist">
<nav class="flex space-x-4" role="tablist">
<button
v-for="tab in [
{ id: 'basic', name: 'Basic', icon: 'ic:outline-info' },
@ -466,7 +466,7 @@ watch(() => settings.value.showSiteNameInHeader, (newValue) => {
activeTab === tab.id
? 'border-primary text-primary bg-primary/5'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'whitespace-nowrap py-3 px-3 border-b-2 font-medium text-sm flex items-center space-x-2 rounded-t-lg transition-all duration-200'
'whitespace-nowrap py-2 px-2 border-b-2 font-medium text-sm flex items-center space-x-2 rounded-t-lg transition-all duration-200'
]"
:aria-selected="activeTab === tab.id"
role="tab"

View File

@ -1,49 +0,0 @@
<template>
<div>
<LayoutsBreadcrumb />
<section class="flex flex-col h-screen">
<div class="mb-4 flex-shrink-0">
<h3>Metabase</h3>
<p>
Metabase is a powerful data visualization and analytics tool that allows you to
create and share dashboards, reports, and visualizations with your team.
</p>
</div>
<div v-if="pending" class="flex justify-center items-center flex-1">
<div class="text-lg">Loading Metabase dashboard...</div>
</div>
<div v-else-if="error" class="flex justify-center items-center flex-1">
<div class="text-red-500">Error loading dashboard: {{ error.message }}</div>
</div>
<iframe
v-else
:src="iframeUrl"
frameborder="0"
width="100%"
class="flex-1"
allowtransparency
/>
</section>
</div>
</template>
<script setup>
// Fetch the JWT token from our server API
const { data: tokenData, pending, error } = await useFetch("/api/metabase/token");
const iframeUrl = computed(() => {
if (tokenData.value?.token && tokenData.value?.siteUrl) {
return (
tokenData.value.siteUrl +
"/embed/dashboard/" +
tokenData.value.token +
"#bordered=true&titled=true"
);
}
return "";
});
</script>
<style lang="scss" scoped></style>

View File

@ -1,30 +0,0 @@
<script setup>
definePageMeta({
title: "Notes",
middleware: ["auth"],
requiresAuth: true,
});
</script>
<template>
<div>
<LayoutsBreadcrumb />
<rs-card>
<template #header>
<div>
Notes
</div>
</template>
<template #body>
<div>
Content for Notes
</div>
</template>
</rs-card>
</div>
</template>
<style scoped>
/* Add your styles here */
</style>

View File

@ -0,0 +1,124 @@
import { defineEventHandler, readBody } from 'h3';
// Define an interface for the expected request body (subset of AsnafProfile)
interface AsnafAnalysisRequest {
monthlyIncome: string;
otherIncome: string;
totalIncome: string;
occupation: string;
maritalStatus: string;
dependents: Array<any>; // Or a more specific type if you have one for dependents
// Add any other fields you deem necessary for OpenAI to analyze
}
interface AidSuggestion {
nama: string;
peratusan: string;
}
// Define an interface for the expected OpenAI response structure (and our API response)
interface AsnafAnalysisResponse {
hadKifayahPercentage: string;
kategoriAsnaf: string;
kategoriKeluarga: string;
cadanganKategori: string;
statusKelayakan: string;
cadanganBantuan: AidSuggestion[];
ramalanJangkaMasaPulih: string;
rumusan: string;
}
export default defineEventHandler(async (event): Promise<AsnafAnalysisResponse> => {
const body = await readBody<AsnafAnalysisRequest>(event);
// --- Placeholder for Actual OpenAI API Call ---
// In a real application, you would:
// 1. Retrieve your OpenAI API key securely (e.g., from environment variables)
const openAIApiKey = process.env.OPENAI_API_KEY;
if (!openAIApiKey) {
console.error('OpenAI API key not configured. Please set OPENAI_API_KEY in your .env file.');
throw createError({ statusCode: 500, statusMessage: 'OpenAI API key not configured' });
}
// 2. Construct the prompt for OpenAI using the data from `body`.
// IMPORTANT: Sanitize or carefully construct any data from `body` included in the prompt to prevent prompt injection.
const prompt = `You are an expert Zakat administrator. Based on the following applicant data: monthlyIncome: ${body.monthlyIncome}, totalIncome: ${body.totalIncome}, occupation: ${body.occupation}, maritalStatus: ${body.maritalStatus}, dependents: ${body.dependents.length}.
Return JSON with keys: hadKifayahPercentage, kategoriAsnaf, kategoriKeluarga, cadanganKategori, statusKelayakan, cadanganBantuan, ramalanJangkaMasaPulih, rumusan.
For 'cadanganBantuan', provide a JSON array of objects, where each object has a 'nama' (string, name of the aid) and 'peratusan' (string, e.g., '85%', representing suitability). Suggest 2-3 most relevant aid types.
Example for cadanganBantuan: [{"nama": "Bantuan Kewangan Bulanan", "peratusan": "90%"}, {"nama": "Bantuan Makanan Asas", "peratusan": "75%"}].
Full JSON Example: {"hadKifayahPercentage": "75%", ..., "cadanganBantuan": [{"nama": "Bantuan Kewangan Bulanan", "peratusan": "90%"}], ...}`;
// Adjust the prompt to be more detailed and specific to your needs and desired JSON output structure.
// 3. Make the API call to OpenAI
try {
const openAIResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${openAIApiKey}`,
},
body: JSON.stringify({
model: 'gpt-3.5-turbo', // Or your preferred model like gpt-4
messages: [{ role: 'user', content: prompt }],
// For more consistent JSON output, consider using a model version that officially supports JSON mode if available
// and set response_format: { type: "json_object" }, (check OpenAI documentation for model compatibility)
}),
});
if (!openAIResponse.ok) {
const errorData = await openAIResponse.text();
console.error('OpenAI API Error details:', errorData);
throw createError({ statusCode: openAIResponse.status, statusMessage: `Failed to get analysis from OpenAI: ${openAIResponse.statusText}` });
}
const openAIData = await openAIResponse.json();
// Parse the content from the response - structure might vary slightly based on OpenAI model/API version
// It's common for the JSON string to be in openAIData.choices[0].message.content
if (openAIData.choices && openAIData.choices[0] && openAIData.choices[0].message && openAIData.choices[0].message.content) {
const analysisResult = JSON.parse(openAIData.choices[0].message.content) as AsnafAnalysisResponse;
return analysisResult;
} else {
console.error('OpenAI response structure not as expected:', openAIData);
throw createError({ statusCode: 500, statusMessage: 'Unexpected response structure from OpenAI' });
}
} catch (error) {
console.error('Error during OpenAI API call or parsing:', error);
// Avoid exposing detailed internal errors to the client if they are not createError objects
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
// We can infer error has statusCode here, but to be super safe with TS:
const e = error as { statusCode: number };
if (e.statusCode) throw e;
}
throw createError({ statusCode: 500, statusMessage: 'Internal server error during AI analysis' });
}
// --- End of Actual OpenAI API Call ---
// The simulated response below this line should be REMOVED once the actual OpenAI call is implemented and working.
/*
console.log('Received for analysis in server route:', body);
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API delay
const totalIncomeNumeric = parseFloat(body.totalIncome);
let percentage = '50%';
if (totalIncomeNumeric < 1000) percentage = '30%';
else if (totalIncomeNumeric < 2000) percentage = '65%';
else if (totalIncomeNumeric < 3000) percentage = '85%';
else percentage = '110%';
return {
hadKifayahPercentage: percentage,
kategoriAsnaf: 'Simulated - Miskin',
kategoriKeluarga: 'Simulated - Miskin (50-100% HK)',
cadanganKategori: 'Simulated - Miskin',
statusKelayakan: 'Simulated - Layak (Miskin)',
cadanganBantuan: [
{ nama: 'Simulated - Bantuan Kewangan Bulanan', peratusan: '80%' },
{ nama: 'Simulated - Bantuan Pendidikan Anak', peratusan: '65%' }
],
ramalanJangkaMasaPulih: 'Simulated - 6 bulan',
rumusan: 'Simulated - Pemohon memerlukan perhatian segera.'
};
*/
});

View File

@ -0,0 +1,44 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
const method = getMethod(event);
try {
if (method === "GET") {
// Get only the loading logo and site name for faster loading
const settings = await prisma.site_settings.findFirst({
select: {
siteLoadingLogo: true,
siteName: true,
},
orderBy: { settingID: "desc" },
});
return {
statusCode: 200,
message: "Success",
data: {
siteLoadingLogo: settings?.siteLoadingLogo || '',
siteName: settings?.siteName || 'corradAF',
},
};
}
return {
statusCode: 405,
message: "Method not allowed",
};
} catch (error) {
console.error("Loading logo API error:", error);
return {
statusCode: 500,
message: "Internal server error",
error: error.message,
};
} finally {
await prisma.$disconnect();
}
});