diff --git a/src/components/base/BaseResponsiveDataView.vue b/src/components/base/BaseResponsiveDataView.vue index 4fa41da3..f5a8275b 100644 --- a/src/components/base/BaseResponsiveDataView.vue +++ b/src/components/base/BaseResponsiveDataView.vue @@ -14,7 +14,6 @@ -
@@ -42,22 +41,40 @@
Filter Data - +
{{ field.label }} - + - - +
- Reset + Reset Terapkan
@@ -98,7 +115,9 @@
{{ col.label }} - {{ item[col.key] }} + + {{ item[col.key] }} +
@@ -178,7 +197,14 @@ :key="`${resolveRowKey(item, rowIndex)}-left-${col.key}`" class="table-list-cell" > - {{ item[col.key] }} + + {{ item[col.key] }} +
@@ -187,7 +213,14 @@ :key="`${resolveRowKey(item, rowIndex)}-right-${col.key}`" class="table-list-cell" > - {{ item[col.key] }} + + {{ item[col.key] }} +
@@ -198,7 +231,7 @@ :key="`${resolveRowKey(item, rowIndex)}-${col.key}`" class="table-list-cell" > - +
- +
@@ -223,7 +263,11 @@ size="sm" @click="toggleExpandRow(resolveRowKey(item, rowIndex))" > - +
@@ -251,7 +295,9 @@ class="hidden-item" > {{ col.label }} - {{ item[col.key] }} + + {{ item[col.key] }} +
@@ -260,7 +306,9 @@
- Previous + Previous {{ page }} - Next + Next
@@ -280,10 +330,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRouter } from 'vue-router' import { CIcon } from '@coreui/icons-vue' import { cilCaretBottom, cilCaretTop } from '@coreui/icons' -import { - getResponsiveViewMode, - RESPONSIVE_VIEW_MODE_EVENT, -} from '@/utils/responsiveViewMode.js' +import { getResponsiveViewMode, RESPONSIVE_VIEW_MODE_EVENT } from '@/utils/responsiveViewMode.js' const props = defineProps({ columns: { @@ -366,8 +413,11 @@ const viewMode = ref(getResponsiveViewMode()) const searchQuery = ref('') const filterOpen = ref(false) const filterValues = reactive({ ...props.initialFilters }) -const activeFilterCount = computed(() => - Object.values(filterValues).filter((value) => value !== '' && value !== null && value !== undefined).length, +const activeFilterCount = computed( + () => + Object.values(filterValues).filter( + (value) => value !== '' && value !== null && value !== undefined, + ).length, ) const perPageOptions = [10, 20, 50, 100] const perPage = ref(10) @@ -418,7 +468,9 @@ const visiblePages = computed(() => { return Array.from({ length: 7 }, (_, index) => start + index) }) -const desktopVisibleColumns = computed(() => Math.min(props.desktopPrimaryCount, props.columns.length)) +const desktopVisibleColumns = computed(() => + Math.min(props.desktopPrimaryCount, props.columns.length), +) const mobileTableVisibleCount = 6 const visibleTableColumns = computed(() => { @@ -515,15 +567,17 @@ const syncViewMode = (event) => { } } -const filterOptionValue = (option) => Array.isArray(option) ? option[0] : option.value -const filterOptionLabel = (option) => Array.isArray(option) ? option[1] : option.label +const filterOptionValue = (option) => (Array.isArray(option) ? option[0] : option.value) +const filterOptionLabel = (option) => (Array.isArray(option) ? option[1] : option.label) const applyFilters = () => { currentPage.value = 1 filterOpen.value = false emitQueryChange() } const resetFilters = () => { - Object.keys(filterValues).forEach((key) => { filterValues[key] = '' }) + Object.keys(filterValues).forEach((key) => { + filterValues[key] = '' + }) props.filterFields.forEach((field) => { if (!Object.prototype.hasOwnProperty.call(filterValues, field.key)) filterValues[field.key] = '' }) @@ -704,4 +758,3 @@ onBeforeUnmount(() => { justify-content: flex-end; } - diff --git a/src/router/index.js b/src/router/index.js index f1030922..8b16248b 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -91,6 +91,12 @@ const routes = [ name: 'Ticket', component: () => import('@/views/tickets/Ticket.vue'), }, + { + path: '/tickets/ticket/:id', + name: 'Detail dan Proses Ticket', + component: () => import('@/views/tickets/TicketDetailPage.vue'), + meta: { activeMenu: '/tickets/ticket' }, + }, { path: '/tickets/ticket-type', alias: '/ticket-types', @@ -400,6 +406,16 @@ const routes = [ component: () => import('@/views/nas/NasResourceView.vue'), meta: { nasResource: 'package-profile' }, }, + { + path: '/nas/script-policies', + name: 'NAS Script Policy', + component: () => import('@/views/nas/ScriptPolicies.vue'), + }, + { + path: '/nas/radius-infrastructure', + name: 'Infrastruktur RADIUS', + component: () => import('@/views/nas/RadiusInfrastructure.vue'), + }, { path: '/nas/olt', name: 'NAS OLT', @@ -418,6 +434,15 @@ const routes = [ component: () => import('@/views/customers/Customers.vue'), meta: { customerScope: 'orders' }, }, + { path: '/warehouse', redirect: '/warehouse/items' }, + { path: '/warehouse/pending', name: 'Barang Pending', component: () => import('@/views/warehouse/PendingItems.vue') }, + { path: '/warehouse/items', name: 'Daftar Barang', component: () => import('@/views/warehouse/ActiveItems.vue') }, + { path: '/warehouse/items/:id', name: 'Detail Barang Gudang', component: () => import('@/views/warehouse/WarehouseDetail.vue'), meta: { activeMenu: '/warehouse/items' } }, + { path: '/warehouse/depleted', name: 'Barang Habis', component: () => import('@/views/warehouse/DepletedItems.vue') }, + { path: '/warehouse/item-codes', name: 'Kode Barang', component: () => import('@/views/warehouse/ItemCodes.vue') }, + { path: '/warehouse/technician-inventory', name: 'Peralatan di Teknisi', component: () => import('@/views/warehouse/TechnicianInventory.vue') }, + { path: '/warehouse/locations', name: 'Daftar Gudang', component: () => import('@/views/warehouse/Warehouses.vue') }, + { path: '/tenants/:id/settings', name: 'Setting Tenant', component: () => import('@/views/tenants/TenantSettings.vue'), meta: { activeMenu: '/tenants' } }, { path: '/customers/orders/create', name: 'Registrasi Customer', diff --git a/src/services/customerService.js b/src/services/customerService.js index fe349df3..ab634fd9 100644 --- a/src/services/customerService.js +++ b/src/services/customerService.js @@ -8,6 +8,10 @@ export async function getCustomerOptions() { const { data } = await api.get('/customers/options') return data } +export async function getAvailableOdpPorts(nodeId, customerId = null) { + const { data } = await api.get(`/customers/options/odp/${nodeId}/ports`, { params: customerId ? { customer_id: customerId } : {} }) + return data +} export async function getCustomer(scope, id) { const { data } = await api.get(`/customers/${scope}/${id}`) return data @@ -16,10 +20,25 @@ export async function createCustomer(payload) { const { data } = await api.post('/customers', payload) return data } +export async function scanCustomerKtp(image, tenantId = null) { + const form = new FormData() + form.append('image', image) + if (tenantId) form.append('tenant_id', tenantId) + const { data } = await api.post('/customers/ocr-ktp', form) + return data +} export async function updateCustomer(id, payload) { const { data } = await api.put(`/customers/${id}`, payload) return data } +export async function prepareCustomerActivation(id, payload) { + const { data } = await api.put(`/customers/${id}/activation-preparation`, payload) + return data +} +export async function updateCustomerRegistration(id, payload) { + const { data } = await api.put(`/customers/${id}/registration`, payload) + return data +} export async function activateCustomer(id, payload) { const { data } = await api.post(`/customers/${id}/activate`, payload) return data @@ -32,6 +51,10 @@ export async function trashCustomer(id) { const { data } = await api.delete(`/customers/${id}`) return data } +export async function cancelCustomerOrder(id) { + const { data } = await api.delete(`/customers/${id}/cancel-order`) + return data +} export async function restoreCustomer(id) { const { data } = await api.post(`/customers/${id}/restore`) return data diff --git a/src/services/nasService.js b/src/services/nasService.js index 1414cdeb..ff219ac1 100644 --- a/src/services/nasService.js +++ b/src/services/nasService.js @@ -36,3 +36,19 @@ export async function deleteNasResource(resource, id) { const { data } = await api.delete(endpoint(resource, id)) return data } + +export async function testMikrotikConnection(id) { + const { data } = await api.post(`/nas/mikrotiks/${id}/test-connection`) + return data +} +export async function getPackageProfileTargets(profileId) { return (await api.get(`/nas/package-profiles/${profileId}/targets`)).data } +export async function savePackageProfileTarget(profileId, payload) { return (await api.post(`/nas/package-profiles/${profileId}/targets`, payload)).data } +export async function syncPackageProfileTarget(targetId, mode) { return (await api.post(`/nas/package-profile-targets/${targetId}/sync`, { mode })).data } +export async function getPackageProfileScriptOptions() { return (await api.get('/nas/package-profile-script-options')).data } +export async function getScriptPolicies(params = {}) { return (await api.get('/nas/script-policies', { params })).data } +export async function createScriptPolicy(payload) { return (await api.post('/nas/script-policies', payload)).data } +export async function updateScriptPolicy(id, payload) { return (await api.put(`/nas/script-policies/${id}`, payload)).data } +export async function getRadiusClusters(params = {}) { return (await api.get('/nas/radius-clusters', { params })).data } +export async function createRadiusCluster(payload) { return (await api.post('/nas/radius-clusters', payload)).data } +export async function updateRadiusCluster(id, payload) { return (await api.put(`/nas/radius-clusters/${id}`, payload)).data } +export async function deleteRadiusCluster(id) { return (await api.delete(`/nas/radius-clusters/${id}`)).data } diff --git a/src/services/tenantInstallationService.js b/src/services/tenantInstallationService.js new file mode 100644 index 00000000..c9b17476 --- /dev/null +++ b/src/services/tenantInstallationService.js @@ -0,0 +1,3 @@ +import api from '@/api/axios' +export const getTenantInstallationSetting = async (tenantId) => (await api.get(`/tenants/${tenantId}/installation-setting`)).data +export const saveTenantInstallationSetting = async (tenantId, payload) => (await api.put(`/tenants/${tenantId}/installation-setting`, payload)).data diff --git a/src/services/ticketService.js b/src/services/ticketService.js new file mode 100644 index 00000000..26b838bb --- /dev/null +++ b/src/services/ticketService.js @@ -0,0 +1,15 @@ +import api from '@/api/axios' +const unwrap = ({ data }) => data +export const getTickets = (params = {}) => api.get('/tickets', { params }).then(unwrap) +export const getTicket = (id) => api.get(`/tickets/${id}`).then(unwrap) +export const getTicketOptions = () => api.get('/tickets/options').then(unwrap) +export const createTicket = (payload) => api.post('/tickets', payload).then(unwrap) +export const updateTicket = (id, payload) => api.put(`/tickets/${id}`, payload).then(unwrap) +export const cancelTicket = (id, reason) => api.post(`/tickets/${id}/cancel`, { reason }).then(unwrap) +export const ticketAction = (id, action, payload = {}) => api.post(`/tickets/${id}/${action}`, payload).then(unwrap) +export const getTicketHistory = (id) => api.get(`/tickets/${id}/workflow`).then(unwrap) +export const addTicketMaterial = (id, payload) => api.post(`/tickets/${id}/materials`, payload).then(unwrap) +export const deleteTicketMaterial = (id, materialId) => api.delete(`/tickets/${id}/materials/${materialId}`).then(unwrap) +export const getTicketMaterialOptions = (id) => api.get(`/tickets/${id}/material-options`).then(unwrap) +export const saveInstallationStep = (id,payload) => api.post(`/tickets/${id}/installation-step`,payload).then(unwrap) +export const getInstallationPorts = (id,node) => api.get(`/tickets/${id}/installation-ports/${node}`).then(unwrap) diff --git a/src/services/warehouseService.js b/src/services/warehouseService.js new file mode 100644 index 00000000..bf2515b9 --- /dev/null +++ b/src/services/warehouseService.js @@ -0,0 +1,23 @@ +import api from '@/api/axios' +export const getWarehouseItems=(params={})=>api.get('/warehouse/items',{params}).then(r=>r.data) +export const createWarehouseItem=(data)=>api.post('/warehouse/items',data).then(r=>r.data) +export const receiveWarehouseItem=(id,data)=>api.post(`/warehouse/items/${id}/receive`,data).then(r=>r.data) +export const getWarehouseOptions=()=>api.get('/warehouse/options').then(r=>r.data) +export const issueWarehouseBarcode=(id,data)=>api.post(`/warehouse/barcodes/${id}/issue`,data).then(r=>r.data) +export const getItemCodes=(params={})=>api.get('/warehouse/item-codes',{params}).then(r=>r.data) +export const createItemCode=(data)=>api.post('/warehouse/item-codes',data).then(r=>r.data) +export const updateItemCode=(id,data)=>api.put(`/warehouse/item-codes/${id}`,data).then(r=>r.data) +export const deleteItemCode=(id)=>api.delete(`/warehouse/item-codes/${id}`).then(r=>r.data) +export const getTechnicianInventory=(params={})=>api.get('/warehouse/technician-inventory',{params}).then(r=>r.data) +export const returnTechnicianInventory=(data)=>api.post('/warehouse/technician-inventory/return',data).then(r=>r.data) +export const getPendingItems=(params={})=>api.get('/warehouse/pending',{params:{...params,status:'draft'}}).then(r=>r.data) +export const createPendingItem=(data)=>api.post('/warehouse/pending',data).then(r=>r.data) +export const saveItemSerials=(id,barcodes)=>api.put(`/warehouse/pending/${id}/serials`,{barcodes}).then(r=>r.data) +export const postPendingItem=(id)=>api.post(`/warehouse/pending/${id}/post`).then(r=>r.data) +export const getDepletedItems=(params={})=>api.get('/warehouse/depleted',{params:{...params,status:'depleted'}}).then(r=>r.data) +export const getWarehouseBatch=(id)=>api.get(`/warehouse/batches/${id}`).then(r=>r.data) +export const getBarcodeMovements=(itemId,barcodeId)=>api.get(`/warehouse/batches/${itemId}/barcodes/${barcodeId}/movements`).then(r=>r.data) +export const getWarehouses=(params={})=>api.get('/warehouse/locations',{params}).then(r=>r.data) +export const createWarehouse=(data)=>api.post('/warehouse/locations',data).then(r=>r.data) +export const updateWarehouse=(id,data)=>api.put(`/warehouse/locations/${id}`,data).then(r=>r.data) +export const deleteWarehouse=(id)=>api.delete(`/warehouse/locations/${id}`).then(r=>r.data) diff --git a/src/views/customers/CustomerFormPage.vue b/src/views/customers/CustomerFormPage.vue index 2a86ea21..eaed8fce 100644 --- a/src/views/customers/CustomerFormPage.vue +++ b/src/views/customers/CustomerFormPage.vue @@ -1,11 +1,27 @@ @@ -179,112 +426,486 @@ onMounted(initialize)
- ← Kembali + ← Kembali

{{ isEdit ? 'Lengkapi Data Customer' : 'Registrasi Customer' }}

-

Identitas, lokasi pemasangan, layanan, dan dokumentasi customer.

+

+ Identitas, lokasi pemasangan, layanan, dan dokumentasi customer. +

- - {{ saving ? 'Menyimpan...' : 'Simpan Customer' }} + + {{ saving ? 'Menyimpan...' : registrationLayout === 'detail' ? 'Simpan Persiapan' : 'Simpan Registrasi' }}
-
Memuat data...
- +
+ Memuat data... +
+ + - Informasi Customer - - Tenant - ID Customer - Nama Lengkap - NIK/Identitas - Email - WhatsApp - - + Informasi Customer + + Tenant + + Nama Lengkap + NIK/Identitas + Email + WhatsApp + Sales / Penanggung Jawab + + + - Lokasi Pemasangan - - Alamat - Provinsi - Kabupaten - Kecamatan - Desa - -
- -
-
Latitude
-
Longitude
- Gunakan Lokasi Saya + Lokasi Pemasangan + + Alamat + Provinsi + + Kabupaten + + Kecamatan + + Desa + + +
+ +
+
+ Latitude +
+
+ Longitude +
+ Gunakan Lokasi Saya +
-
- + - Dokumentasi Lokasi{{ gallery.length }}/20 gambar - - - - Kategori - Keterangan - Jadikan CoverHapus - - + Dokumentasi{{ gallery.length }}/20 gambar + + + + Kategori + Keterangan + Jadikan CoverHapus + +
- Layanan Customer -
Profile Paket
-
NAS Mikrotik
-
Profile Tagihan
-
Username Eksternal
-
Tahap Order
-
Catatan
-
- {{ saving ? 'Menyimpan...' : 'Simpan Customer' }} + Paket BerlanggananProfile Paket + + Isi Data dari KTP{{ installationSetting.ocr_provider === 'gemini' ? 'Gemini AI' : 'OpenAI GPT' }} + +
Foto KTP untuk OCR
+ + {{ ocrLoading ? 'Membaca KTP...' : 'Foto / Upload KTP' }} + AI akan membantu mengisi NIK, nama, alamat, dan wilayah. Data tetap harus diperiksa sebelum disimpan. +
+
+ Layanan Customer +
+ ODP / Topologi + +
+
+ Port ODP Tersedia + Port yang sudah digunakan tidak ditampilkan. +
+
+ Profile Paket + +
+
+ NAS Mikrotik + +
+
+ Profile Tagihan + +
+
+ Catatan +
+ {{ saving ? 'Menyimpan...' : registrationLayout === 'detail' ? 'Simpan Persiapan' : 'Simpan Registrasi' }} + Aktivasi Customer
+ + Aktivasi Customer +
+
+
Username Koneksi
+
Password Koneksi
+
Serial Number
+
MAC Address
+ Data persiapan akan disimpan terlebih dahulu, kemudian akun customer dibuat pada NAS. +
+ BatalAktifkan Sekarang +
diff --git a/src/views/customers/Customers.vue b/src/views/customers/Customers.vue index e3a36b18..47199821 100644 --- a/src/views/customers/Customers.vue +++ b/src/views/customers/Customers.vue @@ -3,13 +3,26 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue' import { useAuthStore } from '@/stores/auth' +import { useMenuStore } from '@/stores/menu' import { getTenants } from '@/services/tenantService' import { - activateCustomer, createCustomer, deactivateCustomer, forceDeleteCustomer, - getCustomer, getCustomerOptions, getCustomers, restoreCustomer, trashCustomer, updateCustomer, + activateCustomer, + createCustomer, + deactivateCustomer, + forceDeleteCustomer, + getCustomer, + getCustomerOptions, + getCustomers, + restoreCustomer, + trashCustomer, + cancelCustomerOrder, + updateCustomer, } from '@/services/customerService' import { - getDesaOptions, getKabupatenOptions, getKecamatanOptions, getProvinsiOptions, + getDesaOptions, + getKabupatenOptions, + getKecamatanOptions, + getProvinsiOptions, } from '@/services/wilayahService' import { showConfirm, showError, showSuccess } from '@/utils/swal.js' import { getTemporaryFileUrl } from '@/services/fileService' @@ -17,106 +30,190 @@ import { getTemporaryFileUrl } from '@/services/fileService' const route = useRoute() const router = useRouter() const authStore = useAuthStore() +const menuStore = useMenuStore() const scope = computed(() => route.meta.customerScope) -const scopeConfig = computed(() => ({ - orders: ['List Order', 'Pendaftaran dan aktivasi customer'], - active: ['Customer Aktif', 'Daftar customer berstatus aktif'], - inactive: ['Customer Tidak Aktif', 'Daftar customer yang dinonaktifkan'], - unmanaged: ['Customer Unmanage', 'Koneksi aktif yang belum terdaftar sebagai customer'], - trash: ['Sampah Customer', 'Customer terhapus yang masih dapat dipulihkan'], -})[scope.value]) -const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin') +const scopeConfig = computed( + () => + ({ + orders: ['List Order', 'Pendaftaran dan aktivasi customer'], + active: ['Customer Aktif', 'Daftar customer berstatus aktif'], + inactive: ['Customer Tidak Aktif', 'Daftar customer yang dinonaktifkan'], + unmanaged: ['Customer Unmanage', 'Koneksi aktif yang belum terdaftar sebagai customer'], + trash: ['Sampah Customer', 'Customer terhapus yang masih dapat dipulihkan'], + })[scope.value], +) +const isMaster = computed( + () => authStore.user?.is_master || authStore.user?.access_level === 'master_admin', +) +const canActivate = computed(() => { + if (isMaster.value || authStore.user?.access_level === 'tenant_owner') return true + return menuStore.accessibleMenus.find((menu) => menu.slug === 'customers-orders')?.permissions?.can_activate === true +}) +const orderPermissions = computed(() => menuStore.accessibleMenus.find((menu) => menu.slug === 'customers-orders')?.permissions || {}) +const isTenantOwner = computed(() => authStore.user?.access_level === 'tenant_owner' || authStore.activeTenant?.role === 'tenant_owner') +const canCreate = computed(() => isMaster.value || isTenantOwner.value || orderPermissions.value.can_create === true) +const canDeleteOrder = computed(() => isMaster.value || isTenantOwner.value || orderPermissions.value.can_delete === true) -const loading = ref(true), loadedOnce = ref(false), saving = ref(false) -const items = ref([]), modal = ref(false), detailModal = ref(false), editingId = ref(null), detail = ref(null) -const tenants = ref([]), profiles = ref([]), mikrotiks = ref([]) -const provinsi = ref([]), kabupaten = ref([]), kecamatan = ref([]), desa = ref([]) +const loading = ref(true), + loadedOnce = ref(false), + saving = ref(false) +const items = ref([]), + modal = ref(false), + detailModal = ref(false), + editingId = ref(null), + detail = ref(null) +const activationModal = ref(false), + activationCustomer = ref(null) +const installationSetting = ref({ activation_mode: 'list_order' }) +const tenants = ref([]), + profiles = ref([]), + mikrotiks = ref([]) +const provinsi = ref([]), + kabupaten = ref([]), + kecamatan = ref([]), + desa = ref([]) const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 }) const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' }) const form = reactive({}) +const activationForm = reactive({ external_username: '', external_password: '' }) const columns = computed(() => [ { key: 'image', label: 'Gambar', sortable: false }, { key: 'customer_code', label: 'ID Customer' }, { key: 'name', label: 'Nama' }, - { key: scope.value === 'unmanaged' ? 'external_username' : 'whatsapp_number', label: scope.value === 'unmanaged' ? 'Username' : 'WhatsApp' }, + { + key: scope.value === 'unmanaged' ? 'external_username' : 'whatsapp_number', + label: scope.value === 'unmanaged' ? 'Username' : 'WhatsApp', + }, { key: 'package_label', label: 'Paket', sortable: false }, { key: 'stage_label', label: 'Status', sortable: false }, ]) const actions = computed(() => { - if (scope.value === 'trash') return [ - { key: 'detail', label: 'Detail', color: 'primary' }, - { key: 'restore', label: 'Pulihkan', color: 'success' }, - { key: 'force-delete', label: 'Hapus Permanen', color: 'danger' }, - ] + if (scope.value === 'trash') + return [ + { key: 'detail', label: 'Detail', color: 'primary' }, + { key: 'restore', label: 'Pulihkan', color: 'success' }, + { key: 'force-delete', label: 'Hapus Permanen', color: 'danger' }, + ] const result = [{ key: 'detail', label: 'Detail', color: 'primary' }] - if (scope.value === 'orders') result.push( - { key: 'edit', label: 'Lengkapi Data', color: 'success' }, - { key: 'activate', label: 'Aktivasi', color: 'info' }, - ) - if (scope.value === 'active') result.push({ key: 'deactivate', label: 'Nonaktifkan', color: 'warning' }) + if (scope.value === 'orders') + { + if (canCreate.value || canActivate.value) result.push({ key: 'edit', label: 'Proses Pemasangan', color: 'success' }) + if (canDeleteOrder.value) result.push({ key: 'cancel-order', label: 'Hapus', color: 'danger' }) + } + if (scope.value === 'active') + result.push({ key: 'deactivate', label: 'Nonaktifkan', color: 'warning' }) if (scope.value === 'inactive') result.push({ key: 'trash', label: 'Ke Sampah', color: 'danger' }) return result }) function emptyForm() { return { - 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: '', order_stage: 'registration', notes: '', + 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: '', + order_stage: 'registration', + notes: '', } } function normalize(item) { - const stages = { registration: 'Registrasi', data_completion: 'Melengkapi Data', ready_activation: 'Siap Aktivasi', activation_failed: 'Aktivasi Gagal' } + const stages = { + registration: 'Registrasi', + data_completion: 'Melengkapi Data', + ready_activation: 'Siap Aktivasi', + activation_failed: 'Aktivasi Gagal', + } return { ...item, image: '/customer-default.svg', package_label: item.package_profile?.name || '-', - stage_label: scope.value === 'orders' ? stages[item.order_stage] || item.order_stage : ({ active: 'Aktif', inactive: 'Tidak Aktif', unmanaged: 'Unmanage' })[item.status] || item.status, + stage_label: + scope.value === 'orders' + ? stages[item.order_stage] || item.order_stage + : { active: 'Aktif', inactive: 'Tidak Aktif', unmanaged: 'Unmanage' }[item.status] || + item.status, } } async function hydrateCoverImages(rows) { - await Promise.allSettled(rows.map(async (item) => { - if (!item.cover_image?.uuid) return - const response = await getTemporaryFileUrl(item.cover_image.uuid) - item.image = response?.data?.url || '/customer-default.svg' - })) + await Promise.allSettled( + rows.map(async (item) => { + if (!item.cover_image?.uuid) return + const response = await getTemporaryFileUrl(item.cover_image.uuid) + item.image = response?.data?.url || '/customer-default.svg' + }), + ) } function optionRows(response) { return Array.isArray(response?.data) ? response.data : [] } async function fetchData(params = query.value) { - query.value = { ...query.value, ...params }; loading.value = true + query.value = { ...query.value, ...params } + loading.value = true try { const response = await getCustomers(scope.value, query.value) - const rows = response?.data?.data || []; items.value = rows.map(normalize) + const rows = response?.data?.data || [] + items.value = rows.map(normalize) hydrateCoverImages(items.value) - pagination.value = { current_page: response?.data?.current_page || 1, last_page: response?.data?.last_page || 1, per_page: response?.data?.per_page || 10, total: response?.data?.total || rows.length } - } catch (error) { showError(error?.response?.data?.message || 'Gagal memuat customer.') } - finally { loading.value = false; loadedOnce.value = true } + pagination.value = { + current_page: response?.data?.current_page || 1, + last_page: response?.data?.last_page || 1, + per_page: response?.data?.per_page || 10, + total: response?.data?.total || rows.length, + } + } catch (error) { + showError(error?.response?.data?.message || 'Gagal memuat customer.') + } finally { + loading.value = false + loadedOnce.value = true + } } async function loadOptions() { try { - const calls = [ - getProvinsiOptions(), - getCustomerOptions(), - ] - if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' })) + const calls = [getProvinsiOptions(), getCustomerOptions()] + if (isMaster.value) + calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' })) const [p, options, tenantResponse] = await Promise.all(calls) - provinsi.value = optionRows(p); profiles.value = options?.data?.package_profiles || []; mikrotiks.value = options?.data?.nas_mikrotiks || [] + provinsi.value = optionRows(p) + profiles.value = options?.data?.package_profiles || [] + mikrotiks.value = options?.data?.nas_mikrotiks || [] + installationSetting.value = options?.data?.installation_setting || { activation_mode: 'list_order' } tenants.value = tenantResponse?.data?.data || [] - } catch (error) { showError(error?.response?.data?.message || 'Gagal memuat data pilihan.') } + } catch (error) { + showError(error?.response?.data?.message || 'Gagal memuat data pilihan.') + } } -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 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)), +) 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)) : [] + kecamatan.value = form.kabupaten_id + ? optionRows(await getKecamatanOptions(form.kabupaten_id)) + : [] } async function loadDesa(reset = true) { if (reset) form.desa_id = '' @@ -135,24 +232,28 @@ async function onKecamatanChange(event) { await loadDesa() } async function openForm(item = null) { - return router.push( - item?.id - ? `/customers/orders/${item.id}/edit` - : '/customers/orders/create', - ) + return router.push(item?.id ? `/customers/orders/${item.id}/edit` : '/customers/orders/create') } function payload() { - return Object.fromEntries(Object.entries(form).map(([key, value]) => [key, value === '' ? null : value])) + return Object.fromEntries( + Object.entries(form).map(([key, value]) => [key, value === '' ? null : value]), + ) } 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 { - if (editingId.value) await updateCustomer(editingId.value, payload()); else await createCustomer(payload()) - modal.value = false; showSuccess('Data customer berhasil disimpan.'); await fetchData() - } catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan customer.') } - finally { saving.value = false } + if (editingId.value) await updateCustomer(editingId.value, payload()) + else await createCustomer(payload()) + modal.value = false + showSuccess('Data customer berhasil disimpan.') + await fetchData() + } catch (error) { + showError(error?.response?.data?.message || 'Gagal menyimpan customer.') + } finally { + saving.value = false + } } async function showDetail(item) { return router.push(`/customers/${scope.value}/${item.id}/detail`) @@ -160,58 +261,283 @@ async function showDetail(item) { async function runAction(action, item) { if (action === 'detail') return showDetail(item) if (action === 'edit') return openForm(item) - const labels = { activate: 'aktifkan', deactivate: 'nonaktifkan', trash: 'pindahkan ke Sampah', restore: 'pulihkan', 'force-delete': 'hapus permanen' } - const result = await showConfirm('Konfirmasi', `Yakin ingin ${labels[action]} customer "${item.name}"?`) + if (action === 'activate') { + activationCustomer.value = item + activationForm.external_username = item.external_username || item.customer_code || '' + activationForm.external_password = '' + activationModal.value = true + return + } + const labels = { + deactivate: 'nonaktifkan', + trash: 'pindahkan ke Sampah', + restore: 'pulihkan', + 'force-delete': 'hapus permanen', + 'cancel-order': 'hapus dari List Order dan pindahkan ke Sampah', + } + const result = await showConfirm( + 'Konfirmasi', + `Yakin ingin ${labels[action]} customer "${item.name}"?`, + ) if (!result.isConfirmed) return try { - if (action === 'activate') await activateCustomer(item.id, { package_profile_id: item.package_profile_id, nas_mikrotik_id: item.nas_mikrotik_id, billing_profile_id: item.billing_profile_id, external_username: item.external_username }) if (action === 'deactivate') await deactivateCustomer(item.id) if (action === 'trash') await trashCustomer(item.id) + if (action === 'cancel-order') await cancelCustomerOrder(item.id) if (action === 'restore') await restoreCustomer(item.id) if (action === 'force-delete') await forceDeleteCustomer(item.id) - showSuccess('Status customer berhasil diperbarui.'); await fetchData() - } catch (error) { showError(error?.response?.data?.message || 'Proses customer gagal.') } + showSuccess('Status customer berhasil diperbarui.') + await fetchData() + } catch (error) { + showError(error?.response?.data?.message || 'Proses customer gagal.') + } } -watch(scope, () => { items.value = []; loadedOnce.value = false; query.value.page = 1; fetchData() }) -onMounted(fetchData) +async function submitActivation() { + const customer = activationCustomer.value + if (!activationForm.external_username || !activationForm.external_password) + return showError('Username dan password RouterOS wajib diisi.') + if (!customer.package_profile_id || !customer.nas_mikrotik_id || !customer.billing_profile_id) + return showError('Profile paket, NAS Mikrotik, dan profile tagihan harus dilengkapi dahulu.') + saving.value = true + try { + await activateCustomer(customer.id, { + activation_source: 'list_order', + package_profile_id: customer.package_profile_id, + nas_mikrotik_id: customer.nas_mikrotik_id, + billing_profile_id: customer.billing_profile_id, + external_username: activationForm.external_username, + external_password: activationForm.external_password, + }) + activationModal.value = false + showSuccess('Akun RouterOS berhasil dibuat dan customer sudah aktif.') + await fetchData() + } catch (error) { + showError( + error?.response?.data?.message || 'Aktivasi RouterOS gagal. Customer belum diaktifkan.', + ) + } finally { + saving.value = false + } +} +watch(scope, () => { + items.value = [] + loadedOnce.value = false + query.value.page = 1 + fetchData() +}) +onMounted(() => Promise.all([fetchData(), loadOptions()])) diff --git a/src/views/group-menus/GroupMenuPermissions.vue b/src/views/group-menus/GroupMenuPermissions.vue index cca0cbee..61c053bb 100644 --- a/src/views/group-menus/GroupMenuPermissions.vue +++ b/src/views/group-menus/GroupMenuPermissions.vue @@ -24,6 +24,7 @@ const permissionKeys = [ 'can_update', 'can_delete', 'can_approve', + 'can_activate', 'can_export', ] const permissionLabels = { @@ -32,13 +33,12 @@ const permissionLabels = { can_update: 'Update', can_delete: 'Delete', can_approve: 'Approve', + can_activate: 'Aktivasi', can_export: 'Export', } const isMasterAdmin = computed( - () => - authStore.user?.is_master === true || - authStore.user?.access_level === 'master_admin', + () => authStore.user?.is_master === true || authStore.user?.access_level === 'master_admin', ) const canManageGroup = computed( () => !!menuGroup.value && (isMasterAdmin.value || !menuGroup.value.is_system), @@ -95,6 +95,7 @@ function normalizeMenus(rawMenus) { can_update: !hasChildren && availablePermissions.can_update && !!permissions.can_update, can_delete: !hasChildren && availablePermissions.can_delete && !!permissions.can_delete, can_approve: !hasChildren && availablePermissions.can_approve && !!permissions.can_approve, + can_activate: !hasChildren && availablePermissions.can_activate && !!permissions.can_activate, can_export: !hasChildren && availablePermissions.can_export && !!permissions.can_export, } }) @@ -141,16 +142,17 @@ async function savePermissions() { saving.value = true try { await syncMenuGroupMenus(route.params.id, { - menus: menuPermissions.value.map((menu) => ({ - menu_id: menu.menu_id, - can_view: !!menu.can_view, - can_create: menu.has_children ? false : !!menu.can_create, - can_update: menu.has_children ? false : !!menu.can_update, - can_delete: menu.has_children ? false : !!menu.can_delete, - can_approve: menu.has_children ? false : !!menu.can_approve, - can_export: menu.has_children ? false : !!menu.can_export, - })), - }) + menus: menuPermissions.value.map((menu) => ({ + menu_id: menu.menu_id, + can_view: !!menu.can_view, + can_create: menu.has_children ? false : !!menu.can_create, + can_update: menu.has_children ? false : !!menu.can_update, + can_delete: menu.has_children ? false : !!menu.can_delete, + can_approve: menu.has_children ? false : !!menu.can_approve, + can_activate: menu.has_children ? false : !!menu.can_activate, + can_export: menu.has_children ? false : !!menu.can_export, + })), + }) showSuccess('Permission menu group berhasil disimpan.') } catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan permission menu group.') @@ -326,6 +328,34 @@ onMounted(loadData) .root-permission-list :deep(.form-check) { margin-bottom: 0; } +.workflow-card { + border-radius: 0.75rem; +} +.workflow-block + .workflow-block { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--cui-border-color); +} +.workflow-steps { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} +.workflow-step { + padding: 0.65rem; + border: 1px solid var(--cui-border-color); + border-radius: 0.6rem; +} +.step-name { + margin-bottom: 0.4rem; + font-size: 0.875rem; + font-weight: 600; +} +@media (max-width: 767.98px) { + .workflow-steps { + grid-template-columns: 1fr; + } +} .permission-list :deep(.form-check) { min-width: 4.7rem; diff --git a/src/views/nas/NasResourceView.vue b/src/views/nas/NasResourceView.vue index db0dd6bb..025baa5a 100644 --- a/src/views/nas/NasResourceView.vue +++ b/src/views/nas/NasResourceView.vue @@ -1,5 +1,5 @@ + + diff --git a/src/views/nas/RadiusInfrastructure.vue b/src/views/nas/RadiusInfrastructure.vue new file mode 100644 index 00000000..1972f69a --- /dev/null +++ b/src/views/nas/RadiusInfrastructure.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/views/nas/ScriptPolicies.vue b/src/views/nas/ScriptPolicies.vue new file mode 100644 index 00000000..94ed9a3c --- /dev/null +++ b/src/views/nas/ScriptPolicies.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/views/tenants/TenantSettings.vue b/src/views/tenants/TenantSettings.vue new file mode 100644 index 00000000..c807e2fb --- /dev/null +++ b/src/views/tenants/TenantSettings.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/src/views/tenants/Tenants.vue b/src/views/tenants/Tenants.vue index 539360cc..e2f6f803 100644 --- a/src/views/tenants/Tenants.vue +++ b/src/views/tenants/Tenants.vue @@ -1,5 +1,8 @@ @@ -240,7 +252,7 @@ function onActionClick({ action, item }) { {{ loading ? 'Loading...' : 'Refresh' }} - + Tambah Tenant + + Tambah Tenant
diff --git a/src/views/tickets/Approved.vue b/src/views/tickets/Approved.vue index ca7ed532..67d63e9b 100644 --- a/src/views/tickets/Approved.vue +++ b/src/views/tickets/Approved.vue @@ -1,55 +1,2 @@ - - - + + diff --git a/src/views/tickets/Rejected.vue b/src/views/tickets/Rejected.vue index 60dd910f..9cbc935f 100644 --- a/src/views/tickets/Rejected.vue +++ b/src/views/tickets/Rejected.vue @@ -1,53 +1,2 @@ - - - + + diff --git a/src/views/tickets/Ticket.vue b/src/views/tickets/Ticket.vue index 9e16c3d0..edc306d7 100644 --- a/src/views/tickets/Ticket.vue +++ b/src/views/tickets/Ticket.vue @@ -1,565 +1,499 @@ - + diff --git a/src/views/tickets/TicketDetailPage.vue b/src/views/tickets/TicketDetailPage.vue new file mode 100644 index 00000000..51872c9f --- /dev/null +++ b/src/views/tickets/TicketDetailPage.vue @@ -0,0 +1,544 @@ + + + diff --git a/src/views/tickets/TicketStatusList.vue b/src/views/tickets/TicketStatusList.vue new file mode 100644 index 00000000..f7e5d52e --- /dev/null +++ b/src/views/tickets/TicketStatusList.vue @@ -0,0 +1,12 @@ + + diff --git a/src/views/warehouse/ActiveItems.vue b/src/views/warehouse/ActiveItems.vue new file mode 100644 index 00000000..c43adf65 --- /dev/null +++ b/src/views/warehouse/ActiveItems.vue @@ -0,0 +1,134 @@ + + diff --git a/src/views/warehouse/DepletedItems.vue b/src/views/warehouse/DepletedItems.vue new file mode 100644 index 00000000..92cdb3f1 --- /dev/null +++ b/src/views/warehouse/DepletedItems.vue @@ -0,0 +1,59 @@ + + diff --git a/src/views/warehouse/ItemCodes.vue b/src/views/warehouse/ItemCodes.vue new file mode 100644 index 00000000..bdb878c8 --- /dev/null +++ b/src/views/warehouse/ItemCodes.vue @@ -0,0 +1,17 @@ + + diff --git a/src/views/warehouse/PendingItems.vue b/src/views/warehouse/PendingItems.vue new file mode 100644 index 00000000..3e3f2faf --- /dev/null +++ b/src/views/warehouse/PendingItems.vue @@ -0,0 +1,207 @@ + + diff --git a/src/views/warehouse/TechnicianInventory.vue b/src/views/warehouse/TechnicianInventory.vue new file mode 100644 index 00000000..9cfd07dd --- /dev/null +++ b/src/views/warehouse/TechnicianInventory.vue @@ -0,0 +1,13 @@ + + diff --git a/src/views/warehouse/Warehouse.vue b/src/views/warehouse/Warehouse.vue new file mode 100644 index 00000000..24872531 --- /dev/null +++ b/src/views/warehouse/Warehouse.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/views/warehouse/WarehouseDetail.vue b/src/views/warehouse/WarehouseDetail.vue new file mode 100644 index 00000000..c3edbe78 --- /dev/null +++ b/src/views/warehouse/WarehouseDetail.vue @@ -0,0 +1,127 @@ + + diff --git a/src/views/warehouse/Warehouses.vue b/src/views/warehouse/Warehouses.vue new file mode 100644 index 00000000..1cccbb78 --- /dev/null +++ b/src/views/warehouse/Warehouses.vue @@ -0,0 +1 @@ +