base struktur update
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
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
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user