DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -0,0 +1,935 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Pencil } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertCloseTrigger,
|
||||
} from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
completeMembershipApplication,
|
||||
downloadMembershipApplicationDocument,
|
||||
fetchMembershipApplicationDocument,
|
||||
getMembershipApplication,
|
||||
submitBoardReview,
|
||||
submitManagementReview,
|
||||
} from '../services/membership-application.service'
|
||||
import type {
|
||||
BoardReviewDecision,
|
||||
ManagementReviewDecision,
|
||||
MembershipApplicationDetail,
|
||||
MembershipApplicationDocumentDetail,
|
||||
MembershipApplicationReferenceDetail,
|
||||
MembershipApplicationReviewDetail,
|
||||
MembershipApplicationStatus,
|
||||
} from '../types/membership-application.types'
|
||||
|
||||
const WORKFLOW_STEPS = [
|
||||
{ id: 1, label: 'Dihantar' },
|
||||
{ id: 2, label: 'Semakan Pentadbiran' },
|
||||
{ id: 3, label: 'Semakan Lembaga' },
|
||||
{ id: 4, label: 'Makluman Keputusan' },
|
||||
{ id: 5, label: 'Selesai' },
|
||||
] as const
|
||||
|
||||
const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
ic_copy: 'Salinan Kad Pengenalan',
|
||||
photo: 'Gambar Passport',
|
||||
salary_slip: 'Slip Gaji',
|
||||
employer_letter: 'Surat Pengesahan Majikan',
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const applicationId = computed(() => String(route.params.id ?? ''))
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
const application = ref<MembershipApplicationDetail | null>(null)
|
||||
const managementRemarks = ref('')
|
||||
const boardRemarks = ref('')
|
||||
const reviewSubmitting = ref(false)
|
||||
const completeSubmitting = ref(false)
|
||||
const confirmDialogOpen = ref(false)
|
||||
const pendingAction = ref<
|
||||
| { type: 'management'; decision: ManagementReviewDecision }
|
||||
| { type: 'board'; decision: BoardReviewDecision }
|
||||
| { type: 'complete' }
|
||||
| null
|
||||
>(null)
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
}
|
||||
|
||||
return labels[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: MembershipApplicationStatus) {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'MANAGEMENT_REJECTED') return 'danger'
|
||||
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function getWorkflowProgress(status: MembershipApplicationStatus) {
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
return { currentStep: 2, failed: false, failedStep: null as number | null }
|
||||
case 'MANAGEMENT_REJECTED':
|
||||
return { currentStep: 2, failed: true, failedStep: 2 }
|
||||
case 'PENDING_BOARD':
|
||||
return { currentStep: 3, failed: false, failedStep: null }
|
||||
case 'PENDING_NOTIFICATION':
|
||||
return { currentStep: 4, failed: false, failedStep: null }
|
||||
case 'COMPLETED':
|
||||
return { currentStep: 6, failed: false, failedStep: null }
|
||||
default:
|
||||
return { currentStep: 1, failed: false, failedStep: null }
|
||||
}
|
||||
}
|
||||
|
||||
const workflowProgress = computed(() =>
|
||||
application.value ? getWorkflowProgress(application.value.status) : null,
|
||||
)
|
||||
|
||||
const showManagementReview = computed(
|
||||
() =>
|
||||
hasPermission('semak permohonan keahlian pentadbiran') &&
|
||||
application.value?.status === 'SUBMITTED',
|
||||
)
|
||||
|
||||
const showBoardReview = computed(
|
||||
() =>
|
||||
hasPermission('semak permohonan keahlian lembaga') &&
|
||||
application.value?.status === 'PENDING_BOARD',
|
||||
)
|
||||
|
||||
const showCompleteAction = computed(
|
||||
() =>
|
||||
hasPermission('selesaikan permohonan keahlian') &&
|
||||
application.value?.status === 'PENDING_NOTIFICATION',
|
||||
)
|
||||
|
||||
const confirmDialogTitle = computed(() => {
|
||||
if (!pendingAction.value) return 'Sahkan Tindakan'
|
||||
|
||||
if (pendingAction.value.type === 'management') {
|
||||
return pendingAction.value.decision === 'APPROVED'
|
||||
? 'Luluskan Permohonan?'
|
||||
: 'Tolak Permohonan?'
|
||||
}
|
||||
|
||||
if (pendingAction.value.type === 'board') {
|
||||
return pendingAction.value.decision === 'PASS'
|
||||
? 'Luluskan Semakan Lembaga?'
|
||||
: 'Gagalkan Semakan Lembaga?'
|
||||
}
|
||||
|
||||
return 'Selesaikan Permohonan?'
|
||||
})
|
||||
|
||||
const confirmDialogDescription = computed(() => {
|
||||
if (!pendingAction.value) return ''
|
||||
|
||||
if (pendingAction.value.type === 'management') {
|
||||
return pendingAction.value.decision === 'APPROVED'
|
||||
? 'Permohonan akan dihantar ke semakan lembaga.'
|
||||
: 'Permohonan akan ditolak pada peringkat pentadbiran.'
|
||||
}
|
||||
|
||||
if (pendingAction.value.type === 'board') {
|
||||
return pendingAction.value.decision === 'PASS'
|
||||
? 'Permohonan akan dihantar ke peringkat makluman keputusan.'
|
||||
: 'Permohonan akan ditandakan gagal semakan lembaga.'
|
||||
}
|
||||
|
||||
return 'E-mel keputusan akan dihantar kepada pemohon. Akaun ahli akan dicipta jika permohonan lulus.'
|
||||
})
|
||||
|
||||
function workflowStepButtonClass(stepId: number) {
|
||||
const progress = workflowProgress.value
|
||||
if (!progress) return 'mx-2 size-12 rounded-full shadow-none bg-background border border-foreground/15'
|
||||
|
||||
if (progress.failed && stepId === progress.failedStep) {
|
||||
return 'mx-2 size-12 rounded-full shadow-none bg-danger text-danger-foreground'
|
||||
}
|
||||
|
||||
if (stepId < progress.currentStep) {
|
||||
return 'mx-2 size-12 rounded-full shadow-none bg-primary text-primary-foreground'
|
||||
}
|
||||
|
||||
if (stepId === progress.currentStep && application.value?.status !== 'COMPLETED') {
|
||||
return 'mx-2 size-12 rounded-full shadow-none'
|
||||
}
|
||||
|
||||
if (application.value?.status === 'COMPLETED' || stepId < progress.currentStep) {
|
||||
return 'mx-2 size-12 rounded-full shadow-none bg-primary text-primary-foreground'
|
||||
}
|
||||
|
||||
return 'bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full'
|
||||
}
|
||||
|
||||
function workflowStepLabelClass(stepId: number) {
|
||||
const progress = workflowProgress.value
|
||||
const isCurrent =
|
||||
progress &&
|
||||
stepId === progress.currentStep &&
|
||||
application.value?.status !== 'COMPLETED' &&
|
||||
!progress.failed
|
||||
|
||||
return isCurrent
|
||||
? 'text-primary ml-3 font-medium opacity-100 lg:mx-auto lg:mt-3 lg:w-32'
|
||||
: 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32'
|
||||
}
|
||||
|
||||
const applicant = computed(() => application.value?.applicant ?? null)
|
||||
|
||||
const sortedReviews = computed(() => {
|
||||
if (!application.value?.reviews.length) return []
|
||||
|
||||
return [...application.value.reviews].sort((left, right) => {
|
||||
const leftTime = left.reviewed_at ? new Date(left.reviewed_at).getTime() : 0
|
||||
const rightTime = right.reviewed_at ? new Date(right.reviewed_at).getTime() : 0
|
||||
return leftTime - rightTime
|
||||
})
|
||||
})
|
||||
|
||||
function displayValue(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD/MM/YYYY')
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD/MM/YYYY HH:mm')
|
||||
}
|
||||
|
||||
function formatCurrency(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
const amount = Number(value)
|
||||
if (Number.isNaN(amount)) return String(value)
|
||||
return `RM ${amount.toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function documentLabel(type: string, name: string): string {
|
||||
return DOCUMENT_TYPE_LABELS[type] ?? name
|
||||
}
|
||||
|
||||
function isImageMimeType(mimeType: string | null | undefined): boolean {
|
||||
return !!mimeType?.startsWith('image/')
|
||||
}
|
||||
|
||||
function isPdfDocument(document: MembershipApplicationDocumentDetail): boolean {
|
||||
return document.mime_type === 'application/pdf' || document.name.toLowerCase().endsWith('.pdf')
|
||||
}
|
||||
|
||||
const isPreviewImage = computed(() => isImageMimeType(previewDocument.value?.mime_type))
|
||||
const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previewDocument.value) : false))
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (previewUrl.value) {
|
||||
window.URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewOpenChange(open: boolean) {
|
||||
previewOpen.value = open
|
||||
|
||||
if (!open) {
|
||||
revokePreviewUrl()
|
||||
previewDocument.value = null
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getReference(type: 'PROPOSER' | 'SUPPORTER'): MembershipApplicationReferenceDetail | null {
|
||||
return application.value?.references.find((reference) => reference.reference_type === type) ?? null
|
||||
}
|
||||
|
||||
function reviewStageLabel(stage: string): string {
|
||||
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
|
||||
if (stage === 'BOARD') return 'Semakan Lembaga'
|
||||
return stage
|
||||
}
|
||||
|
||||
function reviewDecisionLabel(decision: string | null, stage: string): string {
|
||||
if (!decision) return '-'
|
||||
|
||||
if (stage === 'MANAGEMENT') {
|
||||
if (decision === 'APPROVED') return 'Diluluskan'
|
||||
if (decision === 'REJECTED') return 'Ditolak'
|
||||
}
|
||||
|
||||
if (stage === 'BOARD') {
|
||||
if (decision === 'PASS') return 'Lulus'
|
||||
if (decision === 'FAIL') return 'Gagal'
|
||||
}
|
||||
|
||||
return decision
|
||||
}
|
||||
|
||||
function reviewDecisionBadgeVariant(decision: string | null, stage: string) {
|
||||
if (stage === 'MANAGEMENT') {
|
||||
if (decision === 'APPROVED') return 'success'
|
||||
if (decision === 'REJECTED') return 'danger'
|
||||
}
|
||||
|
||||
if (stage === 'BOARD') {
|
||||
if (decision === 'PASS') return 'success'
|
||||
if (decision === 'FAIL') return 'danger'
|
||||
}
|
||||
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function reviewTimelineDotClass(review: MembershipApplicationReviewDetail): string {
|
||||
const variant = reviewDecisionBadgeVariant(review.decision, review.stage)
|
||||
|
||||
if (variant === 'success') return 'border-success bg-success'
|
||||
if (variant === 'danger') return 'border-danger bg-danger'
|
||||
return 'border-primary bg-primary'
|
||||
}
|
||||
|
||||
async function fetchApplication() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await getMembershipApplication(applicationId.value)
|
||||
application.value = response.data
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan butiran permohonan.')
|
||||
application.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openConfirmAction(
|
||||
action:
|
||||
| { type: 'management'; decision: ManagementReviewDecision }
|
||||
| { type: 'board'; decision: BoardReviewDecision }
|
||||
| { type: 'complete' },
|
||||
) {
|
||||
pendingAction.value = action
|
||||
confirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeConfirmDialog() {
|
||||
confirmDialogOpen.value = false
|
||||
pendingAction.value = null
|
||||
}
|
||||
|
||||
async function confirmPendingAction() {
|
||||
if (!pendingAction.value || !application.value) return
|
||||
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
if (pendingAction.value.type === 'complete') {
|
||||
completeSubmitting.value = true
|
||||
} else {
|
||||
reviewSubmitting.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
let response
|
||||
|
||||
if (pendingAction.value.type === 'management') {
|
||||
response = await submitManagementReview(applicationId.value, {
|
||||
decision: pendingAction.value.decision,
|
||||
remarks: managementRemarks.value.trim() || null,
|
||||
})
|
||||
managementRemarks.value = ''
|
||||
} else if (pendingAction.value.type === 'board') {
|
||||
response = await submitBoardReview(applicationId.value, {
|
||||
decision: pendingAction.value.decision,
|
||||
remarks: boardRemarks.value.trim() || null,
|
||||
})
|
||||
boardRemarks.value = ''
|
||||
} else {
|
||||
response = await completeMembershipApplication(applicationId.value)
|
||||
}
|
||||
|
||||
application.value = response.data
|
||||
successMessage.value = response.message
|
||||
closeConfirmDialog()
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memproses tindakan.')
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors?.remarks?.[0]) {
|
||||
error.value = validationErrors.remarks[0]
|
||||
}
|
||||
} finally {
|
||||
reviewSubmitting.value = false
|
||||
completeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || downloadingDocumentId.value) return
|
||||
|
||||
downloadingDocumentId.value = document.id
|
||||
|
||||
try {
|
||||
await downloadMembershipApplicationDocument(
|
||||
application.value.id,
|
||||
document.id,
|
||||
document.name,
|
||||
document.mime_type,
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat turun dokumen.')
|
||||
} finally {
|
||||
downloadingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value) return
|
||||
|
||||
previewDocument.value = document
|
||||
previewOpen.value = true
|
||||
previewLoading.value = true
|
||||
revokePreviewUrl()
|
||||
|
||||
try {
|
||||
const blob = await fetchMembershipApplicationDocument(
|
||||
application.value.id,
|
||||
document.id,
|
||||
document.mime_type,
|
||||
)
|
||||
previewUrl.value = window.URL.createObjectURL(blob)
|
||||
} catch (err) {
|
||||
handlePreviewOpenChange(false)
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan dokumen.')
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchApplication()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokePreviewUrl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="mr-auto">
|
||||
<h2 class="text-lg font-medium">Butiran Permohonan Keahlian</h2>
|
||||
<p v-if="application" class="mt-1 text-sm opacity-70">
|
||||
{{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<Button look="outline" variant="secondary" type="button"
|
||||
@click="router.push({ name: 'list-membership-applications' })">
|
||||
Kembali
|
||||
</Button>
|
||||
<Button v-if="hasPermission('kemaskini permohonan keahlian') && application?.status !== 'COMPLETED'" type="button"
|
||||
@click="router.push({ name: 'edit-membership-application', params: { id: applicationId } })">
|
||||
<Pencil class="mr-2 size-4" />
|
||||
Kemaskini
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="successMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="successMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="error" variant="danger">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Ralat</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="error = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<div v-if="loading" class="opacity-70">Memuatkan butiran permohonan...</div>
|
||||
|
||||
<template v-else-if="application">
|
||||
<Box class="p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm opacity-70">No. Permohonan</div>
|
||||
<div class="text-xl font-semibold">{{ application.application_number }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge :variant="statusBadgeVariant(application.status)" class="whitespace-nowrap">
|
||||
{{ statusLabel(application.status) }}
|
||||
</Badge>
|
||||
<Badge v-if="application.board_result"
|
||||
:variant="application.board_result === 'PASS' ? 'success' : 'danger'">
|
||||
Lembaga: {{ application.board_result === 'PASS' ? 'Lulus' : 'Gagal' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Hantar:</span>
|
||||
{{ formatDateTime(application.submitted_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Tarikh Selesai:</span>
|
||||
{{ formatDateTime(application.completed_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">Emel Pemohon:</span>
|
||||
<span class="lowercase">{{ displayValue(application.applicant?.email) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">No. IC:</span>
|
||||
{{ displayValue(application.applicant?.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box class="py-8 sm:py-10">
|
||||
<div class="px-5 sm:px-8">
|
||||
<div class="text-sm font-medium opacity-70">Status Permohonan</div>
|
||||
</div>
|
||||
<div
|
||||
class="before:bg-foreground/10 relative mt-4 flex flex-col justify-center px-5 before:absolute before:bottom-0 before:top-0 before:mt-6 before:hidden before:h-0.5 before:w-[69%] sm:px-8 lg:flex-row before:lg:block">
|
||||
<div v-for="step in WORKFLOW_STEPS" :key="step.id"
|
||||
class="z-10 flex flex-1 items-center lg:block lg:text-center">
|
||||
<Button type="button" :class="workflowStepButtonClass(step.id)"
|
||||
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'default' : 'ghost'"
|
||||
disabled>
|
||||
{{ step.id }}
|
||||
</Button>
|
||||
<div :class="workflowStepLabelClass(step.id)">
|
||||
{{ step.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="showManagementReview" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Semakan Pentadbiran</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Luluskan atau tolak permohonan pada peringkat pentadbiran.
|
||||
</p>
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="management-remarks">Catatan</FieldLabel>
|
||||
<Textarea id="management-remarks" v-model="managementRemarks" rows="3" placeholder="Catatan semakan (pilihan)"
|
||||
:disabled="reviewSubmitting" />
|
||||
</Field>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'management', decision: 'APPROVED' })">
|
||||
Luluskan
|
||||
</Button>
|
||||
<Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'management', decision: 'REJECTED' })">
|
||||
Tolak
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="showBoardReview" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Semakan Lembaga</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Luluskan atau gagalkan permohonan pada peringkat lembaga.
|
||||
</p>
|
||||
<Field class="mt-4">
|
||||
<FieldLabel for="board-remarks">Catatan</FieldLabel>
|
||||
<Textarea id="board-remarks" v-model="boardRemarks" rows="3" placeholder="Catatan semakan (pilihan)"
|
||||
:disabled="reviewSubmitting" />
|
||||
</Field>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'board', decision: 'PASS' })">
|
||||
Lulus
|
||||
</Button>
|
||||
<Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'board', decision: 'FAIL' })">
|
||||
Gagal
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="showCompleteAction" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Makluman Keputusan</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Hantar e-mel keputusan kepada pemohon
|
||||
<span v-if="application.board_result === 'PASS'"> dan cipta akaun ahli</span>.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="primary" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="openConfirmAction({ type: 'complete' })">
|
||||
Selesaikan & Hantar Makluman
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<TabsRoot defaultValue="personal" class="w-full">
|
||||
<Box raised="single" class="w-full p-0">
|
||||
<div class="w-full px-5 py-4">
|
||||
<TabsList class="mb-0 flex w-full">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="personal">
|
||||
Maklumat Peribadi
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="contact">
|
||||
Hubungan & Alamat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="employment">
|
||||
Pekerjaan & Caruman
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="heirs">
|
||||
Waris
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="references">
|
||||
Pencadang & Penyokong
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="documents">
|
||||
Dokumen
|
||||
</TabsTrigger>
|
||||
<TabsTrigger v-if="application.reviews.length"
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="reviews">
|
||||
Sejarah Semakan
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<TabsContent value="personal" class="mt-6">
|
||||
<div v-if="!applicant" class="opacity-70">Tiada maklumat pemohon.</div>
|
||||
<div v-else class="grid grid-cols-12 gap-4 gap-y-5">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Nama Penuh</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.name)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Emel</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.email)" type="text" disabled class="lowercase" />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>No. Kad Pengenalan</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.ic_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Tarikh Lahir</FieldLabel>
|
||||
<Input :model-value="formatDate(applicant.birth_date)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Tempat Lahir</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.birth_place)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Jantina</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.gender)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Status Perkahwinan</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.marriage_status)" type="text" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contact" class="mt-6">
|
||||
<div v-if="!applicant" class="opacity-70">Tiada maklumat pemohon.</div>
|
||||
<div v-else class="grid grid-cols-12 gap-4 gap-y-5">
|
||||
<Field class="col-span-12">
|
||||
<FieldLabel>Alamat</FieldLabel>
|
||||
<Textarea :model-value="displayValue(applicant.address)" rows="3" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>No. Telefon</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.phone_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>No. Pejabat</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.office_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>Poskod</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.postcode)" type="text" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="employment" class="mt-6">
|
||||
<div v-if="!applicant" class="opacity-70">Tiada maklumat pemohon.</div>
|
||||
<div v-else class="grid grid-cols-12 gap-4 gap-y-5">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Nama Majikan</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.employer_name)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Jawatan Semasa</FieldLabel>
|
||||
<Input :model-value="displayValue(applicant.current_position)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12">
|
||||
<FieldLabel>Alamat Majikan</FieldLabel>
|
||||
<Textarea :model-value="displayValue(applicant.employer_address)" rows="3" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>Tarikh Mula Berkhidmat</FieldLabel>
|
||||
<Input :model-value="formatDate(applicant.start_work_date)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>Caruman Saham (RM)</FieldLabel>
|
||||
<Input :model-value="formatCurrency(applicant.stock_monthly_contribution)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel>Caruman Yuran (RM)</FieldLabel>
|
||||
<Input :model-value="formatCurrency(applicant.fee_monthly_contribution)" type="text" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="heirs" class="mt-6">
|
||||
<div v-if="!application.heirs.length" class="opacity-70">Tiada maklumat waris.</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="(heir, index) in application.heirs" :key="heir.id"
|
||||
class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="mb-4 font-medium">Waris {{ index + 1 }}</div>
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Nama</FieldLabel>
|
||||
<Input :model-value="displayValue(heir.name)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>No. Kad Pengenalan</FieldLabel>
|
||||
<Input :model-value="displayValue(heir.ic_number)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
<Input :model-value="displayValue(heir.relationship)" type="text" disabled />
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>No. Telefon</FieldLabel>
|
||||
<Input :model-value="displayValue(heir.phone_number)" type="text" disabled />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="references" class="mt-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="font-medium">Pencadang</div>
|
||||
<div class="mt-3 space-y-1 text-sm">
|
||||
<div>
|
||||
<span class="opacity-70">Nama:</span>
|
||||
{{ displayValue(getReference('PROPOSER')?.member?.name) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">No. IC:</span>
|
||||
{{ displayValue(getReference('PROPOSER')?.member?.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="font-medium">Penyokong</div>
|
||||
<div class="mt-3 space-y-1 text-sm">
|
||||
<div>
|
||||
<span class="opacity-70">Nama:</span>
|
||||
{{ displayValue(getReference('SUPPORTER')?.member?.name) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-70">No. IC:</span>
|
||||
{{ displayValue(getReference('SUPPORTER')?.member?.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents" class="mt-6">
|
||||
<div v-if="!application.documents.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="document in application.documents" :key="document.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-foreground/10 p-4">
|
||||
<div>
|
||||
<div class="font-medium">{{ documentLabel(document.type, document.name) }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">
|
||||
{{ document.name }} · {{ formatFileSize(document.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="previewLoading && previewDocument?.id === document.id"
|
||||
@click="handleViewDocument(document)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
{{ previewLoading && previewDocument?.id === document.id ? 'Memuatkan...' : 'Lihat' }}
|
||||
</Button>
|
||||
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
|
||||
@click="handleDownloadDocument(document)">
|
||||
<Download class="mr-2 size-4" />
|
||||
{{ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
|
||||
<div class="relative ms-3 ps-8">
|
||||
<div v-for="(review, index) in sortedReviews" :key="review.id" class="relative pb-8 last:pb-0">
|
||||
<span class="absolute -inset-s-8 top-1.5 flex size-3.5 rounded-full border-2 ring-4 ring-background"
|
||||
:class="reviewTimelineDotClass(review)" />
|
||||
<span v-if="index < sortedReviews.length - 1"
|
||||
class="absolute -inset-s-3.5 top-5 h-[calc(100%-0.25rem)] w-px bg-foreground/15" />
|
||||
|
||||
<div class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="font-medium">{{ reviewStageLabel(review.stage) }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">
|
||||
{{ formatDateTime(review.reviewed_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge :variant="reviewDecisionBadgeVariant(review.decision, review.stage)" class="whitespace-nowrap">
|
||||
{{ reviewDecisionLabel(review.decision, review.stage) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="opacity-70">Disemak oleh:</span>
|
||||
{{ displayValue(review.reviewer?.name) }}
|
||||
</div>
|
||||
<div v-if="review.remarks">
|
||||
<span class="opacity-70">Catatan:</span>
|
||||
{{ review.remarks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</template>
|
||||
|
||||
<DialogRoot :open="confirmDialogOpen"
|
||||
@openChange="(details) => { confirmDialogOpen = details.open; if (!details.open) pendingAction = null }">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-2 text-2xl font-medium">{{ confirmDialogTitle }}</div>
|
||||
<div class="mt-2 opacity-70">{{ confirmDialogDescription }}</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button class="w-28" type="button" variant="primary" :disabled="reviewSubmitting || completeSubmitting"
|
||||
@click="confirmPendingAction">
|
||||
{{ reviewSubmitting || completeSubmitting ? 'Memproses...' : 'Sahkan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="previewOpen" class="fixed inset-0 z-70 flex items-center justify-center p-4 sm:p-6" role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="previewDocument ? documentLabel(previewDocument.type, previewDocument.name) : 'Pratonton dokumen'">
|
||||
<button type="button" class="absolute inset-0 bg-black/80" aria-label="Tutup pratonton"
|
||||
@click="handlePreviewOpenChange(false)" />
|
||||
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-2xl">
|
||||
<div class="border-b border-foreground/10 px-5 py-4">
|
||||
<div class="text-lg font-medium">
|
||||
{{ previewDocument ? documentLabel(previewDocument.type, previewDocument.name) : 'Pratonton Dokumen' }}
|
||||
</div>
|
||||
<div v-if="previewDocument" class="mt-1 text-sm opacity-70">
|
||||
{{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto p-5">
|
||||
<div v-if="previewLoading" class="py-12 text-center opacity-70">
|
||||
Memuatkan dokumen...
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewUrl && isPreviewImage" class="flex justify-center">
|
||||
<img :src="previewUrl" :alt="previewDocument?.name ?? 'Pratonton dokumen'"
|
||||
class="block h-auto max-h-[calc(90vh-12rem)] w-auto max-w-full object-contain" />
|
||||
</div>
|
||||
|
||||
<iframe v-else-if="previewUrl && isPreviewPdf" :src="previewUrl"
|
||||
class="block w-full rounded-lg border border-foreground/10" style="height: min(70vh, 720px)"
|
||||
:title="previewDocument?.name ?? 'Pratonton dokumen'" />
|
||||
|
||||
<div v-else-if="previewUrl" class="py-12 text-center opacity-70">
|
||||
Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 border-t border-foreground/10 px-5 py-4">
|
||||
<Button type="button" look="outline" @click="handlePreviewOpenChange(false)">
|
||||
Tutup
|
||||
</Button>
|
||||
<Button v-if="previewDocument" type="button" look="outline"
|
||||
:disabled="downloadingDocumentId === previewDocument.id" @click="handleDownloadDocument(previewDocument)">
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user