Files
My-KOPKB/fe/src/modules/membership-application/pages/MembershipApplication.vue
T
ismailmasseran d06c4701a4
Build Docker Image / build-backend (push) Successful in 1m56s
Build Docker Image / build-frontend (push) Successful in 1m55s
Dev/v1.2 (#4)
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #4
2026-07-06 12:50:55 +08:00

952 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
import type {
MembershipApplicationFormState,
MembershipApplicationHeirForm,
MembershipApplicationReferenceForm,
} from '../types/membership-application.types'
import illustrationUrl from '@/assets/images/logo.svg'
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: 'Hubungan & 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' },
]
// TODO: replace with API lookup
const EMPLOYERS = [
{
name: 'INFRA QUEST SDN BHD',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad',
address: 'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
},
{
name: 'Koperasi Permodalan Kelantan Berhad',
address: 'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
] as const
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') {
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 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">
<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" />
<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>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" />
<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>
<!-- 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"
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 *</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>