503 lines
16 KiB
Vue
503 lines
16 KiB
Vue
<script lang="ts" setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { CircleAlert, CircleCheck, Search, Eye, Pencil } from '@lucide/vue'
|
|
import dayjs from 'dayjs'
|
|
import * as select from '@zag-js/select'
|
|
import {
|
|
AlertRoot,
|
|
AlertTitle,
|
|
AlertDescription,
|
|
AlertCloseTrigger,
|
|
} from '@/components/ui/alert'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { CheckboxRoot, CheckboxControl } from '@/components/ui/checkbox'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
SelectRoot,
|
|
SelectControl,
|
|
SelectTrigger,
|
|
SelectValueText,
|
|
SelectContent,
|
|
SelectItemGroup,
|
|
SelectItemGroupLabel,
|
|
SelectItem,
|
|
SelectItemText,
|
|
} from '@/components/ui/select'
|
|
import { Button } from '@/components/ui/button'
|
|
import { DialogRoot, DialogContent, DialogCloseTrigger } from '@/components/ui/dialog'
|
|
import DataTable from '@/components/ui/usage/DataTable.vue'
|
|
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
|
import axios from 'axios'
|
|
import { useMembershipApplicationList } from '../composables/useMembershipApplicationList'
|
|
import { usePermissions } from '@/composables/usePermissions'
|
|
import { batchCompleteMembershipApplications } from '../services/membership-application.service'
|
|
import type {
|
|
BatchCompleteFailedItem,
|
|
BatchCompleteResponse,
|
|
MembershipApplicationBoardResult,
|
|
MembershipApplicationListItem,
|
|
MembershipApplicationStatus,
|
|
} from '../types/membership-application.types'
|
|
|
|
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: 'Ditolak Pentadbiran', value: 'MANAGEMENT_REJECTED' },
|
|
{ label: 'Menunggu Makluman', value: 'PENDING_NOTIFICATION' },
|
|
{ label: 'Selesai', value: 'COMPLETED' },
|
|
]
|
|
|
|
function createSelectCollection(options: SelectOption[]) {
|
|
return select.collection({
|
|
items: options,
|
|
itemToValue: (item) => item.label,
|
|
})
|
|
}
|
|
|
|
function labelToApiValue(options: SelectOption[], label: string | undefined): string {
|
|
if (!label) return ''
|
|
return options.find((option) => option.label === label)?.value ?? ''
|
|
}
|
|
|
|
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
|
|
if (value === '' || value === null || value === undefined) {
|
|
const allOption = options.find((item) => item.value === '')
|
|
return allOption ? [allOption.label] : []
|
|
}
|
|
|
|
const option = options.find((item) => item.value === value)
|
|
return option ? [option.label] : []
|
|
}
|
|
|
|
const statusFilterCollection = createSelectCollection(STATUS_FILTER_OPTIONS)
|
|
|
|
const router = useRouter()
|
|
const { hasPermission } = usePermissions()
|
|
|
|
const {
|
|
applications,
|
|
loading,
|
|
error,
|
|
search,
|
|
statusFilter,
|
|
sortBy,
|
|
page,
|
|
itemsPerPage,
|
|
pagination,
|
|
handleSortUpdate,
|
|
fetchApplications,
|
|
} = useMembershipApplicationList()
|
|
|
|
const canBatchComplete = computed(() => hasPermission('selesaikan permohonan keahlian'))
|
|
const selectedIds = ref<string[]>([])
|
|
const batchSubmitting = ref(false)
|
|
const batchConfirmOpen = ref(false)
|
|
const batchSuccessMessage = ref<string | null>(null)
|
|
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
|
|
|
|
const selectableApplications = computed(() =>
|
|
applications.value.filter((item) => item.status === 'PENDING_NOTIFICATION'),
|
|
)
|
|
|
|
const allSelectableSelected = computed(() => {
|
|
const eligible = selectableApplications.value
|
|
return eligible.length > 0 && eligible.every((item) => selectedIds.value.includes(item.id))
|
|
})
|
|
|
|
watch(applications, () => {
|
|
selectedIds.value = selectedIds.value.filter((id) =>
|
|
applications.value.some((item) => item.id === id && item.status === 'PENDING_NOTIFICATION'),
|
|
)
|
|
})
|
|
|
|
function isSelectable(item: MembershipApplicationListItem): boolean {
|
|
return item.status === 'PENDING_NOTIFICATION'
|
|
}
|
|
|
|
function isSelected(id: string): boolean {
|
|
return selectedIds.value.includes(id)
|
|
}
|
|
|
|
function toggleSelection(id: string) {
|
|
if (selectedIds.value.includes(id)) {
|
|
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id)
|
|
return
|
|
}
|
|
|
|
selectedIds.value = [...selectedIds.value, id]
|
|
}
|
|
|
|
function toggleSelectAllOnPage(checked: boolean) {
|
|
if (!checked) {
|
|
const pageIds = new Set(selectableApplications.value.map((item) => item.id))
|
|
selectedIds.value = selectedIds.value.filter((id) => !pageIds.has(id))
|
|
return
|
|
}
|
|
|
|
const merged = new Set([
|
|
...selectedIds.value,
|
|
...selectableApplications.value.map((item) => item.id),
|
|
])
|
|
selectedIds.value = [...merged]
|
|
}
|
|
|
|
function openBatchConfirm() {
|
|
if (!selectedIds.value.length) return
|
|
batchConfirmOpen.value = true
|
|
}
|
|
|
|
async function confirmBatchComplete() {
|
|
if (!selectedIds.value.length || batchSubmitting.value) return
|
|
|
|
batchSubmitting.value = true
|
|
batchSuccessMessage.value = null
|
|
batchFailedItems.value = []
|
|
error.value = null
|
|
|
|
try {
|
|
const response = await batchCompleteMembershipApplications(selectedIds.value)
|
|
|
|
if (response.success) {
|
|
batchSuccessMessage.value = response.message
|
|
batchFailedItems.value = response.data.failed
|
|
selectedIds.value = []
|
|
batchConfirmOpen.value = false
|
|
await fetchApplications(page.value)
|
|
} else {
|
|
error.value = response.message
|
|
batchFailedItems.value = response.data.failed
|
|
}
|
|
} catch (err) {
|
|
if (axios.isAxiosError(err) && err.response?.data) {
|
|
const responseData = err.response.data as BatchCompleteResponse
|
|
batchFailedItems.value = responseData.data?.failed ?? []
|
|
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
|
} else {
|
|
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
|
}
|
|
} finally {
|
|
batchSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
function setStatusFilterValue(details: { value: string[] }) {
|
|
statusFilter.value = labelToApiValue(STATUS_FILTER_OPTIONS, details.value[0])
|
|
}
|
|
|
|
const statusFilterInitial = computed(() => apiValueToLabel(STATUS_FILTER_OPTIONS, statusFilter.value))
|
|
|
|
function statusLabel(status: MembershipApplicationStatus): string {
|
|
const labels: Record<MembershipApplicationStatus, string> = {
|
|
SUBMITTED: 'Dihantar',
|
|
PENDING_BOARD: 'Menunggu Lembaga',
|
|
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
|
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
|
COMPLETED: 'Selesai',
|
|
}
|
|
|
|
return labels[status] ?? status
|
|
}
|
|
|
|
function statusBadgeVariant(status: MembershipApplicationStatus) {
|
|
if (status === 'COMPLETED') return 'success'
|
|
if (status === 'MANAGEMENT_REJECTED') return 'danger'
|
|
if (status === 'PENDING_BOARD' || status === 'PENDING_NOTIFICATION') return 'pending'
|
|
return 'outline'
|
|
}
|
|
|
|
function boardResultLabel(result: MembershipApplicationBoardResult | null): string {
|
|
if (result === 'PASS') return 'Lulus'
|
|
if (result === 'FAIL') return 'Gagal'
|
|
return '-'
|
|
}
|
|
|
|
function boardResultBadgeVariant(result: MembershipApplicationBoardResult | null) {
|
|
if (result === 'PASS') return 'success'
|
|
if (result === 'FAIL') return 'danger'
|
|
return 'outline'
|
|
}
|
|
|
|
function formatSubmittedAt(value: string | null): string {
|
|
if (!value) return '-'
|
|
return dayjs(value).format('DD/MM/YYYY HH:mm')
|
|
}
|
|
|
|
function goToApplicationDetail(id: string) {
|
|
router.push({ name: 'view-membership-application', params: { id } })
|
|
}
|
|
|
|
function goToApplicationEdit(id: string) {
|
|
router.push({ name: 'edit-membership-application', params: { id } })
|
|
}
|
|
|
|
const headers = computed<TableHeader[]>(() => {
|
|
const base: TableHeader[] = [
|
|
{ title: 'Bil.', key: '#', sortable: false },
|
|
{ title: 'No. Permohonan', key: 'application_number', sortable: true },
|
|
{
|
|
title: 'Nama Pemohon',
|
|
key: 'applicant_name',
|
|
sortable: false,
|
|
exportValue: (item) => item.applicant?.name ?? '',
|
|
},
|
|
{
|
|
title: 'Emel',
|
|
key: 'applicant_email',
|
|
sortable: false,
|
|
exportValue: (item) => item.applicant?.email ?? '',
|
|
},
|
|
{
|
|
title: 'No. IC',
|
|
key: 'applicant_ic_number',
|
|
sortable: false,
|
|
exportValue: (item) => item.applicant?.ic_number ?? '',
|
|
},
|
|
{
|
|
title: 'Status',
|
|
key: 'status',
|
|
sortable: true,
|
|
exportValue: (item) => statusLabel(item.status),
|
|
},
|
|
{
|
|
title: 'Keputusan Lembaga',
|
|
key: 'board_result',
|
|
sortable: false,
|
|
exportValue: (item) => boardResultLabel(item.board_result),
|
|
},
|
|
{
|
|
title: 'Tarikh Hantar',
|
|
key: 'submitted_at',
|
|
sortable: true,
|
|
exportValue: (item) => formatSubmittedAt(item.submitted_at),
|
|
},
|
|
{ title: 'Tindakan', key: 'actions', sortable: false },
|
|
]
|
|
|
|
if (canBatchComplete.value) {
|
|
return [{ title: '', key: 'select', sortable: false, width: 48 }, ...base]
|
|
}
|
|
|
|
return base
|
|
})
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="w-full space-y-6">
|
|
<div>
|
|
<h2 class="text-lg font-medium">Senarai Permohonan Keahlian</h2>
|
|
<p class="mt-1 text-sm opacity-70">Urus dan semak permohonan keahlian koperasi.</p>
|
|
</div>
|
|
|
|
<AlertRoot v-if="batchSuccessMessage" variant="success">
|
|
<CircleCheck />
|
|
<AlertTitle>Berjaya</AlertTitle>
|
|
<AlertDescription>{{ batchSuccessMessage }}</AlertDescription>
|
|
<AlertCloseTrigger @click="batchSuccessMessage = null" />
|
|
</AlertRoot>
|
|
|
|
<AlertRoot v-if="batchFailedItems.length" variant="warning">
|
|
<CircleAlert />
|
|
<AlertTitle>Sebahagian Permohonan Gagal</AlertTitle>
|
|
<AlertDescription>
|
|
<ul class="mt-2 list-disc space-y-1 ps-4 text-left">
|
|
<li v-for="item in batchFailedItems" :key="item.id">
|
|
{{ item.application_number ?? item.id }}: {{ item.message }}
|
|
</li>
|
|
</ul>
|
|
</AlertDescription>
|
|
<AlertCloseTrigger @click="batchFailedItems = []" />
|
|
</AlertRoot>
|
|
|
|
<AlertRoot v-if="error" variant="danger">
|
|
<CircleAlert />
|
|
<AlertTitle>Ralat</AlertTitle>
|
|
<AlertDescription>{{ error }}</AlertDescription>
|
|
<AlertCloseTrigger @click="error = null" />
|
|
</AlertRoot>
|
|
|
|
<DataTable
|
|
:headers="headers"
|
|
:items="applications"
|
|
:loading="loading"
|
|
:pagination="pagination"
|
|
:current-sort="sortBy"
|
|
show-pagination
|
|
exportable
|
|
export-file-name="permohonan-keahlian"
|
|
v-model:page="page"
|
|
v-model:items-per-page="itemsPerPage"
|
|
@update:sort-by="handleSortUpdate"
|
|
>
|
|
<template #toolbar>
|
|
<div class="flex w-full flex-wrap items-center gap-3">
|
|
<div class="relative w-full max-w-md flex-1">
|
|
<Search
|
|
class="pointer-events-none absolute top-1/2 left-3 z-10 size-4 -translate-y-1/2 text-foreground/50"
|
|
aria-hidden="true"
|
|
/>
|
|
<Input
|
|
v-model="search"
|
|
type="search"
|
|
placeholder="Cari no. permohonan, nama, emel, IC..."
|
|
class="w-full pl-9"
|
|
aria-label="Cari permohonan keahlian"
|
|
/>
|
|
</div>
|
|
|
|
<SelectRoot
|
|
class="w-full sm:w-56"
|
|
:collection="statusFilterCollection"
|
|
:default-value="statusFilterInitial"
|
|
@value-change="setStatusFilterValue"
|
|
>
|
|
<SelectControl>
|
|
<SelectTrigger aria-label="Tapis status">
|
|
<SelectValueText placeholder="Semua Status" />
|
|
</SelectTrigger>
|
|
</SelectControl>
|
|
<SelectContent>
|
|
<SelectItemGroup>
|
|
<SelectItemGroupLabel>Status</SelectItemGroupLabel>
|
|
<SelectItem
|
|
v-for="item in statusFilterCollection.items"
|
|
:key="item.label"
|
|
:item="item"
|
|
>
|
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
|
</SelectItem>
|
|
</SelectItemGroup>
|
|
</SelectContent>
|
|
</SelectRoot>
|
|
|
|
<template v-if="canBatchComplete">
|
|
<Button
|
|
type="button"
|
|
look="outline"
|
|
variant="secondary"
|
|
:disabled="!selectableApplications.length || loading"
|
|
@click="toggleSelectAllOnPage(!allSelectableSelected)"
|
|
>
|
|
{{ allSelectableSelected ? 'Nyahpilih Halaman' : 'Pilih Halaman' }}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
:disabled="!selectedIds.length || loading || batchSubmitting"
|
|
@click="openBatchConfirm"
|
|
>
|
|
Selesaikan Terpilih ({{ selectedIds.length }})
|
|
</Button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-if="canBatchComplete" #item.select="{ item }">
|
|
<CheckboxRoot
|
|
v-if="isSelectable(item as MembershipApplicationListItem)"
|
|
:checked="isSelected((item as MembershipApplicationListItem).id)"
|
|
@checked-change="({ checked }) => toggleSelection((item as MembershipApplicationListItem).id)"
|
|
>
|
|
<CheckboxControl />
|
|
</CheckboxRoot>
|
|
</template>
|
|
|
|
<template #item.applicant_name="{ item }">
|
|
<span class="font-medium">{{ (item as MembershipApplicationListItem).applicant?.name ?? '-' }}</span>
|
|
</template>
|
|
|
|
<template #item.applicant_email="{ item }">
|
|
<span class="lowercase">{{ (item as MembershipApplicationListItem).applicant?.email ?? '-' }}</span>
|
|
</template>
|
|
|
|
<template #item.applicant_ic_number="{ item }">
|
|
{{ (item as MembershipApplicationListItem).applicant?.ic_number ?? '-' }}
|
|
</template>
|
|
|
|
<template #item.status="{ item }">
|
|
<Badge
|
|
:variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
|
|
class="whitespace-nowrap"
|
|
>
|
|
{{ statusLabel((item as MembershipApplicationListItem).status) }}
|
|
</Badge>
|
|
</template>
|
|
|
|
<template #item.board_result="{ item }">
|
|
<Badge
|
|
v-if="(item as MembershipApplicationListItem).board_result"
|
|
:variant="boardResultBadgeVariant((item as MembershipApplicationListItem).board_result)"
|
|
class="whitespace-nowrap"
|
|
>
|
|
{{ boardResultLabel((item as MembershipApplicationListItem).board_result) }}
|
|
</Badge>
|
|
<span v-else class="opacity-50">-</span>
|
|
</template>
|
|
|
|
<template #item.submitted_at="{ item }">
|
|
{{ formatSubmittedAt((item as MembershipApplicationListItem).submitted_at) }}
|
|
</template>
|
|
|
|
<template #item.actions="{ item }">
|
|
<div class="flex items-center gap-2">
|
|
<Button
|
|
v-if="hasPermission('lihat permohonan keahlian')"
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
class="bg-green-600 text-white"
|
|
title="Lihat butiran permohonan"
|
|
@click="goToApplicationDetail((item as MembershipApplicationListItem).id)"
|
|
>
|
|
<Eye class="size-4" aria-hidden="true" />
|
|
</Button>
|
|
<Button
|
|
v-if="hasPermission('kemaskini permohonan keahlian')"
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
class="bg-blue-600 text-white"
|
|
title="Kemaskini permohonan"
|
|
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)"
|
|
>
|
|
<Pencil class="size-4" aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
|
|
<DialogRoot
|
|
:open="batchConfirmOpen"
|
|
@openChange="(details) => { batchConfirmOpen = details.open }"
|
|
>
|
|
<DialogContent>
|
|
<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.
|
|
</div>
|
|
</div>
|
|
<div class="px-5 pb-8 text-center">
|
|
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
|
|
Batal
|
|
</DialogCloseTrigger>
|
|
<Button
|
|
class="w-32"
|
|
type="button"
|
|
variant="primary"
|
|
:disabled="batchSubmitting"
|
|
@click="confirmBatchComplete"
|
|
>
|
|
{{ batchSubmitting ? 'Memproses...' : 'Sahkan' }}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</DialogRoot>
|
|
</div>
|
|
</template>
|