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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user