Giant Update regsiter
NPM Installation / build (16.x, ubuntu-latest) (push) Waiting to run
NPM Installation / build (16.x, windows-latest) (push) Waiting to run
NPM Installation / build (17.x, ubuntu-latest) (push) Waiting to run
NPM Installation / build (17.x, windows-latest) (push) Waiting to run
NPM Installation / build (18.x, ubuntu-latest) (push) Waiting to run
NPM Installation / build (18.x, windows-latest) (push) Waiting to run

This commit is contained in:
Wian Drs
2026-08-01 11:41:31 +07:00
parent 03fe4b1fd4
commit 484f89e2ee
28 changed files with 2439 additions and 320 deletions
+264 -4
View File
@@ -23,6 +23,7 @@
"chart.js": "^4.5.1",
"maplibre-gl": "^3.6.2",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"simplebar-vue": "^2.4.2",
"sweetalert2": "^11.26.25",
"vue": "^3.5.31",
@@ -1520,11 +1521,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -1807,6 +1816,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
@@ -1872,11 +1890,21 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -1889,7 +1917,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -1983,6 +2010,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2009,6 +2045,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2036,6 +2078,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -2494,6 +2542,15 @@
"integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==",
"license": "ISC"
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2795,6 +2852,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -3548,6 +3614,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3565,7 +3640,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3656,6 +3730,15 @@
"pathe": "^2.0.3"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -3746,6 +3829,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -3781,6 +3881,21 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3886,6 +4001,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-value": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
@@ -4063,6 +4184,32 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4515,6 +4662,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4525,6 +4678,20 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
@@ -4535,6 +4702,12 @@
"node": ">=12"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
@@ -4550,6 +4723,93 @@
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+1
View File
@@ -32,6 +32,7 @@
"chart.js": "^4.5.1",
"maplibre-gl": "^3.6.2",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"simplebar-vue": "^2.4.2",
"sweetalert2": "^11.26.25",
"vue": "^3.5.31",
+4
View File
@@ -43,6 +43,7 @@ import {
cilCode,
cilCommentSquare,
cilContrast,
cilCopy,
cilCreditCard,
cilCursor,
cilDataTransferDown,
@@ -55,6 +56,7 @@ import {
cilExternalLink,
cilGlobeAlt,
cilGrid,
cilInfo,
cilFile,
cilJustifyCenter,
cilLaptop,
@@ -114,6 +116,7 @@ export const iconsSet = Object.assign(
cilCode,
cilCommentSquare,
cilContrast,
cilCopy,
cilCreditCard,
cilCursor,
cilDataTransferDown,
@@ -126,6 +129,7 @@ export const iconsSet = Object.assign(
cilExternalLink,
cilGlobeAlt,
cilGrid,
cilInfo,
cilFile,
cilJustifyCenter,
cilLaptop,
+68 -19
View File
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { cilList, cilViewModule } from '@coreui/icons'
import { useColorModes } from '@coreui/vue'
import { useRouter } from 'vue-router'
import AppBreadcrumb from '@/components/AppBreadcrumb.vue'
import AppHeaderDropdownAccnt from '@/components/AppHeaderDropdownAccnt.vue'
@@ -16,6 +17,7 @@ import { getMyNotifications, markNotificationRead } from '@/services/notificatio
const headerClassNames = ref('mb-2 p-0')
const { colorMode, setColorMode } = useColorModes('coreui-free-vue-admin-template-theme')
const sidebar = useSidebarStore()
const router = useRouter()
const viewMode = ref(getResponsiveViewMode())
const appNotifications = ref([])
const unreadCount = computed(() => appNotifications.value.filter((item) => !item.read_at).length)
@@ -51,7 +53,10 @@ const openNotification = async (item) => {
await markNotificationRead(item.uuid)
item.read_at = new Date().toISOString()
}
if (item.action_url) window.location.assign(item.action_url)
if (item.action_url) {
if (item.action_url.startsWith('/')) await router.push(item.action_url)
else window.location.assign(item.action_url)
}
}
onMounted(() => {
@@ -74,27 +79,40 @@ onBeforeUnmount(() => {
</CHeaderToggler>
<CHeaderNav class="d-none d-md-flex">
<CNavItem>
<CNavLink href="/dashboard"> Dashboard </CNavLink>
</CNavItem>
<CNavItem>
<CNavLink href="#">Users</CNavLink>
</CNavItem>
<CNavItem>
<CNavLink href="#">Settings</CNavLink>
<CNavLink href="#/dashboard"> Dashboard </CNavLink>
</CNavItem>
</CHeaderNav>
<CHeaderNav class="ms-auto">
<CDropdown variant="nav-item" placement="bottom-end">
<CDropdownToggle :caret="false" class="position-relative">
<CIcon icon="cil-bell" size="lg" />
<CBadge v-if="unreadCount" color="danger" shape="rounded-pill" class="header-notification-badge">{{ unreadCount > 9 ? '9+' : unreadCount }}</CBadge>
<CBadge
v-if="unreadCount"
color="danger"
shape="rounded-pill"
class="header-notification-badge"
>{{ unreadCount > 9 ? '9+' : unreadCount }}</CBadge
>
</CDropdownToggle>
<CDropdownMenu class="header-notification-menu">
<div class="px-3 py-2 border-bottom"><strong>Notifikasi</strong><small class="d-block text-body-secondary">{{ unreadCount }} belum dibaca</small></div>
<button v-for="item in appNotifications" :key="item.uuid" type="button" class="header-notification-item" :class="{ unread: !item.read_at }" @click="openNotification(item)">
<strong>{{ item.title }}</strong><span>{{ item.body }}</span>
<div class="px-3 py-2 border-bottom">
<strong>Notifikasi</strong
><small class="d-block text-body-secondary">{{ unreadCount }} belum dibaca</small>
</div>
<button
v-for="item in appNotifications"
:key="item.uuid"
type="button"
class="header-notification-item"
:class="{ unread: !item.read_at }"
@click="openNotification(item)"
>
<strong>{{ item.title }}</strong
><span>{{ item.body }}</span>
</button>
<div v-if="!appNotifications.length" class="px-3 py-3 text-body-secondary text-center">Belum ada notifikasi</div>
<div v-if="!appNotifications.length" class="px-3 py-3 text-body-secondary text-center">
Belum ada notifikasi
</div>
</CDropdownMenu>
</CDropdown>
<CNavItem>
@@ -167,10 +185,41 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.header-notification-badge { position: absolute; top: .15rem; right: .05rem; font-size: .6rem; }
.header-notification-menu { width: min(22rem, calc(100vw - 1.5rem)); max-height: 28rem; overflow-y: auto; }
.header-notification-item { display: flex; width: 100%; flex-direction: column; gap: .2rem; padding: .7rem 1rem; text-align: left; background: transparent; border: 0; border-bottom: 1px solid var(--cui-border-color); }
.header-notification-item:hover { background: var(--cui-tertiary-bg); }
.header-notification-item.unread { border-left: 3px solid var(--cui-primary); background: rgba(var(--cui-primary-rgb), .08); }
.header-notification-item span { display: -webkit-box; overflow: hidden; color: var(--cui-secondary-color); font-size: .78rem; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.header-notification-badge {
position: absolute;
top: 0.15rem;
right: 0.05rem;
font-size: 0.6rem;
}
.header-notification-menu {
width: min(22rem, calc(100vw - 1.5rem));
max-height: 28rem;
overflow-y: auto;
}
.header-notification-item {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.2rem;
padding: 0.7rem 1rem;
text-align: left;
background: transparent;
border: 0;
border-bottom: 1px solid var(--cui-border-color);
}
.header-notification-item:hover {
background: var(--cui-tertiary-bg);
}
.header-notification-item.unread {
border-left: 3px solid var(--cui-primary);
background: rgba(var(--cui-primary-rgb), 0.08);
}
.header-notification-item span {
display: -webkit-box;
overflow: hidden;
color: var(--cui-secondary-color);
font-size: 0.78rem;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
</style>
@@ -43,6 +43,10 @@ function openProfile() {
router.push('/profile')
}
function openMyApplications() {
router.push('/my-applications')
}
function onLogout() {
authStore.logout()
router.push('/pages/login')
@@ -97,6 +101,9 @@ function onLogout() {
Settings
</CDropdownHeader>
<CDropdownItem @click="openProfile"> <CIcon icon="cil-user" /> Profile </CDropdownItem>
<CDropdownItem @click="openMyApplications">
<CIcon icon="cil-user-follow" /> Pengajuan Saya
</CDropdownItem>
<CDropdownItem> <CIcon icon="cil-settings" /> Settings </CDropdownItem>
<CDropdownItem>
<CIcon icon="cil-dollar" /> Payments
+17 -1
View File
@@ -1,4 +1,5 @@
<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
// import { logo } from '@/assets/brand/logo'
@@ -6,9 +7,24 @@ import logo from '@/assets/images/Logo_big.png'
import { sygnet } from '@/assets/brand/sygnet'
import { AppSidebarNav } from '@/components/AppSidebarNav.js'
import WalletHeaderWidget from '@/components/WalletHeaderWidget.vue'
import { useMenuStore } from '@/stores/menu.js'
import { useSidebarStore } from '@/stores/sidebar.js'
const sidebar = useSidebarStore()
const menuStore = useMenuStore()
function containsWalletMenu(items = []) {
return items.some(
(item) =>
item?.slug === 'wallet' ||
item?.url === '/deposit-balance' ||
containsWalletMenu(Array.isArray(item?.children) ? item.children : []),
)
}
const canViewWallet = computed(
() => menuStore.initialized && containsWalletMenu(menuStore.dynamicMenus),
)
</script>
<template>
@@ -47,7 +63,7 @@ const sidebar = useSidebarStore()
</RouterLink>
<CCloseButton class="d-lg-none" dark @click="sidebar.toggleVisible()" />
</CSidebarHeader>
<div class="sidebar-wallet-slot">
<div v-if="canViewWallet" class="sidebar-wallet-slot">
<WalletHeaderWidget />
</div>
<AppSidebarNav />
+3 -3
View File
@@ -20,10 +20,10 @@ onBeforeUnmount(() => window.clearInterval(timer))
</script>
<template>
<button type="button" class="wallet-header" title="Buka dompet" @click="router.push('/wallet')">
<button type="button" class="wallet-header" title="Buka saldo" @click="router.push('/deposit-balance')">
<CIcon icon="cil-wallet" size="lg" />
<span v-if="active"><small>{{ active.wallet_type === 'tenant' ? 'Dompet Tenant' : 'Dompet Saya' }}</small><strong>{{ money(active.available_balance) }}</strong></span>
<span v-else><small>Dompet</small><strong>Rp 0</strong></span>
<span v-if="active"><small>{{ active.label || 'Saldo Deposit' }}</small><strong>{{ money(active.available_balance) }}</strong></span>
<span v-else><small>Saldo Deposit</small><strong>Rp 0</strong></span>
<i v-if="wallets.length > 1">{{ activeIndex + 1 }}/{{ wallets.length }}</i>
</button>
</template>
@@ -82,7 +82,7 @@
<script setup>
import { ref, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
import Swal from 'sweetalert2'
import { showSuccessr, showError } from '@/utils/swal'
import { showError } from '@/utils/swal'
const props = defineProps({ visible: Boolean, form: Object, saving: Boolean })
const emit = defineEmits(['close', 'save'])
+77 -4
View File
@@ -37,7 +37,13 @@ import { hasToken } from '@/utils/session'
const routes = [
{
path: '/',
name: 'Home',
name: 'Website',
component: () => import('@/views/website/LandingPage.vue'),
meta: { guestLanding: true },
},
{
path: '/app',
name: 'Application',
component: DefaultLayout,
redirect: '/dashboard',
meta: {
@@ -58,6 +64,18 @@ const routes = [
name: 'Profile',
component: () => import('@/views/profile/Profile.vue'),
},
{
path: '/forbidden',
name: 'Forbidden',
component: () => import('@/views/pages/Page403.vue'),
meta: { publicWithinApp: true },
},
{
path: '/my-applications',
name: 'Pengajuan Saya',
component: () => import('@/views/access-applications/MyApplications.vue'),
meta: { publicWithinApp: true },
},
{
path: '/tickets',
name: 'Tickets',
@@ -539,9 +557,44 @@ const routes = [
component: () => import('@/views/payment-gateway/PaymentLogs.vue'),
},
{
path: '/wallet',
name: 'Dompet',
path: '/deposit-balance',
alias: '/wallet',
name: 'Saldo Deposit',
component: () => import('@/views/wallet/WalletDashboard.vue'),
meta: { activeMenu: '/deposit-balance' },
},
{
path: '/voucher-hotspot/sales',
name: 'Jual Voucher Hotspot',
component: () => import('@/views/voucher-hotspot/VoucherSales.vue'),
},
{
path: '/voucher-hotspot/agents',
name: 'Agen Voucher',
component: () => import('@/views/voucher-hotspot/VoucherAgents.vue'),
},
{
path: '/voucher-hotspot/login-page',
name: 'Voucher LoginPage',
component: () => import('@/views/voucher-hotspot/VoucherLoginPage.vue'),
},
{
path: '/access-applications/tenants',
name: 'Pengajuan Tenant',
component: () => import('@/views/access-applications/ApplicationReviews.vue'),
meta: { applicationType: 'tenant' },
},
{
path: '/access-applications/agents',
name: 'Pengajuan Agen',
component: () => import('@/views/access-applications/ApplicationReviews.vue'),
meta: { applicationType: 'agent' },
},
{
path: '/access-applications/staff',
name: 'Pengajuan Staff',
component: () => import('@/views/access-applications/ApplicationReviews.vue'),
meta: { applicationType: 'staff' },
},
{
path: '/users',
@@ -661,7 +714,27 @@ router.beforeEach(async (to) => {
if (requiresAuth) {
try {
await useMenuStore().fetchMenus()
const menuStore = useMenuStore()
await menuStore.fetchMenus()
const publicWithinApp = to.matched.some((record) => record.meta.publicWithinApp)
const alwaysAllowed = ['/dashboard', '/profile', '/forbidden', '/my-applications']
if (!publicWithinApp && !alwaysAllowed.includes(to.path)) {
let accessUrl = to.meta.activeMenu || to.path
if (to.meta.activeMenuBase && to.params.scope) {
accessUrl = `${to.meta.activeMenuBase}/${to.params.scope}`
}
if (!menuStore.canAccessUrl(accessUrl)) {
return {
name: 'Forbidden',
query: { from: to.fullPath },
replace: true,
}
}
}
} catch {
useAuthStore().logout()
+29
View File
@@ -25,6 +25,35 @@ export async function updateNotificationChannel(channel, id, payload) {
export async function testNotificationChannel(channel, id) {
return (await api.post(`${channelBase(channel)}/settings/${id}/test`)).data
}
export async function deleteNotificationChannel(channel, id) {
return (await api.delete(`${channelBase(channel)}/settings/${id}`)).data
}
export async function getSystemNotificationCampaigns(params = {}) {
return (await api.get('/notifications/system/campaigns', { params })).data
}
export async function createSystemNotificationCampaign(payload) {
return (await api.post('/notifications/system/campaigns', payload)).data
}
export async function getSystemAudienceOptions(params = {}) {
return (await api.get('/notifications/system/audience-options', { params })).data
}
export async function getWhapiState(id) {
return (await api.get(`/notifications/channels/whatsapp-unofficial/settings/${id}/state`)).data
}
export async function startWhapi(id) {
return (await api.post(`/notifications/channels/whatsapp-unofficial/settings/${id}/start`)).data
}
export async function getWhapiQr(id) {
return (await api.get(`/notifications/channels/whatsapp-unofficial/settings/${id}/qr`)).data
}
export async function sendWhapiTestMessage(id, payload) {
return (
await api.post(
`/notifications/channels/whatsapp-unofficial/settings/${id}/test-message`,
payload,
)
).data
}
export async function getNotificationTemplates(channel) {
return (await api.get(`${channelBase(channel)}/templates`)).data
}
+15
View File
@@ -0,0 +1,15 @@
import api from '@/api/axios'
export const getMyRoleApplications = async () => (await api.get('/me/role-applications')).data
export const createRoleApplication = async (payload) =>
(await api.post('/me/role-applications', payload)).data
export const getRoleApplications = async (type, params = {}) =>
(await api.get(`/role-applications/${type}`, { params })).data
export const getTenantMenuGroupOptions = async () =>
(await api.get('/role-applications/tenant/menu-group-options')).data
export const getStaffMenuGroupOptions = async () =>
(await api.get('/role-applications/staff/menu-group-options')).data
export const reviewRoleApplication = async (id, payload) =>
(await api.put(`/role-applications/${id}/review`, payload)).data
export const linkUserCustomer = async (payload) =>
(await api.post('/customer-user-links', payload)).data
+12
View File
@@ -0,0 +1,12 @@
import api from '@/api/axios'
export const getVoucherOptions = async (scope = 'sales') => (await api.get(`/voucher-hotspot/${scope === 'agents' ? 'agent-options' : scope === 'login-page' ? 'login-page-options' : 'sales-options'}`)).data
export const getVoucherSales = async (params = {}) => (await api.get('/voucher-hotspot/sales', { params })).data
export const getVoucherSale = async (id) => (await api.get(`/voucher-hotspot/sales/${id}`)).data
export const createVoucherSale = async (payload) => (await api.post('/voucher-hotspot/sales', payload)).data
export const getVoucherAgents = async (params = {}) => (await api.get('/voucher-hotspot/agents', { params })).data
export const createVoucherAgent = async (payload) => (await api.post('/voucher-hotspot/agents', payload)).data
export const updateVoucherAgent = async (id, payload) => (await api.put(`/voucher-hotspot/agents/${id}`, payload)).data
export const deleteVoucherAgent = async (id) => (await api.delete(`/voucher-hotspot/agents/${id}`)).data
export const getVoucherLoginPage = async () => (await api.get('/voucher-hotspot/login-page')).data
export const saveVoucherLoginPage = async (payload) => (await api.put('/voucher-hotspot/login-page', payload)).data
-1
View File
@@ -2,5 +2,4 @@ import api from '@/api/axios'
export const getMyWallets = async () => (await api.get('/wallets')).data
export const getWalletLedger = async (id, params = {}) => (await api.get(`/wallets/${id}/ledger`, { params })).data
export const transferWallet = async (id, payload) => (await api.post(`/wallets/${id}/transfer`, payload)).data
export const withdrawWallet = async (id, payload) => (await api.post(`/wallets/${id}/withdraw`, payload)).data
+21
View File
@@ -46,6 +46,8 @@ export const useAuthStore = defineStore('auth', {
pendingVerificationEmail: savedVerification.verification_target || '',
verificationChannel: savedVerification.verification_channel || 'email',
verificationTarget: savedVerification.verification_target || '',
verificationCode: savedVerification.verification_code || '',
verificationCodeExpiresAt: savedVerification.expires_at || null,
}
},
@@ -110,10 +112,15 @@ export const useAuthStore = defineStore('auth', {
this.verificationChannel = payload?.verification_channel || 'email'
this.verificationTarget = payload?.verification_target || payload?.email || ''
this.pendingVerificationEmail = this.verificationTarget
const verification = data?.data?.verification || data?.verification || {}
this.verificationCode = verification?.code || ''
this.verificationCodeExpiresAt = verification?.expires_at || null
setVerificationContext({
verification_channel: this.verificationChannel,
verification_target: this.verificationTarget,
verification_code: this.verificationCode,
expires_at: this.verificationCodeExpiresAt,
})
return data
@@ -141,6 +148,8 @@ export const useAuthStore = defineStore('auth', {
this.pendingVerificationEmail = ''
this.verificationChannel = 'email'
this.verificationTarget = ''
this.verificationCode = ''
this.verificationCodeExpiresAt = null
clearVerificationContext()
return data
@@ -158,6 +167,16 @@ export const useAuthStore = defineStore('auth', {
try {
const data = await resendVerificationCodeApi(payload)
const verification = data?.data?.verification || data?.verification || {}
this.verificationCode = verification?.code || ''
this.verificationCodeExpiresAt = verification?.expires_at || null
setVerificationContext({
verification_channel: payload?.verification_channel || this.verificationChannel,
verification_target: payload?.verification_target || this.verificationTarget,
verification_code: this.verificationCode,
expires_at: this.verificationCodeExpiresAt,
})
return data
} catch (error) {
this.error = extractErrorMessage(error)
@@ -176,6 +195,8 @@ export const useAuthStore = defineStore('auth', {
this.pendingVerificationEmail = ''
this.verificationChannel = 'email'
this.verificationTarget = ''
this.verificationCode = ''
this.verificationCodeExpiresAt = null
clearVerificationContext()
},
},
+22
View File
@@ -36,6 +36,13 @@ function mapMenuItem(item, isChild = false) {
}
}
function flattenMenus(items = []) {
return items.flatMap((item) => [
item,
...flattenMenus(Array.isArray(item?.children) ? item.children : []),
])
}
export const useMenuStore = defineStore('menu', {
state: () => ({
isMaster: false,
@@ -49,9 +56,24 @@ export const useMenuStore = defineStore('menu', {
mappedMenus(state) {
return state.dynamicMenus.map((item) => mapMenuItem(item))
},
accessibleMenus(state) {
return flattenMenus(state.dynamicMenus)
},
accessibleUrls() {
return this.accessibleMenus
.filter((item) => item?.permissions?.can_view !== false && item?.url && item.url !== '#')
.map((item) => item.url)
},
},
actions: {
canAccessUrl(url) {
if (!url) return false
return this.accessibleUrls.some(
(menuUrl) => url === menuUrl || url.startsWith(`${menuUrl}/`),
)
},
async fetchMenus({ force = false } = {}) {
if (this.initialized && !force) {
return this.dynamicMenus
@@ -0,0 +1,227 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import {
getRoleApplications,
getStaffMenuGroupOptions,
getTenantMenuGroupOptions,
reviewRoleApplication,
} from '@/services/roleApplicationService'
import { showError, showSuccess } from '@/utils/swal'
const route = useRoute(),
type = computed(() => route.meta.applicationType),
loading = ref(true),
loaded = ref(false),
saving = ref(false),
menuGroupsLoading = ref(false),
menuGroups = ref([]),
items = ref([]),
modal = ref(false),
selected = ref(null)
const query = ref({ page: 1, per_page: 10, status: 'pending' }),
pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const form = reactive({
decision: 'approved',
review_notes: '',
menu_group_id: '',
payment_type: 'prepaid',
price_mode: 'discount',
discount_percent: 0,
commission_percent: 0,
credit_limit: 0,
})
const config = computed(
() =>
({
tenant: ['Pengajuan Tenant', 'Persetujuan tenant baru oleh platform'],
agent: ['Pengajuan Agen Voucher', 'Persetujuan agen pada tenant aktif'],
staff: ['Pengajuan Staff', 'Persetujuan calon karyawan pada tenant aktif'],
})[type.value],
)
const columns = [
{ key: 'applicant_label', label: 'Pemohon' },
{ key: 'target_label', label: 'Pengajuan' },
{ key: 'reason', label: 'Alasan' },
{ key: 'status_label', label: 'Status' },
{ key: 'submitted_label', label: 'Diajukan' },
]
const actions = [{ key: 'review', label: 'Tinjau', color: 'primary' }]
async function fetchData(params = query.value) {
query.value = { ...query.value, ...params }
loading.value = true
try {
const r = await getRoleApplications(type.value, query.value),
rows = r?.data?.data || []
items.value = rows.map((x) => ({
...x,
applicant_label: `${x.applicant?.name || '-'} · ${x.applicant?.email || ''}`,
target_label: type.value === 'tenant' ? x.payload?.tenant_name : x.tenant?.tenant_name,
reason: x.payload?.reason,
status_label: { pending: 'Menunggu', approved: 'Disetujui', rejected: 'Ditolak' }[x.status],
submitted_label: new Date(x.submitted_at).toLocaleString('id-ID'),
}))
pagination.value = {
current_page: r?.data?.current_page || 1,
last_page: r?.data?.last_page || 1,
per_page: r?.data?.per_page || 10,
total: r?.data?.total || 0,
}
} catch (e) {
showError(e?.response?.data?.message || 'Gagal memuat pengajuan.')
} finally {
loading.value = false
loaded.value = true
}
}
async function loadMenuGroups() {
if (!['tenant', 'staff'].includes(type.value)) return
menuGroupsLoading.value = true
try {
const r =
type.value === 'tenant' ? await getTenantMenuGroupOptions() : await getStaffMenuGroupOptions()
menuGroups.value = r?.data || []
} catch (e) {
showError(e?.response?.data?.message || 'Gagal memuat pilihan Group Menu.')
} finally {
menuGroupsLoading.value = false
}
}
async function open({ item }) {
selected.value = item
Object.assign(form, {
decision: 'approved',
review_notes: '',
menu_group_id: '',
payment_type: 'prepaid',
price_mode: 'discount',
discount_percent: 0,
commission_percent: 0,
credit_limit: 0,
})
modal.value = true
await loadMenuGroups()
}
async function submit() {
if (
['tenant', 'staff'].includes(type.value) &&
form.decision === 'approved' &&
!form.menu_group_id
) {
showError('Group Menu wajib dipilih sebelum menyetujui pengajuan.')
return
}
saving.value = true
try {
await reviewRoleApplication(selected.value.id, {
...form,
menu_group_id: form.menu_group_id || null,
})
modal.value = false
showSuccess(`Pengajuan berhasil ${form.decision === 'approved' ? 'disetujui' : 'ditolak'}.`)
await fetchData()
} catch (e) {
const errors = e?.response?.data?.errors
showError(
errors?.menu_group_id?.[0] || e?.response?.data?.message || 'Pengajuan gagal ditinjau.',
)
} finally {
saving.value = false
}
}
watch(type, () => {
loaded.value = false
menuGroups.value = []
fetchData()
})
onMounted(fetchData)
</script>
<template>
<div class="brdvx-page-container">
<div class="brdvx-page-header">
<h5 class="mb-0 fw-semibold">{{ config[0] }}</h5>
<small class="text-body-secondary">{{ config[1] }}</small>
</div>
<div v-if="loading && !loaded" class="brdvx-loading-state">
<CSpinner color="primary" />
<p class="brdvx-loading-text">Memuat data...</p>
</div>
<BaseResponsiveDataView
v-else
:columns="columns"
:items="items"
:actions="actions"
server-side
:pagination="pagination"
:loading="loading"
@query-change="fetchData"
@action-click="open"
/>
</div>
<CModal :visible="modal" @close="modal = false"
><CModalHeader><CModalTitle>Tinjau Pengajuan</CModalTitle></CModalHeader
><CModalBody
><CAlert color="info"
><strong>{{ selected?.applicant?.name }}</strong>
<div>{{ selected?.reason }}</div></CAlert
><CFormLabel>Keputusan</CFormLabel
><CFormSelect v-model="form.decision" class="mb-3"
><option value="approved">Setujui</option>
<option value="rejected">Tolak</option></CFormSelect
><template v-if="['tenant', 'staff'].includes(type) && form.decision === 'approved'"
><CFormLabel>Group Menu <span class="text-danger">*</span></CFormLabel
><CFormSelect
v-model="form.menu_group_id"
class="mb-2"
:disabled="menuGroupsLoading"
required
><option value="">
{{ menuGroupsLoading ? 'Memuat Group Menu...' : `Pilih Group Menu ${type}` }}
</option>
<option v-for="group in menuGroups" :key="group.id" :value="group.id">
{{ group.name }} ({{ group.menus_count }} menu)
</option></CFormSelect
><small v-if="form.menu_group_id" class="d-block text-body-secondary mb-3">{{
menuGroups.find((group) => group.id === Number(form.menu_group_id))?.description
}}</small
><CAlert v-if="!menuGroupsLoading && !menuGroups.length" color="warning"
>Belum ada Group Menu yang dapat diberikan.</CAlert
></template
><template v-if="type === 'agent' && form.decision === 'approved'"
><CFormLabel>Kategori Agen</CFormLabel
><CFormSelect v-model="form.payment_type" class="mb-3"
><option value="prepaid">Deposit dahulu</option>
<option value="postpaid">Jual dahulu, bayar kemudian</option></CFormSelect
><CFormLabel>Skema Harga</CFormLabel
><CFormSelect v-model="form.price_mode" class="mb-3"
><option value="discount">Diskon</option>
<option value="commission">Komisi</option></CFormSelect
><CRow class="g-2"
><CCol :md="6"
><CFormLabel>Diskon (%)</CFormLabel
><CFormInput v-model.number="form.discount_percent" type="number" /></CCol
><CCol :md="6"
><CFormLabel>Komisi (%)</CFormLabel
><CFormInput v-model.number="form.commission_percent" type="number" /></CCol
><CCol v-if="form.payment_type === 'postpaid'" :xs="12"
><CFormLabel>Limit Kredit</CFormLabel
><CFormInput v-model.number="form.credit_limit" type="number" /></CCol></CRow></template
><CFormLabel class="mt-3">Catatan Reviewer</CFormLabel
><CFormTextarea v-model="form.review_notes" rows="3" /></CModalBody
><CModalFooter
><CButton color="secondary" @click="modal = false">Batal</CButton
><CButton
:color="form.decision === 'approved' ? 'success' : 'danger'"
:disabled="
saving ||
(['tenant', 'staff'].includes(type) &&
form.decision === 'approved' &&
(!form.menu_group_id || menuGroupsLoading))
"
@click="submit"
>Simpan Keputusan</CButton
></CModalFooter
></CModal
>
</template>
@@ -0,0 +1,303 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { createRoleApplication, getMyRoleApplications } from '@/services/roleApplicationService'
import { showError, showSuccess } from '@/utils/swal'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(true),
saving = ref(false),
items = ref([]),
modal = ref('')
const form = reactive({})
const statusColor = { pending: 'warning', approved: 'success', rejected: 'danger' }
const accessLevel = computed(() => authStore.user?.access_level || 'customer')
const pendingTypes = computed(
() =>
new Set(
items.value.filter((item) => item.status === 'pending').map((item) => item.application_type),
),
)
const approvedTypes = computed(
() =>
new Set(
items.value.filter((item) => item.status === 'approved').map((item) => item.application_type),
),
)
const isTenantOwner = computed(
() => accessLevel.value === 'tenant_owner' || approvedTypes.value.has('tenant'),
)
const isStaff = computed(() => accessLevel.value === 'staff' || approvedTypes.value.has('staff'))
const hasPendingTenant = computed(() => pendingTypes.value.has('tenant'))
const hasPendingStaff = computed(() => pendingTypes.value.has('staff'))
const hasPendingAgent = computed(() => pendingTypes.value.has('agent'))
const showTenantAction = computed(
() => !isTenantOwner.value && !isStaff.value && !hasPendingTenant.value && !hasPendingStaff.value,
)
const showStaffAction = computed(
() => !isTenantOwner.value && !hasPendingTenant.value && !hasPendingStaff.value,
)
const showAgentAction = computed(
() => !isTenantOwner.value && !hasPendingTenant.value && !hasPendingAgent.value,
)
const contextMessage = computed(() => {
if (isTenantOwner.value)
return 'Akun Anda sudah menjadi Tenant Owner. Pengajuan Staff dan Agen tidak tersedia.'
if (hasPendingTenant.value)
return 'Pengajuan Tenant Owner sedang ditinjau. Pengajuan lain ditutup sementara sampai proses selesai.'
if (isStaff.value)
return 'Sebagai staff, Anda dapat mengajukan perubahan akses staff pada tenant atau menjadi agen.'
if (hasPendingStaff.value)
return 'Pengajuan Staff sedang ditinjau. Anda tetap dapat mengajukan diri sebagai agen.'
return 'Pilih jenis akses yang ingin Anda ajukan.'
})
const defaults = (type) =>
type === 'tenant'
? {
application_type: 'tenant',
tenant_name: '',
tenant_code: '',
phone: '',
email: '',
address: '',
reason: '',
}
: { application_type: type, tenant_id: '', reason: '' }
async function load() {
loading.value = true
try {
items.value = (await getMyRoleApplications())?.data || []
} catch (e) {
showError(e?.response?.data?.message || 'Gagal memuat pengajuan.')
} finally {
loading.value = false
}
}
function open(type) {
Object.keys(form).forEach((key) => delete form[key])
Object.assign(form, defaults(type))
modal.value = type
}
async function submit() {
saving.value = true
try {
await createRoleApplication(form)
modal.value = ''
showSuccess('Pengajuan berhasil dikirim dan menunggu persetujuan.')
await load()
} catch (e) {
showError(e?.response?.data?.message || 'Pengajuan gagal dikirim.')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="brdvx-page-container">
<div class="brdvx-page-header">
<h5 class="mb-0 fw-semibold">Pengajuan Saya</h5>
<small class="text-body-secondary"
>Akun baru selalu berstatus customer. Ajukan akses tenant, staff, atau agen sesuai
kebutuhan.</small
>
</div>
<CAlert v-if="!loading" color="info" class="application-context">
<CIcon icon="cil-info" />
<span>{{ contextMessage }}</span>
</CAlert>
<div v-if="!loading" class="application-actions">
<button v-if="showTenantAction" @click="open('tenant')">
<span><CIcon icon="cil-building" size="xl" /></span>
<div>
<strong>Menjadi Pemilik Tenant</strong
><small>Buat dan kelola layanan jaringan Anda sendiri</small>
</div></button
><button v-if="showAgentAction" @click="open('agent')">
<span><CIcon icon="cil-user-follow" size="xl" /></span>
<div>
<strong>Menjadi Agen Voucher</strong
><small>Bergabung sebagai agen pada tenant tertentu</small>
</div>
</button>
<button v-if="showStaffAction" @click="open('staff')">
<span><CIcon icon="cil-people" size="xl" /></span>
<div>
<strong>Menjadi Staff Tenant</strong
><small>Bergabung sebagai karyawan pada tenant tertentu</small>
</div>
</button>
<div
v-if="!showTenantAction && !showStaffAction && !showAgentAction"
class="application-actions-empty"
>
<CIcon :icon="hasPendingTenant ? 'cil-task' : 'cil-check-circle'" size="xl" />
<strong>{{ hasPendingTenant ? 'Menunggu persetujuan' : 'Akses sudah aktif' }}</strong>
<small>{{ contextMessage }}</small>
</div>
</div>
<div v-if="loading" class="brdvx-loading-state">
<CSpinner color="primary" />
<p class="brdvx-loading-text">Memuat data...</p>
</div>
<div v-else class="history">
<h6>Riwayat Pengajuan</h6>
<div v-if="!items.length" class="empty">Belum ada pengajuan.</div>
<article v-for="item in items" :key="item.id">
<span class="type-icon"
><CIcon
:icon="
item.application_type === 'tenant'
? 'cil-building'
: item.application_type === 'staff'
? 'cil-people'
: 'cil-user-follow'
"
/></span>
<div>
<strong>{{
item.application_type === 'tenant'
? item.payload?.tenant_name
: `${item.application_type === 'staff' ? 'Staff' : 'Agen'} ${item.tenant?.tenant_name || `Tenant #${item.tenant_id}`}`
}}</strong
><small>Diajukan {{ new Date(item.submitted_at).toLocaleString('id-ID') }}</small>
<p v-if="item.review_notes">Catatan: {{ item.review_notes }}</p>
</div>
<CBadge :color="statusColor[item.status]">{{
{ pending: 'Menunggu', approved: 'Disetujui', rejected: 'Ditolak' }[item.status]
}}</CBadge>
</article>
</div>
</div>
<CModal :visible="Boolean(modal)" @close="modal = ''"
><CModalHeader
><CModalTitle>{{
modal === 'tenant'
? 'Pengajuan Tenant'
: modal === 'staff'
? 'Pengajuan Staff Tenant'
: 'Pengajuan Agen Voucher'
}}</CModalTitle></CModalHeader
><CModalBody
><template v-if="modal === 'tenant'"
><CFormLabel>Nama Tenant/Usaha</CFormLabel
><CFormInput v-model="form.tenant_name" class="mb-3" /><CFormLabel
>Kode Tenant yang Diinginkan</CFormLabel
><CFormInput v-model="form.tenant_code" placeholder="Opsional" class="mb-3" /><CFormLabel
>Telepon/WhatsApp Usaha</CFormLabel
><CFormInput v-model="form.phone" class="mb-3" /><CFormLabel>Email Usaha</CFormLabel
><CFormInput v-model="form.email" type="email" class="mb-3" /><CFormLabel
>Alamat Usaha</CFormLabel
><CFormTextarea v-model="form.address" rows="2" class="mb-3" /></template
><template v-else
><CAlert color="info">Masukkan ID tenant yang diberikan oleh pemilik tenant.</CAlert
><CFormLabel>ID Tenant</CFormLabel
><CFormInput v-model.number="form.tenant_id" type="number" min="1" class="mb-3" /></template
><CFormLabel>Alasan Pengajuan</CFormLabel
><CFormTextarea v-model="form.reason" rows="3" /></CModalBody
><CModalFooter
><CButton color="secondary" @click="modal = ''">Batal</CButton
><CButton color="primary" :disabled="saving" @click="submit"
>Kirim Pengajuan</CButton
></CModalFooter
></CModal
>
</template>
<style scoped>
.application-context {
display: flex;
max-width: 850px;
align-items: center;
gap: 0.65rem;
margin: 1rem auto 0;
}
.application-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 340px));
justify-content: center;
gap: 1rem;
margin: 1rem 0 2rem;
}
.application-actions-empty {
display: flex;
width: min(100%, 420px);
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 1.4rem;
color: var(--cui-secondary-color);
text-align: center;
background: var(--cui-tertiary-bg);
border: 1px dashed var(--cui-border-color);
border-radius: 1rem;
}
.application-actions-empty strong {
color: var(--cui-body-color);
}
.application-actions button {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 1rem;
color: var(--cui-body-color);
text-align: left;
background: var(--cui-body-bg);
border: 1px solid var(--cui-border-color);
border-radius: 1rem;
box-shadow: 0 0.4rem 1rem rgba(0, 0, 0, 0.06);
}
.application-actions button:hover {
border-color: #ff8c00;
transform: translateY(-2px);
}
.application-actions span,
.type-icon {
display: grid;
width: 48px;
height: 48px;
flex: 0 0 48px;
place-items: center;
color: #fff;
background: linear-gradient(135deg, #f97316, #ff8c00);
border-radius: 0.8rem;
}
.application-actions div {
display: flex;
flex-direction: column;
}
.application-actions small,
.history article small {
color: var(--cui-secondary-color);
font-size: 0.72rem;
}
.history {
max-width: 850px;
margin: auto;
}
.history > h6 {
margin-bottom: 0.8rem;
}
.history article {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.8rem 0;
border-bottom: 1px solid var(--cui-border-color);
}
.history article > div {
min-width: 0;
flex: 1;
}
.history article p {
margin: 0.25rem 0 0;
color: var(--cui-secondary-color);
font-size: 0.72rem;
}
.empty {
padding: 2rem;
color: var(--cui-secondary-color);
text-align: center;
border: 1px dashed var(--cui-border-color);
border-radius: 1rem;
}
</style>
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<div class="permission-page">
<div class="permission-card">
<span class="permission-icon"><CIcon icon="cil-shield-alt" size="3xl" /></span>
<div>
<p class="permission-code">403</p>
<h1>Tidak Memiliki Akses</h1>
<p>Menu atau halaman ini tidak tersedia untuk akun yang sedang digunakan.</p>
</div>
<div class="d-flex flex-wrap justify-content-center gap-2">
<CButton color="secondary" variant="outline" @click="router.back()">Kembali</CButton>
<CButton color="primary" @click="router.push('/dashboard')">Ke Dashboard</CButton>
</div>
</div>
</div>
</template>
<style scoped>
.permission-page { display: grid; min-height: calc(100vh - 9rem); place-items: center; padding: 1rem; }
.permission-card { width: min(100%, 32rem); padding: 2rem; text-align: center; background: var(--cui-body-bg); border: 1px solid var(--cui-border-color); border-radius: 1rem; box-shadow: 0 .75rem 2rem rgba(0, 0, 0, .08); }
.permission-icon { display: inline-flex; width: 5rem; height: 5rem; align-items: center; justify-content: center; margin-bottom: 1rem; color: #fff; background: linear-gradient(135deg, #f97316, #ff8c00); border-radius: 50%; }
.permission-code { margin: 0; color: #ff8c00; font-size: .8rem; font-weight: 700; letter-spacing: .18em; }
.permission-card h1 { margin: .25rem 0 .65rem; font-size: clamp(1.4rem, 4vw, 2rem); }
.permission-card > div > p:last-child { color: var(--cui-secondary-color); }
</style>
+43
View File
@@ -14,6 +14,11 @@ const form = reactive({
const isLoading = computed(() => authStore.loading)
const errorMessage = computed(() => authStore.error)
const temporaryVerificationCode = computed(() => authStore.verificationCode)
function useTemporaryCode() {
form.verification_code = temporaryVerificationCode.value
}
async function onSubmit() {
authStore.clearError()
@@ -98,6 +103,23 @@ function goToLogin() {
/>
</CInputGroup>
<CAlert
v-if="temporaryVerificationCode"
color="warning"
class="temporary-code-box mb-4"
>
<div>
<small class="d-block text-body-secondary">Kode verifikasi sementara</small>
<strong class="temporary-code-value">{{ temporaryVerificationCode }}</strong>
<small class="d-block mt-1">
Kode ditampilkan di sini karena channel pengiriman belum aktif.
</small>
</div>
<CButton color="warning" size="sm" type="button" @click="useTemporaryCode">
Gunakan kode
</CButton>
</CAlert>
<div class="d-grid gap-2">
<CButton color="primary" type="submit" :disabled="isLoading">
{{ isLoading ? 'Memverifikasi...' : 'Verifikasi' }}
@@ -119,3 +141,24 @@ function goToLogin() {
</CContainer>
</div>
</template>
<style scoped>
.temporary-code-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.temporary-code-value {
font-size: 1.45rem;
letter-spacing: 0.32rem;
}
@media (max-width: 575.98px) {
.temporary-code-box {
align-items: stretch;
flex-direction: column;
}
}
</style>
+2 -2
View File
@@ -2,7 +2,7 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useTicketIncidentStore } from '@/stores/ticketIncident.js'
import { formatTanggal } from '@/utils/tglindo.js'
import { showConfirm, showSuccess, showSuccessr, showError } from '@/utils/swal.js'
import { showConfirm, showSuccess, showError } from '@/utils/swal.js'
import { CButton } from '@coreui/vue'
import api from '@/api/axios'
@@ -136,7 +136,7 @@ async function updateTicketIncidentType(id) {
const response = await api.put(`/ticket-incident-types/${id}`, body)
store.updateItem(editingIndex.value, response.data.data)
modal.value = false
showSuccessr('Data Berhasil Di Simpan')
showSuccess('Data Berhasil Di Simpan')
fetchTicketIncidentTypes()
} catch (err) {
showError(err.response?.data?.message || 'Gagal mengupdate data: ' + err.message)
+2 -2
View File
@@ -2,7 +2,7 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useTicketTypeStore } from '@/stores/ticketType.js'
import { formatTanggal } from '@/utils/tglindo.js'
import { showConfirm, showSuccess, showSuccessr, showError } from '@/utils/swal.js'
import { showConfirm, showSuccess, showError } from '@/utils/swal.js'
import { CButton } from '@coreui/vue'
import { CBadge } from '@coreui/vue'
import api from '@/api/axios'
@@ -169,7 +169,7 @@ async function updateTicketType(id) {
await api.put(`/ticket-types/${id}`, body)
store.updateTicketType(editingIndex.value, { ...body })
modal.value = false
showSuccessr('Data berhasil diupdate')
showSuccess('Data berhasil diupdate')
fetchAllData()
} catch (err) {
showError(err.response?.data?.message || 'Gagal mengupdate data: ' + err.message)
+4
View File
@@ -68,6 +68,7 @@ function emptyForm() {
whatsapp_number: '',
password: '',
access_level: 'staff',
is_platform_staff: false,
status: 'active',
nik: '',
address: '',
@@ -263,6 +264,7 @@ async function hydrateForm(user) {
email: user?.email || '',
whatsapp_number: user?.whatsapp_number || '',
access_level: user?.access_level || 'staff',
is_platform_staff: Boolean(user?.is_platform_staff),
status: user?.status || 'active',
nik: profile.nik || '',
address: profile.address || '',
@@ -320,6 +322,7 @@ function buildPayload() {
whatsapp_number: form.whatsapp_number.trim() || null,
access_level: form.access_level,
is_master: form.access_level === 'master_admin',
is_platform_staff: isMasterActor.value ? Boolean(form.is_platform_staff) : false,
status: form.status,
user_profile: {
nik: form.nik.trim() || null,
@@ -482,6 +485,7 @@ onMounted(fetchUsers)
<option value="active">Aktif</option><option value="inactive">Nonaktif</option><option value="suspended">Ditangguhkan</option>
</CFormSelect>
</CCol>
<CCol v-if="isMasterActor" :md="4" class="d-flex align-items-end pb-2"><CFormCheck v-model="form.is_platform_staff" label="Staff Platform (dapat review pengajuan tenant)" /></CCol>
<CCol :md="4"><CFormLabel>Password {{ isEdit ? '(opsional)' : '' }}</CFormLabel><CFormInput v-model="form.password" type="password" /></CCol>
</CRow>
</CCardBody>
@@ -0,0 +1,21 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { createVoucherAgent, deleteVoucherAgent, getVoucherAgents, getVoucherOptions, updateVoucherAgent } from '@/services/voucherService'
import { showConfirm, showError, showSuccess } from '@/utils/swal'
const loading = ref(true), loaded = ref(false), saving = ref(false), modal = ref(false), editing = ref(null), items = ref([]), users = ref([])
const query = ref({ page: 1, per_page: 10 }), pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 }), form = reactive({})
const columns = [{ key: 'agent_code', label: 'Kode Agen' }, { key: 'user_label', label: 'User' }, { key: 'type_label', label: 'Kategori' }, { key: 'pricing_label', label: 'Parameter Harga' }, { key: 'limit_label', label: 'Limit/Piutang' }, { key: 'status', label: 'Status' }]
const actions = [{ key: 'edit', label: 'Edit', color: 'success' }, { key: 'delete', label: 'Nonaktifkan', color: 'danger' }]
const money = (v) => `Rp ${Number(v || 0).toLocaleString('id-ID')}`
const defaults = () => ({ user_id: '', agent_code: '', payment_type: 'prepaid', price_mode: 'discount', discount_percent: 0, commission_percent: 0, credit_limit: 0, status: 'active', notes: '' })
function normalize(x) { return { ...x, user_label: x.user?.name || '-', type_label: x.payment_type === 'prepaid' ? 'Deposit Dahulu' : 'Jual Dulu, Bayar Kemudian', pricing_label: x.price_mode === 'discount' ? `Diskon ${x.discount_percent}%` : `Komisi ${x.commission_percent}%`, limit_label: x.payment_type === 'postpaid' ? `${money(x.outstanding_balance)} / ${money(x.credit_limit)}` : 'Menggunakan saldo dompet' } }
async function fetchData(params = query.value) { query.value = { ...query.value, ...params }; loading.value = true; try { const r = await getVoucherAgents(query.value), rows = r?.data?.data || []; items.value = rows.map(normalize); pagination.value = { current_page: r?.data?.current_page || 1, last_page: r?.data?.last_page || 1, per_page: r?.data?.per_page || 10, total: r?.data?.total || 0 } } catch (e) { showError(e?.response?.data?.message || 'Gagal memuat agen voucher.') } finally { loading.value = false; loaded.value = true } }
async function open(item = null) { editing.value = item; Object.keys(form).forEach((key) => delete form[key]); Object.assign(form, defaults(), item || {}); try { users.value = (await getVoucherOptions('agents'))?.data?.users || []; modal.value = true } catch (e) { showError(e?.response?.data?.message || 'Gagal memuat user tenant.') } }
async function save() { if (!form.user_id) return showError('User agen wajib dipilih.'); saving.value = true; try { if (editing.value) await updateVoucherAgent(editing.value.id, form); else await createVoucherAgent(form); modal.value = false; showSuccess('Agen voucher berhasil disimpan.'); await fetchData() } catch (e) { showError(e?.response?.data?.message || 'Agen voucher gagal disimpan.') } finally { saving.value = false } }
async function action({ action, item }) { if (action === 'edit') return open(item); const result = await showConfirm('Nonaktifkan Agen', `Nonaktifkan agen ${item.user_label}?`); if (!result.isConfirmed) return; try { await deleteVoucherAgent(item.id); showSuccess('Agen berhasil dinonaktifkan.'); await fetchData() } catch (e) { showError(e?.response?.data?.message || 'Agen gagal dinonaktifkan.') } }
onMounted(fetchData)
</script>
<template><div class="brdvx-page-container"><div class="brdvx-page-header d-flex justify-content-between align-items-center"><div><h5 class="mb-0 fw-semibold">Agen Voucher</h5><small class="text-body-secondary">Atur agen deposit dan agen dengan fasilitas kredit</small></div><CButton color="primary" size="sm" @click="open()">+ Tambah Agen</CButton></div><div v-if="loading && !loaded" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div><BaseResponsiveDataView v-else :columns="columns" :items="items" :actions="actions" server-side :pagination="pagination" :loading="loading" @query-change="fetchData" @action-click="action" /></div>
<CModal :visible="modal" @close="modal = false"><CModalHeader><CModalTitle>{{ editing ? 'Edit' : 'Tambah' }} Agen Voucher</CModalTitle></CModalHeader><CModalBody><CFormLabel>User Tenant</CFormLabel><CFormSelect v-model="form.user_id" :disabled="Boolean(editing)" class="mb-3"><option value="">Pilih user</option><option v-for="user in users" :key="user.id" :value="user.id">{{ user.name }} · {{ user.username }}</option></CFormSelect><CFormLabel>Kode Agen</CFormLabel><CFormInput v-model="form.agent_code" placeholder="Otomatis jika dikosongkan" class="mb-3" /><CFormLabel>Kategori Pembayaran</CFormLabel><CFormSelect v-model="form.payment_type" class="mb-3"><option value="prepaid">Deposit dahulu, lalu beli voucher</option><option value="postpaid">Jual voucher dahulu, bayar kemudian</option></CFormSelect><CFormLabel>Skema Harga</CFormLabel><CFormSelect v-model="form.price_mode" class="mb-3"><option value="discount">Diskon harga beli</option><option value="commission">Komisi penjualan</option></CFormSelect><CRow class="g-3"><CCol :md="6"><CFormLabel>Diskon (%)</CFormLabel><CFormInput v-model.number="form.discount_percent" type="number" min="0" max="100" /></CCol><CCol :md="6"><CFormLabel>Komisi (%)</CFormLabel><CFormInput v-model.number="form.commission_percent" type="number" min="0" max="100" /></CCol><CCol v-if="form.payment_type === 'postpaid'" :xs="12"><CFormLabel>Limit Kredit</CFormLabel><CFormInput v-model.number="form.credit_limit" type="number" min="0" /></CCol></CRow><CFormLabel class="mt-3">Status</CFormLabel><CFormSelect v-model="form.status" class="mb-3"><option value="active">Aktif</option><option value="inactive">Nonaktif</option><option value="suspended">Ditangguhkan</option></CFormSelect><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2" /></CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">Simpan</CButton></CModalFooter></CModal></template>
@@ -0,0 +1,139 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { getVoucherLoginPage, getVoucherOptions, saveVoucherLoginPage } from '@/services/voucherService'
import { showError, showSuccess } from '@/utils/swal'
const loading = ref(true), saving = ref(false), data = ref(null), packages = ref([]), form = reactive({ enabled: false, title: 'Beli Voucher Hotspot', theme_color: '#ff8c00', allowed_package_profile_ids: [], settings: {} })
const setting = computed(() => data.value?.setting || {}), dummySales = computed(() => data.value?.dummy_sales || [])
const money = (v) => `Rp ${Number(v || 0).toLocaleString('id-ID')}`
async function load() { loading.value = true; try { const [page, options] = await Promise.all([getVoucherLoginPage(), getVoucherOptions('login-page')]); data.value = page?.data; packages.value = options?.data?.packages || []; Object.assign(form, { enabled: false, title: 'Beli Voucher Hotspot', theme_color: '#ff8c00', allowed_package_profile_ids: [], settings: {}, ...(data.value?.setting || {}) }) } catch (e) { showError(e?.response?.data?.message || 'Gagal memuat setting LoginPage.') } finally { loading.value = false } }
async function save() { saving.value = true; try { data.value = (await saveVoucherLoginPage(form))?.data; Object.assign(form, data.value?.setting || {}); showSuccess(form.enabled ? 'Voucher LoginPage berhasil diaktifkan.' : 'Voucher LoginPage dinonaktifkan.') } catch (e) { form.enabled = Boolean(setting.value.enabled); showError(e?.response?.data?.message || 'Setting LoginPage gagal disimpan.') } finally { saving.value = false } }
async function copyScript() { try { await navigator.clipboard.writeText(data.value?.embed_script || ''); showSuccess('Script berhasil disalin.') } catch { showError('Browser tidak mengizinkan menyalin script.') } }
onMounted(load)
</script>
<template>
<div class="brdvx-page-container">
<div class="brdvx-page-header">
<h5 class="mb-0 fw-semibold">Voucher LoginPage
</h5>
<small class="text-body-secondary">Tampilkan pembelian voucher langsung pada halaman login hotspot
</small>
</div>
<div v-if="loading" class="brdvx-loading-state">
<CSpinner color="primary" />
<p class="brdvx-loading-text">Memuat data...
</p>
</div>
<CRow v-else class="g-3 justify-content-center">
<CCol :lg="5">
<CCard class="setting-card">
<CCardBody>
<div class="toggle-head">
<span class="feature-icon">
<CIcon icon="cil-barcode" size="xl" />
</span>
<div>
<h6 class="mb-1">Penjualan di LoginPage
</h6>
<small class="text-body-secondary">Membutuhkan payment gateway tenant yang aktif
</small>
</div>
<CFormSwitch v-model="form.enabled" size="xl" />
</div>
<hr />
<CFormLabel>Judul Widget
</CFormLabel>
<CFormInput v-model="form.title" class="mb-3" />
<CFormLabel>Warna Tema
</CFormLabel>
<CFormInput v-model="form.theme_color" type="color" class="mb-3 color-input" />
<CFormLabel>Paket yang Ditampilkan
</CFormLabel>
<CFormSelect v-model="form.allowed_package_profile_ids" multiple class="mb-3">
<option v-for="item in packages" :key="item.id" :value="item.id">{{ item.name }} · {{ money(item.price) }}
</option>
</CFormSelect>
<CButton color="primary" class="w-100" :disabled="saving" @click="save">Simpan Pengaturan
</CButton>
</CCardBody>
</CCard>
<CCard v-if="form.enabled" class="mt-3 setting-card">
<CCardHeader class="d-flex justify-content-between align-items-center">
<strong>Script LoginPage
</strong>
<CButton color="secondary" variant="ghost" size="sm" title="Salin script" @click="copyScript">
<CIcon icon="cil-copy" /> Salin
</CButton>
</CCardHeader>
<CCardBody>
<pre>
<code>{{ data?.embed_script }}
</code>
</pre>
<CAlert color="warning" class="mb-0">
<strong>Petunjuk pemasangan
</strong>
<div>Masukkan script ini ke file login page MikroTik pada posisi tempat daftar voucher ingin ditampilkan. Pastikan elemen dan script tetap berurutan.
</div>
</CAlert>
</CCardBody>
</CCard>
</CCol>
<CCol v-if="form.enabled" :xs="7">
<CCard>
<CCardHeader>
<strong>Riwayat Penjualan LoginPage
</strong>
<CBadge color="secondary">Data Dummy
</CBadge>
</CCardHeader>
<CCardBody class="p-0">
<div class="table-responsive">
<CTable hover class="mb-0">
<CTableHead>
<CTableRow>
<CTableHeaderCell>Referensi
</CTableHeaderCell>
<CTableHeaderCell>Pembeli
</CTableHeaderCell>
<CTableHeaderCell>Paket
</CTableHeaderCell>
<CTableHeaderCell>Nominal
</CTableHeaderCell>
<CTableHeaderCell>Status
</CTableHeaderCell>
<CTableHeaderCell>Waktu
</CTableHeaderCell>
</CTableRow>
</CTableHead>
<CTableBody>
<CTableRow v-for="item in dummySales" :key="item.id">
<CTableDataCell>{{ item.reference }}
</CTableDataCell>
<CTableDataCell>{{ item.buyer }}
</CTableDataCell>
<CTableDataCell>{{ item.package }}
</CTableDataCell>
<CTableDataCell>{{ money(item.amount) }}
</CTableDataCell>
<CTableDataCell>
<CBadge :color="item.status === 'paid' ? 'success' : 'warning'">{{ item.status === 'paid' ? 'Lunas' : 'Menunggu' }}
</CBadge>
</CTableDataCell>
<CTableDataCell>{{ new Date(item.created_at).toLocaleString('id-ID') }}
</CTableDataCell>
</CTableRow>
</CTableBody>
</CTable>
</div>
</CCardBody>
</CCard>
</CCol>
</CRow>
</div>
</template>
<style scoped>.setting-card{border-top:3px solid #ff8c00}.toggle-head{display:flex;align-items:center;gap:.75rem}.toggle-head>div{min-width:0;flex:1}.feature-icon{display:inline-flex;width:44px;height:44px;align-items:center;justify-content:center;color:#fff;background:linear-gradient(135deg,#f97316,#ff8c00);border-radius:.75rem}.color-input{width:100%;height:42px}pre{max-height:16rem;padding:1rem;overflow:auto;color:#e8e8e8;background:#1e1e2d;border-radius:.75rem;white-space:pre-wrap;word-break:break-all}
</style>
@@ -0,0 +1,27 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { createVoucherSale, getVoucherOptions, getVoucherSale, getVoucherSales } from '@/services/voucherService'
import { showError, showSuccess } from '@/utils/swal'
const loading = ref(true), loaded = ref(false), saving = ref(false), modal = ref(false), detailModal = ref(false)
const items = ref([]), packages = ref([]), mikrotiks = ref([]), detail = ref(null)
const query = ref({ page: 1, per_page: 10 }), pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const form = reactive({ nas_package_profile_id: '', nas_mikrotik_id: '', quantity: 1, notes: '' })
const selectedPackage = computed(() => packages.value.find((x) => Number(x.id) === Number(form.nas_package_profile_id)))
const estimatedTotal = computed(() => Number(selectedPackage.value?.price || 0) * Number(form.quantity || 0))
const columns = [{ key: 'sale_number', label: 'Nomor Penjualan' }, { key: 'package_label', label: 'Paket' }, { key: 'quantity', label: 'Jumlah' }, { key: 'agent_label', label: 'Penjual/Agen' }, { key: 'total_label', label: 'Total' }, { key: 'payment_label', label: 'Pembayaran' }, { key: 'created_label', label: 'Tanggal' }]
const actions = [{ key: 'detail', label: 'Lihat Voucher', color: 'primary' }]
const money = (value) => `Rp ${Number(value || 0).toLocaleString('id-ID')}`
function normalize(x) { return { ...x, package_label: x.package_profile?.name || '-', agent_label: x.agent?.user?.name || x.creator?.name || 'Tenant', total_label: money(x.total_amount), payment_label: x.payment_status === 'credit' ? 'Kredit/Belum Dibayar' : 'Lunas', created_label: new Date(x.created_at).toLocaleString('id-ID') } }
async function fetchData(params = query.value) { query.value = { ...query.value, ...params }; loading.value = true; try { const r = await getVoucherSales(query.value), rows = r?.data?.data || []; items.value = rows.map(normalize); pagination.value = { current_page: r?.data?.current_page || 1, last_page: r?.data?.last_page || 1, per_page: r?.data?.per_page || 10, total: r?.data?.total || 0 } } catch (e) { showError(e?.response?.data?.message || 'Gagal memuat penjualan voucher.') } finally { loading.value = false; loaded.value = true } }
async function openCreate() { try { const r = await getVoucherOptions(); packages.value = r?.data?.packages || []; mikrotiks.value = r?.data?.mikrotiks || []; Object.assign(form, { nas_package_profile_id: packages.value[0]?.id || '', nas_mikrotik_id: '', quantity: 1, notes: '' }); modal.value = true } catch (e) { showError(e?.response?.data?.message || 'Gagal memuat pilihan voucher.') } }
async function submit() { if (!form.nas_package_profile_id) return showError('Profile paket hotspot wajib dipilih.'); saving.value = true; try { const r = await createVoucherSale(form); modal.value = false; detail.value = r?.data; detailModal.value = true; showSuccess('Voucher berhasil dibuat dan menunggu sinkronisasi MikroTik.'); await fetchData() } catch (e) { showError(e?.response?.data?.message || 'Voucher gagal dibuat.') } finally { saving.value = false } }
async function showDetail(item) { try { detail.value = (await getVoucherSale(item.id))?.data; detailModal.value = true } catch (e) { showError(e?.response?.data?.message || 'Detail voucher gagal dimuat.') } }
onMounted(fetchData)
</script>
<template><div class="brdvx-page-container"><div class="brdvx-page-header d-flex justify-content-between align-items-center gap-2"><div><h5 class="mb-0 fw-semibold">Jual Voucher</h5><small class="text-body-secondary">Generate voucher hotspot; sinkronisasi MikroTik akan ditambahkan pada tahap integrasi</small></div><CButton color="primary" size="sm" @click="openCreate">+ Buat Voucher</CButton></div><div v-if="loading && !loaded" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div><BaseResponsiveDataView v-else :columns="columns" :items="items" :actions="actions" server-side :pagination="pagination" :loading="loading" @query-change="fetchData" @action-click="({ item }) => showDetail(item)" /></div>
<CModal :visible="modal" @close="modal = false"><CModalHeader><CModalTitle>Buat Voucher Hotspot</CModalTitle></CModalHeader><CModalBody><CFormLabel>Profile Paket Hotspot</CFormLabel><CFormSelect v-model="form.nas_package_profile_id" class="mb-3"><option v-for="item in packages" :key="item.id" :value="item.id">{{ item.name }} · {{ money(item.price) }}</option></CFormSelect><CFormLabel>Target MikroTik</CFormLabel><CFormSelect v-model="form.nas_mikrotik_id" class="mb-3"><option value="">Belum ditentukan</option><option v-for="item in mikrotiks" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect><CFormLabel>Jumlah Voucher</CFormLabel><CFormInput v-model.number="form.quantity" type="number" min="1" max="100" class="mb-3" /><div class="price-summary"><span>Estimasi harga normal</span><strong>{{ money(estimatedTotal) }}</strong><small>Harga akhir agen mengikuti diskon, saldo deposit, atau limit kredit yang ditetapkan tenant.</small></div><CFormLabel class="mt-3">Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2" /></CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="submit">Generate Voucher</CButton></CModalFooter></CModal>
<CModal :visible="detailModal" size="lg" @close="detailModal = false"><CModalHeader><CModalTitle>{{ detail?.sale_number || 'Detail Voucher' }}</CModalTitle></CModalHeader><CModalBody><div class="voucher-list"><div v-for="voucher in detail?.vouchers || []" :key="voucher.id" class="voucher-code"><span><small>USERNAME</small><strong>{{ voucher.username }}</strong></span><span><small>PASSWORD</small><strong>{{ voucher.password }}</strong></span><CBadge color="warning">{{ voucher.sync_status }}</CBadge></div></div></CModalBody></CModal></template>
<style scoped>.price-summary{display:flex;flex-direction:column;padding:.8rem;color:#fff;background:linear-gradient(135deg,#f97316,#ff8c00);border-radius:.8rem}.price-summary strong{font-size:1.35rem}.price-summary small{opacity:.8}.voucher-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:.75rem}.voucher-code{display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:.6rem;padding:.8rem;border:1px dashed #ff8c00;border-radius:.75rem}.voucher-code span{display:flex;min-width:0;flex-direction:column}.voucher-code small{color:var(--cui-secondary-color);font-size:.62rem}.voucher-code strong{overflow:hidden;text-overflow:ellipsis}</style>
+58 -151
View File
@@ -1,171 +1,78 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { getMyWallets, getWalletLedger, transferWallet, withdrawWallet } from '@/services/walletService'
import { getMyWallets, getWalletLedger, withdrawWallet } from '@/services/walletService'
import { showError, showSuccess } from '@/utils/swal'
const loading = ref(true), wallets = ref([]), activeId = ref(null), ledger = ref([]), modal = ref(''), saving = ref(false)
const loading = ref(true), wallets = ref([]), activeId = ref(null), ledger = ref([]), modal = ref(false), saving = ref(false)
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 }), query = ref({ page: 1, per_page: 10 })
const form = reactive({ amount: '', destination_wallet_id: '', bank_code: '', account_number: '', account_name: '' })
const active = computed(() => wallets.value.find((x) => x.id === activeId.value) || wallets.value[0])
const otherWallets = computed(() => wallets.value.filter((x) => x.id !== active.value?.id))
const form = reactive({ amount: '', bank_code: '', account_number: '', account_name: '' })
const active = computed(() => wallets.value.find((item) => item.id === activeId.value) || wallets.value[0])
const columns = [{ key: 'date', label: 'Tanggal' }, { key: 'number', label: 'Referensi' }, { key: 'type', label: 'Jenis' }, { key: 'debit', label: 'Debit' }, { key: 'credit', label: 'Kredit' }, { key: 'balance', label: 'Saldo' }]
const money = (value) => `Rp ${Number(value || 0).toLocaleString('id-ID')}`
async function loadWallets() { loading.value = true; try { wallets.value = (await getMyWallets())?.data || []; activeId.value ||= wallets.value[0]?.id; await loadLedger() } catch (e) { showError(e?.response?.data?.message || 'Gagal memuat dompet.') } finally { loading.value = false } }
async function loadBalances() {
loading.value = true
try {
wallets.value = (await getMyWallets())?.data || []
if (!wallets.value.some((item) => item.id === activeId.value)) activeId.value = wallets.value[0]?.id
await loadLedger()
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat saldo.') }
finally { loading.value = false }
}
async function loadLedger(params = query.value) {
if (!active.value) return
if (!active.value?.ledger_available) {
ledger.value = []; pagination.value = { current_page: 1, last_page: 1, per_page: 10, total: 0 }; return
}
query.value = { ...query.value, ...params }
const response = await getWalletLedger(active.value.id, query.value), rows = response?.data?.data || []
ledger.value = rows.map((x) => ({ ...x, date: new Date(x.created_at).toLocaleString('id-ID'), number: x.transaction?.transaction_number, type: x.transaction?.type, debit: x.entry_type === 'debit' ? money(x.amount) : '-', credit: x.entry_type === 'credit' ? money(x.amount) : '-', balance: money(x.balance_after) }))
ledger.value = rows.map((item) => ({ ...item, date: new Date(item.created_at).toLocaleString('id-ID'), number: item.transaction?.transaction_number, type: item.transaction?.type, debit: item.entry_type === 'debit' ? money(item.amount) : '-', credit: item.entry_type === 'credit' ? money(item.amount) : '-', balance: money(item.balance_after) }))
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 || 0 }
}
function open(type) { Object.assign(form, { amount: '', destination_wallet_id: otherWallets.value[0]?.id || '', bank_code: '', account_number: '', account_name: '' }); modal.value = type }
async function submit() {
function openWithdrawal() { Object.assign(form, { amount: '', bank_code: '', account_number: '', account_name: '' }); modal.value = true }
async function submitWithdrawal() {
saving.value = true
try {
if (modal.value === 'transfer') await transferWallet(active.value.id, { destination_wallet_id: form.destination_wallet_id, amount: form.amount })
if (modal.value === 'withdraw') await withdrawWallet(active.value.id, { amount: form.amount, bank_code: form.bank_code, account_number: form.account_number, account_name: form.account_name })
modal.value = ''; showSuccess('Permintaan berhasil diproses.'); await loadWallets()
} catch (e) { showError(e?.response?.data?.message || 'Transaksi dompet gagal.') } finally { saving.value = false }
try { await withdrawWallet(active.value.id, form); modal.value = false; showSuccess('Permintaan penarikan settlement berhasil dibuat.'); await loadBalances() }
catch (error) { showError(error?.response?.data?.message || 'Penarikan saldo settlement gagal.') }
finally { saving.value = false }
}
watch(activeId, loadLedger)
onMounted(loadWallets)
onMounted(loadBalances)
</script>
<template><div class="brdvx-page-container"><div class="brdvx-page-header"><h5 class="mb-0 fw-semibold">Dompet</h5><small class="text-body-secondary">Saldo user dan tenant dengan pencatatan ledger</small></div><div v-if="loading" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div><template v-else>
<div class="wallet-grid"><button v-for="wallet in wallets" :key="wallet.id" class="wallet-card" :class="{ active: active?.id === wallet.id }" @click="activeId = wallet.id"><span>{{ wallet.wallet_type === 'tenant' ? 'DOMPET TENANT' : 'DOMPET USER' }}</span><strong>{{ money(wallet.available_balance) }}</strong><small>Pending {{ money(wallet.pending_balance) }} · IDR</small></button></div>
<div class="wallet-actions">
<button type="button" class="wallet-action" disabled>
<span class="wallet-action-icon topup"><CIcon icon="cil-data-transfer-down" size="xl" /></span>
<span><strong>Isi Saldo</strong><small>Tambahkan saldo melalui payment gateway</small></span>
</button>
<button type="button" class="wallet-action" :disabled="!active" @click="open('withdraw')">
<span class="wallet-action-icon withdraw"><CIcon icon="cil-data-transfer-up" size="xl" /></span>
<span><strong>Tarik Saldo</strong><small>Kirim saldo ke rekening bank</small></span>
</button>
<button type="button" class="wallet-action" :disabled="!otherWallets.length" @click="open('transfer')">
<span class="wallet-action-icon transfer"><CIcon icon="cil-transfer" size="xl" /></span>
<span><strong>Pindah Saldo</strong><small>Transfer antara dompet user dan tenant</small></span>
</button>
<template>
<div class="brdvx-page-container">
<div class="brdvx-page-header"><h5 class="mb-0 fw-semibold">Saldo Deposit</h5><small class="text-body-secondary">Saldo layanan user, settlement tenant, dan fasilitas kredit voucher pada tenant aktif</small></div>
<div v-if="loading" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
<template v-else>
<div class="wallet-grid">
<button v-for="balance in wallets" :key="balance.id" type="button" class="wallet-card" :class="{ active: active?.id === balance.id, credit: balance.balance_kind === 'credit' }" @click="activeId = balance.id">
<span>{{ (balance.label || 'Saldo Deposit').toUpperCase() }}</span><strong>{{ money(balance.available_balance) }}</strong>
<template v-if="balance.balance_kind === 'credit'"><small>Limit {{ money(balance.credit_limit) }} · Terpakai {{ money(balance.used_credit) }}</small><small class="usage-note">Khusus pembelian voucher hotspot</small></template>
<template v-else><small>Pending {{ money(balance.pending_balance) }} · IDR</small><small class="usage-note">{{ balance.usage_note }}</small></template>
</button>
</div>
<div class="wallet-actions">
<button v-if="active?.balance_kind === 'deposit'" type="button" class="wallet-action" disabled><span class="wallet-action-icon topup"><CIcon icon="cil-data-transfer-down" size="xl" /></span><span><strong>Isi Saldo Deposit</strong><small>Tambahkan saldo untuk transaksi layanan tenant</small></span></button>
<button v-if="active?.can_withdraw" type="button" class="wallet-action" @click="openWithdrawal"><span class="wallet-action-icon withdraw"><CIcon icon="cil-data-transfer-up" size="xl" /></span><span><strong>Tarik Settlement</strong><small>Kirim saldo settlement tenant ke rekening bank</small></span></button>
<div v-if="active?.balance_kind === 'credit'" class="credit-information"><CIcon icon="cil-info" size="xl" /><span><strong>Fasilitas Kredit Voucher</strong><small>Sisa kredit berkurang saat membeli voucher dan bertambah kembali setelah tagihan agen dibayar.</small></span></div>
</div>
<template v-if="active?.ledger_available"><h6 class="mt-4">Mutasi {{ active?.label }}</h6><BaseResponsiveDataView :columns="columns" :items="ledger" server-side :pagination="pagination" @query-change="loadLedger" /></template>
<CAlert v-else-if="active?.balance_kind === 'credit'" color="info" class="mt-4 mb-0">Riwayat pemakaian saldo kredit tersedia pada transaksi penjualan Voucher Hotspot.</CAlert>
</template>
</div>
<h6 class="mt-4">Mutasi {{ active?.wallet_type === 'tenant' ? 'Dompet Tenant' : 'Dompet User' }}</h6><BaseResponsiveDataView :columns="columns" :items="ledger" server-side :pagination="pagination" @query-change="loadLedger" />
</template></div>
<CModal :visible="Boolean(modal)" @close="modal = ''"><CModalHeader><CModalTitle>{{ modal === 'transfer' ? 'Pindah Dompet' : 'Tarik Saldo' }}</CModalTitle></CModalHeader><CModalBody><CFormLabel>Nominal</CFormLabel><CFormInput v-model.number="form.amount" type="number" min="1" class="mb-3" /><template v-if="modal === 'transfer'"><CFormLabel>Dompet Tujuan</CFormLabel><CFormSelect v-model="form.destination_wallet_id"><option v-for="item in otherWallets" :key="item.id" :value="item.id">{{ item.wallet_type === 'tenant' ? 'Dompet Tenant' : 'Dompet User' }}</option></CFormSelect></template><template v-else><CFormLabel>Bank</CFormLabel><CFormInput v-model="form.bank_code" class="mb-2" /><CFormLabel>Nomor Rekening</CFormLabel><CFormInput v-model="form.account_number" class="mb-2" /><CFormLabel>Nama Pemilik</CFormLabel><CFormInput v-model="form.account_name" /></template></CModalBody><CModalFooter><CButton color="secondary" @click="modal = ''">Batal</CButton><CButton color="primary" :disabled="saving" @click="submit">Proses</CButton></CModalFooter></CModal></template>
<CModal :visible="modal" @close="modal = false"><CModalHeader><CModalTitle>Tarik Saldo Settlement</CModalTitle></CModalHeader><CModalBody><CFormLabel>Nominal</CFormLabel><CFormInput v-model.number="form.amount" type="number" min="1" class="mb-3" /><CFormLabel>Bank</CFormLabel><CFormInput v-model="form.bank_code" class="mb-2" /><CFormLabel>Nomor Rekening</CFormLabel><CFormInput v-model="form.account_number" class="mb-2" /><CFormLabel>Nama Pemilik</CFormLabel><CFormInput v-model="form.account_name" /></CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="submitWithdrawal">Proses</CButton></CModalFooter></CModal>
</template>
<style scoped>
.wallet-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 400px));
gap: 1rem;
justify-content: center;
}
.wallet-card {
display: flex;
width: 100%;
max-width: 400px;
min-height: 130px;
flex-direction: column;
justify-content: center;
padding: 1.25rem;
color: white;
text-align: left;
background: linear-gradient(135deg, #f97316, #ff8c00 58%, #f9b115);
border: 3px solid transparent;
border-radius: 1rem;
box-shadow: 0 0.5rem 1rem rgba(249, 115, 22, 0.22);
}
.wallet-card.active {
border-color: rgba(255, 255, 255, 0.85);
}
.wallet-card span,
.wallet-card small {
opacity: 0.82;
}
.wallet-card strong {
margin: 0.4rem 0;
font-size: 1.5rem;
}
.wallet-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 250px));
justify-content: center;
gap: 0.75rem;
max-width: 800px;
margin: 1.25rem auto 0;
}
.wallet-action {
display: flex;
min-height: 78px;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
color: var(--cui-body-color);
text-align: left;
background: var(--cui-body-bg);
border: 1px solid var(--cui-border-color);
border-radius: 0.85rem;
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.06);
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.wallet-action:not(:disabled):hover {
border-color: #ff8c00;
box-shadow: 0 0.4rem 1rem rgba(249, 115, 22, 0.14);
transform: translateY(-2px);
}
.wallet-action:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.wallet-action > span:last-child {
display: flex;
min-width: 0;
flex-direction: column;
}
.wallet-action strong {
font-size: 0.9rem;
}
.wallet-action small {
margin-top: 0.15rem;
color: var(--cui-secondary-color);
font-size: 0.7rem;
line-height: 1.25;
}
.wallet-action-icon {
display: inline-flex;
width: 44px;
height: 44px;
flex: 0 0 44px;
align-items: center;
justify-content: center;
color: white;
border-radius: 0.75rem;
}
.wallet-action-icon.topup {
background: linear-gradient(135deg, #f97316, #f9b115);
}
.wallet-action-icon.withdraw {
background: linear-gradient(135deg, #2eb85c, #1f9d55);
}
.wallet-action-icon.transfer {
background: linear-gradient(135deg, #3399ff, #5856d6);
}
@media (max-width: 575.98px) {
.wallet-action {
width: 100%;
}
}
.wallet-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),400px)); justify-content:center; gap:1rem; }
.wallet-card { display:flex; width:100%; max-width:400px; min-height:150px; flex-direction:column; justify-content:center; padding:1.25rem; color:white; text-align:left; background:linear-gradient(135deg,#f97316,#ff8c00 58%,#f9b115); border:3px solid transparent; border-radius:1rem; box-shadow:0 .5rem 1rem rgba(249,115,22,.22); }
.wallet-card.active { border-color:rgba(255,255,255,.88); }.wallet-card.credit { background:linear-gradient(135deg,#5856d6,#3399ff); box-shadow:0 .5rem 1rem rgba(51,153,255,.2); }
.wallet-card span,.wallet-card small { opacity:.84; }.wallet-card strong { margin:.4rem 0; font-size:1.5rem; }.usage-note { margin-top:.4rem; font-size:.68rem; }
.wallet-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,230px),300px)); justify-content:center; gap:.75rem; max-width:800px; margin:1.25rem auto 0; }
.wallet-action,.credit-information { display:flex; min-height:78px; align-items:center; gap:.75rem; padding:.75rem; text-align:left; border-radius:.85rem; }
.wallet-action { color:var(--cui-body-color); background:var(--cui-body-bg); border:1px solid var(--cui-border-color); box-shadow:0 .25rem .75rem rgba(0,0,0,.06); }.wallet-action:disabled { cursor:not-allowed; opacity:.55; }
.wallet-action>span:last-child,.credit-information span { display:flex; min-width:0; flex-direction:column; }.wallet-action small,.credit-information small { margin-top:.15rem; font-size:.7rem; line-height:1.25; }
.wallet-action-icon { display:inline-flex; width:44px; height:44px; flex:0 0 44px; align-items:center; justify-content:center; color:white; border-radius:.75rem; }.wallet-action-icon.topup { background:linear-gradient(135deg,#f97316,#f9b115); }.wallet-action-icon.withdraw { background:linear-gradient(135deg,#2eb85c,#1f9d55); }
.credit-information { color:var(--cui-info-text-emphasis); background:var(--cui-info-bg-subtle); border:1px solid var(--cui-info-border-subtle); }
</style>
File diff suppressed because one or more lines are too long