822 lines
29 KiB
Vue
822 lines
29 KiB
Vue
<script lang="ts" setup>
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import * as select from '@zag-js/select'
|
|
import Swal from 'sweetalert2'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Box } from '@/components/ui/box'
|
|
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Lucide } from '@/components/ui/lucide'
|
|
import {
|
|
SelectRoot,
|
|
SelectControl,
|
|
SelectTrigger,
|
|
SelectValueText,
|
|
SelectContent,
|
|
SelectItemGroup,
|
|
SelectItemGroupLabel,
|
|
SelectItem,
|
|
SelectItemText,
|
|
} from '@/components/ui/select'
|
|
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
|
import {
|
|
createAddress,
|
|
deleteAddress,
|
|
listAddresses,
|
|
updateAddress,
|
|
} from '@/modules/profile/services/address.service'
|
|
import { updateProfile } from '@/modules/profile/services/profile.service'
|
|
import type { Address, AddressPayload } from '@/modules/profile/types/address.types'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
defineProps<{
|
|
embedded?: boolean
|
|
}>()
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
const saving = ref(false)
|
|
|
|
const form = reactive({
|
|
name: '',
|
|
ic_number: '',
|
|
position: '',
|
|
phone_number: '',
|
|
birth_date: '',
|
|
birth_place: '',
|
|
})
|
|
|
|
type ProfileFieldKey = 'gender' | 'marriage_status' | 'birth_date' | 'birth_place'
|
|
|
|
const PROFILE_FIELD_KEYS: ProfileFieldKey[] = [
|
|
'gender',
|
|
'marriage_status',
|
|
'birth_date',
|
|
'birth_place',
|
|
]
|
|
|
|
const profileErrors = reactive<Partial<Record<ProfileFieldKey, string>>>({})
|
|
|
|
const addresses = ref<Address[]>([])
|
|
const loadingAddresses = ref(false)
|
|
const savingAddress = ref(false)
|
|
const deletingAddressId = ref<string | null>(null)
|
|
const editingAddressId = ref<string | null>(null)
|
|
|
|
type AddressFieldKey =
|
|
| 'address_type'
|
|
| 'address_line_1'
|
|
| 'address_line_2'
|
|
| 'city'
|
|
| 'state'
|
|
| 'postcode'
|
|
| 'country'
|
|
|
|
const ADDRESS_FIELD_KEYS: AddressFieldKey[] = [
|
|
'address_type',
|
|
'address_line_1',
|
|
'address_line_2',
|
|
'city',
|
|
'state',
|
|
'postcode',
|
|
'country',
|
|
]
|
|
|
|
const addressErrors = reactive<Partial<Record<AddressFieldKey, string>>>({})
|
|
|
|
type SelectOption = { label: string; value: string }
|
|
|
|
const GENDER_OPTIONS: SelectOption[] = [
|
|
{ label: 'Lelaki', value: 'Lelaki' },
|
|
{ label: 'Perempuan', value: 'Perempuan' },
|
|
]
|
|
|
|
const MARRIAGE_STATUS_OPTIONS: SelectOption[] = [
|
|
{ label: 'Belum Berkahwin', value: 'Belum Berkahwin' },
|
|
{ label: 'Berkahwin', value: 'Berkahwin' },
|
|
{ label: 'Bercerai', value: 'Bercerai' },
|
|
{ label: 'Balu', value: 'Balu' },
|
|
{ label: 'Duda', value: 'Duda' },
|
|
]
|
|
|
|
const ADDRESS_TYPE_OPTIONS: SelectOption[] = [
|
|
{ label: 'Rumah', value: 'home' },
|
|
{ label: 'Pejabat', value: 'office' },
|
|
{ label: 'Bil', value: 'billing' },
|
|
]
|
|
|
|
const MALAYSIA_STATE_OPTIONS: SelectOption[] = [
|
|
{ label: 'Johor', value: 'Johor' },
|
|
{ label: 'Kedah', value: 'Kedah' },
|
|
{ label: 'Kelantan', value: 'Kelantan' },
|
|
{ label: 'Melaka', value: 'Melaka' },
|
|
{ label: 'Negeri Sembilan', value: 'Negeri Sembilan' },
|
|
{ label: 'Pahang', value: 'Pahang' },
|
|
{ label: 'Perak', value: 'Perak' },
|
|
{ label: 'Perlis', value: 'Perlis' },
|
|
{ label: 'Pulau Pinang', value: 'Pulau Pinang' },
|
|
{ label: 'Sabah', value: 'Sabah' },
|
|
{ label: 'Sarawak', value: 'Sarawak' },
|
|
{ label: 'Selangor', value: 'Selangor' },
|
|
{ label: 'Terengganu', value: 'Terengganu' },
|
|
{ label: 'Wilayah Persekutuan Kuala Lumpur', value: 'Wilayah Persekutuan Kuala Lumpur' },
|
|
{ label: 'Wilayah Persekutuan Labuan', value: 'Wilayah Persekutuan Labuan' },
|
|
{ label: 'Wilayah Persekutuan Putrajaya', value: 'Wilayah Persekutuan Putrajaya' },
|
|
]
|
|
|
|
function createSelectCollection(options: SelectOption[]) {
|
|
return select.collection({
|
|
items: options,
|
|
itemToValue: (item) => item.label,
|
|
})
|
|
}
|
|
|
|
function labelToApiValue(options: SelectOption[], label: string | undefined): string | null {
|
|
if (!label) return null
|
|
return options.find((option) => option.label === label)?.value ?? null
|
|
}
|
|
|
|
function apiValueToLabel(options: SelectOption[], value: string | null | undefined): string[] {
|
|
if (!value) return []
|
|
const option = options.find((item) => item.value === value)
|
|
return option ? [option.label] : [value]
|
|
}
|
|
|
|
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
|
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
|
const addressTypeCollection = createSelectCollection(ADDRESS_TYPE_OPTIONS)
|
|
const stateCollection = createSelectCollection(MALAYSIA_STATE_OPTIONS)
|
|
|
|
const genderValue = ref<string[]>([])
|
|
const marriageStatusValue = ref<string[]>([])
|
|
const genderInitial = ref<string[]>([])
|
|
const marriageStatusInitial = ref<string[]>([])
|
|
const profileSelectKey = ref(0)
|
|
|
|
const addressTypeValue = ref<string[]>([])
|
|
const stateValue = ref<string[]>([])
|
|
const addressTypeInitial = ref<string[]>([])
|
|
const stateInitial = ref<string[]>([])
|
|
|
|
function clearProfileFieldError(field: ProfileFieldKey) {
|
|
delete profileErrors[field]
|
|
}
|
|
|
|
function clearProfileErrors() {
|
|
for (const field of PROFILE_FIELD_KEYS) {
|
|
delete profileErrors[field]
|
|
}
|
|
}
|
|
|
|
function setProfileErrorsFromApi(error: unknown): boolean {
|
|
const apiErrors = getApiValidationErrors(error)
|
|
if (!apiErrors) return false
|
|
|
|
for (const [field, messages] of Object.entries(apiErrors)) {
|
|
if (PROFILE_FIELD_KEYS.includes(field as ProfileFieldKey) && messages[0]) {
|
|
profileErrors[field as ProfileFieldKey] = messages[0]
|
|
}
|
|
}
|
|
|
|
return Object.keys(profileErrors).length > 0
|
|
}
|
|
|
|
function setGenderValue(details: { value: string[] }) {
|
|
genderValue.value = details.value
|
|
clearProfileFieldError('gender')
|
|
}
|
|
|
|
function setMarriageStatusValue(details: { value: string[] }) {
|
|
marriageStatusValue.value = details.value
|
|
clearProfileFieldError('marriage_status')
|
|
}
|
|
|
|
function clearAddressFieldError(field: AddressFieldKey) {
|
|
delete addressErrors[field]
|
|
}
|
|
|
|
function clearAddressErrors() {
|
|
for (const field of ADDRESS_FIELD_KEYS) {
|
|
delete addressErrors[field]
|
|
}
|
|
}
|
|
|
|
function setAddressErrorsFromApi(error: unknown): boolean {
|
|
const apiErrors = getApiValidationErrors(error)
|
|
if (!apiErrors) return false
|
|
|
|
for (const [field, messages] of Object.entries(apiErrors)) {
|
|
if (ADDRESS_FIELD_KEYS.includes(field as AddressFieldKey) && messages[0]) {
|
|
addressErrors[field as AddressFieldKey] = messages[0]
|
|
}
|
|
}
|
|
|
|
return Object.keys(addressErrors).length > 0
|
|
}
|
|
|
|
function validateAddressForm(): boolean {
|
|
clearAddressErrors()
|
|
|
|
let valid = true
|
|
|
|
if (
|
|
!addressTypeValue.value[0] ||
|
|
!labelToApiValue(ADDRESS_TYPE_OPTIONS, addressTypeValue.value[0])
|
|
) {
|
|
addressErrors.address_type = 'Jenis alamat diperlukan.'
|
|
valid = false
|
|
}
|
|
|
|
if (!addressForm.address_line_1.trim()) {
|
|
addressErrors.address_line_1 = 'Alamat 1 diperlukan.'
|
|
valid = false
|
|
}
|
|
|
|
if (!addressForm.city.trim()) {
|
|
addressErrors.city = 'Bandar diperlukan.'
|
|
valid = false
|
|
}
|
|
|
|
if (!labelToApiValue(MALAYSIA_STATE_OPTIONS, stateValue.value[0]) && !addressForm.state.trim()) {
|
|
addressErrors.state = 'Negeri diperlukan.'
|
|
valid = false
|
|
}
|
|
|
|
if (!addressForm.postcode.trim()) {
|
|
addressErrors.postcode = 'Poskod diperlukan.'
|
|
valid = false
|
|
}
|
|
|
|
if (!addressForm.country.trim()) {
|
|
addressErrors.country = 'Negara diperlukan.'
|
|
valid = false
|
|
}
|
|
|
|
return valid
|
|
}
|
|
|
|
function setAddressTypeValue(details: { value: string[] }) {
|
|
addressTypeValue.value = details.value
|
|
clearAddressFieldError('address_type')
|
|
addressForm.address_type =
|
|
labelToApiValue(ADDRESS_TYPE_OPTIONS, details.value[0]) ?? ''
|
|
}
|
|
|
|
function setStateValue(details: { value: string[] }) {
|
|
stateValue.value = details.value
|
|
addressForm.state = details.value[0] ?? ''
|
|
clearAddressFieldError('state')
|
|
}
|
|
|
|
function emptyAddressForm(): AddressPayload {
|
|
return {
|
|
address_type: '',
|
|
address_line_1: '',
|
|
address_line_2: '',
|
|
city: '',
|
|
state: '',
|
|
postcode: '',
|
|
country: 'Malaysia',
|
|
}
|
|
}
|
|
|
|
const addressForm = reactive(emptyAddressForm())
|
|
|
|
const addressTypeLabel = computed(() =>
|
|
Object.fromEntries(ADDRESS_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
|
)
|
|
|
|
const isEditingAddress = computed(() => editingAddressId.value !== null)
|
|
|
|
const displayValue = (value: string | number | null | undefined) => {
|
|
if (value === null || value === undefined || value === '') return '-'
|
|
return String(value).trim() || '-'
|
|
}
|
|
|
|
function formatDate(value: string | null | undefined): string {
|
|
if (!value) return '-'
|
|
return value.slice(0, 10)
|
|
}
|
|
|
|
function calculateAge(birthDate: string | null | undefined): number | null {
|
|
if (!birthDate) return null
|
|
const birth = new Date(birthDate.slice(0, 10))
|
|
if (Number.isNaN(birth.getTime())) return null
|
|
|
|
const today = new Date()
|
|
let age = today.getFullYear() - birth.getFullYear()
|
|
const monthDiff = today.getMonth() - birth.getMonth()
|
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
|
age--
|
|
}
|
|
|
|
return age >= 0 ? age : null
|
|
}
|
|
|
|
const ageLabel = computed(() => {
|
|
const age = calculateAge(form.birth_date || authStore.userBirthDate)
|
|
if (age === null) return '-'
|
|
return `${age} tahun`
|
|
})
|
|
|
|
function toDateInputValue(value: string | null | undefined): string {
|
|
if (!value) return ''
|
|
return value.slice(0, 10)
|
|
}
|
|
|
|
function syncProfileSelectValues() {
|
|
genderValue.value = apiValueToLabel(GENDER_OPTIONS, authStore.user?.gender)
|
|
genderInitial.value = [...genderValue.value]
|
|
marriageStatusValue.value = apiValueToLabel(MARRIAGE_STATUS_OPTIONS, authStore.user?.marriage_status)
|
|
marriageStatusInitial.value = [...marriageStatusValue.value]
|
|
profileSelectKey.value++
|
|
}
|
|
|
|
function syncFormFromUser() {
|
|
const user = authStore.user
|
|
if (!user) return
|
|
|
|
form.name = user.name ?? ''
|
|
form.ic_number = user.ic_number ?? ''
|
|
form.position = user.position ?? ''
|
|
form.phone_number = user.phone_number ?? ''
|
|
form.birth_date = toDateInputValue(user.birth_date)
|
|
form.birth_place = user.birth_place ?? ''
|
|
syncProfileSelectValues()
|
|
}
|
|
|
|
async function onSaveProfile() {
|
|
saving.value = true
|
|
clearProfileErrors()
|
|
|
|
const gender = labelToApiValue(GENDER_OPTIONS, genderValue.value[0])
|
|
const marriageStatus = labelToApiValue(MARRIAGE_STATUS_OPTIONS, marriageStatusValue.value[0])
|
|
|
|
try {
|
|
const res = await updateProfile({
|
|
name: form.name.trim(),
|
|
ic_number: form.ic_number.trim(),
|
|
position: form.position.trim(),
|
|
phone_number: form.phone_number.trim(),
|
|
gender: gender ?? undefined,
|
|
marriage_status: marriageStatus ?? undefined,
|
|
birth_date: form.birth_date || undefined,
|
|
birth_place: form.birth_place.trim() || undefined,
|
|
})
|
|
|
|
if (!res.success) {
|
|
throw new Error(res.message ?? 'Gagal mengemas kini profil.')
|
|
}
|
|
|
|
authStore.setUserProfile(res.data)
|
|
syncFormFromUser()
|
|
|
|
await Swal.fire({
|
|
toast: true,
|
|
position: 'top-end',
|
|
icon: 'success',
|
|
title: 'Profil berjaya dikemas kini.',
|
|
showConfirmButton: false,
|
|
timer: 3000,
|
|
})
|
|
} catch (error) {
|
|
if (!setProfileErrorsFromApi(error)) {
|
|
await Swal.fire({
|
|
icon: 'error',
|
|
title: 'Ralat',
|
|
text: getApiErrorMessage(error, 'Gagal mengemas kini profil.'),
|
|
})
|
|
}
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function syncAddressSelectValues() {
|
|
addressTypeValue.value = apiValueToLabel(ADDRESS_TYPE_OPTIONS, addressForm.address_type)
|
|
addressTypeInitial.value = [...addressTypeValue.value]
|
|
stateValue.value = apiValueToLabel(MALAYSIA_STATE_OPTIONS, addressForm.state)
|
|
stateInitial.value = [...stateValue.value]
|
|
}
|
|
|
|
function resetAddressForm() {
|
|
Object.assign(addressForm, emptyAddressForm())
|
|
editingAddressId.value = null
|
|
clearAddressErrors()
|
|
syncAddressSelectValues()
|
|
}
|
|
|
|
function formatAddressLine(address: Address) {
|
|
return [address.address_line_1, address.address_line_2, address.postcode, address.city, address.state, address.country]
|
|
.filter((part) => part?.trim())
|
|
.join(', ')
|
|
}
|
|
|
|
async function fetchAddresses() {
|
|
loadingAddresses.value = true
|
|
|
|
try {
|
|
const res = await listAddresses()
|
|
addresses.value = res.data
|
|
} catch (error) {
|
|
await Swal.fire({
|
|
icon: 'error',
|
|
title: 'Ralat',
|
|
text: getApiErrorMessage(error, 'Gagal memuatkan alamat.'),
|
|
})
|
|
} finally {
|
|
loadingAddresses.value = false
|
|
}
|
|
}
|
|
|
|
function startEditAddress(address: Address) {
|
|
clearAddressErrors()
|
|
editingAddressId.value = address.id
|
|
addressForm.address_type = address.address_type
|
|
addressForm.address_line_1 = address.address_line_1
|
|
addressForm.address_line_2 = address.address_line_2 ?? ''
|
|
addressForm.city = address.city
|
|
addressForm.state = address.state
|
|
addressForm.postcode = address.postcode
|
|
addressForm.country = address.country
|
|
syncAddressSelectValues()
|
|
}
|
|
|
|
async function onSaveAddress() {
|
|
if (!validateAddressForm()) {
|
|
return
|
|
}
|
|
|
|
savingAddress.value = true
|
|
const wasEditing = isEditingAddress.value
|
|
|
|
const payload: AddressPayload = {
|
|
address_type: labelToApiValue(ADDRESS_TYPE_OPTIONS, addressTypeValue.value[0]) ?? addressForm.address_type,
|
|
address_line_1: addressForm.address_line_1.trim(),
|
|
address_line_2: addressForm.address_line_2?.trim() || undefined,
|
|
city: addressForm.city.trim(),
|
|
state: labelToApiValue(MALAYSIA_STATE_OPTIONS, stateValue.value[0]) ?? addressForm.state.trim(),
|
|
postcode: addressForm.postcode.trim(),
|
|
country: addressForm.country.trim(),
|
|
}
|
|
|
|
try {
|
|
const res = wasEditing
|
|
? await updateAddress(editingAddressId.value!, payload)
|
|
: await createAddress(payload)
|
|
|
|
if (!res.success) {
|
|
throw new Error(res.message ?? 'Gagal menyimpan alamat.')
|
|
}
|
|
|
|
await fetchAddresses()
|
|
resetAddressForm()
|
|
|
|
await Swal.fire({
|
|
toast: true,
|
|
position: 'top-end',
|
|
icon: 'success',
|
|
title: (wasEditing ? 'Alamat berjaya dikemas kini.' : 'Alamat berjaya ditambah.'),
|
|
showConfirmButton: false,
|
|
timer: 3000,
|
|
})
|
|
} catch (error) {
|
|
if (!setAddressErrorsFromApi(error)) {
|
|
await Swal.fire({
|
|
icon: 'error',
|
|
title: 'Ralat',
|
|
text: getApiErrorMessage(error, 'Gagal menyimpan alamat.'),
|
|
})
|
|
}
|
|
} finally {
|
|
savingAddress.value = false
|
|
}
|
|
}
|
|
|
|
async function onDeleteAddress(address: Address) {
|
|
const result = await Swal.fire({
|
|
icon: 'warning',
|
|
title: 'Padam alamat?',
|
|
text: 'Tindakan ini tidak boleh dibatalkan.',
|
|
showCancelButton: true,
|
|
confirmButtonText: 'Padam',
|
|
cancelButtonText: 'Batal',
|
|
})
|
|
|
|
if (!result.isConfirmed) return
|
|
|
|
deletingAddressId.value = address.id
|
|
|
|
try {
|
|
const res = await deleteAddress(address.id)
|
|
|
|
if (!res.success) {
|
|
throw new Error(res.message ?? 'Gagal memadam alamat.')
|
|
}
|
|
|
|
if (editingAddressId.value === address.id) {
|
|
resetAddressForm()
|
|
}
|
|
|
|
await fetchAddresses()
|
|
|
|
await Swal.fire({
|
|
toast: true,
|
|
position: 'top-end',
|
|
icon: 'success',
|
|
title: 'Alamat berjaya dipadam.',
|
|
showConfirmButton: false,
|
|
timer: 3000,
|
|
})
|
|
} catch (error) {
|
|
await Swal.fire({
|
|
icon: 'error',
|
|
title: 'Ralat',
|
|
text: getApiErrorMessage(error, 'Gagal memadam alamat.'),
|
|
})
|
|
} finally {
|
|
deletingAddressId.value = null
|
|
}
|
|
}
|
|
|
|
watch(() => authStore.user, syncFormFromUser, { immediate: true })
|
|
|
|
onMounted(async () => {
|
|
syncFormFromUser()
|
|
syncAddressSelectValues()
|
|
await fetchAddresses()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div :class="embedded ? 'space-y-8' : 'mt-5 space-y-8'">
|
|
<Box raised="single" class="p-6">
|
|
<form class="space-y-6" @submit.prevent="onSaveProfile">
|
|
<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">Maklumat Peribadi</h3>
|
|
<p class="mt-1 text-sm text-slate-500">
|
|
Kemas kini maklumat peribadi anda.
|
|
</p>
|
|
</div>
|
|
<Button type="submit" variant="primary" :disabled="saving">
|
|
{{ saving ? 'Menyimpan...' : 'Simpan' }}
|
|
</Button>
|
|
</div>
|
|
|
|
<FieldGroup>
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<Field>
|
|
<FieldLabel for="profile-name">Nama</FieldLabel>
|
|
<Input id="profile-name" v-model="form.name" type="text" placeholder="Nama penuh" required />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="profile-email">E-mel</FieldLabel>
|
|
<Input id="profile-email" :model-value="authStore.user?.email ?? ''" type="email" disabled />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="profile-ic">No. Kad Pengenalan</FieldLabel>
|
|
<Input id="profile-ic" v-model="form.ic_number" type="text" placeholder="No. kad pengenalan" />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="profile-phone">No. Telefon</FieldLabel>
|
|
<Input id="profile-phone" v-model="form.phone_number" type="tel" pattern="[0-9]*"
|
|
placeholder="0123456789" />
|
|
</Field>
|
|
<Field class="md:col-span-2">
|
|
<FieldLabel for="profile-position">Jawatan</FieldLabel>
|
|
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel>Jantina</FieldLabel>
|
|
<SelectRoot :key="`profile-gender-${profileSelectKey}`" class="w-full" :collection="genderCollection"
|
|
:default-value="genderInitial" :disabled="saving" @value-change="setGenderValue">
|
|
<SelectControl>
|
|
<SelectTrigger :aria-invalid="!!profileErrors.gender">
|
|
<SelectValueText placeholder="Pilih jantina" />
|
|
</SelectTrigger>
|
|
</SelectControl>
|
|
<SelectContent>
|
|
<SelectItemGroup>
|
|
<SelectItemGroupLabel>Jantina</SelectItemGroupLabel>
|
|
<SelectItem v-for="item in genderCollection.items" :key="item.label" :item="item">
|
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
|
</SelectItem>
|
|
</SelectItemGroup>
|
|
</SelectContent>
|
|
</SelectRoot>
|
|
<FieldError v-if="profileErrors.gender">{{ profileErrors.gender }}</FieldError>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="profile-age">Umur</FieldLabel>
|
|
<Input id="profile-age" :model-value="ageLabel" type="text" disabled />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel>Status Perkahwinan</FieldLabel>
|
|
<SelectRoot :key="`profile-marriage-${profileSelectKey}`" class="w-full"
|
|
:collection="marriageStatusCollection" :default-value="marriageStatusInitial" :disabled="saving"
|
|
@value-change="setMarriageStatusValue">
|
|
<SelectControl>
|
|
<SelectTrigger :aria-invalid="!!profileErrors.marriage_status">
|
|
<SelectValueText placeholder="Pilih status perkahwinan" />
|
|
</SelectTrigger>
|
|
</SelectControl>
|
|
<SelectContent>
|
|
<SelectItemGroup>
|
|
<SelectItemGroupLabel>Status Perkahwinan</SelectItemGroupLabel>
|
|
<SelectItem v-for="item in marriageStatusCollection.items" :key="item.label" :item="item">
|
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
|
</SelectItem>
|
|
</SelectItemGroup>
|
|
</SelectContent>
|
|
</SelectRoot>
|
|
<FieldError v-if="profileErrors.marriage_status">{{ profileErrors.marriage_status }}</FieldError>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="profile-member-number">Nombor Anggota</FieldLabel>
|
|
<Input id="profile-member-number" :model-value="displayValue(authStore.userMemberNumber)" type="text"
|
|
disabled />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="profile-member-type">Jenis Anggota</FieldLabel>
|
|
<Input id="profile-member-type" :model-value="displayValue(authStore.userMemberType)" type="text"
|
|
disabled />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="profile-join-date">Tarikh Sertai</FieldLabel>
|
|
<Input id="profile-join-date" :model-value="formatDate(authStore.userJoinDate)" type="text" disabled />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="profile-birth-date">Tarikh Lahir</FieldLabel>
|
|
<Input id="profile-birth-date" v-model="form.birth_date" type="date" :disabled="saving"
|
|
:aria-invalid="!!profileErrors.birth_date" @input="clearProfileFieldError('birth_date')" />
|
|
<FieldError v-if="profileErrors.birth_date">{{ profileErrors.birth_date }}</FieldError>
|
|
</Field>
|
|
<Field class="md:col-span-2">
|
|
<FieldLabel for="profile-birth-place">Tempat Lahir</FieldLabel>
|
|
<Input id="profile-birth-place" v-model="form.birth_place" type="text" placeholder="Tempat lahir"
|
|
:disabled="saving" :aria-invalid="!!profileErrors.birth_place"
|
|
@input="clearProfileFieldError('birth_place')" />
|
|
<FieldError v-if="profileErrors.birth_place">{{ profileErrors.birth_place }}</FieldError>
|
|
</Field>
|
|
</div>
|
|
</FieldGroup>
|
|
</form>
|
|
</Box>
|
|
|
|
<Box raised="single" class="p-6">
|
|
<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">Alamat</h3>
|
|
<p class="mt-1 text-sm text-slate-500">
|
|
Urus alamat rumah, pejabat, atau bil anda.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loadingAddresses" class="text-sm text-slate-500">
|
|
Memuatkan alamat...
|
|
</div>
|
|
|
|
<div v-else-if="addresses.length" class="space-y-3">
|
|
<div v-for="address in addresses" :key="address.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">
|
|
<Badge look="outline">
|
|
{{ addressTypeLabel[address.address_type] ?? address.address_type }}
|
|
</Badge>
|
|
</div>
|
|
<p class="mt-2 text-sm text-slate-700">
|
|
{{ formatAddressLine(address) }}
|
|
</p>
|
|
</div>
|
|
<div class="flex shrink-0 gap-2">
|
|
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
|
|
:disabled="deletingAddressId === address.id" @click="startEditAddress(address)">
|
|
<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="deletingAddressId === address.id" @click="onDeleteAddress(address)">
|
|
<Lucide class="mr-2 size-4" :icon="deletingAddressId === address.id ? 'LoaderCircle' : 'Trash'"
|
|
:class="{ 'animate-spin': deletingAddressId === address.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 alamat direkodkan.
|
|
</div>
|
|
|
|
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveAddress">
|
|
<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">
|
|
{{ isEditingAddress ? 'Kemaskini Alamat' : 'Tambah Alamat' }}
|
|
</h4>
|
|
<p class="mt-1 text-sm text-slate-500">
|
|
{{ isEditingAddress ?
|
|
'Kemas kini maklumat alamat yang dipilih.' : 'Tambah alamat baharu ke profil anda.' }}
|
|
</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<Button v-if="isEditingAddress" type="button" variant="ghost"
|
|
class="border border-foreground/15 shadow-none" :disabled="savingAddress" @click="resetAddressForm">
|
|
Batal
|
|
</Button>
|
|
<Button type="submit" variant="primary" :disabled="savingAddress">
|
|
{{ savingAddress ? 'Menyimpan...' : isEditingAddress ? 'Kemaskini' : 'Tambah' }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Address Form -->
|
|
<FieldGroup>
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<Field>
|
|
<FieldLabel>Jenis Alamat</FieldLabel>
|
|
<SelectRoot :key="`address-type-${editingAddressId ?? 'new'}`" class="w-full"
|
|
:collection="addressTypeCollection" :default-value="addressTypeInitial" :disabled="savingAddress"
|
|
@value-change="setAddressTypeValue">
|
|
<SelectControl>
|
|
<SelectTrigger :aria-invalid="!!addressErrors.address_type">
|
|
<SelectValueText placeholder="Pilih jenis alamat" />
|
|
</SelectTrigger>
|
|
</SelectControl>
|
|
<SelectContent>
|
|
<SelectItemGroup>
|
|
<SelectItemGroupLabel>Jenis Alamat</SelectItemGroupLabel>
|
|
<SelectItem v-for="item in addressTypeCollection.items" :key="item.label" :item="item">
|
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
|
</SelectItem>
|
|
</SelectItemGroup>
|
|
</SelectContent>
|
|
</SelectRoot>
|
|
<FieldError v-if="addressErrors.address_type">{{ addressErrors.address_type }}</FieldError>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="address-country">Negara</FieldLabel>
|
|
<Input id="address-country" v-model="addressForm.country" type="text" placeholder="Negara"
|
|
:aria-invalid="!!addressErrors.country" @input="clearAddressFieldError('country')" />
|
|
<FieldError v-if="addressErrors.country">{{ addressErrors.country }}</FieldError>
|
|
</Field>
|
|
<Field class="md:col-span-2">
|
|
<FieldLabel for="address-line-1">Alamat 1</FieldLabel>
|
|
<Input id="address-line-1" v-model="addressForm.address_line_1" type="text"
|
|
placeholder="Alamat baris pertama" :aria-invalid="!!addressErrors.address_line_1"
|
|
@input="clearAddressFieldError('address_line_1')" />
|
|
<FieldError v-if="addressErrors.address_line_1">{{ addressErrors.address_line_1 }}</FieldError>
|
|
</Field>
|
|
<Field class="md:col-span-2">
|
|
<FieldLabel for="address-line-2">Alamat 2</FieldLabel>
|
|
<Input id="address-line-2" v-model="addressForm.address_line_2" type="text"
|
|
placeholder="Alamat baris kedua (pilihan)" :aria-invalid="!!addressErrors.address_line_2"
|
|
@input="clearAddressFieldError('address_line_2')" />
|
|
<FieldError v-if="addressErrors.address_line_2">{{ addressErrors.address_line_2 }}</FieldError>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="address-city">Bandar</FieldLabel>
|
|
<Input id="address-city" v-model="addressForm.city" type="text" placeholder="Bandar"
|
|
:aria-invalid="!!addressErrors.city" @input="clearAddressFieldError('city')" />
|
|
<FieldError v-if="addressErrors.city">{{ addressErrors.city }}</FieldError>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel>Negeri</FieldLabel>
|
|
<SelectRoot :key="`address-state-${editingAddressId ?? 'new'}`" class="w-full"
|
|
:collection="stateCollection" :default-value="stateInitial" :disabled="savingAddress"
|
|
@value-change="setStateValue">
|
|
<SelectControl>
|
|
<SelectTrigger :aria-invalid="!!addressErrors.state">
|
|
<SelectValueText placeholder="Pilih negeri" />
|
|
</SelectTrigger>
|
|
</SelectControl>
|
|
<SelectContent>
|
|
<SelectItemGroup>
|
|
<SelectItemGroupLabel>Negeri</SelectItemGroupLabel>
|
|
<SelectItem v-for="item in stateCollection.items" :key="item.label" :item="item">
|
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
|
</SelectItem>
|
|
</SelectItemGroup>
|
|
</SelectContent>
|
|
</SelectRoot>
|
|
<FieldError v-if="addressErrors.state">{{ addressErrors.state }}</FieldError>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel for="address-postcode">Poskod</FieldLabel>
|
|
<Input id="address-postcode" v-model="addressForm.postcode" type="text" placeholder="Poskod"
|
|
:aria-invalid="!!addressErrors.postcode" @input="clearAddressFieldError('postcode')" />
|
|
<FieldError v-if="addressErrors.postcode">{{ addressErrors.postcode }}</FieldError>
|
|
</Field>
|
|
</div>
|
|
</FieldGroup>
|
|
</form>
|
|
</div>
|
|
</Box>
|
|
</div>
|
|
</template>
|