304 lines
12 KiB
Vue
304 lines
12 KiB
Vue
<script lang="ts" setup>
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import Swal from 'sweetalert2'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Box } from '@/components/ui/box'
|
|
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
|
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
import { Lucide } from '@/components/ui/lucide'
|
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
|
import { listEmployments } from '@/modules/profile/services/employment.service'
|
|
import { uploadProfileImage } from '@/modules/profile/services/profile.service'
|
|
import type { Employment } from '@/modules/profile/types/employment.types'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import MemberDigitalCard from '../components/MemberDigitalCard.vue'
|
|
import ProfileTab from './ProfileTab.vue'
|
|
import EmploymentTab from './EmploymentTab.vue'
|
|
import BankDetailTab from './BankDetailTab.vue'
|
|
import HeirTab from './HeirTab.vue'
|
|
import ChangePasswordTab from './ChangePasswordTab.vue'
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
const uploadingImage = ref(false)
|
|
const imageInputRef = ref<HTMLInputElement | null>(null)
|
|
const imagePreviewUrl = ref<string | null>(null)
|
|
const employments = ref<Employment[]>([])
|
|
|
|
const companyName = computed(() => {
|
|
const currentEmployment = employments.value.find((employment) => employment.is_current)
|
|
return currentEmployment?.company_name ?? employments.value[0]?.company_name ?? null
|
|
})
|
|
|
|
const profileUrl = computed(() => {
|
|
const token = authStore.user?.public_profile_token
|
|
if (!token) return null
|
|
|
|
const baseUrl = (typeof window !== 'undefined'
|
|
? window.location.origin
|
|
: import.meta.env.VITE_APP_URL || ''
|
|
).replace(/\/$/, '')
|
|
|
|
return `${baseUrl}/v/${token}`
|
|
})
|
|
|
|
const displayValue = (value: string | number | null | undefined) => {
|
|
if (value === null || value === undefined || value === '') return '-'
|
|
return String(value).trim() || '-'
|
|
}
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
active: 'Aktif',
|
|
pending: 'Menunggu',
|
|
inactive: 'Tidak Aktif',
|
|
}
|
|
|
|
const statusLabel = computed(() => {
|
|
const status = authStore.userStatus
|
|
if (!status) return '-'
|
|
return STATUS_LABELS[status] ?? status.charAt(0).toUpperCase() + status.slice(1)
|
|
})
|
|
|
|
const statusBadgeVariant = computed(() => {
|
|
if (authStore.isAccountActive) return 'success' as const
|
|
if (authStore.isAccountPending) return 'pending' as const
|
|
return 'secondary' as const
|
|
})
|
|
|
|
function formatDateLabel(value: string | null | undefined): string {
|
|
if (!value) return '-'
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return value
|
|
return new Intl.DateTimeFormat('ms-MY', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
}).format(date)
|
|
}
|
|
|
|
const avatarSrc = computed(
|
|
() => imagePreviewUrl.value ?? authStore.userImageUrl ?? undefined,
|
|
)
|
|
|
|
function clearImagePreview() {
|
|
if (imagePreviewUrl.value) {
|
|
URL.revokeObjectURL(imagePreviewUrl.value)
|
|
imagePreviewUrl.value = null
|
|
}
|
|
if (imageInputRef.value) {
|
|
imageInputRef.value.value = ''
|
|
}
|
|
}
|
|
|
|
function openImagePicker() {
|
|
if (uploadingImage.value) return
|
|
imageInputRef.value?.click()
|
|
}
|
|
|
|
async function onImageSelected(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
if (!file) return
|
|
|
|
clearImagePreview()
|
|
imagePreviewUrl.value = URL.createObjectURL(file)
|
|
uploadingImage.value = true
|
|
|
|
try {
|
|
const res = await uploadProfileImage(file)
|
|
|
|
if (!res.success) {
|
|
throw new Error(res.message ?? 'Gagal mengemas kini gambar profil.')
|
|
}
|
|
|
|
authStore.setUserProfile(res.data)
|
|
clearImagePreview()
|
|
|
|
await Swal.fire({
|
|
toast: true,
|
|
position: 'top-end',
|
|
icon: 'success',
|
|
title: 'Gambar profil berjaya dikemas kini.',
|
|
showConfirmButton: false,
|
|
timer: 3000,
|
|
})
|
|
} catch (error) {
|
|
clearImagePreview()
|
|
await Swal.fire({
|
|
icon: 'error',
|
|
title: 'Ralat',
|
|
text: getApiErrorMessage(error, 'Gagal mengemas kini gambar profil.'),
|
|
})
|
|
} finally {
|
|
uploadingImage.value = false
|
|
}
|
|
}
|
|
|
|
async function fetchEmployments() {
|
|
try {
|
|
const res = await listEmployments()
|
|
employments.value = res.data
|
|
} catch {
|
|
employments.value = []
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (!authStore.user) {
|
|
await authStore.fetchSession()
|
|
}
|
|
|
|
await fetchEmployments()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="flex items-center">
|
|
<h2 class="mr-auto text-lg font-medium">Profil</h2>
|
|
</div>
|
|
<TabsRoot defaultValue="1">
|
|
<!-- BEGIN: Profile Info -->
|
|
<Box raised="single" class="mt-5 p-0">
|
|
<div class="flex flex-col border-b border-foreground/15 lg:flex-row">
|
|
<!-- Identity -->
|
|
<div class="flex flex-1 items-center justify-center p-5 lg:justify-start">
|
|
<div class="relative shrink-0" :class="{ 'opacity-60': uploadingImage }">
|
|
<AvatarRoot class="size-20 border-5 bg-background rounded-full sm:size-24 lg:size-32">
|
|
<AvatarFallback>{{ authStore.userName }}</AvatarFallback>
|
|
<AvatarImage v-if="avatarSrc" :src="avatarSrc" :alt="authStore.userName" />
|
|
</AvatarRoot>
|
|
<button type="button"
|
|
class="bg-(--color)/70 border-3 border-background absolute bottom-0 right-0 mb-1 mr-1 flex cursor-pointer items-center justify-center rounded-full p-2 text-white [--color:var(--color-primary)] disabled:cursor-not-allowed disabled:opacity-60"
|
|
:disabled="uploadingImage" aria-label="Tukar gambar profil" @click="openImagePicker">
|
|
<Lucide class="size-4" :icon="uploadingImage ? 'LoaderCircle' : 'Camera'"
|
|
:class="{ 'animate-spin': uploadingImage }" />
|
|
</button>
|
|
<input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/jpg,image/gif" class="hidden"
|
|
@change="onImageSelected" />
|
|
</div>
|
|
<div class="ml-5 min-w-0">
|
|
<div class="truncate text-lg font-medium sm:whitespace-normal">
|
|
{{ authStore.userName || '-' }}
|
|
</div>
|
|
<div v-if="authStore.userMemberType"
|
|
class="mt-1 truncate text-sm capitalize opacity-70 sm:whitespace-normal">
|
|
{{ authStore.userMemberType }}
|
|
</div>
|
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
<Badge :variant="statusBadgeVariant">{{ statusLabel }}</Badge>
|
|
<Badge v-if="authStore.userMemberNumber" look="outline" variant="secondary">
|
|
No. {{ authStore.userMemberNumber }}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contact & membership -->
|
|
<div class="flex-1 border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
|
|
<div class="text-center font-medium lg:text-left">Maklumat Hubungan</div>
|
|
<div class="mt-4 flex flex-col items-center lg:items-start">
|
|
<div class="flex items-center truncate sm:whitespace-normal">
|
|
<Lucide class="mr-2 size-4 shrink-0" icon="Mail" />
|
|
{{ displayValue(authStore.user?.email) }}
|
|
</div>
|
|
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
|
<Lucide class="mr-2 size-4 shrink-0" icon="Phone" />
|
|
{{ displayValue(authStore.user?.phone_number) }}
|
|
</div>
|
|
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
|
|
<Lucide class="mr-2 size-4 shrink-0" icon="IdCard" />
|
|
{{ displayValue(authStore.user?.ic_number) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
|
<div class="text-center lg:text-left">
|
|
<div class="truncate text-base font-medium">
|
|
{{ displayValue(authStore.userPosition) }}
|
|
</div>
|
|
<div class="text-xs opacity-70">Jawatan</div>
|
|
</div>
|
|
<div class="text-center lg:text-left">
|
|
<div class="truncate text-base font-medium">
|
|
{{ displayValue(companyName) }}
|
|
</div>
|
|
<div class="text-xs opacity-70">Syarikat</div>
|
|
</div>
|
|
<div class="col-span-2 text-center sm:col-span-1 lg:text-left">
|
|
<div class="truncate text-base font-medium">
|
|
{{ formatDateLabel(authStore.userJoinDate) }}
|
|
</div>
|
|
<div class="text-xs opacity-70">Tarikh Sertai</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Digital card -->
|
|
<div
|
|
class="flex shrink-0 items-center justify-center border-t border-foreground/15 p-5 lg:border-t-0 lg:border-l">
|
|
<MemberDigitalCard large :member-number="authStore.userMemberNumber" :member-name="authStore.userName"
|
|
:member-type="authStore.userMemberType" :company-name="companyName" :profile-url="profileUrl"
|
|
:image-url="avatarSrc" />
|
|
</div>
|
|
</div>
|
|
<!-- Tabs title -->
|
|
<div class="px-5 py-4">
|
|
<TabsList class="mb-0 flex w-full">
|
|
<TabsTrigger
|
|
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
|
value="1" aria-label="Profil">
|
|
<Lucide class="size-4 shrink-0 md:mr-2" icon="User" />
|
|
<span class="hidden md:inline">Profil</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
|
value="5" aria-label="Pekerjaan">
|
|
<Lucide class="size-4 shrink-0 md:mr-2" icon="Briefcase" />
|
|
<span class="hidden md:inline">Pekerjaan</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
|
value="3" aria-label="Bank">
|
|
<Lucide class="size-4 shrink-0 md:mr-2" icon="Banknote" />
|
|
<span class="hidden md:inline">Bank</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
|
value="6" aria-label="Penama">
|
|
<Lucide class="size-4 shrink-0 md:mr-2" icon="Users" />
|
|
<span class="hidden md:inline">Penama</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
class="inline-flex min-w-0 flex-1 items-center justify-center whitespace-nowrap px-2 sm:px-3 md:text-sm"
|
|
value="2" aria-label="Kata Laluan">
|
|
<Lucide class="size-4 shrink-0 md:mr-2" icon="Lock" />
|
|
<span class="hidden md:inline">Kata Laluan</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
</Box>
|
|
<!-- Profile Info -->
|
|
<TabsContent value="1" class="mt-8">
|
|
<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">
|
|
<ChangePasswordTab embedded />
|
|
</TabsContent>
|
|
<!-- Penama -->
|
|
<TabsContent value="6" class="mt-8">
|
|
<HeirTab embedded />
|
|
</TabsContent>
|
|
</TabsRoot>
|
|
</div>
|
|
</template>
|