first init

This commit is contained in:
ISMAIL MASSERAN
2026-06-08 11:37:14 +08:00
commit 94ecbe5887
1058 changed files with 87732 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export { profileLayoutRoutes } from './routes'
export { profileMenu } from './menu'
+9
View File
@@ -0,0 +1,9 @@
import type { Menu } from '@/core/types/menu'
export const profileMenu: Menu[] = [
{
icon: 'User',
route_name: 'profile-overview-2',
title: 'Profil',
},
]
@@ -0,0 +1,136 @@
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import Swal from 'sweetalert2'
import { Box } from '@/components/ui/box'
import { Button } from '@/components/ui/button'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { PasswordInput } from '@/components/ui/password-input'
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { updatePassword } from '@/modules/profile/services/profile.service'
defineProps<{
embedded?: boolean
}>()
const saving = ref(false)
const form = reactive({
current_password: '',
password: '',
password_confirmation: '',
})
function resetForm() {
form.current_password = ''
form.password = ''
form.password_confirmation = ''
}
async function onSubmit() {
if (form.password !== form.password_confirmation) {
await Swal.fire({
icon: 'warning',
title: 'Kata laluan tidak sepadan',
text: 'Kata laluan baharu dan pengesahan mesti sama.',
})
return
}
saving.value = true
try {
const res = await updatePassword({
current_password: form.current_password,
password: form.password,
password_confirmation: form.password_confirmation,
})
if (!res.success) {
throw new Error(res.message ?? 'Gagal menukar kata laluan.')
}
resetForm()
await Swal.fire({
toast: true,
position: 'top-end',
icon: 'success',
title: res.message || 'Kata laluan berjaya dikemas kini.',
showConfirmButton: false,
timer: 3000,
})
} catch (error) {
await Swal.fire({
icon: 'error',
title: 'Ralat',
text: getApiErrorMessage(error, 'Gagal menukar kata laluan.'),
})
} finally {
saving.value = false
}
}
</script>
<template>
<div>
<div v-if="!embedded" class="mb-6 flex items-center">
<h2 class="mr-auto text-lg font-medium">Tukar Kata Laluan</h2>
</div>
<Box raised="single" :class="embedded ? 'p-0' : 'p-0 lg:mt-5'">
<form @submit.prevent="onSubmit">
<div class="flex items-center border-b border-foreground/15 p-5">
<h3 class="mr-auto text-base font-medium">Tukar Kata Laluan</h3>
</div>
<div class="p-5">
<FieldGroup>
<FieldGroup>
<Field>
<FieldLabel for="password-current">Kata Laluan Semasa</FieldLabel>
<PasswordInput
id="password-current"
v-model="form.current_password"
placeholder="Kata laluan semasa"
autocomplete="current-password"
required
:disabled="saving"
/>
</Field>
<Field>
<FieldLabel for="password-new">Kata Laluan Baharu</FieldLabel>
<PasswordInput
id="password-new"
v-model="form.password"
placeholder="Kata laluan baharu"
minlength="8"
autocomplete="new-password"
required
:disabled="saving"
/>
</Field>
<Field>
<FieldLabel for="password-confirm">Sahkan Kata Laluan Baharu</FieldLabel>
<PasswordInput
id="password-confirm"
v-model="form.password_confirmation"
placeholder="Sahkan kata laluan baharu"
minlength="8"
autocomplete="new-password"
required
:disabled="saving"
/>
</Field>
</FieldGroup>
<Field orientation="horizontal">
<Button type="submit" variant="primary" :disabled="saving">
{{ saving ? 'Menyimpan...' : 'Tukar Kata Laluan' }}
</Button>
<Button look="outline" type="button" :disabled="saving" @click="resetForm">
Set Semula
</Button>
</Field>
</FieldGroup>
</div>
</form>
</Box>
</div>
</template>
@@ -0,0 +1,650 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import Swal from 'sweetalert2'
import fakers from '@/utils/faker'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Box } from '@/components/ui/box'
import { TabsRoot, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { MenuRoot, MenuTrigger, MenuPositioner, MenuContent, MenuItem } from '@/components/ui/menu'
import { AvatarRoot, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SwitchRoot, SwitchControl } from '@/components/ui/switch'
import { ProgressRoot, ProgressTrack, ProgressRange } from '@/components/ui/progress-linear'
import {
CarouselRoot,
CarouselPrevTrigger,
CarouselNextTrigger,
CarouselItemGroup,
CarouselItem,
} from '@/components/ui/carousel'
import { 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 { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
import { updateProfile, uploadProfileImage } from '@/modules/profile/services/profile.service'
import { useAuthStore } from '@/stores/auth'
import ChangePassword from './ChangePassword.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 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)
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: res.message || '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 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>
<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 p-5 lg:flex-row">
<div class="flex flex-1 items-center justify-center px-5 lg:justify-start">
<div class="relative" :class="{ 'opacity-60': uploadingImage }">
<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">
<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
class="mt-6 flex-1 border-t border-l border-r border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-t-0 lg:pt-0">
<div class="text-center font-medium lg:mt-3 lg:text-left">Maklumat Hubungan</div>
<div class="mt-4 flex flex-col items-center justify-center lg:items-start">
<div class="flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="Mail" />
{{ displayValue(authStore.user?.email) }}
</div>
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="Phone" />
{{ displayValue(authStore.user?.phone_number) }}
</div>
<div class="mt-3 flex items-center truncate sm:whitespace-normal">
<Lucide class="mr-2 size-4" icon="IdCard" />
{{ displayValue(authStore.user?.ic_number) }}
</div>
</div>
</div>
<div
class="mt-6 flex flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-5 lg:mt-0 lg:border-0 lg:pt-0">
<div class="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>
</div>
</div>
</div>
<!-- Tabs title -->
<div class="px-5 py-4">
<TabsList class="w-full mb-0">
<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="3">
<Lucide class="mr-2 size-4" icon="Banknote" /> Maklumat Bank
</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
</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>
</TabsContent>
<TabsContent value="2" class="mt-8">
<ChangePassword embedded />
</TabsContent>
<!-- Maklumat Bank -->
<TabsContent value="4" class="mt-8">
<div class="grid grid-cols-12 gap-x-6 gap-y-8">
<!-- BEGIN: Latest Uploads -->
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
<div class="flex items-center border-b border-foreground/15 px-5 py-5 sm:py-3">
<h2 class="mr-auto text-base font-medium">Latest Uploads</h2>
<MenuRoot class="w-auto ml-auto sm:hidden">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="all">All Files</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<Button variant="ghost" class="shadow-none border border-foreground/15 hidden sm:flex">All Files</Button>
</div>
<div class="p-5">
<div class="flex items-center">
<FileIcon class="w-12" variant="directory" />
<div class="ml-4">
<a class="font-medium" href="">Documentation</a>
<div class="mt-0.5 text-xs opacity-70">40 KB</div>
</div>
<MenuRoot class="w-auto ml-auto">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="share">
<Lucide class="mr-2 size-4" icon="Users" /> Share File
</MenuItem>
<MenuItem value="delete">
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</div>
<div class="mt-5 flex items-center">
<FileIcon class="w-12 text-xs" variant="file" type="MP3" />
<div class="ml-4">
<a class="font-medium" href="">Celine Dion - Ashes</a>
<div class="mt-0.5 text-xs opacity-70">40 KB</div>
</div>
<MenuRoot class="w-auto ml-auto">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="share">
<Lucide class="mr-2 size-4" icon="Users" /> Share File
</MenuItem>
<MenuItem value="delete">
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</div>
<div class="mt-5 flex items-center">
<FileIcon class="w-12" variant="empty-directory" />
<div class="ml-4">
<a class="font-medium" href="">Resources</a>
<div class="mt-0.5 text-xs opacity-70">0 KB</div>
</div>
<MenuRoot class="w-auto ml-auto">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="share">
<Lucide class="mr-2 size-4" icon="Users" /> Share File
</MenuItem>
<MenuItem value="delete">
<Lucide class="mr-2 size-4" icon="Trash" /> Delete
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
</div>
</div>
</Box>
<!-- END: Latest Uploads -->
<!-- BEGIN: Work In Progress -->
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
<TabsRoot defaultValue="wip-0">
<div class="relative flex items-center border-b border-foreground/15 p-5">
<h2 class="mr-auto text-base font-medium">Work In Progress</h2>
<MenuRoot class="w-auto ml-auto sm:hidden">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="new">New</MenuItem>
<MenuItem value="last-week">Last Week</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<TabsList class="absolute inset-y-0 h-[2.8rem] right-5 my-auto hidden w-auto sm:flex">
<TabsTrigger value="wip-0">New</TabsTrigger>
<TabsTrigger value="wip-1">Last Week</TabsTrigger>
</TabsList>
</div>
<TabsContent value="wip-0" class="p-5">
<div>
<div class="flex">
<div class="mr-auto">Pending Tasks</div>
<div>20%</div>
</div>
<ProgressRoot :defaultValue="50" class="mt-2">
<ProgressTrack>
<ProgressRange />
</ProgressTrack>
</ProgressRoot>
</div>
<div class="mt-5">
<div class="flex">
<div class="mr-auto">Completed Tasks</div>
<div>2 / 20</div>
</div>
<ProgressRoot :defaultValue="25" class="mt-2">
<ProgressTrack>
<ProgressRange />
</ProgressTrack>
</ProgressRoot>
</div>
<div class="mt-5">
<div class="flex">
<div class="mr-auto">Tasks In Progress</div>
<div>42</div>
</div>
<ProgressRoot :defaultValue="75" class="mt-2">
<ProgressTrack>
<ProgressRange />
</ProgressTrack>
</ProgressRoot>
</div>
<div class="text-center">
<Button variant="ghost" class="border border-foreground/15 shadow-none mx-auto mt-5 inline-block"
as="a" href="">
View More Details
</Button>
</div>
</TabsContent>
</TabsRoot>
</Box>
<!-- END: Work In Progress -->
<!-- BEGIN: Daily Sales -->
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
<div class="flex items-center border-b border-foreground/15 px-5 py-5 sm:py-3">
<h2 class="mr-auto text-base font-medium">Daily Sales</h2>
<MenuRoot class="w-auto ml-auto sm:hidden">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="download">
<Lucide class="mr-2 size-4" icon="File" /> Download Excel
</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<Button variant="ghost" class="shadow-none border border-foreground/15 hidden sm:flex">
<Lucide class="mr-2 size-4" icon="File" /> Download Excel
</Button>
</div>
<div class="p-5">
<div v-for="(faker, index) in fakers.slice(0, 3)" :key="index" :class="{ 'mt-5': index > 0 }"
class="relative flex items-center">
<AvatarRoot class="size-12 bg-background rounded-full">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['photos'][0]" />
</AvatarRoot>
<div class="ml-4 mr-auto">
<a class="font-medium" href="">{{ faker['users'][0]!['name'] }}</a>
<div class="mr-5 opacity-70 sm:mr-5">
{{
index === 0
? 'Bootstrap 4 HTML Admin Template'
: index === 1
? 'Tailwind Admin Dashboard Template'
: 'Vuejs HTML Admin Template'
}}
</div>
</div>
<div class="font-medium">
{{ index === 0 ? '+$19' : index === 1 ? '+$25' : '+$21' }}
</div>
</div>
</div>
</Box>
<!-- END: Daily Sales -->
<!-- BEGIN: Latest Tasks -->
<Box raised="single" class="col-span-12 p-0 lg:col-span-6">
<TabsRoot defaultValue="lt-0">
<div class="relative flex items-center border-b border-foreground/15 p-5">
<h2 class="mr-auto text-base font-medium">Latest Tasks</h2>
<MenuRoot class="w-auto ml-auto sm:hidden">
<MenuTrigger as-child>
<a class="block size-5" href="#">
<Lucide class="size-5 opacity-70" icon="MoreHorizontal" />
</a>
</MenuTrigger>
<MenuPositioner>
<MenuContent class="w-40">
<MenuItem value="new">New</MenuItem>
<MenuItem value="last-week">Last Week</MenuItem>
</MenuContent>
</MenuPositioner>
</MenuRoot>
<TabsList class="absolute inset-y-0 h-[2.8rem] right-5 my-auto hidden w-auto sm:flex">
<TabsTrigger value="lt-0">New</TabsTrigger>
<TabsTrigger value="lt-1">Last Week</TabsTrigger>
</TabsList>
</div>
<div class="p-5">
<TabsContent value="lt-0">
<div class="flex items-center">
<div class="border-l-4 border-primary/20 pl-4">
<a class="font-medium" href="">Create New Campaign</a>
<div class="opacity-70">10:00 AM</div>
</div>
<div class="ml-auto">
<SwitchRoot>
<SwitchControl />
</SwitchRoot>
</div>
</div>
<div class="mt-5 flex items-center">
<div class="border-l-4 border-primary/20 pl-4">
<a class="font-medium" href="">Meeting With Client</a>
<div class="opacity-70">02:00 PM</div>
</div>
<div class="ml-auto">
<SwitchRoot>
<SwitchControl />
</SwitchRoot>
</div>
</div>
<div class="mt-5 flex items-center">
<div class="border-l-4 border-primary/20 pl-4">
<a class="font-medium" href="">Create New Repository</a>
<div class="opacity-70">04:00 PM</div>
</div>
<div class="ml-auto">
<SwitchRoot>
<SwitchControl />
</SwitchRoot>
</div>
</div>
</TabsContent>
</div>
</TabsRoot>
</Box>
<!-- END: Latest Tasks -->
<Box raised="single" class="col-span-12 p-0">
<CarouselRoot :default-page="0" :slide-count="fakers.slice(0, 5).length">
<div class="flex items-center border-b border-foreground/15 px-5 py-3">
<h2 class="mr-auto text-base font-medium">New Products</h2>
<CarouselPrevTrigger as-child>
<Button variant="ghost" class="shadow-none border border-foreground/15 mr-2">
<Lucide class="size-4" icon="ChevronLeft" />
</Button>
</CarouselPrevTrigger>
<CarouselNextTrigger as-child>
<Button variant="ghost" class="shadow-none border border-foreground/15">
<Lucide class="size-4" icon="ChevronRight" />
</Button>
</CarouselNextTrigger>
</div>
<div class="px-5">
<CarouselItemGroup class="py-5">
<CarouselItem v-for="(faker, index) in fakers.slice(0, 5)" :key="index" :index="index" class="px-5"
as-child>
<div>
<div class="flex flex-col items-center pb-5 lg:flex-row">
<div class="flex flex-col items-center pr-5 sm:flex-row lg:border-r border-foreground/15">
<div class="sm:mr-5">
<AvatarRoot class="size-20 bg-background rounded-full">
<AvatarFallback>IM</AvatarFallback>
<AvatarImage :src="faker['images'][0]" />
</AvatarRoot>
</div>
<div class="mr-auto mt-3 text-center sm:mt-0 sm:text-left">
<a class="text-lg font-medium" href="">
{{ faker['products'][0]!['name'] }}
</a>
<div class="mt-1 opacity-70 sm:mt-0">
{{ faker['news'][0]!['shortContent'] }}
</div>
</div>
</div>
<div
class="mt-6 flex w-full flex-1 items-center justify-center border-t border-foreground/15 px-5 pt-4 lg:mt-0 lg:w-auto lg:border-t-0 lg:pt-0">
<div class="w-20 rounded-md py-3 text-center">
<div class="text-xl font-medium">{{ faker['totals'][0] }}</div>
<div class="opacity-70">Orders</div>
</div>
<div class="w-20 rounded-md py-3 text-center">
<div class="text-xl font-medium">{{ faker['totals'][1] }}k</div>
<div class="opacity-70">Purchases</div>
</div>
<div class="w-20 rounded-md py-3 text-center">
<div class="text-xl font-medium">{{ faker['totals'][0] }}</div>
<div class="opacity-70">Reviews</div>
</div>
</div>
</div>
<div class="flex flex-col items-center border-t border-foreground/15 pt-5 sm:flex-row">
<div
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>
<div class="opacity-70">Date of Release</div>
</div>
<div class="mt-5 flex sm:ml-auto sm:mt-0">
<Button variant="ghost"
class="border border-foreground/15 shadow-none ml-auto">Preview</Button>
<Button variant="ghost" class="border border-foreground/15 shadow-none ml-2">Details</Button>
</div>
</div>
</div>
</CarouselItem>
</CarouselItemGroup>
</div>
</CarouselRoot>
</Box>
<!-- END: New Products -->
</div>
</TabsContent>
</TabsRoot>
</div>
</template>
@@ -0,0 +1,353 @@
<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>
+22
View File
@@ -0,0 +1,22 @@
import type { RouteRecordRaw } from 'vue-router'
export const profileLayoutRoutes: RouteRecordRaw[] = [
{
path: 'profile-overview-2',
name: 'profile-overview-2',
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,57 @@
import { api } from '@/core/services/api'
import type {
UpdatePasswordPayload,
UpdatePasswordResponse,
UpdateProfilePayload,
UpdateProfileResponse,
} from '../types/profile.types'
function appendIfDefined(formData: FormData, key: string, value: string | undefined) {
if (value !== undefined && value !== '') {
formData.append(key, value)
}
}
export async function uploadProfileImage(image: File): Promise<UpdateProfileResponse> {
const formData = new FormData()
formData.append('image', image)
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export async function updateProfile(payload: UpdateProfilePayload): Promise<UpdateProfileResponse> {
const hasImage = payload.image instanceof File
if (hasImage) {
const formData = new FormData()
formData.append('name', payload.name!.trim())
appendIfDefined(formData, 'ic_number', payload.ic_number)
appendIfDefined(formData, 'position', payload.position)
appendIfDefined(formData, 'phone_number', payload.phone_number)
formData.append('image', payload.image!)
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
const body: Record<string, string> = {
name: payload.name!.trim(),
}
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()
const { data } = await api.post<UpdateProfileResponse>('/v1/profile', body)
return data
}
export async function updatePassword(payload: UpdatePasswordPayload): Promise<UpdatePasswordResponse> {
const { data } = await api.put<UpdatePasswordResponse>('/v1/profile/password', payload)
return data
}
@@ -0,0 +1,26 @@
import type { AuthUser } from '@/modules/auth/types/auth.types'
export interface UpdateProfilePayload {
name?: string
ic_number?: string
position?: string
phone_number?: string
image?: File
}
export interface UpdateProfileResponse {
success: boolean
data: AuthUser
message: string
}
export interface UpdatePasswordPayload {
current_password: string
password: string
password_confirmation: string
}
export interface UpdatePasswordResponse {
success: boolean
message: string
}