Dev/v1.2 (#4)
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local> Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Plus } from '@lucide/vue'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Plus, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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 {
|
||||
@@ -31,6 +32,7 @@ import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
downloadMembershipApplicationDocument,
|
||||
deleteMembershipApplicationDocument,
|
||||
fetchMembershipApplicationDocument,
|
||||
getMembershipApplication,
|
||||
lookupMemberByIcNumber,
|
||||
@@ -52,9 +54,10 @@ import type {
|
||||
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,
|
||||
DOCUMENT_UPLOAD_TYPES,
|
||||
GENDER_OPTIONS,
|
||||
MARRIAGE_STATUS_OPTIONS,
|
||||
RELATIONSHIP_OPTIONS,
|
||||
@@ -73,7 +76,7 @@ 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: 'Semakan Lembaga' },
|
||||
{ id: 3, label: 'Keputusan Ahli Lembaga Koperasi (ALK)' },
|
||||
{ id: 4, label: 'Makluman Keputusan' },
|
||||
{ id: 5, label: 'Selesai' },
|
||||
] as const
|
||||
@@ -91,11 +94,15 @@ 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,
|
||||
@@ -131,17 +138,22 @@ const sortedReviews = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const documentsByType = computed(() => {
|
||||
function buildDocumentsByType(types: readonly DocumentUploadType[]) {
|
||||
const map: Partial<Record<DocumentUploadType, MembershipApplicationDocumentDetail>> = {}
|
||||
|
||||
application.value?.documents.forEach((document) => {
|
||||
if (DOCUMENT_UPLOAD_TYPES.includes(document.type as DocumentUploadType)) {
|
||||
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) {
|
||||
@@ -163,7 +175,7 @@ function getWorkflowProgress(status: MembershipApplicationStatus) {
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
PENDING_BOARD: 'Menunggu Keputusan Mesyuarat ALK',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
@@ -247,7 +259,7 @@ const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previ
|
||||
|
||||
function reviewStageLabel(stage: string): string {
|
||||
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
|
||||
if (stage === 'BOARD') return 'Semakan Lembaga'
|
||||
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
|
||||
return stage
|
||||
}
|
||||
|
||||
@@ -468,6 +480,91 @@ async function handleDocumentUpload(type: DocumentUploadType, event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -635,7 +732,7 @@ onUnmounted(() => {
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 text-xs sm:px-3 sm:text-sm"
|
||||
value="heirs">
|
||||
Waris
|
||||
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"
|
||||
@@ -770,13 +867,13 @@ onUnmounted(() => {
|
||||
<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>
|
||||
}}</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="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address']
|
||||
}}</FieldError>
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
|
||||
@@ -805,7 +902,7 @@ onUnmounted(() => {
|
||||
<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">Waris {{ index + 1 }}</div>
|
||||
<div class="font-medium">Penama</div>
|
||||
<Button v-if="canEdit && form.heirs.length > 1" type="button" look="outline" size="sm"
|
||||
@click="removeHeir(index)">
|
||||
Buang
|
||||
@@ -822,7 +919,7 @@ onUnmounted(() => {
|
||||
<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>
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
@@ -855,10 +952,10 @@ onUnmounted(() => {
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="canEdit" type="button" look="outline" @click="addHeir">
|
||||
<!-- <Button v-if="canEdit" type="button" look="outline" @click="addHeir">
|
||||
<Plus class="mr-2 size-4" />
|
||||
Tambah Waris
|
||||
</Button>
|
||||
Tambah Penama
|
||||
</Button> -->
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -900,25 +997,27 @@ onUnmounted(() => {
|
||||
Muat naik fail baharu untuk menggantikan dokumen sedia ada.
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div v-for="type in DOCUMENT_UPLOAD_TYPES" :key="type" class="rounded-lg border border-foreground/10 p-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="documentsByType[type]" class="mt-1 text-sm opacity-70">
|
||||
{{ documentsByType[type]?.name }} · {{ formatFileSize(documentsByType[type]?.file_size) }}
|
||||
<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="documentsByType[type]" class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="applicantDocumentsByType[type]" class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="previewLoading && previewDocument?.id === documentsByType[type]?.id"
|
||||
@click="documentsByType[type] && handleViewDocument(documentsByType[type]!)">
|
||||
: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 === documentsByType[type]?.id"
|
||||
@click="documentsByType[type] && handleDownloadDocument(documentsByType[type]!)">
|
||||
:disabled="downloadingDocumentId === applicantDocumentsByType[type]?.id"
|
||||
@click="applicantDocumentsByType[type] && handleDownloadDocument(applicantDocumentsByType[type]!)">
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
@@ -926,7 +1025,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<Field v-if="canEdit" class="mt-4">
|
||||
<FieldLabel :for="`document-${type}`">
|
||||
{{ documentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
|
||||
{{ applicantDocumentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
|
||||
</FieldLabel>
|
||||
<Input :id="`document-${type}`" type="file" :accept="documentAccept(type)"
|
||||
:disabled="uploadingDocumentType === type" @change="handleDocumentUpload(type, $event)" />
|
||||
@@ -935,6 +1034,51 @@ onUnmounted(() => {
|
||||
</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">
|
||||
@@ -998,6 +1142,27 @@ onUnmounted(() => {
|
||||
</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"
|
||||
|
||||
Reference in New Issue
Block a user