966 lines
40 KiB
Vue
966 lines
40 KiB
Vue
<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, FieldDescription, 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 { HelpdeskFab } from '@/modules/feedback'
|
|
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
|
|
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
|
import type {
|
|
MembershipApplicationFormState,
|
|
MembershipApplicationHeirForm,
|
|
MembershipApplicationReferenceForm,
|
|
} from '../types/membership-application.types'
|
|
import illustrationUrl from '@/assets/images/logo.svg'
|
|
import { EMPLOYERS } from '@/constants/employers'
|
|
|
|
const MAX_FILE_SIZE_MB = 10
|
|
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
|
const MIN_STOCK_MONTHLY_CONTRIBUTION = 50
|
|
const INITIAL_MANDATORY_STOCK_MONTHLY_CONTRIBUTION = 84
|
|
const MIN_FEE_MONTHLY_CONTRIBUTION = 30
|
|
|
|
const steps = [
|
|
{ id: 1, label: 'Maklumat Peribadi' },
|
|
{ id: 2, label: 'No. Telefon & Alamat' },
|
|
{ id: 3, label: 'Maklumat Pekerjaan' },
|
|
{ id: 4, label: 'Maklumat Penama' },
|
|
{ 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' },
|
|
]
|
|
|
|
const EMPLOYER_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
|
label: employer.name,
|
|
value: employer.name,
|
|
}))
|
|
|
|
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)
|
|
const employerCollection = createSelectCollection(EMPLOYER_OPTIONS)
|
|
|
|
function getEmployerAddress(name: string): string {
|
|
return EMPLOYERS.find((employer) => employer.name === name)?.address ?? ''
|
|
}
|
|
|
|
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),
|
|
)
|
|
const employerInitial = computed(() => apiValueToLabel(EMPLOYER_OPTIONS, form.applicant.employer_name))
|
|
|
|
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 setEmployerValue(details: { value: string[] }) {
|
|
const employerName = labelToApiValue(EMPLOYER_OPTIONS, details.value[0])
|
|
form.applicant.employer_name = employerName
|
|
form.applicant.employer_address = getEmployerAddress(employerName)
|
|
delete fieldErrors['applicant.employer_name']
|
|
delete fieldErrors['applicant.employer_address']
|
|
}
|
|
|
|
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') {
|
|
form.references[role].ic_number = sanitizeIcNumberInput(form.references[role].ic_number)
|
|
clearReference(role)
|
|
}
|
|
|
|
function handleApplicantNameInput() {
|
|
form.applicant.name = sanitizeNameInput(form.applicant.name)
|
|
delete fieldErrors['applicant.name']
|
|
}
|
|
|
|
function handleApplicantIcInput() {
|
|
form.applicant.ic_number = sanitizeIcNumberInput(form.applicant.ic_number)
|
|
delete fieldErrors['applicant.ic_number']
|
|
}
|
|
|
|
function handleHeirNameInput(index: number) {
|
|
const heir = form.heirs[index]
|
|
if (!heir) return
|
|
heir.name = sanitizeNameInput(heir.name)
|
|
delete fieldErrors[`heirs.${index}.name`]
|
|
}
|
|
|
|
function handleHeirIcInput(index: number) {
|
|
const heir = form.heirs[index]
|
|
if (!heir) return
|
|
heir.ic_number = sanitizeIcNumberInput(heir.ic_number)
|
|
delete fieldErrors[`heirs.${index}.ic_number`]
|
|
}
|
|
|
|
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 Rumah Semasa'
|
|
case 3:
|
|
return 'Maklumat Pekerjaan & Caruman'
|
|
case 4:
|
|
return 'Maklumat Penama'
|
|
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 Penama.'
|
|
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,
|
|
'Potongan modal syer minima',
|
|
)
|
|
|
|
const stockContribution = Number(form.applicant.stock_monthly_contribution)
|
|
if (
|
|
form.applicant.stock_monthly_contribution &&
|
|
(Number.isNaN(stockContribution) || stockContribution < MIN_STOCK_MONTHLY_CONTRIBUTION)
|
|
) {
|
|
setError(
|
|
'applicant.stock_monthly_contribution',
|
|
`Potongan modal syer minima mestilah sekurang-kurangnya RM${MIN_STOCK_MONTHLY_CONTRIBUTION}.`,
|
|
)
|
|
valid = false
|
|
}
|
|
requireField(
|
|
'applicant.fee_monthly_contribution',
|
|
form.applicant.fee_monthly_contribution,
|
|
'Potongan yuran bulanan',
|
|
)
|
|
|
|
const feeContribution = Number(form.applicant.fee_monthly_contribution)
|
|
if (
|
|
form.applicant.fee_monthly_contribution &&
|
|
(Number.isNaN(feeContribution) || feeContribution < MIN_FEE_MONTHLY_CONTRIBUTION)
|
|
) {
|
|
setError(
|
|
'applicant.fee_monthly_contribution',
|
|
`Potongan yuran bulanan mestilah sekurang-kurangnya RM${MIN_FEE_MONTHLY_CONTRIBUTION}.`,
|
|
)
|
|
valid = false
|
|
}
|
|
}
|
|
|
|
if (step === 4) {
|
|
form.heirs.forEach((heir, index) => {
|
|
requireField(`heirs.${index}.name`, heir.name, `Nama Penama ${index + 1}`)
|
|
requireField(`heirs.${index}.ic_number`, heir.ic_number, `No. KP Penama ${index + 1}`)
|
|
requireField(`heirs.${index}.relationship`, heir.relationship, `Hubungan Penama ${index + 1}`)
|
|
requireField(`heirs.${index}.phone_number`, heir.phone_number, `No. telefon Penama ${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
|
|
}
|
|
|
|
if (!form.documents.employer_letter) {
|
|
setError('documents.employer_letter', 'Surat pengesahan majikan 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">
|
|
<HelpdeskFab />
|
|
<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 ? 'primary' : '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" @input="handleApplicantNameInput" />
|
|
<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" inputmode="numeric" maxlength="15"
|
|
placeholder="Contoh: 900101011234" @input="handleApplicantIcInput" />
|
|
<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>Nama Majikan</FieldLabel>
|
|
<SelectRoot :key="`employer-${form.applicant.employer_name}`" class="w-full"
|
|
:collection="employerCollection" :default-value="employerInitial" @value-change="setEmployerValue">
|
|
<SelectControl>
|
|
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.employer_name']">
|
|
<SelectValueText placeholder="Pilih majikan" />
|
|
</SelectTrigger>
|
|
</SelectControl>
|
|
<SelectContent>
|
|
<SelectItemGroup>
|
|
<SelectItemGroupLabel>Nama Majikan</SelectItemGroupLabel>
|
|
<SelectItem v-for="item in employerCollection.items" :key="item.label" :item="item">
|
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
|
</SelectItem>
|
|
</SelectItemGroup>
|
|
</SelectContent>
|
|
</SelectRoot>
|
|
<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" disabled />
|
|
<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">Potongan Modal Syer Minima</FieldLabel>
|
|
<FieldDescription>
|
|
Minima RM{{ MIN_STOCK_MONTHLY_CONTRIBUTION }} setiap bulan.
|
|
</FieldDescription>
|
|
<Input id="stock_monthly_contribution" v-model="form.applicant.stock_monthly_contribution" type="number"
|
|
:min="MIN_STOCK_MONTHLY_CONTRIBUTION" step="0.01" />
|
|
<FieldDescription class="mt-2">
|
|
Potongan RM{{ INITIAL_MANDATORY_STOCK_MONTHLY_CONTRIBUTION }} setiap bulan adalah wajib bagi 6 bulan
|
|
pertama bagi menjelaskan modal syer minimum RM500. Anda boleh memilih potongan lebih tinggi. Selepas
|
|
RM500 dijelaskan, potongan boleh dikekalkan atau dikurangkan sehingga minima
|
|
RM{{ MIN_STOCK_MONTHLY_CONTRIBUTION }} setiap bulan.
|
|
</FieldDescription>
|
|
<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">Potongan Yuran Bulanan</FieldLabel>
|
|
<FieldDescription>Minima RM{{ MIN_FEE_MONTHLY_CONTRIBUTION }} setiap bulan.</FieldDescription>
|
|
<Input id="fee_monthly_contribution" v-model="form.applicant.fee_monthly_contribution" type="number"
|
|
:min="MIN_FEE_MONTHLY_CONTRIBUTION" 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">Penama</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"
|
|
@input="handleHeirNameInput(index)" />
|
|
<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" inputmode="numeric"
|
|
maxlength="15" placeholder="Contoh: 900101011234" @input="handleHeirIcInput(index)" />
|
|
<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>
|
|
<!-- Penama hanya boleh 1 orang (disable in frontend)-->
|
|
<!-- <Button type="button" look="outline" @click="addHeir">
|
|
<Lucide icon="Plus" class="mr-2 size-4" />
|
|
Tambah Penama
|
|
</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"
|
|
inputmode="numeric" maxlength="12" 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"
|
|
inputmode="numeric" maxlength="12" 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 *</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. Penama:</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>
|