Files
manja_dev_ui/src/views/profile/Profile.vue
T
Wian Drs 73a32380e7
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
base struktur update
2026-07-27 16:25:37 +07:00

506 lines
18 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { getMyProfile, updateMyProfile } from '@/services/profileService'
import { deleteFile, getTemporaryFileUrl, uploadFile } from '@/services/fileService'
import {
getDesaOptions,
getKabupatenOptions,
getKecamatanOptions,
getProvinsiOptions,
} from '@/services/wilayahService'
import { showError, showSuccess } from '@/utils/swal'
const authStore = useAuthStore()
const loading = ref(false)
const saving = ref(false)
const profile = ref(null)
const selectedPhoto = ref(null)
const photoUrl = ref('')
const localPhotoUrl = ref('')
const photoInput = ref(null)
const photoDragging = ref(false)
const wilayahOptions = reactive({ provinsi: [], kabupaten: [], kecamatan: [], desa: [] })
const wilayahLoading = reactive({ provinsi: false, kabupaten: false, kecamatan: false, desa: false })
const form = reactive({
name: '',
username: '',
email: '',
whatsapp_number: '',
nik: '',
address: '',
provinsi_id: '',
kabupaten_id: '',
kecamatan_id: '',
desa_id: '',
current_password: '',
password: '',
password_confirmation: '',
})
const accessLevelLabel = computed(() => {
const labels = {
customer: 'Customer',
staff: 'Staff',
tenant_owner: 'Pemilik Tenant',
master_admin: 'Master Admin',
}
return labels[profile.value?.access_level] || profile.value?.access_level || '-'
})
const tenants = computed(() => profile.value?.user_tenants || [])
const menuGroups = computed(() => profile.value?.user_menu_groups || [])
const displayedPhotoUrl = computed(() => localPhotoUrl.value || photoUrl.value)
function fillForm(user) {
const userProfile = user?.user_profile || {}
Object.assign(form, {
name: user?.name || '',
username: user?.username || '',
email: user?.email || '',
whatsapp_number: user?.whatsapp_number || '',
nik: userProfile.nik || '',
address: userProfile.address || '',
provinsi_id: userProfile.provinsi_id ? String(userProfile.provinsi_id) : '',
kabupaten_id: userProfile.kabupaten_id ? String(userProfile.kabupaten_id) : '',
kecamatan_id: userProfile.kecamatan_id ? String(userProfile.kecamatan_id) : '',
desa_id: userProfile.desa_id ? String(userProfile.desa_id) : '',
current_password: '',
password: '',
password_confirmation: '',
})
}
function optionRows(response) {
return Array.isArray(response?.data) ? response.data : []
}
async function loadWilayahOptions(level, parentId = null) {
wilayahLoading[level] = true
try {
const loaders = {
provinsi: () => getProvinsiOptions(),
kabupaten: () => getKabupatenOptions(parentId),
kecamatan: () => getKecamatanOptions(parentId),
desa: () => getDesaOptions(parentId),
}
const response = await loaders[level]()
wilayahOptions[level] = optionRows(response)
} catch (error) {
wilayahOptions[level] = []
showError(error?.response?.data?.message || `Gagal memuat pilihan ${level}.`)
} finally {
wilayahLoading[level] = false
}
}
async function hydrateWilayahOptions() {
await loadWilayahOptions('provinsi')
if (form.provinsi_id) await loadWilayahOptions('kabupaten', form.provinsi_id)
if (form.kabupaten_id) await loadWilayahOptions('kecamatan', form.kabupaten_id)
if (form.kecamatan_id) await loadWilayahOptions('desa', form.kecamatan_id)
}
async function onProvinsiChange(event) {
form.provinsi_id = event.target.value
form.kabupaten_id = ''
form.kecamatan_id = ''
form.desa_id = ''
wilayahOptions.kabupaten = []
wilayahOptions.kecamatan = []
wilayahOptions.desa = []
if (form.provinsi_id) await loadWilayahOptions('kabupaten', form.provinsi_id)
}
async function onKabupatenChange(event) {
form.kabupaten_id = event.target.value
form.kecamatan_id = ''
form.desa_id = ''
wilayahOptions.kecamatan = []
wilayahOptions.desa = []
if (form.kabupaten_id) await loadWilayahOptions('kecamatan', form.kabupaten_id)
}
async function onKecamatanChange(event) {
form.kecamatan_id = event.target.value
form.desa_id = ''
wilayahOptions.desa = []
if (form.kecamatan_id) await loadWilayahOptions('desa', form.kecamatan_id)
}
async function fetchProfile() {
loading.value = true
try {
const response = await getMyProfile()
profile.value = response?.data || null
fillForm(profile.value)
await hydrateWilayahOptions()
await loadProfilePhoto(profile.value)
authStore.setUser(profile.value)
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat profile.')
} finally {
loading.value = false
}
}
async function loadProfilePhoto(user) {
photoUrl.value = ''
if (!user?.profile_photo?.uuid) return
try {
const response = await getTemporaryFileUrl(user.profile_photo.uuid)
photoUrl.value = response?.data?.url || ''
} catch {
photoUrl.value = ''
}
}
function setSelectedPhoto(file, input = null) {
if (file && !file.type.startsWith('image/')) {
if (input) input.value = ''
showError('Foto profil harus berupa file gambar.')
return
}
if (file && file.size > 5 * 1024 * 1024) {
if (input) input.value = ''
showError('Ukuran foto profil maksimal 5 MB.')
return
}
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
selectedPhoto.value = file
localPhotoUrl.value = file ? URL.createObjectURL(file) : ''
}
function selectPhoto(event) {
setSelectedPhoto(event.target.files?.[0] || null, event.target)
}
function dropPhoto(event) {
photoDragging.value = false
setSelectedPhoto(event.dataTransfer?.files?.[0] || null)
}
function clearSelectedPhoto() {
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
selectedPhoto.value = null
localPhotoUrl.value = ''
if (photoInput.value) photoInput.value.value = ''
}
function nullableNumber(value) {
return value === '' || value === null ? null : Number(value)
}
function buildPayload() {
const payload = {
name: form.name.trim(),
username: form.username.trim() || null,
email: form.email.trim(),
whatsapp_number: form.whatsapp_number.trim() || null,
user_profile: {
nik: form.nik.trim() || null,
address: form.address.trim() || null,
provinsi_id: nullableNumber(form.provinsi_id),
kabupaten_id: nullableNumber(form.kabupaten_id),
kecamatan_id: nullableNumber(form.kecamatan_id),
desa_id: nullableNumber(form.desa_id),
},
}
if (form.password) {
payload.current_password = form.current_password
payload.password = form.password
payload.password_confirmation = form.password_confirmation
}
return payload
}
async function saveProfile() {
saving.value = true
try {
const payload = buildPayload()
let uploadedPhoto = null
if (selectedPhoto.value) {
const uploadResponse = await uploadFile(selectedPhoto.value, 'profile')
uploadedPhoto = uploadResponse?.data || null
payload.profile_photo_file_id = uploadedPhoto?.id
}
let response
try {
response = await updateMyProfile(payload)
} catch (error) {
if (uploadedPhoto?.uuid) {
await deleteFile(uploadedPhoto.uuid).catch(() => {})
}
throw error
}
profile.value = response?.data || null
fillForm(profile.value)
selectedPhoto.value = null
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
localPhotoUrl.value = ''
await loadProfilePhoto(profile.value)
authStore.setUser(profile.value)
showSuccess('Profile berhasil diperbarui.')
} catch (error) {
const errors = error?.response?.data?.errors
const firstError = errors ? Object.values(errors).flat()[0] : null
showError(firstError || error?.response?.data?.message || 'Gagal memperbarui profile.')
} finally {
saving.value = false
}
}
onMounted(fetchProfile)
onBeforeUnmount(() => {
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
})
</script>
<template>
<CRow class="brdvx-page-row g-0 m-0">
<CCol :xs="12" class="brdvx-page-column p-0">
<div class="brdvx-page-container">
<div class="brdvx-page-header">
<h5 class="mb-1 fw-semibold">Profile Saya</h5>
</div>
<div v-if="loading" class="text-center py-5">
<CSpinner color="primary" />
</div>
<CRow v-else-if="profile" class="g-3">
<CCol :lg="4">
<CCard class="h-100">
<CCardBody class="text-center">
<CAvatar
:src="displayedPhotoUrl || undefined"
color="primary"
text-color="white"
class="image-previewable profile-photo-avatar"
:data-preview-alt="`Foto ${profile.name}`"
>
<template v-if="!displayedPhotoUrl">
{{ profile.name?.charAt(0)?.toUpperCase() || 'U' }}
</template>
</CAvatar>
<div
class="profile-photo-uploader"
:class="{ 'is-dragging': photoDragging }"
@dragenter.prevent="photoDragging = true"
@dragover.prevent="photoDragging = true"
@dragleave.prevent="photoDragging = false"
@drop.prevent="dropPhoto"
>
<div class="profile-photo-uploader-icon">
<CIcon icon="cil-cloud-download" size="xl" />
</div>
<div class="fw-semibold">Ganti foto profil</div>
<div class="small text-body-secondary">
Tarik gambar ke sini atau pilih dari perangkat
</div>
<div class="d-flex flex-wrap justify-content-center gap-2 mt-3">
<CButton color="primary" variant="outline" size="sm" @click="photoInput?.click()">
<CIcon icon="cil-camera" class="me-1" />
Pilih Gambar
</CButton>
<CButton
v-if="selectedPhoto"
color="secondary"
variant="ghost"
size="sm"
@click="clearSelectedPhoto"
>
Batal
</CButton>
</div>
<div v-if="selectedPhoto" class="profile-photo-file mt-3">
<CIcon icon="cil-check-circle" class="text-success" />
<span>{{ selectedPhoto.name }}</span>
</div>
</div>
<input
ref="photoInput"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="d-none"
aria-label="Pilih foto profile"
@change="selectPhoto"
/>
<div class="small text-body-secondary mt-2 mb-3">
JPG, PNG, WebP, atau GIF. Maksimal 5 MB.
</div>
<h5 class="mb-1">{{ profile.name }}</h5>
<div class="text-body-secondary mb-2">{{ profile.email }}</div>
<CBadge color="primary">{{ accessLevelLabel }}</CBadge>
<hr />
<div class="text-start">
<div class="small text-body-secondary">Status</div>
<div class="mb-3">{{ profile.status }}</div>
<div class="small text-body-secondary">Tenant</div>
<div v-if="tenants.length" class="d-flex flex-wrap gap-1 mt-1">
<CBadge
v-for="membership in tenants"
:key="membership.id"
:color="membership.is_default ? 'primary' : 'secondary'"
>
{{ membership.tenant?.tenant_name || `Tenant #${membership.tenant_id}` }}
</CBadge>
</div>
<div v-else>-</div>
<div class="small text-body-secondary mt-3">Menu Group</div>
<div v-if="menuGroups.length" class="d-flex flex-wrap gap-1 mt-1">
<CBadge v-for="assignment in menuGroups" :key="assignment.id" color="info">
{{ assignment.menu_group?.name || `Group #${assignment.menu_group_id}` }}
</CBadge>
</div>
<div v-else>-</div>
</div>
</CCardBody>
</CCard>
</CCol>
<CCol :lg="8">
<CForm @submit.prevent="saveProfile">
<CCard class="mb-3">
<CCardHeader class="fw-semibold">Informasi Akun</CCardHeader>
<CCardBody>
<CRow class="g-3">
<CCol :md="6">
<CFormLabel>Nama</CFormLabel>
<CFormInput v-model="form.name" required />
</CCol>
<CCol :md="6">
<CFormLabel>Username</CFormLabel>
<CFormInput v-model="form.username" autocomplete="username" />
</CCol>
<CCol :md="6">
<CFormLabel>Email</CFormLabel>
<CFormInput v-model="form.email" type="email" required />
</CCol>
<CCol :md="6">
<CFormLabel>Nomor WhatsApp</CFormLabel>
<CFormInput v-model="form.whatsapp_number" />
</CCol>
</CRow>
</CCardBody>
</CCard>
<CCard class="mb-3">
<CCardHeader class="fw-semibold">Data Profile</CCardHeader>
<CCardBody>
<CRow class="g-3">
<CCol :md="6">
<CFormLabel>NIK</CFormLabel>
<CFormInput v-model="form.nik" />
</CCol>
<CCol :xs="12">
<CFormLabel>Alamat</CFormLabel>
<CFormTextarea v-model="form.address" rows="3" />
</CCol>
<CCol :md="6">
<CFormLabel>Provinsi</CFormLabel>
<CFormSelect
v-model="form.provinsi_id"
:disabled="wilayahLoading.provinsi"
@change="onProvinsiChange"
>
<option value="">{{ wilayahLoading.provinsi ? 'Memuat provinsi...' : 'Pilih provinsi' }}</option>
<option v-for="row in wilayahOptions.provinsi" :key="row.id" :value="String(row.id)">
{{ row.kode }} {{ row.nama }}
</option>
</CFormSelect>
</CCol>
<CCol :md="6">
<CFormLabel>Kabupaten</CFormLabel>
<CFormSelect
v-model="form.kabupaten_id"
:disabled="!form.provinsi_id || wilayahLoading.kabupaten"
@change="onKabupatenChange"
>
<option value="">{{ wilayahLoading.kabupaten ? 'Memuat kabupaten...' : 'Pilih kabupaten' }}</option>
<option v-for="row in wilayahOptions.kabupaten" :key="row.id" :value="String(row.id)">
{{ row.kode }} {{ row.nama }}
</option>
</CFormSelect>
</CCol>
<CCol :md="6">
<CFormLabel>Kecamatan</CFormLabel>
<CFormSelect
v-model="form.kecamatan_id"
:disabled="!form.kabupaten_id || wilayahLoading.kecamatan"
@change="onKecamatanChange"
>
<option value="">{{ wilayahLoading.kecamatan ? 'Memuat kecamatan...' : 'Pilih kecamatan' }}</option>
<option v-for="row in wilayahOptions.kecamatan" :key="row.id" :value="String(row.id)">
{{ row.kode }} {{ row.nama }}
</option>
</CFormSelect>
</CCol>
<CCol :md="6">
<CFormLabel>Desa</CFormLabel>
<CFormSelect
v-model="form.desa_id"
:disabled="!form.kecamatan_id || wilayahLoading.desa"
>
<option value="">{{ wilayahLoading.desa ? 'Memuat desa...' : 'Pilih desa' }}</option>
<option v-for="row in wilayahOptions.desa" :key="row.id" :value="String(row.id)">
{{ row.kode }} {{ row.nama }}
</option>
</CFormSelect>
</CCol>
</CRow>
</CCardBody>
</CCard>
<CCard class="mb-3">
<CCardHeader class="fw-semibold">Ubah Password</CCardHeader>
<CCardBody>
<p class="small text-body-secondary">Kosongkan jika tidak ingin mengubah password.</p>
<CRow class="g-3">
<CCol :md="4">
<CFormLabel>Password Saat Ini</CFormLabel>
<CFormInput v-model="form.current_password" type="password" autocomplete="current-password" />
</CCol>
<CCol :md="4">
<CFormLabel>Password Baru</CFormLabel>
<CFormInput v-model="form.password" type="password" autocomplete="new-password" />
</CCol>
<CCol :md="4">
<CFormLabel>Konfirmasi Password</CFormLabel>
<CFormInput v-model="form.password_confirmation" type="password" autocomplete="new-password" />
</CCol>
</CRow>
</CCardBody>
</CCard>
<div class="d-flex justify-content-end">
<CButton color="primary" type="submit" :disabled="saving">
{{ saving ? 'Menyimpan...' : 'Simpan Perubahan' }}
</CButton>
</div>
</CForm>
</CCol>
</CRow>
</div>
</CCol>
</CRow>
</template>