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
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { CircleAlert, CircleCheck, Search, Eye, Pencil, FileText, Download } from '@lucide/vue'
import { CircleAlert, CircleCheck, Search, Eye, Pencil, Download } from '@lucide/vue'
import dayjs from 'dayjs'
import * as select from '@zag-js/select'
import {
@@ -36,7 +36,6 @@ import { usePermissions } from '@/composables/usePermissions'
import {
batchCompleteMembershipApplications,
downloadMembershipApplicationDocument,
generateMembershipApplicationResultLetter,
} from '../services/membership-application.service'
import {
boardResultBadgeVariant,
@@ -46,7 +45,6 @@ import {
import type {
BatchCompleteFailedItem,
BatchCompleteResponse,
GenerateResultLetterResponse,
MembershipApplicationBoardResult,
MembershipApplicationListItem,
MembershipApplicationStatus,
@@ -57,7 +55,7 @@ type SelectOption = { label: string; value: string }
const STATUS_FILTER_OPTIONS: SelectOption[] = [
{ label: 'Semua Status', value: '' },
{ label: 'Dihantar', value: 'SUBMITTED' },
{ label: 'Menunggu Lembaga', value: 'PENDING_BOARD' },
{ label: 'Menunggu Keputusan ALK', value: 'PENDING_BOARD' },
{ label: 'Ditolak Pentadbiran', value: 'MANAGEMENT_REJECTED' },
{ label: 'Menunggu Makluman', value: 'PENDING_NOTIFICATION' },
{ label: 'Selesai', value: 'COMPLETED' },
@@ -105,18 +103,13 @@ const {
} = useMembershipApplicationList()
const canBatchComplete = computed(() => hasPermission('selesaikan permohonan keahlian'))
const canGenerateResultLetter = computed(() => hasPermission('jana surat keputusan keahlian'))
const selectedIds = ref<string[]>([])
const batchSubmitting = ref(false)
const batchConfirmOpen = ref(false)
const batchSuccessMessage = ref<string | null>(null)
const letterSuccessMessage = ref<string | null>(null)
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
const generateDialogOpen = ref(false)
const generateSubmitting = ref(false)
const boardMeetingReference = ref('')
const boardMeetingReferenceError = ref<string | null>(null)
const generateTarget = ref<MembershipApplicationListItem | null>(null)
const downloadingResultLetterId = ref<string | null>(null)
const selectableApplications = computed(() =>
@@ -167,19 +160,28 @@ function toggleSelectAllOnPage(checked: boolean) {
function openBatchConfirm() {
if (!selectedIds.value.length) return
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
batchConfirmOpen.value = true
}
async function confirmBatchComplete() {
if (!selectedIds.value.length || batchSubmitting.value) return
const reference = boardMeetingReference.value.trim()
if (!reference) {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return
}
batchSubmitting.value = true
batchSuccessMessage.value = null
batchFailedItems.value = []
boardMeetingReferenceError.value = null
error.value = null
try {
const response = await batchCompleteMembershipApplications(selectedIds.value)
const response = await batchCompleteMembershipApplications(selectedIds.value, reference)
if (response.success) {
batchSuccessMessage.value = response.message
@@ -196,6 +198,8 @@ async function confirmBatchComplete() {
const responseData = err.response.data as BatchCompleteResponse
batchFailedItems.value = responseData.data?.failed ?? []
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
const validationErrors = getApiValidationErrors(err)
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
} else {
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
}
@@ -213,7 +217,7 @@ const statusFilterInitial = computed(() => apiValueToLabel(STATUS_FILTER_OPTIONS
function statusLabel(status: MembershipApplicationStatus): string {
const labels: Record<MembershipApplicationStatus, string> = {
SUBMITTED: 'Dihantar',
PENDING_BOARD: 'Menunggu Lembaga',
PENDING_BOARD: 'Menunggu Keputusan ALK',
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
PENDING_NOTIFICATION: 'Menunggu Makluman',
COMPLETED: 'Selesai',
@@ -241,70 +245,10 @@ function goToApplicationEdit(id: string) {
router.push({ name: 'edit-membership-application', params: { id } })
}
function canGenerateLetter(item: MembershipApplicationListItem): boolean {
return (
canGenerateResultLetter.value &&
item.status === 'COMPLETED' &&
!item.has_result_letter &&
(item.board_result === 'PASS' || item.board_result === 'FAIL')
)
}
function canDownloadLetter(item: MembershipApplicationListItem): boolean {
return !!item.has_result_letter && !!item.result_letter_document
}
function openGenerateDialog(item: MembershipApplicationListItem) {
generateTarget.value = item
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
generateDialogOpen.value = true
}
function closeGenerateDialog() {
if (generateSubmitting.value) return
generateDialogOpen.value = false
generateTarget.value = null
boardMeetingReference.value = ''
boardMeetingReferenceError.value = null
}
async function confirmGenerateLetter() {
if (!generateTarget.value || generateSubmitting.value) return
const reference = boardMeetingReference.value.trim()
if (!reference) {
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
return
}
generateSubmitting.value = true
boardMeetingReferenceError.value = null
error.value = null
letterSuccessMessage.value = null
try {
const response = await generateMembershipApplicationResultLetter(generateTarget.value.id, {
board_meeting_reference: reference,
})
letterSuccessMessage.value = response.message
closeGenerateDialog()
await fetchApplications(page.value)
} catch (err) {
if (axios.isAxiosError(err) && err.response?.data) {
const responseData = err.response.data as GenerateResultLetterResponse
const validationErrors = getApiValidationErrors(err)
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
} else {
error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
}
} finally {
generateSubmitting.value = false
}
}
async function handleDownloadResultLetter(item: MembershipApplicationListItem) {
const document = item.result_letter_document
if (!document) return
@@ -384,13 +328,6 @@ const headers = computed<TableHeader[]>(() => {
<p class="mt-1 text-sm opacity-70">Urus dan semak permohonan keahlian koperasi.</p>
</div>
<AlertRoot v-if="letterSuccessMessage" variant="success">
<CircleCheck />
<AlertTitle>Berjaya</AlertTitle>
<AlertDescription>{{ letterSuccessMessage }}</AlertDescription>
<AlertCloseTrigger @click="letterSuccessMessage = null" />
</AlertRoot>
<AlertRoot v-if="batchSuccessMessage" variant="success">
<CircleCheck />
<AlertTitle>Berjaya</AlertTitle>
@@ -482,11 +419,8 @@ const headers = computed<TableHeader[]>(() => {
</template>
<template #item.status="{ item }">
<Badge
:variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
:look="statusBadgeLook((item as MembershipApplicationListItem).status)"
class="whitespace-nowrap"
>
<Badge :variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
:look="statusBadgeLook((item as MembershipApplicationListItem).status)" class="whitespace-nowrap">
{{ statusLabel((item as MembershipApplicationListItem).status) }}
</Badge>
</template>
@@ -516,27 +450,10 @@ const headers = computed<TableHeader[]>(() => {
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
v-if="canGenerateLetter(item as MembershipApplicationListItem)"
type="button"
variant="ghost"
size="sm"
class="bg-amber-600 text-white"
title="Jana surat keputusan"
@click="openGenerateDialog(item as MembershipApplicationListItem)"
>
<FileText class="size-4" aria-hidden="true" />
</Button>
<Button
v-if="canDownloadLetter(item as MembershipApplicationListItem)"
type="button"
variant="ghost"
size="sm"
class="bg-purple-600 text-white"
title="Muat turun surat keputusan"
<Button v-if="canDownloadLetter(item as MembershipApplicationListItem)" type="button" variant="ghost"
size="sm" class="bg-purple-600 text-white" title="Muat turun surat keputusan"
:disabled="downloadingResultLetterId === (item as MembershipApplicationListItem).id"
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)"
>
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)">
<Download class="size-4" aria-hidden="true" />
</Button>
</div>
@@ -548,8 +465,16 @@ const headers = computed<TableHeader[]>(() => {
<div class="p-5 text-center">
<div class="mt-2 text-2xl font-medium">Selesaikan Permohonan Terpilih?</div>
<div class="mt-2 opacity-70">
{{ selectedIds.length }} permohonan akan diselesaikan dan e-mel keputusan dihantar.
{{ selectedIds.length }} permohonan akan diselesaikan. Surat keputusan dijana dan e-mel dengan lampiran
surat dihantar.
</div>
<Field class="mt-5 text-left">
<FieldLabel for="batch-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
<Input id="batch-board-meeting-reference" v-model="boardMeetingReference" type="text"
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026" :disabled="batchSubmitting"
@input="boardMeetingReferenceError = null" />
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
</Field>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
@@ -562,45 +487,5 @@ const headers = computed<TableHeader[]>(() => {
</div>
</DialogContent>
</DialogRoot>
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
<DialogContent>
<div class="p-5">
<div class="text-2xl font-medium">Jana Surat Keputusan</div>
<p v-if="generateTarget" class="mt-2 text-sm opacity-70">
{{ generateTarget.application_number }} · {{ generateTarget.applicant?.name ?? '-' }}
</p>
<Field class="mt-5">
<FieldLabel for="board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
<Input
id="board-meeting-reference"
v-model="boardMeetingReference"
type="text"
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026"
:disabled="generateSubmitting"
@input="boardMeetingReferenceError = null"
/>
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
</Field>
<p class="mt-3 text-sm opacity-70">
Surat hanya boleh dijana sekali dan akan disimpan sebagai dokumen permohonan.
</p>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
Batal
</DialogCloseTrigger>
<Button
class="w-32"
type="button"
variant="primary"
:disabled="generateSubmitting"
@click="confirmGenerateLetter"
>
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
</Button>
</div>
</DialogContent>
</DialogRoot>
</div>
</template>