Files
My-KOPKB/fe/src/modules/membership-application/pages/MembershipApplicationEdit.vue
T
ismailmasseran d0f5e368e3
Build Docker Image / build-backend (push) Successful in 5m31s
Build Docker Image / build-frontend (push) Successful in 28s
DONE: fix password bypass in production, use dropdown for admin to edit membership application (#13)
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #13
2026-07-16 11:02:14 +08:00

1247 lines
52 KiB
Vue

<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { CircleAlert, CircleCheck, Download, Eye, Plus, Trash2 } 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, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
SelectRoot,
SelectControl,
SelectTrigger,
SelectValueText,
SelectContent,
SelectItemGroup,
SelectItemGroupLabel,
SelectItem,
SelectItemText,
} from '@/components/ui/select'
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 {
downloadMembershipApplicationDocument,
deleteMembershipApplicationDocument,
fetchMembershipApplicationDocument,
getMembershipApplication,
lookupMemberByIcNumber,
updateMembershipApplication,
uploadMembershipApplicationDocument,
} from '../services/membership-application.service'
import {
reviewDecisionBadgeLook,
reviewDecisionBadgeVariant,
statusBadgeLook,
statusBadgeVariant,
} from '../utils/membership-application-badge.utils'
import type {
DocumentUploadType,
MembershipApplicationDetail,
MembershipApplicationDocumentDetail,
MembershipApplicationFormState,
MembershipApplicationReferenceDetail,
MembershipApplicationReviewDetail,
MembershipApplicationStatus,
} from '../types/membership-application.types'
import { ADMIN_ATTACHMENT_DOCUMENT_TYPE } from '../types/membership-application.types'
import {
APPLICANT_DOCUMENT_UPLOAD_TYPES,
DOCUMENT_TYPE_LABELS,
EMPLOYER_OPTIONS,
GENDER_OPTIONS,
MARRIAGE_STATUS_OPTIONS,
RELATIONSHIP_OPTIONS,
apiValueToLabel,
buildUpdatePayload,
createEmptyHeir,
createEmptyFormState,
createSelectCollection,
detailToFormState,
getEmployerAddress,
labelToApiValue,
} from '../utils/membership-application-form.utils'
const MAX_FILE_SIZE_MB = 10
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
const WORKFLOW_STEPS = [
{ id: 1, label: 'Dihantar' },
{ id: 2, label: 'Semakan Pentadbiran' },
{ id: 3, label: 'Keputusan Ahli Lembaga Koperasi (ALK)' },
{ id: 4, label: 'Makluman Keputusan' },
{ id: 5, label: 'Selesai' },
] as const
const router = useRouter()
const route = useRoute()
const { hasPermission } = usePermissions()
const applicationId = computed(() => String(route.params.id ?? ''))
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const application = ref<MembershipApplicationDetail | null>(null)
const form = reactive<MembershipApplicationFormState>(createEmptyFormState())
const fieldErrors = reactive<Record<string, string>>({})
const downloadingDocumentId = ref<string | null>(null)
const deletingDocumentId = ref<string | null>(null)
const uploadingDocumentType = ref<DocumentUploadType | null>(null)
const uploadingAdminAttachment = ref(false)
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewUrl = ref<string | null>(null)
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
const deleteConfirmDialogOpen = ref(false)
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
const referenceLookupLoading = reactive({
proposer: false,
supporter: false,
})
const genderCollection = createSelectCollection(GENDER_OPTIONS)
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
const employerCollection = createSelectCollection(EMPLOYER_OPTIONS)
const canEdit = computed(
() =>
hasPermission('kemaskini permohonan keahlian') &&
application.value?.status !== 'COMPLETED',
)
const genderInitial = computed(() => apiValueToLabel(GENDER_OPTIONS, form.applicant.gender))
const marriageStatusInitial = computed(() =>
apiValueToLabel(MARRIAGE_STATUS_OPTIONS, form.applicant.marriage_status),
)
const employerInitial = computed(() => apiValueToLabel(EMPLOYER_OPTIONS, form.applicant.employer_name))
const workflowProgress = computed(() =>
application.value ? getWorkflowProgress(application.value.status) : 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 buildDocumentsByType(types: readonly DocumentUploadType[]) {
const map: Partial<Record<DocumentUploadType, MembershipApplicationDocumentDetail>> = {}
application.value?.documents.forEach((document) => {
if (types.includes(document.type as DocumentUploadType)) {
map[document.type as DocumentUploadType] = document
}
})
return map
}
const applicantDocumentsByType = computed(() => buildDocumentsByType(APPLICANT_DOCUMENT_UPLOAD_TYPES))
const adminAttachments = computed(() =>
application.value?.documents.filter((document) => document.type === ADMIN_ATTACHMENT_DOCUMENT_TYPE) ?? [],
)
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 }
}
}
function statusLabel(status: MembershipApplicationStatus): string {
const labels: Record<MembershipApplicationStatus, string> = {
SUBMITTED: 'Dihantar',
PENDING_BOARD: 'Menunggu Keputusan Mesyuarat ALK',
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
PENDING_NOTIFICATION: 'Menunggu Makluman',
COMPLETED: 'Selesai',
}
return labels[status] ?? status
}
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'
}
function displayValue(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') return '-'
return String(value)
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return '-'
return dayjs(value).format('DD/MM/YYYY HH:mm')
}
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 documentAccept(type: DocumentUploadType): string {
if (type === 'photo') return '.jpg,.jpeg,.png'
return '.pdf,.jpg,.jpeg,.png'
}
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 reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
if (stage === 'COMPLETION') return 'Penyelesaian & Makluman Keputusan'
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' || stage === 'COMPLETION') {
if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal'
}
return decision
}
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'
}
function clearErrors() {
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
}
function setFieldErrors(errors: Record<string, string[]>) {
clearErrors()
Object.entries(errors).forEach(([key, messages]) => {
if (messages[0]) fieldErrors[key] = messages[0]
})
}
function setGenderValue(details: { value: string[] }) {
form.applicant.gender = labelToApiValue(GENDER_OPTIONS, details.value[0])
delete fieldErrors['applicant.gender']
}
function setMarriageStatusValue(details: { value: string[] }) {
form.applicant.marriage_status = labelToApiValue(MARRIAGE_STATUS_OPTIONS, details.value[0])
delete fieldErrors['applicant.marriage_status']
}
function setEmployerValue(details: { value: string[] }) {
const employerName = labelToApiValue(EMPLOYER_OPTIONS, details.value[0])
form.applicant.employer_name = employerName
form.applicant.employer_address = getEmployerAddress(employerName)
delete fieldErrors['applicant.employer_name']
delete fieldErrors['applicant.employer_address']
}
function setHeirRelationshipValue(index: number, details: { value: string[] }) {
const heir = form.heirs[index]
if (!heir) return
heir.relationship = labelToApiValue(RELATIONSHIP_OPTIONS, details.value[0])
delete fieldErrors[`heirs.${index}.relationship`]
}
function clearReference(role: 'proposer' | 'supporter') {
form.references[role].user_id = ''
form.references[role].name = ''
delete fieldErrors[`references.${role}_ic_number`]
}
function handleReferenceIcInput(role: 'proposer' | 'supporter') {
clearReference(role)
}
async function lookupReference(role: 'proposer' | 'supporter') {
const reference = form.references[role]
const icNumber = reference.ic_number.trim()
const fieldKey = role === 'proposer' ? 'references.proposer_ic_number' : 'references.supporter_ic_number'
delete fieldErrors[fieldKey]
if (!icNumber) {
clearReference(role)
return
}
if (icNumber === form.applicant.ic_number.trim()) {
clearReference(role)
fieldErrors[fieldKey] = 'Pencadang/penyokong tidak boleh sama dengan pemohon.'
return
}
const otherRole = role === 'proposer' ? 'supporter' : 'proposer'
if (icNumber === form.references[otherRole].ic_number.trim()) {
clearReference(role)
fieldErrors[fieldKey] = 'Pencadang dan penyokong mestilah ahli yang berbeza.'
return
}
referenceLookupLoading[role] = true
try {
const response = await lookupMemberByIcNumber(icNumber)
if (!response.success || !response.data) {
clearReference(role)
fieldErrors[fieldKey] = response.message || 'Ahli tidak dijumpai.'
return
}
reference.user_id = response.data.id
reference.name = response.data.name
reference.ic_number = response.data.ic_number
} catch (err) {
clearReference(role)
fieldErrors[fieldKey] = getApiErrorMessage(err, 'Gagal mencari ahli.')
} finally {
referenceLookupLoading[role] = false
}
}
function addHeir() {
form.heirs.push(createEmptyHeir())
}
function removeHeir(index: number) {
if (form.heirs.length <= 1) return
form.heirs.splice(index, 1)
}
function applyDetailToForm(detail: MembershipApplicationDetail) {
const nextForm = detailToFormState(detail)
Object.assign(form.applicant, nextForm.applicant)
form.heirs.splice(0, form.heirs.length, ...nextForm.heirs)
Object.assign(form.references.proposer, nextForm.references.proposer)
Object.assign(form.references.supporter, nextForm.references.supporter)
}
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
}
}
async function fetchApplication() {
loading.value = true
error.value = null
try {
const response = await getMembershipApplication(applicationId.value)
application.value = response.data
applyDetailToForm(response.data)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan permohonan.')
application.value = null
} finally {
loading.value = false
}
}
async function handleSave() {
if (!application.value || !canEdit.value || saving.value) return
saving.value = true
error.value = null
successMessage.value = null
clearErrors()
try {
const response = await updateMembershipApplication(applicationId.value, buildUpdatePayload(form))
application.value = response.data
applyDetailToForm(response.data)
successMessage.value = response.message || 'Permohonan keahlian berjaya dikemaskini.'
} catch (err) {
const validationErrors = getApiValidationErrors(err)
if (validationErrors) {
setFieldErrors(validationErrors)
}
error.value = getApiErrorMessage(err, 'Gagal mengemaskini permohonan.')
} finally {
saving.value = false
}
}
async function handleDocumentUpload(type: DocumentUploadType, event: Event) {
if (!application.value || !canEdit.value) return
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (file.size > MAX_FILE_SIZE_BYTES) {
fieldErrors[`documents.${type}`] = `Saiz fail melebihi ${MAX_FILE_SIZE_MB}MB.`
return
}
delete fieldErrors[`documents.${type}`]
uploadingDocumentType.value = type
error.value = null
successMessage.value = null
try {
const response = await uploadMembershipApplicationDocument(applicationId.value, type, file)
application.value = response.data
successMessage.value = response.message || 'Dokumen berjaya dimuat naik.'
} catch (err) {
const validationErrors = getApiValidationErrors(err)
if (validationErrors) {
setFieldErrors(validationErrors)
}
error.value = getApiErrorMessage(err, 'Gagal memuat naik dokumen.')
} finally {
uploadingDocumentType.value = null
}
}
async function handleAdminAttachmentUpload(event: Event) {
if (!application.value || !canEdit.value || uploadingAdminAttachment.value) return
const input = event.target as HTMLInputElement
const files = input.files ? Array.from(input.files) : []
input.value = ''
if (!files.length) return
const oversizedFile = files.find((file) => file.size > MAX_FILE_SIZE_BYTES)
if (oversizedFile) {
fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`] =
`Saiz fail melebihi ${MAX_FILE_SIZE_MB}MB.`
return
}
delete fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`]
uploadingAdminAttachment.value = true
error.value = null
successMessage.value = null
try {
let latestResponse = null
for (const file of files) {
latestResponse = await uploadMembershipApplicationDocument(
applicationId.value,
ADMIN_ATTACHMENT_DOCUMENT_TYPE,
file,
)
application.value = latestResponse.data
}
successMessage.value =
files.length > 1
? `${files.length} lampiran pentadbir berjaya dimuat naik.`
: latestResponse?.message || 'Lampiran pentadbir berjaya dimuat naik.'
} catch (err) {
const validationErrors = getApiValidationErrors(err)
if (validationErrors) {
setFieldErrors(validationErrors)
}
error.value = getApiErrorMessage(err, 'Gagal memuat naik lampiran pentadbir.')
} finally {
uploadingAdminAttachment.value = false
}
}
async function handleDeleteAdminAttachment(document: MembershipApplicationDocumentDetail) {
if (!application.value || !canEdit.value || deletingDocumentId.value) return
pendingDeleteDocument.value = document
deleteConfirmDialogOpen.value = true
}
function closeDeleteConfirmDialog() {
deleteConfirmDialogOpen.value = false
pendingDeleteDocument.value = null
}
async function confirmDeleteAdminAttachment() {
const document = pendingDeleteDocument.value
if (!application.value || !document || deletingDocumentId.value) return
deletingDocumentId.value = document.id
error.value = null
successMessage.value = null
try {
const response = await deleteMembershipApplicationDocument(applicationId.value, document.id)
application.value = response.data
if (previewDocument.value?.id === document.id) {
handlePreviewOpenChange(false)
}
successMessage.value = response.message || 'Lampiran pentadbir berjaya dipadam.'
closeDeleteConfirmDialog()
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memadam lampiran pentadbir.')
} finally {
deletingDocumentId.value = null
}
}
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">Kemaskini 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: 'view-membership-application', params: { id: applicationId } })">
Lihat
</Button>
<Button look="outline" variant="secondary" type="button"
@click="router.push({ name: 'list-membership-applications' })">
Kembali
</Button>
</div>
<AlertRoot v-if="application && !canEdit" variant="warning">
<AlertTitle>Tidak Boleh Dikemaskini</AlertTitle>
<AlertDescription>
Permohonan yang telah selesai tidak boleh dikemaskini.
</AlertDescription>
</AlertRoot>
<AlertRoot v-if="error && !application" variant="danger">
<CircleAlert />
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
<AlertCloseTrigger @click="error = null" />
</AlertRoot>
<div v-if="loading" class="opacity-70">Memuatkan 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)" :look="statusBadgeLook(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 ? 'primary' : 'ghost'"
disabled>
{{ step.id }}
</Button>
<div :class="workflowStepLabelClass(step.id)">
{{ step.label }}
</div>
</div>
</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">
Penama
</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 class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="name">Nama Penuh</FieldLabel>
<Input id="name" v-model="form.applicant.name" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.name']">{{ fieldErrors['applicant.name'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="email">Emel</FieldLabel>
<Input id="email" v-model="form.applicant.email" type="email" class="lowercase" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.email']">{{ fieldErrors['applicant.email'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel>
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="birth_date">Tarikh Lahir</FieldLabel>
<Input id="birth_date" v-model="form.applicant.birth_date" type="date" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.birth_date']">{{ fieldErrors['applicant.birth_date'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="birth_place">Tempat Lahir</FieldLabel>
<Input id="birth_place" v-model="form.applicant.birth_place" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.birth_place']">{{ fieldErrors['applicant.birth_place'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Jantina</FieldLabel>
<SelectRoot :key="`gender-${form.applicant.gender}`" class="w-full" :collection="genderCollection"
:default-value="genderInitial" :disabled="!canEdit" @value-change="setGenderValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.gender']">
<SelectValueText placeholder="Pilih jantina" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors['applicant.gender']">{{ fieldErrors['applicant.gender'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Status Perkahwinan</FieldLabel>
<SelectRoot :key="`marriage-${form.applicant.marriage_status}`" class="w-full"
:collection="marriageStatusCollection" :default-value="marriageStatusInitial" :disabled="!canEdit"
@value-change="setMarriageStatusValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.marriage_status']">
<SelectValueText placeholder="Pilih status" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors['applicant.marriage_status']">{{ fieldErrors['applicant.marriage_status'] }}
</FieldError>
</Field>
</div>
</TabsContent>
<TabsContent value="contact" class="mt-6">
<div class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12">
<FieldLabel for="address">Alamat</FieldLabel>
<Textarea id="address" v-model="form.applicant.address" rows="3" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.address']">{{ fieldErrors['applicant.address'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="phone_number">No. Telefon</FieldLabel>
<Input id="phone_number" v-model="form.applicant.phone_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.phone_number']">{{ fieldErrors['applicant.phone_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="office_number">No. Pejabat</FieldLabel>
<Input id="office_number" v-model="form.applicant.office_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.office_number']">{{ fieldErrors['applicant.office_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="postcode">Poskod</FieldLabel>
<Input id="postcode" v-model="form.applicant.postcode" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.postcode']">{{ fieldErrors['applicant.postcode'] }}</FieldError>
</Field>
</div>
</TabsContent>
<TabsContent value="employment" class="mt-6">
<div class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Nama Majikan</FieldLabel>
<SelectRoot :key="`employer-${form.applicant.employer_name}`" class="w-full"
:collection="employerCollection" :default-value="employerInitial" :disabled="!canEdit"
@value-change="setEmployerValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.employer_name']">
<SelectValueText placeholder="Pilih majikan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Nama Majikan</SelectItemGroupLabel>
<SelectItem v-for="item in employerCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors['applicant.employer_name']">{{ fieldErrors['applicant.employer_name'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="current_position">Jawatan Semasa</FieldLabel>
<Input id="current_position" v-model="form.applicant.current_position" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position']
}}</FieldError>
</Field>
<Field class="col-span-12">
<FieldLabel for="employer_address">Alamat Majikan</FieldLabel>
<Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" disabled />
<FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address']
}}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
<Input id="start_work_date" v-model="form.applicant.start_work_date" type="date" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.start_work_date']">{{ fieldErrors['applicant.start_work_date'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="stock_monthly_contribution">Caruman Saham (RM)</FieldLabel>
<Input id="stock_monthly_contribution" v-model="form.applicant.stock_monthly_contribution" type="number"
min="0" step="0.01" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.stock_monthly_contribution']">{{
fieldErrors['applicant.stock_monthly_contribution'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="fee_monthly_contribution">Caruman Yuran (RM)</FieldLabel>
<Input id="fee_monthly_contribution" v-model="form.applicant.fee_monthly_contribution" type="number"
min="0" step="0.01" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">{{
fieldErrors['applicant.fee_monthly_contribution'] }}</FieldError>
</Field>
</div>
</TabsContent>
<TabsContent value="heirs" class="mt-6">
<div class="space-y-4">
<div v-for="(heir, index) in form.heirs" :key="index" class="rounded-lg border border-foreground/10 p-4">
<div class="mb-4 flex items-center justify-between">
<div class="font-medium">Penama</div>
<Button v-if="canEdit && form.heirs.length > 1" type="button" look="outline" size="sm"
@click="removeHeir(index)">
Buang
</Button>
</div>
<div class="grid grid-cols-12 gap-4">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-name-${index}`">Nama</FieldLabel>
<Input :id="`heir-name-${index}`" v-model="heir.name" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors[`heirs.${index}.name`]">{{ fieldErrors[`heirs.${index}.name`] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`]
}}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Hubungan</FieldLabel>
<SelectRoot :key="`heir-relationship-${index}-${heir.relationship}`" class="w-full"
:collection="relationshipCollection"
:default-value="apiValueToLabel(RELATIONSHIP_OPTIONS, heir.relationship)" :disabled="!canEdit"
@value-change="(details) => setHeirRelationshipValue(index, details)">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors[`heirs.${index}.relationship`]">
<SelectValueText placeholder="Pilih hubungan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
<SelectItem v-for="item in relationshipCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors[`heirs.${index}.relationship`]">{{
fieldErrors[`heirs.${index}.relationship`] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-phone-${index}`">No. Telefon</FieldLabel>
<Input :id="`heir-phone-${index}`" v-model="heir.phone_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors[`heirs.${index}.phone_number`]">{{
fieldErrors[`heirs.${index}.phone_number`] }}</FieldError>
</Field>
</div>
</div>
<!-- <Button v-if="canEdit" type="button" look="outline" @click="addHeir">
<Plus class="mr-2 size-4" />
Tambah Penama
</Button> -->
</div>
</TabsContent>
<TabsContent value="references" class="mt-6">
<div class="mb-4 rounded-lg border border-foreground/10 bg-foreground/5 p-4 text-sm opacity-80">
Masukkan no. KP ahli sedia ada. Sistem akan mengesahkan dan mengisi nama secara automatik.
</div>
<div class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
placeholder="Contoh: 900101011234" :disabled="!canEdit || referenceLookupLoading.proposer"
@input="handleReferenceIcInput('proposer')" @blur="lookupReference('proposer')" />
<p v-if="form.references.proposer.name" class="mt-1 text-sm text-success">
{{ form.references.proposer.name }}
</p>
<FieldError v-if="fieldErrors['references.proposer_ic_number']">
{{ fieldErrors['references.proposer_ic_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
placeholder="Contoh: 850505055678" :disabled="!canEdit || referenceLookupLoading.supporter"
@input="handleReferenceIcInput('supporter')" @blur="lookupReference('supporter')" />
<p v-if="form.references.supporter.name" class="mt-1 text-sm text-success">
{{ form.references.supporter.name }}
</p>
<FieldError v-if="fieldErrors['references.supporter_ic_number']">
{{ fieldErrors['references.supporter_ic_number'] }}
</FieldError>
</Field>
</div>
</TabsContent>
<TabsContent value="documents" class="mt-6">
<div class="mb-4 rounded-lg border border-foreground/10 bg-foreground/5 p-4 text-sm opacity-80">
Saiz maksimum setiap fail: <strong>{{ MAX_FILE_SIZE_MB }}MB</strong>.
Muat naik fail baharu untuk menggantikan dokumen sedia ada.
</div>
<div class="space-y-4">
<div v-for="type in APPLICANT_DOCUMENT_UPLOAD_TYPES" :key="type"
class="rounded-lg border border-foreground/10 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="font-medium">{{ DOCUMENT_TYPE_LABELS[type] }}</div>
<div v-if="applicantDocumentsByType[type]" class="mt-1 text-sm opacity-70">
{{ applicantDocumentsByType[type]?.name }} ·
{{ formatFileSize(applicantDocumentsByType[type]?.file_size) }}
</div>
<div v-else class="mt-1 text-sm opacity-70">Tiada dokumen dimuat naik.</div>
</div>
<div v-if="applicantDocumentsByType[type]" class="flex flex-wrap items-center gap-2">
<Button type="button" look="outline" size="sm"
:disabled="previewLoading && previewDocument?.id === applicantDocumentsByType[type]?.id"
@click="applicantDocumentsByType[type] && handleViewDocument(applicantDocumentsByType[type]!)">
<Eye class="mr-2 size-4" />
Lihat
</Button>
<Button type="button" look="outline" size="sm"
:disabled="downloadingDocumentId === applicantDocumentsByType[type]?.id"
@click="applicantDocumentsByType[type] && handleDownloadDocument(applicantDocumentsByType[type]!)">
<Download class="mr-2 size-4" />
Muat Turun
</Button>
</div>
</div>
<Field v-if="canEdit" class="mt-4">
<FieldLabel :for="`document-${type}`">
{{ applicantDocumentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
</FieldLabel>
<Input :id="`document-${type}`" type="file" :accept="documentAccept(type)"
:disabled="uploadingDocumentType === type" @change="handleDocumentUpload(type, $event)" />
<FieldError v-if="fieldErrors[`documents.${type}`]">{{ fieldErrors[`documents.${type}`] }}</FieldError>
<p v-if="uploadingDocumentType === type" class="mt-1 text-sm opacity-70">Memuat naik...</p>
</Field>
</div>
</div>
<div class="mt-8">
<div class="mb-4 font-medium">Lampiran Pentadbir</div>
<p class="mb-4 text-sm opacity-70">
Muat naik satu atau lebih dokumen tambahan semasa semakan permohonan. Setiap muat naik akan
menambah lampiran baharu.
</p>
<div v-if="adminAttachments.length" class="mb-4 space-y-3">
<div v-for="document in adminAttachments" :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">{{ document.name }}</div>
<div class="mt-1 text-sm opacity-70">{{ 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" />
Lihat
</Button>
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
@click="handleDownloadDocument(document)">
<Download class="mr-2 size-4" />
Muat Turun
</Button>
<Button v-if="canEdit" type="button" look="outline" size="sm" variant="danger"
:disabled="deletingDocumentId === document.id" @click="handleDeleteAdminAttachment(document)">
<Trash2 class="mr-2 size-4" />
{{ deletingDocumentId === document.id ? 'Memadam...' : 'Padam' }}
</Button>
</div>
</div>
</div>
<div v-else class="mb-4 text-sm opacity-70">Tiada lampiran pentadbir dimuat naik.</div>
<Field v-if="canEdit" class="rounded-lg border border-foreground/10 p-4">
<FieldLabel for="document-admin-attachment">Tambah Lampiran Pentadbir</FieldLabel>
<Input id="document-admin-attachment" type="file" accept=".pdf,.jpg,.jpeg,.png" multiple
:disabled="uploadingAdminAttachment" @change="handleAdminAttachmentUpload" />
<FieldError v-if="fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`]">
{{ fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`] }}
</FieldError>
<p v-if="uploadingAdminAttachment" class="mt-1 text-sm opacity-70">Memuat naik...</p>
</Field>
</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-5.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)"
:look="reviewDecisionBadgeLook(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>
<div v-if="canEdit" class="space-y-3 pt-2">
<AlertRoot v-if="successMessage" :key="successMessage" variant="success">
<CircleCheck />
<AlertTitle>Berjaya</AlertTitle>
<AlertDescription>{{ successMessage }}</AlertDescription>
<AlertCloseTrigger @click="successMessage = null" />
</AlertRoot>
<AlertRoot v-if="error" :key="error" variant="danger">
<CircleAlert />
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
<AlertCloseTrigger @click="error = null" />
</AlertRoot>
<div class="flex items-center justify-end gap-2">
<Button type="button" :disabled="saving || loading" @click="handleSave">
{{ saving ? 'Menyimpan...' : 'Simpan' }}
</Button>
</div>
</div>
</template>
<DialogRoot :open="deleteConfirmDialogOpen"
@openChange="(details) => { if (!details.open) closeDeleteConfirmDialog() }">
<DialogContent>
<div class="p-5 text-center">
<div class="mt-2 text-2xl font-medium">Padam Lampiran Pentadbir?</div>
<div class="mt-2 opacity-70">
{{ pendingDeleteDocument?.name ?? 'Lampiran pentadbir' }} akan dipadam secara kekal.
</div>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-28" :disabled="!!deletingDocumentId" @click="closeDeleteConfirmDialog">
Batal
</DialogCloseTrigger>
<Button class="w-28" type="button" variant="danger" :disabled="!!deletingDocumentId"
@click="confirmDeleteAdminAttachment">
{{ deletingDocumentId ? 'Memadam...' : 'Padam' }}
</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>