DONE: layout letter with letterhead and footer, notification for newly...

This commit is contained in:
ISMAIL MASSERAN
2026-06-28 02:16:49 +00:00
parent 94ecbe5887
commit 034ecf947f
400 changed files with 29802 additions and 5446 deletions
@@ -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>
+516
View File
@@ -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>
+75 -131
View File
@@ -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">
+821
View File
@@ -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>
-12
View File
@@ -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
}