DONE: membership application to generate letter along with member_number and attach to email, change to dropdown for company or job selection, redesign profile ui, change to incremental ids for jobs; WIP: member digital card
This commit is contained in:
@@ -6,8 +6,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { logout, resolvePostAuthRoute } from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import logoUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { getForgotPasswordErrorMessage, requestForgotPassword } from '@/modules/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
resolvePostAuthRoute,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -125,11 +125,8 @@ const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
<CheckboxLabel>Ingat saya</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="opacity-70 hover:opacity-100"
|
||||
@click="router.push({ name: 'forgot-password' })"
|
||||
>
|
||||
<button type="button" class="opacity-70 hover:opacity-100"
|
||||
@click="router.push({ name: 'forgot-password' })">
|
||||
Lupa Password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import illustrationUrl from '@/assets/images/illustration.svg'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
@@ -12,7 +10,7 @@ const sections = [
|
||||
{
|
||||
id: 'pengenalan',
|
||||
title: '1. Pengenalan',
|
||||
placeholder: 'Selamat datang ke SUTERA 3.0. Kami amat menghargai kepercayaan yang anda berikan untuk mengendalikan maklumat peribadi anda. Dasar Privasi ini digubal untuk membantu anda memahami bagaimana kami mengumpul, menggunakan, mendedahkan, dan melindungi data peribadi anda selaras dengan Akta Perlindungan Data Peribadi 2010 (PDPA) dan piawaian keselamatan global.\n\nDasar ini terpakai kepada semua TDM, dan mana-mana pihak yang mengakses atau menggunakan perkhidmatan, laman web, dan aplikasi kami.'
|
||||
placeholder: 'Selamat datang ke MyKOPKB. Kami amat menghargai kepercayaan yang anda berikan untuk mengendalikan maklumat peribadi anda. Dasar Privasi ini digubal untuk membantu anda memahami bagaimana kami mengumpul, menggunakan, mendedahkan, dan melindungi data peribadi anda selaras dengan Akta Perlindungan Data Peribadi 2010 (PDPA) dan piawaian keselamatan global.\n\nDasar ini terpakai kepada semua Koperasi Permodalan Kelantan Berhad (KOPKB), dan mana-mana pihak yang mengakses atau menggunakan perkhidmatan, laman web, dan aplikasi kami.'
|
||||
},
|
||||
{
|
||||
id: 'data-dikumpul',
|
||||
@@ -60,7 +58,7 @@ const sections = [
|
||||
id: 'hubungi',
|
||||
title: '10. Hubungi Kami',
|
||||
placeholder:
|
||||
'Jika anda ada pertanyaan, komen atau permintaan tentang dasar ini, hubungi kami di\n- KAPT MOHAMMAD EFANDY BIN JAFFARI (effandy.jaffari@army.mil.my)'
|
||||
'Jika anda ada pertanyaan, komen atau permintaan tentang dasar ini, hubungi kami di\n- ISMAIL BIN MASSERAN (ismail@koppkb.com)'
|
||||
|
||||
}
|
||||
];
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { PasswordInput } from '@/components/ui/password-input'
|
||||
import { getRegisterErrorMessage, register } from '@/modules/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
requestForgotPassword,
|
||||
resetPassword,
|
||||
} from '@/modules/auth'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -133,61 +133,28 @@ const onOtpInput = (event: Event) => {
|
||||
</AlertRoot>
|
||||
|
||||
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleReset">
|
||||
<Input
|
||||
v-model="email"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
:model-value="otp"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md text-center tracking-[0.5em] text-lg"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="6"
|
||||
placeholder="000000"
|
||||
autocomplete="one-time-code"
|
||||
required
|
||||
@input="onOtpInput"
|
||||
/>
|
||||
<PasswordInput
|
||||
v-model="password"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Kata laluan baharu"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<PasswordInput
|
||||
v-model="passwordConfirmation"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Sahkan kata laluan baharu"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
|
||||
placeholder="Email" autocomplete="email" required />
|
||||
<Input :model-value="otp"
|
||||
class="box block min-w-full px-5 py-6 xl:min-w-md text-center tracking-[0.5em] text-lg" type="text"
|
||||
inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="000000" autocomplete="one-time-code"
|
||||
required @input="onOtpInput" />
|
||||
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Kata laluan baharu" autocomplete="new-password" required />
|
||||
<PasswordInput v-model="passwordConfirmation" class="box block min-w-full px-5 py-6 xl:min-w-md"
|
||||
placeholder="Sahkan kata laluan baharu" autocomplete="new-password" required />
|
||||
|
||||
<div class="mt-5 text-center xl:mt-10 xl:text-left">
|
||||
<Button class="box w-full px-4 py-5" variant="primary" type="submit"
|
||||
:disabled="loading || !canSubmit">
|
||||
{{ loading ? 'Menyimpan...' : 'Tetapkan Semula Kata Laluan' }}
|
||||
</Button>
|
||||
<Button
|
||||
class="box mt-4 w-full px-4 py-5"
|
||||
look="outline"
|
||||
type="button"
|
||||
:disabled="resendLoading || !email"
|
||||
@click="handleResend"
|
||||
>
|
||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
||||
:disabled="resendLoading || !email" @click="handleResend">
|
||||
{{ resendLoading ? 'Menghantar...' : 'Hantar Semula Kod OTP' }}
|
||||
</Button>
|
||||
<Button
|
||||
class="box mt-4 w-full px-4 py-5"
|
||||
look="outline"
|
||||
type="button"
|
||||
@click="router.push({ name: 'login' })"
|
||||
>
|
||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
||||
@click="router.push({ name: 'login' })">
|
||||
Kembali ke Log Masuk
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
verifyEmail,
|
||||
} from '@/modules/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import logoUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import illustrationUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import logoUrl from '@/assets/images/logo.svg'
|
||||
import illustrationUrl from '@/assets/images/logo.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -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 || '-' }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, FileText, Pencil } from '@lucide/vue'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Pencil, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
@@ -21,9 +21,9 @@ import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
completeMembershipApplication,
|
||||
deleteMembershipApplicationDocument,
|
||||
downloadMembershipApplicationDocument,
|
||||
fetchMembershipApplicationDocument,
|
||||
generateMembershipApplicationResultLetter,
|
||||
getMembershipApplication,
|
||||
submitBoardReview,
|
||||
submitManagementReview,
|
||||
@@ -43,12 +43,15 @@ import type {
|
||||
MembershipApplicationReviewDetail,
|
||||
MembershipApplicationStatus,
|
||||
} from '../types/membership-application.types'
|
||||
import { RESULT_LETTER_DOCUMENT_TYPE } from '../types/membership-application.types'
|
||||
import {
|
||||
ADMIN_ATTACHMENT_DOCUMENT_TYPE,
|
||||
RESULT_LETTER_DOCUMENT_TYPE,
|
||||
} from '../types/membership-application.types'
|
||||
|
||||
const WORKFLOW_STEPS = [
|
||||
{ id: 1, label: 'Dihantar' },
|
||||
{ id: 2, label: 'Semakan Pentadbiran' },
|
||||
{ id: 3, label: 'Semakan Lembaga' },
|
||||
{ id: 3, label: 'Keputusan Ahli Lembaga Koperasi (ALK)' },
|
||||
{ id: 4, label: 'Makluman Keputusan' },
|
||||
{ id: 5, label: 'Selesai' },
|
||||
] as const
|
||||
@@ -58,6 +61,7 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
photo: 'Gambar Passport',
|
||||
salary_slip: 'Slip Gaji',
|
||||
employer_letter: 'Surat Pengesahan Majikan',
|
||||
admin_attachment: 'Lampiran Pentadbir',
|
||||
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
|
||||
}
|
||||
|
||||
@@ -82,19 +86,20 @@ const pendingAction = ref<
|
||||
| null
|
||||
>(null)
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
const deletingDocumentId = ref<string | null>(null)
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
const generateDialogOpen = ref(false)
|
||||
const generateSubmitting = ref(false)
|
||||
const boardMeetingReference = ref('')
|
||||
const boardMeetingReferenceError = ref<string | null>(null)
|
||||
const deleteConfirmDialogOpen = ref(false)
|
||||
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
PENDING_BOARD: 'Menunggu Keputusan Mesyuarat ALK',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
@@ -142,16 +147,14 @@ const showCompleteAction = computed(
|
||||
application.value?.status === 'PENDING_NOTIFICATION',
|
||||
)
|
||||
|
||||
const resultLetterDocument = computed(() =>
|
||||
application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null,
|
||||
const canEditAdminAttachments = computed(
|
||||
() =>
|
||||
hasPermission('kemaskini permohonan keahlian') &&
|
||||
application.value?.status !== 'COMPLETED',
|
||||
)
|
||||
|
||||
const showGenerateResultLetter = computed(
|
||||
() =>
|
||||
hasPermission('jana surat keputusan keahlian') &&
|
||||
application.value?.status === 'COMPLETED' &&
|
||||
!resultLetterDocument.value &&
|
||||
(application.value.board_result === 'PASS' || application.value.board_result === 'FAIL'),
|
||||
const resultLetterDocument = computed(() =>
|
||||
application.value?.documents.find((document) => document.type === RESULT_LETTER_DOCUMENT_TYPE) ?? null,
|
||||
)
|
||||
|
||||
const applicant = computed(() => application.value?.applicant ?? null)
|
||||
@@ -167,8 +170,8 @@ const confirmDialogTitle = computed(() => {
|
||||
|
||||
if (pendingAction.value.type === 'board') {
|
||||
return pendingAction.value.decision === 'PASS'
|
||||
? 'Luluskan Semakan Lembaga?'
|
||||
: 'Gagalkan Semakan Lembaga?'
|
||||
? 'Luluskan?'
|
||||
: 'Gagalkan?'
|
||||
}
|
||||
|
||||
return 'Selesaikan Permohonan?'
|
||||
@@ -179,17 +182,17 @@ const confirmDialogDescription = computed(() => {
|
||||
|
||||
if (pendingAction.value.type === 'management') {
|
||||
return pendingAction.value.decision === 'APPROVED'
|
||||
? 'Permohonan akan dihantar ke semakan lembaga.'
|
||||
? 'Permohonan akan dihantar ke mesyuarat ALK.'
|
||||
: '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.'
|
||||
: 'Permohonan akan ditandakan gagal mesyuarat ALK.'
|
||||
}
|
||||
|
||||
return 'E-mel keputusan akan dihantar kepada pemohon. Akaun ahli akan dicipta jika permohonan lulus.'
|
||||
return 'Surat keputusan akan dijana, dilampirkan dalam e-mel keputusan, dan dihantar kepada pemohon. Akaun ahli akan dicipta jika permohonan lulus.'
|
||||
})
|
||||
|
||||
function workflowStepButtonClass(stepId: number) {
|
||||
@@ -242,6 +245,14 @@ const uploadedDocuments = computed(() =>
|
||||
application.value?.documents.filter((document) => document.type !== RESULT_LETTER_DOCUMENT_TYPE) ?? [],
|
||||
)
|
||||
|
||||
const applicantDocuments = computed(() =>
|
||||
uploadedDocuments.value.filter((document) => document.type !== ADMIN_ATTACHMENT_DOCUMENT_TYPE),
|
||||
)
|
||||
|
||||
const adminAttachments = computed(() =>
|
||||
uploadedDocuments.value.filter((document) => document.type === ADMIN_ATTACHMENT_DOCUMENT_TYPE),
|
||||
)
|
||||
|
||||
function displayValue(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value)
|
||||
@@ -309,7 +320,7 @@ function getReference(type: 'PROPOSER' | 'SUPPORTER'): MembershipApplicationRefe
|
||||
|
||||
function reviewStageLabel(stage: string): string {
|
||||
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
|
||||
if (stage === 'BOARD') return 'Semakan Lembaga'
|
||||
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
|
||||
return stage
|
||||
}
|
||||
|
||||
@@ -358,6 +369,11 @@ function openConfirmAction(
|
||||
| { type: 'board'; decision: BoardReviewDecision }
|
||||
| { type: 'complete' },
|
||||
) {
|
||||
if (action.type === 'complete') {
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
}
|
||||
|
||||
pendingAction.value = action
|
||||
confirmDialogOpen.value = true
|
||||
}
|
||||
@@ -365,6 +381,8 @@ function openConfirmAction(
|
||||
function closeConfirmDialog() {
|
||||
confirmDialogOpen.value = false
|
||||
pendingAction.value = null
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
}
|
||||
|
||||
async function confirmPendingAction() {
|
||||
@@ -372,8 +390,14 @@ async function confirmPendingAction() {
|
||||
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
boardMeetingReferenceError.value = null
|
||||
|
||||
if (pendingAction.value.type === 'complete') {
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
if (!reference) {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
completeSubmitting.value = true
|
||||
} else {
|
||||
reviewSubmitting.value = true
|
||||
@@ -395,7 +419,9 @@ async function confirmPendingAction() {
|
||||
})
|
||||
boardRemarks.value = ''
|
||||
} else {
|
||||
response = await completeMembershipApplication(applicationId.value)
|
||||
response = await completeMembershipApplication(applicationId.value, {
|
||||
board_meeting_reference: boardMeetingReference.value.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
application.value = response.data
|
||||
@@ -407,55 +433,15 @@ async function confirmPendingAction() {
|
||||
if (validationErrors?.remarks?.[0]) {
|
||||
error.value = validationErrors.remarks[0]
|
||||
}
|
||||
if (validationErrors?.board_meeting_reference?.[0]) {
|
||||
boardMeetingReferenceError.value = validationErrors.board_meeting_reference[0]
|
||||
}
|
||||
} finally {
|
||||
reviewSubmitting.value = false
|
||||
completeSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openGenerateDialog() {
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
generateDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeGenerateDialog() {
|
||||
if (generateSubmitting.value) return
|
||||
generateDialogOpen.value = false
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
}
|
||||
|
||||
async function confirmGenerateLetter() {
|
||||
if (!application.value || generateSubmitting.value) return
|
||||
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
if (!reference) {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
generateSubmitting.value = true
|
||||
boardMeetingReferenceError.value = null
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await generateMembershipApplicationResultLetter(application.value.id, {
|
||||
board_meeting_reference: reference,
|
||||
})
|
||||
|
||||
application.value = response.data.application
|
||||
successMessage.value = response.message
|
||||
closeGenerateDialog()
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||
error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
|
||||
} finally {
|
||||
generateSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || downloadingDocumentId.value) return
|
||||
|
||||
@@ -475,6 +461,43 @@ async function handleDownloadDocument(document: MembershipApplicationDocumentDet
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAdminAttachment(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || !canEditAdminAttachments.value || deletingDocumentId.value) return
|
||||
|
||||
pendingDeleteDocument.value = document
|
||||
deleteConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeDeleteConfirmDialog() {
|
||||
deleteConfirmDialogOpen.value = false
|
||||
pendingDeleteDocument.value = null
|
||||
}
|
||||
|
||||
async function confirmDeleteAdminAttachment() {
|
||||
const document = pendingDeleteDocument.value
|
||||
if (!application.value || !document || deletingDocumentId.value) return
|
||||
|
||||
deletingDocumentId.value = document.id
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await deleteMembershipApplicationDocument(application.value.id, document.id)
|
||||
application.value = response.data
|
||||
|
||||
if (previewDocument.value?.id === document.id) {
|
||||
handlePreviewOpenChange(false)
|
||||
}
|
||||
|
||||
successMessage.value = response.message || 'Lampiran pentadbir berjaya dipadam.'
|
||||
closeDeleteConfirmDialog()
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam lampiran pentadbir.')
|
||||
} finally {
|
||||
deletingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value) return
|
||||
|
||||
@@ -649,51 +672,31 @@ onUnmounted(() => {
|
||||
<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>.
|
||||
Jana surat keputusan, hantar e-mel dengan lampiran surat 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
|
||||
Selesaikan, Jana Surat & Hantar E-mel
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-if="showGenerateResultLetter" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Surat Keputusan</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
Jana surat keputusan lembaga untuk permohonan ini. Surat hanya boleh dijana sekali.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button type="button" variant="primary" @click="openGenerateDialog">
|
||||
<FileText class="mr-2 size-4" />
|
||||
Jana Surat Keputusan
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box v-else-if="resultLetterDocument" class="p-5 sm:p-6">
|
||||
<Box v-if="resultLetterDocument" class="p-5 sm:p-6">
|
||||
<div class="font-medium">Surat Keputusan</div>
|
||||
<p class="mt-1 text-sm opacity-70">
|
||||
{{ resultLetterDocument.name }} · {{ formatFileSize(resultLetterDocument.file_size) }}
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
<Button type="button" look="outline"
|
||||
:disabled="previewLoading && previewDocument?.id === resultLetterDocument.id"
|
||||
@click="handleViewDocument(resultLetterDocument)"
|
||||
>
|
||||
@click="handleViewDocument(resultLetterDocument)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
Lihat
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
look="outline"
|
||||
:disabled="downloadingDocumentId === resultLetterDocument.id"
|
||||
@click="handleDownloadDocument(resultLetterDocument)"
|
||||
>
|
||||
<Button type="button" look="outline" :disabled="downloadingDocumentId === resultLetterDocument.id"
|
||||
@click="handleDownloadDocument(resultLetterDocument)">
|
||||
<Download class="mr-2 size-4" />
|
||||
{{ downloadingDocumentId === resultLetterDocument.id ? 'Memuat turun...' : 'Muat Turun' }}
|
||||
</Button>
|
||||
@@ -722,7 +725,7 @@ onUnmounted(() => {
|
||||
<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
|
||||
Penama
|
||||
</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"
|
||||
@@ -834,7 +837,7 @@ onUnmounted(() => {
|
||||
<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="mb-4 font-medium">Penama</div>
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Nama</FieldLabel>
|
||||
@@ -890,30 +893,62 @@ onUnmounted(() => {
|
||||
|
||||
<TabsContent value="documents" class="mt-6">
|
||||
<div v-if="!uploadedDocuments.length" class="opacity-70">Tiada dokumen dimuat naik.</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="document in uploadedDocuments" :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) }}
|
||||
<template v-else>
|
||||
<div v-if="applicantDocuments.length" class="space-y-3">
|
||||
<div class="font-medium">Dokumen Pemohon</div>
|
||||
<div v-for="document in applicantDocuments" :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 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 v-if="adminAttachments.length" class="mt-8 space-y-3">
|
||||
<div class="font-medium">Lampiran Pentadbir</div>
|
||||
<div v-for="document in adminAttachments" :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">{{ document.name }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">{{ 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>
|
||||
<Button v-if="canEditAdminAttachments" type="button" look="outline" size="sm" variant="danger"
|
||||
:disabled="deletingDocumentId === document.id" @click="handleDeleteAdminAttachment(document)">
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
{{ deletingDocumentId === document.id ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
|
||||
@@ -932,11 +967,8 @@ onUnmounted(() => {
|
||||
{{ formatDateTime(review.reviewed_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
|
||||
:look="reviewDecisionBadgeLook(review.decision, review.stage)"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
<Badge :variant="reviewDecisionBadgeVariant(review.decision, review.stage)"
|
||||
:look="reviewDecisionBadgeLook(review.decision, review.stage)" class="whitespace-nowrap">
|
||||
{{ reviewDecisionLabel(review.decision, review.stage) }}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -959,11 +991,18 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<DialogRoot :open="confirmDialogOpen"
|
||||
@openChange="(details) => { confirmDialogOpen = details.open; if (!details.open) pendingAction = null }">
|
||||
@openChange="(details) => { if (!details.open) closeConfirmDialog(); else confirmDialogOpen = details.open }">
|
||||
<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>
|
||||
<Field v-if="pendingAction?.type === 'complete'" class="mt-5 text-left">
|
||||
<FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||
<Input id="detail-board-meeting-reference" v-model="boardMeetingReference" type="text"
|
||||
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026" :disabled="completeSubmitting"
|
||||
@input="boardMeetingReferenceError = null" />
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="reviewSubmitting || completeSubmitting">
|
||||
@@ -977,38 +1016,22 @@ onUnmounted(() => {
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
|
||||
<DialogRoot :open="deleteConfirmDialogOpen"
|
||||
@openChange="(details) => { if (!details.open) closeDeleteConfirmDialog() }">
|
||||
<DialogContent>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-medium">Jana Surat Keputusan</div>
|
||||
<p v-if="application" class="mt-2 text-sm opacity-70">
|
||||
{{ application.application_number }} · {{ application.applicant?.name ?? '-' }}
|
||||
</p>
|
||||
<Field class="mt-5">
|
||||
<FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||
<Input
|
||||
id="detail-board-meeting-reference"
|
||||
v-model="boardMeetingReference"
|
||||
type="text"
|
||||
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026"
|
||||
:disabled="generateSubmitting"
|
||||
@input="boardMeetingReferenceError = null"
|
||||
/>
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-2 text-2xl font-medium">Padam Lampiran Pentadbir?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
{{ pendingDeleteDocument?.name ?? 'Lampiran pentadbir' }} akan dipadam secara kekal.
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="!!deletingDocumentId" @click="closeDeleteConfirmDialog">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-32"
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="generateSubmitting"
|
||||
@click="confirmGenerateLetter"
|
||||
>
|
||||
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
|
||||
<Button class="w-28" type="button" variant="danger" :disabled="!!deletingDocumentId"
|
||||
@click="confirmDeleteAdminAttachment">
|
||||
{{ deletingDocumentId ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Plus } from '@lucide/vue'
|
||||
import { CircleAlert, CircleCheck, Download, Eye, Plus, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
AlertRoot,
|
||||
AlertTitle,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import { usePermissions } from '@/composables/usePermissions'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import {
|
||||
downloadMembershipApplicationDocument,
|
||||
deleteMembershipApplicationDocument,
|
||||
fetchMembershipApplicationDocument,
|
||||
getMembershipApplication,
|
||||
lookupMemberByIcNumber,
|
||||
@@ -52,9 +54,10 @@ import type {
|
||||
MembershipApplicationReviewDetail,
|
||||
MembershipApplicationStatus,
|
||||
} from '../types/membership-application.types'
|
||||
import { ADMIN_ATTACHMENT_DOCUMENT_TYPE } from '../types/membership-application.types'
|
||||
import {
|
||||
APPLICANT_DOCUMENT_UPLOAD_TYPES,
|
||||
DOCUMENT_TYPE_LABELS,
|
||||
DOCUMENT_UPLOAD_TYPES,
|
||||
GENDER_OPTIONS,
|
||||
MARRIAGE_STATUS_OPTIONS,
|
||||
RELATIONSHIP_OPTIONS,
|
||||
@@ -73,7 +76,7 @@ const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
const WORKFLOW_STEPS = [
|
||||
{ id: 1, label: 'Dihantar' },
|
||||
{ id: 2, label: 'Semakan Pentadbiran' },
|
||||
{ id: 3, label: 'Semakan Lembaga' },
|
||||
{ id: 3, label: 'Keputusan Ahli Lembaga Koperasi (ALK)' },
|
||||
{ id: 4, label: 'Makluman Keputusan' },
|
||||
{ id: 5, label: 'Selesai' },
|
||||
] as const
|
||||
@@ -91,11 +94,15 @@ const application = ref<MembershipApplicationDetail | null>(null)
|
||||
const form = reactive<MembershipApplicationFormState>(createEmptyFormState())
|
||||
const fieldErrors = reactive<Record<string, string>>({})
|
||||
const downloadingDocumentId = ref<string | null>(null)
|
||||
const deletingDocumentId = ref<string | null>(null)
|
||||
const uploadingDocumentType = ref<DocumentUploadType | null>(null)
|
||||
const uploadingAdminAttachment = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const previewDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
const deleteConfirmDialogOpen = ref(false)
|
||||
const pendingDeleteDocument = ref<MembershipApplicationDocumentDetail | null>(null)
|
||||
|
||||
const referenceLookupLoading = reactive({
|
||||
proposer: false,
|
||||
@@ -131,17 +138,22 @@ const sortedReviews = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const documentsByType = computed(() => {
|
||||
function buildDocumentsByType(types: readonly DocumentUploadType[]) {
|
||||
const map: Partial<Record<DocumentUploadType, MembershipApplicationDocumentDetail>> = {}
|
||||
|
||||
application.value?.documents.forEach((document) => {
|
||||
if (DOCUMENT_UPLOAD_TYPES.includes(document.type as DocumentUploadType)) {
|
||||
if (types.includes(document.type as DocumentUploadType)) {
|
||||
map[document.type as DocumentUploadType] = document
|
||||
}
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
}
|
||||
|
||||
const applicantDocumentsByType = computed(() => buildDocumentsByType(APPLICANT_DOCUMENT_UPLOAD_TYPES))
|
||||
const adminAttachments = computed(() =>
|
||||
application.value?.documents.filter((document) => document.type === ADMIN_ATTACHMENT_DOCUMENT_TYPE) ?? [],
|
||||
)
|
||||
|
||||
function getWorkflowProgress(status: MembershipApplicationStatus) {
|
||||
switch (status) {
|
||||
@@ -163,7 +175,7 @@ function getWorkflowProgress(status: MembershipApplicationStatus) {
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
PENDING_BOARD: 'Menunggu Keputusan Mesyuarat ALK',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
@@ -247,7 +259,7 @@ const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previ
|
||||
|
||||
function reviewStageLabel(stage: string): string {
|
||||
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
|
||||
if (stage === 'BOARD') return 'Semakan Lembaga'
|
||||
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
|
||||
return stage
|
||||
}
|
||||
|
||||
@@ -468,6 +480,91 @@ async function handleDocumentUpload(type: DocumentUploadType, event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminAttachmentUpload(event: Event) {
|
||||
if (!application.value || !canEdit.value || uploadingAdminAttachment.value) return
|
||||
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files ? Array.from(input.files) : []
|
||||
input.value = ''
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
const oversizedFile = files.find((file) => file.size > MAX_FILE_SIZE_BYTES)
|
||||
if (oversizedFile) {
|
||||
fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`] =
|
||||
`Saiz fail melebihi ${MAX_FILE_SIZE_MB}MB.`
|
||||
return
|
||||
}
|
||||
|
||||
delete fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`]
|
||||
uploadingAdminAttachment.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
let latestResponse = null
|
||||
|
||||
for (const file of files) {
|
||||
latestResponse = await uploadMembershipApplicationDocument(
|
||||
applicationId.value,
|
||||
ADMIN_ATTACHMENT_DOCUMENT_TYPE,
|
||||
file,
|
||||
)
|
||||
application.value = latestResponse.data
|
||||
}
|
||||
|
||||
successMessage.value =
|
||||
files.length > 1
|
||||
? `${files.length} lampiran pentadbir berjaya dimuat naik.`
|
||||
: latestResponse?.message || 'Lampiran pentadbir berjaya dimuat naik.'
|
||||
} catch (err) {
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
if (validationErrors) {
|
||||
setFieldErrors(validationErrors)
|
||||
}
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuat naik lampiran pentadbir.')
|
||||
} finally {
|
||||
uploadingAdminAttachment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAdminAttachment(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || !canEdit.value || deletingDocumentId.value) return
|
||||
|
||||
pendingDeleteDocument.value = document
|
||||
deleteConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeDeleteConfirmDialog() {
|
||||
deleteConfirmDialogOpen.value = false
|
||||
pendingDeleteDocument.value = null
|
||||
}
|
||||
|
||||
async function confirmDeleteAdminAttachment() {
|
||||
const document = pendingDeleteDocument.value
|
||||
if (!application.value || !document || deletingDocumentId.value) return
|
||||
|
||||
deletingDocumentId.value = document.id
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await deleteMembershipApplicationDocument(applicationId.value, document.id)
|
||||
application.value = response.data
|
||||
|
||||
if (previewDocument.value?.id === document.id) {
|
||||
handlePreviewOpenChange(false)
|
||||
}
|
||||
|
||||
successMessage.value = response.message || 'Lampiran pentadbir berjaya dipadam.'
|
||||
closeDeleteConfirmDialog()
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memadam lampiran pentadbir.')
|
||||
} finally {
|
||||
deletingDocumentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadDocument(document: MembershipApplicationDocumentDetail) {
|
||||
if (!application.value || downloadingDocumentId.value) return
|
||||
|
||||
@@ -635,7 +732,7 @@ onUnmounted(() => {
|
||||
<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
|
||||
Penama
|
||||
</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"
|
||||
@@ -770,13 +867,13 @@ onUnmounted(() => {
|
||||
<FieldLabel for="current_position">Jawatan Semasa</FieldLabel>
|
||||
<Input id="current_position" v-model="form.applicant.current_position" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position']
|
||||
}}</FieldError>
|
||||
}}</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="!canEdit" />
|
||||
<FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address']
|
||||
}}</FieldError>
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-4">
|
||||
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
|
||||
@@ -805,7 +902,7 @@ onUnmounted(() => {
|
||||
<div class="space-y-4">
|
||||
<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="canEdit && form.heirs.length > 1" type="button" look="outline" size="sm"
|
||||
@click="removeHeir(index)">
|
||||
Buang
|
||||
@@ -822,7 +919,7 @@ onUnmounted(() => {
|
||||
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
|
||||
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" :disabled="!canEdit" />
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`]
|
||||
}}</FieldError>
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
@@ -855,10 +952,10 @@ onUnmounted(() => {
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="canEdit" type="button" look="outline" @click="addHeir">
|
||||
<!-- <Button v-if="canEdit" type="button" look="outline" @click="addHeir">
|
||||
<Plus class="mr-2 size-4" />
|
||||
Tambah Waris
|
||||
</Button>
|
||||
Tambah Penama
|
||||
</Button> -->
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -900,25 +997,27 @@ onUnmounted(() => {
|
||||
Muat naik fail baharu untuk menggantikan dokumen sedia ada.
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div v-for="type in DOCUMENT_UPLOAD_TYPES" :key="type" class="rounded-lg border border-foreground/10 p-4">
|
||||
<div v-for="type in APPLICANT_DOCUMENT_UPLOAD_TYPES" :key="type"
|
||||
class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">{{ DOCUMENT_TYPE_LABELS[type] }}</div>
|
||||
<div v-if="documentsByType[type]" class="mt-1 text-sm opacity-70">
|
||||
{{ documentsByType[type]?.name }} · {{ formatFileSize(documentsByType[type]?.file_size) }}
|
||||
<div v-if="applicantDocumentsByType[type]" class="mt-1 text-sm opacity-70">
|
||||
{{ applicantDocumentsByType[type]?.name }} ·
|
||||
{{ formatFileSize(applicantDocumentsByType[type]?.file_size) }}
|
||||
</div>
|
||||
<div v-else class="mt-1 text-sm opacity-70">Tiada dokumen dimuat naik.</div>
|
||||
</div>
|
||||
<div v-if="documentsByType[type]" class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="applicantDocumentsByType[type]" class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="previewLoading && previewDocument?.id === documentsByType[type]?.id"
|
||||
@click="documentsByType[type] && handleViewDocument(documentsByType[type]!)">
|
||||
:disabled="previewLoading && previewDocument?.id === applicantDocumentsByType[type]?.id"
|
||||
@click="applicantDocumentsByType[type] && handleViewDocument(applicantDocumentsByType[type]!)">
|
||||
<Eye class="mr-2 size-4" />
|
||||
Lihat
|
||||
</Button>
|
||||
<Button type="button" look="outline" size="sm"
|
||||
:disabled="downloadingDocumentId === documentsByType[type]?.id"
|
||||
@click="documentsByType[type] && handleDownloadDocument(documentsByType[type]!)">
|
||||
:disabled="downloadingDocumentId === applicantDocumentsByType[type]?.id"
|
||||
@click="applicantDocumentsByType[type] && handleDownloadDocument(applicantDocumentsByType[type]!)">
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
@@ -926,7 +1025,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<Field v-if="canEdit" class="mt-4">
|
||||
<FieldLabel :for="`document-${type}`">
|
||||
{{ documentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
|
||||
{{ applicantDocumentsByType[type] ? 'Ganti Dokumen' : 'Muat Naik Dokumen' }}
|
||||
</FieldLabel>
|
||||
<Input :id="`document-${type}`" type="file" :accept="documentAccept(type)"
|
||||
:disabled="uploadingDocumentType === type" @change="handleDocumentUpload(type, $event)" />
|
||||
@@ -935,6 +1034,51 @@ onUnmounted(() => {
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="mb-4 font-medium">Lampiran Pentadbir</div>
|
||||
<p class="mb-4 text-sm opacity-70">
|
||||
Muat naik satu atau lebih dokumen tambahan semasa semakan permohonan. Setiap muat naik akan
|
||||
menambah lampiran baharu.
|
||||
</p>
|
||||
<div v-if="adminAttachments.length" class="mb-4 space-y-3">
|
||||
<div v-for="document in adminAttachments" :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">{{ document.name }}</div>
|
||||
<div class="mt-1 text-sm opacity-70">{{ 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" />
|
||||
Lihat
|
||||
</Button>
|
||||
<Button type="button" look="outline" size="sm" :disabled="downloadingDocumentId === document.id"
|
||||
@click="handleDownloadDocument(document)">
|
||||
<Download class="mr-2 size-4" />
|
||||
Muat Turun
|
||||
</Button>
|
||||
<Button v-if="canEdit" type="button" look="outline" size="sm" variant="danger"
|
||||
:disabled="deletingDocumentId === document.id" @click="handleDeleteAdminAttachment(document)">
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
{{ deletingDocumentId === document.id ? 'Memadam...' : 'Padam' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mb-4 text-sm opacity-70">Tiada lampiran pentadbir dimuat naik.</div>
|
||||
<Field v-if="canEdit" class="rounded-lg border border-foreground/10 p-4">
|
||||
<FieldLabel for="document-admin-attachment">Tambah Lampiran Pentadbir</FieldLabel>
|
||||
<Input id="document-admin-attachment" type="file" accept=".pdf,.jpg,.jpeg,.png" multiple
|
||||
:disabled="uploadingAdminAttachment" @change="handleAdminAttachmentUpload" />
|
||||
<FieldError v-if="fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`]">
|
||||
{{ fieldErrors[`documents.${ADMIN_ATTACHMENT_DOCUMENT_TYPE}`] }}
|
||||
</FieldError>
|
||||
<p v-if="uploadingAdminAttachment" class="mt-1 text-sm opacity-70">Memuat naik...</p>
|
||||
</Field>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="application.reviews.length" value="reviews" class="mt-6">
|
||||
@@ -998,6 +1142,27 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DialogRoot :open="deleteConfirmDialogOpen"
|
||||
@openChange="(details) => { if (!details.open) closeDeleteConfirmDialog() }">
|
||||
<DialogContent>
|
||||
<div class="p-5 text-center">
|
||||
<div class="mt-2 text-2xl font-medium">Padam Lampiran Pentadbir?</div>
|
||||
<div class="mt-2 opacity-70">
|
||||
{{ pendingDeleteDocument?.name ?? 'Lampiran pentadbir' }} akan dipadam secara kekal.
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-28" :disabled="!!deletingDocumentId" @click="closeDeleteConfirmDialog">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button class="w-28" type="button" variant="danger" :disabled="!!deletingDocumentId"
|
||||
@click="confirmDeleteAdminAttachment">
|
||||
{{ deletingDocumentId ? 'Memadam...' : 'Padam' }}
|
||||
</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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CircleAlert, CircleCheck, Search, Eye, Pencil, FileText, Download } from '@lucide/vue'
|
||||
import { CircleAlert, CircleCheck, Search, Eye, Pencil, Download } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import {
|
||||
@@ -36,7 +36,6 @@ import { usePermissions } from '@/composables/usePermissions'
|
||||
import {
|
||||
batchCompleteMembershipApplications,
|
||||
downloadMembershipApplicationDocument,
|
||||
generateMembershipApplicationResultLetter,
|
||||
} from '../services/membership-application.service'
|
||||
import {
|
||||
boardResultBadgeVariant,
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
import type {
|
||||
BatchCompleteFailedItem,
|
||||
BatchCompleteResponse,
|
||||
GenerateResultLetterResponse,
|
||||
MembershipApplicationBoardResult,
|
||||
MembershipApplicationListItem,
|
||||
MembershipApplicationStatus,
|
||||
@@ -57,7 +55,7 @@ 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: 'Menunggu Keputusan ALK', value: 'PENDING_BOARD' },
|
||||
{ label: 'Ditolak Pentadbiran', value: 'MANAGEMENT_REJECTED' },
|
||||
{ label: 'Menunggu Makluman', value: 'PENDING_NOTIFICATION' },
|
||||
{ label: 'Selesai', value: 'COMPLETED' },
|
||||
@@ -105,18 +103,13 @@ const {
|
||||
} = useMembershipApplicationList()
|
||||
|
||||
const canBatchComplete = computed(() => hasPermission('selesaikan permohonan keahlian'))
|
||||
const canGenerateResultLetter = computed(() => hasPermission('jana surat keputusan keahlian'))
|
||||
const selectedIds = ref<string[]>([])
|
||||
const batchSubmitting = ref(false)
|
||||
const batchConfirmOpen = ref(false)
|
||||
const batchSuccessMessage = ref<string | null>(null)
|
||||
const letterSuccessMessage = ref<string | null>(null)
|
||||
const batchFailedItems = ref<BatchCompleteFailedItem[]>([])
|
||||
const generateDialogOpen = ref(false)
|
||||
const generateSubmitting = ref(false)
|
||||
const boardMeetingReference = ref('')
|
||||
const boardMeetingReferenceError = ref<string | null>(null)
|
||||
const generateTarget = ref<MembershipApplicationListItem | null>(null)
|
||||
const downloadingResultLetterId = ref<string | null>(null)
|
||||
|
||||
const selectableApplications = computed(() =>
|
||||
@@ -167,19 +160,28 @@ function toggleSelectAllOnPage(checked: boolean) {
|
||||
|
||||
function openBatchConfirm() {
|
||||
if (!selectedIds.value.length) return
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
batchConfirmOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmBatchComplete() {
|
||||
if (!selectedIds.value.length || batchSubmitting.value) return
|
||||
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
if (!reference) {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
batchSubmitting.value = true
|
||||
batchSuccessMessage.value = null
|
||||
batchFailedItems.value = []
|
||||
boardMeetingReferenceError.value = null
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await batchCompleteMembershipApplications(selectedIds.value)
|
||||
const response = await batchCompleteMembershipApplications(selectedIds.value, reference)
|
||||
|
||||
if (response.success) {
|
||||
batchSuccessMessage.value = response.message
|
||||
@@ -196,6 +198,8 @@ async function confirmBatchComplete() {
|
||||
const responseData = err.response.data as BatchCompleteResponse
|
||||
batchFailedItems.value = responseData.data?.failed ?? []
|
||||
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menyelesaikan permohonan.')
|
||||
}
|
||||
@@ -213,7 +217,7 @@ const statusFilterInitial = computed(() => apiValueToLabel(STATUS_FILTER_OPTIONS
|
||||
function statusLabel(status: MembershipApplicationStatus): string {
|
||||
const labels: Record<MembershipApplicationStatus, string> = {
|
||||
SUBMITTED: 'Dihantar',
|
||||
PENDING_BOARD: 'Menunggu Lembaga',
|
||||
PENDING_BOARD: 'Menunggu Keputusan ALK',
|
||||
MANAGEMENT_REJECTED: 'Ditolak Pentadbiran',
|
||||
PENDING_NOTIFICATION: 'Menunggu Makluman',
|
||||
COMPLETED: 'Selesai',
|
||||
@@ -241,70 +245,10 @@ function goToApplicationEdit(id: string) {
|
||||
router.push({ name: 'edit-membership-application', params: { id } })
|
||||
}
|
||||
|
||||
function canGenerateLetter(item: MembershipApplicationListItem): boolean {
|
||||
return (
|
||||
canGenerateResultLetter.value &&
|
||||
item.status === 'COMPLETED' &&
|
||||
!item.has_result_letter &&
|
||||
(item.board_result === 'PASS' || item.board_result === 'FAIL')
|
||||
)
|
||||
}
|
||||
|
||||
function canDownloadLetter(item: MembershipApplicationListItem): boolean {
|
||||
return !!item.has_result_letter && !!item.result_letter_document
|
||||
}
|
||||
|
||||
function openGenerateDialog(item: MembershipApplicationListItem) {
|
||||
generateTarget.value = item
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
generateDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeGenerateDialog() {
|
||||
if (generateSubmitting.value) return
|
||||
generateDialogOpen.value = false
|
||||
generateTarget.value = null
|
||||
boardMeetingReference.value = ''
|
||||
boardMeetingReferenceError.value = null
|
||||
}
|
||||
|
||||
async function confirmGenerateLetter() {
|
||||
if (!generateTarget.value || generateSubmitting.value) return
|
||||
|
||||
const reference = boardMeetingReference.value.trim()
|
||||
if (!reference) {
|
||||
boardMeetingReferenceError.value = 'Rujukan mesyuarat lembaga diperlukan.'
|
||||
return
|
||||
}
|
||||
|
||||
generateSubmitting.value = true
|
||||
boardMeetingReferenceError.value = null
|
||||
error.value = null
|
||||
letterSuccessMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await generateMembershipApplicationResultLetter(generateTarget.value.id, {
|
||||
board_meeting_reference: reference,
|
||||
})
|
||||
|
||||
letterSuccessMessage.value = response.message
|
||||
closeGenerateDialog()
|
||||
await fetchApplications(page.value)
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.data) {
|
||||
const responseData = err.response.data as GenerateResultLetterResponse
|
||||
const validationErrors = getApiValidationErrors(err)
|
||||
boardMeetingReferenceError.value = validationErrors?.board_meeting_reference?.[0] ?? null
|
||||
error.value = responseData.message ?? getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, 'Gagal menjana surat keputusan.')
|
||||
}
|
||||
} finally {
|
||||
generateSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadResultLetter(item: MembershipApplicationListItem) {
|
||||
const document = item.result_letter_document
|
||||
if (!document) return
|
||||
@@ -384,13 +328,6 @@ const headers = computed<TableHeader[]>(() => {
|
||||
<p class="mt-1 text-sm opacity-70">Urus dan semak permohonan keahlian koperasi.</p>
|
||||
</div>
|
||||
|
||||
<AlertRoot v-if="letterSuccessMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
<AlertDescription>{{ letterSuccessMessage }}</AlertDescription>
|
||||
<AlertCloseTrigger @click="letterSuccessMessage = null" />
|
||||
</AlertRoot>
|
||||
|
||||
<AlertRoot v-if="batchSuccessMessage" variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Berjaya</AlertTitle>
|
||||
@@ -482,11 +419,8 @@ const headers = computed<TableHeader[]>(() => {
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<Badge
|
||||
:variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
|
||||
:look="statusBadgeLook((item as MembershipApplicationListItem).status)"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
<Badge :variant="statusBadgeVariant((item as MembershipApplicationListItem).status)"
|
||||
:look="statusBadgeLook((item as MembershipApplicationListItem).status)" class="whitespace-nowrap">
|
||||
{{ statusLabel((item as MembershipApplicationListItem).status) }}
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -516,27 +450,10 @@ const headers = computed<TableHeader[]>(() => {
|
||||
@click="goToApplicationEdit((item as MembershipApplicationListItem).id)">
|
||||
<Pencil class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canGenerateLetter(item as MembershipApplicationListItem)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-amber-600 text-white"
|
||||
title="Jana surat keputusan"
|
||||
@click="openGenerateDialog(item as MembershipApplicationListItem)"
|
||||
>
|
||||
<FileText class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDownloadLetter(item as MembershipApplicationListItem)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="bg-purple-600 text-white"
|
||||
title="Muat turun surat keputusan"
|
||||
<Button v-if="canDownloadLetter(item as MembershipApplicationListItem)" type="button" variant="ghost"
|
||||
size="sm" class="bg-purple-600 text-white" title="Muat turun surat keputusan"
|
||||
:disabled="downloadingResultLetterId === (item as MembershipApplicationListItem).id"
|
||||
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)"
|
||||
>
|
||||
@click="handleDownloadResultLetter(item as MembershipApplicationListItem)">
|
||||
<Download class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -548,8 +465,16 @@ const headers = computed<TableHeader[]>(() => {
|
||||
<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.
|
||||
{{ selectedIds.length }} permohonan akan diselesaikan. Surat keputusan dijana dan e-mel dengan lampiran
|
||||
surat dihantar.
|
||||
</div>
|
||||
<Field class="mt-5 text-left">
|
||||
<FieldLabel for="batch-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||
<Input id="batch-board-meeting-reference" v-model="boardMeetingReference" type="text"
|
||||
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026" :disabled="batchSubmitting"
|
||||
@input="boardMeetingReferenceError = null" />
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="batchSubmitting">
|
||||
@@ -562,45 +487,5 @@ const headers = computed<TableHeader[]>(() => {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
|
||||
<DialogRoot :open="generateDialogOpen" @openChange="(details) => { if (!details.open) closeGenerateDialog() }">
|
||||
<DialogContent>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-medium">Jana Surat Keputusan</div>
|
||||
<p v-if="generateTarget" class="mt-2 text-sm opacity-70">
|
||||
{{ generateTarget.application_number }} · {{ generateTarget.applicant?.name ?? '-' }}
|
||||
</p>
|
||||
<Field class="mt-5">
|
||||
<FieldLabel for="board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||
<Input
|
||||
id="board-meeting-reference"
|
||||
v-model="boardMeetingReference"
|
||||
type="text"
|
||||
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026"
|
||||
:disabled="generateSubmitting"
|
||||
@input="boardMeetingReferenceError = null"
|
||||
/>
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
<p class="mt-3 text-sm opacity-70">
|
||||
Surat hanya boleh dijana sekali dan akan disimpan sebagai dokumen permohonan.
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-5 pb-8 text-center">
|
||||
<DialogCloseTrigger class="mr-2 w-32" :disabled="generateSubmitting" @click="closeGenerateDialog">
|
||||
Batal
|
||||
</DialogCloseTrigger>
|
||||
<Button
|
||||
class="w-32"
|
||||
type="button"
|
||||
variant="primary"
|
||||
:disabled="generateSubmitting"
|
||||
@click="confirmGenerateLetter"
|
||||
>
|
||||
{{ generateSubmitting ? 'Menjana...' : 'Jana Surat' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -157,6 +157,21 @@ export async function uploadMembershipApplicationDocument(
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteMembershipApplicationDocument(
|
||||
id: string,
|
||||
documentId: string,
|
||||
): Promise<MembershipApplicationUpdateResponse> {
|
||||
const { data } = await api.delete<MembershipApplicationUpdateResponse>(
|
||||
`/v1/membership-applications/${id}/documents/${documentId}`,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Failed to delete document')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Submit management review
|
||||
export async function submitManagementReview(
|
||||
id: string,
|
||||
@@ -194,9 +209,11 @@ export async function submitBoardReview(
|
||||
// Complete membership application
|
||||
export async function completeMembershipApplication(
|
||||
id: string,
|
||||
payload: GenerateResultLetterPayload,
|
||||
): Promise<MembershipApplicationReviewResponse> {
|
||||
const { data } = await api.post<MembershipApplicationReviewResponse>(
|
||||
`/v1/membership-applications/${id}/complete`,
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
@@ -208,10 +225,14 @@ export async function completeMembershipApplication(
|
||||
|
||||
export async function batchCompleteMembershipApplications(
|
||||
applicationIds: string[],
|
||||
boardMeetingReference: string,
|
||||
): Promise<BatchCompleteResponse> {
|
||||
const { data } = await api.post<BatchCompleteResponse>(
|
||||
'/v1/membership-applications/batch-complete',
|
||||
{ application_ids: applicationIds },
|
||||
{
|
||||
application_ids: applicationIds,
|
||||
board_meeting_reference: boardMeetingReference,
|
||||
},
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -81,6 +81,7 @@ export type MembershipApplicationStatus =
|
||||
export type MembershipApplicationBoardResult = 'PASS' | 'FAIL'
|
||||
|
||||
export const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter' as const
|
||||
export const ADMIN_ATTACHMENT_DOCUMENT_TYPE = 'admin_attachment' as const
|
||||
|
||||
export interface MembershipApplicationApplicantSummary {
|
||||
name: string
|
||||
@@ -208,7 +209,12 @@ export type MembershipApplicationUpdateResponse = MembershipApplicationApiRespon
|
||||
message: string
|
||||
}
|
||||
|
||||
export type DocumentUploadType = 'ic_copy' | 'photo' | 'salary_slip' | 'employer_letter'
|
||||
export type DocumentUploadType =
|
||||
| 'ic_copy'
|
||||
| 'photo'
|
||||
| 'salary_slip'
|
||||
| 'employer_letter'
|
||||
| 'admin_attachment'
|
||||
|
||||
export type ManagementReviewDecision = 'APPROVED' | 'REJECTED'
|
||||
export type BoardReviewDecision = 'PASS' | 'FAIL'
|
||||
|
||||
@@ -40,10 +40,26 @@ export const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
photo: 'Gambar Passport',
|
||||
salary_slip: 'Slip Gaji',
|
||||
employer_letter: 'Surat Pengesahan Majikan',
|
||||
admin_attachment: 'Lampiran Pentadbir',
|
||||
}
|
||||
|
||||
export const DOCUMENT_UPLOAD_TYPES = ['ic_copy', 'photo', 'salary_slip', 'employer_letter'] as const
|
||||
export type DocumentUploadType = (typeof DOCUMENT_UPLOAD_TYPES)[number]
|
||||
export const APPLICANT_DOCUMENT_UPLOAD_TYPES = [
|
||||
'ic_copy',
|
||||
'photo',
|
||||
'salary_slip',
|
||||
'employer_letter',
|
||||
] as const
|
||||
|
||||
export const ADMIN_DOCUMENT_UPLOAD_TYPES = ['admin_attachment'] as const
|
||||
|
||||
export const DOCUMENT_UPLOAD_TYPES = [
|
||||
...APPLICANT_DOCUMENT_UPLOAD_TYPES,
|
||||
...ADMIN_DOCUMENT_UPLOAD_TYPES,
|
||||
] as const
|
||||
|
||||
export type ApplicantDocumentUploadType = (typeof APPLICANT_DOCUMENT_UPLOAD_TYPES)[number]
|
||||
export type AdminDocumentUploadType = (typeof ADMIN_DOCUMENT_UPLOAD_TYPES)[number]
|
||||
export type DocumentUploadType = ApplicantDocumentUploadType | AdminDocumentUploadType
|
||||
|
||||
export function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
|
||||
@@ -69,6 +69,33 @@ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Freelance', value: 'Freelance' },
|
||||
]
|
||||
|
||||
// TODO: replace with API lookup
|
||||
const EMPLOYERS = [
|
||||
{
|
||||
name: 'Infra Quest Sdn Bhd (IQSB)',
|
||||
address: 'Lot 1045, Jalan Dato’ Lundang, 15200 Kota Bharu, Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Permodalan Kelantan Berhad (PKB)',
|
||||
address:
|
||||
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
|
||||
},
|
||||
{
|
||||
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
|
||||
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 COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
||||
label: employer.name,
|
||||
value: employer.name,
|
||||
}))
|
||||
|
||||
function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
@@ -88,9 +115,12 @@ function apiValueToLabel(options: SelectOption[], value: string | null | undefin
|
||||
}
|
||||
|
||||
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
|
||||
const companyNameCollection = createSelectCollection(COMPANY_OPTIONS)
|
||||
|
||||
const employmentTypeValue = ref<string[]>([])
|
||||
const employmentTypeInitial = ref<string[]>([])
|
||||
const companyNameValue = ref<string[]>([])
|
||||
const companyNameInitial = ref<string[]>([])
|
||||
|
||||
function clearEmploymentFieldError(field: EmploymentFieldKey) {
|
||||
delete employmentErrors[field]
|
||||
@@ -140,6 +170,10 @@ const employmentTypeLabel = computed(() =>
|
||||
|
||||
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
|
||||
|
||||
const canAddEmployment = computed(() => !loadingEmployments.value && employments.value.length === 0)
|
||||
|
||||
const showEmploymentForm = computed(() => isEditingEmployment.value || canAddEmployment.value)
|
||||
|
||||
function setEmploymentTypeValue(details: { value: string[] }) {
|
||||
employmentTypeValue.value = details.value
|
||||
clearEmploymentFieldError('employment_type')
|
||||
@@ -147,9 +181,17 @@ function setEmploymentTypeValue(details: { value: string[] }) {
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function setCompanyNameValue(details: { value: string[] }) {
|
||||
companyNameValue.value = details.value
|
||||
clearEmploymentFieldError('company_name')
|
||||
employmentForm.company_name = details.value[0] ?? ''
|
||||
}
|
||||
|
||||
function syncEmploymentSelectValues() {
|
||||
employmentTypeValue.value = apiValueToLabel(EMPLOYMENT_TYPE_OPTIONS, employmentForm.employment_type)
|
||||
employmentTypeInitial.value = [...employmentTypeValue.value]
|
||||
companyNameValue.value = apiValueToLabel(COMPANY_OPTIONS, employmentForm.company_name)
|
||||
companyNameInitial.value = [...companyNameValue.value]
|
||||
}
|
||||
|
||||
function resetEmploymentForm() {
|
||||
@@ -194,7 +236,7 @@ function validateEmploymentForm(): boolean {
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!employmentForm.company_name.trim()) {
|
||||
if (!companyNameValue.value[0]?.trim()) {
|
||||
employmentErrors.company_name = 'Nama syarikat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
@@ -441,7 +483,8 @@ onMounted(async () => {
|
||||
Tiada pekerjaan direkodkan.
|
||||
</div>
|
||||
|
||||
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveEmployment">
|
||||
<form v-if="showEmploymentForm" class="space-y-6 border-t border-foreground/10 pt-6"
|
||||
@submit.prevent="onSaveEmployment">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-900">
|
||||
@@ -470,10 +513,24 @@ onMounted(async () => {
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="employment-company">Nama Syarikat</FieldLabel>
|
||||
<Input id="employment-company" v-model="employmentForm.company_name" type="text"
|
||||
placeholder="Nama syarikat" :aria-invalid="!!employmentErrors.company_name"
|
||||
@input="clearEmploymentFieldError('company_name')" />
|
||||
<FieldLabel>Nama Syarikat</FieldLabel>
|
||||
<SelectRoot :key="`company-name-${editingEmploymentId ?? 'new'}`" class="w-full"
|
||||
:collection="companyNameCollection" :default-value="companyNameInitial" :disabled="savingEmployment"
|
||||
@value-change="setCompanyNameValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!employmentErrors.company_name">
|
||||
<SelectValueText placeholder="Pilih syarikat" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in companyNameCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="employmentErrors.company_name">{{ employmentErrors.company_name }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
|
||||
@@ -90,7 +90,10 @@ const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
|
||||
const relationshipValue = ref<string[]>([])
|
||||
const relationshipInitial = ref<string[]>([])
|
||||
|
||||
const MAX_HEIRS = 1
|
||||
const isEditingHeir = computed(() => editingHeirId.value !== null)
|
||||
const hasReachedHeirLimit = computed(() => heirs.value.length >= MAX_HEIRS)
|
||||
const showHeirForm = computed(() => isEditingHeir.value || !hasReachedHeirLimit.value)
|
||||
|
||||
function emptyHeirForm() {
|
||||
return {
|
||||
@@ -204,7 +207,7 @@ async function fetchHeirs() {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan pewaris.'),
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan penama.'),
|
||||
})
|
||||
} finally {
|
||||
loadingHeirs.value = false
|
||||
@@ -224,6 +227,15 @@ function startEditHeir(heir: Heir) {
|
||||
}
|
||||
|
||||
async function onSaveHeir() {
|
||||
if (!isEditingHeir.value && hasReachedHeirLimit.value) {
|
||||
await Swal.fire({
|
||||
icon: 'info',
|
||||
title: 'Had penama',
|
||||
text: 'Hanya satu penama dibenarkan.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateHeirForm()) {
|
||||
return
|
||||
}
|
||||
@@ -238,7 +250,7 @@ async function onSaveHeir() {
|
||||
: await createHeir(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan pewaris.')
|
||||
throw new Error(res.message ?? 'Gagal menyimpan penama.')
|
||||
}
|
||||
|
||||
await fetchHeirs()
|
||||
@@ -248,7 +260,7 @@ async function onSaveHeir() {
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Pewaris berjaya dikemas kini.' : 'Pewaris berjaya ditambah.',
|
||||
title: wasEditing ? 'Penama berjaya dikemas kini.' : 'Penama berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
@@ -257,7 +269,7 @@ async function onSaveHeir() {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan pewaris.'),
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan penama.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
@@ -268,7 +280,7 @@ async function onSaveHeir() {
|
||||
async function onDeleteHeir(heir: Heir) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam pewaris?',
|
||||
title: 'Padam penama?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
@@ -283,7 +295,7 @@ async function onDeleteHeir(heir: Heir) {
|
||||
const res = await deleteHeir(heir.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam pewaris.')
|
||||
throw new Error(res.message ?? 'Gagal memadam penama.')
|
||||
}
|
||||
|
||||
if (editingHeirId.value === heir.id) {
|
||||
@@ -296,7 +308,7 @@ async function onDeleteHeir(heir: Heir) {
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Pewaris berjaya dipadam.',
|
||||
title: 'Penama berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
@@ -304,7 +316,7 @@ async function onDeleteHeir(heir: Heir) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam pewaris.'),
|
||||
text: getApiErrorMessage(error, 'Gagal memadam penama.'),
|
||||
})
|
||||
} finally {
|
||||
deletingHeirId.value = null
|
||||
@@ -323,23 +335,20 @@ onMounted(async () => {
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Penama</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus maklumat pewaris anda.
|
||||
Urus maklumat penama anda. Hanya satu penama dibenarkan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingHeirs" class="text-sm text-slate-500">
|
||||
Memuatkan pewaris...
|
||||
Memuatkan penama...
|
||||
</div>
|
||||
|
||||
<div v-else-if="heirs.length" class="space-y-3">
|
||||
<div
|
||||
v-for="heir in heirs"
|
||||
:key="heir.id"
|
||||
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div v-for="heir in heirs" :key="heir.id"
|
||||
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ heir.name }}</span>
|
||||
@@ -351,64 +360,47 @@ onMounted(async () => {
|
||||
<p class="mt-1 text-sm text-slate-700">{{ heir.address }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingHeirId === heir.id"
|
||||
@click="startEditHeir(heir)"
|
||||
>
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingHeirId === heir.id" @click="startEditHeir(heir)">
|
||||
<Lucide class="mr-2 size-4" icon="Pencil" />
|
||||
Kemaskini
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none text-danger"
|
||||
:disabled="deletingHeirId === heir.id"
|
||||
@click="onDeleteHeir(heir)"
|
||||
>
|
||||
<Lucide
|
||||
class="mr-2 size-4"
|
||||
:icon="deletingHeirId === heir.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingHeirId === heir.id }"
|
||||
/>
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-danger"
|
||||
:disabled="deletingHeirId === heir.id" @click="onDeleteHeir(heir)">
|
||||
<Lucide class="mr-2 size-4" :icon="deletingHeirId === heir.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingHeirId === heir.id }" />
|
||||
Padam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada pewaris direkodkan.
|
||||
<div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
|
||||
Tiada penama direkodkan.
|
||||
</div>
|
||||
|
||||
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveHeir">
|
||||
<div v-if="hasReachedHeirLimit && !isEditingHeir"
|
||||
class="rounded-lg border border-foreground/10 bg-foreground/5 p-4 text-sm text-slate-600">
|
||||
Had penama telah dicapai. Kemaskini atau padam penama sedia ada untuk membuat perubahan.
|
||||
</div>
|
||||
|
||||
<form v-if="showHeirForm" class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveHeir">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-900">
|
||||
{{ isEditingHeir ? 'Kemaskini Pewaris' : 'Tambah Pewaris' }}
|
||||
{{ isEditingHeir ? 'Kemaskini Penama' : 'Tambah Penama' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingHeir
|
||||
? 'Kemas kini maklumat pewaris yang dipilih.'
|
||||
: 'Tambah pewaris baharu ke profil anda.'
|
||||
? 'Kemas kini maklumat penama yang dipilih.'
|
||||
: 'Tambah penama baharu ke profil anda.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isEditingHeir"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="savingHeir"
|
||||
@click="resetHeirForm"
|
||||
>
|
||||
<Button v-if="isEditingHeir" type="button" variant="ghost" class="border border-foreground/15 shadow-none"
|
||||
:disabled="savingHeir" @click="resetHeirForm">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingHeir">
|
||||
@@ -421,38 +413,21 @@ onMounted(async () => {
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="heir-name">Nama</FieldLabel>
|
||||
<Input
|
||||
id="heir-name"
|
||||
v-model="heirForm.name"
|
||||
type="text"
|
||||
placeholder="Nama penuh"
|
||||
:aria-invalid="!!heirErrors.name"
|
||||
@input="clearHeirFieldError('name')"
|
||||
/>
|
||||
<Input id="heir-name" v-model="heirForm.name" type="text" placeholder="Nama penuh"
|
||||
:aria-invalid="!!heirErrors.name" @input="clearHeirFieldError('name')" />
|
||||
<FieldError v-if="heirErrors.name">{{ heirErrors.name }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="heir-ic">No. Kad Pengenalan</FieldLabel>
|
||||
<Input
|
||||
id="heir-ic"
|
||||
v-model="heirForm.ic_number"
|
||||
type="text"
|
||||
placeholder="No. kad pengenalan"
|
||||
:aria-invalid="!!heirErrors.ic_number"
|
||||
@input="clearHeirFieldError('ic_number')"
|
||||
/>
|
||||
<FieldLabel for="heir-ic-number">No. Kad Pengenalan</FieldLabel>
|
||||
<Input id="heir-ic-number" v-model="heirForm.ic_number" type="text" placeholder="Contoh: 900101011234"
|
||||
:aria-invalid="!!heirErrors.ic_number" @input="clearHeirFieldError('ic_number')" />
|
||||
<FieldError v-if="heirErrors.ic_number">{{ heirErrors.ic_number }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Hubungan</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`heir-relationship-${editingHeirId ?? 'new'}`"
|
||||
class="w-full"
|
||||
:collection="relationshipCollection"
|
||||
:default-value="relationshipInitial"
|
||||
:disabled="savingHeir"
|
||||
@value-change="setRelationshipValue"
|
||||
>
|
||||
<SelectRoot :key="`heir-relationship-${editingHeirId ?? 'new'}`" class="w-full"
|
||||
:collection="relationshipCollection" :default-value="relationshipInitial" :disabled="savingHeir"
|
||||
@value-change="setRelationshipValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!heirErrors.relationship">
|
||||
<SelectValueText placeholder="Pilih hubungan" />
|
||||
@@ -461,11 +436,7 @@ onMounted(async () => {
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in relationshipCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItem v-for="item in relationshipCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
@@ -474,37 +445,23 @@ onMounted(async () => {
|
||||
<FieldError v-if="heirErrors.relationship">{{ heirErrors.relationship }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="heir-phone">No. Telefon</FieldLabel>
|
||||
<Input
|
||||
id="heir-phone"
|
||||
v-model="heirForm.phone_number"
|
||||
type="text"
|
||||
placeholder="No. telefon"
|
||||
:aria-invalid="!!heirErrors.phone_number"
|
||||
@input="clearHeirFieldError('phone_number')"
|
||||
/>
|
||||
<FieldLabel for="heir-phone-number">No. Telefon</FieldLabel>
|
||||
<Input id="heir-phone-number" v-model="heirForm.phone_number" type="text"
|
||||
placeholder="Contoh: 0123456789" :aria-invalid="!!heirErrors.phone_number"
|
||||
@input="clearHeirFieldError('phone_number')" />
|
||||
<FieldError v-if="heirErrors.phone_number">{{ heirErrors.phone_number }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<FieldLabel for="heir-address">Alamat</FieldLabel>
|
||||
<Textarea
|
||||
id="heir-address"
|
||||
v-model="heirForm.address"
|
||||
placeholder="Alamat penuh"
|
||||
class="resize-none"
|
||||
:aria-invalid="!!heirErrors.address"
|
||||
@input="clearHeirFieldError('address')"
|
||||
/>
|
||||
<Textarea id="heir-address" v-model="heirForm.address" rows="3" placeholder="Alamat penama"
|
||||
:aria-invalid="!!heirErrors.address" @input="clearHeirFieldError('address')" />
|
||||
<FieldError v-if="heirErrors.address">{{ heirErrors.address }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<CheckboxRoot
|
||||
:checked="heirForm.is_primary"
|
||||
:disabled="savingHeir"
|
||||
@checked-change="({ checked }) => (heirForm.is_primary = checked === true)"
|
||||
>
|
||||
<CheckboxRoot :checked="heirForm.is_primary" :disabled="savingHeir"
|
||||
@checked-change="({ checked }) => (heirForm.is_primary = checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Pewaris utama</CheckboxLabel>
|
||||
<CheckboxLabel>Penama utama</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Swal from 'sweetalert2'
|
||||
import fakers from '@/utils/faker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { SwitchRoot, SwitchControl } from '@/components/ui/switch'
|
||||
import { ProgressRoot, ProgressTrack, ProgressRange } from '@/components/ui/progress-linear'
|
||||
import {
|
||||
CarouselRoot,
|
||||
CarouselPrevTrigger,
|
||||
CarouselNextTrigger,
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { FileIcon } from '@/components/ui/file-icon'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import logoUrl from '@/assets/images/logo-kopkb.svg'
|
||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||
import { listEmployments } from '@/modules/profile/services/employment.service'
|
||||
import { uploadProfileImage } from '@/modules/profile/services/profile.service'
|
||||
import type { Employment } from '@/modules/profile/types/employment.types'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import MemberDigitalCard from '../components/MemberDigitalCard.vue'
|
||||
import ProfileTab from './ProfileTab.vue'
|
||||
import EmploymentTab from './EmploymentTab.vue'
|
||||
import BankDetailTab from './BankDetailTab.vue'
|
||||
@@ -34,12 +23,59 @@ const authStore = useAuthStore()
|
||||
const uploadingImage = ref(false)
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null)
|
||||
const imagePreviewUrl = ref<string | null>(null)
|
||||
const employments = ref<Employment[]>([])
|
||||
|
||||
const companyName = computed(() => {
|
||||
const currentEmployment = employments.value.find((employment) => employment.is_current)
|
||||
return currentEmployment?.company_name ?? employments.value[0]?.company_name ?? null
|
||||
})
|
||||
|
||||
const profileUrl = computed(() => {
|
||||
const token = authStore.user?.public_profile_token
|
||||
if (!token) return null
|
||||
|
||||
const baseUrl = (typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: import.meta.env.VITE_APP_URL || ''
|
||||
).replace(/\/$/, '')
|
||||
|
||||
return `${baseUrl}/v/${token}`
|
||||
})
|
||||
|
||||
const displayValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Aktif',
|
||||
pending: 'Menunggu',
|
||||
inactive: 'Tidak Aktif',
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const status = authStore.userStatus
|
||||
if (!status) return '-'
|
||||
return STATUS_LABELS[status] ?? status.charAt(0).toUpperCase() + status.slice(1)
|
||||
})
|
||||
|
||||
const statusBadgeVariant = computed(() => {
|
||||
if (authStore.isAccountActive) return 'success' as const
|
||||
if (authStore.isAccountPending) return 'pending' as const
|
||||
return 'secondary' as const
|
||||
})
|
||||
|
||||
function formatDateLabel(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('ms-MY', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const avatarSrc = computed(
|
||||
() => imagePreviewUrl.value ?? authStore.userImageUrl ?? undefined,
|
||||
)
|
||||
@@ -98,10 +134,21 @@ async function onImageSelected(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEmployments() {
|
||||
try {
|
||||
const res = await listEmployments()
|
||||
employments.value = res.data
|
||||
} catch {
|
||||
employments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.user) {
|
||||
await authStore.fetchSession()
|
||||
}
|
||||
|
||||
await fetchEmployments()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -113,9 +160,10 @@ onMounted(async () => {
|
||||
<TabsRoot defaultValue="1">
|
||||
<!-- BEGIN: Profile Info -->
|
||||
<Box raised="single" class="mt-5 p-0">
|
||||
<div class="flex flex-col border-b border-foreground/15 p-5 lg:flex-row">
|
||||
<div class="flex flex-1 items-center justify-center px-5 lg:justify-start">
|
||||
<div class="relative" :class="{ 'opacity-60': uploadingImage }">
|
||||
<div class="flex flex-col border-b border-foreground/15 lg:flex-row">
|
||||
<!-- Identity -->
|
||||
<div class="flex flex-1 items-center justify-center p-5 lg:justify-start">
|
||||
<div class="relative shrink-0" :class="{ 'opacity-60': uploadingImage }">
|
||||
<AvatarRoot class="size-20 border-5 bg-background rounded-full sm:size-24 lg:size-32">
|
||||
<AvatarFallback>{{ authStore.userName }}</AvatarFallback>
|
||||
<AvatarImage v-if="avatarSrc" :src="avatarSrc" :alt="authStore.userName" />
|
||||
@@ -129,87 +177,103 @@ onMounted(async () => {
|
||||
<input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/jpg,image/gif" class="hidden"
|
||||
@change="onImageSelected" />
|
||||
</div>
|
||||
<div class="ml-5">
|
||||
<div class="w-24 truncate text-lg font-medium sm:w-40 sm:whitespace-normal">
|
||||
<div class="ml-5 min-w-0">
|
||||
<div class="truncate text-lg font-medium sm:whitespace-normal">
|
||||
{{ authStore.userName || '-' }}
|
||||
</div>
|
||||
<div v-if="authStore.userMemberType"
|
||||
class="mt-1 truncate text-sm capitalize opacity-70 sm:whitespace-normal">
|
||||
{{ authStore.userMemberType }}
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Badge :variant="statusBadgeVariant">{{ statusLabel }}</Badge>
|
||||
<Badge v-if="authStore.userMemberNumber" look="outline" variant="secondary">
|
||||
No. {{ authStore.userMemberNumber }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex-1 border-t border-l border-r border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-t-0 lg:pt-0">
|
||||
<div class="text-center font-medium lg:mt-3 lg:text-left">Maklumat Hubungan</div>
|
||||
<div class="mt-4 flex flex-col items-center justify-center lg:items-start">
|
||||
|
||||
<!-- Contact & membership -->
|
||||
<div class="flex-1 border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
|
||||
<div class="text-center font-medium lg:text-left">Maklumat Hubungan</div>
|
||||
<div class="mt-4 flex flex-col items-center lg:items-start">
|
||||
<div class="flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="Mail" />
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="Mail" />
|
||||
{{ displayValue(authStore.user?.email) }}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="Phone" />
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="Phone" />
|
||||
{{ displayValue(authStore.user?.phone_number) }}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
||||
<Lucide class="mr-2 size-4" icon="IdCard" />
|
||||
<Lucide class="mr-2 size-4 shrink-0" icon="IdCard" />
|
||||
{{ displayValue(authStore.user?.ic_number) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-0 lg:pt-0">
|
||||
<div
|
||||
class="relative aspect-1.75/1 w-full max-w-68 overflow-hidden rounded-2xl bg-linear-to-br from-primary via-primary/95 to-primary/75 p-4 text-primary-foreground shadow-lg ring-1 ring-white/20 sm:max-w-xs sm:p-5"
|
||||
role="img" aria-label="Kad digital anggota">
|
||||
<div class="pointer-events-none absolute inset-0 bg-noise opacity-30" />
|
||||
<div class="pointer-events-none absolute -right-10 -top-10 size-36 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute -bottom-12 -left-8 size-40 rounded-full bg-white/5" />
|
||||
<div
|
||||
class="pointer-events-none absolute right-4 top-1/2 size-10 -translate-y-1/2 rounded-md border border-white/20 bg-white/10" />
|
||||
|
||||
<div class="relative flex h-full flex-col justify-between">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<img :src="logoUrl" alt="" class="h-7 w-auto brightness-0 invert sm:h-8" />
|
||||
<div class="text-right text-[10px] font-semibold uppercase tracking-[0.2em] opacity-80">
|
||||
Kad Digital
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div class="text-center lg:text-left">
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ displayValue(authStore.userPosition) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-[10px] font-medium uppercase tracking-widest opacity-70">No. Anggota</div>
|
||||
<div class="mt-1 font-mono text-2xl font-semibold tracking-[0.15em] sm:text-3xl">
|
||||
{{ displayValue(authStore.userMemberNumber) }}
|
||||
</div>
|
||||
<div class="text-xs opacity-70">Jawatan</div>
|
||||
</div>
|
||||
<div class="text-center lg:text-left">
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ displayValue(companyName) }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between gap-3 border-t border-white/15 pt-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ authStore.userName || '-' }}</div>
|
||||
<div class="mt-0.5 text-[10px] uppercase tracking-wide opacity-60">Nama</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="text-sm font-semibold">{{ displayValue(authStore.userMemberType) }}</div>
|
||||
<div class="mt-0.5 text-[10px] uppercase tracking-wide opacity-60">Jenis Anggota</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70">Syarikat</div>
|
||||
</div>
|
||||
<div class="col-span-2 text-center sm:col-span-1 lg:text-left">
|
||||
<div class="truncate text-base font-medium">
|
||||
{{ formatDateLabel(authStore.userJoinDate) }}
|
||||
</div>
|
||||
<div class="text-xs opacity-70">Tarikh Sertai</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Digital card -->
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-center border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
|
||||
<MemberDigitalCard large :member-number="authStore.userMemberNumber" :member-name="authStore.userName"
|
||||
:member-type="authStore.userMemberType" :company-name="companyName" :profile-url="profileUrl"
|
||||
:image-url="avatarSrc" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs title -->
|
||||
<div class="px-5 py-4">
|
||||
<TabsList class="w-full mb-0 flex justify-between">
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="1">
|
||||
<Lucide class="mr-2 size-4" icon="User" /> Profil
|
||||
<TabsList class="mb-0 flex w-full">
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="1" aria-label="Profil">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="User" />
|
||||
<span class="hidden md:inline">Profil</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="5">
|
||||
<Lucide class="mr-2 size-4" icon="Briefcase" /> Pekerjaan
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="5" aria-label="Pekerjaan">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="Briefcase" />
|
||||
<span class="hidden md:inline">Pekerjaan</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="3">
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="3" aria-label="Bank">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="Banknote" />
|
||||
<span class="hidden md:inline">Bank</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="6">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="6" aria-label="Penama">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="Users" />
|
||||
<span class="hidden md:inline">Penama</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="2">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Kata Laluan
|
||||
<TabsTrigger
|
||||
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
||||
value="2" aria-label="Kata Laluan">
|
||||
<Lucide class="size-4 shrink-0 md:mr-2" icon="Lock" />
|
||||
<span class="hidden md:inline">Kata Laluan</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
@@ -230,7 +294,7 @@ onMounted(async () => {
|
||||
<TabsContent value="2" class="mt-8">
|
||||
<ChangePasswordTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Pewaris -->
|
||||
<!-- Penama -->
|
||||
<TabsContent value="6" class="mt-8">
|
||||
<HeirTab embedded />
|
||||
</TabsContent>
|
||||
|
||||
@@ -13,16 +13,12 @@ defineProps<{
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<Box raised="single" class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Pewaris</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Senarai pewaris pengguna.</p>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Penama</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Senarai penama pengguna.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="user.heirs?.length" class="space-y-3">
|
||||
<div
|
||||
v-for="heir in user.heirs"
|
||||
:key="heir.id"
|
||||
class="rounded-lg border border-foreground/10 p-4"
|
||||
>
|
||||
<div v-for="heir in user.heirs" :key="heir.id" class="rounded-lg border border-foreground/10 p-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-slate-900">{{ heir.name }}</span>
|
||||
<Badge v-if="heir.is_primary" class="bg-green-500 text-white">Utama</Badge>
|
||||
@@ -34,11 +30,8 @@ defineProps<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Tiada pewaris direkodkan.
|
||||
<div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
|
||||
Tiada penama direkodkan.
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@ onMounted(() => {
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="inline-flex w-1/4 items-center justify-center" value="6">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Penama
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user