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
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:
@@ -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
@@ -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 |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<CFooter class="px-4">
|
||||
<div>
|
||||
<a href="https://manjapro.net/" target="_blank">ManjaPro</a>
|
||||
<span class="ms-1">© {{ new Date().getFullYear() }} bestapps</span>
|
||||
<span class="ms-1">© {{ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function getMyMenus() {
|
||||
const { data } = await api.get('/me/menus')
|
||||
return data
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')">
|
||||
← 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user