DONE: layout letter with letterhead and footer, notification for newly...
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } 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 { listActiveBanks } from '@/modules/profile/services/bank.service'
|
||||
import {
|
||||
createBankDetail,
|
||||
deleteBankDetail,
|
||||
listBankDetails,
|
||||
updateBankDetail,
|
||||
} from '@/modules/profile/services/bankDetail.service'
|
||||
import type { Bank } from '@/modules/profile/types/bank.types'
|
||||
import type { BankDetail, BankDetailPayload } from '@/modules/profile/types/bankDetail.types'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const banks = ref<Bank[]>([])
|
||||
const loadingBanks = ref(false)
|
||||
const bankDetails = ref<BankDetail[]>([])
|
||||
const loadingBankDetails = ref(false)
|
||||
const savingBankDetail = ref(false)
|
||||
const deletingBankDetailId = ref<string | null>(null)
|
||||
const editingBankDetailId = ref<string | null>(null)
|
||||
|
||||
type BankDetailFieldKey = 'bank_id' | 'account_name' | 'account_number' | 'account_type'
|
||||
|
||||
const BANK_DETAIL_FIELD_KEYS: BankDetailFieldKey[] = [
|
||||
'bank_id',
|
||||
'account_name',
|
||||
'account_number',
|
||||
'account_type',
|
||||
]
|
||||
|
||||
const bankDetailErrors = reactive<Partial<Record<BankDetailFieldKey, string>>>({})
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const ACCOUNT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Simpanan', value: 'Saving' },
|
||||
{ label: 'Semasa', value: 'Current' },
|
||||
]
|
||||
|
||||
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 bankOptions = computed<SelectOption[]>(() =>
|
||||
banks.value.map((bank) => ({
|
||||
label: `${bank.name} (${bank.code})`,
|
||||
value: bank.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const bankCollection = computed(() => createSelectCollection(bankOptions.value))
|
||||
const accountTypeCollection = createSelectCollection(ACCOUNT_TYPE_OPTIONS)
|
||||
|
||||
const bankValue = ref<string[]>([])
|
||||
const bankInitial = ref<string[]>([])
|
||||
const accountTypeValue = ref<string[]>([])
|
||||
const accountTypeInitial = ref<string[]>([])
|
||||
|
||||
const bankNameById = computed(() =>
|
||||
Object.fromEntries(banks.value.map((bank) => [bank.id, `${bank.name} (${bank.code})`])),
|
||||
)
|
||||
|
||||
const accountTypeLabel = computed(() =>
|
||||
Object.fromEntries(ACCOUNT_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
||||
)
|
||||
|
||||
const isEditingBankDetail = computed(() => editingBankDetailId.value !== null)
|
||||
|
||||
function emptyBankDetailForm() {
|
||||
return {
|
||||
bank_id: '',
|
||||
account_name: '',
|
||||
account_number: '',
|
||||
account_type: '',
|
||||
}
|
||||
}
|
||||
|
||||
const bankDetailForm = reactive(emptyBankDetailForm())
|
||||
|
||||
function clearBankDetailFieldError(field: BankDetailFieldKey) {
|
||||
delete bankDetailErrors[field]
|
||||
}
|
||||
|
||||
function clearBankDetailErrors() {
|
||||
for (const field of BANK_DETAIL_FIELD_KEYS) {
|
||||
delete bankDetailErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setBankDetailErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (BANK_DETAIL_FIELD_KEYS.includes(field as BankDetailFieldKey) && messages[0]) {
|
||||
bankDetailErrors[field as BankDetailFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(bankDetailErrors).length > 0
|
||||
}
|
||||
|
||||
function setBankValue(details: { value: string[] }) {
|
||||
bankValue.value = details.value
|
||||
clearBankDetailFieldError('bank_id')
|
||||
bankDetailForm.bank_id = labelToApiValue(bankOptions.value, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function setAccountTypeValue(details: { value: string[] }) {
|
||||
accountTypeValue.value = details.value
|
||||
clearBankDetailFieldError('account_type')
|
||||
bankDetailForm.account_type =
|
||||
labelToApiValue(ACCOUNT_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function syncBankDetailSelectValues() {
|
||||
bankValue.value = apiValueToLabel(bankOptions.value, bankDetailForm.bank_id)
|
||||
bankInitial.value = [...bankValue.value]
|
||||
accountTypeValue.value = apiValueToLabel(ACCOUNT_TYPE_OPTIONS, bankDetailForm.account_type)
|
||||
accountTypeInitial.value = [...accountTypeValue.value]
|
||||
}
|
||||
|
||||
function resetBankDetailForm() {
|
||||
Object.assign(bankDetailForm, emptyBankDetailForm())
|
||||
editingBankDetailId.value = null
|
||||
clearBankDetailErrors()
|
||||
syncBankDetailSelectValues()
|
||||
}
|
||||
|
||||
function validateBankDetailForm(): boolean {
|
||||
clearBankDetailErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!bankValue.value[0] || !labelToApiValue(bankOptions.value, bankValue.value[0])) {
|
||||
bankDetailErrors.bank_id = 'Bank diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!bankDetailForm.account_name.trim()) {
|
||||
bankDetailErrors.account_name = 'Nama akaun diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!bankDetailForm.account_number.trim()) {
|
||||
bankDetailErrors.account_number = 'Nombor akaun diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!accountTypeValue.value[0] ||
|
||||
!labelToApiValue(ACCOUNT_TYPE_OPTIONS, accountTypeValue.value[0])
|
||||
) {
|
||||
bankDetailErrors.account_type = 'Jenis akaun diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function buildBankDetailPayload(): BankDetailPayload {
|
||||
return {
|
||||
bank_id: labelToApiValue(bankOptions.value, bankValue.value[0]) ?? bankDetailForm.bank_id,
|
||||
account_name: bankDetailForm.account_name.trim(),
|
||||
account_number: bankDetailForm.account_number.trim(),
|
||||
account_type:
|
||||
labelToApiValue(ACCOUNT_TYPE_OPTIONS, accountTypeValue.value[0]) ??
|
||||
bankDetailForm.account_type,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchActiveBanks() {
|
||||
loadingBanks.value = true
|
||||
|
||||
try {
|
||||
const res = await listActiveBanks()
|
||||
banks.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan senarai bank.'),
|
||||
})
|
||||
} finally {
|
||||
loadingBanks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBankDetails() {
|
||||
loadingBankDetails.value = true
|
||||
|
||||
try {
|
||||
const res = await listBankDetails()
|
||||
bankDetails.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan akaun bank.'),
|
||||
})
|
||||
} finally {
|
||||
loadingBankDetails.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditBankDetail(bankDetail: BankDetail) {
|
||||
clearBankDetailErrors()
|
||||
editingBankDetailId.value = bankDetail.id
|
||||
bankDetailForm.bank_id = bankDetail.bank_id
|
||||
bankDetailForm.account_name = bankDetail.account_name
|
||||
bankDetailForm.account_number = bankDetail.account_number
|
||||
bankDetailForm.account_type = bankDetail.account_type
|
||||
syncBankDetailSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveBankDetail() {
|
||||
if (!validateBankDetailForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingBankDetail.value = true
|
||||
const wasEditing = isEditingBankDetail.value
|
||||
const payload = buildBankDetailPayload()
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateBankDetail(editingBankDetailId.value!, payload)
|
||||
: await createBankDetail(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan akaun bank.')
|
||||
}
|
||||
|
||||
await fetchBankDetails()
|
||||
resetBankDetailForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Akaun bank berjaya dikemas kini.' : 'Akaun bank berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!setBankDetailErrorsFromApi(error)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan akaun bank.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingBankDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteBankDetail(bankDetail: BankDetail) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam akaun bank?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingBankDetailId.value = bankDetail.id
|
||||
|
||||
try {
|
||||
const res = await deleteBankDetail(bankDetail.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam akaun bank.')
|
||||
}
|
||||
|
||||
if (editingBankDetailId.value === bankDetail.id) {
|
||||
resetBankDetailForm()
|
||||
}
|
||||
|
||||
await fetchBankDetails()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Akaun bank berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam akaun bank.'),
|
||||
})
|
||||
} finally {
|
||||
deletingBankDetailId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchActiveBanks()
|
||||
syncBankDetailSelectValues()
|
||||
await fetchBankDetails()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<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">Akaun Bank</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus maklumat akaun bank anda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingBankDetails" class="text-sm text-slate-500">
|
||||
Memuatkan akaun bank...
|
||||
</div>
|
||||
|
||||
<div v-else-if="bankDetails.length" class="space-y-3">
|
||||
<div
|
||||
v-for="bankDetail in bankDetails"
|
||||
:key="bankDetail.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">
|
||||
{{ bankNameById[bankDetail.bank_id] ?? bankDetail.bank_id }}
|
||||
</span>
|
||||
<Badge look="outline">
|
||||
{{ accountTypeLabel[bankDetail.account_type] ?? bankDetail.account_type }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-slate-700">{{ bankDetail.account_name }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ bankDetail.account_number }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingBankDetailId === bankDetail.id"
|
||||
@click="startEditBankDetail(bankDetail)"
|
||||
>
|
||||
<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="deletingBankDetailId === bankDetail.id"
|
||||
@click="onDeleteBankDetail(bankDetail)"
|
||||
>
|
||||
<Lucide
|
||||
class="mr-2 size-4"
|
||||
:icon="deletingBankDetailId === bankDetail.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingBankDetailId === bankDetail.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 akaun bank direkodkan.
|
||||
</div>
|
||||
|
||||
<form class="space-y-6 border-t border-foreground/10 pt-6" @submit.prevent="onSaveBankDetail">
|
||||
<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">
|
||||
{{ isEditingBankDetail ? 'Kemaskini Akaun Bank' : 'Tambah Akaun Bank' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingBankDetail
|
||||
? 'Kemas kini maklumat akaun bank yang dipilih.'
|
||||
: 'Tambah akaun bank baharu ke profil anda.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isEditingBankDetail"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-foreground/15 shadow-none"
|
||||
:disabled="savingBankDetail"
|
||||
@click="resetBankDetailForm"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingBankDetail || loadingBanks">
|
||||
{{ savingBankDetail ? 'Menyimpan...' : isEditingBankDetail ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Bank</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`bank-${editingBankDetailId ?? 'new'}-${banks.length}`"
|
||||
class="w-full"
|
||||
:collection="bankCollection"
|
||||
:default-value="bankInitial"
|
||||
:disabled="savingBankDetail || loadingBanks"
|
||||
@value-change="setBankValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!bankDetailErrors.bank_id">
|
||||
<SelectValueText placeholder="Pilih bank" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Bank</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in bankCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="bankDetailErrors.bank_id">{{ bankDetailErrors.bank_id }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Jenis Akaun</FieldLabel>
|
||||
<SelectRoot
|
||||
:key="`account-type-${editingBankDetailId ?? 'new'}`"
|
||||
class="w-full"
|
||||
:collection="accountTypeCollection"
|
||||
:default-value="accountTypeInitial"
|
||||
:disabled="savingBankDetail"
|
||||
@value-change="setAccountTypeValue"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!bankDetailErrors.account_type">
|
||||
<SelectValueText placeholder="Pilih jenis akaun" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Akaun</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in accountTypeCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="bankDetailErrors.account_type">
|
||||
{{ bankDetailErrors.account_type }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="bank-account-name">Nama Akaun</FieldLabel>
|
||||
<Input
|
||||
id="bank-account-name"
|
||||
v-model="bankDetailForm.account_name"
|
||||
type="text"
|
||||
placeholder="Nama pemegang akaun"
|
||||
:aria-invalid="!!bankDetailErrors.account_name"
|
||||
@input="clearBankDetailFieldError('account_name')"
|
||||
/>
|
||||
<FieldError v-if="bankDetailErrors.account_name">
|
||||
{{ bankDetailErrors.account_name }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="bank-account-number">Nombor Akaun</FieldLabel>
|
||||
<Input
|
||||
id="bank-account-number"
|
||||
v-model="bankDetailForm.account_number"
|
||||
type="text"
|
||||
placeholder="Nombor akaun"
|
||||
:aria-invalid="!!bankDetailErrors.account_number"
|
||||
@input="clearBankDetailFieldError('account_number')"
|
||||
/>
|
||||
<FieldError v-if="bankDetailErrors.account_number">
|
||||
{{ bankDetailErrors.account_number }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,541 @@
|
||||
<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 { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
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 {
|
||||
createEmployment,
|
||||
deleteEmployment,
|
||||
listEmployments,
|
||||
updateEmployment,
|
||||
} from '@/modules/profile/services/employment.service'
|
||||
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const employments = ref<Employment[]>([])
|
||||
const loadingEmployments = ref(false)
|
||||
const savingEmployment = ref(false)
|
||||
const deletingEmploymentId = ref<string | null>(null)
|
||||
const editingEmploymentId = ref<string | null>(null)
|
||||
|
||||
type EmploymentFieldKey =
|
||||
| 'company_name'
|
||||
| 'job_title'
|
||||
| 'employment_type'
|
||||
| 'salary'
|
||||
| 'start_date'
|
||||
| 'end_date'
|
||||
| 'is_current'
|
||||
|
||||
const EMPLOYMENT_FIELD_KEYS: EmploymentFieldKey[] = [
|
||||
'company_name',
|
||||
'job_title',
|
||||
'employment_type',
|
||||
'salary',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'is_current',
|
||||
]
|
||||
|
||||
const employmentErrors = reactive<Partial<Record<EmploymentFieldKey, string>>>({})
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Tetap', value: 'Permanent' },
|
||||
{ label: 'Kontrak', value: 'Contract' },
|
||||
{ label: 'Latihan Industri', value: 'Internship' },
|
||||
{ label: 'Freelance', value: 'Freelance' },
|
||||
]
|
||||
|
||||
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 employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
|
||||
|
||||
const employmentTypeValue = ref<string[]>([])
|
||||
const employmentTypeInitial = ref<string[]>([])
|
||||
|
||||
function clearEmploymentFieldError(field: EmploymentFieldKey) {
|
||||
delete employmentErrors[field]
|
||||
}
|
||||
|
||||
function clearEmploymentErrors() {
|
||||
for (const field of EMPLOYMENT_FIELD_KEYS) {
|
||||
delete employmentErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setEmploymentErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (EMPLOYMENT_FIELD_KEYS.includes(field as EmploymentFieldKey) && messages[0]) {
|
||||
employmentErrors[field as EmploymentFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(employmentErrors).length > 0
|
||||
}
|
||||
|
||||
function toDateInputValue(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function emptyEmploymentForm() {
|
||||
return {
|
||||
company_name: '',
|
||||
job_title: '',
|
||||
employment_type: '',
|
||||
salary: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
is_current: true,
|
||||
}
|
||||
}
|
||||
|
||||
const employmentForm = reactive(emptyEmploymentForm())
|
||||
|
||||
const employmentTypeLabel = computed(() =>
|
||||
Object.fromEntries(EMPLOYMENT_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
||||
)
|
||||
|
||||
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
|
||||
|
||||
function setEmploymentTypeValue(details: { value: string[] }) {
|
||||
employmentTypeValue.value = details.value
|
||||
clearEmploymentFieldError('employment_type')
|
||||
employmentForm.employment_type =
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function syncEmploymentSelectValues() {
|
||||
employmentTypeValue.value = apiValueToLabel(EMPLOYMENT_TYPE_OPTIONS, employmentForm.employment_type)
|
||||
employmentTypeInitial.value = [...employmentTypeValue.value]
|
||||
}
|
||||
|
||||
function resetEmploymentForm() {
|
||||
Object.assign(employmentForm, emptyEmploymentForm())
|
||||
editingEmploymentId.value = null
|
||||
clearEmploymentErrors()
|
||||
syncEmploymentSelectValues()
|
||||
}
|
||||
|
||||
function formatSalary(value: number | string | null | undefined): string {
|
||||
const amount = Number(value)
|
||||
if (Number.isNaN(amount)) return '-'
|
||||
return new Intl.NumberFormat('ms-MY', {
|
||||
style: 'currency',
|
||||
currency: 'MYR',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function formatEmploymentPeriod(employment: Employment): string {
|
||||
const start = formatDateLabel(employment.start_date)
|
||||
if (employment.is_current) {
|
||||
return `${start} - Kini`
|
||||
}
|
||||
const end = formatDateLabel(employment.end_date)
|
||||
return end ? `${start} - ${end}` : start
|
||||
}
|
||||
|
||||
function validateEmploymentForm(): boolean {
|
||||
clearEmploymentErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!employmentForm.company_name.trim()) {
|
||||
employmentErrors.company_name = 'Nama syarikat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.job_title.trim()) {
|
||||
employmentErrors.job_title = 'Jawatan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!employmentTypeValue.value[0] ||
|
||||
!labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0])
|
||||
) {
|
||||
employmentErrors.employment_type = 'Jenis kerja diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
const salary = Number(employmentForm.salary)
|
||||
if (!employmentForm.salary.toString().trim() || Number.isNaN(salary) || salary < 0) {
|
||||
employmentErrors.salary = 'Gaji diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.start_date) {
|
||||
employmentErrors.start_date = 'Tarikh mula diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!employmentForm.is_current && !employmentForm.end_date) {
|
||||
employmentErrors.end_date = 'Tarikh tamat diperlukan jika bukan pekerjaan semasa.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!employmentForm.is_current &&
|
||||
employmentForm.start_date &&
|
||||
employmentForm.end_date &&
|
||||
employmentForm.end_date < employmentForm.start_date
|
||||
) {
|
||||
employmentErrors.end_date = 'Tarikh tamat mesti selepas tarikh mula.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function buildEmploymentPayload(): EmploymentPayload {
|
||||
return {
|
||||
company_name: employmentForm.company_name.trim(),
|
||||
job_title: employmentForm.job_title.trim(),
|
||||
employment_type:
|
||||
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0]) ??
|
||||
employmentForm.employment_type,
|
||||
salary: Number(employmentForm.salary),
|
||||
start_date: employmentForm.start_date,
|
||||
end_date: employmentForm.is_current ? null : employmentForm.end_date || null,
|
||||
is_current: employmentForm.is_current,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEmployments() {
|
||||
loadingEmployments.value = true
|
||||
|
||||
try {
|
||||
const res = await listEmployments()
|
||||
employments.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan pekerjaan.'),
|
||||
})
|
||||
} finally {
|
||||
loadingEmployments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditEmployment(employment: Employment) {
|
||||
clearEmploymentErrors()
|
||||
editingEmploymentId.value = employment.id
|
||||
employmentForm.company_name = employment.company_name
|
||||
employmentForm.job_title = employment.job_title
|
||||
employmentForm.employment_type = employment.employment_type
|
||||
employmentForm.salary = String(employment.salary)
|
||||
employmentForm.start_date = toDateInputValue(employment.start_date)
|
||||
employmentForm.end_date = toDateInputValue(employment.end_date)
|
||||
employmentForm.is_current = employment.is_current
|
||||
syncEmploymentSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveEmployment() {
|
||||
if (!validateEmploymentForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingEmployment.value = true
|
||||
const wasEditing = isEditingEmployment.value
|
||||
const payload = buildEmploymentPayload()
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateEmployment(editingEmploymentId.value!, payload)
|
||||
: await createEmployment(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan pekerjaan.')
|
||||
}
|
||||
|
||||
await fetchEmployments()
|
||||
resetEmploymentForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Pekerjaan berjaya dikemas kini.' : 'Pekerjaan berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!setEmploymentErrorsFromApi(error)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan pekerjaan.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingEmployment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteEmployment(employment: Employment) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam pekerjaan?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingEmploymentId.value = employment.id
|
||||
|
||||
try {
|
||||
const res = await deleteEmployment(employment.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam pekerjaan.')
|
||||
}
|
||||
|
||||
if (editingEmploymentId.value === employment.id) {
|
||||
resetEmploymentForm()
|
||||
}
|
||||
|
||||
await fetchEmployments()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Pekerjaan berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam pekerjaan.'),
|
||||
})
|
||||
} finally {
|
||||
deletingEmploymentId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => employmentForm.is_current,
|
||||
(isCurrent) => {
|
||||
if (isCurrent) {
|
||||
employmentForm.end_date = ''
|
||||
clearEmploymentFieldError('end_date')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
syncEmploymentSelectValues()
|
||||
await fetchEmployments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<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">Pekerjaan</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus sejarah dan maklumat pekerjaan anda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingEmployments" class="text-sm text-slate-500">
|
||||
Memuatkan pekerjaan...
|
||||
</div>
|
||||
|
||||
<div v-else-if="employments.length" class="space-y-3">
|
||||
<div v-for="employment in employments" :key="employment.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">{{ employment.company_name }}</span>
|
||||
<Badge class="bg-green-500 text-white" v-if="employment.is_current">Semasa</Badge>
|
||||
<Badge look="outline">
|
||||
{{ employmentTypeLabel[employment.employment_type] ?? employment.employment_type }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-medium text-slate-700">{{ employment.job_title }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatEmploymentPeriod(employment) }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
|
||||
:disabled="deletingEmploymentId === employment.id" @click="startEditEmployment(employment)">
|
||||
<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="deletingEmploymentId === employment.id" @click="onDeleteEmployment(employment)">
|
||||
<Lucide class="mr-2 size-4" :icon="deletingEmploymentId === employment.id ? 'LoaderCircle' : 'Trash'"
|
||||
:class="{ 'animate-spin': deletingEmploymentId === employment.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 pekerjaan direkodkan.
|
||||
</div>
|
||||
|
||||
<form 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">
|
||||
{{ isEditingEmployment ? 'Kemaskini Pekerjaan' : 'Tambah Pekerjaan' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingEmployment
|
||||
? 'Kemas kini maklumat pekerjaan yang dipilih.'
|
||||
: 'Tambah rekod pekerjaan baharu ke profil anda.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button v-if="isEditingEmployment" type="button" variant="ghost"
|
||||
class="border border-foreground/15 shadow-none" :disabled="savingEmployment"
|
||||
@click="resetEmploymentForm">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingEmployment">
|
||||
{{ savingEmployment ? 'Menyimpan...' : isEditingEmployment ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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')" />
|
||||
<FieldError v-if="employmentErrors.company_name">{{ employmentErrors.company_name }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="employment-job-title">Jawatan</FieldLabel>
|
||||
<Input id="employment-job-title" v-model="employmentForm.job_title" type="text" placeholder="Jawatan"
|
||||
:aria-invalid="!!employmentErrors.job_title" @input="clearEmploymentFieldError('job_title')" />
|
||||
<FieldError v-if="employmentErrors.job_title">{{ employmentErrors.job_title }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Jenis Kerja</FieldLabel>
|
||||
<SelectRoot :key="`employment-type-${editingEmploymentId ?? 'new'}`" class="w-full"
|
||||
:collection="employmentTypeCollection" :default-value="employmentTypeInitial"
|
||||
:disabled="savingEmployment" @value-change="setEmploymentTypeValue">
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!employmentErrors.employment_type">
|
||||
<SelectValueText placeholder="Pilih jenis kerja" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel>
|
||||
<SelectItem v-for="item in employmentTypeCollection.items" :key="item.label" :item="item">
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="employmentErrors.employment_type">
|
||||
{{ employmentErrors.employment_type }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="employment-salary">Gaji (RM)</FieldLabel>
|
||||
<Input id="employment-salary" v-model="employmentForm.salary" type="number" min="0" step="0.01"
|
||||
placeholder="0.00" :aria-invalid="!!employmentErrors.salary"
|
||||
@input="clearEmploymentFieldError('salary')" />
|
||||
<FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel>
|
||||
<Input id="employment-start-date" v-model="employmentForm.start_date" type="date"
|
||||
:aria-invalid="!!employmentErrors.start_date" @input="clearEmploymentFieldError('start_date')" />
|
||||
<FieldError v-if="employmentErrors.start_date">{{ employmentErrors.start_date }}</FieldError>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel>
|
||||
<Input id="employment-end-date" v-model="employmentForm.end_date" type="date"
|
||||
:disabled="employmentForm.is_current" :aria-invalid="!!employmentErrors.end_date"
|
||||
@input="clearEmploymentFieldError('end_date')" />
|
||||
<FieldError v-if="employmentErrors.end_date">{{ employmentErrors.end_date }}</FieldError>
|
||||
</Field>
|
||||
<Field class="md:col-span-2">
|
||||
<CheckboxRoot :checked="employmentForm.is_current" :disabled="savingEmployment"
|
||||
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)">
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,516 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } 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 { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
SelectRoot,
|
||||
SelectControl,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
SelectContent,
|
||||
SelectItemGroup,
|
||||
SelectItemGroupLabel,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
} from '@/components/ui/select'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { createHeir, deleteHeir, listHeirs, updateHeir } from '@/modules/profile/services/heir.service'
|
||||
import type { Heir, HeirPayload } from '@/modules/profile/types/heir.types'
|
||||
|
||||
defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const heirs = ref<Heir[]>([])
|
||||
const loadingHeirs = ref(false)
|
||||
const savingHeir = ref(false)
|
||||
const deletingHeirId = ref<string | null>(null)
|
||||
const editingHeirId = ref<string | null>(null)
|
||||
|
||||
type HeirFieldKey =
|
||||
| 'name'
|
||||
| 'ic_number'
|
||||
| 'relationship'
|
||||
| 'phone_number'
|
||||
| 'address'
|
||||
| 'is_primary'
|
||||
|
||||
const HEIR_FIELD_KEYS: HeirFieldKey[] = [
|
||||
'name',
|
||||
'ic_number',
|
||||
'relationship',
|
||||
'phone_number',
|
||||
'address',
|
||||
'is_primary',
|
||||
]
|
||||
|
||||
const heirErrors = reactive<Partial<Record<HeirFieldKey, string>>>({})
|
||||
|
||||
type SelectOption = { label: string; value: string }
|
||||
|
||||
const RELATIONSHIP_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Isteri', value: 'Isteri' },
|
||||
{ label: 'Suami', value: 'Suami' },
|
||||
{ label: 'Anak', value: 'Anak' },
|
||||
{ label: 'Bapa', value: 'Bapa' },
|
||||
{ label: 'Ibu', value: 'Ibu' },
|
||||
{ label: 'Orang Tua', value: 'Orang Tua' },
|
||||
{ label: 'Saudara', value: 'Saudara' },
|
||||
{ label: 'Lain-lain', value: 'Lain-lain' },
|
||||
]
|
||||
|
||||
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 relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
|
||||
|
||||
const relationshipValue = ref<string[]>([])
|
||||
const relationshipInitial = ref<string[]>([])
|
||||
|
||||
const isEditingHeir = computed(() => editingHeirId.value !== null)
|
||||
|
||||
function emptyHeirForm() {
|
||||
return {
|
||||
name: '',
|
||||
ic_number: '',
|
||||
relationship: '',
|
||||
phone_number: '',
|
||||
address: '',
|
||||
is_primary: false,
|
||||
}
|
||||
}
|
||||
|
||||
const heirForm = reactive(emptyHeirForm())
|
||||
|
||||
function clearHeirFieldError(field: HeirFieldKey) {
|
||||
delete heirErrors[field]
|
||||
}
|
||||
|
||||
function clearHeirErrors() {
|
||||
for (const field of HEIR_FIELD_KEYS) {
|
||||
delete heirErrors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function setHeirErrorsFromApi(error: unknown): boolean {
|
||||
const apiErrors = getApiValidationErrors(error)
|
||||
if (!apiErrors) return false
|
||||
|
||||
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||
if (HEIR_FIELD_KEYS.includes(field as HeirFieldKey) && messages[0]) {
|
||||
heirErrors[field as HeirFieldKey] = messages[0]
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(heirErrors).length > 0
|
||||
}
|
||||
|
||||
function setRelationshipValue(details: { value: string[] }) {
|
||||
relationshipValue.value = details.value
|
||||
clearHeirFieldError('relationship')
|
||||
heirForm.relationship = labelToApiValue(RELATIONSHIP_OPTIONS, details.value[0]) ?? ''
|
||||
}
|
||||
|
||||
function syncHeirSelectValues() {
|
||||
relationshipValue.value = apiValueToLabel(RELATIONSHIP_OPTIONS, heirForm.relationship)
|
||||
relationshipInitial.value = [...relationshipValue.value]
|
||||
}
|
||||
|
||||
function resetHeirForm() {
|
||||
Object.assign(heirForm, emptyHeirForm())
|
||||
editingHeirId.value = null
|
||||
clearHeirErrors()
|
||||
syncHeirSelectValues()
|
||||
}
|
||||
|
||||
function validateHeirForm(): boolean {
|
||||
clearHeirErrors()
|
||||
|
||||
let valid = true
|
||||
|
||||
if (!heirForm.name.trim()) {
|
||||
heirErrors.name = 'Nama diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!heirForm.ic_number.trim()) {
|
||||
heirErrors.ic_number = 'No. kad pengenalan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (
|
||||
!relationshipValue.value[0] ||
|
||||
!labelToApiValue(RELATIONSHIP_OPTIONS, relationshipValue.value[0])
|
||||
) {
|
||||
heirErrors.relationship = 'Hubungan diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!heirForm.phone_number.trim()) {
|
||||
heirErrors.phone_number = 'No. telefon diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!heirForm.address.trim()) {
|
||||
heirErrors.address = 'Alamat diperlukan.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function buildHeirPayload(): HeirPayload {
|
||||
return {
|
||||
name: heirForm.name.trim(),
|
||||
ic_number: heirForm.ic_number.trim(),
|
||||
relationship:
|
||||
labelToApiValue(RELATIONSHIP_OPTIONS, relationshipValue.value[0]) ?? heirForm.relationship,
|
||||
phone_number: heirForm.phone_number.trim(),
|
||||
address: heirForm.address.trim(),
|
||||
is_primary: heirForm.is_primary,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHeirs() {
|
||||
loadingHeirs.value = true
|
||||
|
||||
try {
|
||||
const res = await listHeirs()
|
||||
heirs.value = res.data
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memuatkan pewaris.'),
|
||||
})
|
||||
} finally {
|
||||
loadingHeirs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditHeir(heir: Heir) {
|
||||
clearHeirErrors()
|
||||
editingHeirId.value = heir.id
|
||||
heirForm.name = heir.name
|
||||
heirForm.ic_number = heir.ic_number
|
||||
heirForm.relationship = heir.relationship
|
||||
heirForm.phone_number = heir.phone_number
|
||||
heirForm.address = heir.address
|
||||
heirForm.is_primary = heir.is_primary
|
||||
syncHeirSelectValues()
|
||||
}
|
||||
|
||||
async function onSaveHeir() {
|
||||
if (!validateHeirForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
savingHeir.value = true
|
||||
const wasEditing = isEditingHeir.value
|
||||
const payload = buildHeirPayload()
|
||||
|
||||
try {
|
||||
const res = wasEditing
|
||||
? await updateHeir(editingHeirId.value!, payload)
|
||||
: await createHeir(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal menyimpan pewaris.')
|
||||
}
|
||||
|
||||
await fetchHeirs()
|
||||
resetHeirForm()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: wasEditing ? 'Pewaris berjaya dikemas kini.' : 'Pewaris berjaya ditambah.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!setHeirErrorsFromApi(error)) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal menyimpan pewaris.'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
savingHeir.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteHeir(heir: Heir) {
|
||||
const result = await Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Padam pewaris?',
|
||||
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Padam',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deletingHeirId.value = heir.id
|
||||
|
||||
try {
|
||||
const res = await deleteHeir(heir.id)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message ?? 'Gagal memadam pewaris.')
|
||||
}
|
||||
|
||||
if (editingHeirId.value === heir.id) {
|
||||
resetHeirForm()
|
||||
}
|
||||
|
||||
await fetchHeirs()
|
||||
|
||||
await Swal.fire({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: 'Pewaris berjaya dipadam.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal memadam pewaris.'),
|
||||
})
|
||||
} finally {
|
||||
deletingHeirId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncHeirSelectValues()
|
||||
await fetchHeirs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'mt-5'">
|
||||
<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">Pewaris</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Urus maklumat pewaris anda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingHeirs" class="text-sm text-slate-500">
|
||||
Memuatkan pewaris...
|
||||
</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 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>
|
||||
<Badge v-if="heir.is_primary" class="bg-green-500 text-white">Utama</Badge>
|
||||
<Badge look="outline">{{ heir.relationship }}</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ heir.ic_number }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ heir.phone_number }}</p>
|
||||
<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)"
|
||||
>
|
||||
<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 }"
|
||||
/>
|
||||
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>
|
||||
|
||||
<form 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' }}
|
||||
</h4>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{
|
||||
isEditingHeir
|
||||
? 'Kemas kini maklumat pewaris yang dipilih.'
|
||||
: 'Tambah pewaris 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"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" :disabled="savingHeir">
|
||||
{{ savingHeir ? 'Menyimpan...' : isEditingHeir ? 'Kemaskini' : 'Tambah' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<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')"
|
||||
/>
|
||||
<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')"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<SelectControl>
|
||||
<SelectTrigger :aria-invalid="!!heirErrors.relationship">
|
||||
<SelectValueText placeholder="Pilih hubungan" />
|
||||
</SelectTrigger>
|
||||
</SelectControl>
|
||||
<SelectContent>
|
||||
<SelectItemGroup>
|
||||
<SelectItemGroupLabel>Hubungan</SelectItemGroupLabel>
|
||||
<SelectItem
|
||||
v-for="item in relationshipCollection.items"
|
||||
:key="item.label"
|
||||
:item="item"
|
||||
>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectItemGroup>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
<FieldError v-if="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')"
|
||||
/>
|
||||
<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')"
|
||||
/>
|
||||
<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)"
|
||||
>
|
||||
<CheckboxControl />
|
||||
<CheckboxLabel>Pewaris utama</CheckboxLabel>
|
||||
</CheckboxRoot>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
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'
|
||||
@@ -17,51 +16,34 @@ import {
|
||||
CarouselItemGroup,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { updateProfile, uploadProfileImage } from '@/modules/profile/services/profile.service'
|
||||
import { uploadProfileImage } from '@/modules/profile/services/profile.service'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ChangePassword from './ChangePassword.vue'
|
||||
import ProfileTab from './ProfileTab.vue'
|
||||
import EmploymentTab from './EmploymentTab.vue'
|
||||
import BankDetailTab from './BankDetailTab.vue'
|
||||
import HeirTab from './HeirTab.vue'
|
||||
import ChangePasswordTab from './ChangePasswordTab.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const saving = ref(false)
|
||||
const uploadingImage = ref(false)
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null)
|
||||
const imagePreviewUrl = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
ic_number: '',
|
||||
position: '',
|
||||
phone_number: '',
|
||||
})
|
||||
|
||||
const displayValue = (value: string | null | undefined) => value?.trim() || '-'
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const status = authStore.user?.status
|
||||
if (!status) return '-'
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
})
|
||||
const displayValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value).trim() || '-'
|
||||
}
|
||||
|
||||
const avatarSrc = computed(
|
||||
() => imagePreviewUrl.value ?? authStore.userImageUrl ?? undefined,
|
||||
)
|
||||
|
||||
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 ?? ''
|
||||
}
|
||||
|
||||
function clearImagePreview() {
|
||||
if (imagePreviewUrl.value) {
|
||||
URL.revokeObjectURL(imagePreviewUrl.value)
|
||||
@@ -100,7 +82,7 @@ async function onImageSelected(event: Event) {
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
title: res.message || 'Gambar profil berjaya dikemas kini.',
|
||||
title: 'Gambar profil berjaya dikemas kini.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
@@ -116,50 +98,10 @@ async function onImageSelected(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveProfile() {
|
||||
saving.value = true
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
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: res.message || 'Profil berjaya dikemas kini.',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
})
|
||||
} catch (error) {
|
||||
await Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Ralat',
|
||||
text: getApiErrorMessage(error, 'Gagal mengemas kini profil.'),
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => authStore.user, syncFormFromUser, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.user) {
|
||||
await authStore.fetchSession()
|
||||
}
|
||||
syncFormFromUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -191,7 +133,6 @@ onMounted(async () => {
|
||||
<div class="w-24 truncate text-lg font-medium sm:w-40 sm:whitespace-normal">
|
||||
{{ authStore.userName || '-' }}
|
||||
</div>
|
||||
<div class="opacity-70">{{ authStore.userActiveRole }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -214,87 +155,90 @@ onMounted(async () => {
|
||||
</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="grid grid-cols-3 gap-5">
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium">{{ displayValue(authStore.activeRole?.name) }}</div>
|
||||
<div class="opacity-70">Peranan</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium">{{ statusLabel }}</div>
|
||||
<div class="opacity-70">Status</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-medium capitalize">{{ displayValue(authStore.activeRole?.context) }}</div>
|
||||
<div class="opacity-70">Konteks</div>
|
||||
<div
|
||||
class="relative aspect-[1.75/1] w-full max-w-[17rem] overflow-hidden rounded-2xl bg-gradient-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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs title -->
|
||||
<div class="px-5 py-4">
|
||||
<TabsList class="w-full mb-0">
|
||||
<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
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="5">
|
||||
<Lucide class="mr-2 size-4" icon="Briefcase" /> Pekerjaan
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="3">
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Maklumat Bank
|
||||
<Lucide class="mr-2 size-4" icon="Banknote" /> Bank
|
||||
</TabsTrigger>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="6">
|
||||
<Lucide class="mr-2 size-4" icon="Users" /> Pewaris
|
||||
</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>
|
||||
<TabsTrigger class="w-1/4 inline-flex items-center justify-center" value="4">
|
||||
<Lucide class="mr-2 size-4" icon="Server" /> Perkhidmatan
|
||||
<Lucide class="mr-2 size-4" icon="MoreHorizontal" /> Lain-lain
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- Profile Info -->
|
||||
<TabsContent value="1" class="mt-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="text" placeholder="No. telefon" />
|
||||
</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>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</Box>
|
||||
<ProfileTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Pekerjaan -->
|
||||
<TabsContent value="5" class="mt-8">
|
||||
<EmploymentTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Bank -->
|
||||
<TabsContent value="3" class="mt-8">
|
||||
<BankDetailTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Password Change -->
|
||||
<TabsContent value="2" class="mt-8">
|
||||
<ChangePassword embedded />
|
||||
<ChangePasswordTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Maklumat Bank -->
|
||||
<!-- Pewaris -->
|
||||
<TabsContent value="6" class="mt-8">
|
||||
<HeirTab embedded />
|
||||
</TabsContent>
|
||||
<!-- Perkhidmatan -->
|
||||
<TabsContent value="4" class="mt-8">
|
||||
<div class="grid grid-cols-12 gap-x-6 gap-y-8">
|
||||
<!-- BEGIN: Latest Uploads -->
|
||||
@@ -627,7 +571,7 @@ onMounted(async () => {
|
||||
class="flex w-full items-center justify-center border-b border-foreground/15 pb-5 sm:w-auto sm:justify-start sm:border-b-0 sm:pb-0">
|
||||
<Badge look="outline" class="mr-3 px-3 py-2">{{
|
||||
faker['dates'][0]
|
||||
}}</Badge>
|
||||
}}</Badge>
|
||||
<div class="opacity-70">Date of Release</div>
|
||||
</div>
|
||||
<div class="mt-5 flex sm:ml-auto sm:mt-0">
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
<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>
|
||||
@@ -1,353 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import fakers from '@/utils/faker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Box } from '@/components/ui/box'
|
||||
import {
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
MenuPositioner,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
} from '@/components/ui/menu'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
TooltipPositioner,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<h2 class="mr-auto text-lg font-medium">Update Profile</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<!-- BEGIN: Profile Menu -->
|
||||
<div class="col-span-12 flex flex-col-reverse lg:col-span-4 lg:block 2xl:col-span-3">
|
||||
<Box class="mt-5 p-0">
|
||||
<div class="relative flex items-center p-5">
|
||||
<AvatarRoot class="size-12 rounded-full">
|
||||
<AvatarFallback>PA</AvatarFallback>
|
||||
<AvatarImage :src="fakers[0]!['photos'][0]" alt="avatar" />
|
||||
</AvatarRoot>
|
||||
<div class="ml-4 mr-auto">
|
||||
<div class="text-base font-medium">
|
||||
{{ fakers[0]!['users'][0]!['name'] }}
|
||||
</div>
|
||||
<div class="opacity-70">{{ fakers[0]!['jobs'][0] }}</div>
|
||||
</div>
|
||||
<MenuRoot
|
||||
class="w-auto"
|
||||
:positioning="{
|
||||
placement: 'bottom',
|
||||
}"
|
||||
>
|
||||
<MenuTrigger as-child>
|
||||
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
|
||||
</MenuTrigger>
|
||||
<MenuPositioner>
|
||||
<MenuContent class="w-64">
|
||||
<div class="font-medium">Export Options</div>
|
||||
<MenuSeparator />
|
||||
<MenuItem value="0">
|
||||
<Lucide icon="Activity" />
|
||||
English
|
||||
</MenuItem>
|
||||
<MenuItem value="1">
|
||||
<Lucide icon="Box" />
|
||||
Indonesia
|
||||
<Badge variant="danger" look="outline" class="ml-auto">10</Badge>
|
||||
</MenuItem>
|
||||
<MenuItem value="2">
|
||||
<Lucide icon="Layout" />
|
||||
English
|
||||
</MenuItem>
|
||||
<MenuItem value="3">
|
||||
<Lucide icon="Sidebar" />
|
||||
Indonesia
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<div class="flex">
|
||||
<Button class="text-xs" type="button" variant="primary" look="outline" size="sm">
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
class="ml-auto text-xs"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
look="outline"
|
||||
size="sm"
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
</div>
|
||||
</MenuContent>
|
||||
</MenuPositioner>
|
||||
</MenuRoot>
|
||||
</div>
|
||||
<div class="flex flex-col gap-5 border-t border-foreground/10 p-5">
|
||||
<a
|
||||
class="[&.active]:text-primary active flex items-center [&.active]:font-medium"
|
||||
href=""
|
||||
>
|
||||
<Lucide class="mr-2 size-4" icon="Activity" /> Personal Information
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Box" /> Account Settings
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Change Password
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Settings" /> User Settings
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col gap-5 border-t border-foreground/10 p-5">
|
||||
<a class="flex items-center" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Activity" /> Email Settings
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Box" /> Saved Credit Cards
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Lock" /> Social Networks
|
||||
</a>
|
||||
<a class="[&.active]:text-primary flex items-center [&.active]:font-medium" href="">
|
||||
<Lucide class="mr-2 size-4" icon="Settings" /> Tax Information
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex border-t border-foreground/10 p-5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="shadow-none border border-foreground/15"
|
||||
type="button"
|
||||
>
|
||||
New Group
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="shadow-none border border-foreground/15 ml-auto"
|
||||
type="button"
|
||||
>
|
||||
New Quick Link
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<!-- END: Profile Menu -->
|
||||
<div class="col-span-12 lg:col-span-8 2xl:col-span-9">
|
||||
<!-- BEGIN: Display Information -->
|
||||
<Box class="p-0 lg:mt-5">
|
||||
<div class="flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Display Information</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex flex-col xl:flex-row">
|
||||
<div class="mt-6 flex-1 xl:mt-0">
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-12 gap-x-5">
|
||||
<div class="col-span-12 2xl:col-span-6">
|
||||
<Field>
|
||||
<FieldLabel for="update-profile-form-1">Display Name</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-1"
|
||||
type="text"
|
||||
:value="fakers[0]!['users'][0]!['name']"
|
||||
placeholder="Input text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-2">Nearest MRT Station</FieldLabel>
|
||||
<NativeSelect class="w-full" id="update-profile-form-2">
|
||||
<NativeSelectOption value="1">Admiralty</NativeSelectOption>
|
||||
<NativeSelectOption value="2">Aljunied</NativeSelectOption>
|
||||
<NativeSelectOption value="3">Ang Mo Kio</NativeSelectOption>
|
||||
<NativeSelectOption value="4">Bartley</NativeSelectOption>
|
||||
<NativeSelectOption value="5">Beauty World</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-span-12 2xl:col-span-6">
|
||||
<Field class="mt-3 2xl:mt-0">
|
||||
<FieldLabel for="update-profile-form-3">Postal Code</FieldLabel>
|
||||
<NativeSelect class="w-full" id="update-profile-form-3">
|
||||
<NativeSelectOption value="1"
|
||||
>018906 - 1 STRAITS BOULEVARD SINGA...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="2"
|
||||
>018910 - 5A MARINA GARDENS DRIVE...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="3"
|
||||
>018915 - 100A CENTRAL BOULEVARD...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="4"
|
||||
>018925 - 21 PARK STREET MARINA...</NativeSelectOption
|
||||
>
|
||||
<NativeSelectOption value="5"
|
||||
>018926 - 23 PARK STREET MARINA...</NativeSelectOption
|
||||
>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-4">Phone Number</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-4"
|
||||
type="text"
|
||||
value="65570828"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-span-12">
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-5">Address</FieldLabel>
|
||||
<Textarea
|
||||
id="update-profile-form-5"
|
||||
value="10 Anson Road, International Plaza, #10-11, 079903 Singapore, Singapore"
|
||||
placeholder="Adress"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="w-28" type="button" variant="primary"> Save </Button>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<div class="mx-auto w-52 xl:ml-6 xl:mr-0">
|
||||
<div class="rounded-xl border-2 border-dashed border-foreground/15 p-5">
|
||||
<div class="image-fit relative mx-auto h-40 cursor-pointer">
|
||||
<img
|
||||
class="rounded-xl"
|
||||
:src="fakers[0]!['photos'][0]"
|
||||
alt="Midone - Tailwind Admin Dashboard Template"
|
||||
/>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="bg-(--color)/80 border-(--color) text-medium absolute right-0 top-0 -mr-2 -mt-2 flex size-5 items-center justify-center rounded-full text-white [--color:var(--color-danger)]"
|
||||
>
|
||||
<Lucide class="h-4 w-4" icon="X" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPositioner>
|
||||
<TooltipContent>Remove this profile photo?</TooltipContent>
|
||||
</TooltipPositioner>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
<div class="relative mx-auto mt-3 cursor-pointer">
|
||||
<Button class="w-full" type="button" variant="primary"> Change Photo </Button>
|
||||
<Input class="absolute left-0 top-0 h-full w-full opacity-0" type="file" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Display Information -->
|
||||
<!-- BEGIN: Personal Information -->
|
||||
<Box class="mt-8 p-0">
|
||||
<div class="flex items-center border-b border-foreground/15 p-5">
|
||||
<h2 class="mr-auto text-base font-medium">Personal Information</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-12 gap-x-5">
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<Field>
|
||||
<FieldLabel for="update-profile-form-6">Email</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-6"
|
||||
type="text"
|
||||
:value="fakers[0]!['users'][0]!['email']"
|
||||
placeholder="Input text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-7">Name</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-7"
|
||||
type="text"
|
||||
:value="fakers[0]!['users'][0]!['name']"
|
||||
placeholder="Input text"
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-8">ID Type</FieldLabel>
|
||||
<NativeSelect id="update-profile-form-8">
|
||||
<NativeSelectOption>IC</NativeSelectOption>
|
||||
<NativeSelectOption>FIN</NativeSelectOption>
|
||||
<NativeSelectOption>Passport</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-9">ID Number</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-9"
|
||||
type="text"
|
||||
value="357821204950001"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<Field class="mt-3 xl:mt-0">
|
||||
<FieldLabel for="update-profile-form-10">Phone Number</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-10"
|
||||
type="text"
|
||||
value="65570828"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-11">Address</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-11"
|
||||
type="text"
|
||||
value="10 Anson Road, International Plaza, #10-11, 079903 Singapore, Singapore"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-12">Bank Name</FieldLabel>
|
||||
<NativeSelect class="w-full" id="update-profile-form-12">
|
||||
<NativeSelectOption value="1">SBI - STATE BANK OF INDIA</NativeSelectOption>
|
||||
<NativeSelectOption value="2">CITI BANK - CITI BANK</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field class="mt-3">
|
||||
<FieldLabel for="update-profile-form-13">Bank Account</FieldLabel>
|
||||
<Input
|
||||
id="update-profile-form-13"
|
||||
type="text"
|
||||
value="DBS Current 011-903573-0"
|
||||
placeholder="Input text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button class="mr-auto w-28" type="button" variant="primary"> Save </Button>
|
||||
<Button type="button" variant="danger" look="outline">
|
||||
<Lucide class="mr-1 size-4" icon="Trash" /> Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</Box>
|
||||
<!-- END: Personal Information -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,16 +7,4 @@ export const profileLayoutRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('./pages/ProfileOverview2.vue'),
|
||||
meta: { title: 'Profil', module: 'profile' },
|
||||
},
|
||||
{
|
||||
path: 'update-profile',
|
||||
name: 'update-profile',
|
||||
component: () => import('./pages/UpdateProfile.vue'),
|
||||
meta: { title: 'Update Profile', module: 'profile' },
|
||||
},
|
||||
{
|
||||
path: 'change-password',
|
||||
name: 'change-password',
|
||||
component: () => import('./pages/ChangePassword.vue'),
|
||||
meta: { title: 'Change Password', module: 'profile' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
AddressApiResponse,
|
||||
AddressPayload,
|
||||
AddressesApiResponse,
|
||||
} from '../types/address.types'
|
||||
|
||||
export async function listAddresses(perPage = 100): Promise<AddressesApiResponse> {
|
||||
const { data } = await api.get<AddressesApiResponse>('/v1/addresses', {
|
||||
params: { per_page: perPage },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan alamat.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAddress(payload: AddressPayload): Promise<AddressApiResponse> {
|
||||
const { data } = await api.post<AddressApiResponse>('/v1/addresses', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah alamat.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAddress(
|
||||
id: string,
|
||||
payload: AddressPayload,
|
||||
): Promise<AddressApiResponse> {
|
||||
const { data } = await api.put<AddressApiResponse>(`/v1/addresses/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini alamat.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAddress(id: string): Promise<AddressApiResponse> {
|
||||
const { data } = await api.delete<AddressApiResponse>(`/v1/addresses/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam alamat.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { BanksApiResponse } from '../types/bank.types'
|
||||
|
||||
export async function listActiveBanks(): Promise<BanksApiResponse> {
|
||||
const { data } = await api.get<BanksApiResponse>('/v1/banks/active')
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan senarai bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
BankDetailApiResponse,
|
||||
BankDetailPayload,
|
||||
BankDetailsApiResponse,
|
||||
} from '../types/bankDetail.types'
|
||||
|
||||
export async function listBankDetails(perPage = 100): Promise<BankDetailsApiResponse> {
|
||||
const { data } = await api.get<BankDetailsApiResponse>('/v1/bank_details', {
|
||||
params: { per_page: perPage },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan akaun bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createBankDetail(payload: BankDetailPayload): Promise<BankDetailApiResponse> {
|
||||
const { data } = await api.post<BankDetailApiResponse>('/v1/bank_details', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah akaun bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateBankDetail(
|
||||
id: string,
|
||||
payload: BankDetailPayload,
|
||||
): Promise<BankDetailApiResponse> {
|
||||
const { data } = await api.put<BankDetailApiResponse>(`/v1/bank_details/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini akaun bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteBankDetail(id: string): Promise<BankDetailApiResponse> {
|
||||
const { data } = await api.delete<BankDetailApiResponse>(`/v1/bank_details/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam akaun bank.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type {
|
||||
EmploymentApiResponse,
|
||||
EmploymentPayload,
|
||||
EmploymentsApiResponse,
|
||||
} from '../types/employment.types'
|
||||
|
||||
export async function listEmployments(perPage = 100): Promise<EmploymentsApiResponse> {
|
||||
const { data } = await api.get<EmploymentsApiResponse>('/v1/employments', {
|
||||
params: { per_page: perPage },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createEmployment(payload: EmploymentPayload): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.post<EmploymentApiResponse>('/v1/employments', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateEmployment(
|
||||
id: string,
|
||||
payload: EmploymentPayload,
|
||||
): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.put<EmploymentApiResponse>(`/v1/employments/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteEmployment(id: string): Promise<EmploymentApiResponse> {
|
||||
const { data } = await api.delete<EmploymentApiResponse>(`/v1/employments/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam pekerjaan.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { api } from '@/core/services/api'
|
||||
import type { HeirApiResponse, HeirPayload, HeirsApiResponse } from '../types/heir.types'
|
||||
|
||||
export async function listHeirs(perPage = 100): Promise<HeirsApiResponse> {
|
||||
const { data } = await api.get<HeirsApiResponse>('/v1/heirs', {
|
||||
params: { per_page: perPage },
|
||||
})
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memuatkan pewaris.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createHeir(payload: HeirPayload): Promise<HeirApiResponse> {
|
||||
const { data } = await api.post<HeirApiResponse>('/v1/heirs', payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal menambah pewaris.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateHeir(id: string, payload: HeirPayload): Promise<HeirApiResponse> {
|
||||
const { data } = await api.put<HeirApiResponse>(`/v1/heirs/${id}`, payload)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal mengemas kini pewaris.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteHeir(id: string): Promise<HeirApiResponse> {
|
||||
const { data } = await api.delete<HeirApiResponse>(`/v1/heirs/${id}`)
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message ?? 'Gagal memadam pewaris.')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -31,6 +31,10 @@ export async function updateProfile(payload: UpdateProfilePayload): Promise<Upda
|
||||
appendIfDefined(formData, 'ic_number', payload.ic_number)
|
||||
appendIfDefined(formData, 'position', payload.position)
|
||||
appendIfDefined(formData, 'phone_number', payload.phone_number)
|
||||
appendIfDefined(formData, 'gender', payload.gender)
|
||||
appendIfDefined(formData, 'marriage_status', payload.marriage_status)
|
||||
appendIfDefined(formData, 'birth_date', payload.birth_date)
|
||||
appendIfDefined(formData, 'birth_place', payload.birth_place)
|
||||
formData.append('image', payload.image!)
|
||||
|
||||
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', formData, {
|
||||
@@ -46,6 +50,10 @@ export async function updateProfile(payload: UpdateProfilePayload): Promise<Upda
|
||||
if (payload.ic_number?.trim()) body.ic_number = payload.ic_number.trim()
|
||||
if (payload.position?.trim()) body.position = payload.position.trim()
|
||||
if (payload.phone_number?.trim()) body.phone_number = payload.phone_number.trim()
|
||||
if (payload.gender?.trim()) body.gender = payload.gender.trim()
|
||||
if (payload.marriage_status?.trim()) body.marriage_status = payload.marriage_status.trim()
|
||||
if (payload.birth_date?.trim()) body.birth_date = payload.birth_date.trim()
|
||||
if (payload.birth_place?.trim()) body.birth_place = payload.birth_place.trim()
|
||||
|
||||
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', body)
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface Address {
|
||||
id: string
|
||||
user_id: string
|
||||
address_type: string
|
||||
address_line_1: string
|
||||
address_line_2: string | null
|
||||
city: string
|
||||
state: string
|
||||
postcode: string
|
||||
country: string
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface AddressPayload {
|
||||
address_type: string
|
||||
address_line_1: string
|
||||
address_line_2?: string
|
||||
city: string
|
||||
state: string
|
||||
postcode: string
|
||||
country: string
|
||||
}
|
||||
|
||||
export interface AddressesApiResponse {
|
||||
success: boolean
|
||||
data: Address[]
|
||||
pagination?: {
|
||||
current_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface AddressApiResponse {
|
||||
success: boolean
|
||||
data: Address
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface Bank {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
swift_code: string | null
|
||||
is_active: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface BanksApiResponse {
|
||||
success: boolean
|
||||
data: Bank[]
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface BankDetail {
|
||||
id: string
|
||||
user_id: string
|
||||
bank_id: string
|
||||
account_name: string
|
||||
account_number: string
|
||||
account_type: string
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface BankDetailPayload {
|
||||
bank_id: string
|
||||
account_name: string
|
||||
account_number: string
|
||||
account_type: string
|
||||
}
|
||||
|
||||
export interface BankDetailsApiResponse {
|
||||
success: boolean
|
||||
data: BankDetail[]
|
||||
pagination?: {
|
||||
current_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface BankDetailApiResponse {
|
||||
success: boolean
|
||||
data: BankDetail
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface Employment {
|
||||
id: string
|
||||
user_id: string
|
||||
company_name: string
|
||||
job_title: string
|
||||
employment_type: string
|
||||
salary: number | string
|
||||
start_date: string
|
||||
end_date: string | null
|
||||
is_current: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface EmploymentPayload {
|
||||
company_name: string
|
||||
job_title: string
|
||||
employment_type: string
|
||||
salary: number
|
||||
start_date: string
|
||||
end_date?: string | null
|
||||
is_current: boolean
|
||||
}
|
||||
|
||||
export interface EmploymentsApiResponse {
|
||||
success: boolean
|
||||
data: Employment[]
|
||||
pagination?: {
|
||||
current_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface EmploymentApiResponse {
|
||||
success: boolean
|
||||
data: Employment
|
||||
message?: string
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface Heir {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
ic_number: string
|
||||
relationship: string
|
||||
phone_number: string
|
||||
address: string
|
||||
is_primary: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface HeirPayload {
|
||||
name: string
|
||||
ic_number: string
|
||||
relationship: string
|
||||
phone_number: string
|
||||
address: string
|
||||
is_primary: boolean
|
||||
}
|
||||
|
||||
export interface HeirsApiResponse {
|
||||
success: boolean
|
||||
data: Heir[]
|
||||
pagination?: {
|
||||
current_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface HeirApiResponse {
|
||||
success: boolean
|
||||
data: Heir
|
||||
message?: string
|
||||
}
|
||||
@@ -5,6 +5,10 @@ export interface UpdateProfilePayload {
|
||||
ic_number?: string
|
||||
position?: string
|
||||
phone_number?: string
|
||||
gender?: string
|
||||
marriage_status?: string
|
||||
birth_date?: string
|
||||
birth_place?: string
|
||||
image?: File
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user