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
506 lines
18 KiB
Vue
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>
|