DONE: layout letter with letterhead and footer, notification for newly...

This commit is contained in:
ISMAIL MASSERAN
2026-06-28 02:16:49 +00:00
parent 94ecbe5887
commit 034ecf947f
400 changed files with 29802 additions and 5446 deletions
@@ -0,0 +1,854 @@
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import * as select from '@zag-js/select'
import { RouterLink } from 'vue-router'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
SelectRoot,
SelectControl,
SelectTrigger,
SelectValueText,
SelectContent,
SelectItemGroup,
SelectItemGroupLabel,
SelectItem,
SelectItemText,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Lucide } from '@/components/ui/lucide'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
import type {
MembershipApplicationFormState,
MembershipApplicationHeirForm,
MembershipApplicationReferenceForm,
} from '../types/membership-application.types'
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
const MAX_FILE_SIZE_MB = 10
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
const steps = [
{ id: 1, label: 'Maklumat Peribadi' },
{ id: 2, label: 'Hubungan & Alamat' },
{ id: 3, label: 'Maklumat Pekerjaan' },
{ id: 4, label: 'Maklumat Waris' },
{ id: 5, label: 'Dokumen & Hantar' },
] as const
const currentStep = ref(1)
const loading = ref(false)
const submitted = ref(false)
const applicationNumber = ref('')
const errorMessage = ref('')
const fieldErrors = reactive<Record<string, string>>({})
type SelectOption = { label: string; value: string }
const GENDER_OPTIONS: SelectOption[] = [
{ label: 'Lelaki', value: 'Lelaki' },
{ label: 'Perempuan', value: 'Perempuan' },
]
const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
{ label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
{ label: 'Berkahwin', value: 'Berkahwin' },
{ label: 'Bercerai', value: 'Bercerai' },
{ label: 'Balu', value: 'Balu' },
{ label: 'Duda', value: 'Duda' },
]
const RELATIONSHIP_OPTIONS: SelectOption[] = [
{ label: 'Isteri', value: 'Isteri' },
{ label: 'Suami', value: 'Suami' },
{ label: 'Anak', value: 'Anak' },
{ label: 'Bapa', value: 'Bapa' },
{ label: 'Ibu', value: 'Ibu' },
{ label: 'Orang Tua', value: 'Orang Tua' },
{ label: 'Saudara', value: 'Saudara' },
{ label: 'Lain-lain', value: 'Lain-lain' },
]
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 | undefined): string[] {
if (!value) return []
const option = options.find((item) => item.value === value)
return option ? [option.label] : [value]
}
const genderCollection = createSelectCollection(GENDER_OPTIONS)
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
function createEmptyReference(): MembershipApplicationReferenceForm {
return {
ic_number: '',
user_id: '',
name: '',
}
}
function createEmptyHeir(): MembershipApplicationHeirForm {
return {
name: '',
ic_number: '',
relationship: '',
phone_number: '',
}
}
const form = reactive<MembershipApplicationFormState>({
applicant: {
name: '',
email: '',
ic_number: '',
birth_date: '',
birth_place: '',
gender: '',
marriage_status: '',
address: '',
phone_number: '',
office_number: '',
postcode: '',
employer_name: '',
employer_address: '',
current_position: '',
start_work_date: '',
stock_monthly_contribution: '',
fee_monthly_contribution: '',
},
heirs: [createEmptyHeir()],
references: {
proposer: createEmptyReference(),
supporter: createEmptyReference(),
},
documents: {
ic_copy: null,
photo: null,
salary_slip: null,
employer_letter: null,
},
})
const genderInitial = computed(() => apiValueToLabel(GENDER_OPTIONS, form.applicant.gender))
const marriageStatusInitial = computed(() =>
apiValueToLabel(MARRIAGE_STATUS_OPTIONS, form.applicant.marriage_status),
)
function setGenderValue(details: { value: string[] }) {
form.applicant.gender = labelToApiValue(GENDER_OPTIONS, details.value[0])
delete fieldErrors['applicant.gender']
}
function setMarriageStatusValue(details: { value: string[] }) {
form.applicant.marriage_status = labelToApiValue(MARRIAGE_STATUS_OPTIONS, details.value[0])
delete fieldErrors['applicant.marriage_status']
}
function setHeirRelationshipValue(index: number, details: { value: string[] }) {
const heir = form.heirs[index]
if (!heir) return
heir.relationship = labelToApiValue(RELATIONSHIP_OPTIONS, details.value[0])
delete fieldErrors[`heirs.${index}.relationship`]
}
const referenceLookupLoading = reactive({
proposer: false,
supporter: false,
})
function clearReference(role: 'proposer' | 'supporter') {
form.references[role].user_id = ''
form.references[role].name = ''
delete fieldErrors[`references.${role}_ic_number`]
}
function handleReferenceIcInput(role: 'proposer' | 'supporter') {
clearReference(role)
}
async function lookupReference(role: 'proposer' | 'supporter') {
const reference = form.references[role]
const icNumber = reference.ic_number.trim()
const fieldKey = `references.${role}_ic_number`
delete fieldErrors[fieldKey]
if (!icNumber) {
clearReference(role)
return
}
if (icNumber === form.applicant.ic_number.trim()) {
clearReference(role)
setError(fieldKey, 'Pencadang/penyokong tidak boleh sama dengan pemohon.')
return
}
const otherRole = role === 'proposer' ? 'supporter' : 'proposer'
if (icNumber === form.references[otherRole].ic_number.trim()) {
clearReference(role)
setError(fieldKey, 'Pencadang dan penyokong mestilah ahli yang berbeza.')
return
}
referenceLookupLoading[role] = true
try {
const response = await lookupMemberByIcNumber(icNumber)
if (!response.success || !response.data) {
clearReference(role)
setError(fieldKey, response.message || 'Ahli tidak dijumpai.')
return
}
reference.user_id = response.data.id
reference.name = response.data.name
reference.ic_number = response.data.ic_number
} catch (error) {
clearReference(role)
setError(fieldKey, getApiErrorMessage(error, 'Gagal mencari ahli.'))
} finally {
referenceLookupLoading[role] = false
}
}
function validateReference(role: 'proposer' | 'supporter', label: string): boolean {
const reference = form.references[role]
const fieldKey = `references.${role}_ic_number`
if (!reference.ic_number.trim()) {
return true
}
if (!reference.user_id) {
setError(fieldKey, `${label} tidak dijumpai. Sila semak no. KP.`)
return false
}
return true
}
const stepTitle = computed(() => {
switch (currentStep.value) {
case 1:
return 'Maklumat Peribadi'
case 2:
return 'Hubungan & Alamat'
case 3:
return 'Maklumat Pekerjaan & Caruman'
case 4:
return 'Maklumat Waris'
default:
return 'Dokumen & Pengesahan'
}
})
const stepDescription = computed(() => {
switch (currentStep.value) {
case 1:
return 'Sila isi maklumat peribadi anda dengan lengkap dan tepat.'
case 2:
return 'Masukkan maklumat hubungan dan alamat semasa.'
case 3:
return 'Masukkan maklumat pekerjaan dan caruman bulanan.'
case 4:
return 'Tambah sekurang-kurangnya satu waris.'
default:
return 'Muat naik dokumen sokongan dan semak maklumat sebelum hantar.'
}
})
function clearErrors() {
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
errorMessage.value = ''
}
function setError(key: string, message: string) {
fieldErrors[key] = message
}
function validateStep(step: number): boolean {
clearErrors()
let valid = true
const requireField = (key: string, value: string | number | null | undefined, label: string) => {
if (!String(value ?? '').trim()) {
setError(key, `${label} diperlukan.`)
valid = false
}
}
if (step === 1) {
requireField('applicant.name', form.applicant.name, 'Nama penuh')
requireField('applicant.email', form.applicant.email, 'Emel')
requireField('applicant.ic_number', form.applicant.ic_number, 'No. kad pengenalan')
requireField('applicant.birth_date', form.applicant.birth_date, 'Tarikh lahir')
requireField('applicant.birth_place', form.applicant.birth_place, 'Tempat lahir')
requireField('applicant.gender', form.applicant.gender, 'Jantina')
requireField('applicant.marriage_status', form.applicant.marriage_status, 'Status perkahwinan')
if (form.applicant.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.applicant.email)) {
setError('applicant.email', 'Emel tidak sah.')
valid = false
}
}
if (step === 2) {
requireField('applicant.address', form.applicant.address, 'Alamat')
requireField('applicant.phone_number', form.applicant.phone_number, 'No. telefon')
requireField('applicant.postcode', form.applicant.postcode, 'Poskod')
}
if (step === 3) {
requireField('applicant.employer_name', form.applicant.employer_name, 'Nama majikan')
requireField('applicant.employer_address', form.applicant.employer_address, 'Alamat majikan')
requireField('applicant.current_position', form.applicant.current_position, 'Jawatan semasa')
requireField('applicant.start_work_date', form.applicant.start_work_date, 'Tarikh mula berkhidmat')
requireField(
'applicant.stock_monthly_contribution',
form.applicant.stock_monthly_contribution,
'Caruman saham bulanan',
)
requireField(
'applicant.fee_monthly_contribution',
form.applicant.fee_monthly_contribution,
'Caruman yuran bulanan',
)
}
if (step === 4) {
form.heirs.forEach((heir, index) => {
requireField(`heirs.${index}.name`, heir.name, `Nama waris ${index + 1}`)
requireField(`heirs.${index}.ic_number`, heir.ic_number, `No. KP waris ${index + 1}`)
requireField(`heirs.${index}.relationship`, heir.relationship, `Hubungan waris ${index + 1}`)
requireField(`heirs.${index}.phone_number`, heir.phone_number, `No. telefon waris ${index + 1}`)
})
}
if (step === 5) {
if (!validateReference('proposer', 'Pencadang')) {
valid = false
}
if (!validateReference('supporter', 'Penyokong')) {
valid = false
}
if (!form.documents.ic_copy) {
setError('documents.ic_copy', 'Salinan kad pengenalan diperlukan.')
valid = false
}
}
return valid
}
function goNext() {
if (!validateStep(currentStep.value)) return
if (currentStep.value < steps.length) {
currentStep.value += 1
}
}
function goPrevious() {
clearErrors()
if (currentStep.value > 1) {
currentStep.value -= 1
}
}
function addHeir() {
form.heirs.push(createEmptyHeir())
}
function removeHeir(index: number) {
if (form.heirs.length <= 1) return
form.heirs.splice(index, 1)
}
function handleFileChange(
key: keyof MembershipApplicationFormState['documents'],
event: Event,
) {
const target = event.target as HTMLInputElement
const file = target.files?.[0] ?? null
if (file && file.size > MAX_FILE_SIZE_BYTES) {
form.documents[key] = null
target.value = ''
setError(`documents.${key}`, `Saiz fail melebihi had maksimum ${MAX_FILE_SIZE_MB}MB.`)
return
}
form.documents[key] = file
delete fieldErrors[`documents.${key}`]
}
function applyServerErrors(errors: Record<string, string[]>) {
Object.entries(errors).forEach(([key, messages]) => {
if (messages[0]) {
fieldErrors[key] = messages[0]
}
})
const stepByField: Record<string, number> = {
applicant: 1,
heirs: 4,
references: 5,
documents: 5,
}
const firstKey = Object.keys(errors)[0]
if (!firstKey) return
const prefix = firstKey.split('.')[0] ?? ''
const step = stepByField[prefix]
if (step !== undefined) {
currentStep.value = step
}
}
async function handleSubmit() {
if (!validateStep(5)) return
loading.value = true
clearErrors()
try {
const response = await submitMembershipApplication(form)
applicationNumber.value = response.data.application_number
submitted.value = true
} catch (error) {
const validationErrors = getApiValidationErrors(error)
if (validationErrors) {
applyServerErrors(validationErrors)
errorMessage.value = 'Sila semak maklumat yang ditandakan.'
} else {
errorMessage.value = getApiErrorMessage(error, 'Gagal menghantar permohonan.')
}
} finally {
loading.value = false
}
}
function stepButtonClass(stepId: number) {
if (stepId === currentStep.value) {
return 'mx-2 size-12 rounded-full shadow-none'
}
if (stepId < currentStep.value) {
return 'mx-2 size-12 rounded-full shadow-none bg-primary text-primary-foreground'
}
return 'bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full'
}
function stepLabelClass(stepId: number) {
return stepId === currentStep.value
? 'text-primary ml-3 font-medium opacity-100 lg:mx-auto lg:mt-3 lg:w-32'
: 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32'
}
</script>
<template>
<div class="min-h-screen bg-background">
<div class="border-b border-foreground/10 bg-primary/5">
<div class="container mx-auto flex items-center justify-between px-5 py-4 sm:px-8">
<div class="flex items-center gap-4">
<img :src="illustrationUrl" alt="MyKOPKB" class="h-10 w-auto" />
<div>
<div class="text-lg font-semibold">Permohonan Keahlian</div>
<div class="text-sm opacity-70">Borang permohonan ahli koperasi</div>
</div>
</div>
<RouterLink to="/login">
<Button look="outline" size="sm">Log Masuk</Button>
</RouterLink>
</div>
</div>
<div v-if="submitted" class="container mx-auto px-5 py-16 sm:px-8">
<Box class="mx-auto max-w-2xl py-12 text-center">
<div class="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-success/10 text-success">
<Lucide icon="CircleCheck" class="size-8" />
</div>
<h2 class="text-2xl font-semibold">Permohonan Berjaya Dihantar</h2>
<p class="mt-3 opacity-70">
Terima kasih. Permohonan keahlian anda telah diterima.
</p>
<div class="mt-6 rounded-lg border border-foreground/10 bg-foreground/5 px-6 py-4">
<div class="text-sm opacity-70">No. Permohonan</div>
<div class="mt-1 text-xl font-semibold text-primary">{{ applicationNumber }}</div>
</div>
<p class="mt-6 text-sm opacity-70">
E-mel permohonan anda telah dihantar. Anda akan menerima emel
apabila keputusan permohonan tersedia.
</p>
<RouterLink to="/login" class="mt-8 inline-block">
<Button>Kembali ke Log Masuk</Button>
</RouterLink>
</Box>
</div>
<div v-else class="container mx-auto px-5 py-8 sm:px-8">
<Box class="py-10 sm:py-16">
<div
class="before:bg-foreground/10 relative flex flex-col justify-center px-5 before:absolute before:bottom-0 before:top-0 before:mt-6 before:hidden before:h-0.5 before:w-[69%] sm:px-10 lg:flex-row before:lg:block">
<div v-for="step in steps" :key="step.id" class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'default' : 'ghost'">
{{ step.id }}
</Button>
<div :class="stepLabelClass(step.id)">
{{ step.label }}
</div>
</div>
</div>
<div class="mt-10 border-t border-foreground/10 px-5 pt-10 sm:px-10">
<div class="text-center lg:text-left">
<div class="text-lg font-medium">{{ stepTitle }}</div>
<div class="mt-2 opacity-70">{{ stepDescription }}</div>
</div>
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription>
</AlertRoot>
<div class="mt-8 grid grid-cols-12 gap-4 gap-y-5">
<template v-if="currentStep === 1">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="name">Nama Penuh</FieldLabel>
<Input id="name" v-model="form.applicant.name" type="text" />
<FieldError v-if="fieldErrors['applicant.name']">{{ fieldErrors['applicant.name'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="email">Emel</FieldLabel>
<Input id="email" v-model="form.applicant.email" type="email" />
<FieldError v-if="fieldErrors['applicant.email']">{{ fieldErrors['applicant.email'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel>
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" />
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="birth_date">Tarikh Lahir</FieldLabel>
<Input id="birth_date" v-model="form.applicant.birth_date" type="date" />
<FieldError v-if="fieldErrors['applicant.birth_date']">{{ fieldErrors['applicant.birth_date'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="birth_place">Tempat Lahir</FieldLabel>
<Input id="birth_place" v-model="form.applicant.birth_place" type="text" />
<FieldError v-if="fieldErrors['applicant.birth_place']">{{ fieldErrors['applicant.birth_place'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Jantina</FieldLabel>
<SelectRoot :key="`gender-${form.applicant.gender}`" class="w-full" :collection="genderCollection"
:default-value="genderInitial" @value-change="setGenderValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.gender']">
<SelectValueText placeholder="Pilih jantina" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors['applicant.gender']">{{ fieldErrors['applicant.gender'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Status Perkahwinan</FieldLabel>
<SelectRoot :key="`marriage-status-${form.applicant.marriage_status}`" class="w-full"
:collection="marriageStatusCollection" :default-value="marriageStatusInitial"
@value-change="setMarriageStatusValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.marriage_status']">
<SelectValueText placeholder="Pilih status" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors['applicant.marriage_status']">
{{ fieldErrors['applicant.marriage_status'] }}
</FieldError>
</Field>
</template>
<template v-else-if="currentStep === 2">
<Field class="col-span-12">
<FieldLabel for="address">Alamat</FieldLabel>
<Textarea id="address" v-model="form.applicant.address" rows="3" />
<FieldError v-if="fieldErrors['applicant.address']">{{ fieldErrors['applicant.address'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="phone_number">No. Telefon</FieldLabel>
<Input id="phone_number" v-model="form.applicant.phone_number" type="text" />
<FieldError v-if="fieldErrors['applicant.phone_number']">
{{ fieldErrors['applicant.phone_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="office_number">No. Pejabat (Pilihan)</FieldLabel>
<Input id="office_number" v-model="form.applicant.office_number" type="text" />
<FieldError v-if="fieldErrors['applicant.office_number']">
{{ fieldErrors['applicant.office_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="postcode">Poskod</FieldLabel>
<Input id="postcode" v-model="form.applicant.postcode" type="text" />
<FieldError v-if="fieldErrors['applicant.postcode']">{{ fieldErrors['applicant.postcode'] }}
</FieldError>
</Field>
</template>
<template v-else-if="currentStep === 3">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="employer_name">Nama Majikan</FieldLabel>
<Input id="employer_name" v-model="form.applicant.employer_name" type="text" />
<FieldError v-if="fieldErrors['applicant.employer_name']">
{{ fieldErrors['applicant.employer_name'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="current_position">Jawatan Semasa</FieldLabel>
<Input id="current_position" v-model="form.applicant.current_position" type="text" />
<FieldError v-if="fieldErrors['applicant.current_position']">
{{ fieldErrors['applicant.current_position'] }}
</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" />
<FieldError v-if="fieldErrors['applicant.employer_address']">
{{ fieldErrors['applicant.employer_address'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
<Input id="start_work_date" v-model="form.applicant.start_work_date" type="date" />
<FieldError v-if="fieldErrors['applicant.start_work_date']">
{{ fieldErrors['applicant.start_work_date'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="stock_monthly_contribution">Caruman Saham (RM)</FieldLabel>
<Input id="stock_monthly_contribution" v-model="form.applicant.stock_monthly_contribution" type="number"
min="0" step="0.01" />
<FieldError v-if="fieldErrors['applicant.stock_monthly_contribution']">
{{ fieldErrors['applicant.stock_monthly_contribution'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="fee_monthly_contribution">Caruman Yuran (RM)</FieldLabel>
<Input id="fee_monthly_contribution" v-model="form.applicant.fee_monthly_contribution" type="number"
min="0" step="0.01" />
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">
{{ fieldErrors['applicant.fee_monthly_contribution'] }}
</FieldError>
</Field>
</template>
<template v-else-if="currentStep === 4">
<div class="col-span-12 space-y-6">
<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>
<Button v-if="form.heirs.length > 1" type="button" look="outline" size="sm"
@click="removeHeir(index)">
Buang
</Button>
</div>
<div class="grid grid-cols-12 gap-4">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-name-${index}`">Nama</FieldLabel>
<Input :id="`heir-name-${index}`" v-model="heir.name" type="text" />
<FieldError v-if="fieldErrors[`heirs.${index}.name`]">
{{ fieldErrors[`heirs.${index}.name`] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" />
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">
{{ fieldErrors[`heirs.${index}.ic_number`] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Hubungan</FieldLabel>
<SelectRoot :key="`heir-relationship-${index}-${heir.relationship}`" class="w-full"
:collection="relationshipCollection"
:default-value="apiValueToLabel(RELATIONSHIP_OPTIONS, heir.relationship)"
@value-change="(details) => setHeirRelationshipValue(index, details)">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors[`heirs.${index}.relationship`]">
<SelectValueText placeholder="Pilih hubungan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
<SelectItem v-for="item in relationshipCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors[`heirs.${index}.relationship`]">
{{ fieldErrors[`heirs.${index}.relationship`] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel :for="`heir-phone-${index}`">No. Telefon</FieldLabel>
<Input :id="`heir-phone-${index}`" v-model="heir.phone_number" type="text" />
<FieldError v-if="fieldErrors[`heirs.${index}.phone_number`]">
{{ fieldErrors[`heirs.${index}.phone_number`] }}
</FieldError>
</Field>
</div>
</div>
<Button type="button" look="outline" @click="addHeir">
<Lucide icon="Plus" class="mr-2 size-4" />
Tambah Waris
</Button>
</div>
</template>
<template v-else>
<div class="col-span-12 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
<div class="mb-1 font-medium">Pencadang & Penyokong (Pilihan)</div>
<p class="text-sm opacity-70">
Masukkan no. KP ahli sedia ada. Sistem akan mengesahkan dan mengisi nama secara automatik.
</p>
</div>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
placeholder="Contoh: 900101011234" :disabled="referenceLookupLoading.proposer"
@input="handleReferenceIcInput('proposer')" @blur="lookupReference('proposer')" />
<p v-if="form.references.proposer.name" class="mt-1 text-sm text-success">
{{ form.references.proposer.name }}
</p>
<FieldError v-if="fieldErrors['references.proposer_ic_number']">
{{ fieldErrors['references.proposer_ic_number'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
placeholder="Contoh: 850505055678" :disabled="referenceLookupLoading.supporter"
@input="handleReferenceIcInput('supporter')" @blur="lookupReference('supporter')" />
<p v-if="form.references.supporter.name" class="mt-1 text-sm text-success">
{{ form.references.supporter.name }}
</p>
<FieldError v-if="fieldErrors['references.supporter_ic_number']">
{{ fieldErrors['references.supporter_ic_number'] }}
</FieldError>
</Field>
<div class="col-span-12 rounded-lg border border-foreground/10 bg-foreground/5 p-4 text-sm opacity-80">
Saiz maksimum setiap fail: <strong>{{ MAX_FILE_SIZE_MB }}MB</strong>.
Format yang dibenarkan: PDF, JPG, JPEG atau PNG.
</div>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="ic_copy">Salinan Kad Pengenalan *</FieldLabel>
<Input id="ic_copy" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('ic_copy', $event)" />
<FieldError v-if="fieldErrors['documents.ic_copy']">{{ fieldErrors['documents.ic_copy'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="photo">Gambar Passport (Pilihan)</FieldLabel>
<Input id="photo" type="file" accept=".jpg,.jpeg,.png" @change="handleFileChange('photo', $event)" />
<FieldError v-if="fieldErrors['documents.photo']">{{ fieldErrors['documents.photo'] }}</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="salary_slip">Slip Gaji (Pilihan)</FieldLabel>
<Input id="salary_slip" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('salary_slip', $event)" />
<FieldError v-if="fieldErrors['documents.salary_slip']">{{ fieldErrors['documents.salary_slip'] }}
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="employer_letter">Surat Pengesahan Majikan (Pilihan)</FieldLabel>
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('employer_letter', $event)" />
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
}}</FieldError>
</Field>
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
<div class="mb-3 font-medium">Semakan Ringkas</div>
<div class="grid gap-2 text-sm sm:grid-cols-2">
<div><span class="opacity-70">Nama:</span> {{ form.applicant.name }}</div>
<div><span class="opacity-70">Emel:</span> {{ form.applicant.email }}</div>
<div><span class="opacity-70">No. KP:</span> {{ form.applicant.ic_number }}</div>
<div><span class="opacity-70">Majikan:</span> {{ form.applicant.employer_name }}</div>
<div><span class="opacity-70">Bil. Waris:</span> {{ form.heirs.length }}</div>
<div>
<span class="opacity-70">Pencadang:</span>
{{ form.references.proposer.name || '-' }}
</div>
<div>
<span class="opacity-70">Penyokong:</span>
{{ form.references.supporter.name || '-' }}
</div>
<div><span class="opacity-70">Dokumen IC:</span> {{ form.documents.ic_copy?.name ?? '-' }}</div>
</div>
</div>
</template>
<div class="col-span-12 mt-5 flex items-center justify-center sm:justify-end">
<Button v-if="currentStep > 1" type="button" look="outline" class="w-32" :disabled="loading"
@click="goPrevious">
Sebelum
</Button>
<Button v-if="currentStep < steps.length" type="button" class="ml-2 w-32" @click="goNext">
Seterusnya
</Button>
<Button v-else type="button" class="ml-2 w-40" :disabled="loading" @click="handleSubmit">
{{ loading ? 'Menghantar...' : 'Hantar Permohonan' }}
</Button>
</div>
</div>
</div>
</Box>
</div>
</div>
</template>
@@ -0,0 +1,935 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { CircleAlert, CircleCheck, Download, Eye, Pencil } from '@lucide/vue'
import {
AlertRoot,
AlertTitle,
AlertDescription,
AlertCloseTrigger,
} from '@/components/ui/alert'
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, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { usePermissions } from '@/composables/usePermissions'
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
import {
completeMembershipApplication,
downloadMembershipApplicationDocument,
fetchMembershipApplicationDocument,
getMembershipApplication,
submitBoardReview,
submitManagementReview,
} from '../services/membership-application.service'
import type {
BoardReviewDecision,
ManagementReviewDecision,
MembershipApplicationDetail,
MembershipApplicationDocumentDetail,
MembershipApplicationReferenceDetail,
MembershipApplicationReviewDetail,
MembershipApplicationStatus,
} from '../types/membership-application.types'
const WORKFLOW_STEPS = [
{ id: 1, label: 'Dihantar' },
{ id: 2, label: 'Semakan Pentadbiran' },
{ id: 3, label: 'Semakan Lembaga' },
{ id: 4, label: 'Makluman Keputusan' },
{ id: 5, label: 'Selesai' },
] as const
const DOCUMENT_TYPE_LABELS: Record<string, string> = {
ic_copy: 'Salinan Kad Pengenalan',
photo: 'Gambar Passport',
salary_slip: 'Slip Gaji',
employer_letter: 'Surat Pengesahan Majikan',
}
const router = useRouter()
const route = useRoute()
const { hasPermission } = usePermissions()
const applicationId = computed(() => String(route.params.id ?? ''))
const loading = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const application = ref<MembershipApplicationDetail | null>(null)
const managementRemarks = ref('')
const boardRemarks = ref('')
const reviewSubmitting = ref(false)
const completeSubmitting = ref(false)
const confirmDialogOpen = ref(false)
const pendingAction = ref<
| { type: 'management'; decision: ManagementReviewDecision }
| { type: 'board'; decision: BoardReviewDecision }
| { type: 'complete' }
| null
>(null)
const downloadingDocumentId = ref<string | null>(null)
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewUrl = ref<string | null>(null)
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
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 getWorkflowProgress(status: MembershipApplicationStatus) {
switch (status) {
case 'SUBMITTED':
return { currentStep: 2, failed: false, failedStep: null as number | null }
case 'MANAGEMENT_REJECTED':
return { currentStep: 2, failed: true, failedStep: 2 }
case 'PENDING_BOARD':
return { currentStep: 3, failed: false, failedStep: null }
case 'PENDING_NOTIFICATION':
return { currentStep: 4, failed: false, failedStep: null }
case 'COMPLETED':
return { currentStep: 6, failed: false, failedStep: null }
default:
return { currentStep: 1, failed: false, failedStep: null }
}
}
const workflowProgress = computed(() =>
application.value ? getWorkflowProgress(application.value.status) : null,
)
const showManagementReview = computed(
() =>
hasPermission('semak permohonan keahlian pentadbiran') &&
application.value?.status === 'SUBMITTED',
)
const showBoardReview = computed(
() =>
hasPermission('semak permohonan keahlian lembaga') &&
application.value?.status === 'PENDING_BOARD',
)
const showCompleteAction = computed(
() =>
hasPermission('selesaikan permohonan keahlian') &&
application.value?.status === 'PENDING_NOTIFICATION',
)
const confirmDialogTitle = computed(() => {
if (!pendingAction.value) return 'Sahkan Tindakan'
if (pendingAction.value.type === 'management') {
return pendingAction.value.decision === 'APPROVED'
? 'Luluskan Permohonan?'
: 'Tolak Permohonan?'
}
if (pendingAction.value.type === 'board') {
return pendingAction.value.decision === 'PASS'
? 'Luluskan Semakan Lembaga?'
: 'Gagalkan Semakan Lembaga?'
}
return 'Selesaikan Permohonan?'
})
const confirmDialogDescription = computed(() => {
if (!pendingAction.value) return ''
if (pendingAction.value.type === 'management') {
return pendingAction.value.decision === 'APPROVED'
? 'Permohonan akan dihantar ke semakan lembaga.'
: 'Permohonan akan ditolak pada peringkat pentadbiran.'
}
if (pendingAction.value.type === 'board') {
return pendingAction.value.decision === 'PASS'
? 'Permohonan akan dihantar ke peringkat makluman keputusan.'
: 'Permohonan akan ditandakan gagal semakan lembaga.'
}
return 'E-mel keputusan akan dihantar kepada pemohon. Akaun ahli akan dicipta jika permohonan lulus.'
})
function workflowStepButtonClass(stepId: number) {
const progress = workflowProgress.value
if (!progress) return 'mx-2 size-12 rounded-full shadow-none bg-background border border-foreground/15'
if (progress.failed && stepId === progress.failedStep) {
return 'mx-2 size-12 rounded-full shadow-none bg-danger text-danger-foreground'
}
if (stepId < progress.currentStep) {
return 'mx-2 size-12 rounded-full shadow-none bg-primary text-primary-foreground'
}
if (stepId === progress.currentStep && application.value?.status !== 'COMPLETED') {
return 'mx-2 size-12 rounded-full shadow-none'
}
if (application.value?.status === 'COMPLETED' || stepId < progress.currentStep) {
return 'mx-2 size-12 rounded-full shadow-none bg-primary text-primary-foreground'
}
return 'bg-background border border-foreground/15 shadow-none mx-2 size-12 rounded-full'
}
function workflowStepLabelClass(stepId: number) {
const progress = workflowProgress.value
const isCurrent =
progress &&
stepId === progress.currentStep &&
application.value?.status !== 'COMPLETED' &&
!progress.failed
return isCurrent
? 'text-primary ml-3 font-medium opacity-100 lg:mx-auto lg:mt-3 lg:w-32'
: 'ml-3 opacity-70 lg:mx-auto lg:mt-3 lg:w-32'
}
const applicant = computed(() => application.value?.applicant ?? null)
const sortedReviews = computed(() => {
if (!application.value?.reviews.length) return []
return [...application.value.reviews].sort((left, right) => {
const leftTime = left.reviewed_at ? new Date(left.reviewed_at).getTime() : 0
const rightTime = right.reviewed_at ? new Date(right.reviewed_at).getTime() : 0
return leftTime - rightTime
})
})
function displayValue(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') return '-'
return String(value)
}
function formatDate(value: string | null | undefined): string {
if (!value) return '-'
return dayjs(value).format('DD/MM/YYYY')
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return '-'
return dayjs(value).format('DD/MM/YYYY HH:mm')
}
function formatCurrency(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') return '-'
const amount = Number(value)
if (Number.isNaN(amount)) return String(value)
return `RM ${amount.toFixed(2)}`
}
function formatFileSize(bytes: number | null | undefined): string {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function documentLabel(type: string, name: string): string {
return DOCUMENT_TYPE_LABELS[type] ?? name
}
function isImageMimeType(mimeType: string | null | undefined): boolean {
return !!mimeType?.startsWith('image/')
}
function isPdfDocument(document: MembershipApplicationDocumentDetail): boolean {
return document.mime_type === 'application/pdf' || document.name.toLowerCase().endsWith('.pdf')
}
const isPreviewImage = computed(() => isImageMimeType(previewDocument.value?.mime_type))
const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previewDocument.value) : false))
function revokePreviewUrl() {
if (previewUrl.value) {
window.URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
}
function handlePreviewOpenChange(open: boolean) {
previewOpen.value = open
if (!open) {
revokePreviewUrl()
previewDocument.value = null
previewLoading.value = false
}
}
function getReference(type: 'PROPOSER' | 'SUPPORTER'): MembershipApplicationReferenceDetail | null {
return application.value?.references.find((reference) => reference.reference_type === type) ?? null
}
function reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Semakan Lembaga'
return stage
}
function reviewDecisionLabel(decision: string | null, stage: string): string {
if (!decision) return '-'
if (stage === 'MANAGEMENT') {
if (decision === 'APPROVED') return 'Diluluskan'
if (decision === 'REJECTED') return 'Ditolak'
}
if (stage === 'BOARD') {
if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal'
}
return decision
}
function reviewDecisionBadgeVariant(decision: string | null, stage: string) {
if (stage === 'MANAGEMENT') {
if (decision === 'APPROVED') return 'success'
if (decision === 'REJECTED') return 'danger'
}
if (stage === 'BOARD') {
if (decision === 'PASS') return 'success'
if (decision === 'FAIL') return 'danger'
}
return 'outline'
}
function reviewTimelineDotClass(review: MembershipApplicationReviewDetail): string {
const variant = reviewDecisionBadgeVariant(review.decision, review.stage)
if (variant === 'success') return 'border-success bg-success'
if (variant === 'danger') return 'border-danger bg-danger'
return 'border-primary bg-primary'
}
async function fetchApplication() {
loading.value = true
error.value = null
try {
const response = await getMembershipApplication(applicationId.value)
application.value = response.data
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuatkan butiran permohonan.')
application.value = null
} finally {
loading.value = false
}
}
function openConfirmAction(
action:
| { type: 'management'; decision: ManagementReviewDecision }
| { type: 'board'; decision: BoardReviewDecision }
| { type: 'complete' },
) {
pendingAction.value = action
confirmDialogOpen.value = true
}
function closeConfirmDialog() {
confirmDialogOpen.value = false
pendingAction.value = null
}
async function confirmPendingAction() {
if (!pendingAction.value || !application.value) return
error.value = null
successMessage.value = null
if (pendingAction.value.type === 'complete') {
completeSubmitting.value = true
} else {
reviewSubmitting.value = true
}
try {
let response
if (pendingAction.value.type === 'management') {
response = await submitManagementReview(applicationId.value, {
decision: pendingAction.value.decision,
remarks: managementRemarks.value.trim() || null,
})
managementRemarks.value = ''
} else if (pendingAction.value.type === 'board') {
response = await submitBoardReview(applicationId.value, {
decision: pendingAction.value.decision,
remarks: boardRemarks.value.trim() || null,
})
boardRemarks.value = ''
} else {
response = await completeMembershipApplication(applicationId.value)
}
application.value = response.data
successMessage.value = response.message
closeConfirmDialog()
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memproses tindakan.')
const validationErrors = getApiValidationErrors(err)
if (validationErrors?.remarks?.[0]) {
error.value = validationErrors.remarks[0]
}
} finally {
reviewSubmitting.value = false
completeSubmitting.value = false
}
}
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
if (!application.value || downloadingDocumentId.value) return
downloadingDocumentId.value = document.id
try {
await downloadMembershipApplicationDocument(
application.value.id,
document.id,
document.name,
document.mime_type,
)
} catch (err) {
error.value = getApiErrorMessage(err, 'Gagal memuat turun dokumen.')
} finally {
downloadingDocumentId.value = null
}
}
async function handleViewDocument(document: MembershipApplicationDocumentDetail) {
if (!application.value) return
previewDocument.value = document
previewOpen.value = true
previewLoading.value = true
revokePreviewUrl()
try {
const blob = await fetchMembershipApplicationDocument(
application.value.id,
document.id,
document.mime_type,
)
previewUrl.value = window.URL.createObjectURL(blob)
} catch (err) {
handlePreviewOpenChange(false)
error.value = getApiErrorMessage(err, 'Gagal memuatkan dokumen.')
} finally {
previewLoading.value = false
}
}
onMounted(() => {
fetchApplication()
})
onUnmounted(() => {
revokePreviewUrl()
})
</script>
<template>
<div class="w-full space-y-6">
<div class="flex flex-wrap items-center gap-3">
<div class="mr-auto">
<h2 class="text-lg font-medium">Butiran Permohonan Keahlian</h2>
<p v-if="application" class="mt-1 text-sm opacity-70">
{{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
</p>
</div>
<Button look="outline" variant="secondary" type="button"
@click="router.push({ name: 'list-membership-applications' })">
Kembali
</Button>
<Button v-if="hasPermission('kemaskini permohonan keahlian') && application?.status !== 'COMPLETED'" type="button"
@click="router.push({ name: 'edit-membership-application', params: { id: applicationId } })">
<Pencil class="mr-2 size-4" />
Kemaskini
</Button>
</div>
<AlertRoot v-if="successMessage" variant="success">
<CircleCheck />
<AlertTitle>Berjaya</AlertTitle>
<AlertDescription>{{ successMessage }}</AlertDescription>
<AlertCloseTrigger @click="successMessage = null" />
</AlertRoot>
<AlertRoot v-if="error" variant="danger">
<CircleAlert />
<AlertTitle>Ralat</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
<AlertCloseTrigger @click="error = null" />
</AlertRoot>
<div v-if="loading" class="opacity-70">Memuatkan butiran permohonan...</div>
<template v-else-if="application">
<Box class="p-5 sm:p-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm opacity-70">No. Permohonan</div>
<div class="text-xl font-semibold">{{ application.application_number }}</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="statusBadgeVariant(application.status)" class="whitespace-nowrap">
{{ statusLabel(application.status) }}
</Badge>
<Badge v-if="application.board_result"
:variant="application.board_result === 'PASS' ? 'success' : 'danger'">
Lembaga: {{ application.board_result === 'PASS' ? 'Lulus' : 'Gagal' }}
</Badge>
</div>
</div>
<div class="mt-4 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div>
<span class="opacity-70">Tarikh Hantar:</span>
{{ formatDateTime(application.submitted_at) }}
</div>
<div>
<span class="opacity-70">Tarikh Selesai:</span>
{{ formatDateTime(application.completed_at) }}
</div>
<div>
<span class="opacity-70">Emel Pemohon:</span>
<span class="lowercase">{{ displayValue(application.applicant?.email) }}</span>
</div>
<div>
<span class="opacity-70">No. IC:</span>
{{ displayValue(application.applicant?.ic_number) }}
</div>
</div>
</Box>
<Box class="py-8 sm:py-10">
<div class="px-5 sm:px-8">
<div class="text-sm font-medium opacity-70">Status Permohonan</div>
</div>
<div
class="before:bg-foreground/10 relative mt-4 flex flex-col justify-center px-5 before:absolute before:bottom-0 before:top-0 before:mt-6 before:hidden before:h-0.5 before:w-[69%] sm:px-8 lg:flex-row before:lg:block">
<div v-for="step in WORKFLOW_STEPS" :key="step.id"
class="z-10 flex flex-1 items-center lg:block lg:text-center">
<Button type="button" :class="workflowStepButtonClass(step.id)"
:variant="step.id === workflowProgress?.currentStep && application.status !== 'COMPLETED' && !workflowProgress?.failed ? 'default' : 'ghost'"
disabled>
{{ step.id }}
</Button>
<div :class="workflowStepLabelClass(step.id)">
{{ step.label }}
</div>
</div>
</div>
</Box>
<Box v-if="showManagementReview" class="p-5 sm:p-6">
<div class="font-medium">Semakan Pentadbiran</div>
<p class="mt-1 text-sm opacity-70">
Luluskan atau tolak permohonan pada peringkat pentadbiran.
</p>
<Field class="mt-4">
<FieldLabel for="management-remarks">Catatan</FieldLabel>
<Textarea id="management-remarks" v-model="managementRemarks" rows="3" placeholder="Catatan semakan (pilihan)"
:disabled="reviewSubmitting" />
</Field>
<div class="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'management', decision: 'APPROVED' })">
Luluskan
</Button>
<Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'management', decision: 'REJECTED' })">
Tolak
</Button>
</div>
</Box>
<Box v-if="showBoardReview" class="p-5 sm:p-6">
<div class="font-medium">Semakan Lembaga</div>
<p class="mt-1 text-sm opacity-70">
Luluskan atau gagalkan permohonan pada peringkat lembaga.
</p>
<Field class="mt-4">
<FieldLabel for="board-remarks">Catatan</FieldLabel>
<Textarea id="board-remarks" v-model="boardRemarks" rows="3" placeholder="Catatan semakan (pilihan)"
:disabled="reviewSubmitting" />
</Field>
<div class="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'board', decision: 'PASS' })">
Lulus
</Button>
<Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'board', decision: 'FAIL' })">
Gagal
</Button>
</div>
</Box>
<Box v-if="showCompleteAction" class="p-5 sm:p-6">
<div class="font-medium">Makluman Keputusan</div>
<p class="mt-1 text-sm opacity-70">
Hantar e-mel keputusan kepada pemohon
<span v-if="application.board_result === 'PASS'"> dan cipta akaun ahli</span>.
</p>
<div class="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="primary" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'complete' })">
Selesaikan & Hantar Makluman
</Button>
</div>
</Box>
<TabsRoot defaultValue="personal" class="w-full">
<Box raised="single" class="w-full p-0">
<div class="w-full px-5 py-4">
<TabsList class="mb-0 flex w-full">
<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="personal">
Maklumat Peribadi
</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"
value="contact">
Hubungan & Alamat
</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"
value="employment">
Pekerjaan & Caruman
</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"
value="heirs">
Waris
</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"
value="references">
Pencadang & Penyokong
</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"
value="documents">
Dokumen
</TabsTrigger>
<TabsTrigger v-if="application.reviews.length"
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="reviews">
Sejarah Semakan
</TabsTrigger>
</TabsList>
</div>
</Box>
<TabsContent value="personal" class="mt-6">
<div v-if="!applicant" class="opacity-70">Tiada maklumat pemohon.</div>
<div v-else class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Nama Penuh</FieldLabel>
<Input :model-value="displayValue(applicant.name)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Emel</FieldLabel>
<Input :model-value="displayValue(applicant.email)" type="text" disabled class="lowercase" />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>No. Kad Pengenalan</FieldLabel>
<Input :model-value="displayValue(applicant.ic_number)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Tarikh Lahir</FieldLabel>
<Input :model-value="formatDate(applicant.birth_date)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Tempat Lahir</FieldLabel>
<Input :model-value="displayValue(applicant.birth_place)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Jantina</FieldLabel>
<Input :model-value="displayValue(applicant.gender)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Status Perkahwinan</FieldLabel>
<Input :model-value="displayValue(applicant.marriage_status)" type="text" disabled />
</Field>
</div>
</TabsContent>
<TabsContent value="contact" class="mt-6">
<div v-if="!applicant" class="opacity-70">Tiada maklumat pemohon.</div>
<div v-else class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12">
<FieldLabel>Alamat</FieldLabel>
<Textarea :model-value="displayValue(applicant.address)" rows="3" disabled />
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel>No. Telefon</FieldLabel>
<Input :model-value="displayValue(applicant.phone_number)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel>No. Pejabat</FieldLabel>
<Input :model-value="displayValue(applicant.office_number)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel>Poskod</FieldLabel>
<Input :model-value="displayValue(applicant.postcode)" type="text" disabled />
</Field>
</div>
</TabsContent>
<TabsContent value="employment" class="mt-6">
<div v-if="!applicant" class="opacity-70">Tiada maklumat pemohon.</div>
<div v-else class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Nama Majikan</FieldLabel>
<Input :model-value="displayValue(applicant.employer_name)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Jawatan Semasa</FieldLabel>
<Input :model-value="displayValue(applicant.current_position)" type="text" disabled />
</Field>
<Field class="col-span-12">
<FieldLabel>Alamat Majikan</FieldLabel>
<Textarea :model-value="displayValue(applicant.employer_address)" rows="3" disabled />
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel>Tarikh Mula Berkhidmat</FieldLabel>
<Input :model-value="formatDate(applicant.start_work_date)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel>Caruman Saham (RM)</FieldLabel>
<Input :model-value="formatCurrency(applicant.stock_monthly_contribution)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel>Caruman Yuran (RM)</FieldLabel>
<Input :model-value="formatCurrency(applicant.fee_monthly_contribution)" type="text" disabled />
</Field>
</div>
</TabsContent>
<TabsContent value="heirs" class="mt-6">
<div v-if="!application.heirs.length" class="opacity-70">Tiada maklumat waris.</div>
<div v-else class="space-y-4">
<div v-for="(heir, index) in application.heirs" :key="heir.id"
class="rounded-lg border border-foreground/10 p-4">
<div class="mb-4 font-medium">Waris {{ index + 1 }}</div>
<div class="grid grid-cols-12 gap-4">
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Nama</FieldLabel>
<Input :model-value="displayValue(heir.name)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>No. Kad Pengenalan</FieldLabel>
<Input :model-value="displayValue(heir.ic_number)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>Hubungan</FieldLabel>
<Input :model-value="displayValue(heir.relationship)" type="text" disabled />
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel>No. Telefon</FieldLabel>
<Input :model-value="displayValue(heir.phone_number)" type="text" disabled />
</Field>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="references" class="mt-6">
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-foreground/10 p-4">
<div class="font-medium">Pencadang</div>
<div class="mt-3 space-y-1 text-sm">
<div>
<span class="opacity-70">Nama:</span>
{{ displayValue(getReference('PROPOSER')?.member?.name) }}
</div>
<div>
<span class="opacity-70">No. IC:</span>
{{ displayValue(getReference('PROPOSER')?.member?.ic_number) }}
</div>
</div>
</div>
<div class="rounded-lg border border-foreground/10 p-4">
<div class="font-medium">Penyokong</div>
<div class="mt-3 space-y-1 text-sm">
<div>
<span class="opacity-70">Nama:</span>
{{ displayValue(getReference('SUPPORTER')?.member?.name) }}
</div>
<div>
<span class="opacity-70">No. IC:</span>
{{ displayValue(getReference('SUPPORTER')?.member?.ic_number) }}
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="documents" class="mt-6">
<div v-if="!application.documents.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
<div v-else class="space-y-3">
<div v-for="document in application.documents" :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">{{ documentLabel(document.type, document.name) }}</div>
<div class="mt-1 text-sm opacity-70">
{{ document.name }} · {{ 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" />
{{ previewLoading && previewDocument?.id === document.id ? 'Memuatkan...' : 'Lihat' }}
</Button>
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
@click="handleDownloadDocument(document)">
<Download class="mr-2 size-4" />
{{ downloadingDocumentId === document.id ? 'Memuat turun...' : 'Muat Turun' }}
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
<div class="relative ms-3 ps-8">
<div v-for="(review, index) in sortedReviews" :key="review.id" class="relative pb-8 last:pb-0">
<span class="absolute -inset-s-8 top-1.5 flex size-3.5 rounded-full border-2 ring-4 ring-background"
:class="reviewTimelineDotClass(review)" />
<span v-if="index < sortedReviews.length - 1"
class="absolute -inset-s-3.5 top-5 h-[calc(100%-0.25rem)] w-px bg-foreground/15" />
<div class="rounded-lg border border-foreground/10 p-4">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<div class="font-medium">{{ reviewStageLabel(review.stage) }}</div>
<div class="mt-1 text-sm opacity-70">
{{ formatDateTime(review.reviewed_at) }}
</div>
</div>
<Badge :variant="reviewDecisionBadgeVariant(review.decision, review.stage)" class="whitespace-nowrap">
{{ reviewDecisionLabel(review.decision, review.stage) }}
</Badge>
</div>
<div class="mt-3 space-y-2 text-sm">
<div>
<span class="opacity-70">Disemak oleh:</span>
{{ displayValue(review.reviewer?.name) }}
</div>
<div v-if="review.remarks">
<span class="opacity-70">Catatan:</span>
{{ review.remarks }}
</div>
</div>
</div>
</div>
</div>
</TabsContent>
</TabsRoot>
</template>
<DialogRoot :open="confirmDialogOpen"
@openChange="(details) => { confirmDialogOpen = details.open; if (!details.open) pendingAction = null }">
<DialogContent>
<div class="p-5 text-center">
<div class="mt-2 text-2xl font-medium">{{ confirmDialogTitle }}</div>
<div class="mt-2 opacity-70">{{ confirmDialogDescription }}</div>
</div>
<div class="px-5 pb-8 text-center">
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
Batal
</DialogCloseTrigger>
<Button class="w-28" type="button" variant="primary" :disabled="reviewSubmitting || completeSubmitting"
@click="confirmPendingAction">
{{ reviewSubmitting || completeSubmitting ? 'Memproses...' : 'Sahkan' }}
</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"
:aria-label="previewDocument ? documentLabel(previewDocument.type, previewDocument.name) : 'Pratonton dokumen'">
<button type="button" class="absolute inset-0 bg-black/80" aria-label="Tutup pratonton"
@click="handlePreviewOpenChange(false)" />
<div
class="relative z-10 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-foreground/10 bg-background shadow-2xl">
<div class="border-b border-foreground/10 px-5 py-4">
<div class="text-lg font-medium">
{{ previewDocument ? documentLabel(previewDocument.type, previewDocument.name) : 'Pratonton Dokumen' }}
</div>
<div v-if="previewDocument" class="mt-1 text-sm opacity-70">
{{ previewDocument.name }} · {{ formatFileSize(previewDocument.file_size) }}
</div>
</div>
<div class="overflow-auto p-5">
<div v-if="previewLoading" class="py-12 text-center opacity-70">
Memuatkan dokumen...
</div>
<div v-else-if="previewUrl && isPreviewImage" class="flex justify-center">
<img :src="previewUrl" :alt="previewDocument?.name ?? 'Pratonton dokumen'"
class="block h-auto max-h-[calc(90vh-12rem)] w-auto max-w-full object-contain" />
</div>
<iframe v-else-if="previewUrl && isPreviewPdf" :src="previewUrl"
class="block w-full rounded-lg border border-foreground/10" style="height: min(70vh, 720px)"
:title="previewDocument?.name ?? 'Pratonton dokumen'" />
<div v-else-if="previewUrl" class="py-12 text-center opacity-70">
Pratonton tidak tersedia untuk jenis fail ini. Sila muat turun dokumen.
</div>
</div>
<div class="flex justify-end gap-2 border-t border-foreground/10 px-5 py-4">
<Button type="button" look="outline" @click="handlePreviewOpenChange(false)">
Tutup
</Button>
<Button v-if="previewDocument" type="button" look="outline"
:disabled="downloadingDocumentId === previewDocument.id" @click="handleDownloadDocument(previewDocument)">
<Download class="mr-2 size-4" />
Muat Turun
</Button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,502 @@
<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>