base struktur update
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled

This commit is contained in:
Wian Drs
2026-07-27 16:25:37 +07:00
parent d5cb197f96
commit 73a32380e7
44 changed files with 5456 additions and 139 deletions
+40
View File
@@ -0,0 +1,40 @@
# Tenant API Integration TODO
- [x] Review existing tenant API and UI navigation/routes (done in analysis)
- [x] Add tenant service for API CRUD in `src/services/tenantService.js`
- [x] Add tenant route in `src/router/index.js`
- [x] Add tenant menu item in `src/_nav.js`
- [x] Create tenant page `src/views/tenants/Tenants.vue` and integrate list API
- [x] Verify integration consistency
# Tenant CRUD UI TODO
- [x] Upgrade `src/views/tenants/Tenants.vue` to full CRUD page
- [x] Integrate create/read/update/delete flows with `tenantService`
- [x] Add form validation for `tenant_code`, `tenant_name`, `status`
- [x] Add detail modal and delete confirmation
- [x] Final consistency review
# BaseResponsiveDataView Header/Table Alignment TODO
- [x] Refactor desktop table header structure to follow row list layout
- [x] Align desktop header columns with rendered desktop columns
- [x] Remove desktop action spacer and use consistent action column
- [x] Keep mobile behavior unchanged
- [x] Mark tasks complete after implementation
# GroupMenus Responsive Style Alignment TODO
- [x] Review `GroupMenus.vue` and map existing actions/data to `BaseResponsiveDataView`
- [x] Refactor GroupMenus list section to use `BaseResponsiveDataView`
- [x] Keep existing action flows (detail, detail menu, edit, hapus) working via action handler
- [x] Add scoped container/header styling pattern similar to `Tenants.vue`
- [x] Final consistency review
# BaseResponsiveDataView Desktop Column Proportion TODO
- [ ] Review desktop grid logic in `BaseResponsiveDataView.vue` and `_base-responsive-data-view.scss`
- [ ] Make desktop column count dynamic (not hardcoded 6)
- [ ] Make action column width proportional and consistent
- [ ] Ensure desktop remains balanced even with only 2 data columns
- [ ] Final consistency review for desktop/mobile behavior
+7 -14
View File
@@ -1,21 +1,14 @@
<!doctype html>
<!--
* CoreUI Free Vue.js Admin Template
* @version v5.5.0
* @link https://coreui.io/product/free-vue-admin-template/
* Copyright (c) 2026 creativeLabs Łukasz Holeczek
* Licensed under MIT (https://github.com/coreui/coreui-free-vue-admin-template/blob/main/LICENSE)
-->
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,shrink-to-fit=no" />
<meta name="description" content="ManjaPro 2 - UI/UX" />
<meta name="description" content="MANJAPRO - UI/UX" />
<meta name="author" content="creativeLabs Łukasz Holeczek" />
<title>ManjaPro 2</title>
<title>MANJAPRO</title>
<!-- favicons for all devices -->
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png" />
<!-- <link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png" />
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png" />
@@ -24,10 +17,10 @@
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png" /> -->
<link rel="icon" type="image/png" href="/apple-icon.png" />
<!-- <link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> -->
<link rel="manifest" href="/manifest.json" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+2
View File
@@ -16,6 +16,7 @@ import { onBeforeMount } from 'vue'
import { useColorModes } from '@coreui/vue'
import { useThemeStore } from '@/stores/theme.js'
import GlobalImagePreview from '@/components/GlobalImagePreview.vue'
// Initialize CoreUI color modes with local storage key
const { isColorModeSet, setColorMode } = useColorModes(
@@ -46,6 +47,7 @@ onBeforeMount(() => {
<template>
<router-view />
<GlobalImagePreview />
</template>
<style lang="scss">
+70 -49
View File
@@ -22,7 +22,7 @@
* @type {Array<Object>}
*/
export default [
const baseNav = [
{
component: 'CNavItem',
name: 'Dashboard',
@@ -33,54 +33,54 @@ export default [
text: 'NEW',
},
},
{
component: 'CNavTitle',
name: 'Ticketing',
},
{
component: 'CNavGroup',
name: 'Ticketing',
to: '/ticket',
icon: 'cil-layers',
items: [
{
component: 'CNavItem',
name: 'Ticket',
to: '/tickets/ticket',
icon: 'cil-notes',
},
{
component: 'CNavItem',
name: 'Ticket Type',
to: '/tickets/ticket-type',
icon: 'cil-list',
},
{
component: 'CNavItem',
name: 'Ticket Materials',
to: '/tickets/ticket-materials',
icon: 'cil-file',
},
{
component: 'CNavItem',
name: 'Ticket Incident',
to: '/tickets/ticket-incident',
icon: 'cil-Puzzle',
},
{
component: 'CNavItem',
name: 'Approved',
to: '/tickets/approved',
icon: 'cil-check',
},
{
component: 'CNavItem',
name: 'Rejected',
to: '/tickets/rejected',
icon: 'cil-ban',
},
],
},
// {
// component: 'CNavTitle',
// name: 'Ticketing',
// },
// {
// component: 'CNavGroup',
// name: 'Ticketing',
// to: '/ticket',
// icon: 'cil-layers',
// items: [
// {
// component: 'CNavItem',
// name: 'Ticket',
// to: '/tickets/ticket',
// icon: 'cil-notes',
// },
// {
// component: 'CNavItem',
// name: 'Ticket Type',
// to: '/tickets/ticket-type',
// icon: 'cil-list',
// },
// {
// component: 'CNavItem',
// name: 'Ticket Materials',
// to: '/tickets/ticket-materials',
// icon: 'cil-file',
// },
// {
// component: 'CNavItem',
// name: 'Ticket Incident',
// to: '/tickets/ticket-incident',
// icon: 'cil-Puzzle',
// },
// {
// component: 'CNavItem',
// name: 'Approved',
// to: '/tickets/approved',
// icon: 'cil-check',
// },
// {
// component: 'CNavItem',
// name: 'Rejected',
// to: '/tickets/rejected',
// icon: 'cil-ban',
// },
// ],
// },
{
component: 'CNavTitle',
name: 'Theme',
@@ -454,6 +454,18 @@ export default [
},
],
},
{
component: 'CNavItem',
name: 'Users',
to: '/users',
icon: 'cil-user',
},
{
component: 'CNavItem',
name: 'Tenants',
to: '/tenants',
icon: 'cil-building',
},
{
component: 'CNavItem',
name: 'Widgets',
@@ -498,3 +510,12 @@ export default [
],
},
]
export function injectMenusAfterDashboard(dynamicMenus = []) {
if (!Array.isArray(baseNav) || baseNav.length === 0) return dynamicMenus
const [dashboard] = baseNav
return [dashboard, ...dynamicMenus]
}
export default baseNav
+18 -2
View File
@@ -1,8 +1,9 @@
import axios from 'axios'
import { getToken } from '@/utils/session'
import { clearSession, getToken } from '@/utils/session'
const api = axios.create({
baseURL: 'https://api.radiq.my.id/api',
// baseURL: 'https://api.radiq.my.id/api',
baseURL: 'http://127.0.0.1:8000/api',
headers: {
'Content-Type': 'application/json',
},
@@ -19,4 +20,19 @@ api.interceptors.request.use(
(error) => Promise.reject(error),
)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401 && getToken()) {
clearSession()
if (window.location.hash !== '#/pages/login') {
window.location.hash = '#/pages/login'
}
}
return Promise.reject(error)
},
)
export default api
+3 -3
View File
@@ -2,11 +2,11 @@
<CFooter class="px-4">
<div>
<a href="https://manjapro.net/" target="_blank">ManjaPro</a>
<span class="ms-1">&copy; {{ new Date().getFullYear() }} bestapps</span>
<span class="ms-1">&copy; {{ new Date().getFullYear() }}</span>
</div>
<div class="ms-auto">
<span class="me-1" target="_blank">Powered by</span>
<a href="https://rjn-net.id/">PT. LJN Cikembar</a>
<!-- <span class="me-1" target="_blank">Powered by</span> -->
<a href="https://radiq.my.id/">PT. Radhika Data Nusantara</a>
</div>
</CFooter>
</template>
+5 -5
View File
@@ -6,16 +6,16 @@ import AppBreadcrumb from '@/components/AppBreadcrumb.vue'
import AppHeaderDropdownAccnt from '@/components/AppHeaderDropdownAccnt.vue'
import { useSidebarStore } from '@/stores/sidebar.js'
const headerClassNames = ref('mb-4 p-0')
const headerClassNames = ref('mb-2 p-0')
const { colorMode, setColorMode } = useColorModes('coreui-free-vue-admin-template-theme')
const sidebar = useSidebarStore()
onMounted(() => {
document.addEventListener('scroll', () => {
if (document.documentElement.scrollTop > 0) {
headerClassNames.value = 'mb-4 p-0 shadow-sm'
headerClassNames.value = 'mb-2 p-0 shadow-sm'
} else {
headerClassNames.value = 'mb-4 p-0'
headerClassNames.value = 'mb-2 p-0'
}
})
})
@@ -101,8 +101,8 @@ onMounted(() => {
<AppHeaderDropdownAccnt />
</CHeaderNav>
</CContainer>
<CContainer class="px-4" fluid>
<!-- <CContainer class="px-4" fluid>
<AppBreadcrumb />
</CContainer>
</CContainer> -->
</CHeader>
</template>
+63 -5
View File
@@ -1,21 +1,79 @@
<script setup>
import avatar from '@/assets/images/avatars/8.jpg'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { getTemporaryFileUrl } from '@/services/fileService'
import { getMyProfile } from '@/services/profileService'
const itemsCount = 42
const router = useRouter()
const authStore = useAuthStore()
const avatarUrl = ref('')
const profileLoadedForUserId = ref(null)
watch(
() => [authStore.user?.id, authStore.user?.profile_photo?.uuid],
async ([userId]) => {
avatarUrl.value = ''
if (!userId) return
try {
let user = authStore.user
if (!user?.profile_photo && profileLoadedForUserId.value !== userId) {
profileLoadedForUserId.value = userId
const profileResponse = await getMyProfile()
user = profileResponse?.data || user
authStore.setUser(user)
}
const uuid = user?.profile_photo?.uuid
if (!uuid) return
const response = await getTemporaryFileUrl(uuid)
avatarUrl.value = response?.data?.url || ''
} catch {
avatarUrl.value = ''
}
},
{ immediate: true },
)
function openProfile() {
router.push('/profile')
}
function onLogout() {
authStore.logout()
router.push('/pages/login')
}
</script>
<template>
<CDropdown placement="bottom-end" variant="nav-item">
<CDropdownToggle class="py-0 pe-0" :caret="false">
<CAvatar :src="avatar" size="md" />
<CAvatar
:src="avatarUrl || undefined"
color="primary"
text-color="white"
size="md"
class="header-profile-avatar"
>
<template v-if="!avatarUrl">
{{ authStore.user?.name?.charAt(0)?.toUpperCase() || 'U' }}
</template>
</CAvatar>
</CDropdownToggle>
<CDropdownMenu class="pt-0">
<CDropdownHeader
component="h6"
class="bg-body-secondary text-body-secondary fw-semibold mb-2 rounded-top"
>
Account
{{ authStore.user?.name || 'Account' }}
</CDropdownHeader>
<CDropdownItemText class="small text-body-secondary">
{{ authStore.user?.email || '-' }}
</CDropdownItemText>
<CDropdownItem>
<CIcon icon="cil-bell" /> Updates
<CBadge color="info" class="ms-auto">{{ itemsCount }}</CBadge>
@@ -38,7 +96,7 @@ const itemsCount = 42
>
Settings
</CDropdownHeader>
<CDropdownItem> <CIcon icon="cil-user" /> Profile </CDropdownItem>
<CDropdownItem @click="openProfile"> <CIcon icon="cil-user" /> Profile </CDropdownItem>
<CDropdownItem> <CIcon icon="cil-settings" /> Settings </CDropdownItem>
<CDropdownItem>
<CIcon icon="cil-dollar" /> Payments
@@ -50,7 +108,7 @@ const itemsCount = 42
</CDropdownItem>
<CDropdownDivider />
<CDropdownItem> <CIcon icon="cil-shield-alt" /> Lock Account </CDropdownItem>
<CDropdownItem> <CIcon icon="cil-lock-locked" /> Logout </CDropdownItem>
<CDropdownItem @click="onLogout"> <CIcon icon="cil-lock-locked" /> Logout </CDropdownItem>
</CDropdownMenu>
</CDropdown>
</template>
+4 -4
View File
@@ -33,14 +33,14 @@ const sidebar = useSidebarStore()
class="d-flex align-items-center text-decoration-none"
>
<!-- Logo -->
<img :src="logo" alt="Logo" style="height: 60px; width: auto" />
<img :src="logo" alt="Logo" style="height: 40px; width: auto" />
<!-- Teks -->
<div class="ms-2 text-start">
<div class="fw-bold" style="font-size: 30px; line-height: 1; color: #ff8c00">
ManjaPro
<div class="fw-bold" style="font-size: 22px; line-height: 1; color: #ff8c00; margin-top: 8px">
MANJAPRO
</div>
<small class="fw-bold" style="font-size: 11px"> PT. Rhadika Data Nusantara </small>
<small class="fw-bold" style="font-size: 11px"> PT. Radhika Data Nusantara </small>
</div>
</CSidebarBrand>
</RouterLink>
+13 -6
View File
@@ -1,9 +1,9 @@
import { defineComponent, h, onMounted, ref, resolveComponent } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { cilExternalLink } from '@coreui/icons'
import { CBadge, CSidebarNav, CNavItem, CNavGroup, CNavTitle } from '@coreui/vue'
import nav from '@/_nav.js'
import { injectMenusAfterDashboard } from '@/_nav.js'
import { useMenuStore } from '@/stores/menu'
import simplebar from 'simplebar-vue'
import 'simplebar-vue/dist/simplebar.min.css'
@@ -24,8 +24,11 @@ const isActiveLink = (route, link) => {
const currentPath = normalizePath(route.path)
const targetPath = normalizePath(link)
const activeMenuPath = route.meta?.activeMenu
? normalizePath(route.meta.activeMenu)
: null
return currentPath === targetPath
return currentPath === targetPath || activeMenuPath === targetPath
}
const isActiveItem = (route, item) => {
@@ -50,6 +53,7 @@ const AppSidebarNav = defineComponent({
setup() {
const route = useRoute()
const firstRender = ref(true)
const menuStore = useMenuStore()
onMounted(() => {
firstRender.value = false
@@ -173,16 +177,19 @@ const AppSidebarNav = defineComponent({
)
}
return () =>
h(
return () => {
const mergedNav = injectMenusAfterDashboard(menuStore.mappedMenus)
return h(
CSidebarNav,
{
as: simplebar,
},
{
default: () => nav.map((item) => renderItem(item)),
default: () => mergedNav.map((item) => renderItem(item)),
},
)
}
},
})
+65
View File
@@ -0,0 +1,65 @@
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
const visible = ref(false)
const imageUrl = ref('')
const imageAlt = ref('Preview gambar')
function openPreview(event) {
const previewTarget = event.target.closest?.('.image-previewable')
if (!previewTarget) return
const image = previewTarget.matches('img') ? previewTarget : previewTarget.querySelector('img')
if (!image?.src) return
imageUrl.value = image.currentSrc || image.src
imageAlt.value = image.alt || previewTarget.dataset.previewAlt || 'Preview gambar'
visible.value = true
document.body.classList.add('image-preview-open')
}
function closePreview() {
visible.value = false
document.body.classList.remove('image-preview-open')
}
function handleKeydown(event) {
if (event.key === 'Escape' && visible.value) closePreview()
}
onMounted(() => {
document.addEventListener('click', openPreview)
document.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', openPreview)
document.removeEventListener('keydown', handleKeydown)
document.body.classList.remove('image-preview-open')
})
</script>
<template>
<Teleport to="body">
<Transition name="image-preview-fade">
<div
v-if="visible"
class="image-preview-modal"
role="dialog"
aria-modal="true"
:aria-label="imageAlt"
@click.self="closePreview"
>
<button
type="button"
class="image-preview-close"
aria-label="Tutup preview gambar"
@click="closePreview"
>
<CIcon icon="cil-x-circle" size="xl" />
</button>
<img :src="imageUrl" :alt="imageAlt" class="image-preview-modal-image" />
</div>
</Transition>
</Teleport>
</template>
@@ -0,0 +1,588 @@
<template>
<div class="responsive-data-view brdvx-root" :aria-busy="loading">
<div class="data-toolbar">
<div class="toolbar-left">
<div v-if="!isMobile" class="per-page-wrap">
<label class="toolbar-label" for="per-page-select">Show</label>
<CFormSelect
id="per-page-select"
size="sm"
class="per-page-select"
:model-value="String(perPage)"
@change="onPerPageChange"
>
<option v-for="opt in perPageOptions" :key="opt" :value="String(opt)">{{ opt }}</option>
</CFormSelect>
</div>
</div>
<div class="toolbar-search-wrap">
<div class="toolbar-search">
<CFormInput
v-model="searchQuery"
type="text"
size="sm"
placeholder="Search"
aria-label="Search"
/>
</div>
<CButton
size="sm"
color="secondary"
variant="outline"
class="view-toggle-btn"
@click="setViewMode(viewMode === 'card' ? 'table' : 'card')"
>
<CIcon :icon="viewMode === 'card' ? cilList : cilViewModule" />
</CButton>
</div>
</div>
<div v-if="loading" class="brdvx-inline-loading" role="status" aria-live="polite">
<CSpinner color="primary" size="sm" />
<span>Memuat data...</span>
</div>
<template v-if="viewMode === 'card'">
<div class="cards-grid" :class="{ mobile: isMobile }">
<CCard
v-for="(item, rowIndex) in renderedItems"
:key="resolveRowKey(item, rowIndex)"
class="mobile-card"
role="button"
@click="onCardClick(item)"
>
<div v-if="item.image" class="mobile-image-header">
<img
:src="item.image"
:alt="item.name || `Item ${rowIndex + 1}`"
class="mobile-image"
@error="onImageError($event, item)"
/>
</div>
<CCardHeader v-else class="mobile-card-header">
<span class="mobile-card-title">{{ item.name || `Row ${rowIndex + 1}` }}</span>
<CBadge color="light" class="text-dark">#{{ item.id || rowIndex + 1 }}</CBadge>
</CCardHeader>
<CCardBody class="mobile-card-body">
<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>
</div>
</div>
<div v-if="!hideDefaultActions" class="mobile-actions" @click.stop>
<CButton
v-for="action in mobileActions"
:key="action.key"
size="sm"
:color="action.color"
variant="outline"
@click="onActionClick(action.key, item)"
>
{{ action.label }}
</CButton>
</div>
</CCardBody>
</CCard>
</div>
</template>
<template v-else>
<div class="table-list-wrapper">
<CCard v-if="!isMobile" class="table-list-card table-list-header-card">
<CCardBody class="table-list-card-body">
<div class="table-list-main">
<div class="table-list-data-col desktop-row-grid" :style="desktopGridStyle">
<div
v-for="col in desktopHeaderColumns"
:key="`head-${col.key}`"
class="table-list-cell table-list-head-cell"
>
<button
v-if="serverSide && col.sortable !== false"
type="button"
class="brdvx-sort-button"
@click="toggleSort(col)"
>
<span>{{ col.label }}</span>
<span v-if="sortBy === (col.sortKey || col.key)">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</button>
<span v-else class="table-list-value row-pair">
<span class="row-right no-label">{{ col.label }}</span>
</span>
</div>
</div>
</div>
<div class="table-list-right" :style="desktopActionStyle">
<div class="table-list-head-cell action-head">Action</div>
</div>
</CCardBody>
</CCard>
<CCard
v-for="(item, rowIndex) in renderedItems"
:key="resolveRowKey(item, rowIndex)"
class="table-list-card"
>
<CCardBody class="table-list-card-body">
<div class="table-list-main" :class="{ 'with-image': hasImageCell(item) && isMobile }">
<div v-if="hasImageCell(item) && isMobile" class="table-list-image-col">
<img
:src="item.image"
:alt="item.name || 'avatar'"
class="table-list-mobile-image"
@error="onImageError($event, item)"
/>
</div>
<div v-if="isMobile" class="table-list-data-col mobile-split">
<div class="mobile-col">
<div
v-for="col in leftMobileColumns(item)"
:key="`${resolveRowKey(item, rowIndex)}-left-${col.key}`"
class="table-list-cell"
>
<span class="table-list-value">{{ item[col.key] }}</span>
</div>
</div>
<div class="mobile-col">
<div
v-for="col in rightMobileColumns(item)"
:key="`${resolveRowKey(item, rowIndex)}-right-${col.key}`"
class="table-list-cell"
>
<span class="table-list-value">{{ item[col.key] }}</span>
</div>
</div>
</div>
<div v-else class="table-list-data-col desktop-row-grid" :style="desktopGridStyle">
<div
v-for="col in displayColumns(item)"
:key="`${resolveRowKey(item, rowIndex)}-${col.key}`"
class="table-list-cell"
>
<span class="table-list-value row-pair">
<template v-if="col.key === 'image' && item.image">
<img
:src="item.image"
:alt="item.name || 'avatar'"
class="table-avatar"
@error="onImageError($event, item)"
/>
</template>
<template v-else>
<span class="row-right no-label">{{ item[col.key] }}</span>
</template>
</span>
</div>
</div>
</div>
<div class="table-list-right" :style="desktopActionStyle">
<CButton
v-if="hiddenTableColumns.length"
size="sm"
@click="toggleExpandRow(resolveRowKey(item, rowIndex))"
>
<CIcon :icon="isRowExpanded(resolveRowKey(item, rowIndex)) ? cilCaretTop : cilCaretBottom" />
</CButton>
<div v-if="!hideDefaultActions" class="table-actions">
<CButton
v-for="action in actions"
:key="`${resolveRowKey(item, rowIndex)}-${action.key}`"
size="sm"
:color="action.color"
variant="outline"
@click="onActionClick(action.key, item)"
>
{{ action.label }}
</CButton>
</div>
</div>
</CCardBody>
<div
v-if="hiddenTableColumns.length && isRowExpanded(resolveRowKey(item, rowIndex))"
class="table-list-hidden"
>
<div
v-for="col in hiddenTableColumns"
:key="`${resolveRowKey(item, rowIndex)}-hidden-${col.key}`"
class="hidden-item"
>
<span class="hidden-label">{{ col.label }}</span>
<span class="hidden-value">{{ item[col.key] }}</span>
</div>
</div>
</CCard>
</div>
</template>
<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
v-for="page in visiblePages"
:key="page"
:active="page === currentPage"
@click="goToPage(page)"
>
{{ page }}
</CPaginationItem>
<CPaginationItem :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">Next</CPaginationItem>
</CPagination>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { CIcon } from '@coreui/icons-vue'
import { cilCaretBottom, cilCaretTop, cilList, cilViewModule } from '@coreui/icons'
const VIEW_MODE_STORAGE_KEY = 'manja-responsive-data-view-mode'
const getStoredViewMode = () => {
const storedMode = localStorage.getItem(VIEW_MODE_STORAGE_KEY)
return storedMode === 'card' || storedMode === 'table' ? storedMode : 'table'
}
const props = defineProps({
columns: {
type: Array,
required: true,
},
items: {
type: Array,
required: true,
},
rowKey: {
type: String,
default: 'id',
},
mobileBreakpoint: {
type: Number,
default: 768,
},
mobileVisibleCount: {
type: Number,
default: 5,
},
desktopPrimaryCount: {
type: Number,
default: 6,
},
actions: {
type: Array,
default: () => [
{ key: 'detail', label: 'Detail', color: 'primary' },
{ key: 'edit', label: 'Edit', color: 'success' },
{ key: 'delete', label: 'Delete', color: 'danger' },
],
},
hideDefaultActions: {
type: Boolean,
default: false,
},
rowRoute: {
type: [String, Function],
default: null,
},
serverSide: {
type: Boolean,
default: false,
},
pagination: {
type: Object,
default: () => ({}),
},
initialSortBy: {
type: String,
default: '',
},
initialSortDirection: {
type: String,
default: 'asc',
},
loading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['action-click', 'row-click', 'query-change'])
const router = useRouter()
const width = ref(window.innerWidth)
const isMobile = computed(() => width.value < props.mobileBreakpoint)
const viewMode = ref(getStoredViewMode())
const searchQuery = ref('')
const perPageOptions = [10, 20, 50, 100]
const perPage = ref(10)
const currentPage = ref(1)
const mobileLoadedPages = ref(1)
const expandedRows = ref(new Set())
const sortBy = ref(props.initialSortBy)
const sortDirection = ref(props.initialSortDirection === 'desc' ? 'desc' : 'asc')
let searchTimer = null
const normalizeValue = (val) => (val === null || val === undefined ? '' : String(val).toLowerCase())
const filteredItems = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return props.items
return props.items.filter((item) =>
props.columns.some((col) => normalizeValue(item?.[col.key]).includes(query)),
)
})
const totalPages = computed(() => {
if (props.serverSide) return Math.max(1, Number(props.pagination?.last_page) || 1)
if (isMobile.value) return 1
return Math.max(1, Math.ceil(filteredItems.value.length / perPage.value))
})
const desktopItems = computed(() => {
const start = (currentPage.value - 1) * perPage.value
return filteredItems.value.slice(start, start + perPage.value)
})
const mobileItems = computed(() => {
const size = perPage.value * mobileLoadedPages.value
return filteredItems.value.slice(0, size)
})
const renderedItems = computed(() => {
if (props.serverSide) return props.items
return isMobile.value ? mobileItems.value : desktopItems.value
})
const visiblePages = computed(() => {
const lastPage = totalPages.value
if (lastPage <= 7) return Array.from({ length: lastPage }, (_, index) => index + 1)
const start = Math.max(1, Math.min(currentPage.value - 3, lastPage - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const desktopVisibleColumns = computed(() => Math.min(props.desktopPrimaryCount, props.columns.length))
const mobileTableVisibleCount = 6
const visibleTableColumns = computed(() => {
if (isMobile.value && viewMode.value === 'table') {
return props.columns.slice(0, mobileTableVisibleCount)
}
return props.columns.slice(0, desktopVisibleColumns.value)
})
const hiddenTableColumns = computed(() => {
if (viewMode.value === 'table' && props.columns.length > mobileTableVisibleCount) {
return props.columns.slice(mobileTableVisibleCount)
}
return []
})
const hasImageCell = (item) => Boolean(item?.image)
const onImageError = (event, item) => {
const fallback = item?.imageFallback
if (!fallback || event.target.src.endsWith(fallback)) return
event.target.src = fallback
}
const displayColumns = (item) => {
const cols = visibleTableColumns.value
if (isMobile.value && hasImageCell(item)) {
return cols.filter((col) => col.key !== 'image')
}
return cols
}
const mobileColumns = computed(() => props.columns.slice(0, props.mobileVisibleCount))
const cardColumns = (item) =>
mobileColumns.value.filter((column) => {
if (column.key === 'id') return false
if (item?.image && (column.key === 'image' || column.key === 'avatar')) return false
return true
})
const leftMobileColumns = (item) => {
const cols = displayColumns(item)
const split = Math.ceil(cols.length / 2)
return cols.slice(0, split)
}
const rightMobileColumns = (item) => {
const cols = displayColumns(item)
const split = Math.ceil(cols.length / 2)
return cols.slice(split)
}
const desktopHeaderColumns = computed(() => visibleTableColumns.value)
const mobileActions = computed(() => props.actions)
const desktopColumnCount = computed(() => Math.max(1, desktopHeaderColumns.value.length))
const actionColumnWidth = computed(() => {
const actionCount = props.actions?.length ?? 0
if (actionCount <= 1) return '140px'
if (actionCount === 2) return '180px'
if (actionCount === 3) return '230px'
return '280px'
})
const desktopGridStyle = computed(() => ({
'--brdvx-desktop-cols': desktopColumnCount.value,
}))
const desktopActionStyle = computed(() => ({
'--brdvx-action-col-width': actionColumnWidth.value,
}))
const resolveRowKey = (item, index) => item?.[props.rowKey] ?? `row-${index}`
const onCardClick = (item) => {
emit('row-click', item)
if (typeof props.rowRoute === 'function') {
const target = props.rowRoute(item)
if (target) router.push(target)
} else if (typeof props.rowRoute === 'string' && props.rowRoute) {
router.push(props.rowRoute.replace(':id', item?.id))
}
}
const onActionClick = (actionKey, item) => {
emit('action-click', { action: actionKey, item })
}
const setViewMode = (mode) => {
if (mode !== 'table' && mode !== 'card') return
viewMode.value = mode
localStorage.setItem(VIEW_MODE_STORAGE_KEY, mode)
}
const onPerPageChange = (event) => {
const value = Number(event.target.value)
if (!Number.isNaN(value)) {
perPage.value = value
currentPage.value = 1
mobileLoadedPages.value = 1
emitQueryChange()
}
}
const goToPage = (page) => {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
if (props.serverSide) emitQueryChange()
}
const toggleSort = (column) => {
const key = column.sortKey || column.key
if (sortBy.value === key) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortBy.value = key
sortDirection.value = 'asc'
}
currentPage.value = 1
emitQueryChange()
}
const emitQueryChange = () => {
if (!props.serverSide) return
emit('query-change', {
page: currentPage.value,
per_page: perPage.value,
search: searchQuery.value.trim() || undefined,
sort_by: sortBy.value || undefined,
sort_direction: sortBy.value ? sortDirection.value : undefined,
})
}
const isRowExpanded = (rowKey) => expandedRows.value.has(rowKey)
const toggleExpandRow = (rowKey) => {
const clone = new Set(expandedRows.value)
if (clone.has(rowKey)) clone.delete(rowKey)
else clone.add(rowKey)
expandedRows.value = clone
}
const handleInfiniteScroll = () => {
if (!isMobile.value || props.serverSide) return
const nearBottom =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120
if (!nearBottom) return
const maxPage = Math.max(1, Math.ceil(filteredItems.value.length / perPage.value))
if (mobileLoadedPages.value < maxPage) {
mobileLoadedPages.value += 1
}
}
const onResize = () => {
width.value = window.innerWidth
}
watch([searchQuery, () => props.items], () => {
if (!props.serverSide) currentPage.value = 1
mobileLoadedPages.value = 1
expandedRows.value = new Set()
})
watch(searchQuery, () => {
if (!props.serverSide) return
window.clearTimeout(searchTimer)
searchTimer = window.setTimeout(() => {
currentPage.value = 1
emitQueryChange()
}, 400)
})
watch(
() => props.pagination,
(pagination) => {
if (!props.serverSide) return
currentPage.value = Number(pagination?.current_page) || 1
perPage.value = Number(pagination?.per_page) || perPage.value
},
{ immediate: true, deep: true },
)
watch(
() => isMobile.value,
() => {
currentPage.value = 1
mobileLoadedPages.value = 1
expandedRows.value = new Set()
},
)
onMounted(() => {
window.addEventListener('resize', onResize)
window.addEventListener('scroll', handleInfiniteScroll, { passive: true })
})
onBeforeUnmount(() => {
window.clearTimeout(searchTimer)
window.removeEventListener('resize', onResize)
window.removeEventListener('scroll', handleInfiniteScroll)
})
</script>
+1 -1
View File
@@ -11,7 +11,7 @@ import AppSidebar from '@/components/AppSidebar.vue'
<div class="wrapper d-flex flex-column min-vh-100">
<AppHeader />
<div class="body flex-grow-1">
<CContainer class="px-4" fluid>
<CContainer class="px-1" fluid>
<router-view />
</CContainer>
</div>
-7
View File
@@ -17,7 +17,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { setToken, hasToken } from '@/utils/session'
import App from './App.vue'
import router from './router'
@@ -33,7 +32,6 @@ import DocsExample from '@/components/DocsExample'
import DocsIcons from '@/components/DocsIcons'
import { CButton } from '@coreui/vue'
import { CBadge } from '@coreui/vue'
// Create Vue application instance
const app = createApp(App)
@@ -54,10 +52,5 @@ app.component('DocsComponents', DocsComponents)
app.component('DocsExample', DocsExample)
app.component('DocsIcons', DocsIcons)
// TODO: Hapus setelah fitur Login tersedia — token sementara untuk development
if (!hasToken()) {
setToken('UU7HVn7ECHe7MvqMTeTeEpoFdBA2wc0NHQX29WyG91fc6982')
}
// Mount application to DOM
app.mount('#app')
+134 -5
View File
@@ -26,6 +26,9 @@ import { h, resolveComponent } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import DefaultLayout from '@/layouts/DefaultLayout'
import { useAuthStore } from '@/stores/auth'
import { useMenuStore } from '@/stores/menu'
import { hasToken } from '@/utils/session'
/**
* Application routes configuration
@@ -37,6 +40,9 @@ const routes = [
name: 'Home',
component: DefaultLayout,
redirect: '/dashboard',
meta: {
requiresAuth: true,
},
children: [
{
path: '/dashboard',
@@ -47,6 +53,11 @@ const routes = [
component: () =>
import(/* webpackChunkName: "dashboard" */ '@/views/dashboard/Dashboard.vue'),
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/profile/Profile.vue'),
},
{
path: '/tickets',
name: 'Tickets',
@@ -64,21 +75,25 @@ const routes = [
},
{
path: '/tickets/ticket-type',
alias: '/ticket-types',
name: 'Ticket Type',
component: () => import('@/views/tickets/TicketType.vue'),
},
{
path: '/tickets/ticket-materials',
alias: '/materials',
name: 'Ticket Materials',
component: () => import('@/views/tickets/TicketMaterials.vue'),
},
{
path: '/tickets/ticket-incident',
alias: '/ticket-incident-types',
name: 'Ticket Incident',
component: () => import('@/views/tickets/TicketIncident.vue'),
},
{
path: '/tickets/approved',
alias: '/tickets/approval',
name: 'Approved',
component: () => import('@/views/tickets/Approved.vue'),
},
@@ -87,11 +102,6 @@ const routes = [
name: 'Rejected',
component: () => import('@/views/tickets/Rejected.vue'),
},
{
path: '/tickets/ticket-incident',
name: 'Ticket Incident',
component: () => import('@/views/tickets/TicketIncident.vue'),
},
],
},
{
@@ -349,6 +359,75 @@ const routes = [
},
],
},
{
path: '/group-menus',
name: 'Group Menus',
component: () => import('@/views/group-menus/GroupMenus.vue'),
},
{
path: '/group-menus/:id/menus',
name: 'Group Menu Permissions',
component: () => import('@/views/group-menus/GroupMenuPermissions.vue'),
meta: { activeMenu: '/group-menus' },
},
{
path: '/nas/mikrotik',
name: 'NAS Mikrotik',
component: () => import('@/views/nas/NasResourceView.vue'),
meta: { nasResource: 'mikrotik' },
},
{
path: '/nas/package-profiles',
name: 'NAS Profile Paket',
component: () => import('@/views/nas/NasResourceView.vue'),
meta: { nasResource: 'package-profile' },
},
{
path: '/nas/olt',
name: 'NAS OLT',
component: () => import('@/views/nas/NasResourceView.vue'),
meta: { nasResource: 'olt' },
},
{
path: '/nas/webfig',
name: 'NAS Webfig',
component: () => import('@/views/nas/NasResourceView.vue'),
meta: { nasResource: 'webfig' },
},
{
path: '/users',
name: 'Users',
component: () => import('@/views/users/Users.vue'),
},
{
path: '/tenants',
name: 'Tenants',
component: () => import('@/views/tenants/Tenants.vue'),
},
{
path: '/wilayah/desa',
name: 'Wilayah Desa',
component: () => import('@/views/wilayah/Wilayah.vue'),
meta: { tingkat: 'desa' },
},
{
path: '/wilayah/kecamatan',
name: 'Wilayah Kecamatan',
component: () => import('@/views/wilayah/Wilayah.vue'),
meta: { tingkat: 'kecamatan' },
},
{
path: '/wilayah/kabupaten',
name: 'Wilayah Kabupaten',
component: () => import('@/views/wilayah/Wilayah.vue'),
meta: { tingkat: 'kabupaten' },
},
{
path: '/wilayah/provinsi',
name: 'Wilayah Provinsi',
component: () => import('@/views/wilayah/Wilayah.vue'),
meta: { tingkat: 'provinsi' },
},
{
path: '/widgets',
name: 'Widgets',
@@ -380,11 +459,25 @@ const routes = [
path: 'login',
name: 'Login',
component: () => import('@/views/pages/Login'),
meta: {
guestOnly: true,
},
},
{
path: 'register',
name: 'Register',
component: () => import('@/views/pages/Register'),
meta: {
guestOnly: true,
},
},
{
path: 'verify-code',
name: 'VerifyCode',
component: () => import('@/views/pages/VerifyCode'),
meta: {
guestOnly: true,
},
},
],
},
@@ -399,4 +492,40 @@ const router = createRouter({
},
})
router.beforeEach(async (to) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth)
const guestOnly = to.matched.some((record) => record.meta.guestOnly)
const authenticated = hasToken()
if (requiresAuth && !authenticated) {
return {
name: 'Login',
query: {
redirect: to.fullPath,
},
}
}
if (guestOnly && authenticated) {
return { name: 'Dashboard' }
}
if (requiresAuth) {
try {
await useMenuStore().fetchMenus()
} catch {
useAuthStore().logout()
return {
name: 'Login',
query: {
redirect: to.fullPath,
},
}
}
}
return true
})
export default router
+21
View File
@@ -0,0 +1,21 @@
import api from '@/api/axios'
export async function login(payload) {
const response = await api.post('/login', payload)
return response.data
}
export async function register(payload) {
const response = await api.post('/register', payload)
return response.data
}
export async function verifyCode(payload) {
const response = await api.post('/register/verify', payload)
return response.data
}
export async function resendVerificationCode(payload) {
const response = await api.post('/register/resend-code', payload)
return response.data
}
+23
View File
@@ -0,0 +1,23 @@
import api from '@/api/axios'
export async function uploadFile(file, category = 'general') {
const payload = new FormData()
payload.append('file', file)
payload.append('category', category)
const { data } = await api.post('/files', payload, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export async function getTemporaryFileUrl(uuid) {
const { data } = await api.get(`/files/${uuid}/temporary-url`)
return data
}
export async function deleteFile(uuid) {
const { data } = await api.delete(`/files/${uuid}`)
return data
}
+36
View File
@@ -0,0 +1,36 @@
import api from '@/api/axios'
export async function getMenuGroups(params = {}) {
const { data } = await api.get('/menu-groups', { params })
return data
}
export async function createMenuGroup(payload) {
const { data } = await api.post('/menu-groups', payload)
return data
}
export async function getMenuGroupById(id) {
const { data } = await api.get(`/menu-groups/${id}`)
return data
}
export async function updateMenuGroup(id, payload) {
const { data } = await api.put(`/menu-groups/${id}`, payload)
return data
}
export async function deleteMenuGroup(id) {
const { data } = await api.delete(`/menu-groups/${id}`)
return data
}
export async function getMenuGroupAvailableById(id) {
const { data } = await api.get(`/menu-groups/${id}/available-menus`)
return data
}
export async function syncMenuGroupMenus(id, payload) {
const { data } = await api.put(`/menu-groups/${id}/menus`, payload)
return data
}
+6
View File
@@ -0,0 +1,6 @@
import api from '@/api/axios'
export async function getMyMenus() {
const { data } = await api.get('/me/menus')
return data
}
+38
View File
@@ -0,0 +1,38 @@
import api from '@/api/axios'
const paths = {
mikrotik: 'mikrotiks',
'package-profile': 'package-profiles',
olt: 'olts',
webfig: 'webfig-devices',
}
function endpoint(resource, id = null) {
const base = `/nas/${paths[resource]}`
return id ? `${base}/${id}` : base
}
export async function getNasResources(resource, params = {}) {
const { data } = await api.get(endpoint(resource), { params })
return data
}
export async function getNasResource(resource, id) {
const { data } = await api.get(endpoint(resource, id))
return data
}
export async function createNasResource(resource, payload) {
const { data } = await api.post(endpoint(resource), payload)
return data
}
export async function updateNasResource(resource, id, payload) {
const { data } = await api.put(endpoint(resource, id), payload)
return data
}
export async function deleteNasResource(resource, id) {
const { data } = await api.delete(endpoint(resource, id))
return data
}
+11
View File
@@ -0,0 +1,11 @@
import api from '@/api/axios'
export async function getMyProfile() {
const { data } = await api.get('/me/profile')
return data
}
export async function updateMyProfile(payload) {
const { data } = await api.put('/me/profile', payload)
return data
}
+26
View File
@@ -0,0 +1,26 @@
import api from '@/api/axios'
export async function getTenants(params = {}) {
const { data } = await api.get('/tenants', { params })
return data
}
export async function createTenant(payload) {
const { data } = await api.post('/tenants', payload)
return data
}
export async function getTenantById(id) {
const { data } = await api.get(`/tenants/${id}`)
return data
}
export async function updateTenant(id, payload) {
const { data } = await api.put(`/tenants/${id}`, payload)
return data
}
export async function deleteTenant(id) {
const { data } = await api.delete(`/tenants/${id}`)
return data
}
+26
View File
@@ -0,0 +1,26 @@
import api from '@/api/axios'
export async function getUsers(params = {}) {
const { data } = await api.get('/users', { params })
return data
}
export async function createUser(payload) {
const { data } = await api.post('/users', payload)
return data
}
export async function getUserById(id) {
const { data } = await api.get(`/users/${id}`)
return data
}
export async function updateUser(id, payload) {
const { data } = await api.put(`/users/${id}`, payload)
return data
}
export async function deleteUser(id) {
const { data } = await api.delete(`/users/${id}`)
return data
}
+51
View File
@@ -0,0 +1,51 @@
import api from '@/api/axios'
export async function getWilayah(tingkat, params = {}) {
const { data } = await api.get(`/wilayah/${tingkat}`, { params })
return data
}
export async function getWilayahById(tingkat, id) {
const { data } = await api.get(`/wilayah/${tingkat}/${id}`)
return data
}
export async function createWilayah(tingkat, payload) {
const { data } = await api.post(`/wilayah/${tingkat}`, payload)
return data
}
export async function updateWilayah(tingkat, id, payload) {
const { data } = await api.put(`/wilayah/${tingkat}/${id}`, payload)
return data
}
export async function deleteWilayah(tingkat, id) {
const { data } = await api.delete(`/wilayah/${tingkat}/${id}`)
return data
}
export async function getAlamatWilayah(params) {
const { data } = await api.get('/wilayah/alamat-lengkap', { params })
return data
}
export async function getProvinsiOptions(params = {}) {
const { data } = await api.get('/wilayah/options/provinsi', { params })
return data
}
export async function getKabupatenOptions(provinsiId, params = {}) {
const { data } = await api.get(`/wilayah/options/kabupaten/${provinsiId}`, { params })
return data
}
export async function getKecamatanOptions(kabupatenId, params = {}) {
const { data } = await api.get(`/wilayah/options/kecamatan/${kabupatenId}`, { params })
return data
}
export async function getDesaOptions(kecamatanId, params = {}) {
const { data } = await api.get(`/wilayah/options/desa/${kecamatanId}`, { params })
return data
}
+182
View File
@@ -0,0 +1,182 @@
import { defineStore } from 'pinia'
import {
login as loginApi,
register as registerApi,
verifyCode as verifyCodeApi,
resendVerificationCode as resendVerificationCodeApi,
} from '@/services/authService'
import {
getAuthenticatedUser,
removeAuthenticatedUser,
removeToken,
setAuthenticatedUser,
setToken,
} from '@/utils/session'
import { useMenuStore } from '@/stores/menu'
import {
setVerificationContext,
getVerificationContext,
clearVerificationContext,
} from '@/utils/verificationSession'
function extractErrorMessage(error) {
const fallback = 'Terjadi kesalahan. Silakan coba lagi.'
const data = error?.response?.data
if (!data) return error?.message || fallback
if (typeof data?.message === 'string') return data.message
if (data?.errors && typeof data.errors === 'object') {
const firstKey = Object.keys(data.errors)[0]
const firstError = data.errors[firstKey]
if (Array.isArray(firstError) && firstError.length > 0) return firstError[0]
}
return fallback
}
export const useAuthStore = defineStore('auth', {
state: () => {
const savedVerification = getVerificationContext() || {}
return {
user: getAuthenticatedUser(),
loading: false,
error: null,
pendingVerificationEmail: savedVerification.verification_target || '',
verificationChannel: savedVerification.verification_channel || 'email',
verificationTarget: savedVerification.verification_target || '',
}
},
actions: {
setUser(user) {
this.user = user || null
if (this.user) {
setAuthenticatedUser(this.user)
} else {
removeAuthenticatedUser()
}
},
clearError() {
this.error = null
},
async login(payload) {
this.loading = true
this.error = null
let tokenWasStored = false
try {
const data = await loginApi(payload)
const token = data?.token || data?.access_token || data?.data?.token
if (!token) {
throw new Error('Token tidak ditemukan pada response login.')
}
setToken(token)
tokenWasStored = true
this.setUser(data?.user || data?.data?.user || null)
await useMenuStore().fetchMenus({ force: true })
return data
} catch (error) {
if (tokenWasStored) {
removeToken()
removeAuthenticatedUser()
this.user = null
useMenuStore().reset()
this.error = 'Login berhasil, tetapi menu akses gagal dimuat. Silakan coba lagi.'
} else {
this.error = extractErrorMessage(error)
}
throw error
} finally {
this.loading = false
}
},
async register(payload) {
this.loading = true
this.error = null
try {
const data = await registerApi(payload)
this.verificationChannel = payload?.verification_channel || 'email'
this.verificationTarget = payload?.verification_target || payload?.email || ''
this.pendingVerificationEmail = this.verificationTarget
setVerificationContext({
verification_channel: this.verificationChannel,
verification_target: this.verificationTarget,
})
return data
} catch (error) {
this.error = extractErrorMessage(error)
throw error
} finally {
this.loading = false
}
},
async verifyCode(payload) {
this.loading = true
this.error = null
try {
const data = await verifyCodeApi(payload)
const token = data?.token || data?.access_token || data?.data?.token
if (token) {
setToken(token)
}
this.setUser(data?.user || data?.data?.user || this.user)
this.pendingVerificationEmail = ''
this.verificationChannel = 'email'
this.verificationTarget = ''
clearVerificationContext()
return data
} catch (error) {
this.error = extractErrorMessage(error)
throw error
} finally {
this.loading = false
}
},
async resendVerificationCode(payload) {
this.loading = true
this.error = null
try {
const data = await resendVerificationCodeApi(payload)
return data
} catch (error) {
this.error = extractErrorMessage(error)
throw error
} finally {
this.loading = false
}
},
logout() {
removeToken()
removeAuthenticatedUser()
useMenuStore().reset()
this.user = null
this.error = null
this.pendingVerificationEmail = ''
this.verificationChannel = 'email'
this.verificationTarget = ''
clearVerificationContext()
},
},
})
+100
View File
@@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import { getMyMenus } from '@/services/menuService'
let pendingMenuRequest = null
function extractErrorMessage(error) {
const fallback = 'Gagal memuat menu.'
const data = error?.response?.data
if (!data) return error?.message || fallback
if (typeof data?.message === 'string') return data.message
return fallback
}
function mapMenuItem(item, isChild = false) {
const hasChildren = Array.isArray(item?.children) && item.children.filter(Boolean).length > 0
const name = item?.name || 'Untitled'
const to = item?.url || '#'
if (hasChildren) {
return {
component: 'CNavGroup',
name,
to,
...(isChild ? {} : { icon: item?.icon || 'cil-menu' }),
items: item.children.filter(Boolean).map((child) => mapMenuItem(child, true)),
}
}
return {
component: 'CNavItem',
name,
to,
...(isChild ? {} : { icon: item?.icon || 'cil-menu' }),
}
}
export const useMenuStore = defineStore('menu', {
state: () => ({
isMaster: false,
dynamicMenus: [],
loading: false,
error: null,
initialized: false,
}),
getters: {
mappedMenus(state) {
return state.dynamicMenus.map((item) => mapMenuItem(item))
},
},
actions: {
async fetchMenus({ force = false } = {}) {
if (this.initialized && !force) {
return this.dynamicMenus
}
if (pendingMenuRequest) {
return pendingMenuRequest
}
this.loading = true
this.error = null
pendingMenuRequest = (async () => {
try {
const response = await getMyMenus()
const payload = response?.data || {}
this.isMaster = !!payload?.is_master
this.dynamicMenus = Array.isArray(payload?.menus) ? payload.menus : []
this.initialized = true
return this.dynamicMenus
} catch (error) {
this.error = extractErrorMessage(error)
this.initialized = false
this.dynamicMenus = []
throw error
} finally {
this.loading = false
pendingMenuRequest = null
}
})()
return pendingMenuRequest
},
reset() {
this.isMaster = false
this.dynamicMenus = []
this.loading = false
this.error = null
this.initialized = false
pendingMenuRequest = null
},
},
})
+24
View File
@@ -0,0 +1,24 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useMenuGroupStore = defineStore('menuGroup', () => {
const menuGroups = ref([])
function setMenuGroups(data) {
menuGroups.value = data
}
function addMenuGroup(item) {
menuGroups.value.push({ ...item })
}
function updateMenuGroup(index, item) {
menuGroups.value[index] = { ...item }
}
function deleteMenuGroup(index) {
menuGroups.value.splice(index, 1)
}
return { menuGroups, setMenuGroups, addMenuGroup, updateMenuGroup, deleteMenuGroup }
})
+517
View File
@@ -0,0 +1,517 @@
/* Reusable UI/UX style for BaseResponsiveDataView
Prefix khusus: brdvx- (agar tidak bentrok dengan style existing) */
.brdvx-page-row {
--cui-gutter-x: 0;
margin-right: 0;
margin-left: 0;
}
.brdvx-page-column {
padding-right: 0;
padding-left: 0;
}
.brdvx-page-container {
margin: 0;
padding-right: 0.5rem;
padding-left: 0.5rem;
}
.brdvx-page-header {
margin-bottom: 0.85rem;
}
.brdvx-root {
--brdvx-font-size: 0.9rem;
--brdvx-font-size-label: 0.78rem;
--brdvx-font-size-heading: 0.84rem;
--brdvx-font-weight-normal: 400;
--brdvx-font-weight-medium: 500;
--brdvx-font-weight-heading: 600;
width: 100%;
font-size: var(--brdvx-font-size);
}
.brdvx-loading-state {
display: flex;
min-height: 220px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--cui-secondary-color);
}
.brdvx-loading-text {
margin: 0;
font-size: 0.9rem;
font-weight: 400;
}
.brdvx-inline-loading {
display: flex;
min-height: 2.5rem;
align-items: center;
justify-content: center;
gap: 0.55rem;
margin-bottom: 0.5rem;
color: var(--cui-secondary-color);
font-size: 0.875rem;
font-weight: 400;
}
.brdvx-root .data-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.9rem;
flex-wrap: wrap;
}
.brdvx-root .toolbar-left {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.brdvx-root .per-page-wrap {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.brdvx-root .toolbar-label {
font-size: 0.875rem;
font-weight: var(--brdvx-font-weight-normal);
color: var(--cui-secondary-color);
margin: 0;
}
.brdvx-root .per-page-select {
min-width: 80px;
}
.brdvx-root .mode-switch {
display: inline-flex;
gap: 0.35rem;
}
.brdvx-root .toolbar-search-wrap {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 220px;
width: min(420px, 100%);
}
.brdvx-root .toolbar-search {
flex: 1;
}
.brdvx-root .view-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.brdvx-root .cards-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
}
.brdvx-root .cards-grid.mobile {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.brdvx-root .mobile-card {
display: flex;
flex-direction: column;
height: 100%;
border-radius: 0.75rem;
border: 1px solid var(--cui-border-color);
box-shadow: 0 2px 12px rgba(33, 37, 41, 0.06);
overflow: hidden;
}
.brdvx-root .mobile-image-header {
width: 100%;
max-height: 160px;
aspect-ratio: 10 / 7;
background: #f1f3f5;
}
.brdvx-root .mobile-image {
width: 100%;
max-height: 160px;
object-fit: cover;
}
.brdvx-root .mobile-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--cui-border-color);
background: var(--cui-tertiary-bg);
}
.brdvx-root .mobile-card-title {
font-weight: var(--brdvx-font-weight-heading);
font-size: 0.95rem;
color: var(--cui-body-color);
}
.brdvx-root .mobile-card-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
padding: 0.65rem 0.75rem;
}
.brdvx-root .mobile-fields {
display: grid;
gap: 0.38rem;
}
.brdvx-root .mobile-field {
display: grid;
grid-template-columns: minmax(70px, 1fr) minmax(0, 1fr);
align-items: start;
gap: 0.45rem;
padding-bottom: 0.3rem;
border-bottom: 1px dashed var(--cui-border-color);
}
.brdvx-root .mobile-field:last-child {
border-bottom: none;
padding-bottom: 0;
}
.brdvx-root .field-label {
font-size: var(--brdvx-font-size-label);
color: var(--cui-secondary-color);
text-transform: uppercase;
letter-spacing: 0.25px;
font-weight: var(--brdvx-font-weight-heading);
}
.brdvx-root .field-value {
font-size: var(--brdvx-font-size);
color: var(--cui-body-color);
font-weight: var(--brdvx-font-weight-normal);
word-break: break-word;
text-align: right;
}
.brdvx-root .mobile-actions {
margin-top: auto;
padding-top: 0.55rem;
display: flex;
justify-content: flex-end;
gap: 0.3rem;
flex-wrap: wrap;
}
.brdvx-root .table-list-wrapper {
border-radius: 0.75rem;
overflow: hidden;
// background: #fff;
// border: 1px solid #e9ecef;
display: grid;
gap: 0.25rem;
padding: 0.25rem;
}
.brdvx-root .table-list-head {
display: grid;
width: 100%;
gap: 0.55rem 0.75rem;
padding: 0 0.65rem 0.35rem;
border-bottom: 1px solid var(--cui-border-color);
margin-bottom: 0.2rem;
align-items: end;
}
.brdvx-root .table-list-head-cell {
font-size: var(--brdvx-font-size-heading);
color: var(--cui-secondary-color);
font-weight: var(--brdvx-font-weight-heading);
text-transform: uppercase;
}
.brdvx-root .table-list-card {
border: 1px solid var(--cui-border-color);
border-radius: 0.65rem;
}
.brdvx-root .table-list-card-body {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem;
}
.brdvx-root .table-list-main {
display: grid;
grid-template-columns: 1fr;
gap: 0.55rem 0.75rem;
flex: 1;
min-width: 0;
}
.brdvx-root .table-list-main.with-image {
grid-template-columns: 15% 85%;
align-items: start;
gap: 0.65rem;
}
.brdvx-root .table-list-image-col {
min-width: 0;
}
.brdvx-root .table-list-mobile-image {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 0.55rem;
object-fit: cover;
border: 1px solid var(--cui-border-color);
}
.brdvx-root .table-list-data-col {
display: grid;
width: 100%;
gap: 0.55rem 0.75rem;
align-items: center;
}
.brdvx-root .desktop-row-grid {
grid-template-columns: repeat(var(--brdvx-desktop-cols, 6), minmax(120px, 1fr));
}
.brdvx-root .table-list-cell {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.brdvx-root .table-list-value {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
font-size: var(--brdvx-font-size);
color: var(--cui-body-color);
font-weight: var(--brdvx-font-weight-normal);
text-align: left;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.brdvx-sort-button {
display: inline-flex;
width: 100%;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
padding: 0;
color: inherit;
font: inherit;
text-align: left;
text-transform: uppercase;
background: transparent;
border: 0;
}
.brdvx-sort-button:hover {
color: var(--cui-primary);
}
.brdvx-root .row-pair {
display: flex;
width: 100%;
min-width: 0;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.5rem;
}
.brdvx-root .row-left {
color: var(--cui-secondary-color);
font-weight: var(--brdvx-font-weight-heading);
font-size: var(--brdvx-font-size-label);
text-align: left;
}
.brdvx-root .row-left.no-label {
text-transform: none;
}
.brdvx-root .row-right {
flex: 1 1 100%;
text-align: left;
min-width: 0;
max-width: 100%;
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.brdvx-root .row-right.no-label {
width: 100%;
}
.brdvx-root .table-list-right {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
margin-left: auto;
width: var(--brdvx-action-col-width, 230px);
min-width: var(--brdvx-action-col-width, 230px);
}
.brdvx-root .table-avatar {
width: 55px;
height: 55px;
border-radius: 50%;
object-fit: cover;
}
.brdvx-root .table-actions {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
justify-content: flex-end;
width: 100%;
}
.brdvx-root .table-list-hidden {
border-top: 1px dashed var(--cui-border-color);
padding: 0.55rem 0.65rem 0.65rem;
display: grid;
gap: 0.35rem;
}
.brdvx-root .hidden-item {
display: grid;
grid-template-columns: minmax(80px, 1fr) minmax(0, 1fr);
gap: 0.5rem;
}
.brdvx-root .hidden-label {
font-size: var(--brdvx-font-size-label);
color: var(--cui-secondary-color);
font-weight: var(--brdvx-font-weight-heading);
}
.brdvx-root .hidden-value {
font-size: var(--brdvx-font-size);
color: var(--cui-body-color);
font-weight: var(--brdvx-font-weight-normal);
}
.brdvx-root .pagination-center {
margin-top: 0.9rem;
display: flex;
justify-content: center;
}
@media (max-width: 1200px) {
.brdvx-root .cards-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 992px) {
.brdvx-root .cards-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.brdvx-root .data-toolbar {
justify-content: center;
}
.brdvx-root .toolbar-left {
width: 100%;
justify-content: center;
}
.brdvx-root .toolbar-search {
width: 100%;
max-width: 360px;
}
.brdvx-root .table-list-card-body {
flex-direction: column;
}
.brdvx-root .table-list-main {
width: 100%;
}
.brdvx-root .table-list-data-col {
grid-template-columns: 1fr;
}
.brdvx-root .table-list-data-col.mobile-split {
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.brdvx-root .mobile-col {
display: grid;
gap: 0.35rem;
}
.brdvx-root .row-left {
text-align: left;
}
.brdvx-root .row-right {
text-align: left;
}
.brdvx-root .table-list-right {
width: 100%;
justify-content: space-between;
}
.brdvx-root .table-actions {
overflow-x: auto;
max-width: 100%;
}
}
@media (max-width: 420px) {
.brdvx-root .cards-grid.mobile {
gap: 0.5rem;
}
.brdvx-root .mobile-card {
border-radius: 0.65rem;
}
.brdvx-root .mobile-card-body {
padding: 0.55rem 0.6rem;
}
.brdvx-root .field-label {
font-size: 0.74rem;
}
.brdvx-root .field-value {
font-size: 0.85rem;
}
}
+119
View File
@@ -7,6 +7,7 @@
);
@use '@coreui/chartjs/scss/coreui-chartjs';
@use 'vendors/simplebar';
@use 'base-responsive-data-view';
body {
background-color: var(--cui-tertiary-bg);
@@ -202,3 +203,121 @@ body {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
// Tambahkan class ini pada <img> atau pembungkus gambar agar dapat diklik dan diperbesar.
.image-previewable {
cursor: zoom-in;
}
.image-previewable img {
transition: transform 0.2s ease, filter 0.2s ease;
}
.image-previewable:hover img {
filter: brightness(0.92);
transform: scale(1.025);
}
body.image-preview-open {
overflow: hidden;
}
.image-preview-modal {
position: fixed;
inset: 0;
z-index: 11000;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
background: rgba(7, 10, 16, 0.88);
backdrop-filter: blur(8px);
}
.image-preview-modal-image {
display: block;
max-width: min(92vw, 1100px);
max-height: 88vh;
object-fit: contain;
border-radius: 1rem;
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.45);
}
.image-preview-close {
position: fixed;
top: 1.25rem;
right: 1.25rem;
z-index: 1;
display: grid;
width: 2.75rem;
height: 2.75rem;
padding: 0;
color: #fff;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 50%;
place-items: center;
}
.image-preview-fade-enter-active,
.image-preview-fade-leave-active {
transition: opacity 0.2s ease;
}
.image-preview-fade-enter-from,
.image-preview-fade-leave-to {
opacity: 0;
}
.header-profile-avatar {
font-weight: 700;
}
.profile-photo-avatar {
width: 112px;
height: 112px;
margin-bottom: 1rem;
overflow: hidden;
font-size: 2rem;
border: 4px solid var(--cui-body-bg);
box-shadow: 0 0 0 2px rgba(var(--cui-primary-rgb), 0.25), 0 0.75rem 2rem rgba(0, 0, 0, 0.12);
}
.profile-photo-uploader {
padding: 1.15rem;
background: rgba(var(--cui-primary-rgb), 0.045);
border: 1.5px dashed rgba(var(--cui-primary-rgb), 0.42);
border-radius: 1rem;
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.profile-photo-uploader.is-dragging {
background: rgba(var(--cui-primary-rgb), 0.12);
border-color: var(--cui-primary);
transform: translateY(-2px);
}
.profile-photo-uploader-icon {
display: grid;
width: 3rem;
height: 3rem;
margin: 0 auto 0.7rem;
color: var(--cui-primary);
background: rgba(var(--cui-primary-rgb), 0.12);
border-radius: 50%;
place-items: center;
}
.profile-photo-file {
display: flex;
gap: 0.4rem;
align-items: center;
justify-content: center;
min-width: 0;
font-size: 0.8rem;
}
.profile-photo-file span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+24
View File
@@ -1,4 +1,5 @@
const TOKEN_KEY = 'UU7HVn7ECHe7MvqMTeTeEpoFdBA2wc0NHQX29WyG91fc6982'
const USER_KEY = 'manja_authenticated_user'
export function setToken(token) {
sessionStorage.setItem(TOKEN_KEY, token)
@@ -16,6 +17,29 @@ export function hasToken() {
return !!sessionStorage.getItem(TOKEN_KEY)
}
export function setAuthenticatedUser(user) {
if (user) {
sessionStorage.setItem(USER_KEY, JSON.stringify(user))
}
}
export function getAuthenticatedUser() {
const value = sessionStorage.getItem(USER_KEY)
if (!value) return null
try {
return JSON.parse(value)
} catch {
sessionStorage.removeItem(USER_KEY)
return null
}
}
export function removeAuthenticatedUser() {
sessionStorage.removeItem(USER_KEY)
}
export function clearSession() {
sessionStorage.clear()
}
+14 -17
View File
@@ -15,23 +15,12 @@ export function showConfirm(title = 'Konfirmasi', text = 'Apakah Anda yakin?') {
export function showSuccess(message = 'Data berhasil disimpan') {
Swal.fire({
position: 'top-end',
icon: 'success',
title: 'Berhasil',
// title: 'Berhasil',
text: message,
showConfirmButton: false,
timer: 2000,
timerProgressBar: true,
theme: 'auto',
})
}
export function showSuccessr() {
Swal.fire({
position: 'top-end',
icon: 'success',
title: 'Data Berhasil Di Simpan',
showConfirmButton: false,
timer: 1500,
theme: 'auto',
toast: true,
width: '350px',
@@ -42,12 +31,20 @@ export function showSuccessr() {
})
}
export function showError() {
export function showError(message = 'Terjadi Kesalahan') {
Swal.fire({
position: 'top-end',
icon: 'error',
title: 'Gagal',
text: 'Terjadi Kesalahan',
confirmButtonText: 'Tutup',
// title: 'Gagal',
text: message,
showConfirmButton: 'Tutup',
// timer: 2000,
theme: 'auto',
toast: true,
width: '350px',
padding: '10px 18px',
customClass: {
title: 'swal-title-bold',
},
})
}
+21
View File
@@ -0,0 +1,21 @@
const VERIFICATION_SESSION_KEY = 'manja_verification_context'
export function setVerificationContext(payload) {
sessionStorage.setItem(VERIFICATION_SESSION_KEY, JSON.stringify(payload))
}
export function getVerificationContext() {
const raw = sessionStorage.getItem(VERIFICATION_SESSION_KEY)
if (!raw) return null
try {
return JSON.parse(raw)
} catch (error) {
console.error('Failed to parse verification session context:', error)
return null
}
}
export function clearVerificationContext() {
sessionStorage.removeItem(VERIFICATION_SESSION_KEY)
}
@@ -0,0 +1,353 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
getMenuGroupAvailableById,
getMenuGroupById,
syncMenuGroupMenus,
} from '@/services/menuGroupService'
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 menuGroup = ref(null)
const menuPermissions = ref([])
const permissionKeys = [
'can_view',
'can_create',
'can_update',
'can_delete',
'can_approve',
'can_export',
]
const permissionLabels = {
can_view: 'View',
can_create: 'Create',
can_update: 'Update',
can_delete: 'Delete',
can_approve: 'Approve',
can_export: 'Export',
}
const isMasterAdmin = computed(
() =>
authStore.user?.is_master === true ||
authStore.user?.access_level === 'master_admin',
)
const canManageGroup = computed(
() => !!menuGroup.value && (isMasterAdmin.value || !menuGroup.value.is_system),
)
const menuSections = computed(() => {
const childrenByParent = new Map()
menuPermissions.value.forEach((menu) => {
const parentId = menu.parent_id ?? null
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, [])
childrenByParent.get(parentId).push(menu)
})
const flattenChildren = (parentId, depth = 1) =>
(childrenByParent.get(parentId) || []).flatMap((menu) => [
Object.assign(menu, { depth }),
...flattenChildren(menu.menu_id, depth + 1),
])
return (childrenByParent.get(null) || []).map((root) => ({
root,
descendants: flattenChildren(root.menu_id),
}))
})
function normalizeMenus(rawMenus) {
const accessibleMenus = isMasterAdmin.value
? rawMenus
: rawMenus.filter((menu) =>
permissionKeys.some((key) => menu.available_permissions?.[key] === true),
)
const accessibleIds = new Set(accessibleMenus.map((menu) => menu.menu_id))
const parentIds = new Set(
accessibleMenus.map((menu) => menu.parent_id).filter((id) => accessibleIds.has(id)),
)
return accessibleMenus.map((menu) => {
const permissions = menu.permissions || {}
const hasChildren = parentIds.has(menu.menu_id)
const availablePermissions = permissionKeys.reduce((result, key) => {
result[key] = menu.available_permissions?.[key] !== false
return result
}, {})
return {
menu_id: menu.menu_id,
parent_id: accessibleIds.has(menu.parent_id) ? menu.parent_id : null,
has_children: hasChildren,
name: menu.name || menu.label || `Menu #${menu.menu_id}`,
available_permissions: availablePermissions,
can_view: availablePermissions.can_view && !!permissions.can_view,
can_create: !hasChildren && availablePermissions.can_create && !!permissions.can_create,
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_export: !hasChildren && availablePermissions.can_export && !!permissions.can_export,
}
})
}
async function loadData() {
loading.value = true
try {
const [groupResponse, menuResponse] = await Promise.all([
getMenuGroupById(route.params.id),
getMenuGroupAvailableById(route.params.id),
])
menuGroup.value = groupResponse?.data || null
const rawMenus = Array.isArray(menuResponse?.data?.menus) ? menuResponse.data.menus : []
menuPermissions.value = normalizeMenus(rawMenus)
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat detail menu group.')
router.replace('/group-menus')
} finally {
loading.value = false
}
}
function availablePermissionKeys(menu) {
const keys = menu.has_children ? ['can_view'] : permissionKeys
return keys.filter((key) => menu.available_permissions?.[key])
}
function isMenuChecked(menu) {
const keys = availablePermissionKeys(menu)
return keys.length > 0 && keys.every((key) => menu[key])
}
function toggleMenu(menu, checked) {
availablePermissionKeys(menu).forEach((key) => {
menu[key] = checked
})
}
async function savePermissions() {
if (!canManageGroup.value || !menuPermissions.value.length) return
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,
})),
})
showSuccess('Permission menu group berhasil disimpan.')
} catch (error) {
showError(error?.response?.data?.message || 'Gagal menyimpan permission menu group.')
} finally {
saving.value = false
}
}
onMounted(loadData)
</script>
<template>
<div class="container-fluid px-2">
<div class="permissions-header">
<div>
<!-- <CButton color="secondary" variant="ghost" size="sm" class="mb-1" @click="router.push('/group-menus')">
&larr; Kembali
</CButton> -->
<h5 class="mb-1 fw-semibold">Permission Menu Group</h5>
<div class="small text-body-secondary">
{{ menuGroup?.name || 'Memuat menu group...' }}
<span v-if="menuGroup?.tenant?.tenant_name"> · {{ menuGroup.tenant.tenant_name }}</span>
</div>
</div>
<CButton
v-if="canManageGroup"
color="primary"
size="sm"
:disabled="loading || saving || !menuPermissions.length"
@click="savePermissions"
>
{{ saving ? 'Menyimpan...' : 'Simpan Permission' }}
</CButton>
</div>
<div v-if="loading" class="loading-state">
<CSpinner color="primary" size="sm" />
<span>Memuat data menu...</span>
</div>
<CAlert v-else-if="!canManageGroup" color="warning">
Menu group sistem hanya dapat diubah oleh Master Admin.
</CAlert>
<CRow v-else-if="menuSections.length" class="g-2">
<CCol v-for="section in menuSections" :key="section.root.menu_id" :xs="6" :md="4">
<CCard class="menu-section-card">
<CCardHeader class="menu-section-header">
<CFormCheck
:model-value="isMenuChecked(section.root)"
@change="toggleMenu(section.root, $event.target.checked)"
/>
<span>{{ section.root.name }}</span>
</CCardHeader>
<CCardBody class="p-0">
<div v-if="!section.descendants.length" class="root-permission-list">
<CFormCheck
v-for="key in availablePermissionKeys(section.root)"
:key="key"
v-model="section.root[key]"
:label="permissionLabels[key]"
/>
</div>
<div
v-for="menu in section.descendants"
:key="menu.menu_id"
class="menu-permission-item"
:style="{ '--menu-depth': menu.depth }"
>
<div class="menu-name-row">
<span class="tree-connector" aria-hidden="true"></span>
<CFormCheck
:model-value="isMenuChecked(menu)"
@change="toggleMenu(menu, $event.target.checked)"
/>
<span class="menu-name">{{ menu.name }}</span>
</div>
<div class="permission-list">
<CFormCheck
v-for="key in availablePermissionKeys(menu)"
:key="key"
v-model="menu[key]"
:label="permissionLabels[key]"
/>
</div>
</div>
</CCardBody>
</CCard>
</CCol>
</CRow>
<div v-else class="empty-state">Data menu tidak tersedia.</div>
</div>
</template>
<style scoped>
.permissions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.loading-state,
.empty-state {
display: flex;
justify-content: center;
align-items: center;
gap: 0.6rem;
min-height: 10rem;
color: var(--cui-secondary-color);
}
.menu-section-card {
height: 100%;
overflow: hidden;
}
.menu-section-header {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 2.65rem;
padding: 0.55rem 0.75rem;
font-size: 0.925rem;
font-weight: 600;
}
.menu-permission-item {
padding: 0.55rem 0.7rem 0.55rem calc(0.7rem + (var(--menu-depth) * 0.65rem));
border-top: 1px solid var(--cui-border-color);
}
.menu-name-row {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
font-size: 0.875rem;
font-weight: 500;
}
.tree-connector {
width: 0.65rem;
height: 0.65rem;
flex: 0 0 auto;
border-bottom: 1px solid var(--cui-border-color);
border-left: 1px solid var(--cui-border-color);
}
.menu-name {
min-width: 0;
overflow-wrap: anywhere;
}
.permission-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.85rem;
padding: 0.35rem 0 0 2rem;
font-size: 0.8rem;
}
.root-permission-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.3rem 0.65rem;
padding: 0.6rem 0.75rem;
font-size: 0.8rem;
}
.root-permission-list :deep(.form-check) {
margin-bottom: 0;
}
.permission-list :deep(.form-check) {
min-width: 4.7rem;
margin-bottom: 0;
}
@media (max-width: 575.98px) {
.permissions-header {
align-items: flex-start;
flex-direction: column;
}
.permissions-header > button {
width: 100%;
}
.root-permission-list {
grid-template-columns: 1fr;
}
.permission-list {
padding-left: 1.25rem;
}
}
</style>
+433
View File
@@ -0,0 +1,433 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { useMenuGroupStore } from '@/stores/menuGroup'
import { useAuthStore } from '@/stores/auth'
import {
getMenuGroups,
createMenuGroup,
getMenuGroupById,
updateMenuGroup,
deleteMenuGroup,
} from '@/services/menuGroupService'
import { formatTanggal } from '@/utils/tglindo.js'
import { showConfirm, showSuccess, showError } from '@/utils/swal.js'
import { getTenants } from '@/services/tenantService'
const store = useMenuGroupStore()
const authStore = useAuthStore()
const router = useRouter()
const loading = ref(true)
const loadedOnce = ref(false)
const saving = ref(false)
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 modal = ref(false)
const detailModal = ref(false)
const isEdit = ref(false)
const editingIndex = ref(-1)
const deleteIndex = ref(-1)
const tenantOptions = ref([])
const tenantOptionsLoaded = ref(false)
const form = reactive({
tenant_id: '',
name: '',
description: '',
is_active: true,
})
const detailItem = ref(null)
function emptyForm() {
return {
tenant_id: '',
name: '',
description: '',
is_active: true,
}
}
function responseRows(response) {
if (Array.isArray(response?.data?.data)) return response.data.data
if (Array.isArray(response?.data)) return response.data
return []
}
async function loadTenantOptions() {
if (!isMasterAdmin.value || tenantOptionsLoaded.value) return
try {
const response = await getTenants({
per_page: 100,
sort_by: 'tenant_name',
sort_direction: 'asc',
})
tenantOptions.value = responseRows(response)
tenantOptionsLoaded.value = true
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat pilihan tenant.')
}
}
async function fetchAllData(params = currentQuery.value) {
currentQuery.value = { ...currentQuery.value, ...params }
params = currentQuery.value
loading.value = true
try {
const res = await getMenuGroups(params)
const rows = Array.isArray(res?.data?.data)
? res.data.data
: Array.isArray(res?.data)
? res.data
: []
store.setMenuGroups(rows)
const meta = res?.data && !Array.isArray(res.data) ? res.data : {}
pagination.value = {
current_page: meta.current_page || params.page || 1,
last_page: meta.last_page || 1,
per_page: meta.per_page || params.per_page || 10,
total: meta.total || rows.length,
}
} catch (err) {
showError(err?.response?.data?.message || 'Gagal memuat menu groups: ' + err.message)
} finally {
loading.value = false
loadedOnce.value = true
}
}
onMounted(() => {
fetchAllData()
})
async function openAddModal() {
isEdit.value = false
editingIndex.value = -1
Object.assign(form, emptyForm())
await loadTenantOptions()
modal.value = true
}
async function openEditModal(item) {
if (!canManageGroup(item)) {
showError('Menu group sistem hanya dapat diubah oleh Master Admin.')
return
}
const idx = store.menuGroups.findIndex((m) => m.id === item.id)
if (idx === -1) return
await loadTenantOptions()
isEdit.value = true
editingIndex.value = idx
Object.assign(form, {
tenant_id: item.tenant_id ? String(item.tenant_id) : '',
name: item.name || '',
description: item.description || '',
is_active: !!item.is_active,
})
modal.value = true
}
async function openDetail(item) {
try {
const res = await getMenuGroupById(item.id)
const payload = Array.isArray(res?.data) ? res.data[0] : res?.data
detailItem.value = payload || null
detailModal.value = true
} catch (err) {
showError(err?.response?.data?.message || 'Gagal memuat detail: ' + err.message)
}
}
async function openDeleteModal(item) {
if (!canManageGroup(item)) {
showError('Menu group sistem hanya dapat dihapus oleh Master Admin.')
return
}
const idx = store.menuGroups.findIndex((m) => m.id === item.id)
if (idx === -1) return
const result = await showConfirm(
'Konfirmasi Hapus',
`Apakah Anda yakin ingin menghapus group menu "${item.name}"?`,
)
if (!result.isConfirmed) return
deleteIndex.value = idx
await removeItem(item.id)
}
async function createItem() {
saving.value = true
try {
const body = {
name: form.name,
description: form.description,
is_active: !!form.is_active,
}
if (isMasterAdmin.value) {
body.tenant_id = form.tenant_id ? Number(form.tenant_id) : null
}
const res = await createMenuGroup(body)
const created = Array.isArray(res?.data) ? res.data[0] : res?.data
store.addMenuGroup(created || body)
modal.value = false
showSuccess('Data berhasil disimpan')
await fetchAllData()
} catch (err) {
showError(err?.response?.data?.message || 'Gagal menyimpan data: ' + err.message)
} finally {
saving.value = false
}
}
async function updateItem(id) {
saving.value = true
try {
const body = {
name: form.name,
description: form.description,
is_active: !!form.is_active,
}
if (isMasterAdmin.value) {
body.tenant_id = form.tenant_id ? Number(form.tenant_id) : null
}
const res = await updateMenuGroup(id, body)
const updated = Array.isArray(res?.data) ? res.data[0] : res?.data
store.updateMenuGroup(editingIndex.value, updated || { id, ...body })
modal.value = false
showSuccess('Data berhasil diupdate')
await fetchAllData()
} catch (err) {
showError(err?.response?.data?.message || 'Gagal mengupdate data: ' + err.message)
} finally {
saving.value = false
}
}
async function removeItem(id) {
try {
await deleteMenuGroup(id)
store.deleteMenuGroup(deleteIndex.value)
showSuccess('Data berhasil dihapus')
await fetchAllData()
} catch (err) {
showError(err?.response?.data?.message || 'Gagal menghapus data: ' + err.message)
} finally {
deleteIndex.value = -1
}
}
const columns = [
{ key: 'name', label: 'Nama' },
{ key: 'description_label', label: 'Deskripsi', sortKey: 'description' },
{ key: 'scope_label', label: 'Scope', sortable: false },
{ key: 'type_label', label: 'Tipe', sortKey: 'is_system' },
{ key: 'status_label', label: 'Status', sortKey: 'is_active' },
]
const tableActions = [
{ key: 'detail', label: 'Detail', color: 'primary' },
{ key: 'detail-menu', label: 'Detail Menu', color: 'secondary' },
{ key: 'edit', label: 'Edit', color: 'success' },
{ key: 'delete', label: 'Hapus', color: 'danger' },
]
const menuGroupItems = computed(() =>
store.menuGroups.map((item) => ({
...item,
description_label: item.description || '-',
scope_label:
item.tenant?.tenant_name || (item.tenant_id ? `Tenant #${item.tenant_id}` : 'Global'),
type_label: item.is_system ? 'Sistem' : 'Tenant',
status_label: item.is_active ? 'Aktif' : 'Nonaktif',
})),
)
const isMasterAdmin = computed(
() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin',
)
const activeTenant = computed(() => {
const memberships = authStore.user?.user_tenants || []
return memberships.find((row) => row.is_default)?.tenant || memberships[0]?.tenant || null
})
function canManageGroup(item) {
return !!item && (isMasterAdmin.value || !item.is_system)
}
function onActionClick({ action, item }) {
if (action === 'detail') openDetail(item)
if (action === 'detail-menu') router.push(`/group-menus/${item.id}/menus`)
if (action === 'edit') openEditModal(item)
if (action === 'delete') openDeleteModal(item)
}
async function saveItem() {
if (!form.name.trim()) {
showError('Nama group menu wajib diisi.')
return
}
if (isEdit.value && editingIndex.value >= 0) {
const item = store.menuGroups[editingIndex.value]
await updateItem(item.id)
} else {
await createItem()
}
}
</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">Group Menus</h5>
</div>
<div class="d-flex gap-2">
<CButton color="primary" size="sm" variant="outline" :disabled="loading" @click="fetchAllData()">
{{ loading ? 'Loading...' : 'Refresh' }}
</CButton>
<CButton color="primary" size="sm" @click="openAddModal">+ Tambah Group Menu</CButton>
</div>
</div>
<div v-if="loading && !loadedOnce" class="brdvx-loading-state" role="status" aria-live="polite">
<CSpinner color="primary" />
<p class="brdvx-loading-text">Memuat data...</p>
</div>
<BaseResponsiveDataView
v-else
:columns="columns"
:items="menuGroupItems"
server-side
:pagination="pagination"
:loading="loading"
initial-sort-by="id"
initial-sort-direction="desc"
:actions="tableActions"
row-key="id"
@action-click="onActionClick"
@query-change="fetchAllData"
/>
</div>
</CCol>
</CRow>
<CModal :visible="modal" @close="modal = false" alignment="center">
<CModalHeader dismiss @close="modal = false">
<CModalTitle>{{ isEdit ? 'Edit Group Menu' : 'Tambah Group Menu' }}</CModalTitle>
</CModalHeader>
<CModalBody>
<CForm>
<div v-if="isMasterAdmin" class="mb-3">
<CFormLabel>Scope Tenant</CFormLabel>
<CFormSelect v-model="form.tenant_id">
<option value="">Global / Sistem</option>
<option v-for="tenant in tenantOptions" :key="tenant.id" :value="String(tenant.id)">
{{ tenant.tenant_code }} {{ tenant.tenant_name }}
</option>
</CFormSelect>
<div class="form-text">
Pilih Global untuk group sistem, atau tenant untuk group khusus tenant.
</div>
</div>
<div v-else class="alert alert-info py-2">
Group akan dibuat untuk tenant
<strong>{{ activeTenant?.tenant_name || 'aktif' }}</strong>.
</div>
<div class="mb-3">
<CFormLabel>Nama</CFormLabel>
<CFormInput v-model="form.name" placeholder="Contoh: Master Data" />
</div>
<div class="mb-3">
<CFormLabel>Deskripsi</CFormLabel>
<CFormTextarea
v-model="form.description"
rows="3"
placeholder="Deskripsi group menu (opsional)"
/>
</div>
<div class="mb-3">
<CFormCheck v-model="form.is_active" label="Aktif" />
</div>
</CForm>
</CModalBody>
<CModalFooter>
<CButton color="secondary" @click="modal = false" :disabled="saving">Batal</CButton>
<CButton color="primary" @click="saveItem" :disabled="saving">
<span v-if="saving">Menyimpan...</span>
<span v-else>Simpan</span>
</CButton>
</CModalFooter>
</CModal>
<CModal :visible="detailModal" @close="detailModal = false" alignment="center">
<CModalHeader dismiss @close="detailModal = false">
<CModalTitle>Detail Group Menu</CModalTitle>
</CModalHeader>
<CModalBody>
<CTable bordered small v-if="detailItem">
<CTableBody>
<CTableRow>
<CTableHeaderCell style="width: 40%">ID</CTableHeaderCell>
<CTableDataCell>{{ detailItem.id }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Nama</CTableHeaderCell>
<CTableDataCell>{{ detailItem.name }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Deskripsi</CTableHeaderCell>
<CTableDataCell>{{ detailItem.description || '-' }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Scope</CTableHeaderCell>
<CTableDataCell>
{{
detailItem.tenant?.tenant_name ||
(detailItem.tenant_id ? `Tenant #${detailItem.tenant_id}` : 'Global')
}}
</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Tipe</CTableHeaderCell>
<CTableDataCell>{{ detailItem.is_system ? 'Sistem' : 'Tenant' }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Status</CTableHeaderCell>
<CTableDataCell>
<CBadge :color="detailItem.is_active ? 'success' : 'secondary'">
{{ detailItem.is_active ? 'Aktif' : 'Nonaktif' }}
</CBadge>
</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Created At</CTableHeaderCell>
<CTableDataCell>{{ formatTanggal(detailItem.created_at) }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Updated At</CTableHeaderCell>
<CTableDataCell>{{ formatTanggal(detailItem.updated_at) }}</CTableDataCell>
</CTableRow>
</CTableBody>
</CTable>
</CModalBody>
<CModalFooter>
<CButton color="secondary" @click="detailModal = false">Tutup</CButton>
</CModalFooter>
</CModal>
</template>
+376
View File
@@ -0,0 +1,376 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { useAuthStore } from '@/stores/auth'
import { getTenants } from '@/services/tenantService'
import {
createNasResource,
deleteNasResource,
getNasResource,
getNasResources,
updateNasResource,
} from '@/services/nasService'
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
const route = useRoute()
const authStore = useAuthStore()
const resource = computed(() => route.meta.nasResource)
const configs = {
mikrotik: {
title: 'Mikrotik',
nameLabel: 'Nama Mikrotik',
columns: [
{ key: 'name', label: 'Nama' },
{ key: 'connection_type_label', label: 'Koneksi', sortKey: 'connection_type' },
{ key: 'host', label: 'Host' },
{ 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: 'timeout', label: 'Timeout (detik)', type: 'number' },
],
},
'package-profile': {
title: 'Profile Paket',
nameLabel: 'Nama Paket',
columns: [
{ key: 'name', label: 'Nama' },
{ key: 'service_type_label', label: 'Layanan', sortKey: 'service_type' },
{ key: 'speed_label', label: 'Kecepatan', sortable: false },
{ key: 'price_label', label: 'Harga', sortable: false },
{ 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: '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: 'price', label: 'Harga', type: 'number' },
],
},
olt: {
title: 'OLT',
nameLabel: 'Nama OLT',
columns: [
{ key: 'name', label: 'Nama' },
{ key: 'vendor', label: 'Vendor' },
{ key: 'host', label: 'Host' },
{ key: 'snmp_version', label: 'SNMP' },
{ key: 'status_label', label: 'Status', sortKey: 'status' },
],
fields: [
{ key: 'vendor', label: 'Vendor' },
{ 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: '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: 'timeout', label: 'Timeout (detik)', type: 'number' },
],
},
webfig: {
title: 'Webfig',
nameLabel: 'Nama Perangkat',
columns: [
{ key: 'name', label: 'Nama' },
{ key: 'device_type', label: 'Tipe' },
{ key: 'url', label: 'URL' },
{ key: 'status_label', label: 'Status', sortKey: 'status' },
],
fields: [
{ key: 'device_type', label: 'Tipe Perangkat' },
{ key: 'url', label: 'URL Perangkat', type: 'url', required: true },
{ key: 'username', label: 'Username' },
{ key: 'password', label: 'Password', type: 'password', secret: true },
],
},
}
const config = computed(() => configs[resource.value])
const loading = ref(true)
const loadedOnce = ref(false)
const saving = ref(false)
const items = ref([])
const tenants = ref([])
const modal = ref(false)
const detailModal = ref(false)
const editingId = ref(null)
const detailItem = ref(null)
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 isMasterAdmin = computed(
() => authStore.user?.is_master === true || authStore.user?.access_level === 'master_admin',
)
const actions = [
{ key: 'detail', label: 'Detail', color: 'primary' },
{ key: 'edit', label: 'Edit', color: 'success' },
{ key: 'delete', label: 'Hapus', color: 'danger' },
]
function defaults() {
return {
tenant_id: '',
name: '',
status: 'active',
notes: '',
connection_type: 'api',
api_port: 8728,
radius_auth_port: 1812,
radius_accounting_port: 1813,
timeout: resource.value === 'olt' ? 5 : 10,
service_type: 'ppp',
shared_users: 1,
price: 0,
snmp_port: 161,
snmp_version: 'v2c',
security_level: 'noAuthNoPriv',
}
}
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` : '-',
price_label: Number(item.price || 0).toLocaleString('id-ID'),
status_label: item.status === 'active' ? 'Aktif' : 'Nonaktif',
}
}
async function loadTenants() {
if (!isMasterAdmin.value || tenants.value.length) return
const response = await getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' })
tenants.value = response?.data?.data || []
}
async function fetchData(params = currentQuery.value) {
currentQuery.value = { ...currentQuery.value, ...params }
loading.value = true
try {
const response = await getNasResources(resource.value, currentQuery.value)
const rows = response?.data?.data || []
items.value = rows.map(normalize)
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 ${config.value.title}.`)
} finally {
loading.value = false
loadedOnce.value = true
}
}
async function openAdd(connectionType = null) {
editingId.value = null
Object.keys(form).forEach((key) => delete form[key])
Object.assign(form, defaults())
if (connectionType) form.connection_type = connectionType
await loadTenants()
modal.value = true
}
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] = ''
})
await loadTenants()
modal.value = true
}
async function openDetail(item) {
try {
const response = await getNasResource(resource.value, item.id)
detailItem.value = normalize(response?.data || item)
detailModal.value = true
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat detail.')
}
}
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
config.value.fields.forEach((field) => {
if (!field.show || field.show(form)) data[field.key] = form[field.key] === '' ? null : form[field.key]
})
return data
}
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.')
saving.value = true
try {
if (editingId.value) await updateNasResource(resource.value, editingId.value, payload())
else await createNasResource(resource.value, payload())
modal.value = false
showSuccess(`${config.value.title} berhasil disimpan.`)
await fetchData()
} catch (error) {
showError(error?.response?.data?.message || `Gagal menyimpan ${config.value.title}.`)
} finally {
saving.value = false
}
}
async function remove(item) {
const result = await showConfirm('Konfirmasi Hapus', `Hapus "${item.name}"?`)
if (!result.isConfirmed) return
try {
await deleteNasResource(resource.value, item.id)
showSuccess(`${config.value.title} berhasil dihapus.`)
await fetchData()
} catch (error) {
showError(error?.response?.data?.message || 'Gagal menghapus data.')
}
}
function onActionClick({ action, item }) {
if (action === 'detail') openDetail(item)
if (action === 'edit') openEdit(item)
if (action === 'delete') remove(item)
}
watch(resource, () => {
loadedOnce.value = false
items.value = []
currentQuery.value = { page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' }
fetchData()
})
onMounted(fetchData)
</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">
<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>
</template>
<CButton v-else color="primary" size="sm" @click="openAdd()">+ Tambah {{ config.title }}</CButton>
</div>
</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="config.columns"
:items="items"
server-side
:pagination="pagination"
:loading="loading"
:actions="actions"
row-key="id"
@action-click="onActionClick"
@query-change="fetchData"
/>
</div>
</CCol>
</CRow>
<CModal :visible="modal" @close="modal = false" alignment="center" size="lg">
<CModalHeader dismiss @close="modal = false">
<CModalTitle>{{ editingId ? 'Edit' : 'Tambah' }} {{ config.title }}</CModalTitle>
</CModalHeader>
<CModalBody>
<CRow class="g-3">
<CCol v-if="isMasterAdmin" :md="6">
<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>
</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>
<CCol v-for="field in config.fields.filter((item) => !item.show || item.show(form))" :key="field.key" :md="6">
<CFormLabel>{{ 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>
</CFormSelect>
<CFormInput
v-else
v-model="form[field.key]"
:type="field.type || 'text'"
:placeholder="field.secret && editingId ? 'Kosongkan jika tidak diubah' : ''"
/>
</CCol>
<CCol :md="6">
<CFormLabel>Status</CFormLabel>
<CFormSelect v-model="form.status">
<option value="active">Aktif</option>
<option value="inactive">Nonaktif</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" :disabled="saving">Batal</CButton>
<CButton color="primary" @click="save" :disabled="saving">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton>
</CModalFooter>
</CModal>
<CModal :visible="detailModal" @close="detailModal = false" alignment="center">
<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>
<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 color="secondary" @click="detailModal = false">Tutup</CButton>
</CModalFooter>
</CModal>
</template>
+59 -16
View File
@@ -1,3 +1,41 @@
<script setup>
import { reactive, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const form = reactive({
login: '',
password: '',
})
const isLoading = computed(() => authStore.loading)
const errorMessage = computed(() => authStore.error)
async function onSubmit() {
authStore.clearError()
try {
await authStore.login({
login: form.login,
password: form.password,
})
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
await router.replace(redirect)
} catch (error) {
console.error('Login failed:', error)
}
}
function goToRegister() {
router.push('/pages/register')
}
</script>
<template>
<div class="wrapper min-vh-100 d-flex flex-row align-items-center">
<CContainer>
@@ -6,16 +44,24 @@
<CCardGroup>
<CCard class="p-4">
<CCardBody>
<CForm>
<h1>Login</h1>
<p class="text-body-secondary">Sign In to your account</p>
<CForm @submit.prevent="onSubmit">
<h1>Masuk</h1>
<p class="text-body-secondary">Masuk ke akun anda</p>
<CAlert v-if="errorMessage" color="danger" class="mb-3">
{{ errorMessage }}
</CAlert>
<CInputGroup class="mb-3">
<CInputGroupText>
<CIcon icon="cil-user" />
</CInputGroupText>
<CFormInput
placeholder="Username"
v-model="form.login"
type="text"
placeholder="Username atau Email"
autocomplete="username"
required
/>
</CInputGroup>
<CInputGroup class="mb-4">
@@ -23,19 +69,20 @@
<CIcon icon="cil-lock-locked" />
</CInputGroupText>
<CFormInput
v-model="form.password"
type="password"
placeholder="Password"
autocomplete="current-password"
required
/>
</CInputGroup>
<CRow>
<CCol :xs="6">
<CButton color="primary" class="px-4"> Login </CButton>
<CButton color="primary" class="px-4" type="submit" :disabled="isLoading">
{{ isLoading ? 'Loading...' : 'Masuk' }}
</CButton>
</CCol>
<CCol :xs="6" class="text-right">
<CButton color="link" class="px-0">
Forgot password?
</CButton>
</CCol>
</CRow>
</CForm>
@@ -44,14 +91,10 @@
<CCard class="text-white bg-primary py-5" style="width: 44%">
<CCardBody class="text-center">
<div>
<h2>Sign up</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua.
</p>
<CButton color="light" variant="outline" class="mt-3">
Register Now!
<h2>Belum punya akun?</h2>
<p>Silakan registrasi untuk membuat akun baru.</p>
<CButton color="light" variant="outline" class="mt-3" @click="goToRegister">
Registrasi Sekarang!
</CButton>
</div>
</CCardBody>
+107 -5
View File
@@ -1,3 +1,56 @@
<script setup>
import { reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = reactive({
name: '',
username: '',
email: '',
whatsapp_number: '',
password: '',
password_confirmation: '',
verification_channel: 'email',
})
const isLoading = computed(() => authStore.loading)
const errorMessage = computed(() => authStore.error)
async function onSubmit() {
authStore.clearError()
if (form.password !== form.password_confirmation) {
authStore.error = 'Konfirmasi password tidak sama.'
return
}
try {
await authStore.register({
name: form.name,
username: form.username,
email: form.email,
whatsapp_number: form.whatsapp_number,
password: form.password,
password_confirmation: form.password_confirmation,
verification_channel: form.verification_channel,
verification_target:
form.verification_channel === 'whatsapp' ? form.whatsapp_number : form.email,
})
await router.push('/pages/verify-code')
} catch (error) {
console.error('Register failed:', error)
}
}
function goToLogin() {
router.push('/pages/login')
}
</script>
<template>
<div class="bwrapper min-vh-100 d-flex flex-row align-items-center">
<CContainer>
@@ -5,27 +58,69 @@
<CCol :md="9" :lg="7" :xl="6">
<CCard class="mx-4">
<CCardBody class="p-4">
<CForm>
<CForm @submit.prevent="onSubmit">
<h1>Register</h1>
<p class="text-body-secondary">Create your account</p>
<CAlert v-if="errorMessage" color="danger" class="mb-3">
{{ errorMessage }}
</CAlert>
<CInputGroup class="mb-3">
<CInputGroupText>
<CIcon icon="cil-user" />
</CInputGroupText>
<CFormInput placeholder="Username" autocomplete="username" />
<CFormInput
v-model="form.name"
placeholder="Nama Lengkap"
autocomplete="name"
required
/>
</CInputGroup>
<CInputGroup class="mb-3">
<CInputGroupText>
<CIcon icon="cil-user-follow" />
</CInputGroupText>
<CFormInput
v-model="form.username"
placeholder="Username"
autocomplete="username"
required
/>
</CInputGroup>
<CInputGroup class="mb-3">
<CInputGroupText>@</CInputGroupText>
<CFormInput placeholder="Email" autocomplete="email" />
<CFormInput
v-model="form.email"
type="email"
placeholder="Email"
autocomplete="email"
required
/>
</CInputGroup>
<CInputGroup class="mb-3">
<CInputGroupText>
<CIcon icon="cil-phone" />
</CInputGroupText>
<CFormInput
v-model="form.whatsapp_number"
placeholder="WhatsApp Number"
autocomplete="tel"
required
/>
</CInputGroup>
<CInputGroup class="mb-3">
<CInputGroupText>
<CIcon icon="cil-lock-locked" />
</CInputGroupText>
<CFormInput
v-model="form.password"
type="password"
placeholder="Password"
autocomplete="new-password"
required
/>
</CInputGroup>
<CInputGroup class="mb-4">
@@ -33,13 +128,20 @@
<CIcon icon="cil-lock-locked" />
</CInputGroupText>
<CFormInput
v-model="form.password_confirmation"
type="password"
placeholder="Repeat password"
autocomplete="new-password"
required
/>
</CInputGroup>
<div class="d-grid">
<CButton color="success">Create Account</CButton>
<div class="d-grid gap-2">
<CButton color="success" type="submit" :disabled="isLoading">
{{ isLoading ? 'Memproses...' : 'Create Account' }}
</CButton>
<CButton color="light" variant="outline" type="button" @click="goToLogin">
Kembali ke Login
</CButton>
</div>
</CForm>
</CCardBody>
+121
View File
@@ -0,0 +1,121 @@
<script setup>
import { reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = reactive({
verification_channel: authStore.verificationChannel || 'email',
verification_target: authStore.verificationTarget || authStore.pendingVerificationEmail || '',
verification_code: '',
})
const isLoading = computed(() => authStore.loading)
const errorMessage = computed(() => authStore.error)
async function onSubmit() {
authStore.clearError()
try {
await authStore.verifyCode({
verification_channel: form.verification_channel,
verification_target: form.verification_target,
verification_code: form.verification_code,
})
await router.push('/dashboard')
} catch (error) {
// error handled in store
console.error('Verify code failed:', error)
}
}
async function onResend() {
authStore.clearError()
try {
await authStore.resendVerificationCode({
verification_channel: form.verification_channel,
verification_target: form.verification_target,
})
} catch (error) {
console.error('Resend verification failed:', error)
}
}
function goToLogin() {
router.push('/pages/login')
}
</script>
<template>
<div class="wrapper min-vh-100 d-flex flex-row align-items-center">
<CContainer>
<CRow class="justify-content-center">
<CCol :md="7" :lg="6">
<CCard class="mx-4">
<CCardBody class="p-4">
<CForm @submit.prevent="onSubmit">
<h1>Verifikasi Kode</h1>
<p class="text-body-secondary">Masukkan target verifikasi dan kode OTP</p>
<CAlert v-if="errorMessage" color="danger" class="mb-3">
{{ errorMessage }}
</CAlert>
<CInputGroup class="mb-3">
<CInputGroupText>
<CIcon icon="cil-envelope-open" />
</CInputGroupText>
<CFormSelect v-model="form.verification_channel" required>
<option value="email">Email</option>
<option value="whatsapp">WhatsApp</option>
</CFormSelect>
</CInputGroup>
<CInputGroup class="mb-3">
<CInputGroupText>
<CIcon icon="cil-at" />
</CInputGroupText>
<CFormInput
v-model="form.verification_target"
placeholder="Verification Target (email/whatsapp number)"
required
/>
</CInputGroup>
<CInputGroup class="mb-4">
<CInputGroupText>
<CIcon icon="cil-shield-alt" />
</CInputGroupText>
<CFormInput
v-model="form.verification_code"
placeholder="Kode Verifikasi"
autocomplete="one-time-code"
required
/>
</CInputGroup>
<div class="d-grid gap-2">
<CButton color="primary" type="submit" :disabled="isLoading">
{{ isLoading ? 'Memverifikasi...' : 'Verifikasi' }}
</CButton>
<CButton color="warning" variant="outline" type="button" @click="onResend">
Kirim Ulang Kode
</CButton>
<CButton color="light" variant="outline" type="button" @click="goToLogin">
Kembali ke Login
</CButton>
</div>
</CForm>
</CCardBody>
</CCard>
</CCol>
</CRow>
</CContainer>
</div>
</template>
+505
View File
@@ -0,0 +1,505 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { getMyProfile, updateMyProfile } from '@/services/profileService'
import { deleteFile, getTemporaryFileUrl, uploadFile } from '@/services/fileService'
import {
getDesaOptions,
getKabupatenOptions,
getKecamatanOptions,
getProvinsiOptions,
} from '@/services/wilayahService'
import { showError, showSuccess } from '@/utils/swal'
const authStore = useAuthStore()
const loading = ref(false)
const saving = ref(false)
const profile = ref(null)
const selectedPhoto = ref(null)
const photoUrl = ref('')
const localPhotoUrl = ref('')
const photoInput = ref(null)
const photoDragging = ref(false)
const wilayahOptions = reactive({ provinsi: [], kabupaten: [], kecamatan: [], desa: [] })
const wilayahLoading = reactive({ provinsi: false, kabupaten: false, kecamatan: false, desa: false })
const form = reactive({
name: '',
username: '',
email: '',
whatsapp_number: '',
nik: '',
address: '',
provinsi_id: '',
kabupaten_id: '',
kecamatan_id: '',
desa_id: '',
current_password: '',
password: '',
password_confirmation: '',
})
const accessLevelLabel = computed(() => {
const labels = {
customer: 'Customer',
staff: 'Staff',
tenant_owner: 'Pemilik Tenant',
master_admin: 'Master Admin',
}
return labels[profile.value?.access_level] || profile.value?.access_level || '-'
})
const tenants = computed(() => profile.value?.user_tenants || [])
const menuGroups = computed(() => profile.value?.user_menu_groups || [])
const displayedPhotoUrl = computed(() => localPhotoUrl.value || photoUrl.value)
function fillForm(user) {
const userProfile = user?.user_profile || {}
Object.assign(form, {
name: user?.name || '',
username: user?.username || '',
email: user?.email || '',
whatsapp_number: user?.whatsapp_number || '',
nik: userProfile.nik || '',
address: userProfile.address || '',
provinsi_id: userProfile.provinsi_id ? String(userProfile.provinsi_id) : '',
kabupaten_id: userProfile.kabupaten_id ? String(userProfile.kabupaten_id) : '',
kecamatan_id: userProfile.kecamatan_id ? String(userProfile.kecamatan_id) : '',
desa_id: userProfile.desa_id ? String(userProfile.desa_id) : '',
current_password: '',
password: '',
password_confirmation: '',
})
}
function optionRows(response) {
return Array.isArray(response?.data) ? response.data : []
}
async function loadWilayahOptions(level, parentId = null) {
wilayahLoading[level] = true
try {
const loaders = {
provinsi: () => getProvinsiOptions(),
kabupaten: () => getKabupatenOptions(parentId),
kecamatan: () => getKecamatanOptions(parentId),
desa: () => getDesaOptions(parentId),
}
const response = await loaders[level]()
wilayahOptions[level] = optionRows(response)
} catch (error) {
wilayahOptions[level] = []
showError(error?.response?.data?.message || `Gagal memuat pilihan ${level}.`)
} finally {
wilayahLoading[level] = false
}
}
async function hydrateWilayahOptions() {
await loadWilayahOptions('provinsi')
if (form.provinsi_id) await loadWilayahOptions('kabupaten', form.provinsi_id)
if (form.kabupaten_id) await loadWilayahOptions('kecamatan', form.kabupaten_id)
if (form.kecamatan_id) await loadWilayahOptions('desa', form.kecamatan_id)
}
async function onProvinsiChange(event) {
form.provinsi_id = event.target.value
form.kabupaten_id = ''
form.kecamatan_id = ''
form.desa_id = ''
wilayahOptions.kabupaten = []
wilayahOptions.kecamatan = []
wilayahOptions.desa = []
if (form.provinsi_id) await loadWilayahOptions('kabupaten', form.provinsi_id)
}
async function onKabupatenChange(event) {
form.kabupaten_id = event.target.value
form.kecamatan_id = ''
form.desa_id = ''
wilayahOptions.kecamatan = []
wilayahOptions.desa = []
if (form.kabupaten_id) await loadWilayahOptions('kecamatan', form.kabupaten_id)
}
async function onKecamatanChange(event) {
form.kecamatan_id = event.target.value
form.desa_id = ''
wilayahOptions.desa = []
if (form.kecamatan_id) await loadWilayahOptions('desa', form.kecamatan_id)
}
async function fetchProfile() {
loading.value = true
try {
const response = await getMyProfile()
profile.value = response?.data || null
fillForm(profile.value)
await hydrateWilayahOptions()
await loadProfilePhoto(profile.value)
authStore.setUser(profile.value)
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat profile.')
} finally {
loading.value = false
}
}
async function loadProfilePhoto(user) {
photoUrl.value = ''
if (!user?.profile_photo?.uuid) return
try {
const response = await getTemporaryFileUrl(user.profile_photo.uuid)
photoUrl.value = response?.data?.url || ''
} catch {
photoUrl.value = ''
}
}
function setSelectedPhoto(file, input = null) {
if (file && !file.type.startsWith('image/')) {
if (input) input.value = ''
showError('Foto profil harus berupa file gambar.')
return
}
if (file && file.size > 5 * 1024 * 1024) {
if (input) input.value = ''
showError('Ukuran foto profil maksimal 5 MB.')
return
}
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
selectedPhoto.value = file
localPhotoUrl.value = file ? URL.createObjectURL(file) : ''
}
function selectPhoto(event) {
setSelectedPhoto(event.target.files?.[0] || null, event.target)
}
function dropPhoto(event) {
photoDragging.value = false
setSelectedPhoto(event.dataTransfer?.files?.[0] || null)
}
function clearSelectedPhoto() {
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
selectedPhoto.value = null
localPhotoUrl.value = ''
if (photoInput.value) photoInput.value.value = ''
}
function nullableNumber(value) {
return value === '' || value === null ? null : Number(value)
}
function buildPayload() {
const payload = {
name: form.name.trim(),
username: form.username.trim() || null,
email: form.email.trim(),
whatsapp_number: form.whatsapp_number.trim() || null,
user_profile: {
nik: form.nik.trim() || null,
address: form.address.trim() || null,
provinsi_id: nullableNumber(form.provinsi_id),
kabupaten_id: nullableNumber(form.kabupaten_id),
kecamatan_id: nullableNumber(form.kecamatan_id),
desa_id: nullableNumber(form.desa_id),
},
}
if (form.password) {
payload.current_password = form.current_password
payload.password = form.password
payload.password_confirmation = form.password_confirmation
}
return payload
}
async function saveProfile() {
saving.value = true
try {
const payload = buildPayload()
let uploadedPhoto = null
if (selectedPhoto.value) {
const uploadResponse = await uploadFile(selectedPhoto.value, 'profile')
uploadedPhoto = uploadResponse?.data || null
payload.profile_photo_file_id = uploadedPhoto?.id
}
let response
try {
response = await updateMyProfile(payload)
} catch (error) {
if (uploadedPhoto?.uuid) {
await deleteFile(uploadedPhoto.uuid).catch(() => {})
}
throw error
}
profile.value = response?.data || null
fillForm(profile.value)
selectedPhoto.value = null
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
localPhotoUrl.value = ''
await loadProfilePhoto(profile.value)
authStore.setUser(profile.value)
showSuccess('Profile berhasil diperbarui.')
} catch (error) {
const errors = error?.response?.data?.errors
const firstError = errors ? Object.values(errors).flat()[0] : null
showError(firstError || error?.response?.data?.message || 'Gagal memperbarui profile.')
} finally {
saving.value = false
}
}
onMounted(fetchProfile)
onBeforeUnmount(() => {
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
})
</script>
<template>
<CRow class="brdvx-page-row g-0 m-0">
<CCol :xs="12" class="brdvx-page-column p-0">
<div class="brdvx-page-container">
<div class="brdvx-page-header">
<h5 class="mb-1 fw-semibold">Profile Saya</h5>
</div>
<div v-if="loading" class="text-center py-5">
<CSpinner color="primary" />
</div>
<CRow v-else-if="profile" class="g-3">
<CCol :lg="4">
<CCard class="h-100">
<CCardBody class="text-center">
<CAvatar
:src="displayedPhotoUrl || undefined"
color="primary"
text-color="white"
class="image-previewable profile-photo-avatar"
:data-preview-alt="`Foto ${profile.name}`"
>
<template v-if="!displayedPhotoUrl">
{{ profile.name?.charAt(0)?.toUpperCase() || 'U' }}
</template>
</CAvatar>
<div
class="profile-photo-uploader"
:class="{ 'is-dragging': photoDragging }"
@dragenter.prevent="photoDragging = true"
@dragover.prevent="photoDragging = true"
@dragleave.prevent="photoDragging = false"
@drop.prevent="dropPhoto"
>
<div class="profile-photo-uploader-icon">
<CIcon icon="cil-cloud-download" size="xl" />
</div>
<div class="fw-semibold">Ganti foto profil</div>
<div class="small text-body-secondary">
Tarik gambar ke sini atau pilih dari perangkat
</div>
<div class="d-flex flex-wrap justify-content-center gap-2 mt-3">
<CButton color="primary" variant="outline" size="sm" @click="photoInput?.click()">
<CIcon icon="cil-camera" class="me-1" />
Pilih Gambar
</CButton>
<CButton
v-if="selectedPhoto"
color="secondary"
variant="ghost"
size="sm"
@click="clearSelectedPhoto"
>
Batal
</CButton>
</div>
<div v-if="selectedPhoto" class="profile-photo-file mt-3">
<CIcon icon="cil-check-circle" class="text-success" />
<span>{{ selectedPhoto.name }}</span>
</div>
</div>
<input
ref="photoInput"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="d-none"
aria-label="Pilih foto profile"
@change="selectPhoto"
/>
<div class="small text-body-secondary mt-2 mb-3">
JPG, PNG, WebP, atau GIF. Maksimal 5 MB.
</div>
<h5 class="mb-1">{{ profile.name }}</h5>
<div class="text-body-secondary mb-2">{{ profile.email }}</div>
<CBadge color="primary">{{ accessLevelLabel }}</CBadge>
<hr />
<div class="text-start">
<div class="small text-body-secondary">Status</div>
<div class="mb-3">{{ profile.status }}</div>
<div class="small text-body-secondary">Tenant</div>
<div v-if="tenants.length" class="d-flex flex-wrap gap-1 mt-1">
<CBadge
v-for="membership in tenants"
:key="membership.id"
:color="membership.is_default ? 'primary' : 'secondary'"
>
{{ membership.tenant?.tenant_name || `Tenant #${membership.tenant_id}` }}
</CBadge>
</div>
<div v-else>-</div>
<div class="small text-body-secondary mt-3">Menu Group</div>
<div v-if="menuGroups.length" class="d-flex flex-wrap gap-1 mt-1">
<CBadge v-for="assignment in menuGroups" :key="assignment.id" color="info">
{{ assignment.menu_group?.name || `Group #${assignment.menu_group_id}` }}
</CBadge>
</div>
<div v-else>-</div>
</div>
</CCardBody>
</CCard>
</CCol>
<CCol :lg="8">
<CForm @submit.prevent="saveProfile">
<CCard class="mb-3">
<CCardHeader class="fw-semibold">Informasi Akun</CCardHeader>
<CCardBody>
<CRow class="g-3">
<CCol :md="6">
<CFormLabel>Nama</CFormLabel>
<CFormInput v-model="form.name" required />
</CCol>
<CCol :md="6">
<CFormLabel>Username</CFormLabel>
<CFormInput v-model="form.username" autocomplete="username" />
</CCol>
<CCol :md="6">
<CFormLabel>Email</CFormLabel>
<CFormInput v-model="form.email" type="email" required />
</CCol>
<CCol :md="6">
<CFormLabel>Nomor WhatsApp</CFormLabel>
<CFormInput v-model="form.whatsapp_number" />
</CCol>
</CRow>
</CCardBody>
</CCard>
<CCard class="mb-3">
<CCardHeader class="fw-semibold">Data Profile</CCardHeader>
<CCardBody>
<CRow class="g-3">
<CCol :md="6">
<CFormLabel>NIK</CFormLabel>
<CFormInput v-model="form.nik" />
</CCol>
<CCol :xs="12">
<CFormLabel>Alamat</CFormLabel>
<CFormTextarea v-model="form.address" rows="3" />
</CCol>
<CCol :md="6">
<CFormLabel>Provinsi</CFormLabel>
<CFormSelect
v-model="form.provinsi_id"
:disabled="wilayahLoading.provinsi"
@change="onProvinsiChange"
>
<option value="">{{ wilayahLoading.provinsi ? 'Memuat provinsi...' : 'Pilih provinsi' }}</option>
<option v-for="row in wilayahOptions.provinsi" :key="row.id" :value="String(row.id)">
{{ row.kode }} {{ row.nama }}
</option>
</CFormSelect>
</CCol>
<CCol :md="6">
<CFormLabel>Kabupaten</CFormLabel>
<CFormSelect
v-model="form.kabupaten_id"
:disabled="!form.provinsi_id || wilayahLoading.kabupaten"
@change="onKabupatenChange"
>
<option value="">{{ wilayahLoading.kabupaten ? 'Memuat kabupaten...' : 'Pilih kabupaten' }}</option>
<option v-for="row in wilayahOptions.kabupaten" :key="row.id" :value="String(row.id)">
{{ row.kode }} {{ row.nama }}
</option>
</CFormSelect>
</CCol>
<CCol :md="6">
<CFormLabel>Kecamatan</CFormLabel>
<CFormSelect
v-model="form.kecamatan_id"
:disabled="!form.kabupaten_id || wilayahLoading.kecamatan"
@change="onKecamatanChange"
>
<option value="">{{ wilayahLoading.kecamatan ? 'Memuat kecamatan...' : 'Pilih kecamatan' }}</option>
<option v-for="row in wilayahOptions.kecamatan" :key="row.id" :value="String(row.id)">
{{ row.kode }} {{ row.nama }}
</option>
</CFormSelect>
</CCol>
<CCol :md="6">
<CFormLabel>Desa</CFormLabel>
<CFormSelect
v-model="form.desa_id"
:disabled="!form.kecamatan_id || wilayahLoading.desa"
>
<option value="">{{ wilayahLoading.desa ? 'Memuat desa...' : 'Pilih desa' }}</option>
<option v-for="row in wilayahOptions.desa" :key="row.id" :value="String(row.id)">
{{ row.kode }} {{ row.nama }}
</option>
</CFormSelect>
</CCol>
</CRow>
</CCardBody>
</CCard>
<CCard class="mb-3">
<CCardHeader class="fw-semibold">Ubah Password</CCardHeader>
<CCardBody>
<p class="small text-body-secondary">Kosongkan jika tidak ingin mengubah password.</p>
<CRow class="g-3">
<CCol :md="4">
<CFormLabel>Password Saat Ini</CFormLabel>
<CFormInput v-model="form.current_password" type="password" autocomplete="current-password" />
</CCol>
<CCol :md="4">
<CFormLabel>Password Baru</CFormLabel>
<CFormInput v-model="form.password" type="password" autocomplete="new-password" />
</CCol>
<CCol :md="4">
<CFormLabel>Konfirmasi Password</CFormLabel>
<CFormInput v-model="form.password_confirmation" type="password" autocomplete="new-password" />
</CCol>
</CRow>
</CCardBody>
</CCard>
<div class="d-flex justify-content-end">
<CButton color="primary" type="submit" :disabled="saving">
{{ saving ? 'Menyimpan...' : 'Simpan Perubahan' }}
</CButton>
</div>
</CForm>
</CCol>
</CRow>
</div>
</CCol>
</CRow>
</template>
+369
View File
@@ -0,0 +1,369 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import {
getTenants,
createTenant,
getTenantById,
updateTenant,
deleteTenant,
} from '@/services/tenantService'
import { formatTanggal } from '@/utils/tglindo.js'
import { showConfirm, showSuccess, showError } from '@/utils/swal.js'
const loading = ref(true)
const loadedOnce = ref(false)
const saving = ref(false)
const tenants = ref([])
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 modal = ref(false)
const detailModal = ref(false)
const isEdit = ref(false)
const editingId = ref(null)
const form = reactive({
tenant_code: '',
tenant_name: '',
phone: '',
email: '',
address: '',
status: 'active',
})
const detailItem = ref(null)
const columns = [
{ key: 'tenant_code', label: 'Code' },
{ key: 'tenant_name', label: 'Name' },
{ key: 'phone', label: 'Phone', sortable: false },
{ key: 'email', label: 'Email' },
{ 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' },
]
function emptyForm() {
return {
tenant_code: '',
tenant_name: '',
phone: '',
email: '',
address: '',
status: 'active',
}
}
function normalizeTenant(item = {}) {
const status = item.status ?? 'inactive'
return {
id: item.id ?? '-',
tenant_code: item.tenant_code ?? '-',
tenant_name: item.tenant_name ?? '-',
phone: item.phone ?? '-',
email: item.email ?? '-',
address: item.address ?? '-',
status,
status_label: status === 'active' ? 'Aktif' : 'Nonaktif',
created_at: item.created_at ?? null,
updated_at: item.updated_at ?? null,
}
}
async function fetchAllData(params = currentQuery.value) {
currentQuery.value = { ...currentQuery.value, ...params }
params = currentQuery.value
loading.value = true
try {
const res = await getTenants(params)
const payload = Array.isArray(res?.data?.data)
? res.data.data
: Array.isArray(res?.data)
? res.data
: Array.isArray(res)
? res
: []
tenants.value = payload.map(normalizeTenant)
const meta = res?.data && !Array.isArray(res.data) ? res.data : {}
pagination.value = {
current_page: meta.current_page || params.page || 1,
last_page: meta.last_page || 1,
per_page: meta.per_page || params.per_page || 10,
total: meta.total || payload.length,
}
} catch (err) {
showError(err?.response?.data?.message || 'Gagal memuat data tenant: ' + err.message)
} finally {
loading.value = false
loadedOnce.value = true
}
}
onMounted(() => {
fetchAllData()
})
function openAddModal() {
isEdit.value = false
editingId.value = null
Object.assign(form, emptyForm())
modal.value = true
}
function openEditModal(item) {
isEdit.value = true
editingId.value = item.id
Object.assign(form, {
tenant_code: item.tenant_code || '',
tenant_name: item.tenant_name || '',
phone: item.phone || '',
email: item.email || '',
address: item.address || '',
status: item.status || 'active',
})
modal.value = true
}
async function openDetail(item) {
try {
const res = await getTenantById(item.id)
const payload = Array.isArray(res?.data) ? res.data[0] : res?.data
detailItem.value = normalizeTenant(payload || item)
detailModal.value = true
} catch (err) {
showError(err?.response?.data?.message || 'Gagal memuat detail tenant: ' + err.message)
}
}
function validateForm() {
if (!form.tenant_code.trim()) {
showError('Tenant code wajib diisi.')
return false
}
if (!form.tenant_name.trim()) {
showError('Tenant name wajib diisi.')
return false
}
if (!['active', 'inactive'].includes(form.status)) {
showError('Status harus active atau inactive.')
return false
}
return true
}
function formPayload() {
return {
tenant_code: form.tenant_code.trim(),
tenant_name: form.tenant_name.trim(),
phone: form.phone?.trim() || null,
email: form.email?.trim() || null,
address: form.address?.trim() || null,
status: form.status,
}
}
async function createItem() {
saving.value = true
try {
await createTenant(formPayload())
modal.value = false
showSuccess('Tenant berhasil dibuat.')
await fetchAllData()
} catch (err) {
showError(err?.response?.data?.message || 'Gagal membuat tenant: ' + err.message)
} finally {
saving.value = false
}
}
async function updateItem() {
if (!editingId.value) return
saving.value = true
try {
await updateTenant(editingId.value, formPayload())
modal.value = false
showSuccess('Tenant berhasil diupdate.')
await fetchAllData()
} catch (err) {
showError(err?.response?.data?.message || 'Gagal mengupdate tenant: ' + err.message)
} finally {
saving.value = false
}
}
async function openDeleteModal(item) {
const result = await showConfirm(
'Konfirmasi Hapus',
`Apakah Anda yakin ingin menghapus tenant "${item.tenant_name}"?`,
)
if (!result.isConfirmed) return
try {
await deleteTenant(item.id)
showSuccess('Tenant berhasil dihapus.')
await fetchAllData()
} catch (err) {
showError(err?.response?.data?.message || 'Gagal menghapus tenant: ' + err.message)
}
}
async function saveItem() {
if (!validateForm()) return
if (isEdit.value) {
await updateItem()
} else {
await createItem()
}
}
function onActionClick({ action, item }) {
if (action === 'detail') openDetail(item)
if (action === 'edit') openEditModal(item)
if (action === 'delete') openDeleteModal(item)
}
</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">Tenants</h5>
</div>
<div class="d-flex gap-2">
<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>
</div>
</div>
<div v-if="loading && !loadedOnce" class="brdvx-loading-state" role="status" aria-live="polite">
<CSpinner color="primary" />
<p class="brdvx-loading-text">Memuat data...</p>
</div>
<BaseResponsiveDataView
v-else
:columns="columns"
:items="tenants"
server-side
:pagination="pagination"
:loading="loading"
initial-sort-by="id"
initial-sort-direction="desc"
:actions="tableActions"
row-key="id"
@action-click="onActionClick"
@query-change="fetchAllData"
/>
</div>
</CCol>
</CRow>
<CModal :visible="modal" @close="modal = false" alignment="center">
<CModalHeader dismiss @close="modal = false">
<CModalTitle>{{ isEdit ? 'Edit Tenant' : 'Tambah Tenant' }}</CModalTitle>
</CModalHeader>
<CModalBody>
<CForm>
<div class="mb-3">
<CFormLabel>Tenant Code</CFormLabel>
<CFormInput v-model="form.tenant_code" placeholder="Contoh: TNT-001" />
</div>
<div class="mb-3">
<CFormLabel>Tenant Name</CFormLabel>
<CFormInput v-model="form.tenant_name" placeholder="Nama tenant" />
</div>
<div class="mb-3">
<CFormLabel>Phone</CFormLabel>
<CFormInput v-model="form.phone" placeholder="Nomor telepon (opsional)" />
</div>
<div class="mb-3">
<CFormLabel>Email</CFormLabel>
<CFormInput v-model="form.email" type="email" placeholder="Email (opsional)" />
</div>
<div class="mb-3">
<CFormLabel>Address</CFormLabel>
<CFormTextarea v-model="form.address" rows="3" placeholder="Alamat (opsional)" />
</div>
<div class="mb-3">
<CFormLabel>Status</CFormLabel>
<CFormSelect v-model="form.status">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</CFormSelect>
</div>
</CForm>
</CModalBody>
<CModalFooter>
<CButton color="secondary" size="sm" @click="modal = false" :disabled="saving">Batal</CButton>
<CButton color="primary" size="sm" @click="saveItem" :disabled="saving">
<span v-if="saving">Menyimpan...</span>
<span v-else>Simpan</span>
</CButton>
</CModalFooter>
</CModal>
<CModal :visible="detailModal" @close="detailModal = false" alignment="center">
<CModalHeader dismiss @close="detailModal = false">
<CModalTitle>Detail Tenant</CModalTitle>
</CModalHeader>
<CModalBody>
<CTable bordered small v-if="detailItem">
<CTableBody>
<CTableRow>
<CTableHeaderCell style="width: 40%">ID</CTableHeaderCell>
<CTableDataCell>{{ detailItem.id }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Tenant Code</CTableHeaderCell>
<CTableDataCell>{{ detailItem.tenant_code }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Tenant Name</CTableHeaderCell>
<CTableDataCell>{{ detailItem.tenant_name }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Phone</CTableHeaderCell>
<CTableDataCell>{{ detailItem.phone }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Email</CTableHeaderCell>
<CTableDataCell>{{ detailItem.email }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Address</CTableHeaderCell>
<CTableDataCell>{{ detailItem.address }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Status</CTableHeaderCell>
<CTableDataCell>
<CBadge :color="detailItem.status === 'active' ? 'success' : 'secondary'">
{{ detailItem.status === 'active' ? 'Aktif' : 'Nonaktif' }}
</CBadge>
</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Created At</CTableHeaderCell>
<CTableDataCell>{{ formatTanggal(detailItem.created_at) }}</CTableDataCell>
</CTableRow>
<CTableRow>
<CTableHeaderCell>Updated At</CTableHeaderCell>
<CTableDataCell>{{ formatTanggal(detailItem.updated_at) }}</CTableDataCell>
</CTableRow>
</CTableBody>
</CTable>
</CModalBody>
<CModalFooter>
<CButton color="secondary" size="sm" @click="detailModal = false">Tutup</CButton>
</CModalFooter>
</CModal>
</template>
+564
View File
@@ -0,0 +1,564 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { useAuthStore } from '@/stores/auth'
import { createUser, deleteUser, getUserById, getUsers, updateUser } from '@/services/userService'
import { getTemporaryFileUrl } from '@/services/fileService'
import { getMenuGroups } from '@/services/menuGroupService'
import { getTenants } from '@/services/tenantService'
import {
getAlamatWilayah,
getDesaOptions,
getKabupatenOptions,
getKecamatanOptions,
getProvinsiOptions,
} from '@/services/wilayahService'
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
const DEFAULT_USER_AVATAR = '/user-default.jpg'
const authStore = useAuthStore()
const loading = ref(true)
const loadedOnce = ref(false)
const saving = ref(false)
const formLoading = ref(false)
const detailLoading = ref(false)
const users = ref([])
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 modal = ref(false)
const detailModal = ref(false)
const editingId = ref(null)
const detailItem = ref(null)
const detailAddress = ref('')
const tenantOptions = ref([])
const menuGroupOptions = ref([])
const wilayahOptions = reactive({ provinsi: [], kabupaten: [], kecamatan: [], desa: [] })
const wilayahLoading = reactive({ kabupaten: false, kecamatan: false, desa: false })
const form = reactive(emptyForm())
const isEdit = computed(() => editingId.value !== null)
const isMasterActor = computed(
() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin',
)
const accessLevels = [
{ value: 'customer', label: 'Customer' },
{ value: 'staff', label: 'Staff' },
{ value: 'tenant_owner', label: 'Pemilik Tenant' },
{ value: 'master_admin', label: 'Master Admin' },
]
const columns = [
{ key: 'image', label: 'Avatar', sortable: false },
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone', sortKey: 'whatsapp_number' },
{ key: 'role', label: 'Role', sortKey: 'access_level' },
{ key: 'status', label: 'Status' },
]
const tableActions = [
{ key: 'detail', label: 'Detail', color: 'primary' },
{ key: 'edit', label: 'Edit', color: 'success' },
{ key: 'delete', label: 'Delete', color: 'danger' },
]
function emptyForm() {
return {
name: '',
username: '',
email: '',
whatsapp_number: '',
password: '',
access_level: 'staff',
status: 'active',
nik: '',
address: '',
provinsi_id: '',
kabupaten_id: '',
kecamatan_id: '',
desa_id: '',
tenantRows: [],
groupRows: [],
}
}
function normalizeResponseList(response) {
if (Array.isArray(response?.data?.data)) return response.data.data
if (Array.isArray(response?.data)) return response.data
return Array.isArray(response) ? response : []
}
function normalizeResponseItem(response) {
return response?.data?.data || response?.data || response
}
function normalizeUser(item = {}, index = 0) {
const levelLabels = {
customer: 'Customer',
staff: 'Staff',
tenant_owner: 'Pemilik Tenant',
master_admin: 'Master Admin',
}
return {
id: item.id ?? index + 1,
image: DEFAULT_USER_AVATAR,
imageFallback: DEFAULT_USER_AVATAR,
name: item.name || '-',
email: item.email || '-',
phone: item.whatsapp_number || '-',
role: levelLabels[item.access_level] || (item.is_master ? 'Master Admin' : 'Staff'),
status: item.status || '-',
raw: item,
}
}
async function resolveUserAvatar(user) {
const normalized = normalizeUser(user)
if (!user?.profile_photo?.uuid) return normalized
try {
const response = await getTemporaryFileUrl(user.profile_photo.uuid)
normalized.image = response?.data?.url || DEFAULT_USER_AVATAR
} catch {
normalized.image = DEFAULT_USER_AVATAR
}
return normalized
}
async function fetchUsers(params = currentQuery.value) {
currentQuery.value = { ...currentQuery.value, ...params }
params = currentQuery.value
loading.value = true
try {
const response = await getUsers(params)
const rows = normalizeResponseList(response)
users.value = await Promise.all(rows.map(resolveUserAvatar))
const meta = response?.data && !Array.isArray(response.data) ? response.data : {}
pagination.value = {
current_page: meta.current_page || params.page || 1,
last_page: meta.last_page || 1,
per_page: meta.per_page || params.per_page || 10,
total: meta.total || rows.length,
}
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat data user.')
} finally {
loading.value = false
loadedOnce.value = true
}
}
async function loadFormReferences() {
formLoading.value = true
try {
const tenantPromise = isMasterActor.value
? getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' })
: Promise.resolve({
data: (authStore.user?.user_tenants || []).map((row) => row.tenant).filter(Boolean),
})
const [tenantResponse, groupResponse, provinsiResponse] = await Promise.all([
tenantPromise,
getMenuGroups({ per_page: 100, sort_by: 'name', sort_direction: 'asc' }),
getProvinsiOptions(),
])
tenantOptions.value = normalizeResponseList(tenantResponse)
menuGroupOptions.value = normalizeResponseList(groupResponse)
wilayahOptions.provinsi = normalizeResponseList(provinsiResponse)
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat referensi form user.')
} finally {
formLoading.value = false
}
}
async function loadWilayah(level, parentId) {
const childOrder = { kabupaten: ['kecamatan', 'desa'], kecamatan: ['desa'] }
wilayahOptions[level] = []
for (const child of childOrder[level] || []) wilayahOptions[child] = []
if (!parentId) return
wilayahLoading[level] = true
try {
const loaders = {
kabupaten: getKabupatenOptions,
kecamatan: getKecamatanOptions,
desa: getDesaOptions,
}
const response = await loaders[level](parentId)
wilayahOptions[level] = normalizeResponseList(response)
} catch (error) {
wilayahOptions[level] = []
showError(error?.response?.data?.message || `Gagal memuat pilihan ${level}.`)
} finally {
wilayahLoading[level] = false
}
}
async function onProvinsiChange(event) {
form.provinsi_id = event.target.value
form.kabupaten_id = ''
form.kecamatan_id = ''
form.desa_id = ''
await loadWilayah('kabupaten', form.provinsi_id)
}
async function onKabupatenChange(event) {
form.kabupaten_id = event.target.value
form.kecamatan_id = ''
form.desa_id = ''
await loadWilayah('kecamatan', form.kabupaten_id)
}
async function onKecamatanChange(event) {
form.kecamatan_id = event.target.value
form.desa_id = ''
await loadWilayah('desa', form.kecamatan_id)
}
function addTenantRow() {
form.tenantRows.push({ tenant_id: '', is_default: form.tenantRows.length === 0 })
}
function removeTenantRow(index) {
const wasDefault = form.tenantRows[index]?.is_default
form.tenantRows.splice(index, 1)
if (wasDefault && form.tenantRows.length) form.tenantRows[0].is_default = true
}
function setDefaultTenant(index) {
form.tenantRows.forEach((row, rowIndex) => {
row.is_default = rowIndex === index
})
}
function addGroupRow() {
form.groupRows.push({ menu_group_id: '', tenant_id: defaultTenantId() })
}
function defaultTenantId() {
return form.tenantRows.find((row) => row.is_default)?.tenant_id || form.tenantRows[0]?.tenant_id || ''
}
function tenantName(id) {
return tenantOptions.value.find((tenant) => Number(tenant.id) === Number(id))?.tenant_name || `Tenant #${id}`
}
function groupName(id) {
return menuGroupOptions.value.find((group) => Number(group.id) === Number(id))?.name || `Group #${id}`
}
async function openAddModal() {
editingId.value = null
Object.assign(form, emptyForm())
await loadFormReferences()
if (!isMasterActor.value && tenantOptions.value.length) {
form.tenantRows = [{ tenant_id: String(tenantOptions.value[0].id), is_default: true }]
} else {
addTenantRow()
}
modal.value = true
}
async function hydrateForm(user) {
const profile = user?.user_profile || {}
Object.assign(form, emptyForm(), {
name: user?.name || '',
username: user?.username || '',
email: user?.email || '',
whatsapp_number: user?.whatsapp_number || '',
access_level: user?.access_level || 'staff',
status: user?.status || 'active',
nik: profile.nik || '',
address: profile.address || '',
provinsi_id: profile.provinsi_id ? String(profile.provinsi_id) : '',
kabupaten_id: profile.kabupaten_id ? String(profile.kabupaten_id) : '',
kecamatan_id: profile.kecamatan_id ? String(profile.kecamatan_id) : '',
desa_id: profile.desa_id ? String(profile.desa_id) : '',
tenantRows: (user?.user_tenants || []).map((row) => ({
tenant_id: String(row.tenant_id),
is_default: Boolean(row.is_default),
})),
groupRows: (user?.user_menu_groups || []).map((row) => ({
menu_group_id: String(row.menu_group_id),
tenant_id: row.tenant_id ? String(row.tenant_id) : '',
})),
})
if (form.provinsi_id) await loadWilayah('kabupaten', form.provinsi_id)
if (form.kabupaten_id) await loadWilayah('kecamatan', form.kabupaten_id)
if (form.kecamatan_id) await loadWilayah('desa', form.kecamatan_id)
}
async function openEditModal(item) {
editingId.value = item.id
formLoading.value = true
modal.value = true
try {
const [response] = await Promise.all([getUserById(item.id), loadFormReferences()])
await hydrateForm(normalizeResponseItem(response))
} catch (error) {
modal.value = false
showError(error?.response?.data?.message || 'Gagal memuat data user.')
} finally {
formLoading.value = false
}
}
function nullableId(value) {
return value ? Number(value) : null
}
function buildPayload() {
const tenantRows = form.tenantRows
.filter((row) => row.tenant_id)
.map((row) => ({ tenant_id: Number(row.tenant_id), is_default: Boolean(row.is_default) }))
const groupRows = form.groupRows
.filter((row) => row.menu_group_id)
.map((row) => ({
menu_group_id: Number(row.menu_group_id),
tenant_id: nullableId(row.tenant_id),
}))
const payload = {
name: form.name.trim(),
username: form.username.trim() || null,
email: form.email.trim(),
whatsapp_number: form.whatsapp_number.trim() || null,
access_level: form.access_level,
is_master: form.access_level === 'master_admin',
status: form.status,
user_profile: {
nik: form.nik.trim() || null,
address: form.address.trim() || null,
provinsi_id: nullableId(form.provinsi_id),
kabupaten_id: nullableId(form.kabupaten_id),
kecamatan_id: nullableId(form.kecamatan_id),
desa_id: nullableId(form.desa_id),
},
user_tenants: tenantRows,
user_menu_groups: groupRows,
}
if (!isEdit.value || form.password) payload.password = form.password
return payload
}
function validateForm() {
if (!form.name.trim() || !form.email.trim()) return 'Nama dan email wajib diisi.'
if (!isEdit.value && form.password.length < 8) return 'Password minimal 8 karakter.'
if (form.password && form.password.length < 8) return 'Password minimal 8 karakter.'
const tenantIds = form.tenantRows.filter((row) => row.tenant_id).map((row) => row.tenant_id)
if (new Set(tenantIds).size !== tenantIds.length) return 'Tenant tidak boleh dipilih lebih dari satu kali.'
if (form.tenantRows.length && !form.tenantRows.some((row) => row.is_default)) return 'Pilih satu tenant default.'
if (form.groupRows.some((row) => !row.menu_group_id || (!row.tenant_id && form.access_level !== 'master_admin'))) {
return 'Group menu dan tenant assignment wajib dipilih.'
}
return null
}
async function submitForm() {
const validationMessage = validateForm()
if (validationMessage) return showError(validationMessage)
saving.value = true
try {
const payload = buildPayload()
if (isEdit.value) {
await updateUser(editingId.value, payload)
showSuccess('User berhasil diperbarui.')
} else {
await createUser(payload)
showSuccess('User berhasil ditambahkan.')
}
modal.value = false
await fetchUsers()
} catch (error) {
const errors = error?.response?.data?.errors
showError(
(errors && Object.values(errors).flat()[0]) ||
error?.response?.data?.message ||
'Gagal menyimpan user.',
)
} finally {
saving.value = false
}
}
async function openDetailModal(item) {
detailLoading.value = true
detailModal.value = true
try {
detailItem.value = normalizeResponseItem(await getUserById(item.id))
const profile = detailItem.value?.user_profile || {}
const addressParams = profile.desa_id
? { desa_id: profile.desa_id }
: profile.kecamatan_id
? { kecamatan_id: profile.kecamatan_id }
: profile.kabupaten_id
? { kabupaten_id: profile.kabupaten_id }
: profile.provinsi_id
? { provinsi_id: profile.provinsi_id }
: null
detailAddress.value = ''
if (addressParams) {
const addressResponse = await getAlamatWilayah(addressParams).catch(() => null)
detailAddress.value = addressResponse?.data?.alamat_wilayah || ''
}
} catch (error) {
detailModal.value = false
showError(error?.response?.data?.message || 'Gagal mengambil detail user.')
} finally {
detailLoading.value = false
}
}
async function removeUser(item) {
const confirmation = await showConfirm('Hapus user ini?', 'Data yang dihapus tidak dapat dikembalikan.')
if (!confirmation.isConfirmed) return
try {
await deleteUser(item.id)
showSuccess('User berhasil dihapus.')
await fetchUsers()
} catch (error) {
showError(error?.response?.data?.message || 'Gagal menghapus user.')
}
}
function handleActionClick({ action, item }) {
if (action === 'detail') openDetailModal(item)
if (action === 'edit') openEditModal(item)
if (action === 'delete') removeUser(item)
}
onMounted(fetchUsers)
</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">
<h5 class="mb-0 fw-semibold">Users</h5>
<CButton color="primary" size="sm" @click="openAddModal">Tambah User</CButton>
</div>
<div v-if="loading && !loadedOnce" class="brdvx-loading-state" role="status">
<CSpinner color="primary" />
<p class="brdvx-loading-text">Memuat data...</p>
</div>
<BaseResponsiveDataView
v-else
:columns="columns"
:items="users"
server-side
:pagination="pagination"
:loading="loading"
initial-sort-by="id"
initial-sort-direction="desc"
row-key="id"
:actions="tableActions"
@action-click="handleActionClick"
@query-change="fetchUsers"
/>
</div>
</CCol>
</CRow>
<CModal :visible="modal" size="xl" alignment="center" scrollable @close="modal = false">
<CModalHeader><CModalTitle>{{ isEdit ? 'Edit User' : 'Tambah User' }}</CModalTitle></CModalHeader>
<CModalBody>
<div v-if="formLoading" class="brdvx-loading-state"><CSpinner color="primary" /><span>Memuat form...</span></div>
<CForm v-else>
<CCard class="mb-3">
<CCardHeader class="fw-semibold">Informasi Akun</CCardHeader>
<CCardBody>
<CRow class="g-3">
<CCol :md="6"><CFormLabel>Nama</CFormLabel><CFormInput v-model="form.name" /></CCol>
<CCol :md="6"><CFormLabel>Username</CFormLabel><CFormInput v-model="form.username" /></CCol>
<CCol :md="6"><CFormLabel>Email</CFormLabel><CFormInput v-model="form.email" type="email" /></CCol>
<CCol :md="6"><CFormLabel>WhatsApp</CFormLabel><CFormInput v-model="form.whatsapp_number" /></CCol>
<CCol :md="4">
<CFormLabel>Level Akses</CFormLabel>
<CFormSelect v-model="form.access_level">
<option v-for="level in accessLevels" :key="level.value" :value="level.value" :disabled="!isMasterActor && ['tenant_owner', 'master_admin'].includes(level.value)">
{{ level.label }}
</option>
</CFormSelect>
</CCol>
<CCol :md="4">
<CFormLabel>Status</CFormLabel>
<CFormSelect v-model="form.status">
<option value="active">Aktif</option><option value="inactive">Nonaktif</option><option value="suspended">Ditangguhkan</option>
</CFormSelect>
</CCol>
<CCol :md="4"><CFormLabel>Password {{ isEdit ? '(opsional)' : '' }}</CFormLabel><CFormInput v-model="form.password" type="password" /></CCol>
</CRow>
</CCardBody>
</CCard>
<CCard class="mb-3">
<CCardHeader class="fw-semibold">Profil dan Wilayah</CCardHeader>
<CCardBody>
<CRow class="g-3">
<CCol :md="4"><CFormLabel>NIK</CFormLabel><CFormInput v-model="form.nik" /></CCol>
<CCol :md="8"><CFormLabel>Alamat</CFormLabel><CFormInput v-model="form.address" /></CCol>
<CCol :md="3"><CFormLabel>Provinsi</CFormLabel><CFormSelect v-model="form.provinsi_id" @change="onProvinsiChange"><option value="">Pilih provinsi</option><option v-for="row in wilayahOptions.provinsi" :key="row.id" :value="String(row.id)">{{ row.nama }}</option></CFormSelect></CCol>
<CCol :md="3"><CFormLabel>Kabupaten</CFormLabel><CFormSelect v-model="form.kabupaten_id" :disabled="!form.provinsi_id || wilayahLoading.kabupaten" @change="onKabupatenChange"><option value="">{{ wilayahLoading.kabupaten ? 'Memuat kabupaten...' : 'Pilih kabupaten' }}</option><option v-for="row in wilayahOptions.kabupaten" :key="row.id" :value="String(row.id)">{{ row.nama }}</option></CFormSelect></CCol>
<CCol :md="3"><CFormLabel>Kecamatan</CFormLabel><CFormSelect v-model="form.kecamatan_id" :disabled="!form.kabupaten_id || wilayahLoading.kecamatan" @change="onKecamatanChange"><option value="">{{ wilayahLoading.kecamatan ? 'Memuat kecamatan...' : 'Pilih kecamatan' }}</option><option v-for="row in wilayahOptions.kecamatan" :key="row.id" :value="String(row.id)">{{ row.nama }}</option></CFormSelect></CCol>
<CCol :md="3"><CFormLabel>Desa</CFormLabel><CFormSelect v-model="form.desa_id" :disabled="!form.kecamatan_id || wilayahLoading.desa"><option value="">{{ wilayahLoading.desa ? 'Memuat desa...' : 'Pilih desa' }}</option><option v-for="row in wilayahOptions.desa" :key="row.id" :value="String(row.id)">{{ row.nama }}</option></CFormSelect></CCol>
</CRow>
</CCardBody>
</CCard>
<CRow class="g-3">
<CCol :lg="6">
<CCard class="h-100">
<CCardHeader class="d-flex justify-content-between align-items-center"><span class="fw-semibold">Tenant User</span><CButton size="sm" color="primary" variant="outline" @click="addTenantRow">+ Tenant</CButton></CCardHeader>
<CCardBody>
<div v-if="!form.tenantRows.length" class="text-body-secondary">Belum ada tenant.</div>
<div v-for="(row, index) in form.tenantRows" :key="index" class="d-flex gap-2 align-items-center mb-2">
<CFormSelect v-model="row.tenant_id"><option value="">Pilih tenant</option><option v-for="tenant in tenantOptions" :key="tenant.id" :value="String(tenant.id)">{{ tenant.tenant_code }} {{ tenant.tenant_name }}</option></CFormSelect>
<CFormCheck type="radio" name="default-tenant" label="Default" :checked="row.is_default" @change="setDefaultTenant(index)" />
<CButton color="danger" variant="ghost" @click="removeTenantRow(index)">Hapus</CButton>
</div>
</CCardBody>
</CCard>
</CCol>
<CCol :lg="6">
<CCard class="h-100">
<CCardHeader class="d-flex justify-content-between align-items-center"><span class="fw-semibold">Group Menu</span><CButton size="sm" color="primary" variant="outline" @click="addGroupRow">+ Group</CButton></CCardHeader>
<CCardBody>
<div v-if="!form.groupRows.length" class="text-body-secondary">Belum ada group menu.</div>
<div v-for="(row, index) in form.groupRows" :key="index" class="d-flex gap-2 mb-2">
<CFormSelect v-model="row.menu_group_id"><option value="">Pilih group</option><option v-for="group in menuGroupOptions" :key="group.id" :value="String(group.id)">{{ group.name }}</option></CFormSelect>
<CFormSelect v-model="row.tenant_id"><option value="">Global</option><option v-for="tenantRow in form.tenantRows.filter((item) => item.tenant_id)" :key="tenantRow.tenant_id" :value="tenantRow.tenant_id">{{ tenantName(tenantRow.tenant_id) }}</option></CFormSelect>
<CButton color="danger" variant="ghost" @click="form.groupRows.splice(index, 1)">Hapus</CButton>
</div>
</CCardBody>
</CCard>
</CCol>
</CRow>
</CForm>
</CModalBody>
<CModalFooter>
<CButton color="secondary" variant="ghost" :disabled="saving" @click="modal = false">Batal</CButton>
<CButton color="primary" :disabled="saving || formLoading" @click="submitForm">{{ saving ? 'Menyimpan...' : 'Simpan User' }}</CButton>
</CModalFooter>
</CModal>
<CModal :visible="detailModal" size="lg" alignment="center" scrollable @close="detailModal = false">
<CModalHeader><CModalTitle>Detail User</CModalTitle></CModalHeader>
<CModalBody>
<div v-if="detailLoading" class="brdvx-loading-state"><CSpinner color="primary" /><span>Memuat detail...</span></div>
<template v-else-if="detailItem">
<CRow class="g-3 mb-3">
<CCol :md="6"><div class="small text-body-secondary">Nama</div><div class="fw-semibold">{{ detailItem.name }}</div></CCol>
<CCol :md="6"><div class="small text-body-secondary">Username</div><div>{{ detailItem.username || '-' }}</div></CCol>
<CCol :md="6"><div class="small text-body-secondary">Email</div><div>{{ detailItem.email }}</div></CCol>
<CCol :md="6"><div class="small text-body-secondary">WhatsApp</div><div>{{ detailItem.whatsapp_number || '-' }}</div></CCol>
<CCol :md="6"><div class="small text-body-secondary">Level Akses</div><div>{{ detailItem.access_level }}</div></CCol>
<CCol :md="6"><div class="small text-body-secondary">Status</div><div>{{ detailItem.status }}</div></CCol>
<CCol :md="6"><div class="small text-body-secondary">NIK</div><div>{{ detailItem.user_profile?.nik || '-' }}</div></CCol>
<CCol :md="6"><div class="small text-body-secondary">Alamat</div><div>{{ detailItem.user_profile?.address || '-' }}</div></CCol>
<CCol :xs="12"><div class="small text-body-secondary">Wilayah</div><div>{{ detailAddress || '-' }}</div></CCol>
</CRow>
<h6>Tenant</h6>
<div class="d-flex flex-wrap gap-2 mb-3"><CBadge v-for="row in detailItem.user_tenants || []" :key="row.id" :color="row.is_default ? 'primary' : 'secondary'">{{ row.tenant?.tenant_name || `Tenant #${row.tenant_id}` }}{{ row.is_default ? ' (Default)' : '' }}</CBadge><span v-if="!detailItem.user_tenants?.length">-</span></div>
<h6>Group Menu</h6>
<div class="d-flex flex-column gap-2"><div v-for="row in detailItem.user_menu_groups || []" :key="row.id" class="border rounded p-2"><strong>{{ row.menu_group?.name || groupName(row.menu_group_id) }}</strong><span class="text-body-secondary"> — {{ row.tenant?.tenant_name || (row.tenant_id ? `Tenant #${row.tenant_id}` : 'Global') }}</span></div><span v-if="!detailItem.user_menu_groups?.length">-</span></div>
</template>
</CModalBody>
<CModalFooter><CButton color="secondary" @click="detailModal = false">Tutup</CButton></CModalFooter>
</CModal>
</template>
+317
View File
@@ -0,0 +1,317 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import {
createWilayah,
deleteWilayah,
getWilayah,
getWilayahById,
updateWilayah,
} from '@/services/wilayahService'
import { showConfirm, showError, showSuccess } from '@/utils/swal'
const route = useRoute()
const loading = ref(true)
const loadedOnce = ref(false)
const saving = ref(false)
const items = ref([])
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const currentQuery = ref({ page: 1, per_page: 10, sort_by: 'nama', sort_direction: 'asc' })
const parentOptions = ref([])
const modal = ref(false)
const detailModal = ref(false)
const editingId = ref(null)
const detail = ref(null)
const form = reactive({ kode: '', nama: '', parent_id: '' })
const configs = {
desa: {
title: 'Desa',
parentLevel: 'kecamatan',
parentKey: 'kecamatan_id',
parentLabel: 'Kecamatan',
},
kecamatan: {
title: 'Kecamatan',
parentLevel: 'kabupaten',
parentKey: 'kabupaten_id',
parentLabel: 'Kabupaten',
},
kabupaten: {
title: 'Kabupaten',
parentLevel: 'provinsi',
parentKey: 'provinsi_id',
parentLabel: 'Provinsi',
},
provinsi: {
title: 'Provinsi',
parentLevel: null,
parentKey: null,
parentLabel: null,
},
}
const tingkat = computed(() => route.meta.tingkat)
const config = computed(() => configs[tingkat.value])
const isEdit = computed(() => editingId.value !== null)
const columns = computed(() => [
{ key: 'kode', label: 'Kode' },
{ key: 'nama', label: 'Nama' },
...(config.value.parentKey
? [{ key: 'parent_name', label: config.value.parentLabel, sortable: false }]
: []),
{ key: 'hierarchy', label: 'Hierarki Wilayah', sortable: false },
])
function responseItems(response) {
const payload = response?.data
if (Array.isArray(payload)) return payload
return Array.isArray(payload?.data) ? payload.data : []
}
function normalize(item) {
const parent = config.value.parentKey ? item[config.value.parentLevel] : null
const hierarchy = [item.nama, item.kecamatan?.nama, item.kabupaten?.nama, item.provinsi?.nama]
.filter((value, index, values) => value && values.indexOf(value) === index)
.join(', ')
return {
...item,
parent_name: parent?.nama || '-',
hierarchy: hierarchy || item.nama,
}
}
async function fetchItems(params = currentQuery.value) {
currentQuery.value = { ...currentQuery.value, ...params }
params = currentQuery.value
loading.value = true
try {
const response = await getWilayah(tingkat.value, params)
const rows = responseItems(response)
items.value = rows.map(normalize)
const meta = response?.data && !Array.isArray(response.data) ? response.data : {}
pagination.value = {
current_page: meta.current_page || params.page || 1,
last_page: meta.last_page || 1,
per_page: meta.per_page || params.per_page || 10,
total: meta.total || rows.length,
}
} catch (error) {
showError(error?.response?.data?.message || `Gagal memuat data ${config.value.title}.`)
} finally {
loading.value = false
loadedOnce.value = true
}
}
async function fetchParents() {
parentOptions.value = []
if (!config.value.parentLevel) return
try {
const response = await getWilayah(config.value.parentLevel, { per_page: 100 })
parentOptions.value = responseItems(response)
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat pilihan wilayah induk.')
}
}
function resetForm() {
Object.assign(form, { kode: '', nama: '', parent_id: '' })
}
async function openCreate() {
editingId.value = null
resetForm()
await fetchParents()
modal.value = true
}
async function openEdit(item) {
editingId.value = item.id
await fetchParents()
Object.assign(form, {
kode: item.kode || '',
nama: item.nama || '',
parent_id: config.value.parentKey ? String(item[config.value.parentKey] || '') : '',
})
modal.value = true
}
async function openDetail(item) {
try {
const response = await getWilayahById(tingkat.value, item.id)
detail.value = normalize(response?.data || item)
detailModal.value = true
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat detail wilayah.')
}
}
function payload() {
const data = {
kode: form.kode.trim(),
nama: form.nama.trim(),
}
if (config.value.parentKey) data[config.value.parentKey] = Number(form.parent_id)
return data
}
async function save() {
if (!form.kode.trim() || !form.nama.trim()) {
showError('Kode dan nama wilayah wajib diisi.')
return
}
if (config.value.parentKey && !form.parent_id) {
showError(`${config.value.parentLabel} wajib dipilih.`)
return
}
saving.value = true
try {
if (isEdit.value) {
await updateWilayah(tingkat.value, editingId.value, payload())
} else {
await createWilayah(tingkat.value, payload())
}
modal.value = false
showSuccess(`${config.value.title} berhasil ${isEdit.value ? 'diperbarui' : 'dibuat'}.`)
await fetchItems()
} catch (error) {
const errors = error?.response?.data?.errors
showError(
(errors && Object.values(errors).flat()[0]) ||
error?.response?.data?.message ||
'Gagal menyimpan wilayah.',
)
} finally {
saving.value = false
}
}
async function remove(item) {
const confirmation = await showConfirm(
`Hapus ${config.value.title}`,
`Hapus "${item.nama}"? Data yang masih memiliki wilayah di bawahnya tidak dapat dihapus.`,
)
if (!confirmation.isConfirmed) return
try {
await deleteWilayah(tingkat.value, item.id)
showSuccess(`${config.value.title} berhasil dihapus.`)
await fetchItems()
} catch (error) {
const errors = error?.response?.data?.errors
showError(
(errors && Object.values(errors).flat()[0]) ||
error?.response?.data?.message ||
'Gagal menghapus wilayah.',
)
}
}
function onAction({ action, item }) {
if (action === 'detail') openDetail(item)
if (action === 'edit') openEdit(item)
if (action === 'delete') remove(item)
}
watch(tingkat, () => {
modal.value = false
detailModal.value = false
fetchItems({ ...currentQuery.value, page: 1 })
})
onMounted(fetchItems)
</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-1 fw-semibold">Wilayah {{ config.title }}</h5>
<div class="small text-body-secondary">Kelola referensi wilayah secara berjenjang.</div>
</div>
<div class="d-flex gap-2">
<CButton color="primary" size="sm" variant="outline" :disabled="loading" @click="fetchItems()">
{{ loading ? 'Memuat...' : 'Refresh' }}
</CButton>
<CButton color="primary" size="sm" @click="openCreate">+ Tambah {{ config.title }}</CButton>
</div>
</div>
<div v-if="loading && !loadedOnce" class="brdvx-loading-state" role="status" aria-live="polite">
<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"
initial-sort-by="nama"
initial-sort-direction="asc"
row-key="id"
@action-click="onAction"
@query-change="fetchItems"
/>
</div>
</CCol>
</CRow>
<CModal :visible="modal" alignment="center" @close="modal = false">
<CModalHeader>
<CModalTitle>{{ isEdit ? 'Edit' : 'Tambah' }} {{ config.title }}</CModalTitle>
</CModalHeader>
<CModalBody>
<CRow class="g-3">
<CCol :md="5">
<CFormLabel>Kode</CFormLabel>
<CFormInput v-model="form.kode" maxlength="20" placeholder="Kode wilayah" />
</CCol>
<CCol :md="7">
<CFormLabel>Nama {{ config.title }}</CFormLabel>
<CFormInput v-model="form.nama" :placeholder="`Nama ${config.title.toLowerCase()}`" />
</CCol>
<CCol v-if="config.parentKey" :xs="12">
<CFormLabel>{{ config.parentLabel }}</CFormLabel>
<CFormSelect v-model="form.parent_id">
<option value="">Pilih {{ config.parentLabel }}</option>
<option v-for="parent in parentOptions" :key="parent.id" :value="String(parent.id)">
{{ parent.kode }} {{ parent.nama }}
</option>
</CFormSelect>
<div class="form-text">{{ config.parentLabel }} wajib dipilih sesuai tingkatan wilayah.</div>
</CCol>
</CRow>
</CModalBody>
<CModalFooter>
<CButton color="secondary" variant="ghost" :disabled="saving" @click="modal = false">Batal</CButton>
<CButton color="primary" :disabled="saving" @click="save">
{{ saving ? 'Menyimpan...' : 'Simpan' }}
</CButton>
</CModalFooter>
</CModal>
<CModal :visible="detailModal" alignment="center" @close="detailModal = false">
<CModalHeader><CModalTitle>Detail {{ config.title }}</CModalTitle></CModalHeader>
<CModalBody v-if="detail">
<div class="small text-body-secondary">Kode</div>
<div class="fw-semibold mb-3">{{ detail.kode }}</div>
<div class="small text-body-secondary">Nama</div>
<div class="fw-semibold mb-3">{{ detail.nama }}</div>
<div class="small text-body-secondary">Hierarki Wilayah</div>
<div>{{ detail.hierarchy }}</div>
</CModalBody>
<CModalFooter>
<CButton color="secondary" @click="detailModal = false">Tutup</CButton>
</CModalFooter>
</CModal>
</template>