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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user