Dev/v1.2 (#4)
Build Docker Image / build-backend (push) Successful in 1m56s
Build Docker Image / build-frontend (push) Successful in 1m55s

Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-07-06 12:50:55 +08:00
parent e77712105c
commit d06c4701a4
190 changed files with 5365 additions and 26981 deletions
@@ -4,7 +4,7 @@ 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 { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
SelectRoot,
@@ -27,16 +27,19 @@ import type {
MembershipApplicationHeirForm,
MembershipApplicationReferenceForm,
} from '../types/membership-application.types'
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
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 Waris' },
{ id: 4, label: 'Maklumat Penama' },
{ id: 5, label: 'Dokumen & Hantar' },
] as const
@@ -73,6 +76,31 @@ const RELATIONSHIP_OPTIONS: SelectOption[] = [
{ 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,
@@ -94,6 +122,11 @@ function apiValueToLabel(options: SelectOption[], value: string | undefined): st
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 {
@@ -149,6 +182,7 @@ const genderInitial = computed(() => apiValueToLabel(GENDER_OPTIONS, form.applic
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])
@@ -160,6 +194,14 @@ function setMarriageStatusValue(details: { value: string[] }) {
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
@@ -254,7 +296,7 @@ const stepTitle = computed(() => {
case 3:
return 'Maklumat Pekerjaan & Caruman'
case 4:
return 'Maklumat Waris'
return 'Maklumat Penama'
default:
return 'Dokumen & Pengesahan'
}
@@ -269,7 +311,7 @@ const stepDescription = computed(() => {
case 3:
return 'Masukkan maklumat pekerjaan dan caruman bulanan.'
case 4:
return 'Tambah sekurang-kurangnya satu waris.'
return 'Tambah sekurang-kurangnya satu Penama.'
default:
return 'Muat naik dokumen sokongan dan semak maklumat sebelum hantar.'
}
@@ -324,21 +366,45 @@ function validateStep(step: number): boolean {
requireField(
'applicant.stock_monthly_contribution',
form.applicant.stock_monthly_contribution,
'Caruman saham bulanan',
'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,
'Caruman yuran bulanan',
'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 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}`)
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}`)
})
}
@@ -355,6 +421,11 @@ function validateStep(step: number): boolean {
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
@@ -638,8 +709,23 @@ function stepLabelClass(stepId: number) {
<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" />
<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>
@@ -653,7 +739,7 @@ function stepLabelClass(stepId: number) {
</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" />
<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>
@@ -666,17 +752,27 @@ function stepLabelClass(stepId: number) {
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-4">
<FieldLabel for="stock_monthly_contribution">Caruman Saham (RM)</FieldLabel>
<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="0" step="0.01" />
: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">Caruman Yuran (RM)</FieldLabel>
<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="0" step="0.01" />
:min="MIN_FEE_MONTHLY_CONTRIBUTION" step="0.01" />
<FieldError v-if="fieldErrors['applicant.fee_monthly_contribution']">
{{ fieldErrors['applicant.fee_monthly_contribution'] }}
</FieldError>
@@ -688,7 +784,7 @@ function stepLabelClass(stepId: number) {
<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>
<div class="font-medium">Penama</div>
<Button v-if="form.heirs.length > 1" type="button" look="outline" size="sm"
@click="removeHeir(index)">
Buang
@@ -742,10 +838,11 @@ function stepLabelClass(stepId: number) {
</Field>
</div>
</div>
<Button type="button" look="outline" @click="addHeir">
<!-- Penama hanya boleh 1 orang (disable in frontend)-->
<!-- <Button type="button" look="outline" @click="addHeir">
<Lucide icon="Plus" class="mr-2 size-4" />
Tambah Waris
</Button>
Tambah Penama
</Button> -->
</div>
</template>
@@ -806,7 +903,7 @@ function stepLabelClass(stepId: number) {
</FieldError>
</Field>
<Field class="col-span-12 sm:col-span-6">
<FieldLabel for="employer_letter">Surat Pengesahan Majikan (Pilihan)</FieldLabel>
<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']
@@ -820,7 +917,7 @@ function stepLabelClass(stepId: number) {
<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">Bil. Penama:</span> {{ form.heirs.length }}</div>
<div>
<span class="opacity-70">Pencadang:</span>
{{ form.references.proposer.name || '-' }}