1247 lines
52 KiB
Vue
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>
|