Files
My-KOPKB/fe/src/modules/membership-application/pages/MembershipApplicationList.vue
T

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>