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

This commit is contained in:
Wian Drs
2026-08-07 09:17:04 +07:00
parent 484f89e2ee
commit 8682cb7e73
28 changed files with 4012 additions and 990 deletions
+80 -27
View File
@@ -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">&times;</button>
<button type="button" aria-label="Tutup filter" @click="filterOpen = false">
&times;
</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>
+25
View File
@@ -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',
+23
View File
@@ -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
+16
View File
@@ -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
+15
View File
@@ -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)
+23
View File
@@ -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
View File
@@ -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>
+43 -13
View File
@@ -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;
+690 -57
View File
@@ -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>
+42
View File
@@ -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>
+78
View File
@@ -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>
+134
View File
@@ -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>
+19 -7
View File
@@ -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>
+2 -55
View File
@@ -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>
+2 -53
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+544
View File
@@ -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>
+12
View File
@@ -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>
+134
View File
@@ -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>
+59
View File
@@ -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>
+17
View File
@@ -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>
+207
View File
@@ -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>
+44
View File
@@ -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>
+127
View File
@@ -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>
+1
View File
@@ -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>