big update all menus
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:
@@ -14,7 +14,6 @@
|
||||
<option v-for="opt in perPageOptions" :key="opt" :value="String(opt)">{{ opt }}</option>
|
||||
</CFormSelect>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="toolbar-search-wrap">
|
||||
@@ -42,22 +41,40 @@
|
||||
<div v-if="filterOpen" class="brdvx-filter-panel">
|
||||
<div class="brdvx-filter-panel-head">
|
||||
<strong>Filter Data</strong>
|
||||
<button type="button" aria-label="Tutup filter" @click="filterOpen = false">×</button>
|
||||
<button type="button" aria-label="Tutup filter" @click="filterOpen = false">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="brdvx-filter-fields">
|
||||
<div v-for="field in filterFields" :key="field.key">
|
||||
<CFormLabel>{{ field.label }}</CFormLabel>
|
||||
<CFormSelect v-if="field.type === 'select'" v-model="filterValues[field.key]" size="sm">
|
||||
<CFormSelect
|
||||
v-if="field.type === 'select'"
|
||||
v-model="filterValues[field.key]"
|
||||
size="sm"
|
||||
>
|
||||
<option value="">{{ field.placeholder || 'Semua' }}</option>
|
||||
<option v-for="option in field.options || []" :key="filterOptionValue(option)" :value="filterOptionValue(option)">
|
||||
<option
|
||||
v-for="option in field.options || []"
|
||||
:key="filterOptionValue(option)"
|
||||
:value="filterOptionValue(option)"
|
||||
>
|
||||
{{ filterOptionLabel(option) }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
<CFormInput v-else v-model="filterValues[field.key]" :type="field.type || 'text'" size="sm" :placeholder="field.placeholder || ''" />
|
||||
<CFormInput
|
||||
v-else
|
||||
v-model="filterValues[field.key]"
|
||||
:type="field.type || 'text'"
|
||||
size="sm"
|
||||
:placeholder="field.placeholder || ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brdvx-filter-actions">
|
||||
<CButton color="secondary" variant="outline" size="sm" @click="resetFilters">Reset</CButton>
|
||||
<CButton color="secondary" variant="outline" size="sm" @click="resetFilters"
|
||||
>Reset</CButton
|
||||
>
|
||||
<CButton color="primary" size="sm" @click="applyFilters">Terapkan</CButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +115,9 @@
|
||||
<div class="mobile-fields">
|
||||
<div v-for="col in cardColumns(item)" :key="col.key" class="mobile-field">
|
||||
<span class="field-label">{{ col.label }}</span>
|
||||
<span class="field-value">{{ item[col.key] }}</span>
|
||||
<slot :name="`cell-${col.key}`" :item="item" :value="item[col.key]" :column="col">
|
||||
<span class="field-value">{{ item[col.key] }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,7 +197,14 @@
|
||||
:key="`${resolveRowKey(item, rowIndex)}-left-${col.key}`"
|
||||
class="table-list-cell"
|
||||
>
|
||||
<span class="table-list-value">{{ item[col.key] }}</span>
|
||||
<slot
|
||||
:name="`cell-${col.key}`"
|
||||
:item="item"
|
||||
:value="item[col.key]"
|
||||
:column="col"
|
||||
>
|
||||
<span class="table-list-value">{{ item[col.key] }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-col">
|
||||
@@ -187,7 +213,14 @@
|
||||
:key="`${resolveRowKey(item, rowIndex)}-right-${col.key}`"
|
||||
class="table-list-cell"
|
||||
>
|
||||
<span class="table-list-value">{{ item[col.key] }}</span>
|
||||
<slot
|
||||
:name="`cell-${col.key}`"
|
||||
:item="item"
|
||||
:value="item[col.key]"
|
||||
:column="col"
|
||||
>
|
||||
<span class="table-list-value">{{ item[col.key] }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +231,7 @@
|
||||
:key="`${resolveRowKey(item, rowIndex)}-${col.key}`"
|
||||
class="table-list-cell"
|
||||
>
|
||||
<span class="table-list-value row-pair">
|
||||
<div class="table-list-value row-pair">
|
||||
<template v-if="col.key === 'image' && item.image">
|
||||
<img
|
||||
:src="item.image"
|
||||
@@ -210,9 +243,16 @@
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="row-right no-label">{{ item[col.key] }}</span>
|
||||
<slot
|
||||
:name="`cell-${col.key}`"
|
||||
:item="item"
|
||||
:value="item[col.key]"
|
||||
:column="col"
|
||||
>
|
||||
<span class="row-right no-label">{{ item[col.key] }}</span>
|
||||
</slot>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,7 +263,11 @@
|
||||
size="sm"
|
||||
@click="toggleExpandRow(resolveRowKey(item, rowIndex))"
|
||||
>
|
||||
<CIcon :icon="isRowExpanded(resolveRowKey(item, rowIndex)) ? cilCaretTop : cilCaretBottom" />
|
||||
<CIcon
|
||||
:icon="
|
||||
isRowExpanded(resolveRowKey(item, rowIndex)) ? cilCaretTop : cilCaretBottom
|
||||
"
|
||||
/>
|
||||
</CButton>
|
||||
|
||||
<div v-if="!hideDefaultActions" class="table-actions">
|
||||
@@ -251,7 +295,9 @@
|
||||
class="hidden-item"
|
||||
>
|
||||
<span class="hidden-label">{{ col.label }}</span>
|
||||
<span class="hidden-value">{{ item[col.key] }}</span>
|
||||
<slot :name="`cell-${col.key}`" :item="item" :value="item[col.key]" :column="col">
|
||||
<span class="hidden-value">{{ item[col.key] }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</CCard>
|
||||
@@ -260,7 +306,9 @@
|
||||
|
||||
<div v-if="totalPages > 1" class="pagination-center">
|
||||
<CPagination align="center" aria-label="Page navigation">
|
||||
<CPaginationItem :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">Previous</CPaginationItem>
|
||||
<CPaginationItem :disabled="currentPage === 1" @click="goToPage(currentPage - 1)"
|
||||
>Previous</CPaginationItem
|
||||
>
|
||||
<CPaginationItem
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
@@ -269,7 +317,9 @@
|
||||
>
|
||||
{{ page }}
|
||||
</CPaginationItem>
|
||||
<CPaginationItem :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">Next</CPaginationItem>
|
||||
<CPaginationItem :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)"
|
||||
>Next</CPaginationItem
|
||||
>
|
||||
</CPagination>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
+428
-102
@@ -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()]))
|
||||
</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 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div><h5 class="mb-0 fw-semibold">{{ scopeConfig[0] }}</h5><small class="text-body-secondary">{{ scopeConfig[1] }}</small></div>
|
||||
<CButton v-if="scope === 'orders'" color="primary" size="sm" @click="openForm()">+ Daftarkan Customer</CButton>
|
||||
</div>
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
|
||||
<BaseResponsiveDataView v-else :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" row-key="id" @action-click="({ action, item }) => runAction(action, item)" @query-change="fetchData" />
|
||||
</div></CCol></CRow>
|
||||
<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 d-flex justify-content-between align-items-center flex-wrap gap-2"
|
||||
>
|
||||
<div>
|
||||
<h5 class="mb-0 fw-semibold">{{ scopeConfig[0] }}</h5>
|
||||
<small class="text-body-secondary">{{ scopeConfig[1] }}</small>
|
||||
</div>
|
||||
<CButton v-if="scope === 'orders' && canCreate" color="primary" size="sm" @click="openForm()"
|
||||
>+ Daftarkan Customer</CButton
|
||||
>
|
||||
</div>
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state">
|
||||
<CSpinner color="primary" />
|
||||
<p class="brdvx-loading-text">Memuat data...</p>
|
||||
</div>
|
||||
<BaseResponsiveDataView
|
||||
v-else
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
server-side
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
:actions="actions"
|
||||
row-key="id"
|
||||
@action-click="({ action, item }) => runAction(action, item)"
|
||||
@query-change="fetchData"
|
||||
/></div></CCol
|
||||
></CRow>
|
||||
|
||||
<CModal :visible="modal" @close="modal = false" size="xl">
|
||||
<CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editingId ? 'Lengkapi Data' : 'Pendaftaran' }} Customer</CModalTitle></CModalHeader>
|
||||
<CModalBody><CRow class="g-3">
|
||||
<CCol v-if="isMaster" :md="4"><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="4"><CFormLabel>ID Customer</CFormLabel><CFormInput v-model="form.customer_code" placeholder="Otomatis jika kosong" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>Nama</CFormLabel><CFormInput v-model="form.name" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>NIK/Identitas</CFormLabel><CFormInput v-model="form.identity_number" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>Email</CFormLabel><CFormInput v-model="form.email" type="email" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>WhatsApp</CFormLabel><CFormInput v-model="form.whatsapp_number" /></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Alamat Pemasangan</CFormLabel><CFormTextarea v-model="form.installation_address" rows="2" /></CCol>
|
||||
<CCol :md="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="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="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="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>
|
||||
<CCol :md="4"><CFormLabel>Profile Paket</CFormLabel><CFormSelect v-model="form.package_profile_id"><option value="">Pilih paket</option><option v-for="x in tenantProfiles" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><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></CCol>
|
||||
<CCol :md="4"><CFormLabel>Username Eksternal</CFormLabel><CFormInput v-model="form.external_username" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Latitude</CFormLabel><CFormInput v-model="form.latitude" type="number" step="any" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Longitude</CFormLabel><CFormInput v-model="form.longitude" type="number" step="any" /></CCol>
|
||||
<CCol :md="6"><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></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2" /></CCol>
|
||||
</CRow></CModalBody>
|
||||
<CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter>
|
||||
<CModalHeader dismiss @close="modal = false"
|
||||
><CModalTitle
|
||||
>{{ editingId ? 'Lengkapi Data' : 'Pendaftaran' }} Customer</CModalTitle
|
||||
></CModalHeader
|
||||
>
|
||||
<CModalBody
|
||||
><CRow class="g-3">
|
||||
<CCol v-if="isMaster" :md="4"
|
||||
><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="4"
|
||||
><CFormLabel>ID Customer</CFormLabel
|
||||
><CFormInput v-model="form.customer_code" placeholder="Otomatis jika kosong"
|
||||
/></CCol>
|
||||
<CCol :md="4"><CFormLabel>Nama</CFormLabel><CFormInput v-model="form.name" /></CCol>
|
||||
<CCol :md="4"
|
||||
><CFormLabel>NIK/Identitas</CFormLabel><CFormInput v-model="form.identity_number"
|
||||
/></CCol>
|
||||
<CCol :md="4"
|
||||
><CFormLabel>Email</CFormLabel><CFormInput v-model="form.email" type="email"
|
||||
/></CCol>
|
||||
<CCol :md="4"
|
||||
><CFormLabel>WhatsApp</CFormLabel><CFormInput v-model="form.whatsapp_number"
|
||||
/></CCol>
|
||||
<CCol :xs="12"
|
||||
><CFormLabel>Alamat Pemasangan</CFormLabel
|
||||
><CFormTextarea v-model="form.installation_address" rows="2"
|
||||
/></CCol>
|
||||
<CCol :md="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="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="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="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
|
||||
>
|
||||
<CCol :md="4"
|
||||
><CFormLabel>Profile Paket</CFormLabel
|
||||
><CFormSelect v-model="form.package_profile_id"
|
||||
><option value="">Pilih paket</option>
|
||||
<option v-for="x in tenantProfiles" :key="x.id" :value="x.id">
|
||||
{{ x.name }}
|
||||
</option></CFormSelect
|
||||
></CCol
|
||||
>
|
||||
<CCol :md="4"
|
||||
><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
|
||||
></CCol
|
||||
>
|
||||
<CCol :md="4"
|
||||
><CFormLabel>Username Eksternal</CFormLabel><CFormInput v-model="form.external_username"
|
||||
/></CCol>
|
||||
<CCol :md="3"
|
||||
><CFormLabel>Latitude</CFormLabel
|
||||
><CFormInput v-model="form.latitude" type="number" step="any"
|
||||
/></CCol>
|
||||
<CCol :md="3"
|
||||
><CFormLabel>Longitude</CFormLabel
|
||||
><CFormInput v-model="form.longitude" type="number" step="any"
|
||||
/></CCol>
|
||||
<CCol :md="6"
|
||||
><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
|
||||
></CCol
|
||||
>
|
||||
<CCol :xs="12"
|
||||
><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2"
|
||||
/></CCol> </CRow
|
||||
></CModalBody>
|
||||
<CModalFooter
|
||||
><CButton color="secondary" @click="modal = false">Batal</CButton
|
||||
><CButton color="primary" :disabled="saving" @click="save">{{
|
||||
saving ? 'Menyimpan...' : 'Simpan'
|
||||
}}</CButton></CModalFooter
|
||||
>
|
||||
</CModal>
|
||||
|
||||
<CModal :visible="detailModal" @close="detailModal = false" size="lg"><CModalHeader dismiss @close="detailModal = false"><CModalTitle>Detail Customer</CModalTitle></CModalHeader>
|
||||
<CModalBody v-if="detail"><CRow class="g-3"><CCol :md="6"><strong>{{ detail.customer_code }} — {{ detail.name }}</strong><div>{{ detail.email || '-' }}</div><div>{{ detail.whatsapp_number || '-' }}</div></CCol><CCol :md="6"><div class="text-body-secondary">Alamat Pemasangan</div><div>{{ detail.full_installation_address || '-' }}</div></CCol><CCol :md="6"><div class="text-body-secondary">Paket</div><div>{{ detail.package_profile?.name || '-' }}</div></CCol><CCol :md="6"><div class="text-body-secondary">NAS</div><div>{{ detail.nas_mikrotik?.name || '-' }}</div></CCol></CRow></CModalBody>
|
||||
<CModalFooter><CButton color="secondary" @click="detailModal = false">Tutup</CButton></CModalFooter></CModal>
|
||||
<CModal :visible="detailModal" @close="detailModal = false" size="lg"
|
||||
><CModalHeader dismiss @close="detailModal = false"
|
||||
><CModalTitle>Detail Customer</CModalTitle></CModalHeader
|
||||
>
|
||||
<CModalBody v-if="detail"
|
||||
><CRow class="g-3"
|
||||
><CCol :md="6"
|
||||
><strong>{{ detail.customer_code }} — {{ detail.name }}</strong>
|
||||
<div>{{ detail.email || '-' }}</div>
|
||||
<div>{{ detail.whatsapp_number || '-' }}</div></CCol
|
||||
><CCol :md="6"
|
||||
><div class="text-body-secondary">Alamat Pemasangan</div>
|
||||
<div>{{ detail.full_installation_address || '-' }}</div></CCol
|
||||
><CCol :md="6"
|
||||
><div class="text-body-secondary">Paket</div>
|
||||
<div>{{ detail.package_profile?.name || '-' }}</div></CCol
|
||||
><CCol :md="6"
|
||||
><div class="text-body-secondary">NAS</div>
|
||||
<div>{{ detail.nas_mikrotik?.name || '-' }}</div></CCol
|
||||
></CRow
|
||||
></CModalBody
|
||||
>
|
||||
<CModalFooter
|
||||
><CButton color="secondary" @click="detailModal = false">Tutup</CButton></CModalFooter
|
||||
></CModal
|
||||
>
|
||||
|
||||
<CModal :visible="activationModal" @close="activationModal = false">
|
||||
<CModalHeader dismiss @close="activationModal = false">
|
||||
<CModalTitle>Aktivasi Customer ke Mikrotik</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CAlert color="info">
|
||||
Koneksi RouterOS akan diuji dan akun dibuat terlebih dahulu. Customer hanya menjadi aktif
|
||||
jika seluruh proses di Mikrotik berhasil.
|
||||
</CAlert>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Customer</CFormLabel>
|
||||
<CFormInput :model-value="activationCustomer?.name" disabled />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Username PPPoE/Hotspot</CFormLabel>
|
||||
<CFormInput v-model="activationForm.external_username" autocomplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
<CFormLabel>Password PPPoE/Hotspot</CFormLabel>
|
||||
<CFormInput
|
||||
v-model="activationForm.external_password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="activationModal = false">Batal</CButton>
|
||||
<CButton color="success" :disabled="saving" @click="submitActivation">
|
||||
{{ saving ? 'Menghubungkan...' : 'Buat Akun & Aktifkan' }}
|
||||
</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
getNasResource,
|
||||
getNasResources,
|
||||
updateNasResource,
|
||||
testMikrotikConnection,
|
||||
getPackageProfileTargets,
|
||||
savePackageProfileTarget,
|
||||
syncPackageProfileTarget,
|
||||
getPackageProfileScriptOptions,
|
||||
} from '@/services/nasService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
@@ -23,18 +28,49 @@ const configs = {
|
||||
nameLabel: 'Nama Mikrotik',
|
||||
columns: [
|
||||
{ key: 'name', label: 'Nama' },
|
||||
{ key: 'connection_type_label', label: 'Koneksi', sortKey: 'connection_type' },
|
||||
{ key: 'integration_type_label', label: 'Jenis Integrasi', sortable: false },
|
||||
{ key: 'host', label: 'Host' },
|
||||
{ key: 'connection_status_label', label: 'Status Koneksi', sortable: false },
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 'host', label: 'Host / IP', required: true },
|
||||
{ key: 'api_port', label: 'API Port', type: 'number', show: (f) => f.connection_type === 'api' },
|
||||
{ key: 'api_username', label: 'API Username', show: (f) => f.connection_type === 'api' },
|
||||
{ key: 'api_password', label: 'API Password', type: 'password', secret: true, show: (f) => f.connection_type === 'api' },
|
||||
{ key: 'radius_auth_port', label: 'RADIUS Auth Port', type: 'number', show: (f) => f.connection_type === 'radius' },
|
||||
{ key: 'radius_accounting_port', label: 'RADIUS Accounting Port', type: 'number', show: (f) => f.connection_type === 'radius' },
|
||||
{ key: 'radius_secret', label: 'RADIUS Secret', type: 'password', secret: true, show: (f) => f.connection_type === 'radius' },
|
||||
{ key: 'backend_mode_override', label: 'Jenis Integrasi', type: 'select', options: [['routeros_api', 'RouterOS API'], ['platform_radius', 'RADIUS Global'], ['external_radius', 'RADIUS Eksternal']] },
|
||||
{ key: 'host', label: 'Host / IP Mikrotik', required: true },
|
||||
{ key: 'nas_identifier', label: 'NAS Identifier', show: (f) => f.backend_mode_override !== 'routeros_api' },
|
||||
{ key: 'nas_ip_address', label: 'IP NAS yang dikenali RADIUS', show: (f) => f.backend_mode_override !== 'routeros_api' },
|
||||
{ key: 'radius_host', label: 'Host RADIUS Eksternal', show: (f) => f.backend_mode_override === 'external_radius' },
|
||||
{
|
||||
key: 'api_port',
|
||||
label: 'API Port',
|
||||
type: 'number',
|
||||
},
|
||||
{ key: 'api_username', label: 'API Username' },
|
||||
{
|
||||
key: 'api_password',
|
||||
label: 'API Password',
|
||||
type: 'password',
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
key: 'radius_auth_port',
|
||||
label: 'RADIUS Auth Port',
|
||||
type: 'number',
|
||||
show: (f) => f.backend_mode_override !== 'routeros_api',
|
||||
},
|
||||
{
|
||||
key: 'radius_accounting_port',
|
||||
label: 'RADIUS Accounting Port',
|
||||
type: 'number',
|
||||
show: (f) => f.backend_mode_override !== 'routeros_api',
|
||||
},
|
||||
{ key: 'radius_coa_port', label: 'RADIUS CoA Port', type: 'number', show: (f) => f.backend_mode_override !== 'routeros_api' },
|
||||
{
|
||||
key: 'radius_secret',
|
||||
label: 'RADIUS Secret',
|
||||
type: 'password',
|
||||
secret: true,
|
||||
show: (f) => f.backend_mode_override !== 'routeros_api',
|
||||
},
|
||||
{ key: 'timeout', label: 'Timeout (detik)', type: 'number' },
|
||||
],
|
||||
},
|
||||
@@ -49,15 +85,38 @@ const configs = {
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 'service_type', label: 'Tipe Layanan', type: 'select', options: [['ppp', 'PPP'], ['hotspot', 'Hotspot'], ['radius', 'RADIUS']] },
|
||||
{ key: 'external_profile_name', label: 'Nama Profile Eksternal' },
|
||||
{
|
||||
key: 'service_type',
|
||||
label: 'Tipe Layanan',
|
||||
type: 'select',
|
||||
options: [
|
||||
['ppp', 'PPPoE Bulanan'],
|
||||
['hotspot', 'Hotspot'],
|
||||
['static_ip', 'Customer IP Static'],
|
||||
['dynamic_ip', 'Customer IP Dinamis'],
|
||||
],
|
||||
},
|
||||
{ key: 'profile_code', label: 'Kode Profile', required: true },
|
||||
{ key: 'usage_mode', label: 'Mode Hotspot', type: 'select', show: (f) => f.service_type === 'hotspot', options: [['periodic', 'Bulanan / Periodik'], ['one_time', 'Voucher Sekali Pakai'], ['recurring_member', 'Voucher Member']] },
|
||||
{ key: 'validity_start', label: 'Masa Aktif Dimulai', type: 'select', show: (f) => f.service_type === 'hotspot', options: [['activation', 'Saat Dibuat / Aktivasi'], ['first_login', 'Saat Login Pertama']] },
|
||||
{ key: 'validity_value', label: 'Durasi Masa Aktif', type: 'number', show: (f) => f.service_type === 'hotspot' },
|
||||
{ key: 'validity_unit', label: 'Satuan Durasi', type: 'select', show: (f) => f.service_type === 'hotspot', options: [['minutes', 'Menit'], ['hours', 'Jam'], ['days', 'Hari'], ['months', 'Bulan']] },
|
||||
{ key: 'uptime_limit_seconds', label: 'Batas Total Uptime (detik)', type: 'number', show: (f) => f.service_type === 'hotspot' && f.usage_mode !== 'periodic' },
|
||||
{ key: 'data_limit_bytes', label: 'Batas Total Data (byte)', type: 'number', show: (f) => f.service_type === 'hotspot' && f.usage_mode !== 'periodic' },
|
||||
{ key: 'grace_period_minutes', label: 'Grace Period (menit)', type: 'number', show: (f) => f.service_type === 'hotspot' },
|
||||
{ key: 'download_kbps', label: 'Download (Kbps)', type: 'number' },
|
||||
{ key: 'upload_kbps', label: 'Upload (Kbps)', type: 'number' },
|
||||
{ key: 'local_address', label: 'Local Address' },
|
||||
{ key: 'remote_address_pool', label: 'Remote Address Pool' },
|
||||
{ key: 'session_timeout', label: 'Session Timeout (detik)', type: 'number' },
|
||||
{ key: 'idle_timeout', label: 'Idle Timeout (detik)', type: 'number' },
|
||||
{ key: 'shared_users', label: 'Shared Users', type: 'number' },
|
||||
{ key: 'local_address', label: 'Local Address', show: (f) => f.service_type === 'ppp' },
|
||||
{ key: 'remote_address_pool', label: 'Remote Address Pool', show: (f) => f.service_type === 'ppp' },
|
||||
{ key: 'session_timeout', label: 'Session Timeout (detik)', type: 'number', show: (f) => f.service_type === 'hotspot' },
|
||||
{ key: 'idle_timeout', label: 'Idle Timeout (detik)', type: 'number', show: (f) => f.service_type === 'hotspot' },
|
||||
{ key: 'shared_users', label: 'Shared Users', type: 'number', show: (f) => f.service_type === 'hotspot' },
|
||||
{ key: 'mac_lock_required', label: 'Kunci MAC ONT/Router', type: 'checkbox', show: (f) => ['static_ip', 'dynamic_ip'].includes(f.service_type) },
|
||||
{ key: 'ip_pool', label: 'Nama IP Pool', show: (f) => f.service_type === 'dynamic_ip' },
|
||||
{ key: 'subnet_cidr', label: 'Subnet CIDR', show: (f) => ['static_ip', 'dynamic_ip'].includes(f.service_type) },
|
||||
{ key: 'gateway', label: 'Gateway', show: (f) => ['static_ip', 'dynamic_ip'].includes(f.service_type) },
|
||||
{ key: 'dns_servers', label: 'DNS Server (pisahkan koma)', show: (f) => ['static_ip', 'dynamic_ip'].includes(f.service_type) },
|
||||
{ key: 'vlan_id', label: 'VLAN ID', type: 'number', show: (f) => ['static_ip', 'dynamic_ip'].includes(f.service_type) },
|
||||
{ key: 'price', label: 'Harga', type: 'number' },
|
||||
],
|
||||
},
|
||||
@@ -76,14 +135,72 @@ const configs = {
|
||||
{ key: 'model', label: 'Model' },
|
||||
{ key: 'host', label: 'Host / IP', required: true },
|
||||
{ key: 'snmp_port', label: 'SNMP Port', type: 'number' },
|
||||
{ key: 'snmp_version', label: 'SNMP Version', type: 'select', options: [['v1', 'v1'], ['v2c', 'v2c'], ['v3', 'v3']] },
|
||||
{ key: 'community', label: 'Community', type: 'password', secret: true, show: (f) => f.snmp_version !== 'v3' },
|
||||
{ key: 'security_level', label: 'Security Level', type: 'select', options: [['noAuthNoPriv', 'noAuthNoPriv'], ['authNoPriv', 'authNoPriv'], ['authPriv', 'authPriv']], show: (f) => f.snmp_version === 'v3' },
|
||||
{
|
||||
key: 'snmp_version',
|
||||
label: 'SNMP Version',
|
||||
type: 'select',
|
||||
options: [
|
||||
['v1', 'v1'],
|
||||
['v2c', 'v2c'],
|
||||
['v3', 'v3'],
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'community',
|
||||
label: 'Community',
|
||||
type: 'password',
|
||||
secret: true,
|
||||
show: (f) => f.snmp_version !== 'v3',
|
||||
},
|
||||
{
|
||||
key: 'security_level',
|
||||
label: 'Security Level',
|
||||
type: 'select',
|
||||
options: [
|
||||
['noAuthNoPriv', 'noAuthNoPriv'],
|
||||
['authNoPriv', 'authNoPriv'],
|
||||
['authPriv', 'authPriv'],
|
||||
],
|
||||
show: (f) => f.snmp_version === 'v3',
|
||||
},
|
||||
{ key: 'security_name', label: 'Security Name', show: (f) => f.snmp_version === 'v3' },
|
||||
{ key: 'auth_protocol', label: 'Auth Protocol', type: 'select', options: [['MD5', 'MD5'], ['SHA', 'SHA'], ['SHA256', 'SHA256'], ['SHA512', 'SHA512']], show: (f) => f.snmp_version === 'v3' && f.security_level !== 'noAuthNoPriv' },
|
||||
{ key: 'auth_password', label: 'Auth Password', type: 'password', secret: true, show: (f) => f.snmp_version === 'v3' && f.security_level !== 'noAuthNoPriv' },
|
||||
{ key: 'privacy_protocol', label: 'Privacy Protocol', type: 'select', options: [['DES', 'DES'], ['AES', 'AES'], ['AES256', 'AES256']], show: (f) => f.snmp_version === 'v3' && f.security_level === 'authPriv' },
|
||||
{ key: 'privacy_password', label: 'Privacy Password', type: 'password', secret: true, show: (f) => f.snmp_version === 'v3' && f.security_level === 'authPriv' },
|
||||
{
|
||||
key: 'auth_protocol',
|
||||
label: 'Auth Protocol',
|
||||
type: 'select',
|
||||
options: [
|
||||
['MD5', 'MD5'],
|
||||
['SHA', 'SHA'],
|
||||
['SHA256', 'SHA256'],
|
||||
['SHA512', 'SHA512'],
|
||||
],
|
||||
show: (f) => f.snmp_version === 'v3' && f.security_level !== 'noAuthNoPriv',
|
||||
},
|
||||
{
|
||||
key: 'auth_password',
|
||||
label: 'Auth Password',
|
||||
type: 'password',
|
||||
secret: true,
|
||||
show: (f) => f.snmp_version === 'v3' && f.security_level !== 'noAuthNoPriv',
|
||||
},
|
||||
{
|
||||
key: 'privacy_protocol',
|
||||
label: 'Privacy Protocol',
|
||||
type: 'select',
|
||||
options: [
|
||||
['DES', 'DES'],
|
||||
['AES', 'AES'],
|
||||
['AES256', 'AES256'],
|
||||
],
|
||||
show: (f) => f.snmp_version === 'v3' && f.security_level === 'authPriv',
|
||||
},
|
||||
{
|
||||
key: 'privacy_password',
|
||||
label: 'Privacy Password',
|
||||
type: 'password',
|
||||
secret: true,
|
||||
show: (f) => f.snmp_version === 'v3' && f.security_level === 'authPriv',
|
||||
},
|
||||
{ key: 'timeout', label: 'Timeout (detik)', type: 'number' },
|
||||
],
|
||||
},
|
||||
@@ -115,18 +232,35 @@ const modal = ref(false)
|
||||
const detailModal = ref(false)
|
||||
const editingId = ref(null)
|
||||
const detailItem = ref(null)
|
||||
const deploymentModal = ref(false)
|
||||
const deploymentProfile = ref(null)
|
||||
const deploymentTargets = ref([])
|
||||
const deploymentNas = ref([])
|
||||
const targetForm = reactive({ nas_mikrotik_id: '', external_profile_name: '' })
|
||||
const scriptOptions = ref([])
|
||||
const profileScripts = reactive({ on_up: '', on_down: '', on_login: '', on_logout: '' })
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const currentQuery = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
|
||||
const form = reactive({})
|
||||
const activeConnectionItem = ref(null)
|
||||
const connectionDetailBox = ref(null)
|
||||
const connectionPopoverStyle = ref({})
|
||||
let connectionAnchor = null
|
||||
|
||||
const isMasterAdmin = computed(
|
||||
() => authStore.user?.is_master === true || authStore.user?.access_level === 'master_admin',
|
||||
)
|
||||
const actions = [
|
||||
const actions = computed(() => [
|
||||
{ key: 'detail', label: 'Detail', color: 'primary' },
|
||||
...(resource.value === 'mikrotik'
|
||||
? [{ key: 'test-connection', label: 'Tes Koneksi', color: 'info' }]
|
||||
: []),
|
||||
...(resource.value === 'package-profile'
|
||||
? [{ key: 'deployment', label: 'Deployment', color: 'info' }]
|
||||
: []),
|
||||
{ key: 'edit', label: 'Edit', color: 'success' },
|
||||
{ key: 'delete', label: 'Hapus', color: 'danger' },
|
||||
]
|
||||
])
|
||||
|
||||
function defaults() {
|
||||
return {
|
||||
@@ -135,11 +269,17 @@ function defaults() {
|
||||
status: 'active',
|
||||
notes: '',
|
||||
connection_type: 'api',
|
||||
backend_mode_override: 'routeros_api',
|
||||
api_port: 8728,
|
||||
radius_auth_port: 1812,
|
||||
radius_accounting_port: 1813,
|
||||
radius_coa_port: 3799,
|
||||
timeout: resource.value === 'olt' ? 5 : 10,
|
||||
service_type: 'ppp',
|
||||
usage_mode: 'recurring_member',
|
||||
validity_start: 'activation',
|
||||
validity_unit: 'days',
|
||||
grace_period_minutes: 0,
|
||||
shared_users: 1,
|
||||
price: 0,
|
||||
snmp_port: 161,
|
||||
@@ -152,16 +292,91 @@ function normalize(item) {
|
||||
return {
|
||||
...item,
|
||||
connection_type_label: item.connection_type === 'radius' ? 'Radius Server' : 'Mikrotik API',
|
||||
service_type_label: ({ ppp: 'PPP', hotspot: 'Hotspot', radius: 'RADIUS' })[item.service_type] || '-',
|
||||
speed_label: item.download_kbps || item.upload_kbps ? `${item.download_kbps || 0}/${item.upload_kbps || 0} Kbps` : '-',
|
||||
integration_type_label: ({ routeros_api: 'RouterOS API', platform_radius: 'RADIUS Global', external_radius: 'RADIUS Eksternal' })[item.backend_mode_override] || 'RouterOS API',
|
||||
service_type_label:
|
||||
{ ppp: 'PPPoE', hotspot: 'Hotspot', static_ip: 'IP Static', dynamic_ip: 'IP Dinamis' }[item.service_type] || '-',
|
||||
speed_label:
|
||||
item.download_kbps || item.upload_kbps
|
||||
? `${item.download_kbps || 0}/${item.upload_kbps || 0} Kbps`
|
||||
: '-',
|
||||
price_label: Number(item.price || 0).toLocaleString('id-ID'),
|
||||
status_label: item.status === 'active' ? 'Aktif' : 'Nonaktif',
|
||||
connection_status_label:
|
||||
{ online: 'Online', offline: 'Offline', unchecked: 'Belum diperiksa' }[
|
||||
item.connection_status
|
||||
] || '-',
|
||||
}
|
||||
}
|
||||
|
||||
function connectionColor(status) {
|
||||
return { online: 'success', offline: 'danger', checking: 'warning' }[status] || 'secondary'
|
||||
}
|
||||
|
||||
function formatCheckedAt(value) {
|
||||
if (!value) return 'Belum pernah diperiksa'
|
||||
return new Intl.DateTimeFormat('id-ID', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function statusTooltip(item) {
|
||||
if (item.connection_status === 'online')
|
||||
return `${item.connection_type === 'api' ? item.health_details?.identity || item.name : 'RADIUS'} online · ${item.last_latency_ms || 0} ms`
|
||||
return item.last_connection_error || 'Klik untuk melihat detail koneksi'
|
||||
}
|
||||
|
||||
async function toggleConnectionDetail(item, event) {
|
||||
if (activeConnectionItem.value?.id === item.id) {
|
||||
closeConnectionDetail()
|
||||
return
|
||||
}
|
||||
|
||||
activeConnectionItem.value = item
|
||||
connectionAnchor = event.currentTarget
|
||||
connectionPopoverStyle.value = { visibility: 'hidden' }
|
||||
await nextTick()
|
||||
positionConnectionDetail()
|
||||
}
|
||||
|
||||
function positionConnectionDetail() {
|
||||
if (!connectionAnchor || !activeConnectionItem.value) return
|
||||
const anchor = connectionAnchor.getBoundingClientRect()
|
||||
const viewportPadding = 12
|
||||
const gap = 8
|
||||
const width = Math.min(336, window.innerWidth - viewportPadding * 2)
|
||||
const height = connectionDetailBox.value?.offsetHeight || 340
|
||||
const spaceBelow = window.innerHeight - anchor.bottom - viewportPadding
|
||||
const openBelow = spaceBelow >= Math.min(height, 420)
|
||||
const left = Math.min(
|
||||
Math.max(viewportPadding, anchor.left),
|
||||
window.innerWidth - width - viewportPadding,
|
||||
)
|
||||
const top = openBelow
|
||||
? anchor.bottom + gap
|
||||
: Math.max(viewportPadding, anchor.top - Math.min(height, window.innerHeight - 24) - gap)
|
||||
|
||||
connectionPopoverStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
maxHeight: `${window.innerHeight - viewportPadding * 2}px`,
|
||||
visibility: 'visible',
|
||||
}
|
||||
}
|
||||
|
||||
function closeConnectionDetail() {
|
||||
activeConnectionItem.value = null
|
||||
connectionAnchor = null
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
if (!isMasterAdmin.value || tenants.value.length) return
|
||||
const response = await getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' })
|
||||
const response = await getTenants({
|
||||
per_page: 100,
|
||||
sort_by: 'tenant_name',
|
||||
sort_direction: 'asc',
|
||||
})
|
||||
tenants.value = response?.data?.data || []
|
||||
}
|
||||
|
||||
@@ -190,8 +405,10 @@ async function openAdd(connectionType = null) {
|
||||
editingId.value = null
|
||||
Object.keys(form).forEach((key) => delete form[key])
|
||||
Object.assign(form, defaults())
|
||||
Object.keys(profileScripts).forEach((event) => (profileScripts[event] = ''))
|
||||
if (connectionType) form.connection_type = connectionType
|
||||
await loadTenants()
|
||||
if (resource.value === 'package-profile') await loadScriptOptions()
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
@@ -199,10 +416,23 @@ async function openEdit(item) {
|
||||
editingId.value = item.id
|
||||
Object.keys(form).forEach((key) => delete form[key])
|
||||
Object.assign(form, defaults(), item)
|
||||
config.value.fields.filter((field) => field.secret).forEach((field) => {
|
||||
form[field.key] = ''
|
||||
})
|
||||
if (resource.value === 'mikrotik') {
|
||||
const effective = item.backend_mode_override || (item.connection_type === 'radius' ? 'external_radius' : 'routeros_api')
|
||||
form.backend_mode_override = effective === 'hybrid' ? 'platform_radius' : effective
|
||||
form.connection_type = 'api'
|
||||
}
|
||||
if (Array.isArray(form.dns_servers)) form.dns_servers = form.dns_servers.join(', ')
|
||||
config.value.fields
|
||||
.filter((field) => field.secret)
|
||||
.forEach((field) => {
|
||||
form[field.key] = ''
|
||||
})
|
||||
await loadTenants()
|
||||
if (resource.value === 'package-profile') {
|
||||
await loadScriptOptions()
|
||||
Object.keys(profileScripts).forEach((event) => (profileScripts[event] = ''))
|
||||
;(item.scripts || []).forEach((policy) => (profileScripts[policy.pivot.event] = policy.id))
|
||||
}
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
@@ -217,14 +447,44 @@ async function openDetail(item) {
|
||||
}
|
||||
|
||||
function payload() {
|
||||
const data = { tenant_id: form.tenant_id || null, name: form.name?.trim(), status: form.status, notes: form.notes || null }
|
||||
if (resource.value === 'mikrotik') data.connection_type = form.connection_type
|
||||
const data = {
|
||||
tenant_id: form.tenant_id || null,
|
||||
name: form.name?.trim(),
|
||||
status: form.status,
|
||||
notes: form.notes || null,
|
||||
}
|
||||
if (resource.value === 'mikrotik') data.connection_type = 'api'
|
||||
config.value.fields.forEach((field) => {
|
||||
if (!field.show || field.show(form)) data[field.key] = form[field.key] === '' ? null : form[field.key]
|
||||
if (!field.show || field.show(form))
|
||||
data[field.key] = form[field.key] === '' ? null : form[field.key]
|
||||
})
|
||||
if (resource.value === 'package-profile') {
|
||||
data.dns_servers = typeof data.dns_servers === 'string'
|
||||
? data.dns_servers.split(',').map((value) => value.trim()).filter(Boolean)
|
||||
: data.dns_servers
|
||||
const events = form.service_type === 'hotspot' ? ['on_login', 'on_logout'] : ['on_up', 'on_down']
|
||||
data.scripts = ['ppp', 'hotspot'].includes(form.service_type) ? events.filter((event) => profileScripts[event]).map((event) => {
|
||||
const policy = scriptOptions.value.find((item) => item.id === Number(profileScripts[event]))
|
||||
return { policy_id: policy.id, version_id: policy.latest_version?.id || null, event }
|
||||
}) : []
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
async function loadScriptOptions() {
|
||||
if (scriptOptions.value.length) return
|
||||
try {
|
||||
const response = await getPackageProfileScriptOptions()
|
||||
scriptOptions.value = response?.data || []
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal memuat pilihan Script Policy.')
|
||||
}
|
||||
}
|
||||
|
||||
function policiesFor(event) {
|
||||
return scriptOptions.value.filter((item) => item.service_type === form.service_type && item.event === event)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name?.trim()) return showError(`${config.value.nameLabel} wajib diisi.`)
|
||||
if (isMasterAdmin.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
|
||||
@@ -258,6 +518,63 @@ function onActionClick({ action, item }) {
|
||||
if (action === 'detail') openDetail(item)
|
||||
if (action === 'edit') openEdit(item)
|
||||
if (action === 'delete') remove(item)
|
||||
if (action === 'test-connection') checkConnection(item)
|
||||
if (action === 'deployment') openDeployment(item)
|
||||
}
|
||||
|
||||
async function openDeployment(item) {
|
||||
deploymentProfile.value = item
|
||||
deploymentModal.value = true
|
||||
targetForm.nas_mikrotik_id = ''
|
||||
targetForm.external_profile_name = item.external_profile_name || item.profile_code || item.name
|
||||
try {
|
||||
const [targets, nas] = await Promise.all([
|
||||
getPackageProfileTargets(item.id),
|
||||
getNasResources('mikrotik', { per_page: 100 }),
|
||||
])
|
||||
deploymentTargets.value = targets?.data || []
|
||||
deploymentNas.value = nas?.data?.data || []
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal memuat deployment profile.')
|
||||
}
|
||||
}
|
||||
|
||||
async function addTarget() {
|
||||
if (!targetForm.nas_mikrotik_id || !targetForm.external_profile_name)
|
||||
return showError('NAS dan nama profile eksternal wajib diisi.')
|
||||
try {
|
||||
await savePackageProfileTarget(deploymentProfile.value.id, { ...targetForm })
|
||||
await openDeployment(deploymentProfile.value)
|
||||
showSuccess('Target deployment berhasil ditambahkan.')
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal menambah target deployment.')
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTarget(target, mode = 'update') {
|
||||
try {
|
||||
await syncPackageProfileTarget(target.id, mode)
|
||||
await openDeployment(deploymentProfile.value)
|
||||
showSuccess('Profile target berhasil disinkronkan.')
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Sinkronisasi profile gagal.')
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConnection(item) {
|
||||
try {
|
||||
const response = await testMikrotikConnection(item.id)
|
||||
const result = response?.data || {}
|
||||
showSuccess(
|
||||
item.connection_type === 'api'
|
||||
? `Terhubung ke ${result.identity || item.name} · RouterOS ${result.version || '-'} · ${result.latency_ms || 0} ms`
|
||||
: `RADIUS ${item.name} merespons Status-Server · ${result.latency_ms || 0} ms`,
|
||||
)
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Koneksi RouterOS API gagal.')
|
||||
await fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
watch(resource, () => {
|
||||
@@ -266,21 +583,37 @@ watch(resource, () => {
|
||||
currentQuery.value = { page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' }
|
||||
fetchData()
|
||||
})
|
||||
onMounted(fetchData)
|
||||
watch(() => form.service_type, (type) => {
|
||||
if (['static_ip', 'dynamic_ip'].includes(type)) form.mac_lock_required = true
|
||||
})
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
window.addEventListener('resize', closeConnectionDetail)
|
||||
window.addEventListener('scroll', closeConnectionDetail, true)
|
||||
document.addEventListener('click', closeConnectionDetail)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', closeConnectionDetail)
|
||||
window.removeEventListener('scroll', closeConnectionDetail, true)
|
||||
document.removeEventListener('click', closeConnectionDetail)
|
||||
})
|
||||
</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 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div
|
||||
class="brdvx-page-header d-flex justify-content-between align-items-center flex-wrap gap-2"
|
||||
>
|
||||
<h5 class="mb-0 fw-semibold">{{ config.title }}</h5>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<template v-if="resource === 'mikrotik'">
|
||||
<CButton color="primary" size="sm" @click="openAdd('api')">+ Mikrotik API</CButton>
|
||||
<CButton color="info" size="sm" @click="openAdd('radius')">+ Radius Server</CButton>
|
||||
<CButton color="primary" size="sm" @click="openAdd()">+ Tambah Mikrotik</CButton>
|
||||
</template>
|
||||
<CButton v-else color="primary" size="sm" @click="openAdd()">+ Tambah {{ config.title }}</CButton>
|
||||
<CButton v-else color="primary" size="sm" @click="openAdd()"
|
||||
>+ Tambah {{ config.title }}</CButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state">
|
||||
@@ -298,11 +631,125 @@ onMounted(fetchData)
|
||||
row-key="id"
|
||||
@action-click="onActionClick"
|
||||
@query-change="fetchData"
|
||||
/>
|
||||
>
|
||||
<template #cell-connection_status_label="{ item }">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-trigger"
|
||||
:title="statusTooltip(item)"
|
||||
:aria-expanded="activeConnectionItem?.id === item.id"
|
||||
@click.stop="toggleConnectionDetail(item, $event)"
|
||||
>
|
||||
<CBadge
|
||||
:color="connectionColor(item.connection_status)"
|
||||
class="connection-status-badge"
|
||||
>
|
||||
<span class="connection-status-dot"></span>
|
||||
{{ item.connection_status_label }}
|
||||
<span v-if="item.connection_status === 'online' && item.last_latency_ms">
|
||||
· {{ item.last_latency_ms }} ms
|
||||
</span>
|
||||
</CBadge>
|
||||
</button>
|
||||
</template>
|
||||
</BaseResponsiveDataView>
|
||||
</div>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="activeConnectionItem"
|
||||
ref="connectionDetailBox"
|
||||
class="connection-detail-box"
|
||||
:style="connectionPopoverStyle"
|
||||
@click.stop
|
||||
>
|
||||
<div class="connection-detail-heading">
|
||||
<strong>{{ activeConnectionItem.name }}</strong>
|
||||
<small>{{
|
||||
activeConnectionItem.connection_type === 'api' ? 'RouterOS API' : 'RADIUS Server'
|
||||
}}</small>
|
||||
</div>
|
||||
|
||||
<template v-if="activeConnectionItem.connection_type === 'api'">
|
||||
<div>
|
||||
<span>Identity</span
|
||||
><strong>{{
|
||||
activeConnectionItem.health_details?.identity ||
|
||||
activeConnectionItem.router_identity ||
|
||||
'-'
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>RouterOS</span
|
||||
><strong>{{
|
||||
activeConnectionItem.health_details?.version ||
|
||||
activeConnectionItem.router_version ||
|
||||
'-'
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Uptime</span
|
||||
><strong>{{ activeConnectionItem.health_details?.uptime || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Platform</span
|
||||
><strong>{{ activeConnectionItem.health_details?.platform || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Board</span
|
||||
><strong>{{ activeConnectionItem.health_details?.board_name || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Endpoint</span
|
||||
><strong
|
||||
>{{ activeConnectionItem.host }}:{{ activeConnectionItem.api_port || 8728 }}</strong
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<span>Host</span
|
||||
><strong>{{
|
||||
activeConnectionItem.health_details?.host || activeConnectionItem.host
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Auth Port</span
|
||||
><strong>{{
|
||||
activeConnectionItem.health_details?.auth_port ||
|
||||
activeConnectionItem.radius_auth_port ||
|
||||
1812
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Accounting</span
|
||||
><strong>{{
|
||||
activeConnectionItem.health_details?.accounting_port ||
|
||||
activeConnectionItem.radius_accounting_port ||
|
||||
1813
|
||||
}}</strong>
|
||||
</div>
|
||||
<div><span>Metode</span><strong>RADIUS Status-Server</strong></div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<span>Latency</span
|
||||
><strong>{{
|
||||
activeConnectionItem.last_latency_ms ? `${activeConnectionItem.last_latency_ms} ms` : '-'
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Diperiksa</span
|
||||
><strong>{{ formatCheckedAt(activeConnectionItem.last_checked_at) }}</strong>
|
||||
</div>
|
||||
<div v-if="activeConnectionItem.last_connection_error" class="connection-error">
|
||||
<span>Error</span><strong>{{ activeConnectionItem.last_connection_error }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<CModal :visible="modal" @close="modal = false" alignment="center" size="lg">
|
||||
<CModalHeader dismiss @close="modal = false">
|
||||
<CModalTitle>{{ editingId ? 'Edit' : 'Tambah' }} {{ config.title }}</CModalTitle>
|
||||
@@ -313,32 +760,56 @@ onMounted(fetchData)
|
||||
<CFormLabel>Tenant</CFormLabel>
|
||||
<CFormSelect v-model="form.tenant_id">
|
||||
<option value="">Pilih tenant</option>
|
||||
<option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">{{ tenant.tenant_name }}</option>
|
||||
<option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">
|
||||
{{ tenant.tenant_name }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>{{ config.nameLabel }}</CFormLabel>
|
||||
<CFormInput v-model="form.name" />
|
||||
</CCol>
|
||||
<CCol v-if="resource === 'mikrotik'" :md="6">
|
||||
<CFormLabel>Tipe Koneksi</CFormLabel>
|
||||
<CFormSelect v-model="form.connection_type">
|
||||
<option value="api">Mikrotik API</option>
|
||||
<option value="radius">Radius Server</option>
|
||||
</CFormSelect>
|
||||
<CCol v-if="resource === 'mikrotik'" :xs="12">
|
||||
<CAlert color="info" class="mb-0">
|
||||
<strong>{{ ({ routeros_api: 'RouterOS API', platform_radius: 'RADIUS Global + API', external_radius: 'RADIUS Eksternal + API' })[form.backend_mode_override] }}</strong>
|
||||
<span class="d-block mt-1">API Mikrotik selalu dikonfigurasi untuk pemeriksaan kondisi perangkat dan konfigurasi RADIUS. Parameter RADIUS hanya muncul jika jenis integrasinya menggunakan RADIUS.</span>
|
||||
</CAlert>
|
||||
</CCol>
|
||||
<CCol v-for="field in config.fields.filter((item) => !item.show || item.show(form))" :key="field.key" :md="6">
|
||||
<CFormLabel>{{ field.label }}</CFormLabel>
|
||||
<CCol
|
||||
v-for="field in config.fields.filter((item) => !item.show || item.show(form))"
|
||||
:key="field.key"
|
||||
:md="6"
|
||||
>
|
||||
<CFormLabel v-if="field.type !== 'checkbox'">{{ field.label }}</CFormLabel>
|
||||
<CFormSelect v-if="field.type === 'select'" v-model="form[field.key]">
|
||||
<option v-for="[value, label] in field.options" :key="value" :value="value">{{ label }}</option>
|
||||
<option v-for="[value, label] in field.options" :key="value" :value="value">
|
||||
{{ label }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
<CFormInput
|
||||
v-else
|
||||
v-else-if="field.type !== 'checkbox'"
|
||||
v-model="form[field.key]"
|
||||
:type="field.type || 'text'"
|
||||
:placeholder="field.secret && editingId ? 'Kosongkan jika tidak diubah' : ''"
|
||||
/>
|
||||
<CFormCheck
|
||||
v-else
|
||||
v-model="form[field.key]"
|
||||
:label="field.label"
|
||||
:disabled="['static_ip', 'dynamic_ip'].includes(form.service_type)"
|
||||
/>
|
||||
</CCol>
|
||||
<template v-if="resource === 'package-profile' && ['ppp', 'hotspot'].includes(form.service_type)">
|
||||
<CCol v-for="event in (form.service_type === 'hotspot' ? ['on_login', 'on_logout'] : ['on_up', 'on_down'])" :key="event" :md="6">
|
||||
<CFormLabel>Script {{ event.replace('_', ' ').toUpperCase() }}</CFormLabel>
|
||||
<CFormSelect v-model="profileScripts[event]">
|
||||
<option value="">Tanpa script</option>
|
||||
<option v-for="policy in policiesFor(event)" :key="policy.id" :value="policy.id">
|
||||
{{ policy.name }} · v{{ policy.current_version }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
</template>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Status</CFormLabel>
|
||||
<CFormSelect v-model="form.status">
|
||||
@@ -354,23 +825,185 @@ onMounted(fetchData)
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="modal = false" :disabled="saving">Batal</CButton>
|
||||
<CButton color="primary" @click="save" :disabled="saving">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton>
|
||||
<CButton color="primary" @click="save" :disabled="saving">{{
|
||||
saving ? 'Menyimpan...' : 'Simpan'
|
||||
}}</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<CModal :visible="deploymentModal" @close="deploymentModal = false" size="xl" alignment="center">
|
||||
<CModalHeader dismiss @close="deploymentModal = false">
|
||||
<CModalTitle>Deployment · {{ deploymentProfile?.name }}</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CAlert color="info">
|
||||
Satu profile paket dapat diterapkan ke beberapa Mikrotik API atau RADIUS. Akun customer hanya dapat diaktifkan pada target yang statusnya sudah tersinkron.
|
||||
</CAlert>
|
||||
<CRow class="g-3 align-items-end mb-4">
|
||||
<CCol :md="5">
|
||||
<CFormLabel>NAS Tujuan</CFormLabel>
|
||||
<CFormSelect v-model="targetForm.nas_mikrotik_id">
|
||||
<option value="">Pilih NAS</option>
|
||||
<option v-for="nas in deploymentNas" :key="nas.id" :value="nas.id">
|
||||
{{ nas.name }} · {{ ({ routeros_api: 'RouterOS API', platform_radius: 'RADIUS Global', external_radius: 'RADIUS Eksternal' })[nas.backend_mode_override] || 'RouterOS API' }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol :md="5">
|
||||
<CFormLabel>Nama Profile pada NAS</CFormLabel>
|
||||
<CFormInput v-model="targetForm.external_profile_name" />
|
||||
</CCol>
|
||||
<CCol :md="2"><CButton color="primary" class="w-100" @click="addTarget">Tambah</CButton></CCol>
|
||||
</CRow>
|
||||
<div v-if="!deploymentTargets.length" class="text-center text-body-secondary py-4">Belum ada target deployment.</div>
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead><tr><th>NAS</th><th>Backend</th><th>Profile Tujuan</th><th>Status</th><th>Sinkron Terakhir</th><th class="text-end">Tindakan</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="target in deploymentTargets" :key="target.id">
|
||||
<td>{{ target.mikrotik?.name || '-' }}</td>
|
||||
<td>{{ target.backend_type === 'routeros_api' ? 'RouterOS API' : 'RADIUS' }}</td>
|
||||
<td><code>{{ target.external_profile_name }}</code></td>
|
||||
<td><CBadge :color="target.sync_status === 'synced' ? 'success' : target.sync_status === 'error' ? 'danger' : 'secondary'">{{ target.sync_status }}</CBadge><small v-if="target.last_error" class="d-block text-danger mt-1">{{ target.last_error }}</small></td>
|
||||
<td>{{ target.last_synced_at ? new Date(target.last_synced_at).toLocaleString('id-ID') : '-' }}</td>
|
||||
<td class="text-end"><div class="d-flex justify-content-end flex-wrap gap-1">
|
||||
<CButton color="outline-primary" size="sm" @click="syncTarget(target, 'create')">Buat</CButton>
|
||||
<CButton color="outline-info" size="sm" @click="syncTarget(target, 'adopt')">Adopsi</CButton>
|
||||
<CButton color="primary" size="sm" @click="syncTarget(target, 'update')">Sinkronkan</CButton>
|
||||
</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CModalBody>
|
||||
<CModalFooter><CButton color="secondary" @click="deploymentModal = false">Tutup</CButton></CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<CModal :visible="detailModal" @close="detailModal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="detailModal = false"><CModalTitle>Detail {{ config.title }}</CModalTitle></CModalHeader>
|
||||
<CModalHeader dismiss @close="detailModal = false"
|
||||
><CModalTitle>Detail {{ config.title }}</CModalTitle></CModalHeader
|
||||
>
|
||||
<CModalBody>
|
||||
<div v-if="detailItem" class="d-grid gap-2">
|
||||
<div v-for="column in config.columns" :key="column.key" class="d-flex justify-content-between gap-3 border-bottom pb-2">
|
||||
<span class="text-body-secondary">{{ column.label }}</span><strong class="text-end">{{ detailItem[column.key] || '-' }}</strong>
|
||||
<div
|
||||
v-for="column in config.columns"
|
||||
:key="column.key"
|
||||
class="d-flex justify-content-between gap-3 border-bottom pb-2"
|
||||
>
|
||||
<span class="text-body-secondary">{{ column.label }}</span
|
||||
><strong class="text-end">{{ detailItem[column.key] || '-' }}</strong>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between gap-3">
|
||||
<span class="text-body-secondary">Tenant</span
|
||||
><strong>{{ detailItem.tenant?.tenant_name || '-' }}</strong>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between gap-3"><span class="text-body-secondary">Tenant</span><strong>{{ detailItem.tenant?.tenant_name || '-' }}</strong></div>
|
||||
</div>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton v-if="resource === 'webfig' && detailItem?.url" color="primary" :href="detailItem.url" target="_blank" rel="noopener noreferrer">Buka Perangkat</CButton>
|
||||
<CButton
|
||||
v-if="resource === 'webfig' && detailItem?.url"
|
||||
color="primary"
|
||||
:href="detailItem.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Buka Perangkat</CButton
|
||||
>
|
||||
<CButton color="secondary" @click="detailModal = false">Tutup</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.connection-status-trigger {
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.connection-status-trigger:focus-visible {
|
||||
outline: 2px solid var(--cui-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.connection-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
min-height: 1.7rem;
|
||||
padding: 0.35rem 0.62rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.01em;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 10%);
|
||||
}
|
||||
|
||||
.connection-status-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
background: currentcolor;
|
||||
border: 2px solid rgb(255 255 255 / 70%);
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.connection-detail-box {
|
||||
position: fixed;
|
||||
z-index: 1090;
|
||||
display: grid;
|
||||
overflow-y: auto;
|
||||
padding: 0.85rem;
|
||||
color: var(--cui-body-color);
|
||||
background: var(--cui-body-bg);
|
||||
border: 1px solid var(--cui-border-color);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.75rem 2rem rgb(15 23 42 / 18%);
|
||||
animation: connection-detail-enter 0.14s ease-out;
|
||||
}
|
||||
|
||||
.connection-detail-box > div:not(.connection-detail-heading) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5.5rem, 0.7fr) minmax(0, 1.3fr);
|
||||
gap: 0.65rem;
|
||||
padding: 0.38rem 0;
|
||||
font-size: 0.76rem;
|
||||
border-bottom: 1px solid var(--cui-border-color-translucent);
|
||||
}
|
||||
|
||||
.connection-detail-box span,
|
||||
.connection-detail-heading small {
|
||||
color: var(--cui-secondary-color);
|
||||
}
|
||||
|
||||
.connection-detail-box strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-detail-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.45rem;
|
||||
padding-bottom: 0.65rem;
|
||||
border-bottom: 1px solid var(--cui-border-color);
|
||||
}
|
||||
|
||||
.connection-detail-box .connection-error strong {
|
||||
color: var(--cui-danger);
|
||||
}
|
||||
|
||||
@keyframes connection-detail-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.3rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { createRadiusCluster, deleteRadiusCluster, getRadiusClusters, updateRadiusCluster } from '@/services/nasService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
const loading = ref(true), saving = ref(false), modal = ref(false), editingId = ref(null)
|
||||
const items = ref([]), query = ref({ page: 1, per_page: 10 })
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const columns = [{ key: 'code', label: 'Kode' }, { key: 'name', label: 'Cluster' }, { key: 'region', label: 'Region' }, { key: 'auth_label', label: 'Authentication', sortable: false }, { key: 'status_label', label: 'Status', sortable: false }]
|
||||
const form = reactive({ code: '', name: '', region: '', primary_host: '', secondary_host: '', auth_port: 1812, accounting_port: 1813, coa_port: 3799, provisioning_api_url: '', provisioning_api_token: '', healthcheck_url: '', request_timeout: 5, retry_attempts: 2, status: 'planned', notes: '', has_provisioning_api_token: false })
|
||||
|
||||
function emptyForm() { return { code: '', name: '', region: '', primary_host: '', secondary_host: '', auth_port: 1812, accounting_port: 1813, coa_port: 3799, provisioning_api_url: '', provisioning_api_token: '', healthcheck_url: '', request_timeout: 5, retry_attempts: 2, status: 'planned', notes: '', has_provisioning_api_token: false } }
|
||||
function normalize(item) { return { ...item, auth_label: (item.auth_endpoints || []).map((x) => `${x.host}:${x.port}`).join(', ') || '-', status_label: ({ planned: 'Direncanakan', active: 'Aktif', maintenance: 'Maintenance', inactive: 'Nonaktif' })[item.status] || item.status } }
|
||||
async function load(params = query.value) { query.value = { ...query.value, ...params }; loading.value = true; try { const response = await getRadiusClusters(query.value), page = response?.data || {}; items.value = (page.data || []).map(normalize); pagination.value = { current_page: page.current_page || 1, last_page: page.last_page || 1, per_page: page.per_page || 10, total: page.total || 0 } } catch (error) { showError(error?.response?.data?.message || 'Gagal memuat infrastruktur RADIUS.') } finally { loading.value = false } }
|
||||
function open(item = null) { editingId.value = item?.id || null; const auth = item?.auth_endpoints || [], accounting = item?.accounting_endpoints || [], coa = item?.coa_endpoints || []; Object.assign(form, emptyForm(), item || {}, { primary_host: auth[0]?.host || '', secondary_host: auth[1]?.host || '', auth_port: auth[0]?.port || 1812, accounting_port: accounting[0]?.port || 1813, coa_port: coa[0]?.port || 3799, provisioning_api_token: '' }); modal.value = true }
|
||||
function endpoints(port) { return [form.primary_host, form.secondary_host].filter(Boolean).map((host, index) => ({ host, port: Number(port), priority: index + 1 })) }
|
||||
function payload() { return { code: form.code, name: form.name, region: form.region || null, auth_endpoints: endpoints(form.auth_port), accounting_endpoints: endpoints(form.accounting_port), coa_endpoints: endpoints(form.coa_port), provisioning_api_url: form.provisioning_api_url || null, provisioning_api_token: form.provisioning_api_token || null, healthcheck_url: form.healthcheck_url || null, request_timeout: Number(form.request_timeout), retry_attempts: Number(form.retry_attempts), status: form.status, notes: form.notes || null } }
|
||||
async function save() { saving.value = true; try { editingId.value ? await updateRadiusCluster(editingId.value, payload()) : await createRadiusCluster(payload()); modal.value = false; showSuccess('Konfigurasi cluster RADIUS berhasil disimpan.'); await load() } catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan cluster RADIUS.') } finally { saving.value = false } }
|
||||
async function remove(item) { const answer = await showConfirm('Hapus Cluster RADIUS', `Hapus cluster "${item.name}"?`); if (!answer.isConfirmed) return; try { await deleteRadiusCluster(item.id); showSuccess('Cluster RADIUS berhasil dihapus.'); await load() } catch (error) { showError(error?.response?.data?.message || 'Cluster RADIUS tidak dapat dihapus.') } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between align-items-center gap-2"><div><h5 class="mb-0">Infrastruktur RADIUS</h5><small class="text-body-secondary">Control plane cluster RADIUS Global platform</small></div><CButton color="primary" size="sm" @click="open()">+ Tambah Cluster</CButton></div>
|
||||
<BaseResponsiveDataView :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="[{ key: 'edit', label: 'Edit', color: 'success' }, { key: 'delete', label: 'Hapus', color: 'danger' }]" @query-change="load" @action-click="({ action, item }) => action === 'edit' ? open(item) : remove(item)" />
|
||||
</div>
|
||||
<CModal :visible="modal" size="xl" alignment="center" @close="modal = false">
|
||||
<CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editingId ? 'Edit' : 'Tambah' }} Cluster RADIUS</CModalTitle></CModalHeader>
|
||||
<CModalBody><CRow class="g-3">
|
||||
<CCol :md="4"><CFormLabel>Kode Cluster</CFormLabel><CFormInput v-model="form.code" /></CCol><CCol :md="5"><CFormLabel>Nama Cluster</CFormLabel><CFormInput v-model="form.name" /></CCol><CCol :md="3"><CFormLabel>Region</CFormLabel><CFormInput v-model="form.region" placeholder="id-jakarta-1" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>Primary RADIUS Host</CFormLabel><CFormInput v-model="form.primary_host" /></CCol><CCol :md="6"><CFormLabel>Secondary RADIUS Host</CFormLabel><CFormInput v-model="form.secondary_host" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>Authentication Port</CFormLabel><CFormInput v-model="form.auth_port" type="number" /></CCol><CCol :md="4"><CFormLabel>Accounting Port</CFormLabel><CFormInput v-model="form.accounting_port" type="number" /></CCol><CCol :md="4"><CFormLabel>CoA / Disconnect Port</CFormLabel><CFormInput v-model="form.coa_port" type="number" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>Provisioning API URL</CFormLabel><CFormInput v-model="form.provisioning_api_url" placeholder="https://radius-api.example.com" /></CCol><CCol :md="6"><CFormLabel>Provisioning API Token</CFormLabel><CFormInput v-model="form.provisioning_api_token" type="password" :placeholder="editingId && form.has_provisioning_api_token ? 'Kosongkan jika tidak diubah' : ''" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>Health Check URL</CFormLabel><CFormInput v-model="form.healthcheck_url" /></CCol><CCol :md="2"><CFormLabel>Timeout</CFormLabel><CFormInput v-model="form.request_timeout" type="number" /></CCol><CCol :md="2"><CFormLabel>Retry</CFormLabel><CFormInput v-model="form.retry_attempts" type="number" /></CCol><CCol :md="2"><CFormLabel>Status</CFormLabel><CFormSelect v-model="form.status"><option value="planned">Direncanakan</option><option value="active">Aktif</option><option value="maintenance">Maintenance</option><option value="inactive">Nonaktif</option></CFormSelect></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2" /></CCol>
|
||||
<CCol :xs="12"><CAlert color="info" class="mb-0">Shared secret tetap dibuat per Mikrotik. Token di halaman ini hanya digunakan services_core untuk provisioning akun/profile ke API internal server RADIUS.</CAlert></CCol>
|
||||
</CRow></CModalBody>
|
||||
<CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { createScriptPolicy, getScriptPolicies, updateScriptPolicy } from '@/services/nasService'
|
||||
import { showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const modal = ref(false)
|
||||
const editing = ref(null)
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const query = ref({ page: 1, per_page: 10 })
|
||||
const form = reactive({ code: '', name: '', service_type: 'hotspot', event: 'on_login', script_template: '', change_notes: '', enabled: true })
|
||||
const columns = [
|
||||
{ key: 'code', label: 'Kode' }, { key: 'name', label: 'Nama' },
|
||||
{ key: 'service_label', label: 'Layanan', sortable: false },
|
||||
{ key: 'event_label', label: 'Event', sortable: false },
|
||||
{ key: 'version_label', label: 'Versi', sortable: false },
|
||||
{ key: 'status_label', label: 'Status', sortable: false },
|
||||
]
|
||||
|
||||
async function load(params = query.value) {
|
||||
query.value = { ...query.value, ...params }
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getScriptPolicies(query.value)
|
||||
const rows = response?.data?.data || []
|
||||
items.value = rows.map((item) => ({ ...item, service_label: item.service_type === 'ppp' ? 'PPPoE' : 'Hotspot', event_label: item.event.replace('_', '-'), version_label: `v${item.current_version}`, status_label: item.enabled ? 'Aktif' : 'Nonaktif' }))
|
||||
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 Script Policy.')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function open(item = null) {
|
||||
editing.value = item?.id || null
|
||||
Object.assign(form, { code: item?.code || '', name: item?.name || '', service_type: item?.service_type || 'hotspot', event: item?.event || 'on_login', script_template: item?.latest_version?.script_template || '', change_notes: '', enabled: item?.enabled ?? true })
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
editing.value ? await updateScriptPolicy(editing.value, { ...form }) : await createScriptPolicy({ ...form })
|
||||
modal.value = false
|
||||
showSuccess(editing.value ? 'Versi baru berhasil dibuat.' : 'Script Policy berhasil dibuat.')
|
||||
await load()
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal menyimpan Script Policy.')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between">
|
||||
<div><h5 class="mb-0">Script Policy</h5><small class="text-body-secondary">Master script berversi untuk event PPPoE dan Hotspot</small></div>
|
||||
<CButton size="sm" color="primary" @click="open()">+ Script Policy</CButton>
|
||||
</div>
|
||||
<BaseResponsiveDataView :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="[{ key: 'edit', label: 'Buat Versi Baru', color: 'success' }]" @query-change="load" @action-click="({ item }) => open(item)" />
|
||||
</div>
|
||||
<CModal :visible="modal" size="lg" @close="modal = false">
|
||||
<CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editing ? 'Versi Baru' : 'Tambah' }} Script Policy</CModalTitle></CModalHeader>
|
||||
<CModalBody><CRow class="g-3">
|
||||
<CCol :md="6"><CFormLabel>Kode</CFormLabel><CFormInput v-model="form.code" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>Nama</CFormLabel><CFormInput v-model="form.name" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>Layanan</CFormLabel><CFormSelect v-model="form.service_type"><option value="ppp">PPPoE</option><option value="hotspot">Hotspot</option></CFormSelect></CCol>
|
||||
<CCol :md="6"><CFormLabel>Event</CFormLabel><CFormSelect v-model="form.event"><template v-if="form.service_type === 'ppp'"><option value="on_up">On Up</option><option value="on_down">On Down</option></template><template v-else><option value="on_login">On Login</option><option value="on_logout">On Logout</option></template></CFormSelect></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Script Template</CFormLabel><CFormTextarea v-model="form.script_template" rows="12" class="font-monospace" /><CFormText>Variabel: username, customer_code, customer_name, package_code, tenant_code, dan valid_until. Gunakan kurung kurawal ganda pada nama variabel.</CFormText></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Catatan Versi</CFormLabel><CFormTextarea v-model="form.change_notes" rows="2" /></CCol>
|
||||
<CCol><CFormCheck v-model="form.enabled" label="Aktif" /></CCol>
|
||||
</CRow></CModalBody>
|
||||
<CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getTenantInstallationSetting, saveTenantInstallationSetting } from '@/services/tenantInstallationService'
|
||||
import { showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const apiKeyConfigured = ref(false)
|
||||
const tenant = ref(null)
|
||||
const form = reactive({
|
||||
activation_mode: 'list_order',
|
||||
ktp_ocr_enabled: false,
|
||||
ocr_provider: null,
|
||||
ocr_api_key: '',
|
||||
clear_ocr_api_key: false,
|
||||
})
|
||||
const isMaster = computed(() =>
|
||||
authStore.user?.is_master || authStore.user?.access_level === 'master_admin',
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getTenantInstallationSetting(route.params.id)
|
||||
const data = response?.data || {}
|
||||
tenant.value = data.tenant || null
|
||||
Object.assign(form, {
|
||||
activation_mode: data.activation_mode || 'list_order',
|
||||
ktp_ocr_enabled: !!data.ktp_ocr_enabled,
|
||||
ocr_provider: data.ocr_provider || null,
|
||||
ocr_api_key: '',
|
||||
clear_ocr_api_key: false,
|
||||
})
|
||||
apiKeyConfigured.value = !!data.ocr_api_key_configured
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal memuat setting tenant.')
|
||||
router.replace('/tenants')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { activation_mode: form.activation_mode }
|
||||
if (isMaster.value) Object.assign(payload, {
|
||||
ktp_ocr_enabled: form.ktp_ocr_enabled,
|
||||
ocr_provider: form.ktp_ocr_enabled ? form.ocr_provider : null,
|
||||
ocr_api_key: form.ocr_api_key || null,
|
||||
clear_ocr_api_key: form.clear_ocr_api_key,
|
||||
})
|
||||
const response = await saveTenantInstallationSetting(route.params.id, payload)
|
||||
apiKeyConfigured.value = !!response?.data?.ocr_api_key_configured
|
||||
form.ocr_api_key = ''
|
||||
form.clear_ocr_api_key = false
|
||||
showSuccess('Setting tenant berhasil disimpan.')
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || Object.values(error?.response?.data?.errors || {})?.[0]?.[0] || 'Gagal menyimpan setting.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<CButton color="secondary" variant="ghost" size="sm" @click="router.push('/tenants')">← Kembali</CButton>
|
||||
<h4 class="mb-1 mt-1">Setting Tenant</h4>
|
||||
<small class="text-body-secondary">{{ tenant?.tenant_name || 'Memuat tenant...' }}</small>
|
||||
</div>
|
||||
<CButton color="primary" :disabled="loading || saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan Setting' }}</CButton>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading"><CSpinner color="primary" /> Memuat setting...</div>
|
||||
<CRow v-else class="g-3">
|
||||
<CCol :lg="7">
|
||||
<CCard class="setting-card h-100">
|
||||
<CCardHeader><strong>Proses Pemasangan Baru</strong></CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary">Tentukan lokasi staff menjalankan aktivasi customer baru.</p>
|
||||
<div class="mode-grid">
|
||||
<label :class="{ active: form.activation_mode === 'list_order' }">
|
||||
<CFormCheck v-model="form.activation_mode" type="radio" value="list_order" />
|
||||
<strong>Aktivasi On List Order</strong>
|
||||
<small>Aktivasi dilakukan langsung dari daftar order customer.</small>
|
||||
</label>
|
||||
<label :class="{ active: form.activation_mode === 'ticket' }">
|
||||
<CFormCheck v-model="form.activation_mode" type="radio" value="ticket" />
|
||||
<strong>Aktivasi On Ticket</strong>
|
||||
<small>Aktivasi tersedia setelah pekerjaan ticket diselesaikan.</small>
|
||||
</label>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
|
||||
<CCol :lg="5">
|
||||
<CCard class="setting-card h-100">
|
||||
<CCardHeader class="d-flex justify-content-between align-items-center">
|
||||
<strong>OCR KTP</strong>
|
||||
<CBadge :color="form.ktp_ocr_enabled ? 'success' : 'secondary'">{{ form.ktp_ocr_enabled ? 'Aktif' : 'Tidak Aktif' }}</CBadge>
|
||||
</CCardHeader>
|
||||
<CCardBody class="d-grid gap-3">
|
||||
<template v-if="isMaster">
|
||||
<CFormCheck v-model="form.ktp_ocr_enabled" label="Aktifkan OCR KTP untuk tenant ini" />
|
||||
<div><CFormLabel>Provider AI</CFormLabel><CFormSelect v-model="form.ocr_provider" :disabled="!form.ktp_ocr_enabled"><option :value="null">Pilih provider</option><option value="gemini">Gemini AI</option><option value="openai">OpenAI GPT</option></CFormSelect></div>
|
||||
<div><CFormLabel>API Key</CFormLabel><CFormInput v-model="form.ocr_api_key" type="password" :disabled="!form.ktp_ocr_enabled" :placeholder="apiKeyConfigured ? 'Sudah tersimpan — kosongkan untuk mempertahankan' : 'Masukkan API key'" /><small class="text-body-secondary">Credential disimpan terenkripsi dan tidak ditampilkan kembali.</small></div>
|
||||
<CFormCheck v-if="apiKeyConfigured" v-model="form.clear_ocr_api_key" label="Hapus API key yang tersimpan" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="read-row"><span>Status OCR</span><strong>{{ form.ktp_ocr_enabled ? 'Aktif' : 'Tidak aktif' }}</strong></div>
|
||||
<div class="read-row"><span>Provider AI</span><strong>{{ form.ocr_provider === 'gemini' ? 'Gemini AI' : form.ocr_provider === 'openai' ? 'OpenAI GPT' : 'Belum dipilih' }}</strong></div>
|
||||
<div class="privacy-note">Provider dan API key hanya dapat dikonfigurasi oleh Master Admin.</div>
|
||||
</template>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page{padding:.5rem}.page-head{display:flex;justify-content:space-between;align-items:center;gap:1rem;margin-bottom:1rem}.setting-card{border:1px solid var(--cui-border-color);border-radius:.85rem}.loading{min-height:14rem;display:flex;align-items:center;justify-content:center;gap:.6rem}.mode-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}.mode-grid label{display:flex;flex-direction:column;gap:.3rem;padding:1rem;border:1px solid var(--cui-border-color);border-radius:.75rem;cursor:pointer}.mode-grid label.active{border-color:var(--cui-primary);background:rgba(var(--cui-primary-rgb),.08)}.mode-grid small,.read-row span{color:var(--cui-secondary-color)}.read-row{display:flex;justify-content:space-between;gap:1rem;padding:.75rem;background:var(--cui-tertiary-bg);border-radius:.65rem}.privacy-note{padding:.75rem;border-radius:.65rem;background:rgba(var(--cui-info-rgb),.1);color:var(--cui-info)}@media(max-width:767px){.page-head{align-items:flex-start}.mode-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { computed, ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useMenuStore } from '@/stores/menu'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import {
|
||||
getTenants,
|
||||
@@ -12,6 +15,9 @@ import { formatTanggal } from '@/utils/tglindo.js'
|
||||
import { showConfirm, showSuccess, showError } from '@/utils/swal.js'
|
||||
|
||||
const loading = ref(true)
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const menuStore = useMenuStore()
|
||||
const loadedOnce = ref(false)
|
||||
const saving = ref(false)
|
||||
const tenants = ref([])
|
||||
@@ -42,11 +48,16 @@ const columns = [
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
]
|
||||
|
||||
const tableActions = [
|
||||
{ key: 'detail', label: 'Detail', color: 'primary' },
|
||||
{ key: 'edit', label: 'Edit', color: 'success' },
|
||||
{ key: 'delete', label: 'Delete', color: 'danger' },
|
||||
]
|
||||
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
|
||||
const isTenantOwner = computed(() => authStore.user?.access_level === 'tenant_owner' || authStore.activeTenant?.role === 'tenant_owner')
|
||||
const tenantPermissions = computed(() => menuStore.accessibleMenus.find((menu) => menu.slug === 'tenants')?.permissions || {})
|
||||
const tableActions = computed(() => {
|
||||
const actions = [{ key: 'detail', label: 'Detail', color: 'primary' }]
|
||||
if (isMaster.value) actions.push({ key: 'edit', label: 'Edit', color: 'success' })
|
||||
if (isMaster.value || isTenantOwner.value || tenantPermissions.value.can_update) actions.push({ key: 'settings', label: 'Setting', color: 'warning' })
|
||||
if (isMaster.value) actions.push({ key: 'delete', label: 'Delete', color: 'danger' })
|
||||
return actions
|
||||
})
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
@@ -224,6 +235,7 @@ async function saveItem() {
|
||||
function onActionClick({ action, item }) {
|
||||
if (action === 'detail') openDetail(item)
|
||||
if (action === 'edit') openEditModal(item)
|
||||
if (action === 'settings') router.push(`/tenants/${item.id}/settings`)
|
||||
if (action === 'delete') openDeleteModal(item)
|
||||
}
|
||||
</script>
|
||||
@@ -240,7 +252,7 @@ function onActionClick({ action, item }) {
|
||||
<CButton color="primary" size="sm" variant="outline" :disabled="loading" @click="fetchAllData()">
|
||||
{{ loading ? 'Loading...' : 'Refresh' }}
|
||||
</CButton>
|
||||
<CButton color="primary" size="sm" @click="openAddModal">+ Tambah Tenant</CButton>
|
||||
<CButton v-if="isMaster" color="primary" size="sm" @click="openAddModal">+ Tambah Tenant</CButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,55 +1,2 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useTicketStore } from '@/stores/ticket.js'
|
||||
|
||||
const store = useTicketStore()
|
||||
|
||||
const approvedTickets = computed(() =>
|
||||
store.tickets.filter((t) => t.approved_by && t.approved_by !== '-')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow>
|
||||
<CCol :xs="12">
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Approved Tickets</strong>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<div class="table-responsive">
|
||||
<CTable striped hover small>
|
||||
<CTableHead>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="col">No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Ticket No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Kode Tiket</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Deskripsi</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Approved By</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Approved At</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Status</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
<CTableBody>
|
||||
<CTableRow v-for="(item, index) in approvedTickets" :key="item.ticket_no">
|
||||
<CTableHeaderCell scope="row">{{ index + 1 }}</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.ticket_no }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.kode_tiket }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.deskripsi }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.approved_by }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.approved_at }}</CTableDataCell>
|
||||
<CTableDataCell>
|
||||
<CBadge color="success">Approved</CBadge>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow v-if="approvedTickets.length === 0">
|
||||
<CTableDataCell colspan="7" class="text-center">Tidak ada data approved tickets</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</template>
|
||||
<script setup>import TicketStatusList from './TicketStatusList.vue'</script>
|
||||
<template><TicketStatusList status="approved" title="Ticket Disetujui" description="Ticket yang siap ditugaskan kepada teknisi."/></template>
|
||||
|
||||
@@ -1,53 +1,2 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useTicketStore } from '@/stores/ticket.js'
|
||||
|
||||
const store = useTicketStore()
|
||||
|
||||
const rejectedTickets = computed(() =>
|
||||
store.tickets.filter((t) => t.rejected_by && t.rejected_by !== '-')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow>
|
||||
<CCol :xs="12">
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Rejected Tickets</strong>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<div class="table-responsive">
|
||||
<CTable striped hover small>
|
||||
<CTableHead>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="col">No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Ticket No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Kode Tiket</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Deskripsi</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Rejected By</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Rejected At</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Alasan</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
<CTableBody>
|
||||
<CTableRow v-for="(item, index) in rejectedTickets" :key="item.ticket_no">
|
||||
<CTableHeaderCell scope="row">{{ index + 1 }}</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.ticket_no }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.kode_tiket }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.deskripsi }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.rejected_by }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.rejected_at }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.rejection_reason }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow v-if="rejectedTickets.length === 0">
|
||||
<CTableDataCell colspan="7" class="text-center">Tidak ada data rejected tickets</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</template>
|
||||
<script setup>import TicketStatusList from './TicketStatusList.vue'</script>
|
||||
<template><TicketStatusList status="rejected" title="Ticket Ditolak" description="Riwayat ticket yang ditolak pada proses persetujuan."/></template>
|
||||
|
||||
+474
-540
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,544 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
addTicketMaterial,
|
||||
deleteTicketMaterial,
|
||||
getTicket,
|
||||
getTicketHistory,
|
||||
getTicketOptions,
|
||||
getTicketMaterialOptions,
|
||||
saveInstallationStep,
|
||||
getInstallationPorts,
|
||||
ticketAction,
|
||||
} from '@/services/ticketService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
import { activateCustomer } from '@/services/customerService'
|
||||
import { useMenuStore } from '@/stores/menu'
|
||||
import { uploadFile } from '@/services/fileService'
|
||||
const route = useRoute(),
|
||||
router = useRouter(),
|
||||
loading = ref(true),
|
||||
saving = ref(false),
|
||||
ticket = ref(null),
|
||||
history = ref([]),
|
||||
technicians = ref([]),
|
||||
actionModal = ref(false),
|
||||
materialModal = ref(false),
|
||||
activationModal = ref(false),
|
||||
installationSetting = ref({ activation_mode: 'list_order' }),
|
||||
actionFiles = ref([]),
|
||||
materialOptions = ref({ serialized: [], consumables: [] }),
|
||||
customerOptions = ref({}),
|
||||
odpPorts = ref([]),
|
||||
currentAction = ref('')
|
||||
const menuStore = useMenuStore()
|
||||
const canActivate = computed(() => menuStore.isMaster || menuStore.accessibleMenus.find((menu) => menu.slug === 'customers-orders')?.permissions?.can_activate === true)
|
||||
const actionForm = reactive({
|
||||
notes: '',
|
||||
reason: '',
|
||||
technicians: [],
|
||||
leader_id: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
}),
|
||||
materialForm = reactive({ source_key: '', qty: 1, notes: '' })
|
||||
const activationForm = reactive({ external_username: '', external_password: '' })
|
||||
const installationForm=reactive({stored_file_ids:[],topology_id:'',topology_port_number:'',package_profile_id:'',nas_mikrotik_id:'',billing_profile_id:'',external_username:'',external_password:'',serial_number:'',mac_address:''})
|
||||
const statusLabels = {
|
||||
open: 'Open',
|
||||
approved: 'Disetujui',
|
||||
assigned: 'Ditugaskan',
|
||||
progress: 'Dikerjakan',
|
||||
pending: 'Pending',
|
||||
resolved: 'Selesai',
|
||||
closed: 'Ditutup',
|
||||
cancelled: 'Dibatalkan',
|
||||
rejected: 'Ditolak',
|
||||
},
|
||||
priorityLabels = { low: 'Rendah', medium: 'Sedang', high: 'Tinggi', critical: 'Kritis' }
|
||||
const actionLabels = {
|
||||
approve: 'Setujui Tiket',
|
||||
reject: 'Tolak Tiket',
|
||||
assign: 'Tugaskan Teknisi',
|
||||
reassign: 'Ubah Penugasan',
|
||||
start: 'Mulai Pekerjaan',
|
||||
pending: 'Jadikan Pending',
|
||||
resume: 'Lanjutkan Pekerjaan',
|
||||
resolve: 'Selesaikan Pekerjaan',
|
||||
close: 'Tutup Tiket',
|
||||
verify: 'Verifikasi Pekerjaan',
|
||||
cancel: 'Batalkan Tiket',
|
||||
}
|
||||
const availableActions = computed(
|
||||
() =>
|
||||
({
|
||||
open: ['approve', 'reject', 'cancel'],
|
||||
approved: ['assign', 'cancel'],
|
||||
assigned: ['reassign', 'start', 'cancel'],
|
||||
progress: ['pending', 'resolve', 'cancel'],
|
||||
pending: ['resume', 'cancel'],
|
||||
resolved: ticket.value?.verified_at ? ['close'] : ['verify'],
|
||||
})[ticket.value?.status] || [],
|
||||
)
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [detail, logs, options] = await Promise.all([
|
||||
getTicket(route.params.id),
|
||||
getTicketHistory(route.params.id),
|
||||
getTicketOptions(),
|
||||
])
|
||||
ticket.value = detail.data
|
||||
history.value = logs.data || []
|
||||
technicians.value = options?.data?.technicians || []
|
||||
installationSetting.value = options?.data?.installation_setting || { activation_mode: 'list_order' }
|
||||
customerOptions.value = options?.data || {}
|
||||
Object.assign(installationForm,{topology_id:ticket.value.customer?.topology_id||'',topology_port_number:ticket.value.customer?.topology_port_number||'',package_profile_id:ticket.value.customer?.package_profile_id||'',nas_mikrotik_id:ticket.value.customer?.nas_mikrotik_id||'',billing_profile_id:ticket.value.customer?.billing_profile_id||'',external_username:ticket.value.customer?.customer_code||''})
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Gagal memuat detail ticket.')
|
||||
router.replace('/tickets/ticket')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function openAction(action) {
|
||||
currentAction.value = action
|
||||
Object.assign(actionForm, {
|
||||
notes: '',
|
||||
reason: '',
|
||||
technicians: (ticket.value.assignments || []).map((x) => x.user_id),
|
||||
leader_id: ticket.value.assignments?.find((x) => x.is_leader)?.user_id || '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
})
|
||||
actionFiles.value = []
|
||||
actionModal.value = true
|
||||
}
|
||||
async function submitAction() {
|
||||
const action = currentAction.value,
|
||||
payload = {}
|
||||
if (['approve', 'start', 'resolve', 'verify', 'close'].includes(action))
|
||||
payload.notes = actionForm.notes || null
|
||||
if (['reject', 'pending', 'cancel'].includes(action)) payload.reason = actionForm.reason
|
||||
if (['assign', 'reassign'].includes(action)) {
|
||||
payload.technicians = actionForm.technicians.map(Number)
|
||||
payload.leader_id = Number(actionForm.leader_id)
|
||||
}
|
||||
if (action === 'start') {
|
||||
payload.latitude = actionForm.latitude || null
|
||||
payload.longitude = actionForm.longitude || null
|
||||
}
|
||||
if (['start', 'pending', 'resolve'].includes(action)) payload.stored_file_ids = actionFiles.value.map((x) => x.id)
|
||||
saving.value = true
|
||||
try {
|
||||
await ticketAction(ticket.value.id, action, payload)
|
||||
actionModal.value = false
|
||||
showSuccess(`${actionLabels[action]} berhasil.`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(
|
||||
e?.response?.data?.message ||
|
||||
Object.values(e?.response?.data?.errors || {})?.[0]?.[0] ||
|
||||
'Proses ticket gagal.',
|
||||
)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
async function saveMaterial() {
|
||||
saving.value = true
|
||||
try {
|
||||
const [type,id] = materialForm.source_key.split(':')
|
||||
await addTicketMaterial(ticket.value.id, { technician_material_id:type==='serialized'?Number(id):null, technician_consumable_id:type==='consumable'?Number(id):null, qty:materialForm.qty, notes:materialForm.notes })
|
||||
materialModal.value = false
|
||||
showSuccess('Material berhasil dicatat.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Material gagal ditambahkan.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
async function openMaterial() {
|
||||
const response = await getTicketMaterialOptions(ticket.value.id)
|
||||
materialOptions.value = response?.data || { serialized: [], consumables: [] }
|
||||
Object.assign(materialForm, { source_key: '', qty: 1, notes: '' })
|
||||
materialModal.value = true
|
||||
}
|
||||
async function uploadActionPhotos(event) { for (const file of [...(event.target.files || [])]) { try { const response=await uploadFile(file,'ticket-photo'); actionFiles.value.push(response.data) } catch(e) { showError(e?.response?.data?.message || 'Foto gagal diupload.') } } event.target.value='' }
|
||||
async function loadInstallationPorts(){installationForm.topology_port_number='';odpPorts.value=installationForm.topology_id?(await getInstallationPorts(ticket.value.id,installationForm.topology_id))?.data||[]:[]}
|
||||
async function uploadInstallationPhotos(event){for(const file of [...(event.target.files||[])]){const r=await uploadFile(file,'customer-image');installationForm.stored_file_ids.push(r.data.id)}event.target.value=''}
|
||||
async function submitInstallationStep(){const step=ticket.value.installation_step;let payload={step};if(step==='documentation')payload={...payload,stored_file_ids:installationForm.stored_file_ids,topology_id:installationForm.topology_id,topology_port_number:installationForm.topology_port_number};if(step==='service')payload={...payload,package_profile_id:installationForm.package_profile_id,nas_mikrotik_id:installationForm.nas_mikrotik_id,billing_profile_id:installationForm.billing_profile_id};if(step==='activation')payload={...payload,package_profile_id:installationForm.package_profile_id,nas_mikrotik_id:installationForm.nas_mikrotik_id,billing_profile_id:installationForm.billing_profile_id,external_username:installationForm.external_username,external_password:installationForm.external_password,serial_number:installationForm.serial_number||null,mac_address:installationForm.mac_address||null};saving.value=true;try{await saveInstallationStep(ticket.value.id,payload);showSuccess('Tahap pemasangan berhasil disimpan.');await load()}catch(e){showError(e?.response?.data?.message||Object.values(e?.response?.data?.errors||{})?.[0]?.[0]||'Tahap gagal disimpan.')}finally{saving.value=false}}
|
||||
async function removeMaterial(item) {
|
||||
const a = await showConfirm('Hapus Material', `Hapus ${item.material_name} dari ticket?`)
|
||||
if (!a.isConfirmed) return
|
||||
try {
|
||||
await deleteTicketMaterial(ticket.value.id, item.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Material gagal dihapus.')
|
||||
}
|
||||
}
|
||||
function openActivation() {
|
||||
activationForm.external_username = ticket.value.customer?.external_username || ticket.value.customer?.customer_code || ''
|
||||
activationForm.external_password = ''
|
||||
activationModal.value = true
|
||||
}
|
||||
async function submitActivation() {
|
||||
const customer = ticket.value.customer
|
||||
if (!customer?.package_profile_id || !customer?.nas_mikrotik_id || !customer?.billing_profile_id)
|
||||
return showError('Profile paket, NAS, dan profile tagihan customer harus dilengkapi dahulu.')
|
||||
if (!activationForm.external_username || !activationForm.external_password)
|
||||
return showError('Username dan password koneksi wajib diisi.')
|
||||
saving.value = true
|
||||
try {
|
||||
await activateCustomer(customer.id, {
|
||||
activation_source: 'ticket',
|
||||
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('Customer berhasil diaktifkan dari Ticket.')
|
||||
await load()
|
||||
} catch (e) { showError(e?.response?.data?.message || 'Aktivasi customer gagal.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<div class="ticket-detail-page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<CButton color="secondary" variant="ghost" size="sm" @click="router.push('/tickets/ticket')"
|
||||
>← Kembali</CButton
|
||||
>
|
||||
<h4 class="mb-0 mt-1">{{ ticket?.ticket_no || 'Detail Ticket' }}</h4>
|
||||
<small class="text-body-secondary">{{ ticket?.title }}</small>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<CButton
|
||||
v-if="ticket?.customer_id"
|
||||
color="info"
|
||||
size="sm"
|
||||
@click="router.push(`/customers/orders/${ticket.customer_id}/edit`)"
|
||||
>Buka Form Customer</CButton
|
||||
><CButton
|
||||
v-if="ticket?.customer_id && ticket?.status === 'resolved' && installationSetting.activation_mode === 'ticket' && ticket.customer?.status !== 'active' && canActivate"
|
||||
color="success"
|
||||
size="sm"
|
||||
@click="openActivation"
|
||||
>Aktivasi Customer</CButton
|
||||
><CButton
|
||||
v-for="action in availableActions"
|
||||
:key="action"
|
||||
:color="
|
||||
['cancel', 'reject'].includes(action)
|
||||
? 'danger'
|
||||
: action === 'close'
|
||||
? 'success'
|
||||
: 'primary'
|
||||
"
|
||||
size="sm"
|
||||
@click="openAction(action)"
|
||||
>{{ actionLabels[action] }}</CButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="loading">
|
||||
<CSpinner color="primary" /><span>Memuat detail ticket...</span>
|
||||
</div>
|
||||
<template v-else-if="ticket"
|
||||
><div class="flow">
|
||||
<span>Approval</span><span>Penugasan</span><span>Mulai Kerja</span><span>Isi Form</span
|
||||
><span>Selesaikan</span><span>Aktivasi</span>
|
||||
</div>
|
||||
<div class="summary">
|
||||
<div>
|
||||
<small>Status</small><strong>{{ statusLabels[ticket.status] || ticket.status }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>Customer</small><strong>{{ ticket.customer?.name || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>Sumber</small><strong>{{ ticket.customer_id ? 'Pemasangan baru' : 'Ticket umum' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>Prioritas</small><strong>{{ priorityLabels[ticket.priority] }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<CRow class="g-3"
|
||||
><CCol :xl="8"
|
||||
><CCard class="section-card mb-3"
|
||||
><CCardHeader><strong>Informasi Pekerjaan</strong></CCardHeader
|
||||
><CCardBody
|
||||
><p>{{ ticket.description || 'Tidak ada deskripsi.' }}</p>
|
||||
<div class="details">
|
||||
<span>Jenis tiket</span><strong>{{ ticket.ticket_type?.name || '-' }}</strong
|
||||
><span>Tenant</span><strong>{{ ticket.tenant?.tenant_name || '-' }}</strong
|
||||
><span>Dibuat oleh</span><strong>{{ ticket.creator?.name || '-' }}</strong
|
||||
><span>SLA</span
|
||||
><strong>{{ ticket.sla_minutes ? `${ticket.sla_minutes} menit` : '-' }}</strong>
|
||||
</div></CCardBody
|
||||
></CCard
|
||||
>
|
||||
<CCard v-if="ticket.source==='new_installation' && ['progress','pending'].includes(ticket.status) && ticket.installation_step!=='completed'" class="section-card mb-3">
|
||||
<CCardHeader><strong>Proses Pemasangan Baru</strong><div class="small text-body-secondary">Tahap aktif: {{ {documentation:'Dokumentasi, Topologi & Port ODP',service:'Data Layanan',activation:'Aktivasi Customer'}[ticket.installation_step] }}</div></CCardHeader>
|
||||
<CCardBody class="d-grid gap-3">
|
||||
<template v-if="ticket.installation_step==='documentation'"><CFormInput type="file" accept="image/*" multiple @change="uploadInstallationPhotos"/><small>{{installationForm.stored_file_ids.length}} foto dokumentasi siap.</small><CFormSelect v-model="installationForm.topology_id" @change="loadInstallationPorts"><option value="">Pilih ODP</option><option v-for="x in customerOptions.topology_odps||[]" :key="x.id" :value="x.id">{{x.code}} · {{x.name}}</option></CFormSelect><CFormSelect v-model="installationForm.topology_port_number" :disabled="!installationForm.topology_id"><option value="">Pilih port ODP</option><option v-for="x in odpPorts" :key="x.port_number" :value="x.port_number">Port {{x.port_number}}</option></CFormSelect></template>
|
||||
<template v-if="ticket.installation_step==='service'"><CFormSelect v-model="installationForm.package_profile_id"><option value="">Pilih profile paket</option><option v-for="x in customerOptions.package_profiles||[]" :key="x.id" :value="x.id">{{x.name}}</option></CFormSelect><CFormSelect v-model="installationForm.nas_mikrotik_id"><option value="">Pilih NAS</option><option v-for="x in customerOptions.nas_mikrotiks||[]" :key="x.id" :value="x.id">{{x.name}}</option></CFormSelect><CFormSelect v-model="installationForm.billing_profile_id"><option value="">Pilih profile tagihan</option><option v-for="x in customerOptions.billing_profiles||[]" :key="x.id" :value="x.id">{{x.name}}</option></CFormSelect></template>
|
||||
<template v-if="ticket.installation_step==='activation'"><CFormInput v-model="installationForm.external_username" placeholder="Username koneksi"/><CFormInput v-model="installationForm.external_password" type="password" placeholder="Password koneksi"/><CFormInput v-model="installationForm.serial_number" placeholder="Serial Number (opsional)"/><CFormInput v-model="installationForm.mac_address" placeholder="MAC Address (opsional)"/></template>
|
||||
<CButton color="success" :disabled="saving" @click="submitInstallationStep">Simpan dan Lanjutkan Tahap</CButton>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
<CCard class="section-card"
|
||||
><CCardHeader class="d-flex justify-content-between align-items-center"
|
||||
><div>
|
||||
<strong>Material Pekerjaan</strong>
|
||||
<div class="small text-body-secondary">
|
||||
Material menjadi bagian dari penyelesaian ticket bila jenis ticket mewajibkannya.
|
||||
</div>
|
||||
</div>
|
||||
<CButton
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="openMaterial"
|
||||
>+ Material</CButton
|
||||
></CCardHeader
|
||||
><CCardBody
|
||||
><div v-if="ticket.materials?.length" class="material-list">
|
||||
<div v-for="item in ticket.materials" :key="item.id">
|
||||
<div>
|
||||
<strong>{{ item.material_name }}</strong
|
||||
><small
|
||||
>{{ item.qty }} · {{ item.notes || 'Tanpa catatan' }} ·
|
||||
{{ item.user?.name || '-' }}</small
|
||||
>
|
||||
</div>
|
||||
<CButton color="danger" variant="ghost" size="sm" @click="removeMaterial(item)"
|
||||
>Hapus</CButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">Belum ada material yang digunakan.</div></CCardBody
|
||||
></CCard
|
||||
></CCol
|
||||
>
|
||||
<CCol :xl="4"
|
||||
><CCard class="section-card mb-3"
|
||||
><CCardHeader><strong>Tim Teknisi</strong></CCardHeader
|
||||
><CCardBody
|
||||
><div v-if="ticket.assignments?.length" class="team">
|
||||
<div v-for="a in ticket.assignments" :key="a.id">
|
||||
<strong>{{ a.user?.name }}</strong
|
||||
><CBadge :color="a.is_leader ? 'primary' : 'secondary'">{{
|
||||
a.is_leader ? 'Leader' : 'Anggota'
|
||||
}}</CBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">Belum ada teknisi yang ditugaskan.</div></CCardBody
|
||||
></CCard
|
||||
><CCard class="section-card"
|
||||
><CCardHeader><strong>Riwayat Ticket</strong></CCardHeader
|
||||
><CCardBody class="timeline"
|
||||
><div v-for="log in history" :key="log.id">
|
||||
<strong>{{ log.activity }}</strong
|
||||
><small>{{ log.notes || '-' }}</small
|
||||
><time>{{ new Date(log.created_at).toLocaleString('id-ID') }}</time>
|
||||
</div>
|
||||
<div v-if="!history.length" class="empty">Belum ada riwayat.</div></CCardBody
|
||||
></CCard
|
||||
></CCol
|
||||
></CRow
|
||||
></template
|
||||
>
|
||||
</div>
|
||||
<CModal :visible="actionModal" alignment="center" @close="actionModal = false"
|
||||
><CModalHeader
|
||||
><CModalTitle>{{ actionLabels[currentAction] }}</CModalTitle></CModalHeader
|
||||
><CModalBody
|
||||
><template v-if="['assign', 'reassign'].includes(currentAction)"
|
||||
><CFormLabel>Teknisi</CFormLabel>
|
||||
<div class="tech-options">
|
||||
<CFormCheck
|
||||
v-for="x in technicians"
|
||||
:key="x.id"
|
||||
v-model="actionForm.technicians"
|
||||
:value="x.id"
|
||||
:label="x.name"
|
||||
/>
|
||||
</div>
|
||||
<CFormLabel class="mt-3">Leader</CFormLabel
|
||||
><CFormSelect v-model="actionForm.leader_id"
|
||||
><option value="">Pilih leader</option>
|
||||
<option
|
||||
v-for="x in technicians.filter((t) =>
|
||||
actionForm.technicians.map(Number).includes(t.id),
|
||||
)"
|
||||
:key="x.id"
|
||||
:value="x.id"
|
||||
>
|
||||
{{ x.name }}
|
||||
</option></CFormSelect
|
||||
></template
|
||||
><template v-else-if="['reject', 'pending', 'cancel'].includes(currentAction)"
|
||||
><CFormLabel>Alasan</CFormLabel
|
||||
><CFormTextarea v-model="actionForm.reason" rows="3" /></template
|
||||
><template v-else
|
||||
><CFormLabel>Catatan</CFormLabel
|
||||
><CFormTextarea v-model="actionForm.notes" rows="3" /></template>
|
||||
<div v-if="['start','pending','resolve'].includes(currentAction)" class="mt-3"><CFormLabel>Foto Bukti <span class="text-danger">*</span></CFormLabel><CFormInput type="file" accept="image/*" multiple @change="uploadActionPhotos"/><small class="text-body-secondary">{{ actionFiles.length }} foto siap dikirim.</small></div></CModalBody
|
||||
><CModalFooter
|
||||
><CButton color="secondary" @click="actionModal = false">Batal</CButton
|
||||
><CButton color="primary" :disabled="saving" @click="submitAction"
|
||||
>Konfirmasi</CButton
|
||||
></CModalFooter
|
||||
></CModal
|
||||
>
|
||||
<CModal :visible="materialModal" alignment="center" @close="materialModal = false"
|
||||
><CModalHeader><CModalTitle>Tambah Material Pekerjaan</CModalTitle></CModalHeader
|
||||
><CModalBody
|
||||
><div class="d-grid gap-3">
|
||||
<div><CFormLabel>Material yang Dibawa</CFormLabel><CFormSelect v-model="materialForm.source_key"><option value="">Pilih material</option><optgroup label="Barang Serial"><option v-for="x in materialOptions.serialized" :key="`s${x.id}`" :value="`serialized:${x.id}`">{{x.material_name}} · {{x.barcode_id}}</option></optgroup><optgroup label="Barang Kuantitas"><option v-for="x in materialOptions.consumables" :key="`c${x.id}`" :value="`consumable:${x.id}`">{{x.material_name}} · sisa {{x.qty_remaining}} {{x.unit}}</option></optgroup></CFormSelect></div>
|
||||
<div>
|
||||
<CFormLabel>Jumlah</CFormLabel
|
||||
><CFormInput v-model="materialForm.qty" type="number" min="0.01" step="0.01" />
|
||||
</div>
|
||||
<div>
|
||||
<CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="materialForm.notes" rows="2" />
|
||||
</div></div></CModalBody
|
||||
><CModalFooter
|
||||
><CButton color="secondary" @click="materialModal = false">Batal</CButton
|
||||
><CButton color="primary" :disabled="saving" @click="saveMaterial"
|
||||
>Simpan Material</CButton
|
||||
></CModalFooter
|
||||
></CModal
|
||||
>
|
||||
<CModal :visible="activationModal" alignment="center" @close="activationModal = false">
|
||||
<CModalHeader><CModalTitle>Aktivasi Customer</CModalTitle></CModalHeader>
|
||||
<CModalBody><div class="d-grid gap-3">
|
||||
<div><CFormLabel>Username Koneksi</CFormLabel><CFormInput v-model="activationForm.external_username" /></div>
|
||||
<div><CFormLabel>Password Koneksi</CFormLabel><CFormInput v-model="activationForm.external_password" type="password" /></div>
|
||||
<small class="text-body-secondary">Profile paket, NAS, dan profile tagihan mengikuti detail customer.</small>
|
||||
</div></CModalBody>
|
||||
<CModalFooter><CButton color="secondary" @click="activationModal=false">Batal</CButton><CButton color="success" :disabled="saving" @click="submitActivation">Aktifkan</CButton></CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
<style scoped>
|
||||
.ticket-detail-page {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-height: 16rem;
|
||||
}
|
||||
.flow {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.flow span {
|
||||
white-space: nowrap;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--cui-tertiary-bg);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.summary > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--cui-tertiary-bg);
|
||||
}
|
||||
.summary small,
|
||||
.material-list small,
|
||||
.timeline small,
|
||||
.timeline time {
|
||||
color: var(--cui-secondary-color);
|
||||
}
|
||||
.section-card {
|
||||
border: 1px solid var(--cui-border-color);
|
||||
border-radius: 0.8rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.55rem 1rem;
|
||||
}
|
||||
.material-list,
|
||||
.team,
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.material-list > div,
|
||||
.team > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.65rem;
|
||||
background: var(--cui-tertiary-bg);
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
.material-list > div > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.timeline > div:not(.empty) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 0.7rem;
|
||||
border-left: 3px solid var(--cui-primary);
|
||||
}
|
||||
.timeline time {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.tech-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.4rem;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--cui-border-color);
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--cui-secondary-color);
|
||||
padding: 1rem;
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.page-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.summary {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.tech-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { getTickets } from '@/services/ticketService'
|
||||
import { showError } from '@/utils/swal.js'
|
||||
const props=defineProps({status:{type:String,required:true},title:{type:String,required:true},description:{type:String,default:''}})
|
||||
const loading=ref(true),items=ref([]),query=ref({page:1,per_page:10}),pagination=ref({current_page:1,last_page:1,per_page:10,total:0})
|
||||
const columns=[{key:'ticket_no',label:'Nomor Tiket'},{key:'title',label:'Pekerjaan'},{key:'customer_label',label:'Customer',sortable:false},{key:'priority_label',label:'Prioritas',sortable:false},{key:'created_label',label:'Dibuat',sortable:false}]
|
||||
async function load(params=query.value){query.value={...query.value,...params,status:props.status};loading.value=true;try{const r=await getTickets(query.value),p=r?.data||{};items.value=(p.data||[]).map(x=>({...x,customer_label:x.customer?`${x.customer.customer_code||'-'} · ${x.customer.name}`:'-',priority_label:({low:'Rendah',medium:'Sedang',high:'Tinggi',critical:'Kritis'})[x.priority],created_label:x.created_at?new Date(x.created_at).toLocaleString('id-ID'):'-'}));pagination.value={current_page:p.current_page||1,last_page:p.last_page||1,per_page:p.per_page||10,total:p.total||0}}catch(e){showError(e?.response?.data?.message||'Gagal memuat ticket.')}finally{loading.value=false}}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template><div class="brdvx-page-container"><div class="brdvx-page-header"><h5 class="mb-0">{{title}}</h5><small class="text-body-secondary">{{description}}</small></div><BaseResponsiveDataView :columns="columns" :items="items" :loading="loading" server-side :pagination="pagination" :hide-default-actions="true" @query-change="load"/></div></template>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import {
|
||||
getWarehouseItems,
|
||||
getWarehouseOptions,
|
||||
issueWarehouseBarcode,
|
||||
} from '@/services/warehouseService'
|
||||
import { showError, showSuccess } from '@/utils/swal.js'
|
||||
const router = useRouter(),
|
||||
loading = ref(true),
|
||||
items = ref([]),
|
||||
pagination = ref({}),
|
||||
users = ref([]),
|
||||
modal = ref(false),
|
||||
selected = ref(null),
|
||||
form = reactive({ barcode_id: '', user_id: '', qty: 1, notes: '' })
|
||||
const columns = [
|
||||
{ key: 'item_number', label: 'Nomor Item' },
|
||||
{ key: 'name', label: 'Barang' },
|
||||
{ key: 'warehouse_name', label: 'Gudang' },
|
||||
{ key: 'stock_label', label: 'Stok Tersedia' },
|
||||
{ key: 'barcode_count', label: 'Barcode' },
|
||||
]
|
||||
async function load(q = { page: 1, per_page: 10 }) {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await getWarehouseItems({ ...q, status: 'active' }),
|
||||
d = r.data || {}
|
||||
items.value = (d.data || []).map((x) => ({
|
||||
...x,
|
||||
name: x.name,
|
||||
warehouse_name: x.warehouse?.name,
|
||||
stock_label: `${(x.barcodes || []).reduce((n, b) => n + Number(b.remaining_qty), 0)} ${x.unit}`,
|
||||
barcode_count: x.barcodes?.length || 0,
|
||||
}))
|
||||
pagination.value = {
|
||||
current_page: d.current_page,
|
||||
last_page: d.last_page,
|
||||
per_page: d.per_page,
|
||||
total: d.total,
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Gagal memuat barang aktif.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function issue(x) {
|
||||
selected.value = x
|
||||
const b = x.barcodes.find((y) => Number(y.remaining_qty) > 0)
|
||||
Object.assign(form, { barcode_id: b?.id || '', user_id: '', qty: 1, notes: '' })
|
||||
modal.value = true
|
||||
}
|
||||
async function submit() {
|
||||
try {
|
||||
await issueWarehouseBarcode(form.barcode_id, {
|
||||
user_id: form.user_id,
|
||||
qty: form.qty,
|
||||
notes: form.notes,
|
||||
})
|
||||
modal.value = false
|
||||
showSuccess('Pengeluaran barang berhasil dicatat.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Pengeluaran gagal.')
|
||||
}
|
||||
}
|
||||
function action({ action, item }) {
|
||||
action === 'detail' ? router.push(`/warehouse/items/${item.id}`) : issue(item)
|
||||
}
|
||||
onMounted(async () => {
|
||||
const o = await getWarehouseOptions()
|
||||
users.value = o?.data?.users || []
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between">
|
||||
<div>
|
||||
<h5 class="mb-0">Daftar Barang</h5>
|
||||
<small class="text-body-secondary"
|
||||
>Stok aktif yang sudah diposting dan siap dikeluarkan.</small
|
||||
>
|
||||
</div>
|
||||
<CButton color="warning" size="sm" :disabled="!items.length" @click="issue(items[0])"
|
||||
>Pengeluaran</CButton
|
||||
>
|
||||
</div>
|
||||
<BaseResponsiveDataView
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
server-side
|
||||
:pagination="pagination"
|
||||
:actions="[
|
||||
{ key: 'detail', label: 'Detail', color: 'info' },
|
||||
{ key: 'issue', label: 'Pengeluaran', color: 'warning' },
|
||||
]"
|
||||
@query-change="load"
|
||||
@action-click="action"
|
||||
/>
|
||||
</div>
|
||||
<CModal :visible="modal" @close="modal = false"
|
||||
><CModalHeader
|
||||
><CModalTitle>Pengeluaran · {{ selected?.name }}</CModalTitle></CModalHeader
|
||||
><CModalBody
|
||||
><div class="d-grid gap-2">
|
||||
<CFormSelect v-model="form.barcode_id" label="Barcode"
|
||||
><option
|
||||
v-for="x in selected?.barcodes.filter((y) => Number(y.remaining_qty) > 0)"
|
||||
:key="x.id"
|
||||
:value="x.id"
|
||||
>
|
||||
{{ x.barcode }} · {{ x.remaining_qty }} {{ x.unit }}
|
||||
</option></CFormSelect
|
||||
><CFormSelect v-model="form.user_id" label="Diserahkan kepada"
|
||||
><option value="">Pilih teknisi/user</option>
|
||||
<option v-for="x in users" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect
|
||||
><CFormInput
|
||||
v-model="form.qty"
|
||||
type="number"
|
||||
min="0.01"
|
||||
:disabled="selected?.item_code?.tracking_type === 'serialized'"
|
||||
label="Jumlah"
|
||||
/><CFormTextarea v-model="form.notes" label="Catatan" /></div></CModalBody
|
||||
><CModalFooter
|
||||
><CButton @click="modal = false">Batal</CButton
|
||||
><CButton color="warning" @click="submit">Catat Pengeluaran</CButton></CModalFooter
|
||||
></CModal
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { getDepletedItems } from '@/services/warehouseService'
|
||||
import { showError } from '@/utils/swal.js'
|
||||
const router = useRouter(),
|
||||
loading = ref(true),
|
||||
items = ref([]),
|
||||
pagination = ref({})
|
||||
const columns = [
|
||||
{ key: 'item_number', label: 'Nomor Item' },
|
||||
{ key: 'name', label: 'Barang' },
|
||||
{ key: 'warehouse_name', label: 'Gudang' },
|
||||
{ key: 'received_qty', label: 'Jumlah Awal' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
async function load(q = { page: 1, per_page: 10 }) {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await getDepletedItems(q),
|
||||
d = r.data || {}
|
||||
items.value = (d.data || []).map((x) => ({
|
||||
...x,
|
||||
name: x.name,
|
||||
warehouse_name: x.warehouse?.name,
|
||||
}))
|
||||
pagination.value = {
|
||||
current_page: d.current_page,
|
||||
last_page: d.last_page,
|
||||
per_page: d.per_page,
|
||||
total: d.total,
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Gagal memuat barang habis.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header">
|
||||
<h5 class="mb-0">Barang Habis</h5>
|
||||
<small class="text-body-secondary">Batch barang dengan sisa stok nol.</small>
|
||||
</div>
|
||||
<BaseResponsiveDataView
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
server-side
|
||||
:pagination="pagination"
|
||||
:actions="[{ key: 'detail', label: 'Detail', color: 'info' }]"
|
||||
@query-change="load"
|
||||
@action-click="({ item }) => router.push(`/warehouse/items/${item.id}`)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { createItemCode, deleteItemCode, getItemCodes, updateItemCode } from '@/services/warehouseService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
const loading=ref(true),items=ref([]),pagination=ref({}),modal=ref(false),selected=ref(null),query=ref({page:1,per_page:10})
|
||||
const form=reactive({code:'',name:'',tracking_type:'serialized',need_serial_number:false,unit:'pcs',minimum_stock:0,description:'',is_active:true})
|
||||
const columns=[{key:'code',label:'Kode'},{key:'name',label:'Nama Barang'},{key:'tracking_label',label:'Tracking'},{key:'unit_label',label:'Satuan'},{key:'minimum_stock',label:'Minimum Stok'}]
|
||||
async function load(p=query.value){query.value={...query.value,...p};loading.value=true;try{const r=await getItemCodes(query.value),d=r.data||{};items.value=(d.data||[]).map(x=>({...x,tracking_label:x.tracking_type==='serialized'?'Per Unit':'Berdasarkan Jumlah',unit_label:unitOptions.find(u=>u.value===x.unit)?.label||x.unit}));pagination.value={current_page:d.current_page,last_page:d.last_page,per_page:d.per_page,total:d.total}}catch(e){showError(e?.response?.data?.message||'Gagal memuat kode barang.')}finally{loading.value=false}}
|
||||
const unitOptions=[{value:'pcs',label:'PCS'},{value:'meter',label:'Meter'},{value:'lembar',label:'Lembar'},{value:'roll',label:'Roll'},{value:'box',label:'Box'},{value:'dus',label:'Dus'},{value:'set',label:'Set'},{value:'unit',label:'Unit'}]
|
||||
function open(item=null){selected.value=item;Object.assign(form,item||{code:'',name:'',tracking_type:'serialized',need_serial_number:false,unit:'pcs',minimum_stock:0,description:'',is_active:true});modal.value=true}
|
||||
async function save(){try{selected.value?await updateItemCode(selected.value.id,form):await createItemCode(form);modal.value=false;showSuccess('Kode barang berhasil disimpan.');await load()}catch(e){showError(e?.response?.data?.message||Object.values(e?.response?.data?.errors||{})?.[0]?.[0]||'Gagal menyimpan kode barang.')}}
|
||||
async function remove(item){const result=await showConfirm('Hapus Kode Barang',`Hapus kode ${item.code}?`);if(!result.isConfirmed)return;try{await deleteItemCode(item.id);showSuccess('Kode barang berhasil dihapus.');await load()}catch(e){showError(e?.response?.data?.message||'Kode barang tidak dapat dihapus.')}}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template><div class="brdvx-page-container"><div class="brdvx-page-header d-flex justify-content-between"><div><h5 class="mb-0">Kode Barang</h5><small class="text-body-secondary">Master jenis, tracking, serial number, satuan, dan batas minimum stok.</small></div><CButton color="primary" size="sm" @click="open()">+ Kode Barang</CButton></div><BaseResponsiveDataView :columns="columns" :items="items" :loading="loading" server-side :pagination="pagination" :actions="[{key:'edit',label:'Edit',color:'warning'},{key:'delete',label:'Hapus',color:'danger'}]" @query-change="load" @action-click="({action,item})=>action==='edit'?open(item):remove(item)" /></div>
|
||||
<CModal :visible="modal" @close="modal=false"><CModalHeader><CModalTitle>{{selected?'Edit':'Tambah'}} Kode Barang</CModalTitle></CModalHeader><CModalBody><div class="d-grid gap-2"><CFormInput v-model="form.code" label="Kode barang"/><CFormInput v-model="form.name" label="Nama barang"/><CFormSelect v-model="form.tracking_type" label="Jenis tracking"><option value="serialized">Per Unit / serialized</option><option value="quantity">Berdasarkan jumlah / quantity</option></CFormSelect><CFormCheck v-model="form.need_serial_number" label="Serial number wajib"/><CFormSelect v-model="form.unit" label="Satuan"><option v-for="unit in unitOptions" :key="unit.value" :value="unit.value">{{unit.label}}</option></CFormSelect><CFormInput v-model="form.minimum_stock" type="number" min="0" label="Minimum stok"/><CFormTextarea v-model="form.description" label="Keterangan"/></div></CModalBody><CModalFooter><CButton @click="modal=false">Batal</CButton><CButton color="primary" @click="save">Simpan</CButton></CModalFooter></CModal></template>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import {
|
||||
createPendingItem,
|
||||
getItemCodes,
|
||||
getPendingItems,
|
||||
getWarehouses,
|
||||
postPendingItem,
|
||||
saveItemSerials,
|
||||
} from '@/services/warehouseService'
|
||||
import { showError, showSuccess } from '@/utils/swal.js'
|
||||
const loading = ref(true),
|
||||
items = ref([]),
|
||||
pagination = ref({}),
|
||||
modal = ref(false),
|
||||
serialModal = ref(false),
|
||||
selected = ref(null),
|
||||
codes = ref([]),
|
||||
warehouses = ref([])
|
||||
const form = reactive({
|
||||
warehouse_id: '',
|
||||
item_code_id: '',
|
||||
name: '',
|
||||
quantity: 1,
|
||||
package_count: 1,
|
||||
received_at: new Date().toISOString().slice(0, 10),
|
||||
notes: '',
|
||||
})
|
||||
const columns = [
|
||||
{ key: 'item_number', label: 'Nomor Item' },
|
||||
{ key: 'name', label: 'Nama Barang' },
|
||||
{ key: 'warehouse_name', label: 'Gudang' },
|
||||
{ key: 'received_qty', label: 'Jumlah' },
|
||||
{ key: 'barcode_count', label: 'Barcode' },
|
||||
{ key: 'readiness', label: 'Kesiapan' },
|
||||
]
|
||||
async function load(q = { page: 1, per_page: 10 }) {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await getPendingItems(q),
|
||||
d = r.data || {}
|
||||
items.value = (d.data || []).map((x) => ({
|
||||
...x,
|
||||
warehouse_name: x.warehouse?.name,
|
||||
barcode_count: x.barcodes?.length || 0,
|
||||
readiness: x.item_code?.need_serial_number
|
||||
? `${(x.barcodes || []).filter((b) => b.serial_number).length}/${x.barcodes.length} SN`
|
||||
: 'Siap diposting',
|
||||
}))
|
||||
pagination.value = {
|
||||
current_page: d.current_page,
|
||||
last_page: d.last_page,
|
||||
per_page: d.per_page,
|
||||
total: d.total,
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Gagal memuat barang pending.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
try {
|
||||
await createPendingItem(form)
|
||||
modal.value = false
|
||||
showSuccess('Draft barang dan barcode berhasil dibuat.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(
|
||||
e?.response?.data?.message ||
|
||||
Object.values(e?.response?.data?.errors || {})?.[0]?.[0] ||
|
||||
'Gagal membuat draft.',
|
||||
)
|
||||
}
|
||||
}
|
||||
function serials(item) {
|
||||
selected.value = JSON.parse(JSON.stringify(item))
|
||||
serialModal.value = true
|
||||
}
|
||||
async function saveSerials() {
|
||||
try {
|
||||
await saveItemSerials(
|
||||
selected.value.id,
|
||||
selected.value.barcodes.map((x) => ({ id: x.id, serial_number: x.serial_number })),
|
||||
)
|
||||
serialModal.value = false
|
||||
showSuccess('Serial number berhasil disimpan.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Gagal menyimpan serial number.')
|
||||
}
|
||||
}
|
||||
async function post(item) {
|
||||
try {
|
||||
await postPendingItem(item.id)
|
||||
showSuccess('Barang berhasil diposting.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Barang belum dapat diposting.')
|
||||
}
|
||||
}
|
||||
function action({ action, item }) {
|
||||
action === 'serial' ? serials(item) : post(item)
|
||||
}
|
||||
onMounted(async () => {
|
||||
const [c, w] = await Promise.all([
|
||||
getItemCodes({ per_page: 100 }),
|
||||
getWarehouses({ per_page: 100 }),
|
||||
])
|
||||
codes.value = c?.data?.data || []
|
||||
warehouses.value = w?.data?.data || []
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between">
|
||||
<div>
|
||||
<h5 class="mb-0">Barang Pending</h5>
|
||||
<small class="text-body-secondary"
|
||||
>Lengkapi barcode dan serial number sebelum stok diposting.</small
|
||||
>
|
||||
</div>
|
||||
<CButton color="primary" size="sm" @click="modal = true">+ Tambah Barang</CButton>
|
||||
</div>
|
||||
<BaseResponsiveDataView
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
server-side
|
||||
:pagination="pagination"
|
||||
:actions="[
|
||||
{ key: 'serial', label: 'Barcode & SN', color: 'warning' },
|
||||
{ key: 'post', label: 'Posting', color: 'success' },
|
||||
]"
|
||||
@query-change="load"
|
||||
@action-click="action"
|
||||
/>
|
||||
</div>
|
||||
<CModal :visible="modal" @close="modal = false"
|
||||
><CModalHeader><CModalTitle>Tambah Barang Pending</CModalTitle></CModalHeader
|
||||
><CModalBody
|
||||
><div class="d-grid gap-2">
|
||||
<CFormSelect v-model="form.warehouse_id" label="Gudang"
|
||||
><option value="">Pilih gudang</option>
|
||||
<option v-for="x in warehouses" :key="x.id" :value="x.id">
|
||||
{{ x.code }} · {{ x.name }}
|
||||
</option></CFormSelect
|
||||
><CFormSelect v-model="form.item_code_id" label="Kode barang"
|
||||
><option value="">Pilih barang</option>
|
||||
<option v-for="x in codes" :key="x.id" :value="x.id">
|
||||
{{ x.code }} · {{ x.name }}
|
||||
</option></CFormSelect
|
||||
><CFormInput v-model="form.name" label="Nama barang" placeholder="Masukkan nama barang" />
|
||||
<CFormInput
|
||||
v-model="form.quantity"
|
||||
type="number"
|
||||
min="0.01"
|
||||
label="Jumlah total"
|
||||
/><CFormInput
|
||||
v-if="codes.find((x) => x.id == form.item_code_id)?.tracking_type === 'quantity'"
|
||||
v-model="form.package_count"
|
||||
type="number"
|
||||
min="1"
|
||||
label="Jumlah barcode/kemasan"
|
||||
/><CFormInput
|
||||
v-model="form.received_at"
|
||||
type="date"
|
||||
label="Tanggal penerimaan"
|
||||
/><CFormTextarea v-model="form.notes" label="Catatan" /></div></CModalBody
|
||||
><CModalFooter
|
||||
><CButton @click="modal = false">Batal</CButton
|
||||
><CButton color="primary" @click="save">Buat Draft</CButton></CModalFooter
|
||||
></CModal
|
||||
><CModal size="lg" :visible="serialModal" @close="serialModal = false"
|
||||
><CModalHeader><CModalTitle>Barcode dan Serial Number</CModalTitle></CModalHeader
|
||||
><CModalBody
|
||||
><div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Barcode</th>
|
||||
<th>Jumlah</th>
|
||||
<th>Serial Number</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="x in selected?.barcodes" :key="x.id">
|
||||
<td>{{ x.barcode }}</td>
|
||||
<td>{{ x.initial_qty }} {{ x.unit }}</td>
|
||||
<td>
|
||||
<CFormInput
|
||||
v-model="x.serial_number"
|
||||
:disabled="!selected?.item_code?.need_serial_number"
|
||||
placeholder="Serial number"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div></CModalBody
|
||||
><CModalFooter
|
||||
><CButton @click="serialModal = false">Tutup</CButton
|
||||
><CButton color="primary" @click="saveSerials">Simpan SN</CButton></CModalFooter
|
||||
></CModal
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { getTechnicianInventory, returnTechnicianInventory } from '@/services/warehouseService'
|
||||
import { showError, showSuccess } from '@/utils/swal.js'
|
||||
const loading=ref(true),items=ref([]),pagination=ref({}),modal=ref(false),selected=ref(null),form=ref({qty:1,notes:''})
|
||||
const columns=[{key:'user',label:'Teknisi'},{key:'material_name',label:'Barang'},{key:'barcode',label:'Barcode'},{key:'tracking_label',label:'Tracking'},{key:'qty_label',label:'Jumlah/Sisa'},{key:'status',label:'Status'}]
|
||||
async function load(q={page:1,per_page:10}){loading.value=true;try{const r=await getTechnicianInventory(q),d=r.data||{};items.value=(d.data||[]).map(x=>({...x,tracking_label:x.tracking_type==='serialized'?'Per Unit':'Quantity',qty_label:`${x.qty} ${x.unit}`}));pagination.value={current_page:d.current_page,last_page:d.last_page,per_page:d.per_page,total:d.total}}catch(e){showError(e?.response?.data?.message||'Gagal memuat peralatan teknisi.')}finally{loading.value=false}}
|
||||
onMounted(load)
|
||||
function openReturn(item){selected.value=item;form.value={qty:item.tracking_type==='serialized'?1:item.qty,notes:''};modal.value=true}
|
||||
async function submitReturn(){try{const [prefix,id]=selected.value.id.split('-');await returnTechnicianInventory({source_type:prefix==='s'?'serialized':'quantity',source_id:Number(id),qty:form.value.qty,notes:form.value.notes});modal.value=false;showSuccess('Barang berhasil dikembalikan ke gudang.');await load()}catch(e){showError(e?.response?.data?.message||Object.values(e?.response?.data?.errors||{})?.[0]?.[0]||'Pengembalian gagal.')}}
|
||||
</script>
|
||||
<template><div class="brdvx-page-container"><div class="brdvx-page-header"><h5 class="mb-0">Peralatan di Teknisi</h5><small class="text-body-secondary">Barang dan material yang masih menjadi tanggung jawab masing-masing teknisi.</small></div><BaseResponsiveDataView :columns="columns" :items="items" :loading="loading" server-side :pagination="pagination" :actions="[{key:'return',label:'Kembalikan',color:'success'}]" @query-change="load" @action-click="({item})=>openReturn(item)" /></div><CModal :visible="modal" @close="modal=false"><CModalHeader><CModalTitle>Kembalikan ke Gudang</CModalTitle></CModalHeader><CModalBody><div class="d-grid gap-2"><div><strong>{{selected?.material_name}}</strong><div class="small text-body-secondary">{{selected?.barcode}} · {{selected?.user}}</div></div><CFormInput v-model="form.qty" type="number" min="0.01" :max="selected?.qty" :disabled="selected?.tracking_type==='serialized'" label="Jumlah dikembalikan"/><CFormTextarea v-model="form.notes" label="Catatan"/></div></CModalBody><CModalFooter><CButton @click="modal=false">Batal</CButton><CButton color="success" @click="submitReturn">Kembalikan</CButton></CModalFooter></CModal></template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { createWarehouseItem, getWarehouseItems, getWarehouseOptions, issueWarehouseBarcode, receiveWarehouseItem } from '@/services/warehouseService'
|
||||
import { showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
const loading = ref(true), items = ref([]), pagination = ref({}), modal = ref(false), receiveModal = ref(false), issueModal = ref(false), selected = ref(null), users = ref([]), itemCodes = ref([])
|
||||
const query = ref({ page: 1, per_page: 10 })
|
||||
const form = reactive({ item_code_id: '', is_active: true })
|
||||
const stock = reactive({ barcode: '', serial_number: '', qty: 1, notes: '' })
|
||||
const issue = reactive({ barcode_id: '', user_id: '', qty: 1, notes: '' })
|
||||
const columns = [{ key: 'sku', label: 'SKU' }, { key: 'name', label: 'Barang' }, { key: 'tracking_label', label: 'Kategori Barcode' }, { key: 'stock', label: 'Stok', sortable: false }, { key: 'unit', label: 'Satuan' }]
|
||||
|
||||
async function load(params = query.value) {
|
||||
query.value = { ...query.value, ...params }; loading.value = true
|
||||
try {
|
||||
const response = await getWarehouseItems(query.value), data = response?.data || {}
|
||||
items.value = (data.data || []).map((item) => ({ ...item, sku: item.item_code?.code || item.sku, name: item.item_code?.name || item.name, tracking_label: item.item_code?.tracking_type === 'serialized' ? 'Per Unit' : 'Berdasarkan Jumlah', stock: (item.barcodes || []).reduce((total, barcode) => total + Number(barcode.remaining_qty || 0), 0) }))
|
||||
pagination.value = { current_page: data.current_page || 1, last_page: data.last_page || 1, per_page: data.per_page || 10, total: data.total || 0 }
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat gudang.') } finally { loading.value = false }
|
||||
}
|
||||
async function save() { try { await createWarehouseItem(form); modal.value = false; showSuccess('Barang berhasil dibuat.'); await load() } catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan barang.') } }
|
||||
function openReceive(item) { selected.value = item; Object.assign(stock, { barcode: '', serial_number: '', qty: 1, notes: '' }); receiveModal.value = true }
|
||||
async function receive() { try { await receiveWarehouseItem(selected.value.id, stock); receiveModal.value = false; showSuccess('Stok masuk berhasil dicatat.'); await load() } catch (error) { showError(error?.response?.data?.message || 'Gagal mencatat stok.') } }
|
||||
function openIssue(item) { selected.value = item; const barcode = (item.barcodes || []).find((entry) => Number(entry.remaining_qty) > 0); Object.assign(issue, { barcode_id: barcode?.id || '', user_id: '', qty: 1, notes: '' }); issueModal.value = true }
|
||||
async function submitIssue() { try { await issueWarehouseBarcode(issue.barcode_id, { user_id: issue.user_id, qty: issue.qty, notes: issue.notes }); issueModal.value = false; showSuccess('Barang berhasil diserahkan kepada user.'); await load() } catch (error) { showError(error?.response?.data?.message || Object.values(error?.response?.data?.errors || {})?.[0]?.[0] || 'Gagal mengeluarkan barang.') } }
|
||||
function runAction({ action, item }) { action === 'receive' ? openReceive(item) : openIssue(item) }
|
||||
onMounted(async () => { const options = await getWarehouseOptions().catch(() => ({ data: { users: [], item_codes: [] } })); users.value = options?.data?.users || []; itemCodes.value = options?.data?.item_codes || []; await load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between">
|
||||
<div><h5 class="mb-0">Gudang</h5><small class="text-body-secondary">Master barang, barcode, stok masuk, dan penyerahan material ke user.</small></div>
|
||||
<CButton color="primary" size="sm" @click="modal = true">+ Barang</CButton>
|
||||
</div>
|
||||
<div v-if="loading" class="brdvx-loading-state"><CSpinner /><p>Memuat data...</p></div>
|
||||
<BaseResponsiveDataView v-else :columns="columns" :items="items" :actions="[{ key: 'receive', label: 'Stok Masuk', color: 'success' }, { key: 'issue', label: 'Serahkan ke User', color: 'warning' }]" server-side :pagination="pagination" @query-change="load" @action-click="runAction" />
|
||||
</div>
|
||||
|
||||
<CModal :visible="modal" @close="modal = false"><CModalHeader><CModalTitle>Tambah Barang ke Gudang</CModalTitle></CModalHeader><CModalBody><CFormSelect v-model="form.item_code_id"><option value="">Pilih kode barang</option><option v-for="code in itemCodes" :key="code.id" :value="code.id">{{ code.code }} · {{ code.name }}</option></CFormSelect><small class="text-body-secondary">Jenis tracking, satuan, dan batas stok mengikuti master Kode Barang.</small></CModalBody><CModalFooter><CButton @click="modal = false">Batal</CButton><CButton color="primary" @click="save">Simpan</CButton></CModalFooter></CModal>
|
||||
<CModal :visible="receiveModal" @close="receiveModal = false"><CModalHeader><CModalTitle>Stok Masuk · {{ selected?.name }}</CModalTitle></CModalHeader><CModalBody><div class="d-grid gap-2"><CFormInput v-model="stock.barcode" placeholder="Barcode" /><CFormInput v-if="selected?.item_code?.need_serial_number" v-model="stock.serial_number" placeholder="Serial number" /><CFormInput v-model="stock.qty" type="number" min="0.01" :disabled="selected?.item_code?.tracking_type === 'serialized'" /><small v-if="selected?.item_code?.tracking_type === 'quantity'" class="text-body-secondary">Isi jumlah awal dalam {{ selected?.item_code?.unit }}. Jumlah ini menjadi stok awal barcode.</small><CFormTextarea v-model="stock.notes" placeholder="Catatan" /></div></CModalBody><CModalFooter><CButton @click="receiveModal = false">Batal</CButton><CButton color="success" @click="receive">Simpan Stok</CButton></CModalFooter></CModal>
|
||||
<CModal :visible="issueModal" @close="issueModal = false"><CModalHeader><CModalTitle>Serahkan Material · {{ selected?.name }}</CModalTitle></CModalHeader><CModalBody><div class="d-grid gap-2"><CFormSelect v-model="issue.barcode_id"><option value="">Pilih barcode</option><option v-for="barcode in (selected?.barcodes || []).filter((entry) => Number(entry.remaining_qty) > 0)" :key="barcode.id" :value="barcode.id">{{ barcode.barcode }} · tersedia {{ barcode.remaining_qty }} {{ barcode.unit }}</option></CFormSelect><CFormSelect v-model="issue.user_id"><option value="">Pilih penerima</option><option v-for="user in users" :key="user.id" :value="user.id">{{ user.name }}</option></CFormSelect><CFormInput v-model="issue.qty" type="number" min="0.01" :disabled="selected?.item_code?.tracking_type === 'serialized'" /><small v-if="selected?.item_code?.tracking_type === 'quantity'" class="text-body-secondary">Jumlah yang diserahkan akan mengurangi sisa pada barcode terpilih.</small><CFormTextarea v-model="issue.notes" placeholder="Catatan penyerahan" /></div></CModalBody><CModalFooter><CButton @click="issueModal = false">Batal</CButton><CButton color="warning" @click="submitIssue">Serahkan</CButton></CModalFooter></CModal>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getBarcodeMovements, getWarehouseBatch } from '@/services/warehouseService'
|
||||
import { showError } from '@/utils/swal.js'
|
||||
const route = useRoute(),
|
||||
router = useRouter(),
|
||||
loading = ref(true),
|
||||
item = ref(null),
|
||||
barcodeId = ref(''),
|
||||
barcodeMovements = ref([])
|
||||
async function load() {
|
||||
try {
|
||||
const r = await getWarehouseBatch(route.params.id)
|
||||
item.value = r.data
|
||||
barcodeId.value = item.value.barcodes?.[0]?.id || ''
|
||||
await loadBarcode()
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Detail barang gagal dimuat.')
|
||||
router.back()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
async function loadBarcode() {
|
||||
if (!barcodeId.value) {
|
||||
barcodeMovements.value = []
|
||||
return
|
||||
}
|
||||
const r = await getBarcodeMovements(item.value.id, barcodeId.value)
|
||||
barcodeMovements.value = r.data || []
|
||||
}
|
||||
const selectedBarcode = () => item.value?.barcodes?.find((x) => x.id == barcodeId.value)
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div v-if="loading" class="brdvx-loading-state">
|
||||
<CSpinner />
|
||||
<p>Memuat detail...</p>
|
||||
</div>
|
||||
<template v-else-if="item"
|
||||
><div class="brdvx-page-header d-flex justify-content-between">
|
||||
<div>
|
||||
<h5 class="mb-0">{{ item.item_number }}</h5>
|
||||
<small>{{ item.name }} · Kode {{ item.item_code?.code }} · {{ item.warehouse?.name }}</small>
|
||||
</div>
|
||||
<CButton size="sm" @click="router.back()">Kembali</CButton>
|
||||
</div>
|
||||
<CRow
|
||||
><CCol :lg="7"
|
||||
><CCard class="mb-3"
|
||||
><CCardHeader><strong>Detail Barang</strong></CCardHeader
|
||||
><CCardBody
|
||||
><div class="row g-3">
|
||||
<div class="col-6">
|
||||
<small>Status</small>
|
||||
<div>{{ item.status }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small>Kode</small>
|
||||
<div>{{ item.item_code?.code }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small>Jumlah awal</small>
|
||||
<div>{{ item.received_qty }} {{ item.unit }}</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small>Sisa stok</small>
|
||||
<div>
|
||||
{{ item.barcodes.reduce((n, x) => n + Number(x.remaining_qty), 0) }}
|
||||
{{ item.unit }}
|
||||
</div>
|
||||
</div>
|
||||
</div></CCardBody
|
||||
></CCard
|
||||
><CCard
|
||||
><CCardHeader><strong>Histori Stok Barang</strong></CCardHeader
|
||||
><CCardBody
|
||||
><div v-if="!item.movements?.length" class="text-body-secondary">
|
||||
Belum ada histori.
|
||||
</div>
|
||||
<div v-for="x in item.movements" :key="x.id" class="border-bottom py-2">
|
||||
<strong>{{ x.movement_type }}</strong>
|
||||
<div>
|
||||
{{ x.qty }} {{ item.unit }} · {{ new Date(x.created_at).toLocaleString('id-ID') }}
|
||||
</div>
|
||||
<small>{{ x.notes }}</small>
|
||||
</div></CCardBody
|
||||
></CCard
|
||||
></CCol
|
||||
><CCol :lg="5"
|
||||
><CCard class="mb-3"
|
||||
><CCardHeader><strong>Detail Barcode</strong></CCardHeader
|
||||
><CCardBody
|
||||
><CFormSelect v-model="barcodeId" @change="loadBarcode"
|
||||
><option v-for="x in item.barcodes" :key="x.id" :value="x.id">
|
||||
{{ x.barcode }}
|
||||
</option></CFormSelect
|
||||
>
|
||||
<div v-if="selectedBarcode()" class="mt-3">
|
||||
<div>Serial: {{ selectedBarcode().serial_number || '-' }}</div>
|
||||
<div>
|
||||
Jumlah awal: {{ selectedBarcode().initial_qty }} {{ selectedBarcode().unit }}
|
||||
</div>
|
||||
<div>Sisa: {{ selectedBarcode().remaining_qty }} {{ selectedBarcode().unit }}</div>
|
||||
<div>Status: {{ selectedBarcode().status }}</div>
|
||||
</div></CCardBody
|
||||
></CCard
|
||||
><CCard
|
||||
><CCardHeader><strong>Histori Barcode</strong></CCardHeader
|
||||
><CCardBody
|
||||
><div v-if="!barcodeMovements.length" class="text-body-secondary">
|
||||
Belum ada pergerakan barcode.
|
||||
</div>
|
||||
<div v-for="x in barcodeMovements" :key="x.id" class="border-bottom py-2">
|
||||
<strong>{{ x.movement_type }}</strong>
|
||||
<div>{{ x.qty }} · {{ new Date(x.created_at).toLocaleString('id-ID') }}</div>
|
||||
<small>{{ x.notes }}</small>
|
||||
</div></CCardBody
|
||||
></CCard
|
||||
></CCol
|
||||
></CRow
|
||||
></template
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
<script setup>import{onMounted,reactive,ref}from'vue';import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue';import{createWarehouse,deleteWarehouse,getWarehouses,updateWarehouse}from'@/services/warehouseService';import{showConfirm,showError,showSuccess}from'@/utils/swal.js';const loading=ref(true),items=ref([]),pagination=ref({}),modal=ref(false),selected=ref(null),form=reactive({code:'',name:'',address:'',is_active:true});const columns=[{key:'code',label:'Kode'},{key:'name',label:'Nama Gudang'},{key:'address',label:'Alamat'},{key:'items_count',label:'Jumlah Batch'},{key:'status_label',label:'Status'}];async function load(q={page:1,per_page:10}){loading.value=true;try{const r=await getWarehouses(q),d=r.data||{};items.value=(d.data||[]).map(x=>({...x,status_label:x.is_active?'Aktif':'Nonaktif'}));pagination.value={current_page:d.current_page,last_page:d.last_page,per_page:d.per_page,total:d.total}}catch(e){showError(e?.response?.data?.message||'Gagal memuat gudang.')}finally{loading.value=false}}function open(x=null){selected.value=x;Object.assign(form,x||{code:'',name:'',address:'',is_active:true});modal.value=true}async function save(){try{selected.value?await updateWarehouse(selected.value.id,form):await createWarehouse(form);modal.value=false;showSuccess('Gudang berhasil disimpan.');await load()}catch(e){showError(e?.response?.data?.message||'Gagal menyimpan gudang.')}}async function remove(x){const c=await showConfirm('Hapus Gudang',`Hapus ${x.name}?`);if(!c.isConfirmed)return;try{await deleteWarehouse(x.id);await load()}catch(e){showError(e?.response?.data?.message||'Gudang tidak dapat dihapus.')}}onMounted(load)</script><template><div class="brdvx-page-container"><div class="brdvx-page-header d-flex justify-content-between"><div><h5 class="mb-0">Daftar Gudang</h5><small class="text-body-secondary">Lokasi penyimpanan barang setiap tenant.</small></div><CButton color="primary" size="sm" @click="open()">+ Gudang</CButton></div><BaseResponsiveDataView :columns="columns" :items="items" :loading="loading" server-side :pagination="pagination" :actions="[{key:'edit',label:'Edit',color:'warning'},{key:'delete',label:'Hapus',color:'danger'}]" @query-change="load" @action-click="({action,item})=>action==='edit'?open(item):remove(item)"/></div><CModal :visible="modal" @close="modal=false"><CModalHeader><CModalTitle>{{selected?'Edit':'Tambah'}} Gudang</CModalTitle></CModalHeader><CModalBody><div class="d-grid gap-2"><CFormInput v-model="form.code" label="Kode gudang"/><CFormInput v-model="form.name" label="Nama gudang"/><CFormTextarea v-model="form.address" label="Alamat"/><CFormCheck v-model="form.is_active" label="Gudang aktif"/></div></CModalBody><CModalFooter><CButton @click="modal=false">Batal</CButton><CButton color="primary" @click="save">Simpan</CButton></CModalFooter></CModal></template>
|
||||
Reference in New Issue
Block a user