Dev/v1.2 (#4)
Build Docker Image / build-backend (push) Successful in 1m56s
Build Docker Image / build-frontend (push) Successful in 1m55s

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:
2026-07-06 12:50:55 +08:00
parent e77712105c
commit d06c4701a4
190 changed files with 5365 additions and 26981 deletions
@@ -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"