03fe4b1fd4
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
291 lines
18 KiB
Vue
291 lines
18 KiB
Vue
<script setup>
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { createCustomer, getCustomer, getCustomerOptions, updateCustomer } from '@/services/customerService'
|
|
import { getTenants } from '@/services/tenantService'
|
|
import { deleteFile, getTemporaryFileUrl, uploadFile } from '@/services/fileService'
|
|
import { getDesaOptions, getKabupatenOptions, getKecamatanOptions, getProvinsiOptions } from '@/services/wilayahService'
|
|
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
|
import { compressCustomerImage } from '@/utils/imageCompression'
|
|
import CustomerLocationMap from '@/components/customers/CustomerLocationMap.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const authStore = useAuthStore()
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const uploading = ref(false)
|
|
const fileInput = ref(null)
|
|
const selectedImage = ref(0)
|
|
const tenants = ref([]), profiles = ref([]), mikrotiks = ref([]), billingProfiles = ref([])
|
|
const provinsi = ref([]), kabupaten = ref([]), kecamatan = ref([]), desa = ref([])
|
|
const gallery = ref([])
|
|
const isEdit = computed(() => !!route.params.id)
|
|
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
|
|
const form = reactive({
|
|
tenant_id: '', customer_code: '', name: '', identity_number: '', email: '', whatsapp_number: '',
|
|
installation_address: '', provinsi_id: '', kabupaten_id: '', kecamatan_id: '', desa_id: '',
|
|
latitude: '', longitude: '', package_profile_id: '', nas_mikrotik_id: '', external_username: '',
|
|
billing_profile_id: '', order_stage: 'registration', notes: '',
|
|
})
|
|
const categoryOptions = [
|
|
['house', 'Rumah'], ['installation', 'Instalasi'], ['modem', 'Modem/ONT'],
|
|
['odp', 'ODP'], ['cable', 'Jalur Kabel'], ['identity', 'Identitas'], ['other', 'Lainnya'],
|
|
]
|
|
const activeImage = computed(() => gallery.value[selectedImage.value] || null)
|
|
const tenantProfiles = computed(() => !form.tenant_id ? profiles.value : profiles.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
|
|
const tenantNas = computed(() => !form.tenant_id ? mikrotiks.value : mikrotiks.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
|
|
const tenantBillingProfiles = computed(() => !form.tenant_id ? billingProfiles.value : billingProfiles.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
|
|
|
|
function optionRows(response) { return Array.isArray(response?.data) ? response.data : [] }
|
|
async function loadReferences() {
|
|
const calls = [getProvinsiOptions(), getCustomerOptions()]
|
|
if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
|
|
const [provinceResponse, customerOptions, tenantResponse] = await Promise.all(calls)
|
|
provinsi.value = optionRows(provinceResponse)
|
|
profiles.value = customerOptions?.data?.package_profiles || []
|
|
mikrotiks.value = customerOptions?.data?.nas_mikrotiks || []
|
|
billingProfiles.value = customerOptions?.data?.billing_profiles || []
|
|
tenants.value = tenantResponse?.data?.data || []
|
|
}
|
|
async function loadKabupaten(reset = true) {
|
|
if (reset) Object.assign(form, { kabupaten_id: '', kecamatan_id: '', desa_id: '' })
|
|
kabupaten.value = form.provinsi_id ? optionRows(await getKabupatenOptions(form.provinsi_id)) : []
|
|
}
|
|
async function loadKecamatan(reset = true) {
|
|
if (reset) Object.assign(form, { kecamatan_id: '', desa_id: '' })
|
|
kecamatan.value = form.kabupaten_id ? optionRows(await getKecamatanOptions(form.kabupaten_id)) : []
|
|
}
|
|
async function loadDesa(reset = true) {
|
|
if (reset) form.desa_id = ''
|
|
desa.value = form.kecamatan_id ? optionRows(await getDesaOptions(form.kecamatan_id)) : []
|
|
}
|
|
async function onProvinsiChange(event) { form.provinsi_id = event.target.value; await loadKabupaten() }
|
|
async function onKabupatenChange(event) { form.kabupaten_id = event.target.value; await loadKecamatan() }
|
|
async function onKecamatanChange(event) { form.kecamatan_id = event.target.value; await loadDesa() }
|
|
|
|
async function resolveImageUrl(image) {
|
|
try {
|
|
const response = await getTemporaryFileUrl(image.uuid)
|
|
return response?.data?.url || '/customer-default.svg'
|
|
} catch {
|
|
return '/customer-default.svg'
|
|
}
|
|
}
|
|
async function hydrateGallery(images = []) {
|
|
gallery.value = await Promise.all(images.map(async (image) => ({
|
|
...image,
|
|
url: await resolveImageUrl(image),
|
|
isNew: false,
|
|
})))
|
|
selectedImage.value = Math.max(0, gallery.value.findIndex((image) => image.is_cover))
|
|
}
|
|
async function loadCustomer() {
|
|
if (!isEdit.value) return
|
|
const response = await getCustomer('orders', route.params.id)
|
|
const customer = response?.data || {}
|
|
Object.keys(form).forEach((key) => {
|
|
if (Object.prototype.hasOwnProperty.call(customer, key)) form[key] = customer[key] ?? ''
|
|
})
|
|
if (form.provinsi_id) await loadKabupaten(false)
|
|
if (form.kabupaten_id) await loadKecamatan(false)
|
|
if (form.kecamatan_id) await loadDesa(false)
|
|
await hydrateGallery(customer.images || [])
|
|
}
|
|
async function initialize() {
|
|
loading.value = true
|
|
try {
|
|
await loadReferences()
|
|
await loadCustomer()
|
|
} catch (error) {
|
|
showError(error?.response?.data?.message || 'Gagal memuat form customer.')
|
|
router.replace('/customers/orders')
|
|
} finally { loading.value = false }
|
|
}
|
|
async function addImages(event) {
|
|
const files = [...(event.target.files || [])].filter((file) => file.type.startsWith('image/'))
|
|
if (!files.length) return
|
|
if (gallery.value.length + files.length > 20) {
|
|
showError('Maksimal 20 gambar untuk satu customer.'); event.target.value = ''; return
|
|
}
|
|
uploading.value = true
|
|
try {
|
|
for (const file of files) {
|
|
const compressedFile = await compressCustomerImage(file)
|
|
const response = await uploadFile(compressedFile, 'customer-image')
|
|
const uploaded = response?.data
|
|
const tempResponse = await getTemporaryFileUrl(uploaded.uuid)
|
|
gallery.value.push({
|
|
stored_file_id: uploaded.id, uuid: uploaded.uuid, original_name: uploaded.original_name,
|
|
category: 'other', caption: '', is_cover: gallery.value.length === 0,
|
|
sort_order: gallery.value.length, url: tempResponse?.data?.url || URL.createObjectURL(compressedFile), isNew: true,
|
|
})
|
|
}
|
|
selectedImage.value = gallery.value.length - 1
|
|
} catch (error) { showError(error?.response?.data?.message || 'Gagal mengunggah gambar.') }
|
|
finally { uploading.value = false; event.target.value = '' }
|
|
}
|
|
function setCover(index) {
|
|
gallery.value.forEach((image, imageIndex) => { image.is_cover = imageIndex === index })
|
|
selectedImage.value = index
|
|
}
|
|
async function removeImage(index) {
|
|
const image = gallery.value[index]
|
|
const result = await showConfirm('Hapus Gambar', `Hapus "${image.original_name}" dari galeri?`)
|
|
if (!result.isConfirmed) return
|
|
if (image.isNew) {
|
|
try { await deleteFile(image.uuid) } catch { /* relasi tetap dibuang dari form */ }
|
|
}
|
|
gallery.value.splice(index, 1)
|
|
gallery.value.forEach((item, itemIndex) => { item.sort_order = itemIndex })
|
|
if (gallery.value.length && !gallery.value.some((item) => item.is_cover)) gallery.value[0].is_cover = true
|
|
selectedImage.value = Math.min(selectedImage.value, Math.max(0, gallery.value.length - 1))
|
|
}
|
|
function useCurrentLocation() {
|
|
if (!navigator.geolocation) return showError('Browser tidak mendukung geolocation.')
|
|
navigator.geolocation.getCurrentPosition(
|
|
({ coords }) => { form.latitude = coords.latitude.toFixed(7); form.longitude = coords.longitude.toFixed(7) },
|
|
() => showError('Lokasi tidak dapat diambil. Pastikan izin lokasi sudah diberikan.'),
|
|
{ enableHighAccuracy: true, timeout: 15000 },
|
|
)
|
|
}
|
|
function payload() {
|
|
const data = Object.fromEntries(Object.entries(form).map(([key, value]) => [key, value === '' ? null : value]))
|
|
data.images = gallery.value.map((image, index) => ({
|
|
stored_file_id: image.stored_file_id, category: image.category, caption: image.caption || null,
|
|
is_cover: !!image.is_cover, sort_order: index,
|
|
}))
|
|
return data
|
|
}
|
|
async function save() {
|
|
if (!form.name?.trim()) return showError('Nama customer wajib diisi.')
|
|
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
|
|
saving.value = true
|
|
try {
|
|
const response = isEdit.value
|
|
? await updateCustomer(route.params.id, payload())
|
|
: await createCustomer(payload())
|
|
showSuccess(`Customer berhasil ${isEdit.value ? 'diperbarui' : 'didaftarkan'}.`)
|
|
router.replace(`/customers/orders/${response?.data?.id || route.params.id}/edit`)
|
|
if (!isEdit.value) await initialize()
|
|
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan customer.') }
|
|
finally { saving.value = false }
|
|
}
|
|
onMounted(initialize)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="customer-form-page">
|
|
<div class="customer-page-header">
|
|
<div>
|
|
<CButton color="secondary" variant="ghost" size="sm" @click="router.push('/customers/orders')">← Kembali</CButton>
|
|
<h4 class="mb-1 mt-1">{{ isEdit ? 'Lengkapi Data Customer' : 'Registrasi Customer' }}</h4>
|
|
<p class="text-body-secondary mb-0">Identitas, lokasi pemasangan, layanan, dan dokumentasi customer.</p>
|
|
</div>
|
|
<CButton color="primary" size="sm" :disabled="loading || saving || uploading" @click="save">
|
|
{{ saving ? 'Menyimpan...' : 'Simpan Customer' }}
|
|
</CButton>
|
|
</div>
|
|
<div v-if="loading" class="form-loading"><CSpinner color="primary" /><span>Memuat data...</span></div>
|
|
<CRow v-else class="g-3">
|
|
<CCol :xl="8">
|
|
<CCard class="form-card mb-3"><CCardHeader><strong>Informasi Customer</strong></CCardHeader><CCardBody>
|
|
<CRow class="g-3">
|
|
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="x in tenants" :key="x.id" :value="x.id">{{ x.tenant_name }}</option></CFormSelect></CCol>
|
|
<CCol :md="6"><CFormLabel>ID Customer</CFormLabel><CFormInput v-model="form.customer_code" placeholder="Dibuat otomatis jika kosong" /></CCol>
|
|
<CCol :md="6"><CFormLabel>Nama Lengkap</CFormLabel><CFormInput v-model="form.name" /></CCol>
|
|
<CCol :md="6"><CFormLabel>NIK/Identitas</CFormLabel><CFormInput v-model="form.identity_number" /></CCol>
|
|
<CCol :md="6"><CFormLabel>Email</CFormLabel><CFormInput v-model="form.email" type="email" /></CCol>
|
|
<CCol :md="6"><CFormLabel>WhatsApp</CFormLabel><CFormInput v-model="form.whatsapp_number" /></CCol>
|
|
</CRow>
|
|
</CCardBody></CCard>
|
|
|
|
<CCard class="form-card mb-3"><CCardHeader><strong>Lokasi Pemasangan</strong></CCardHeader><CCardBody>
|
|
<CRow class="g-3">
|
|
<CCol :xs="12"><CFormLabel>Alamat</CFormLabel><CFormTextarea v-model="form.installation_address" rows="2" /></CCol>
|
|
<CCol :md="6" :xl="3"><CFormLabel>Provinsi</CFormLabel><CFormSelect v-model="form.provinsi_id" @change="onProvinsiChange"><option value="">Pilih</option><option v-for="x in provinsi" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
|
<CCol :md="6" :xl="3"><CFormLabel>Kabupaten</CFormLabel><CFormSelect v-model="form.kabupaten_id" @change="onKabupatenChange"><option value="">Pilih</option><option v-for="x in kabupaten" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
|
<CCol :md="6" :xl="3"><CFormLabel>Kecamatan</CFormLabel><CFormSelect v-model="form.kecamatan_id" @change="onKecamatanChange"><option value="">Pilih</option><option v-for="x in kecamatan" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
|
<CCol :md="6" :xl="3"><CFormLabel>Desa</CFormLabel><CFormSelect v-model="form.desa_id"><option value="">Pilih</option><option v-for="x in desa" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
|
</CRow>
|
|
<div class="map-shell mt-3">
|
|
<CustomerLocationMap
|
|
v-model:latitude="form.latitude"
|
|
v-model:longitude="form.longitude"
|
|
/>
|
|
<div class="map-coordinates">
|
|
<div><CFormLabel>Latitude</CFormLabel><CFormInput v-model="form.latitude" type="number" step="any" /></div>
|
|
<div><CFormLabel>Longitude</CFormLabel><CFormInput v-model="form.longitude" type="number" step="any" /></div>
|
|
<CButton color="primary" variant="outline" @click="useCurrentLocation">Gunakan Lokasi Saya</CButton>
|
|
</div>
|
|
</div>
|
|
</CCardBody></CCard>
|
|
|
|
<CCard class="form-card"><CCardHeader class="d-flex justify-content-between"><strong>Dokumentasi Lokasi</strong><small>{{ gallery.length }}/20 gambar</small></CCardHeader><CCardBody>
|
|
<div class="gallery-main">
|
|
<img :src="activeImage?.url || '/customer-default.svg'" alt="Dokumentasi customer" class="image-previewable" />
|
|
<div v-if="activeImage" class="gallery-overlay"><CBadge color="primary">{{ categoryOptions.find(([value]) => value === activeImage.category)?.[1] }}</CBadge><span>{{ activeImage.caption || activeImage.original_name }}</span></div>
|
|
</div>
|
|
<div class="gallery-thumbnails">
|
|
<button v-for="(image, index) in gallery" :key="image.uuid" type="button" class="gallery-thumb" :class="{ active: index === selectedImage }" @click="selectedImage = index">
|
|
<img :src="image.url" :alt="image.original_name" /><span v-if="image.is_cover">Cover</span>
|
|
</button>
|
|
<button type="button" class="gallery-add" :disabled="uploading || gallery.length >= 20" @click="fileInput?.click()"><strong>+</strong><span>{{ uploading ? 'Upload...' : 'Tambah' }}</span></button>
|
|
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="addImages" />
|
|
</div>
|
|
<CRow v-if="activeImage" class="g-2 mt-2 align-items-end">
|
|
<CCol :md="4"><CFormLabel>Kategori</CFormLabel><CFormSelect v-model="activeImage.category"><option v-for="[value, label] in categoryOptions" :key="value" :value="value">{{ label }}</option></CFormSelect></CCol>
|
|
<CCol :md="5"><CFormLabel>Keterangan</CFormLabel><CFormInput v-model="activeImage.caption" /></CCol>
|
|
<CCol :md="3" class="d-flex gap-2"><CButton color="primary" variant="outline" class="flex-fill" @click="setCover(selectedImage)">Jadikan Cover</CButton><CButton color="danger" variant="outline" @click="removeImage(selectedImage)">Hapus</CButton></CCol>
|
|
</CRow>
|
|
</CCardBody></CCard>
|
|
</CCol>
|
|
|
|
<CCol :xl="4">
|
|
<div class="customer-sidebar">
|
|
<CCard class="form-card mb-3"><CCardHeader><strong>Layanan Customer</strong></CCardHeader><CCardBody class="d-grid gap-3">
|
|
<div><CFormLabel>Profile Paket</CFormLabel><CFormSelect v-model="form.package_profile_id"><option value="">Pilih profile paket</option><option v-for="x in tenantProfiles" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></div>
|
|
<div><CFormLabel>NAS Mikrotik</CFormLabel><CFormSelect v-model="form.nas_mikrotik_id"><option value="">Pilih NAS</option><option v-for="x in tenantNas" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></div>
|
|
<div><CFormLabel>Profile Tagihan</CFormLabel><CFormSelect v-model="form.billing_profile_id"><option value="">Pilih profile tagihan</option><option v-for="x in tenantBillingProfiles" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></div>
|
|
<div><CFormLabel>Username Eksternal</CFormLabel><CFormInput v-model="form.external_username" /></div>
|
|
<div><CFormLabel>Tahap Order</CFormLabel><CFormSelect v-model="form.order_stage"><option value="registration">Registrasi</option><option value="data_completion">Melengkapi Data</option><option value="ready_activation">Siap Aktivasi</option><option value="activation_failed">Aktivasi Gagal</option></CFormSelect></div>
|
|
<div><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="4" /></div>
|
|
</CCardBody></CCard>
|
|
<CButton color="primary" class="w-100 save-mobile" :disabled="saving || uploading" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan Customer' }}</CButton>
|
|
</div>
|
|
</CCol>
|
|
</CRow>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.customer-form-page { padding: 0.5rem; }
|
|
.customer-page-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
|
.form-loading { min-height: 16rem; display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
|
|
.form-card { border: 1px solid var(--cui-border-color); border-radius: 0.85rem; box-shadow: 0 0.2rem 0.7rem rgba(0, 0, 0, 0.04); overflow: hidden; }
|
|
.map-shell { overflow: hidden; border: 1px solid var(--cui-border-color); border-radius: 0.75rem; }
|
|
.map-coordinates { display: grid; grid-template-columns: 1fr 1fr auto; align-items: end; gap: 0.75rem; padding: 0.75rem; background: var(--cui-tertiary-bg); }
|
|
.gallery-main { position: relative; overflow: hidden; aspect-ratio: 16 / 8; border-radius: 0.75rem; background: var(--cui-tertiary-bg); }
|
|
.gallery-main img { width: 100%; height: 100%; object-fit: cover; cursor: zoom-in; }
|
|
.gallery-overlay { position: absolute; inset: auto 0 0; display: flex; align-items: center; gap: 0.5rem; padding: 1rem; color: white; background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); }
|
|
.gallery-thumbnails { display: flex; gap: 0.6rem; overflow-x: auto; padding: 0.75rem 0 0.25rem; }
|
|
.gallery-thumb, .gallery-add { position: relative; width: 5.5rem; height: 4.5rem; flex: 0 0 auto; overflow: hidden; border: 2px solid transparent; border-radius: 0.65rem; background: var(--cui-tertiary-bg); }
|
|
.gallery-thumb.active { border-color: var(--cui-primary); }
|
|
.gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
|
.gallery-thumb span { position: absolute; right: 0.2rem; bottom: 0.2rem; padding: 0.05rem 0.25rem; border-radius: 0.25rem; color: white; font-size: 0.65rem; background: var(--cui-primary); }
|
|
.gallery-add { display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--cui-primary); border-style: dashed; border-color: var(--cui-primary); }
|
|
.gallery-add strong { font-size: 1.5rem; line-height: 1; }
|
|
.gallery-add span { font-size: 0.72rem; }
|
|
.customer-sidebar { position: sticky; top: 5.5rem; }
|
|
@media (max-width: 1199.98px) { .customer-sidebar { position: static; } }
|
|
@media (max-width: 767.98px) {
|
|
.customer-form-page { padding: 0; }
|
|
.customer-page-header { align-items: flex-start; flex-direction: column; }
|
|
.customer-page-header > button { display: none; }
|
|
.map-coordinates { grid-template-columns: 1fr; }
|
|
.gallery-main { aspect-ratio: 4 / 3; }
|
|
.save-mobile { position: sticky; bottom: 0.5rem; z-index: 5; }
|
|
}
|
|
</style>
|