Giant Update
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-07-31 13:57:55 +07:00
parent 73a32380e7
commit 03fe4b1fd4
32 changed files with 3635 additions and 37 deletions
+551 -1
View File
@@ -21,6 +21,7 @@
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"maplibre-gl": "^3.6.2",
"pinia": "^3.0.4",
"simplebar-vue": "^2.4.2",
"sweetalert2": "^11.26.25",
@@ -527,6 +528,83 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
"license": "ISC",
"dependencies": {
"get-stream": "^6.0.1",
"minimist": "^1.2.6"
},
"bin": {
"geojson-rewind": "geojson-rewind"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
"integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
"integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~0.1.0"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "19.3.3",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz",
"integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^3.0.0",
"minimist": "^1.2.8",
"rw": "^1.3.3",
"sort-object": "^3.0.3"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -1163,6 +1241,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/jsesc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
@@ -1176,6 +1260,38 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mapbox__point-geometry": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
"integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==",
"license": "MIT"
},
"node_modules/@types/mapbox__vector-tile": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
"integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/mapbox__point-geometry": "*",
"@types/pbf": "*"
}
},
"node_modules/@types/pbf": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
"license": "MIT"
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
@@ -1427,6 +1543,24 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/arr-union": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
"integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/assign-symbols": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
"integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@@ -1631,6 +1765,25 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bytewise": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz",
"integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==",
"license": "MIT",
"dependencies": {
"bytewise-core": "^1.2.2",
"typewise": "^1.0.3"
}
},
"node_modules/bytewise-core": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz",
"integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==",
"license": "MIT",
"dependencies": {
"typewise-core": "^1.2"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1870,6 +2023,12 @@
"node": ">= 0.4"
}
},
"node_modules/earcut": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.376",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz",
@@ -2155,6 +2314,18 @@
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"license": "MIT"
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2317,6 +2488,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/geojson-vt": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz",
"integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==",
"license": "ISC"
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2354,6 +2531,33 @@
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-value": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
"integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2367,6 +2571,32 @@
"node": ">=10.13.0"
}
},
"node_modules/global-prefix": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz",
"integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==",
"license": "MIT",
"dependencies": {
"ini": "^1.3.5",
"kind-of": "^6.0.2",
"which": "^1.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/global-prefix/node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"which": "bin/which"
}
},
"node_modules/globals": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
@@ -2476,6 +2706,26 @@
"node": ">= 6"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2520,6 +2770,21 @@
"node": ">=0.8.19"
}
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2543,6 +2808,18 @@
"node": ">=0.10.0"
}
},
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
"license": "MIT",
"dependencies": {
"isobject": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@@ -2559,9 +2836,17 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-yaml": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
@@ -2618,6 +2903,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
"integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -2630,6 +2921,12 @@
"node": ">=6"
}
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2640,6 +2937,15 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -2992,6 +3298,46 @@
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/maplibre-gl": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-3.6.2.tgz",
"integrity": "sha512-krg2KFIdOpLPngONDhP6ixCoWl5kbdMINP0moMSJFVX7wX1Clm2M9hlNKXS8vBGlVWwR5R3ZfI6IPrYz7c+aCQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^0.1.0",
"@mapbox/tiny-sdf": "^2.0.6",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^1.3.1",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/maplibre-gl-style-spec": "^19.3.3",
"@types/geojson": "^7946.0.13",
"@types/mapbox__point-geometry": "^0.1.4",
"@types/mapbox__vector-tile": "^1.3.4",
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"earcut": "^2.2.4",
"geojson-vt": "^3.2.1",
"gl-matrix": "^3.4.3",
"global-prefix": "^3.0.0",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^3.2.1",
"potpack": "^2.0.0",
"quickselect": "^2.0.0",
"supercluster": "^8.0.1",
"tinyqueue": "^2.0.3",
"vt-pbf": "^3.1.3"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3035,6 +3381,15 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
@@ -3082,6 +3437,12 @@
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -3226,6 +3587,19 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pbf": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
"integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
"license": "BSD-3-Clause",
"dependencies": {
"ieee754": "^1.1.12",
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -3331,6 +3705,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -3341,6 +3721,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -3376,6 +3762,12 @@
],
"license": "MIT"
},
"node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
"license": "ISC"
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -3399,6 +3791,15 @@
"node": ">=4"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -3439,6 +3840,12 @@
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/sass": {
"version": "1.101.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz",
@@ -3479,6 +3886,21 @@
"node": ">=10"
}
},
"node_modules/set-value": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
"integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"is-extendable": "^0.1.1",
"is-plain-object": "^2.0.3",
"split-string": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3551,6 +3973,41 @@
}
}
},
"node_modules/sort-asc": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz",
"integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sort-desc": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz",
"integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sort-object": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz",
"integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==",
"license": "MIT",
"dependencies": {
"bytewise": "^1.1.0",
"get-value": "^2.0.2",
"is-extendable": "^0.1.1",
"sort-asc": "^0.2.0",
"sort-desc": "^0.2.0",
"union-value": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3569,6 +4026,43 @@
"node": ">=0.10.0"
}
},
"node_modules/split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
"integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split-string/node_modules/extend-shallow": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
"integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
"license": "MIT",
"dependencies": {
"assign-symbols": "^1.0.0",
"is-extendable": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split-string/node_modules/is-extendable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
"integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
"license": "MIT",
"dependencies": {
"is-plain-object": "^2.0.4"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -3582,6 +4076,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
@@ -3633,6 +4136,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyqueue": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz",
"integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==",
"license": "ISC"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3653,12 +4162,42 @@
"node": ">= 0.8.0"
}
},
"node_modules/typewise": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz",
"integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==",
"license": "MIT",
"dependencies": {
"typewise-core": "^1.2.0"
}
},
"node_modules/typewise-core": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz",
"integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==",
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"license": "MIT"
},
"node_modules/union-value": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
"integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
"license": "MIT",
"dependencies": {
"arr-union": "^3.1.0",
"get-value": "^2.0.6",
"is-extendable": "^0.1.1",
"set-value": "^2.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/unplugin": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
@@ -3815,6 +4354,17 @@
}
}
},
"node_modules/vt-pbf": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
"integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "0.1.0",
"@mapbox/vector-tile": "^1.3.1",
"pbf": "^3.2.1"
}
},
"node_modules/vue": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
+1
View File
@@ -30,6 +30,7 @@
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"chart.js": "^4.5.1",
"maplibre-gl": "^3.6.2",
"pinia": "^3.0.4",
"simplebar-vue": "^2.4.2",
"sweetalert2": "^11.26.25",
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#e9f2ff"/>
<stop offset="1" stop-color="#d7e4f5"/>
</linearGradient>
</defs>
<rect width="1200" height="675" fill="url(#bg)"/>
<path d="M350 360 600 155l250 205v185H350z" fill="#fff" stroke="#6f8fb8" stroke-width="18" stroke-linejoin="round"/>
<path d="M300 375 600 125l300 250" fill="none" stroke="#376da8" stroke-width="28" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="535" y="385" width="130" height="160" rx="8" fill="#9db8d8"/>
<rect x="405" y="385" width="90" height="80" rx="8" fill="#c9dcf0"/>
<rect x="705" y="385" width="90" height="80" rx="8" fill="#c9dcf0"/>
<text x="600" y="620" text-anchor="middle" font-family="Arial, sans-serif" font-size="38" fill="#557393">Dokumentasi Customer</text>
</svg>

After

Width:  |  Height:  |  Size: 915 B

+10
View File
@@ -43,7 +43,10 @@ import {
cilCode,
cilCommentSquare,
cilContrast,
cilCreditCard,
cilCursor,
cilDataTransferDown,
cilDataTransferUp,
cilDrop,
cilDollar,
cilEnvelopeClosed,
@@ -76,9 +79,11 @@ import {
cilStar,
cilSun,
cilTask,
cilTransfer,
cilUser,
cilUserFemale,
cilUserFollow,
cilWallet,
cilXCircle,
cilSquare, // new add
cilBarcode,
@@ -109,7 +114,10 @@ export const iconsSet = Object.assign(
cilCode,
cilCommentSquare,
cilContrast,
cilCreditCard,
cilCursor,
cilDataTransferDown,
cilDataTransferUp,
cilDrop,
cilDollar,
cilEnvelopeClosed,
@@ -142,9 +150,11 @@ export const iconsSet = Object.assign(
cilStar,
cilSun,
cilTask,
cilTransfer,
cilUser,
cilUserFemale,
cilUserFollow,
cilWallet,
cilXCircle,
cilSquare, // new add
cilBarcode,
+81 -13
View File
@@ -1,23 +1,68 @@
<script setup>
import { onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { cilList, cilViewModule } from '@coreui/icons'
import { useColorModes } from '@coreui/vue'
import AppBreadcrumb from '@/components/AppBreadcrumb.vue'
import AppHeaderDropdownAccnt from '@/components/AppHeaderDropdownAccnt.vue'
import { useSidebarStore } from '@/stores/sidebar.js'
import {
getResponsiveViewMode,
RESPONSIVE_VIEW_MODE_EVENT,
saveResponsiveViewMode,
} from '@/utils/responsiveViewMode.js'
import { getMyNotifications, markNotificationRead } from '@/services/notificationService.js'
const headerClassNames = ref('mb-2 p-0')
const { colorMode, setColorMode } = useColorModes('coreui-free-vue-admin-template-theme')
const sidebar = useSidebarStore()
const viewMode = ref(getResponsiveViewMode())
const appNotifications = ref([])
const unreadCount = computed(() => appNotifications.value.filter((item) => !item.read_at).length)
const handleScroll = () => {
headerClassNames.value =
document.documentElement.scrollTop > 0 ? 'mb-2 p-0 shadow-sm' : 'mb-2 p-0'
}
const syncViewMode = (event) => {
if (event.detail === 'card' || event.detail === 'table') {
viewMode.value = event.detail
}
}
const toggleViewMode = () => {
const nextMode = viewMode.value === 'card' ? 'table' : 'card'
viewMode.value = nextMode
saveResponsiveViewMode(nextMode)
}
const loadNotifications = async () => {
try {
const response = await getMyNotifications({ per_page: 8 })
appNotifications.value = response?.data?.data || []
} catch {
appNotifications.value = []
}
}
const openNotification = async (item) => {
if (!item.read_at) {
await markNotificationRead(item.uuid)
item.read_at = new Date().toISOString()
}
if (item.action_url) window.location.assign(item.action_url)
}
onMounted(() => {
document.addEventListener('scroll', () => {
if (document.documentElement.scrollTop > 0) {
headerClassNames.value = 'mb-2 p-0 shadow-sm'
} else {
headerClassNames.value = 'mb-2 p-0'
}
document.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener(RESPONSIVE_VIEW_MODE_EVENT, syncViewMode)
loadNotifications()
})
onBeforeUnmount(() => {
document.removeEventListener('scroll', handleScroll)
window.removeEventListener(RESPONSIVE_VIEW_MODE_EVENT, syncViewMode)
})
</script>
@@ -39,14 +84,28 @@ onMounted(() => {
</CNavItem>
</CHeaderNav>
<CHeaderNav class="ms-auto">
<CNavItem>
<CNavLink href="#">
<CDropdown variant="nav-item" placement="bottom-end">
<CDropdownToggle :caret="false" class="position-relative">
<CIcon icon="cil-bell" size="lg" />
</CNavLink>
</CNavItem>
<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>
</button>
<div v-if="!appNotifications.length" class="px-3 py-3 text-body-secondary text-center">Belum ada notifikasi</div>
</CDropdownMenu>
</CDropdown>
<CNavItem>
<CNavLink href="#">
<CIcon icon="cil-list" size="lg" />
<CNavLink
component="button"
type="button"
:aria-label="viewMode === 'card' ? 'Gunakan tampilan tabel' : 'Gunakan tampilan kartu'"
:title="viewMode === 'card' ? 'Tampilan tabel' : 'Tampilan kartu'"
@click="toggleViewMode"
>
<CIcon :icon="viewMode === 'card' ? cilList : cilViewModule" size="lg" />
</CNavLink>
</CNavItem>
<CNavItem>
@@ -106,3 +165,12 @@ onMounted(() => {
</CContainer> -->
</CHeader>
</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; }
</style>
+29
View File
@@ -5,6 +5,7 @@ import { RouterLink } from 'vue-router'
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 { useSidebarStore } from '@/stores/sidebar.js'
const sidebar = useSidebarStore()
@@ -46,9 +47,37 @@ const sidebar = useSidebarStore()
</RouterLink>
<CCloseButton class="d-lg-none" dark @click="sidebar.toggleVisible()" />
</CSidebarHeader>
<div class="sidebar-wallet-slot">
<WalletHeaderWidget />
</div>
<AppSidebarNav />
<CSidebarFooter class="border-top d-none d-lg-flex">
<CSidebarToggler @click="sidebar.toggleUnfoldable()" />
</CSidebarFooter>
</CSidebar>
</template>
<style scoped>
.sidebar-wallet-slot {
padding: 0.7rem 0.75rem 0.35rem;
}
.sidebar-wallet-slot :deep(.wallet-header) {
width: 100%;
}
:global(.sidebar-narrow-unfoldable:not(:hover)) .sidebar-wallet-slot {
padding-inline: 0.45rem;
}
:global(.sidebar-narrow-unfoldable:not(:hover)) .sidebar-wallet-slot :deep(.wallet-header) {
min-width: 0;
justify-content: center;
padding-inline: 0.35rem;
}
:global(.sidebar-narrow-unfoldable:not(:hover)) .sidebar-wallet-slot :deep(.wallet-header span),
:global(.sidebar-narrow-unfoldable:not(:hover)) .sidebar-wallet-slot :deep(.wallet-header i) {
display: none;
}
</style>
+2
View File
@@ -26,6 +26,8 @@ const isActiveLink = (route, link) => {
const targetPath = normalizePath(link)
const activeMenuPath = route.meta?.activeMenu
? normalizePath(route.meta.activeMenu)
: route.meta?.activeMenuBase && route.params?.scope
? normalizePath(`${route.meta.activeMenuBase}/${route.params.scope}`)
: null
return currentPath === targetPath || activeMenuPath === targetPath
+38
View File
@@ -0,0 +1,38 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getMyWallets } from '@/services/walletService'
const router = useRouter()
const wallets = ref([])
const activeIndex = ref(0)
let timer
const active = computed(() => wallets.value[activeIndex.value] || null)
const money = (value) => `Rp ${Number(value || 0).toLocaleString('id-ID')}`
onMounted(async () => {
try { wallets.value = (await getMyWallets())?.data || [] } catch { wallets.value = [] }
timer = window.setInterval(() => {
if (wallets.value.length > 1) activeIndex.value = (activeIndex.value + 1) % wallets.value.length
}, 5000)
})
onBeforeUnmount(() => window.clearInterval(timer))
</script>
<template>
<button type="button" class="wallet-header" title="Buka dompet" @click="router.push('/wallet')">
<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>
<i v-if="wallets.length > 1">{{ activeIndex + 1 }}/{{ wallets.length }}</i>
</button>
</template>
<style scoped>
.wallet-header { display: flex; min-width: 9.5rem; min-height: 3.35rem; align-items: center; gap: .65rem; padding: .5rem .7rem; color: #fff; text-align: left; background: linear-gradient(135deg, #f97316, #ff8c00 58%, #f9b115); border: 1px solid rgba(255, 255, 255, .24); border-radius: .75rem; box-shadow: 0 .35rem .8rem rgba(249, 115, 22, .24); }
.wallet-header span { display: flex; min-width: 0; flex: 1; flex-direction: column; line-height: 1.15; }
.wallet-header small { color: rgba(255, 255, 255, .72); font-size: .65rem; }
.wallet-header strong { overflow: hidden; font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
.wallet-header i { color: rgba(255, 255, 255, .7); font-size: .6rem; font-style: normal; }
.wallet-header:hover { filter: brightness(1.06); }
</style>
+135 -16
View File
@@ -27,15 +27,41 @@
aria-label="Search"
/>
</div>
<div v-if="filterFields.length" class="brdvx-filter-wrap">
<CButton
style="zoom: 90%"
size="sm"
color="secondary"
variant="outline"
class="view-toggle-btn"
@click="setViewMode(viewMode === 'card' ? 'table' : 'card')"
class="brdvx-filter-button"
:class="{ active: activeFilterCount > 0 }"
@click="filterOpen = !filterOpen"
>
<CIcon :icon="viewMode === 'card' ? cilList : cilViewModule" />
Filter<span v-if="activeFilterCount"> ({{ activeFilterCount }})</span>
</CButton>
<div v-if="filterOpen" class="brdvx-filter-panel">
<div class="brdvx-filter-panel-head">
<strong>Filter Data</strong>
<button type="button" aria-label="Tutup filter" @click="filterOpen = false">&times;</button>
</div>
<div class="brdvx-filter-fields">
<div v-for="field in filterFields" :key="field.key">
<CFormLabel>{{ field.label }}</CFormLabel>
<CFormSelect v-if="field.type === 'select'" v-model="filterValues[field.key]" size="sm">
<option value="">{{ field.placeholder || 'Semua' }}</option>
<option v-for="option in field.options || []" :key="filterOptionValue(option)" :value="filterOptionValue(option)">
{{ filterOptionLabel(option) }}
</option>
</CFormSelect>
<CFormInput v-else v-model="filterValues[field.key]" :type="field.type || 'text'" size="sm" :placeholder="field.placeholder || ''" />
</div>
</div>
<div class="brdvx-filter-actions">
<CButton color="secondary" variant="outline" size="sm" @click="resetFilters">Reset</CButton>
<CButton color="primary" size="sm" @click="applyFilters">Terapkan</CButton>
</div>
</div>
</div>
</div>
</div>
@@ -58,6 +84,8 @@
:src="item.image"
:alt="item.name || `Item ${rowIndex + 1}`"
class="mobile-image"
loading="lazy"
decoding="async"
@error="onImageError($event, item)"
/>
</div>
@@ -137,6 +165,8 @@
:src="item.image"
:alt="item.name || 'avatar'"
class="table-list-mobile-image"
loading="lazy"
decoding="async"
@error="onImageError($event, item)"
/>
</div>
@@ -174,6 +204,8 @@
:src="item.image"
:alt="item.name || 'avatar'"
class="table-avatar"
loading="lazy"
decoding="async"
@error="onImageError($event, item)"
/>
</template>
@@ -244,16 +276,14 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, 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'
}
import { cilCaretBottom, cilCaretTop } from '@coreui/icons'
import {
getResponsiveViewMode,
RESPONSIVE_VIEW_MODE_EVENT,
} from '@/utils/responsiveViewMode.js'
const props = defineProps({
columns: {
@@ -316,6 +346,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
filterFields: {
type: Array,
default: () => [],
},
initialFilters: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['action-click', 'row-click', 'query-change'])
@@ -324,8 +362,13 @@ const router = useRouter()
const width = ref(window.innerWidth)
const isMobile = computed(() => width.value < props.mobileBreakpoint)
const viewMode = ref(getStoredViewMode())
const viewMode = ref(getResponsiveViewMode())
const searchQuery = ref('')
const filterOpen = ref(false)
const filterValues = reactive({ ...props.initialFilters })
const activeFilterCount = computed(() =>
Object.values(filterValues).filter((value) => value !== '' && value !== null && value !== undefined).length,
)
const perPageOptions = [10, 20, 50, 100]
const perPage = ref(10)
const currentPage = ref(1)
@@ -466,11 +509,25 @@ const onActionClick = (actionKey, item) => {
emit('action-click', { action: actionKey, item })
}
const setViewMode = (mode) => {
if (mode !== 'table' && mode !== 'card') return
const syncViewMode = (event) => {
if (event.detail === 'table' || event.detail === 'card') {
viewMode.value = event.detail
}
}
viewMode.value = mode
localStorage.setItem(VIEW_MODE_STORAGE_KEY, mode)
const filterOptionValue = (option) => Array.isArray(option) ? option[0] : option.value
const filterOptionLabel = (option) => Array.isArray(option) ? option[1] : option.label
const applyFilters = () => {
currentPage.value = 1
filterOpen.value = false
emitQueryChange()
}
const resetFilters = () => {
Object.keys(filterValues).forEach((key) => { filterValues[key] = '' })
props.filterFields.forEach((field) => {
if (!Object.prototype.hasOwnProperty.call(filterValues, field.key)) filterValues[field.key] = ''
})
applyFilters()
}
const onPerPageChange = (event) => {
@@ -510,6 +567,9 @@ const emitQueryChange = () => {
search: searchQuery.value.trim() || undefined,
sort_by: sortBy.value || undefined,
sort_direction: sortBy.value ? sortDirection.value : undefined,
...Object.fromEntries(
Object.entries(filterValues).map(([key, value]) => [key, value === '' ? undefined : value]),
),
})
}
@@ -577,12 +637,71 @@ watch(
onMounted(() => {
window.addEventListener('resize', onResize)
window.addEventListener('scroll', handleInfiniteScroll, { passive: true })
window.addEventListener(RESPONSIVE_VIEW_MODE_EVENT, syncViewMode)
})
onBeforeUnmount(() => {
window.clearTimeout(searchTimer)
window.removeEventListener('resize', onResize)
window.removeEventListener('scroll', handleInfiniteScroll)
window.removeEventListener(RESPONSIVE_VIEW_MODE_EVENT, syncViewMode)
})
</script>
<style scoped>
.brdvx-filter-wrap {
position: relative;
}
.brdvx-filter-button.active {
background-color: var(--cui-light-bg);
color: var(--cui-secondary-color);
border-color: var(--cui-secondary-color);
}
.brdvx-filter-panel {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
z-index: 1050;
width: min(22rem, calc(100vw - 2rem));
padding: 0.85rem;
border: 1px solid var(--cui-border-color);
border-radius: 0.75rem;
background: var(--cui-body-bg);
box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.16);
}
.brdvx-filter-panel-head,
.brdvx-filter-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.brdvx-filter-panel-head button {
padding: 0;
color: var(--cui-secondary-color);
font-size: 1.35rem;
line-height: 1;
background: transparent;
border: 0;
}
.brdvx-filter-fields {
display: grid;
gap: 0.7rem;
margin: 0.8rem 0;
}
.brdvx-filter-fields :deep(.form-label) {
margin-bottom: 0.25rem;
font-size: 0.8rem;
}
.brdvx-filter-actions {
justify-content: flex-end;
}
</style>
@@ -0,0 +1,161 @@
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
const props = defineProps({
latitude: { type: [String, Number], default: '' },
longitude: { type: [String, Number], default: '' },
readonly: { type: Boolean, default: false },
})
const emit = defineEmits(['update:latitude', 'update:longitude'])
const mapElement = ref(null)
let map = null
let marker = null
let resizeObserver = null
const hasCoordinates = () =>
Number.isFinite(Number(props.latitude)) &&
Number.isFinite(Number(props.longitude)) &&
props.latitude !== '' &&
props.longitude !== ''
function coordinates() {
return hasCoordinates()
? [Number(props.longitude), Number(props.latitude)]
: [118, -2.5]
}
function emitCoordinates(lngLat) {
emit('update:latitude', Number(lngLat.lat).toFixed(7))
emit('update:longitude', Number(lngLat.lng).toFixed(7))
}
function placeMarker(lngLat, center = false) {
if (!map) return
if (!marker) {
marker = new maplibregl.Marker({ color: '#2f74c0', draggable: !props.readonly })
.setLngLat(lngLat)
.addTo(map)
if (!props.readonly) marker.on('dragend', () => emitCoordinates(marker.getLngLat()))
} else {
marker.setLngLat(lngLat)
}
if (center) map.easeTo({ center: lngLat, zoom: Math.max(map.getZoom(), 15) })
}
async function initializeMap() {
await nextTick()
if (!mapElement.value || map) return
map = new maplibregl.Map({
container: mapElement.value,
center: coordinates(),
zoom: hasCoordinates() ? 16 : 4,
style: {
version: 8,
sources: {
satellite: {
type: 'raster',
tiles: [
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
maxzoom: 19,
attribution: 'Tiles &copy; Esri and imagery providers',
},
transportation: {
type: 'raster',
tiles: [
'https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
maxzoom: 19,
},
labels: {
type: 'raster',
tiles: [
'https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
maxzoom: 19,
},
},
layers: [
{ id: 'satellite', type: 'raster', source: 'satellite' },
{ id: 'transportation', type: 'raster', source: 'transportation' },
{ id: 'labels', type: 'raster', source: 'labels' },
],
},
})
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
if (!props.readonly) {
map.on('click', (event) => {
placeMarker(event.lngLat)
emitCoordinates(event.lngLat)
})
}
if (hasCoordinates()) placeMarker(coordinates())
resizeObserver = new ResizeObserver(() => map?.resize())
resizeObserver.observe(mapElement.value)
}
watch(
() => [props.latitude, props.longitude],
() => {
if (hasCoordinates()) placeMarker(coordinates(), true)
},
)
onMounted(initializeMap)
onBeforeUnmount(() => {
resizeObserver?.disconnect()
marker?.remove()
map?.remove()
marker = null
map = null
})
</script>
<template>
<div class="customer-location-map">
<div ref="mapElement" class="map-canvas"></div>
<div v-if="!readonly" class="map-help">Klik peta atau geser marker untuk menentukan lokasi pemasangan.</div>
</div>
</template>
<style scoped>
.customer-location-map {
position: relative;
overflow: hidden;
border-radius: 0.75rem 0.75rem 0 0;
}
.map-canvas {
width: 100%;
min-height: 20rem;
}
.map-help {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
left: 0.75rem;
z-index: 2;
max-width: max-content;
padding: 0.4rem 0.65rem;
color: #fff;
font-size: 0.75rem;
border-radius: 0.45rem;
background: rgba(20, 30, 45, 0.78);
pointer-events: none;
}
@media (max-width: 575.98px) {
.map-canvas {
min-height: 17rem;
}
}
</style>
+149
View File
@@ -394,6 +394,155 @@ const routes = [
component: () => import('@/views/nas/NasResourceView.vue'),
meta: { nasResource: 'webfig' },
},
{
path: '/customers/orders',
name: 'Customer List Order',
component: () => import('@/views/customers/Customers.vue'),
meta: { customerScope: 'orders' },
},
{
path: '/customers/orders/create',
name: 'Registrasi Customer',
component: () => import('@/views/customers/CustomerFormPage.vue'),
meta: { activeMenu: '/customers/orders' },
},
{
path: '/customers/orders/:id/edit',
name: 'Edit Customer',
component: () => import('@/views/customers/CustomerFormPage.vue'),
meta: { activeMenu: '/customers/orders' },
},
{
path: '/customers/:scope/:id/detail',
name: 'Detail Customer',
component: () => import('@/views/customers/CustomerDetailPage.vue'),
meta: { activeMenuBase: '/customers' },
},
{
path: '/customers/active',
name: 'Customer Aktif',
component: () => import('@/views/customers/Customers.vue'),
meta: { customerScope: 'active' },
},
{
path: '/customers/inactive',
name: 'Customer Tidak Aktif',
component: () => import('@/views/customers/Customers.vue'),
meta: { customerScope: 'inactive' },
},
{
path: '/customers/unmanaged',
name: 'Customer Unmanage',
component: () => import('@/views/customers/Customers.vue'),
meta: { customerScope: 'unmanaged' },
},
{
path: '/customers/trash',
name: 'Sampah Customer',
component: () => import('@/views/customers/Customers.vue'),
meta: { customerScope: 'trash' },
},
{
path: '/billing/profiles',
name: 'Profile Tagihan',
component: () => import('@/views/billing/BillingProfiles.vue'),
},
{
path: '/billing/running',
name: 'Tagihan Berjalan',
component: () => import('@/views/billing/Invoices.vue'),
meta: { invoiceScope: 'running' },
},
{
path: '/billing/overdue',
name: 'Tunggakan',
component: () => import('@/views/billing/Invoices.vue'),
meta: { invoiceScope: 'overdue' },
},
{
path: '/billing/paid',
name: 'Tagihan Lunas',
component: () => import('@/views/billing/Invoices.vue'),
meta: { invoiceScope: 'paid' },
},
{
path: '/topology/map',
name: 'Peta Jaringan',
component: () => import('@/views/topology/TopologyMap.vue'),
},
{
path: '/topology/devices',
name: 'Perangkat Topologi',
component: () => import('@/views/topology/TopologyResourceView.vue'),
meta: { topologyResource: 'node' },
},
{
path: '/topology/links',
name: 'Jalur Kabel',
component: () => import('@/views/topology/TopologyResourceView.vue'),
meta: { topologyResource: 'link' },
},
{
path: '/topology/network',
name: 'Topologi Jaringan',
component: () => import('@/views/topology/TopologyNetwork.vue'),
},
{
path: '/topology/device-types',
name: 'Kategori Perangkat',
component: () => import('@/views/topology/TopologyResourceView.vue'),
meta: { topologyResource: 'device-type' },
},
{
path: '/notifications-service/broadcasts',
name: 'Pesan Siaran',
component: () => import('@/views/notifications-service/NotificationBroadcasts.vue'),
},
{
path: '/notifications-service/system',
name: 'Notifikasi Aplikasi',
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
meta: { notificationChannel: 'system' },
},
{
path: '/notifications-service/whatsapp-official',
name: 'WhatsApp Official',
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
meta: { notificationChannel: 'whatsapp_official' },
},
{
path: '/notifications-service/whatsapp-unofficial',
name: 'WhatsApp Unofficial',
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
meta: { notificationChannel: 'whatsapp_unofficial' },
},
{
path: '/notifications-service/email',
name: 'Email Notifikasi',
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
meta: { notificationChannel: 'email' },
},
{
path: '/notifications-service/telegram',
name: 'Telegram Notifikasi',
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
meta: { notificationChannel: 'telegram' },
},
{
path: '/payment-gateway/settings',
name: 'Setting Payment Gateway',
component: () => import('@/views/payment-gateway/PaymentGatewaySettings.vue'),
},
{
path: '/payment-gateway/logs',
name: 'Log Pembayaran',
component: () => import('@/views/payment-gateway/PaymentLogs.vue'),
},
{
path: '/wallet',
name: 'Dompet',
component: () => import('@/views/wallet/WalletDashboard.vue'),
},
{
path: '/users',
name: 'Users',
+14
View File
@@ -0,0 +1,14 @@
import api from '@/api/axios'
export async function getBillingProfiles(params = {}) { return (await api.get('/billing/profiles', { params })).data }
export async function getBillingProfile(id) { return (await api.get(`/billing/profiles/${id}`)).data }
export async function createBillingProfile(payload) { return (await api.post('/billing/profiles', payload)).data }
export async function updateBillingProfile(id, payload) { return (await api.put(`/billing/profiles/${id}`, payload)).data }
export async function deleteBillingProfile(id) { return (await api.delete(`/billing/profiles/${id}`)).data }
export async function getInvoices(scope, params = {}) { return (await api.get(`/billing/invoices/${scope}`, { params })).data }
export async function getInvoice(scope, id) { return (await api.get(`/billing/invoices/${scope}/${id}`)).data }
export async function getInvoiceOptions() { return (await api.get('/billing/invoice-options')).data }
export async function createInvoice(payload) { return (await api.post('/billing/invoices', payload)).data }
export async function updateInvoice(id, payload) { return (await api.put(`/billing/invoices/${id}`, payload)).data }
export async function payInvoice(scope, id, payload) { return (await api.post(`/billing/invoices/${scope}/${id}/pay`, payload)).data }
export async function cancelInvoice(id) { return (await api.post(`/billing/invoices/${id}/cancel`)).data }
+42
View File
@@ -0,0 +1,42 @@
import api from '@/api/axios'
export async function getCustomers(scope, params = {}) {
const { data } = await api.get(`/customers/${scope}`, { params })
return data
}
export async function getCustomerOptions() {
const { data } = await api.get('/customers/options')
return data
}
export async function getCustomer(scope, id) {
const { data } = await api.get(`/customers/${scope}/${id}`)
return data
}
export async function createCustomer(payload) {
const { data } = await api.post('/customers', payload)
return data
}
export async function updateCustomer(id, payload) {
const { data } = await api.put(`/customers/${id}`, payload)
return data
}
export async function activateCustomer(id, payload) {
const { data } = await api.post(`/customers/${id}/activate`, payload)
return data
}
export async function deactivateCustomer(id) {
const { data } = await api.post(`/customers/${id}/deactivate`)
return data
}
export async function trashCustomer(id) {
const { data } = await api.delete(`/customers/${id}`)
return data
}
export async function restoreCustomer(id) {
const { data } = await api.post(`/customers/${id}/restore`)
return data
}
export async function forceDeleteCustomer(id) {
const { data } = await api.delete(`/customers/${id}/force`)
return data
}
+75
View File
@@ -0,0 +1,75 @@
import api from '@/api/axios'
const channelPaths = {
system: 'system',
whatsapp_official: 'whatsapp-official',
whatsapp_unofficial: 'whatsapp-unofficial',
email: 'email',
telegram: 'telegram',
}
const channelBase = (channel) => `/notifications/channels/${channelPaths[channel]}`
export async function getNotificationEvents() {
return (await api.get('/notifications/events')).data
}
export async function getNotificationChannels(channel) {
return (await api.get(`${channelBase(channel)}/settings`)).data
}
export async function createNotificationChannel(channel, payload) {
return (await api.post(`${channelBase(channel)}/settings`, payload)).data
}
export async function updateNotificationChannel(channel, id, payload) {
return (await api.put(`${channelBase(channel)}/settings/${id}`, payload)).data
}
export async function testNotificationChannel(channel, id) {
return (await api.post(`${channelBase(channel)}/settings/${id}/test`)).data
}
export async function getNotificationTemplates(channel) {
return (await api.get(`${channelBase(channel)}/templates`)).data
}
export async function createNotificationTemplate(channel, payload) {
return (await api.post(`${channelBase(channel)}/templates`, payload)).data
}
export async function updateNotificationTemplate(channel, id, payload) {
return (await api.put(`${channelBase(channel)}/templates/${id}`, payload)).data
}
export async function getNotificationLogs(channel, params = {}) {
return (await api.get(`${channelBase(channel)}/logs`, { params })).data
}
export async function getNotificationPreferences(params = {}) {
return (await api.get('/notifications/preferences', { params })).data
}
export async function saveNotificationPreferences(preferences) {
return (await api.put('/notifications/preferences', { preferences })).data
}
export async function getBroadcasts(params = {}) {
return (await api.get('/notifications/broadcasts', { params })).data
}
export async function previewBroadcast(payload) {
return (await api.post('/notifications/broadcasts/preview', payload)).data
}
export async function createBroadcast(payload) {
return (await api.post('/notifications/broadcasts', payload)).data
}
export async function approveBroadcast(id) {
return (await api.post(`/notifications/broadcasts/${id}/approve`)).data
}
export async function getMyNotifications(params = {}) {
return (await api.get('/me/notifications', { params })).data
}
export async function markNotificationRead(uuid) {
return (await api.put(`/me/notifications/${uuid}/read`)).data
}
export async function getCustomerNotificationContacts(customerId) {
return (await api.get(`/customers/${customerId}/notification-contacts`)).data
}
export async function createCustomerNotificationContact(customerId, payload) {
return (await api.post(`/customers/${customerId}/notification-contacts`, payload)).data
}
export async function updateCustomerNotificationContact(customerId, id, payload) {
return (await api.put(`/customers/${customerId}/notification-contacts/${id}`, payload)).data
}
export async function deleteCustomerNotificationContact(customerId, id) {
return (await api.delete(`/customers/${customerId}/notification-contacts/${id}`)).data
}
+8
View File
@@ -0,0 +1,8 @@
import api from '@/api/axios'
export const getPaymentProviders = async () => (await api.get('/payment-gateway/providers')).data
export const getPaymentSettings = async () => (await api.get('/payment-gateway/settings')).data
export const createGatewayAccount = async (payload) => (await api.post('/payment-gateway/accounts', payload)).data
export const updateGatewayAccount = async (id, payload) => (await api.put(`/payment-gateway/accounts/${id}`, payload)).data
export const saveTenantPaymentSetting = async (payload) => (await api.put('/payment-gateway/tenant-setting', payload)).data
export const getPaymentTransactions = async (params = {}) => (await api.get('/payment-gateway/transactions', { params })).data
+60
View File
@@ -0,0 +1,60 @@
import api from '@/api/axios'
const paths = {
'device-type': 'device-types',
node: 'nodes',
link: 'links',
}
const endpoint = (resource, id = null) => {
const base = `/topology/${paths[resource]}`
return id ? `${base}/${id}` : base
}
export async function getTopologyResources(resource, params = {}) {
return (await api.get(endpoint(resource), { params })).data
}
export async function getTopologyResource(resource, id) {
return (await api.get(endpoint(resource, id))).data
}
export async function createTopologyResource(resource, payload) {
return (await api.post(endpoint(resource), payload)).data
}
export async function updateTopologyResource(resource, id, payload) {
return (await api.put(endpoint(resource, id), payload)).data
}
export async function deleteTopologyResource(resource, id) {
return (await api.delete(endpoint(resource, id))).data
}
export async function getTopologyOptions() {
return (await api.get('/topology/options')).data
}
export async function getTopologyMap(params = {}) {
return (await api.get('/topology/map', { params })).data
}
export async function getNodePorts(nodeId) {
return (await api.get(`/topology/nodes/${nodeId}/ports`)).data
}
export async function createNodePort(nodeId, payload) {
return (await api.post(`/topology/nodes/${nodeId}/ports`, payload)).data
}
export async function updateNodePort(id, payload) {
return (await api.put(`/topology/ports/${id}`, payload)).data
}
export async function deleteNodePort(id) {
return (await api.delete(`/topology/ports/${id}`)).data
}
export async function traceTopology(nodeId, direction = 'downstream') {
return (await api.get(`/topology/nodes/${nodeId}/${direction}`)).data
}
+6
View File
@@ -0,0 +1,6 @@
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
+48
View File
@@ -0,0 +1,48 @@
const MAX_CUSTOMER_IMAGE_BYTES = 500 * 1024
function canvasBlob(canvas, type, quality) {
return new Promise((resolve) => canvas.toBlob(resolve, type, quality))
}
export async function compressCustomerImage(file) {
if (!file.type.startsWith('image/')) throw new Error('File harus berupa gambar.')
const bitmap = await createImageBitmap(file)
const maxDimension = 1920
let scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height))
let width = Math.max(1, Math.round(bitmap.width * scale))
let height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d', { alpha: false })
let blob = null
let quality = 0.86
for (let attempt = 0; attempt < 10; attempt += 1) {
canvas.width = width
canvas.height = height
context.fillStyle = '#ffffff'
context.fillRect(0, 0, width, height)
context.drawImage(bitmap, 0, 0, width, height)
blob = await canvasBlob(canvas, 'image/webp', quality)
if (blob && blob.size <= MAX_CUSTOMER_IMAGE_BYTES) break
if (quality > 0.58) quality -= 0.08
else {
width = Math.max(640, Math.round(width * 0.82))
height = Math.max(360, Math.round(height * 0.82))
}
}
bitmap.close()
if (!blob || blob.size > MAX_CUSTOMER_IMAGE_BYTES) {
throw new Error('Gambar tidak dapat dikompresi hingga di bawah 500 KB.')
}
const baseName = file.name.replace(/\.[^.]+$/, '')
return new File([blob], `${baseName}.webp`, {
type: 'image/webp',
lastModified: Date.now(),
})
}
export { MAX_CUSTOMER_IMAGE_BYTES }
+18
View File
@@ -0,0 +1,18 @@
export const RESPONSIVE_VIEW_MODE_STORAGE_KEY = 'manja-responsive-data-view-mode'
export const RESPONSIVE_VIEW_MODE_EVENT = 'manja-responsive-data-view-mode-change'
export const getResponsiveViewMode = () => {
const storedMode = localStorage.getItem(RESPONSIVE_VIEW_MODE_STORAGE_KEY)
return storedMode === 'card' || storedMode === 'table' ? storedMode : 'table'
}
export const saveResponsiveViewMode = (mode) => {
if (mode !== 'card' && mode !== 'table') return
localStorage.setItem(RESPONSIVE_VIEW_MODE_STORAGE_KEY, mode)
window.dispatchEvent(
new CustomEvent(RESPONSIVE_VIEW_MODE_EVENT, {
detail: mode,
}),
)
}
+109
View File
@@ -0,0 +1,109 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { useAuthStore } from '@/stores/auth'
import { getTenants } from '@/services/tenantService'
import { createBillingProfile, deleteBillingProfile, getBillingProfiles, updateBillingProfile } from '@/services/billingService'
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
const authStore = useAuthStore()
const loading = ref(true), loadedOnce = ref(false), saving = ref(false)
const items = ref([]), tenants = ref([]), modal = ref(false), editingId = ref(null)
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
const form = reactive({})
const columns = [
{ key: 'name', label: 'Nama Profile' },
{ key: 'type_label', label: 'Tipe Jadwal', sortable: false },
{ key: 'schedule_label', label: 'Jadwal', sortable: false },
{ key: 'tax_label', label: 'PPN', sortable: false },
{ key: 'status_label', label: 'Status', sortKey: 'status' },
]
const actions = [
{ key: 'edit', label: 'Edit', color: 'success' },
{ key: 'delete', label: 'Hapus', color: 'danger' },
]
function defaults() {
return {
tenant_id: '', name: '', schedule_type: 'fixed_date',
invoice_day: 1, send_day: 1, warning_day: 10, isolation_day: 15,
invoice_days_before: 7, send_days_before: 5, warning_days_before: 0,
isolation_days_after: 3, tax_type: 'non_tax', tax_rate: 0, status: 'active', notes: '',
}
}
function normalize(item) {
const taxes = { inclusive: 'Include PPN', exclusive: 'Exclude PPN', non_tax: 'Non PPN' }
const installationSchedule = item.schedule_type === 'installation_date'
return {
...item,
type_label: installationSchedule ? 'Tanggal pemasangan' : 'Tanggal tetap',
schedule_label: installationSchedule
? `Buat H-${item.invoice_days_before} · Kirim H-${item.send_days_before} · Peringatan ${Number(item.warning_days_before) === 0 ? 'H' : `H-${item.warning_days_before}`} · Isolir H+${item.isolation_days_after}`
: `Buat ${item.invoice_day} · Kirim ${item.send_day} · Peringatan ${item.warning_day} · Isolir ${item.isolation_day}`,
tax_label: `${taxes[item.tax_type] || item.tax_type}${item.tax_type !== 'non_tax' ? ` (${item.tax_rate}%)` : ''}`,
status_label: item.status === 'active' ? 'Aktif' : 'Nonaktif',
}
}
async function fetchData(params = query.value) {
query.value = { ...query.value, ...params }; loading.value = true
try {
const response = await getBillingProfiles(query.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 profile tagihan.') }
finally { loading.value = false; loadedOnce.value = true }
}
async function openForm(item = null) {
editingId.value = item?.id || null; Object.keys(form).forEach((key) => delete form[key]); Object.assign(form, defaults(), item || {})
if (isMaster.value && !tenants.value.length) {
const response = await getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }); tenants.value = response?.data?.data || []
}
modal.value = true
}
async function save() {
if (!form.name?.trim()) return showError('Nama profile wajib diisi.')
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
saving.value = true
try {
if (editingId.value) await updateBillingProfile(editingId.value, form); else await createBillingProfile(form)
modal.value = false; showSuccess('Profile tagihan berhasil disimpan.'); await fetchData()
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan profile tagihan.') }
finally { saving.value = false }
}
async function remove(item) {
const result = await showConfirm('Hapus Profile', `Hapus profile "${item.name}"?`); if (!result.isConfirmed) return
try { await deleteBillingProfile(item.id); showSuccess('Profile berhasil dihapus.'); await fetchData() }
catch (error) { showError(error?.response?.data?.message || 'Gagal menghapus profile.') }
}
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"><div><h5 class="mb-0 fw-semibold">Profile Tagihan</h5><small class="text-body-secondary">Aturan jadwal tagihan bulanan customer</small></div><CButton color="primary" size="sm" @click="openForm()">+ Tambah Profile</CButton></div>
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
<BaseResponsiveDataView v-else :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" @query-change="fetchData" @action-click="({ action, item }) => action === 'edit' ? openForm(item) : remove(item)" />
</div></CCol></CRow>
<CModal :visible="modal" @close="modal = false" size="lg"><CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editingId ? 'Edit' : 'Tambah' }} Profile Tagihan</CModalTitle></CModalHeader><CModalBody><CRow class="g-3">
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="x in tenants" :key="x.id" :value="x.id">{{ x.tenant_name }}</option></CFormSelect></CCol>
<CCol :md="6"><CFormLabel>Nama Profile</CFormLabel><CFormInput v-model="form.name" /></CCol>
<CCol :md="6"><CFormLabel>Tipe Jadwal</CFormLabel><CFormSelect v-model="form.schedule_type"><option value="fixed_date">Tanggal tetap setiap bulan</option><option value="installation_date">Mengikuti tanggal pemasangan</option></CFormSelect></CCol>
<template v-if="form.schedule_type === 'fixed_date'">
<CCol :md="3"><CFormLabel>Tanggal Buat</CFormLabel><CFormInput v-model="form.invoice_day" type="number" min="1" max="28" /></CCol>
<CCol :md="3"><CFormLabel>Tanggal Kirim</CFormLabel><CFormInput v-model="form.send_day" type="number" min="1" max="28" /></CCol>
<CCol :md="3"><CFormLabel>Tanggal Peringatan</CFormLabel><CFormInput v-model="form.warning_day" type="number" min="1" max="28" /></CCol>
<CCol :md="3"><CFormLabel>Tanggal Isolir</CFormLabel><CFormInput v-model="form.isolation_day" type="number" min="1" max="28" /></CCol>
</template>
<template v-else>
<CCol :xs="12"><CAlert color="info" class="mb-0">Tanggal pemasangan customer menjadi hari acuan (H). Isi jumlah hari sebelum atau sesudah tanggal tersebut.</CAlert></CCol>
<CCol :md="3"><CFormLabel>Buat Tagihan (H-)</CFormLabel><CFormInput v-model="form.invoice_days_before" type="number" min="0" max="90" /></CCol>
<CCol :md="3"><CFormLabel>Kirim Tagihan (H-)</CFormLabel><CFormInput v-model="form.send_days_before" type="number" min="0" max="90" /></CCol>
<CCol :md="3"><CFormLabel>Peringatan (H-)</CFormLabel><CFormInput v-model="form.warning_days_before" type="number" min="0" max="90" /><CFormText>Isi 0 agar tepat pada tanggal pemasangan.</CFormText></CCol>
<CCol :md="3"><CFormLabel>Isolir (H+)</CFormLabel><CFormInput v-model="form.isolation_days_after" type="number" min="0" max="90" /></CCol>
</template>
<CCol :md="4"><CFormLabel>Jenis PPN</CFormLabel><CFormSelect v-model="form.tax_type"><option value="inclusive">Include PPN</option><option value="exclusive">Exclude PPN</option><option value="non_tax">Non PPN</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Persentase PPN</CFormLabel><CFormInput v-model="form.tax_rate" type="number" min="0" max="100" :disabled="form.tax_type === 'non_tax'" /></CCol>
<CCol :md="4"><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">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter></CModal>
</template>
+176
View File
@@ -0,0 +1,176 @@
<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 { cancelInvoice, createInvoice, getInvoice, getInvoiceOptions, getInvoices, payInvoice, updateInvoice } from '@/services/billingService'
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
const route = useRoute(), authStore = useAuthStore()
const scope = computed(() => route.meta.invoiceScope)
const config = computed(() => ({
running: ['Tagihan Berjalan', 'Tagihan dari proses dibuat sampai peringatan'],
overdue: ['Tunggakan', 'Tagihan melewati tanggal isolir dan belum lunas'],
paid: ['Tagihan Lunas', 'Riwayat tagihan yang telah dibayar'],
})[scope.value])
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
const loading = ref(true), loadedOnce = ref(false), saving = ref(false)
const items = ref([]), tenants = ref([]), customers = ref([])
const modal = ref(false), detailModal = ref(false), paymentModal = ref(false), editingId = ref(null), detail = ref(null)
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
const form = reactive({}), payment = reactive({ amount: '', payment_method: 'cash', payment_reference: '', paid_at: '', notes: '' })
const columns = [
{ key: 'invoice_number', label: 'Nomor Invoice' },
{ key: 'customer_name_snapshot', label: 'Customer' },
{ key: 'issue_date', label: 'Tanggal Tagihan', sortKey: 'issue_date' },
{ key: 'total_label', label: 'Total', sortKey: 'total' },
{ key: 'balance_label', label: 'Sisa', sortKey: 'balance' },
{ key: 'status_label', label: 'Status', sortKey: 'status' },
]
const statusOptions = computed(() => scope.value === 'running'
? [['draft', 'Draft'], ['published', 'Dibuat'], ['sent', 'Terkirim'], ['warning', 'Peringatan']]
: scope.value === 'overdue' ? [['overdue', 'Tunggakan']] : [['paid', 'Lunas']])
const filterFields = computed(() => [
{ key: 'date_from', label: 'Tanggal Mulai', type: 'date' },
{ key: 'date_to', label: 'Tanggal Akhir', type: 'date' },
{ key: 'status', label: 'Status', type: 'select', options: statusOptions.value },
])
const actions = computed(() => {
const result = [{ key: 'detail', label: 'Detail', color: 'primary' }]
if (scope.value === 'running') result.push({ key: 'edit', label: 'Edit', color: 'success' }, { key: 'pay', label: 'Bayar', color: 'info' }, { key: 'cancel', label: 'Batalkan', color: 'danger' })
if (scope.value === 'overdue') result.push({ key: 'pay', label: 'Bayar', color: 'success' })
return result
})
const tenantCustomers = computed(() => !form.tenant_id ? customers.value : customers.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
function money(value) { return `Rp ${Number(value || 0).toLocaleString('id-ID')}` }
function normalize(item) {
const labels = { draft: 'Draft', published: 'Dibuat', sent: 'Terkirim', warning: 'Peringatan', overdue: 'Tunggakan', paid: 'Lunas', cancelled: 'Dibatalkan' }
return { ...item, total_label: money(item.total), balance_label: money(item.balance), status_label: labels[item.status] || item.status }
}
async function fetchData(params = query.value) {
query.value = { ...query.value, ...params }; loading.value = true
try {
const response = await getInvoices(scope.value, query.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 tagihan.') }
finally { loading.value = false; loadedOnce.value = true }
}
function dateInput(value = new Date()) {
const date = value instanceof Date ? value : new Date(value)
const pad = (number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function monthDate(baseDate, day) {
const date = new Date(`${baseDate}T00:00:00`); date.setDate(Number(day)); return dateInput(date)
}
function shiftedDate(baseDate, days) {
const date = new Date(`${baseDate}T00:00:00`)
date.setDate(date.getDate() + Number(days))
return dateInput(date)
}
function installationAnchor(customer) {
if (!customer?.activated_at || !form.period_start) return null
const period = new Date(`${form.period_start}T00:00:00`)
const installed = new Date(customer.activated_at)
const lastDay = new Date(period.getFullYear(), period.getMonth() + 1, 0).getDate()
period.setDate(Math.min(installed.getDate(), lastDay))
return dateInput(period)
}
function defaults() {
const now = new Date(), first = new Date(now.getFullYear(), now.getMonth(), 1), last = new Date(now.getFullYear(), now.getMonth() + 1, 0)
return { tenant_id: '', customer_id: '', invoice_number: '', period_start: dateInput(first), period_end: dateInput(last), issue_date: dateInput(now), send_date: '', warning_date: '', isolation_date: '', subtotal: 0, notes: '' }
}
async function loadOptions() {
const calls = [getInvoiceOptions()]
if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
const [response, tenantResponse] = await Promise.all(calls)
customers.value = response?.data?.customers || []; tenants.value = tenantResponse?.data?.data || []
}
function applyProfileSchedule() {
const customer = customers.value.find((x) => Number(x.id) === Number(form.customer_id))
const profile = customer?.billing_profile
if (!profile) return
if (profile.schedule_type === 'installation_date') {
const anchor = installationAnchor(customer)
if (!anchor) return
form.issue_date = shiftedDate(anchor, -Number(profile.invoice_days_before || 0))
form.send_date = shiftedDate(anchor, -Number(profile.send_days_before || 0))
form.warning_date = shiftedDate(anchor, -Number(profile.warning_days_before || 0))
form.isolation_date = shiftedDate(anchor, Number(profile.isolation_days_after || 0))
return
}
if (!form.issue_date) return
const base = form.issue_date
form.issue_date = monthDate(base, profile.invoice_day)
form.send_date = monthDate(base, profile.send_day)
form.warning_date = monthDate(base, profile.warning_day)
form.isolation_date = monthDate(base, profile.isolation_day)
}
function onCustomerChange() {
const customer = customers.value.find((x) => Number(x.id) === Number(form.customer_id))
if (customer?.billing_profile_id) applyProfileSchedule()
}
async function openForm(item = null) {
await loadOptions(); editingId.value = item?.id || null; Object.keys(form).forEach((key) => delete form[key]); Object.assign(form, defaults(), item || {}); modal.value = true
}
async function save() {
if (!form.customer_id) return showError('Customer wajib dipilih.')
const customer = customers.value.find((x) => Number(x.id) === Number(form.customer_id))
if (!customer?.billing_profile_id) return showError('Customer belum memiliki profile tagihan.')
saving.value = true
try {
if (editingId.value) await updateInvoice(editingId.value, form); else await createInvoice(form)
modal.value = false; showSuccess('Tagihan berhasil disimpan.'); await fetchData()
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan tagihan.') }
finally { saving.value = false }
}
async function showDetail(item) {
try { detail.value = normalize((await getInvoice(scope.value, item.id))?.data || item); detailModal.value = true }
catch (error) { showError(error?.response?.data?.message || 'Gagal memuat detail tagihan.') }
}
function openPayment(item) {
detail.value = item; Object.assign(payment, { amount: item.balance, payment_method: 'cash', payment_reference: '', paid_at: dateInput(), notes: '' }); paymentModal.value = true
}
async function submitPayment() {
saving.value = true
try { await payInvoice(scope.value, detail.value.id, payment); paymentModal.value = false; showSuccess('Pembayaran berhasil dicatat.'); await fetchData() }
catch (error) { showError(error?.response?.data?.message || 'Gagal mencatat pembayaran.') }
finally { saving.value = false }
}
async function cancel(item) {
const result = await showConfirm('Batalkan Tagihan', `Batalkan invoice "${item.invoice_number}"?`); if (!result.isConfirmed) return
try { await cancelInvoice(item.id); showSuccess('Tagihan dibatalkan.'); await fetchData() }
catch (error) { showError(error?.response?.data?.message || 'Gagal membatalkan tagihan.') }
}
function action({ action, item }) {
if (action === 'detail') showDetail(item)
if (action === 'edit') openForm(item)
if (action === 'pay') openPayment(item)
if (action === 'cancel') cancel(item)
}
watch(scope, () => { loadedOnce.value = false; query.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"><div><h5 class="mb-0 fw-semibold">{{ config[0] }}</h5><small class="text-body-secondary">{{ config[1] }}</small></div><CButton v-if="scope === 'running'" color="primary" size="sm" @click="openForm()">+ Buat Tagihan</CButton></div>
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
<BaseResponsiveDataView v-else :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" :filter-fields="filterFields" @query-change="fetchData" @action-click="action" />
</div></CCol></CRow>
<CModal :visible="modal" @close="modal = false" size="lg"><CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editingId ? 'Edit' : 'Buat' }} Tagihan</CModalTitle></CModalHeader><CModalBody><CRow class="g-3">
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="x in tenants" :key="x.id" :value="x.id">{{ x.tenant_name }}</option></CFormSelect></CCol>
<CCol :md="6"><CFormLabel>Customer</CFormLabel><CFormSelect v-model="form.customer_id" @change="onCustomerChange"><option value="">Pilih customer</option><option v-for="x in tenantCustomers" :key="x.id" :value="x.id">{{ x.customer_code }} {{ x.name }}</option></CFormSelect></CCol>
<CCol :md="6"><CFormLabel>Nomor Invoice</CFormLabel><CFormInput v-model="form.invoice_number" placeholder="Otomatis jika kosong" /></CCol>
<CCol :md="4"><CFormLabel>Periode Mulai</CFormLabel><CFormInput v-model="form.period_start" type="date" @change="applyProfileSchedule" /></CCol><CCol :md="4"><CFormLabel>Periode Akhir</CFormLabel><CFormInput v-model="form.period_end" type="date" /></CCol><CCol :md="4"><CFormLabel>Subtotal</CFormLabel><CFormInput v-model="form.subtotal" type="number" min="0" /></CCol>
<CCol :md="3"><CFormLabel>Tanggal Buat</CFormLabel><CFormInput v-model="form.issue_date" type="date" /></CCol><CCol :md="3"><CFormLabel>Tanggal Kirim</CFormLabel><CFormInput v-model="form.send_date" type="date" /></CCol><CCol :md="3"><CFormLabel>Peringatan</CFormLabel><CFormInput v-model="form.warning_date" type="date" /></CCol><CCol :md="3"><CFormLabel>Isolir</CFormLabel><CFormInput v-model="form.isolation_date" type="date" /></CCol>
<CCol :xs="12"><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2" /></CCol>
</CRow></CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter></CModal>
<CModal :visible="paymentModal" @close="paymentModal = false"><CModalHeader dismiss @close="paymentModal = false"><CModalTitle>Catat Pembayaran Manual</CModalTitle></CModalHeader><CModalBody><div class="d-grid gap-3"><div><CFormLabel>Jumlah Bayar</CFormLabel><CFormInput v-model="payment.amount" type="number" /></div><div><CFormLabel>Metode</CFormLabel><CFormSelect v-model="payment.payment_method"><option value="cash">Tunai</option><option value="transfer">Transfer</option><option value="other">Lainnya</option></CFormSelect></div><div><CFormLabel>Referensi</CFormLabel><CFormInput v-model="payment.payment_reference" /></div><div><CFormLabel>Tanggal Bayar</CFormLabel><CFormInput v-model="payment.paid_at" type="date" /></div></div></CModalBody><CModalFooter><CButton color="secondary" @click="paymentModal = false">Batal</CButton><CButton color="success" :disabled="saving" @click="submitPayment">Simpan Pembayaran</CButton></CModalFooter></CModal>
<CModal :visible="detailModal" @close="detailModal = false"><CModalHeader dismiss @close="detailModal = false"><CModalTitle>Detail Tagihan</CModalTitle></CModalHeader><CModalBody v-if="detail"><div class="d-grid gap-2"><div class="d-flex justify-content-between"><span>Invoice</span><strong>{{ detail.invoice_number }}</strong></div><div class="d-flex justify-content-between"><span>Customer</span><strong>{{ detail.customer_name_snapshot }}</strong></div><div class="d-flex justify-content-between"><span>Total</span><strong>{{ detail.total_label }}</strong></div><div class="d-flex justify-content-between"><span>Terbayar</span><strong>{{ money(detail.paid_amount) }}</strong></div><div class="d-flex justify-content-between"><span>Sisa</span><strong>{{ detail.balance_label }}</strong></div><div class="d-flex justify-content-between"><span>Status</span><CBadge color="primary">{{ detail.status_label }}</CBadge></div></div></CModalBody><CModalFooter><CButton color="secondary" @click="detailModal = false">Tutup</CButton></CModalFooter></CModal>
</template>
+205
View File
@@ -0,0 +1,205 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getCustomer } from '@/services/customerService'
import { getTemporaryFileUrl } from '@/services/fileService'
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
import CustomerLocationMap from '@/components/customers/CustomerLocationMap.vue'
import {
createCustomerNotificationContact, deleteCustomerNotificationContact,
getCustomerNotificationContacts, updateCustomerNotificationContact,
} from '@/services/notificationService'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const customer = ref(null)
const gallery = ref([])
const selectedImage = ref(0)
const contacts = ref([])
const contactModal = ref(false)
const editingContactId = ref(null)
const contactForm = reactive({})
const scope = computed(() => route.params.scope)
const activeImage = computed(() => gallery.value[selectedImage.value] || null)
const statusLabel = computed(() => ({
order: 'Order', active: 'Aktif', inactive: 'Tidak Aktif', unmanaged: 'Unmanage',
})[customer.value?.status] || customer.value?.status || '-')
const statusColor = computed(() => ({
order: 'info', active: 'success', inactive: 'warning', unmanaged: 'secondary',
})[customer.value?.status] || 'secondary')
async function hydrateGallery(images) {
gallery.value = images.map((image) => ({ ...image, url: '/customer-default.svg' }))
selectedImage.value = Math.max(0, gallery.value.findIndex((image) => image.is_cover))
await Promise.allSettled(gallery.value.map(async (image) => {
if (!image.uuid) return
const response = await getTemporaryFileUrl(image.uuid)
image.url = response?.data?.url || '/customer-default.svg'
}))
}
async function loadDetail() {
loading.value = true
try {
const response = await getCustomer(scope.value, route.params.id)
customer.value = response?.data || null
if (scope.value === 'active') {
contacts.value = (await getCustomerNotificationContacts(customer.value.id))?.data || []
}
loading.value = false
hydrateGallery(customer.value?.images || [])
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat detail customer.')
router.replace(`/customers/${scope.value}`)
} finally { loading.value = false }
}
function back() { router.push(`/customers/${scope.value}`) }
function openContact(contact = null) {
editingContactId.value = contact?.id || null
Object.assign(contactForm, {
type: contact?.type || 'whatsapp', value: contact?.value || '', label: contact?.label || '',
is_primary: contact?.is_primary ?? true, is_verified: contact?.is_verified ?? false,
can_receive_transactional: contact?.can_receive_transactional ?? true,
can_receive_broadcast: contact?.can_receive_broadcast ?? true,
opted_out_at: contact?.opted_out_at || null,
})
contactModal.value = true
}
async function saveContact() {
try {
if (editingContactId.value) await updateCustomerNotificationContact(customer.value.id, editingContactId.value, contactForm)
else await createCustomerNotificationContact(customer.value.id, contactForm)
contacts.value = (await getCustomerNotificationContacts(customer.value.id))?.data || []
contactModal.value = false; showSuccess('Kontak notifikasi berhasil disimpan.')
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan kontak notifikasi.') }
}
async function removeContact(contact) {
const result = await showConfirm('Hapus Kontak', `Hapus kontak "${contact.value}"?`); if (!result.isConfirmed) return
try {
await deleteCustomerNotificationContact(customer.value.id, contact.id)
contacts.value = contacts.value.filter((item) => item.id !== contact.id)
} catch (error) { showError(error?.response?.data?.message || 'Kontak tidak dapat dihapus.') }
}
onMounted(loadDetail)
</script>
<template>
<div class="customer-detail-page">
<div class="detail-header">
<div class="d-flex align-items-center gap-3">
<img :src="activeImage?.url || '/customer-default.svg'" alt="Cover customer" class="detail-avatar image-previewable" loading="lazy" decoding="async" />
<div>
<CButton color="secondary" variant="ghost" size="sm" class="px-0" @click="back">&larr; Kembali</CButton>
<h4 class="mb-1">{{ customer?.name || 'Detail Customer' }}</h4>
<div class="d-flex align-items-center flex-wrap gap-2">
<span class="text-body-secondary">{{ customer?.customer_code || '-' }}</span>
<CBadge :color="statusColor">{{ statusLabel }}</CBadge>
</div>
</div>
</div>
<CButton v-if="scope === 'orders' && customer" color="primary" @click="router.push(`/customers/orders/${customer.id}/edit`)">Edit Customer</CButton>
</div>
<div v-if="loading" class="detail-loading"><CSpinner color="primary" /><span>Memuat detail customer...</span></div>
<CRow v-else-if="customer" class="g-3">
<CCol :xl="8">
<CCard class="detail-card mb-3"><CCardHeader><strong>Profil Customer</strong></CCardHeader><CCardBody>
<div class="info-grid">
<div><span>Nama</span><strong>{{ customer.name }}</strong></div>
<div><span>NIK/Identitas</span><strong>{{ customer.identity_number || '-' }}</strong></div>
<div><span>Email</span><strong>{{ customer.email || '-' }}</strong></div>
<div><span>WhatsApp</span><strong>{{ customer.whatsapp_number || '-' }}</strong></div>
<div><span>Tenant</span><strong>{{ customer.tenant?.tenant_name || '-' }}</strong></div>
<div><span>Username Eksternal</span><strong>{{ customer.external_username || '-' }}</strong></div>
</div>
</CCardBody></CCard>
<CCard class="detail-card mb-3"><CCardHeader><strong>Lokasi Pemasangan</strong></CCardHeader><CCardBody>
<p class="mb-3">{{ customer.full_installation_address || '-' }}</p>
<CustomerLocationMap
v-if="customer.latitude && customer.longitude"
:latitude="customer.latitude"
:longitude="customer.longitude"
readonly
/>
<div v-else class="detail-map-empty">Koordinat lokasi pemasangan belum tersedia.</div>
</CCardBody></CCard>
<CCard class="detail-card"><CCardHeader class="d-flex justify-content-between"><strong>Dokumentasi</strong><small>{{ gallery.length }} gambar</small></CCardHeader><CCardBody>
<div class="detail-gallery-main">
<img :src="activeImage?.url || '/customer-default.svg'" alt="Dokumentasi customer" class="image-previewable" loading="lazy" decoding="async" />
<div v-if="activeImage" class="detail-caption">{{ activeImage.caption || activeImage.original_name }}</div>
</div>
<div v-if="gallery.length" class="detail-thumbnails">
<button v-for="(image, index) in gallery" :key="image.uuid" type="button" :class="{ active: selectedImage === index }" @click="selectedImage = index">
<img :src="image.url" :alt="image.original_name" loading="lazy" decoding="async" />
</button>
</div>
</CCardBody></CCard>
</CCol>
<CCol :xl="4">
<div class="detail-sidebar">
<CCard class="detail-card mb-3"><CCardHeader><strong>Layanan</strong></CCardHeader><CCardBody class="info-stack">
<div><span>Profile Paket</span><strong>{{ customer.package_profile?.name || '-' }}</strong></div>
<div><span>NAS Mikrotik</span><strong>{{ customer.nas_mikrotik?.name || '-' }}</strong></div>
<div><span>Profile Tagihan</span><strong>{{ customer.billing_profile?.name || '-' }}</strong></div>
<div><span>Tipe Koneksi</span><strong>{{ customer.nas_mikrotik?.connection_type || '-' }}</strong></div>
<div><span>Tahap Order</span><strong>{{ customer.order_stage || '-' }}</strong></div>
<div><span>Aktivasi</span><strong>{{ customer.activated_at || '-' }}</strong></div>
</CCardBody></CCard>
<CCard v-if="scope === 'active'" class="detail-card mb-3"><CCardHeader class="d-flex justify-content-between align-items-center"><strong>Kontak Notifikasi</strong><CButton color="primary" size="sm" @click="openContact()">+ Kontak</CButton></CCardHeader><CCardBody>
<div v-if="contacts.length" class="notification-contact-list">
<div v-for="contact in contacts" :key="contact.id"><div><strong>{{ contact.label || contact.type }}</strong><span>{{ contact.value }}</span><small>{{ contact.can_receive_broadcast ? 'Transaksional & siaran' : 'Hanya transaksional' }}</small></div><div><CButton size="sm" color="success" variant="outline" @click="openContact(contact)">Edit</CButton><CButton size="sm" color="danger" variant="outline" @click="removeContact(contact)">Hapus</CButton></div></div>
</div><div v-else class="text-body-secondary">Belum ada kontak notifikasi.</div>
</CCardBody></CCard>
<CCard class="detail-card"><CCardHeader><strong>Catatan</strong></CCardHeader><CCardBody><p class="mb-0 text-pre-wrap">{{ customer.notes || 'Tidak ada catatan.' }}</p></CCardBody></CCard>
</div>
</CCol>
</CRow>
<CModal :visible="contactModal" @close="contactModal = false"><CModalHeader dismiss @close="contactModal = false"><CModalTitle>Kontak Notifikasi</CModalTitle></CModalHeader><CModalBody><div class="d-grid gap-3">
<div><CFormLabel>Jenis</CFormLabel><CFormSelect v-model="contactForm.type"><option value="whatsapp">WhatsApp</option><option value="email">Email</option><option value="telegram">Telegram Chat ID</option></CFormSelect></div>
<div><CFormLabel>Nomor/Alamat/Chat ID</CFormLabel><CFormInput v-model="contactForm.value" /></div>
<div><CFormLabel>Label</CFormLabel><CFormInput v-model="contactForm.label" /></div>
<CFormCheck v-model="contactForm.is_primary" label="Kontak utama" /><CFormCheck v-model="contactForm.can_receive_transactional" label="Terima pesan transaksional" /><CFormCheck v-model="contactForm.can_receive_broadcast" label="Terima pesan siaran" /><CFormCheck v-model="contactForm.is_verified" label="Kontak terverifikasi" />
</div></CModalBody><CModalFooter><CButton color="secondary" @click="contactModal = false">Batal</CButton><CButton color="primary" @click="saveContact">Simpan</CButton></CModalFooter></CModal>
</div>
</template>
<style scoped>
.customer-detail-page { padding: 0.5rem; }
.detail-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.detail-avatar { width: 5.25rem; height: 5.25rem; object-fit: cover; border: 3px solid var(--cui-body-bg); border-radius: 1rem; box-shadow: 0 0.2rem 0.8rem rgba(0,0,0,.14); }
.detail-loading { min-height: 18rem; display: flex; align-items: center; justify-content: center; gap: .75rem; }
.detail-card { overflow: hidden; border: 1px solid var(--cui-border-color); border-radius: .85rem; box-shadow: 0 .2rem .7rem rgba(0,0,0,.04); }
.info-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 1rem; }
.info-grid div, .info-stack div { display: flex; flex-direction: column; gap: .2rem; min-width: 0; }
.info-grid span, .info-stack span { color: var(--cui-secondary-color); font-size: .8rem; }
.info-grid strong, .info-stack strong { overflow-wrap: anywhere; }
.info-stack { display: grid; gap: 1rem; }
.detail-map-empty { min-height: 10rem; display: flex; align-items: center; justify-content: center; color: var(--cui-secondary-color); border-radius: .75rem; background: var(--cui-tertiary-bg); }
.detail-gallery-main { position: relative; overflow: hidden; aspect-ratio: 16/8; border-radius: .75rem; background: var(--cui-tertiary-bg); }
.detail-gallery-main img { width: 100%; height: 100%; object-fit: cover; cursor: zoom-in; }
.detail-caption { position: absolute; inset: auto 0 0; padding: 1.5rem 1rem .75rem; color: white; background: linear-gradient(transparent, rgba(0,0,0,.8)); }
.detail-thumbnails { display: flex; gap: .55rem; overflow-x: auto; padding-top: .75rem; }
.detail-thumbnails button { width: 5rem; height: 4rem; flex: 0 0 auto; overflow: hidden; padding: 0; border: 2px solid transparent; border-radius: .55rem; }
.detail-thumbnails button.active { border-color: var(--cui-primary); }
.detail-thumbnails img { width: 100%; height: 100%; object-fit: cover; }
.detail-sidebar { position: sticky; top: 5.5rem; }
.text-pre-wrap { white-space: pre-wrap; }
.notification-contact-list { display: grid; gap: .65rem; }
.notification-contact-list > div { display: flex; justify-content: space-between; gap: .5rem; padding-bottom: .65rem; border-bottom: 1px solid var(--cui-border-color); }
.notification-contact-list > div > div:first-child { display: flex; min-width: 0; flex-direction: column; }
.notification-contact-list span, .notification-contact-list small { overflow-wrap: anywhere; }
.notification-contact-list small { color: var(--cui-secondary-color); }
.notification-contact-list > div > div:last-child { display: flex; align-items: flex-start; gap: .35rem; }
@media (max-width: 1199.98px) { .detail-sidebar { position: static; } }
@media (max-width: 767.98px) {
.customer-detail-page { padding: 0; }
.detail-header { align-items: flex-start; flex-direction: column; }
.detail-header > button { width: 100%; }
.detail-avatar { width: 4.25rem; height: 4.25rem; }
.info-grid { grid-template-columns: 1fr; }
.detail-gallery-main { aspect-ratio: 4/3; }
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { createCustomer, getCustomer, getCustomerOptions, updateCustomer } from '@/services/customerService'
import { getTenants } from '@/services/tenantService'
import { deleteFile, getTemporaryFileUrl, uploadFile } from '@/services/fileService'
import { getDesaOptions, getKabupatenOptions, getKecamatanOptions, getProvinsiOptions } from '@/services/wilayahService'
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
import { compressCustomerImage } from '@/utils/imageCompression'
import CustomerLocationMap from '@/components/customers/CustomerLocationMap.vue'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const saving = ref(false)
const uploading = ref(false)
const fileInput = ref(null)
const selectedImage = ref(0)
const tenants = ref([]), profiles = ref([]), mikrotiks = ref([]), billingProfiles = ref([])
const provinsi = ref([]), kabupaten = ref([]), kecamatan = ref([]), desa = ref([])
const gallery = ref([])
const isEdit = computed(() => !!route.params.id)
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
const form = reactive({
tenant_id: '', customer_code: '', name: '', identity_number: '', email: '', whatsapp_number: '',
installation_address: '', provinsi_id: '', kabupaten_id: '', kecamatan_id: '', desa_id: '',
latitude: '', longitude: '', package_profile_id: '', nas_mikrotik_id: '', external_username: '',
billing_profile_id: '', order_stage: 'registration', notes: '',
})
const categoryOptions = [
['house', 'Rumah'], ['installation', 'Instalasi'], ['modem', 'Modem/ONT'],
['odp', 'ODP'], ['cable', 'Jalur Kabel'], ['identity', 'Identitas'], ['other', 'Lainnya'],
]
const activeImage = computed(() => gallery.value[selectedImage.value] || null)
const tenantProfiles = computed(() => !form.tenant_id ? profiles.value : profiles.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
const tenantNas = computed(() => !form.tenant_id ? mikrotiks.value : mikrotiks.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
const tenantBillingProfiles = computed(() => !form.tenant_id ? billingProfiles.value : billingProfiles.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
function optionRows(response) { return Array.isArray(response?.data) ? response.data : [] }
async function loadReferences() {
const calls = [getProvinsiOptions(), getCustomerOptions()]
if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
const [provinceResponse, customerOptions, tenantResponse] = await Promise.all(calls)
provinsi.value = optionRows(provinceResponse)
profiles.value = customerOptions?.data?.package_profiles || []
mikrotiks.value = customerOptions?.data?.nas_mikrotiks || []
billingProfiles.value = customerOptions?.data?.billing_profiles || []
tenants.value = tenantResponse?.data?.data || []
}
async function loadKabupaten(reset = true) {
if (reset) Object.assign(form, { kabupaten_id: '', kecamatan_id: '', desa_id: '' })
kabupaten.value = form.provinsi_id ? optionRows(await getKabupatenOptions(form.provinsi_id)) : []
}
async function loadKecamatan(reset = true) {
if (reset) Object.assign(form, { kecamatan_id: '', desa_id: '' })
kecamatan.value = form.kabupaten_id ? optionRows(await getKecamatanOptions(form.kabupaten_id)) : []
}
async function loadDesa(reset = true) {
if (reset) form.desa_id = ''
desa.value = form.kecamatan_id ? optionRows(await getDesaOptions(form.kecamatan_id)) : []
}
async function onProvinsiChange(event) { form.provinsi_id = event.target.value; await loadKabupaten() }
async function onKabupatenChange(event) { form.kabupaten_id = event.target.value; await loadKecamatan() }
async function onKecamatanChange(event) { form.kecamatan_id = event.target.value; await loadDesa() }
async function resolveImageUrl(image) {
try {
const response = await getTemporaryFileUrl(image.uuid)
return response?.data?.url || '/customer-default.svg'
} catch {
return '/customer-default.svg'
}
}
async function hydrateGallery(images = []) {
gallery.value = await Promise.all(images.map(async (image) => ({
...image,
url: await resolveImageUrl(image),
isNew: false,
})))
selectedImage.value = Math.max(0, gallery.value.findIndex((image) => image.is_cover))
}
async function loadCustomer() {
if (!isEdit.value) return
const response = await getCustomer('orders', route.params.id)
const customer = response?.data || {}
Object.keys(form).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(customer, key)) form[key] = customer[key] ?? ''
})
if (form.provinsi_id) await loadKabupaten(false)
if (form.kabupaten_id) await loadKecamatan(false)
if (form.kecamatan_id) await loadDesa(false)
await hydrateGallery(customer.images || [])
}
async function initialize() {
loading.value = true
try {
await loadReferences()
await loadCustomer()
} catch (error) {
showError(error?.response?.data?.message || 'Gagal memuat form customer.')
router.replace('/customers/orders')
} finally { loading.value = false }
}
async function addImages(event) {
const files = [...(event.target.files || [])].filter((file) => file.type.startsWith('image/'))
if (!files.length) return
if (gallery.value.length + files.length > 20) {
showError('Maksimal 20 gambar untuk satu customer.'); event.target.value = ''; return
}
uploading.value = true
try {
for (const file of files) {
const compressedFile = await compressCustomerImage(file)
const response = await uploadFile(compressedFile, 'customer-image')
const uploaded = response?.data
const tempResponse = await getTemporaryFileUrl(uploaded.uuid)
gallery.value.push({
stored_file_id: uploaded.id, uuid: uploaded.uuid, original_name: uploaded.original_name,
category: 'other', caption: '', is_cover: gallery.value.length === 0,
sort_order: gallery.value.length, url: tempResponse?.data?.url || URL.createObjectURL(compressedFile), isNew: true,
})
}
selectedImage.value = gallery.value.length - 1
} catch (error) { showError(error?.response?.data?.message || 'Gagal mengunggah gambar.') }
finally { uploading.value = false; event.target.value = '' }
}
function setCover(index) {
gallery.value.forEach((image, imageIndex) => { image.is_cover = imageIndex === index })
selectedImage.value = index
}
async function removeImage(index) {
const image = gallery.value[index]
const result = await showConfirm('Hapus Gambar', `Hapus "${image.original_name}" dari galeri?`)
if (!result.isConfirmed) return
if (image.isNew) {
try { await deleteFile(image.uuid) } catch { /* relasi tetap dibuang dari form */ }
}
gallery.value.splice(index, 1)
gallery.value.forEach((item, itemIndex) => { item.sort_order = itemIndex })
if (gallery.value.length && !gallery.value.some((item) => item.is_cover)) gallery.value[0].is_cover = true
selectedImage.value = Math.min(selectedImage.value, Math.max(0, gallery.value.length - 1))
}
function useCurrentLocation() {
if (!navigator.geolocation) return showError('Browser tidak mendukung geolocation.')
navigator.geolocation.getCurrentPosition(
({ coords }) => { form.latitude = coords.latitude.toFixed(7); form.longitude = coords.longitude.toFixed(7) },
() => showError('Lokasi tidak dapat diambil. Pastikan izin lokasi sudah diberikan.'),
{ enableHighAccuracy: true, timeout: 15000 },
)
}
function payload() {
const data = Object.fromEntries(Object.entries(form).map(([key, value]) => [key, value === '' ? null : value]))
data.images = gallery.value.map((image, index) => ({
stored_file_id: image.stored_file_id, category: image.category, caption: image.caption || null,
is_cover: !!image.is_cover, sort_order: index,
}))
return data
}
async function save() {
if (!form.name?.trim()) return showError('Nama customer wajib diisi.')
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
saving.value = true
try {
const response = isEdit.value
? await updateCustomer(route.params.id, payload())
: await createCustomer(payload())
showSuccess(`Customer berhasil ${isEdit.value ? 'diperbarui' : 'didaftarkan'}.`)
router.replace(`/customers/orders/${response?.data?.id || route.params.id}/edit`)
if (!isEdit.value) await initialize()
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan customer.') }
finally { saving.value = false }
}
onMounted(initialize)
</script>
<template>
<div class="customer-form-page">
<div class="customer-page-header">
<div>
<CButton color="secondary" variant="ghost" size="sm" @click="router.push('/customers/orders')">&larr; Kembali</CButton>
<h4 class="mb-1 mt-1">{{ isEdit ? 'Lengkapi Data Customer' : 'Registrasi Customer' }}</h4>
<p class="text-body-secondary mb-0">Identitas, lokasi pemasangan, layanan, dan dokumentasi customer.</p>
</div>
<CButton color="primary" size="sm" :disabled="loading || saving || uploading" @click="save">
{{ saving ? 'Menyimpan...' : 'Simpan Customer' }}
</CButton>
</div>
<div v-if="loading" class="form-loading"><CSpinner color="primary" /><span>Memuat data...</span></div>
<CRow v-else class="g-3">
<CCol :xl="8">
<CCard class="form-card mb-3"><CCardHeader><strong>Informasi Customer</strong></CCardHeader><CCardBody>
<CRow class="g-3">
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="x in tenants" :key="x.id" :value="x.id">{{ x.tenant_name }}</option></CFormSelect></CCol>
<CCol :md="6"><CFormLabel>ID Customer</CFormLabel><CFormInput v-model="form.customer_code" placeholder="Dibuat otomatis jika kosong" /></CCol>
<CCol :md="6"><CFormLabel>Nama Lengkap</CFormLabel><CFormInput v-model="form.name" /></CCol>
<CCol :md="6"><CFormLabel>NIK/Identitas</CFormLabel><CFormInput v-model="form.identity_number" /></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>
</CRow>
</CCardBody></CCard>
<CCard class="form-card mb-3"><CCardHeader><strong>Lokasi Pemasangan</strong></CCardHeader><CCardBody>
<CRow class="g-3">
<CCol :xs="12"><CFormLabel>Alamat</CFormLabel><CFormTextarea v-model="form.installation_address" rows="2" /></CCol>
<CCol :md="6" :xl="3"><CFormLabel>Provinsi</CFormLabel><CFormSelect v-model="form.provinsi_id" @change="onProvinsiChange"><option value="">Pilih</option><option v-for="x in provinsi" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
<CCol :md="6" :xl="3"><CFormLabel>Kabupaten</CFormLabel><CFormSelect v-model="form.kabupaten_id" @change="onKabupatenChange"><option value="">Pilih</option><option v-for="x in kabupaten" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
<CCol :md="6" :xl="3"><CFormLabel>Kecamatan</CFormLabel><CFormSelect v-model="form.kecamatan_id" @change="onKecamatanChange"><option value="">Pilih</option><option v-for="x in kecamatan" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
<CCol :md="6" :xl="3"><CFormLabel>Desa</CFormLabel><CFormSelect v-model="form.desa_id"><option value="">Pilih</option><option v-for="x in desa" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
</CRow>
<div class="map-shell mt-3">
<CustomerLocationMap
v-model:latitude="form.latitude"
v-model:longitude="form.longitude"
/>
<div class="map-coordinates">
<div><CFormLabel>Latitude</CFormLabel><CFormInput v-model="form.latitude" type="number" step="any" /></div>
<div><CFormLabel>Longitude</CFormLabel><CFormInput v-model="form.longitude" type="number" step="any" /></div>
<CButton color="primary" variant="outline" @click="useCurrentLocation">Gunakan Lokasi Saya</CButton>
</div>
</div>
</CCardBody></CCard>
<CCard class="form-card"><CCardHeader class="d-flex justify-content-between"><strong>Dokumentasi Lokasi</strong><small>{{ gallery.length }}/20 gambar</small></CCardHeader><CCardBody>
<div class="gallery-main">
<img :src="activeImage?.url || '/customer-default.svg'" alt="Dokumentasi customer" class="image-previewable" />
<div v-if="activeImage" class="gallery-overlay"><CBadge color="primary">{{ categoryOptions.find(([value]) => value === activeImage.category)?.[1] }}</CBadge><span>{{ activeImage.caption || activeImage.original_name }}</span></div>
</div>
<div class="gallery-thumbnails">
<button v-for="(image, index) in gallery" :key="image.uuid" type="button" class="gallery-thumb" :class="{ active: index === selectedImage }" @click="selectedImage = index">
<img :src="image.url" :alt="image.original_name" /><span v-if="image.is_cover">Cover</span>
</button>
<button type="button" class="gallery-add" :disabled="uploading || gallery.length >= 20" @click="fileInput?.click()"><strong>+</strong><span>{{ uploading ? 'Upload...' : 'Tambah' }}</span></button>
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="addImages" />
</div>
<CRow v-if="activeImage" class="g-2 mt-2 align-items-end">
<CCol :md="4"><CFormLabel>Kategori</CFormLabel><CFormSelect v-model="activeImage.category"><option v-for="[value, label] in categoryOptions" :key="value" :value="value">{{ label }}</option></CFormSelect></CCol>
<CCol :md="5"><CFormLabel>Keterangan</CFormLabel><CFormInput v-model="activeImage.caption" /></CCol>
<CCol :md="3" class="d-flex gap-2"><CButton color="primary" variant="outline" class="flex-fill" @click="setCover(selectedImage)">Jadikan Cover</CButton><CButton color="danger" variant="outline" @click="removeImage(selectedImage)">Hapus</CButton></CCol>
</CRow>
</CCardBody></CCard>
</CCol>
<CCol :xl="4">
<div class="customer-sidebar">
<CCard class="form-card mb-3"><CCardHeader><strong>Layanan Customer</strong></CCardHeader><CCardBody class="d-grid gap-3">
<div><CFormLabel>Profile Paket</CFormLabel><CFormSelect v-model="form.package_profile_id"><option value="">Pilih profile paket</option><option v-for="x in tenantProfiles" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></div>
<div><CFormLabel>NAS Mikrotik</CFormLabel><CFormSelect v-model="form.nas_mikrotik_id"><option value="">Pilih NAS</option><option v-for="x in tenantNas" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></div>
<div><CFormLabel>Profile Tagihan</CFormLabel><CFormSelect v-model="form.billing_profile_id"><option value="">Pilih profile tagihan</option><option v-for="x in tenantBillingProfiles" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></div>
<div><CFormLabel>Username Eksternal</CFormLabel><CFormInput v-model="form.external_username" /></div>
<div><CFormLabel>Tahap Order</CFormLabel><CFormSelect v-model="form.order_stage"><option value="registration">Registrasi</option><option value="data_completion">Melengkapi Data</option><option value="ready_activation">Siap Aktivasi</option><option value="activation_failed">Aktivasi Gagal</option></CFormSelect></div>
<div><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="4" /></div>
</CCardBody></CCard>
<CButton color="primary" class="w-100 save-mobile" :disabled="saving || uploading" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan Customer' }}</CButton>
</div>
</CCol>
</CRow>
</div>
</template>
<style scoped>
.customer-form-page { padding: 0.5rem; }
.customer-page-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.form-loading { min-height: 16rem; display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
.form-card { border: 1px solid var(--cui-border-color); border-radius: 0.85rem; box-shadow: 0 0.2rem 0.7rem rgba(0, 0, 0, 0.04); overflow: hidden; }
.map-shell { overflow: hidden; border: 1px solid var(--cui-border-color); border-radius: 0.75rem; }
.map-coordinates { display: grid; grid-template-columns: 1fr 1fr auto; align-items: end; gap: 0.75rem; padding: 0.75rem; background: var(--cui-tertiary-bg); }
.gallery-main { position: relative; overflow: hidden; aspect-ratio: 16 / 8; border-radius: 0.75rem; background: var(--cui-tertiary-bg); }
.gallery-main img { width: 100%; height: 100%; object-fit: cover; cursor: zoom-in; }
.gallery-overlay { position: absolute; inset: auto 0 0; display: flex; align-items: center; gap: 0.5rem; padding: 1rem; color: white; background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); }
.gallery-thumbnails { display: flex; gap: 0.6rem; overflow-x: auto; padding: 0.75rem 0 0.25rem; }
.gallery-thumb, .gallery-add { position: relative; width: 5.5rem; height: 4.5rem; flex: 0 0 auto; overflow: hidden; border: 2px solid transparent; border-radius: 0.65rem; background: var(--cui-tertiary-bg); }
.gallery-thumb.active { border-color: var(--cui-primary); }
.gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }
.gallery-thumb span { position: absolute; right: 0.2rem; bottom: 0.2rem; padding: 0.05rem 0.25rem; border-radius: 0.25rem; color: white; font-size: 0.65rem; background: var(--cui-primary); }
.gallery-add { display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--cui-primary); border-style: dashed; border-color: var(--cui-primary); }
.gallery-add strong { font-size: 1.5rem; line-height: 1; }
.gallery-add span { font-size: 0.72rem; }
.customer-sidebar { position: sticky; top: 5.5rem; }
@media (max-width: 1199.98px) { .customer-sidebar { position: static; } }
@media (max-width: 767.98px) {
.customer-form-page { padding: 0; }
.customer-page-header { align-items: flex-start; flex-direction: column; }
.customer-page-header > button { display: none; }
.map-coordinates { grid-template-columns: 1fr; }
.gallery-main { aspect-ratio: 4 / 3; }
.save-mobile { position: sticky; bottom: 0.5rem; z-index: 5; }
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { useAuthStore } from '@/stores/auth'
import { getTenants } from '@/services/tenantService'
import {
activateCustomer, createCustomer, deactivateCustomer, forceDeleteCustomer,
getCustomer, getCustomerOptions, getCustomers, restoreCustomer, trashCustomer, updateCustomer,
} from '@/services/customerService'
import {
getDesaOptions, getKabupatenOptions, getKecamatanOptions, getProvinsiOptions,
} from '@/services/wilayahService'
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
import { getTemporaryFileUrl } from '@/services/fileService'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const scope = computed(() => route.meta.customerScope)
const scopeConfig = computed(() => ({
orders: ['List Order', 'Pendaftaran dan aktivasi customer'],
active: ['Customer Aktif', 'Daftar customer berstatus aktif'],
inactive: ['Customer Tidak Aktif', 'Daftar customer yang dinonaktifkan'],
unmanaged: ['Customer Unmanage', 'Koneksi aktif yang belum terdaftar sebagai customer'],
trash: ['Sampah Customer', 'Customer terhapus yang masih dapat dipulihkan'],
})[scope.value])
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
const loading = ref(true), loadedOnce = ref(false), saving = ref(false)
const items = ref([]), modal = ref(false), detailModal = ref(false), editingId = ref(null), detail = ref(null)
const tenants = ref([]), profiles = ref([]), mikrotiks = ref([])
const provinsi = ref([]), kabupaten = ref([]), kecamatan = ref([]), desa = ref([])
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
const form = reactive({})
const columns = computed(() => [
{ key: 'image', label: 'Gambar', sortable: false },
{ key: 'customer_code', label: 'ID Customer' },
{ key: 'name', label: 'Nama' },
{ key: scope.value === 'unmanaged' ? 'external_username' : 'whatsapp_number', label: scope.value === 'unmanaged' ? 'Username' : 'WhatsApp' },
{ key: 'package_label', label: 'Paket', sortable: false },
{ key: 'stage_label', label: 'Status', sortable: false },
])
const actions = computed(() => {
if (scope.value === 'trash') return [
{ key: 'detail', label: 'Detail', color: 'primary' },
{ key: 'restore', label: 'Pulihkan', color: 'success' },
{ key: 'force-delete', label: 'Hapus Permanen', color: 'danger' },
]
const result = [{ key: 'detail', label: 'Detail', color: 'primary' }]
if (scope.value === 'orders') result.push(
{ key: 'edit', label: 'Lengkapi Data', color: 'success' },
{ key: 'activate', label: 'Aktivasi', color: 'info' },
)
if (scope.value === 'active') result.push({ key: 'deactivate', label: 'Nonaktifkan', color: 'warning' })
if (scope.value === 'inactive') result.push({ key: 'trash', label: 'Ke Sampah', color: 'danger' })
return result
})
function emptyForm() {
return {
tenant_id: '', customer_code: '', name: '', identity_number: '', email: '',
whatsapp_number: '', installation_address: '', provinsi_id: '', kabupaten_id: '',
kecamatan_id: '', desa_id: '', latitude: '', longitude: '', package_profile_id: '',
nas_mikrotik_id: '', external_username: '', order_stage: 'registration', notes: '',
}
}
function normalize(item) {
const stages = { registration: 'Registrasi', data_completion: 'Melengkapi Data', ready_activation: 'Siap Aktivasi', activation_failed: 'Aktivasi Gagal' }
return {
...item,
image: '/customer-default.svg',
package_label: item.package_profile?.name || '-',
stage_label: scope.value === 'orders' ? stages[item.order_stage] || item.order_stage : ({ active: 'Aktif', inactive: 'Tidak Aktif', unmanaged: 'Unmanage' })[item.status] || item.status,
}
}
async function hydrateCoverImages(rows) {
await Promise.allSettled(rows.map(async (item) => {
if (!item.cover_image?.uuid) return
const response = await getTemporaryFileUrl(item.cover_image.uuid)
item.image = response?.data?.url || '/customer-default.svg'
}))
}
function optionRows(response) {
return Array.isArray(response?.data) ? response.data : []
}
async function fetchData(params = query.value) {
query.value = { ...query.value, ...params }; loading.value = true
try {
const response = await getCustomers(scope.value, query.value)
const rows = response?.data?.data || []; items.value = rows.map(normalize)
hydrateCoverImages(items.value)
pagination.value = { current_page: response?.data?.current_page || 1, last_page: response?.data?.last_page || 1, per_page: response?.data?.per_page || 10, total: response?.data?.total || rows.length }
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat customer.') }
finally { loading.value = false; loadedOnce.value = true }
}
async function loadOptions() {
try {
const calls = [
getProvinsiOptions(),
getCustomerOptions(),
]
if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
const [p, options, tenantResponse] = await Promise.all(calls)
provinsi.value = optionRows(p); profiles.value = options?.data?.package_profiles || []; mikrotiks.value = options?.data?.nas_mikrotiks || []
tenants.value = tenantResponse?.data?.data || []
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat data pilihan.') }
}
const tenantProfiles = computed(() => !form.tenant_id ? profiles.value : profiles.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
const tenantNas = computed(() => !form.tenant_id ? mikrotiks.value : mikrotiks.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
async function loadKabupaten(reset = true) {
if (reset) Object.assign(form, { kabupaten_id: '', kecamatan_id: '', desa_id: '' })
kabupaten.value = form.provinsi_id ? optionRows(await getKabupatenOptions(form.provinsi_id)) : []
}
async function loadKecamatan(reset = true) {
if (reset) Object.assign(form, { kecamatan_id: '', desa_id: '' })
kecamatan.value = form.kabupaten_id ? optionRows(await getKecamatanOptions(form.kabupaten_id)) : []
}
async function loadDesa(reset = true) {
if (reset) form.desa_id = ''
desa.value = form.kecamatan_id ? optionRows(await getDesaOptions(form.kecamatan_id)) : []
}
async function onProvinsiChange(event) {
form.provinsi_id = event.target.value
await loadKabupaten()
}
async function onKabupatenChange(event) {
form.kabupaten_id = event.target.value
await loadKecamatan()
}
async function onKecamatanChange(event) {
form.kecamatan_id = event.target.value
await loadDesa()
}
async function openForm(item = null) {
return router.push(
item?.id
? `/customers/orders/${item.id}/edit`
: '/customers/orders/create',
)
}
function payload() {
return Object.fromEntries(Object.entries(form).map(([key, value]) => [key, value === '' ? null : value]))
}
async function save() {
if (!form.name?.trim()) return showError('Nama customer wajib diisi.')
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
saving.value = true
try {
if (editingId.value) await updateCustomer(editingId.value, payload()); else await createCustomer(payload())
modal.value = false; showSuccess('Data customer berhasil disimpan.'); await fetchData()
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan customer.') }
finally { saving.value = false }
}
async function showDetail(item) {
return router.push(`/customers/${scope.value}/${item.id}/detail`)
}
async function runAction(action, item) {
if (action === 'detail') return showDetail(item)
if (action === 'edit') return openForm(item)
const labels = { activate: 'aktifkan', deactivate: 'nonaktifkan', trash: 'pindahkan ke Sampah', restore: 'pulihkan', 'force-delete': 'hapus permanen' }
const result = await showConfirm('Konfirmasi', `Yakin ingin ${labels[action]} customer "${item.name}"?`)
if (!result.isConfirmed) return
try {
if (action === 'activate') await activateCustomer(item.id, { package_profile_id: item.package_profile_id, nas_mikrotik_id: item.nas_mikrotik_id, billing_profile_id: item.billing_profile_id, external_username: item.external_username })
if (action === 'deactivate') await deactivateCustomer(item.id)
if (action === 'trash') await trashCustomer(item.id)
if (action === 'restore') await restoreCustomer(item.id)
if (action === 'force-delete') await forceDeleteCustomer(item.id)
showSuccess('Status customer berhasil diperbarui.'); await fetchData()
} catch (error) { showError(error?.response?.data?.message || 'Proses customer gagal.') }
}
watch(scope, () => { items.value = []; loadedOnce.value = false; query.value.page = 1; 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">
<div><h5 class="mb-0 fw-semibold">{{ scopeConfig[0] }}</h5><small class="text-body-secondary">{{ scopeConfig[1] }}</small></div>
<CButton v-if="scope === 'orders'" color="primary" size="sm" @click="openForm()">+ Daftarkan Customer</CButton>
</div>
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
<BaseResponsiveDataView v-else :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" row-key="id" @action-click="({ action, item }) => runAction(action, item)" @query-change="fetchData" />
</div></CCol></CRow>
<CModal :visible="modal" @close="modal = false" size="xl">
<CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editingId ? 'Lengkapi Data' : 'Pendaftaran' }} Customer</CModalTitle></CModalHeader>
<CModalBody><CRow class="g-3">
<CCol v-if="isMaster" :md="4"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="x in tenants" :key="x.id" :value="x.id">{{ x.tenant_name }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>ID Customer</CFormLabel><CFormInput v-model="form.customer_code" placeholder="Otomatis jika kosong" /></CCol>
<CCol :md="4"><CFormLabel>Nama</CFormLabel><CFormInput v-model="form.name" /></CCol>
<CCol :md="4"><CFormLabel>NIK/Identitas</CFormLabel><CFormInput v-model="form.identity_number" /></CCol>
<CCol :md="4"><CFormLabel>Email</CFormLabel><CFormInput v-model="form.email" type="email" /></CCol>
<CCol :md="4"><CFormLabel>WhatsApp</CFormLabel><CFormInput v-model="form.whatsapp_number" /></CCol>
<CCol :xs="12"><CFormLabel>Alamat Pemasangan</CFormLabel><CFormTextarea v-model="form.installation_address" rows="2" /></CCol>
<CCol :md="3"><CFormLabel>Provinsi</CFormLabel><CFormSelect v-model="form.provinsi_id" @change="onProvinsiChange"><option value="">Pilih</option><option v-for="x in provinsi" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
<CCol :md="3"><CFormLabel>Kabupaten</CFormLabel><CFormSelect v-model="form.kabupaten_id" @change="onKabupatenChange"><option value="">Pilih</option><option v-for="x in kabupaten" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
<CCol :md="3"><CFormLabel>Kecamatan</CFormLabel><CFormSelect v-model="form.kecamatan_id" @change="onKecamatanChange"><option value="">Pilih</option><option v-for="x in kecamatan" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
<CCol :md="3"><CFormLabel>Desa</CFormLabel><CFormSelect v-model="form.desa_id"><option value="">Pilih</option><option v-for="x in desa" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Profile Paket</CFormLabel><CFormSelect v-model="form.package_profile_id"><option value="">Pilih paket</option><option v-for="x in tenantProfiles" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>NAS Mikrotik</CFormLabel><CFormSelect v-model="form.nas_mikrotik_id"><option value="">Pilih NAS</option><option v-for="x in tenantNas" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Username Eksternal</CFormLabel><CFormInput v-model="form.external_username" /></CCol>
<CCol :md="3"><CFormLabel>Latitude</CFormLabel><CFormInput v-model="form.latitude" type="number" step="any" /></CCol>
<CCol :md="3"><CFormLabel>Longitude</CFormLabel><CFormInput v-model="form.longitude" type="number" step="any" /></CCol>
<CCol :md="6"><CFormLabel>Tahap Order</CFormLabel><CFormSelect v-model="form.order_stage"><option value="registration">Registrasi</option><option value="data_completion">Melengkapi Data</option><option value="ready_activation">Siap Aktivasi</option><option value="activation_failed">Aktivasi Gagal</option></CFormSelect></CCol>
<CCol :xs="12"><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2" /></CCol>
</CRow></CModalBody>
<CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter>
</CModal>
<CModal :visible="detailModal" @close="detailModal = false" size="lg"><CModalHeader dismiss @close="detailModal = false"><CModalTitle>Detail Customer</CModalTitle></CModalHeader>
<CModalBody v-if="detail"><CRow class="g-3"><CCol :md="6"><strong>{{ detail.customer_code }} — {{ detail.name }}</strong><div>{{ detail.email || '-' }}</div><div>{{ detail.whatsapp_number || '-' }}</div></CCol><CCol :md="6"><div class="text-body-secondary">Alamat Pemasangan</div><div>{{ detail.full_installation_address || '-' }}</div></CCol><CCol :md="6"><div class="text-body-secondary">Paket</div><div>{{ detail.package_profile?.name || '-' }}</div></CCol><CCol :md="6"><div class="text-body-secondary">NAS</div><div>{{ detail.nas_mikrotik?.name || '-' }}</div></CCol></CRow></CModalBody>
<CModalFooter><CButton color="secondary" @click="detailModal = false">Tutup</CButton></CModalFooter></CModal>
</template>
@@ -0,0 +1,155 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { useAuthStore } from '@/stores/auth'
import { getTenants } from '@/services/tenantService'
import { getCustomerOptions } from '@/services/customerService'
import { getTopologyOptions } from '@/services/topologyService'
import {
approveBroadcast, createBroadcast, getBroadcasts, getNotificationChannels, getNotificationTemplates, previewBroadcast,
} from '@/services/notificationService'
import { showError, showSuccess } from '@/utils/swal.js'
const authStore = useAuthStore()
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
const loading = ref(true), loadedOnce = ref(false), saving = ref(false), formVisible = ref(false)
const items = ref([]), tenants = ref([]), channels = ref([]), templates = ref([])
const topologyNodes = ref([]), packageProfiles = ref([]), billingProfiles = ref([])
const preview = ref(null)
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const query = ref({ page: 1, per_page: 10 })
const form = reactive({})
const channelOptions = [
['system', 'Aplikasi'], ['whatsapp_official', 'WhatsApp Official'],
['whatsapp_unofficial', 'WhatsApp Unofficial'], ['email', 'Email'], ['telegram', 'Telegram'],
]
const columns = [
{ key: 'name', label: 'Nama Siaran' }, { key: 'channel_label', label: 'Channel', sortable: false },
{ key: 'recipient_label', label: 'Penerima', sortable: false }, { key: 'scheduled_label', label: 'Jadwal', sortable: false },
{ key: 'status_label', label: 'Status' },
]
const actions = [
{ key: 'approve', label: 'Setujui', color: 'success' },
]
function defaults() {
return {
tenant_id: '', name: '', channel: 'whatsapp_official', notification_channel_id: '',
notification_template_id: '', subject: '', body: '', scheduled_at: '',
filters: { customer_status: ['active'], source: '', package_profile_id: '', billing_profile_id: '', topology_node_id: '' },
}
}
function normalize(item) {
const labels = Object.fromEntries(channelOptions)
return {
...item, channel_label: labels[item.channel] || item.channel,
recipient_label: `${item.processed_recipients || 0} / ${item.estimated_recipients || 0}`,
scheduled_label: item.scheduled_at || 'Segera',
status_label: ({ draft: 'Draft', pending_approval: 'Menunggu Persetujuan', scheduled: 'Terjadwal', queued: 'Antrean', processing: 'Diproses', completed: 'Selesai', cancelled: 'Dibatalkan' })[item.status] || item.status,
}
}
async function fetchData(params = query.value) {
query.value = { ...query.value, ...params }; loading.value = true
try {
const response = await getBroadcasts(query.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 pesan siaran.') }
finally { loading.value = false; loadedOnce.value = true }
}
async function loadOptions() {
const calls = [getCustomerOptions(), getTopologyOptions()]
if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
const [customerResponse, topologyResponse, tenantResponse] = await Promise.all(calls)
packageProfiles.value = customerResponse?.data?.package_profiles || []
billingProfiles.value = customerResponse?.data?.billing_profiles || []
topologyNodes.value = topologyResponse?.data?.nodes || []
tenants.value = tenantResponse?.data?.data || []
}
async function loadChannelOptions() {
const [channelResponse, templateResponse] = await Promise.all([
getNotificationChannels(form.channel), getNotificationTemplates(form.channel),
])
channels.value = (channelResponse?.data || []).filter((item) => item.enabled && (!form.tenant_id || Number(item.tenant_id) === Number(form.tenant_id)))
templates.value = (templateResponse?.data || []).filter((item) => item.enabled && (!item.tenant_id || !form.tenant_id || Number(item.tenant_id) === Number(form.tenant_id)))
form.notification_channel_id = channels.value.find((item) => item.is_default)?.id || channels.value[0]?.id || ''
form.notification_template_id = ''
}
async function openForm() {
Object.keys(form).forEach((key) => delete form[key]); Object.assign(form, defaults())
preview.value = null; await loadOptions(); await loadChannelOptions(); formVisible.value = true
}
function payload() {
return {
...form, tenant_id: form.tenant_id || null,
notification_channel_id: form.notification_channel_id || null,
notification_template_id: form.notification_template_id || null,
scheduled_at: form.scheduled_at || null,
filters: Object.fromEntries(Object.entries(form.filters).map(([key, value]) => [key, value || undefined])),
}
}
async function calculatePreview() {
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
saving.value = true
try { preview.value = (await previewBroadcast(payload()))?.data }
catch (error) { showError(error?.response?.data?.message || 'Gagal menghitung penerima.') }
finally { saving.value = false }
}
async function submit() {
if (!form.name?.trim()) return showError('Nama siaran wajib diisi.')
if (form.channel === 'whatsapp_official' && !form.notification_template_id) return showError('WhatsApp Official wajib menggunakan template yang sudah disetujui.')
if (!preview.value) return showError('Hitung dan periksa penerima terlebih dahulu.')
saving.value = true
try {
await createBroadcast(payload()); formVisible.value = false
showSuccess('Pesan siaran berhasil dimasukkan ke antrean.'); await fetchData()
} catch (error) { showError(error?.response?.data?.message || 'Gagal membuat pesan siaran.') }
finally { saving.value = false }
}
async function onAction({ action, item }) {
if (action !== 'approve' || item.status !== 'pending_approval') return
try { await approveBroadcast(item.id); showSuccess('Siaran berhasil disetujui.'); await fetchData() }
catch (error) { showError(error?.response?.data?.message || 'Siaran tidak dapat disetujui.') }
}
watch(() => form.channel, () => { if (formVisible.value) loadChannelOptions() })
watch(() => form.tenant_id, () => { preview.value = null; if (formVisible.value) loadChannelOptions() })
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">
<div><h5 class="mb-0 fw-semibold">Pesan Siaran</h5><small class="text-body-secondary">Broadcast tersegmentasi dengan snapshot penerima dan pengiriman melalui queue</small></div>
<CButton color="primary" size="sm" @click="openForm">+ Buat Siaran</CButton>
</div>
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
<BaseResponsiveDataView v-else :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" @query-change="fetchData" @action-click="onAction" />
</div></CCol></CRow>
<CModal :visible="formVisible" @close="formVisible = false" size="xl"><CModalHeader dismiss @close="formVisible = false"><CModalTitle>Buat Pesan Siaran</CModalTitle></CModalHeader><CModalBody>
<CRow class="g-3">
<CCol v-if="isMaster" :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>Nama Siaran</CFormLabel><CFormInput v-model="form.name" /></CCol>
<CCol :md="4"><CFormLabel>Channel</CFormLabel><CFormSelect v-model="form.channel"><option v-for="[value, label] in channelOptions" :key="value" :value="value">{{ label }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Konfigurasi Pengirim</CFormLabel><CFormSelect v-model="form.notification_channel_id" :disabled="form.channel === 'system'"><option value="">Default</option><option v-for="item in channels" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Template Pesan</CFormLabel><CFormSelect v-model="form.notification_template_id"><option value="">Pesan manual</option><option v-for="item in templates" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect></CCol>
<CCol v-if="form.channel === 'email' || form.channel === 'system'" :xs="12"><CFormLabel>Judul</CFormLabel><CFormInput v-model="form.subject" /></CCol>
<CCol :xs="12"><CFormLabel>Pesan Manual</CFormLabel><CFormTextarea v-model="form.body" rows="5" :disabled="Boolean(form.notification_template_id)" /><CFormText>Variabel customer tetap dapat digunakan, misalnya &#123;&#123;customer.name&#125;&#125;.</CFormText></CCol>
<CCol :xs="12"><h6 class="section-title">Filter Penerima</h6></CCol>
<CCol :md="4"><CFormLabel>Status Customer</CFormLabel><CFormSelect v-model="form.filters.customer_status" multiple><option value="order">Order</option><option value="active">Aktif</option><option value="inactive">Tidak Aktif</option><option value="unmanaged">Unmanage</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Status/Sumber Koneksi</CFormLabel><CFormSelect v-model="form.filters.source"><option value="">Semua</option><option value="manual">Terkelola</option><option value="nas">Terdeteksi NAS</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Topologi</CFormLabel><CFormSelect v-model="form.filters.topology_node_id"><option value="">Semua topologi</option><option v-for="node in topologyNodes.filter((x) => !form.tenant_id || Number(x.tenant_id) === Number(form.tenant_id))" :key="node.id" :value="node.id">{{ node.device_type?.code }} · {{ node.code }} {{ node.name }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Profile Paket</CFormLabel><CFormSelect v-model="form.filters.package_profile_id"><option value="">Semua paket</option><option v-for="item in packageProfiles.filter((x) => !form.tenant_id || Number(x.tenant_id) === Number(form.tenant_id))" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Profile Tagihan</CFormLabel><CFormSelect v-model="form.filters.billing_profile_id"><option value="">Semua profile</option><option v-for="item in billingProfiles.filter((x) => !form.tenant_id || Number(x.tenant_id) === Number(form.tenant_id))" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Jadwalkan</CFormLabel><CFormInput v-model="form.scheduled_at" type="datetime-local" /></CCol>
<CCol :xs="12"><CButton color="info" variant="outline" :disabled="saving" @click="calculatePreview">Hitung Penerima</CButton></CCol>
<CCol v-if="preview" :xs="12"><CAlert color="info"><strong>{{ preview.estimated_recipients }}</strong> customer akan menerima pesan.<div class="sample-list"><span v-for="customer in preview.sample" :key="customer.id">{{ customer.customer_code }} · {{ customer.name }}</span></div></CAlert></CCol>
</CRow>
</CModalBody><CModalFooter><CButton color="secondary" @click="formVisible = false">Batal</CButton><CButton color="primary" :disabled="saving || !preview" @click="submit">Kirim/Jadwalkan</CButton></CModalFooter></CModal>
</template>
<style scoped>
.section-title { margin: .5rem 0 0; padding-bottom: .5rem; border-bottom: 1px solid var(--cui-border-color); }
.sample-list { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .6rem; }
.sample-list span { padding: .25rem .45rem; font-size: .75rem; border: 1px solid currentColor; border-radius: 999px; }
</style>
@@ -0,0 +1,244 @@
<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 {
createNotificationChannel, createNotificationTemplate, getNotificationChannels,
getNotificationLogs, getNotificationPreferences, getNotificationTemplates,
saveNotificationPreferences, testNotificationChannel, updateNotificationChannel,
updateNotificationTemplate,
} from '@/services/notificationService'
import { showError, showSuccess } from '@/utils/swal.js'
const route = useRoute(), authStore = useAuthStore()
const channel = computed(() => route.meta.notificationChannel)
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
const configs = {
system: {
title: 'Notifikasi Aplikasi', provider: 'internal',
settings: [['retention_days', 'Retensi Notifikasi (hari)', 'number'], ['maximum_notifications_per_user', 'Maksimal per User', 'number']],
credentials: [],
},
whatsapp_official: {
title: 'WhatsApp Official', provider: 'meta_cloud_api',
settings: [['base_url', 'Graph API Base URL'], ['default_country_code', 'Kode Negara Default']],
credentials: [['phone_number_id', 'Phone Number ID'], ['business_account_id', 'Business Account ID'], ['access_token', 'Access Token', 'password'], ['webhook_verify_token', 'Webhook Verify Token', 'password'], ['app_secret', 'App Secret', 'password']],
},
whatsapp_unofficial: {
title: 'WhatsApp Unofficial', provider: 'generic_gateway',
settings: [['send_url', 'URL Kirim Pesan'], ['health_url', 'URL Health/Session'], ['api_key_header', 'Nama Header API Key'], ['destination_field', 'Field Nomor Tujuan'], ['message_field', 'Field Isi Pesan'], ['message_id_path', 'Path ID Pesan']],
credentials: [['instance_id', 'Instance ID'], ['api_key', 'API Key', 'password'], ['webhook_secret', 'Webhook Secret', 'password']],
},
email: {
title: 'Email', provider: 'smtp',
settings: [['host', 'SMTP Host'], ['port', 'SMTP Port', 'number'], ['encryption', 'Enkripsi'], ['from_address', 'Alamat Pengirim', 'email'], ['from_name', 'Nama Pengirim'], ['reply_to', 'Reply To', 'email']],
credentials: [['username', 'SMTP Username'], ['password', 'SMTP Password', 'password']],
},
telegram: {
title: 'Telegram', provider: 'telegram_bot',
settings: [['parse_mode', 'Parse Mode'], ['default_chat_id', 'Default Chat ID']],
credentials: [['bot_token', 'Bot Token', 'password'], ['webhook_secret', 'Webhook Secret', 'password']],
},
}
const config = computed(() => configs[channel.value])
const activeTab = ref('settings')
const loading = ref(true), saving = ref(false), modal = ref(false), editingId = ref(null)
const channels = ref([]), templates = ref([]), logs = ref([]), tenants = ref([])
const events = ref([]), preferences = ref([]), selectedTenantId = ref('')
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const query = ref({ page: 1, per_page: 10 })
const form = reactive({}), templateForm = reactive({})
const tabs = computed(() => [
['settings', 'Setting'],
['templates', 'Template Pesan'],
...(channel.value === 'system' ? [['automation', 'Otomasi Event']] : []),
['logs', 'Log Pesan'],
])
const logColumns = [
{ key: 'created_at', label: 'Waktu' }, { key: 'destination', label: 'Tujuan' },
{ key: 'message_label', label: 'Pesan', sortable: false }, { key: 'status_label', label: 'Status' },
{ key: 'attempt_count', label: 'Percobaan' },
]
const defaultChannelConfig = computed(() => channels.value.find((item) => item.is_default) || channels.value[0])
function emptyChannelForm() {
return {
tenant_id: selectedTenantId.value, channel: channel.value, provider: config.value.provider,
name: config.value.title, enabled: true, is_default: true, settings: {}, credentials: {},
}
}
function openSetting(item = null) {
editingId.value = item?.id || null
Object.keys(form).forEach((key) => delete form[key])
Object.assign(form, emptyChannelForm(), item || {})
form.settings = { ...(item?.settings || {}) }
form.credentials = {}
modal.value = true
}
function openTemplate(item) {
editingId.value = item.id
Object.keys(templateForm).forEach((key) => delete templateForm[key])
Object.assign(templateForm, {
tenant_id: selectedTenantId.value, notification_event_id: item.notification_event_id,
notification_channel_id: item.notification_channel_id || defaultChannelConfig.value?.id || null,
channel: channel.value, name: item.name, subject: item.subject || '', body: item.body,
provider_template_name: item.provider_template_name || '', provider_template_id: item.provider_template_id || '',
language: item.language || 'id', approval_status: item.approval_status || 'not_required', enabled: item.enabled,
})
modal.value = true
}
async function loadBase() {
if (isMaster.value && !tenants.value.length) {
tenants.value = (await getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))?.data?.data || []
}
}
async function loadTab(params = query.value) {
loading.value = true
try {
await loadBase()
if (activeTab.value === 'settings') channels.value = (await getNotificationChannels(channel.value))?.data || []
if (activeTab.value === 'templates') {
const [channelResponse, templateResponse] = await Promise.all([getNotificationChannels(channel.value), getNotificationTemplates(channel.value)])
channels.value = channelResponse?.data || []; templates.value = templateResponse?.data || []
}
if (activeTab.value === 'automation') {
const response = await getNotificationPreferences({ tenant_id: selectedTenantId.value || undefined })
events.value = response?.data?.events || []; preferences.value = response?.data?.preferences || []
}
if (activeTab.value === 'logs') {
query.value = { ...query.value, ...params }
const response = await getNotificationLogs(channel.value, query.value)
const rows = response?.data?.data || []
logs.value = rows.map((item) => ({
...item, message_label: item.rendered_body?.slice(0, 90), status_label: item.status,
}))
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 modul notifikasi.') }
finally { loading.value = false }
}
async function saveSetting() {
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
saving.value = true
try {
if (editingId.value) await updateNotificationChannel(channel.value, editingId.value, form)
else await createNotificationChannel(channel.value, form)
modal.value = false; showSuccess('Konfigurasi berhasil disimpan.'); await loadTab()
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan konfigurasi.') }
finally { saving.value = false }
}
async function saveTemplate() {
if (isMaster.value && !templateForm.tenant_id) return showError('Tenant untuk override template wajib dipilih.')
saving.value = true
try {
await updateNotificationTemplate(channel.value, editingId.value, templateForm)
modal.value = false; showSuccess('Template tenant berhasil disimpan.'); await loadTab()
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan template.') }
finally { saving.value = false }
}
async function test(item) {
try {
const response = await testNotificationChannel(channel.value, item.id)
showSuccess(response?.data?.last_health_message || 'Pengujian selesai.'); await loadTab()
} catch (error) { showError(error?.response?.data?.message || 'Pengujian koneksi gagal.') }
}
function preference(event, targetChannel) {
return preferences.value.find((item) => item.notification_event_id === event.id && item.channel === targetChannel)
}
function preferenceEnabled(event, targetChannel) {
return preference(event, targetChannel)?.enabled || false
}
async function setPreference(event, targetChannel, enabled) {
if (isMaster.value && !selectedTenantId.value) return showError('Pilih tenant terlebih dahulu.')
const existing = preference(event, targetChannel)
const row = {
tenant_id: selectedTenantId.value || undefined, notification_event_id: event.id,
notification_channel_id: existing?.notification_channel_id || (targetChannel === channel.value ? defaultChannelConfig.value?.id : null),
channel: targetChannel, enabled, priority: existing?.priority || 1, send_delay_minutes: existing?.send_delay_minutes || 0,
}
try { await saveNotificationPreferences([row]); await loadTab() }
catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan otomasi.') }
}
watch([channel, activeTab], () => {
editingId.value = null; modal.value = false; loadTab()
})
watch(selectedTenantId, () => { if (activeTab.value === 'automation') loadTab() })
onMounted(loadTab)
</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">{{ config.title }}</h5><small class="text-body-secondary">Konfigurasi, template, otomasi dan monitoring pengiriman</small></div>
<CButton v-if="activeTab === 'settings'" color="primary" size="sm" @click="openSetting()">+ Tambah Setting</CButton>
</div>
<div class="notification-tabs">
<button v-for="[key, label] in tabs" :key="key" type="button" :class="{ active: activeTab === key }" @click="activeTab = key">{{ label }}</button>
</div>
<div v-if="loading" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
<div v-else-if="activeTab === 'settings'" class="setting-grid">
<CAlert v-if="!channels.length" color="info">Belum ada konfigurasi {{ config.title }} untuk tenant ini.</CAlert>
<CCard v-for="item in channels" :key="item.id" class="setting-card"><CCardBody>
<div class="d-flex justify-content-between gap-2"><div><h6>{{ item.name }}</h6><small>{{ item.tenant?.tenant_name || 'Tenant aktif' }}</small></div><CBadge :color="item.enabled ? 'success' : 'secondary'">{{ item.enabled ? 'Aktif' : 'Nonaktif' }}</CBadge></div>
<div class="health-line"><span>Kredensial</span><strong>{{ item.credentials_configured ? 'Tersimpan' : 'Belum lengkap' }}</strong></div>
<div class="health-line"><span>Status koneksi</span><strong>{{ item.last_health_status || 'Belum diuji' }}</strong></div>
<div class="d-flex gap-2 mt-3"><CButton size="sm" color="success" variant="outline" @click="openSetting(item)">Edit</CButton><CButton size="sm" color="info" variant="outline" @click="test(item)">Tes Koneksi</CButton></div>
</CCardBody></CCard>
</div>
<div v-else-if="activeTab === 'templates'" class="template-grid">
<CCard v-for="item in templates" :key="item.id" class="template-card"><CCardBody>
<div class="d-flex justify-content-between gap-2"><div><CBadge color="primary">{{ item.event?.category }}</CBadge><h6 class="mt-2 mb-1">{{ item.event?.name }}</h6></div><CBadge :color="item.enabled ? 'success' : 'secondary'">{{ item.enabled ? 'Enable' : 'Disable' }}</CBadge></div>
<p>{{ item.body }}</p><small>{{ item.tenant_id ? 'Override tenant' : 'Template bawaan' }} · Versi {{ item.version }}</small>
<div class="mt-3"><CButton size="sm" color="primary" variant="outline" @click="openTemplate(item)">Atur Template</CButton></div>
</CCardBody></CCard>
</div>
<div v-else-if="activeTab === 'automation'">
<div v-if="isMaster" class="mb-3"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="selectedTenantId" class="tenant-select"><option value="">Pilih tenant</option><option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">{{ tenant.tenant_name }}</option></CFormSelect></div>
<CCard><CCardBody class="p-0"><div class="automation-table">
<div class="automation-row automation-head"><strong>Jenis Pesan</strong><span>Aplikasi</span><span>WA Official</span><span>WA Unofficial</span><span>Email</span><span>Telegram</span></div>
<div v-for="event in events" :key="event.id" class="automation-row"><div><strong>{{ event.name }}</strong><small>{{ event.code }}</small></div>
<CFormSwitch v-for="target in ['system','whatsapp_official','whatsapp_unofficial','email','telegram']" :key="target" :model-value="preferenceEnabled(event, target)" @change="setPreference(event, target, $event.target.checked)" />
</div>
</div></CCardBody></CCard>
</div>
<BaseResponsiveDataView v-else :columns="logColumns" :items="logs" server-side :pagination="pagination" :loading="loading" :actions="[]" hide-default-actions @query-change="loadTab" />
</div></CCol></CRow>
<CModal :visible="modal && activeTab === 'settings'" @close="modal = false" size="lg"><CModalHeader dismiss @close="modal = false"><CModalTitle>Setting {{ config.title }}</CModalTitle></CModalHeader><CModalBody><CRow class="g-3">
<CCol v-if="isMaster" :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>Nama Konfigurasi</CFormLabel><CFormInput v-model="form.name" /></CCol>
<CCol v-for="[key, label, type] in config.settings" :key="key" :md="6"><CFormLabel>{{ label }}</CFormLabel><CFormInput v-model="form.settings[key]" :type="type || 'text'" /></CCol>
<CCol v-for="[key, label, type] in config.credentials" :key="key" :md="6"><CFormLabel>{{ label }}</CFormLabel><CFormInput v-model="form.credentials[key]" :type="type || 'text'" :placeholder="editingId ? 'Kosongkan jika tidak diubah' : ''" /></CCol>
<CCol :md="6"><CFormCheck v-model="form.enabled" label="Channel aktif" /></CCol><CCol :md="6"><CFormCheck v-model="form.is_default" label="Jadikan default" /></CCol>
</CRow></CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="saveSetting">Simpan</CButton></CModalFooter></CModal>
<CModal :visible="modal && activeTab === 'templates'" @close="modal = false" size="lg"><CModalHeader dismiss @close="modal = false"><CModalTitle>Template Pesan</CModalTitle></CModalHeader><CModalBody><CRow class="g-3">
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant Override</CFormLabel><CFormSelect v-model="templateForm.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>Nama Template</CFormLabel><CFormInput v-model="templateForm.name" /></CCol>
<CCol v-if="channel === 'email' || channel === 'system'" :xs="12"><CFormLabel>Subject/Judul</CFormLabel><CFormInput v-model="templateForm.subject" /></CCol>
<CCol :xs="12"><CFormLabel>Isi Pesan</CFormLabel><CFormTextarea v-model="templateForm.body" rows="7" /><CFormText>Gunakan variabel seperti &#123;&#123;customer.name&#125;&#125; atau &#123;&#123;invoice.total&#125;&#125;.</CFormText></CCol>
<template v-if="channel === 'whatsapp_official'"><CCol :md="6"><CFormLabel>Nama Template Meta</CFormLabel><CFormInput v-model="templateForm.provider_template_name" /></CCol><CCol :md="3"><CFormLabel>Bahasa</CFormLabel><CFormInput v-model="templateForm.language" /></CCol><CCol :md="3"><CFormLabel>Approval</CFormLabel><CFormSelect v-model="templateForm.approval_status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="rejected">Rejected</option></CFormSelect></CCol></template>
<CCol :xs="12"><CFormCheck v-model="templateForm.enabled" label="Template aktif" /></CCol>
</CRow></CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="saveTemplate">Simpan Override</CButton></CModalFooter></CModal>
</template>
<style scoped>
.notification-tabs { display: flex; gap: .35rem; overflow-x: auto; margin-bottom: 1rem; padding-bottom: .25rem; }
.notification-tabs button { padding: .5rem .8rem; color: var(--cui-secondary-color); white-space: nowrap; background: transparent; border: 0; border-bottom: 2px solid transparent; }
.notification-tabs button.active { color: var(--cui-primary); border-bottom-color: var(--cui-primary); }
.setting-grid, .template-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: .8rem; }
.setting-card, .template-card { height: 100%; }
.health-line { display: flex; justify-content: space-between; gap: 1rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--cui-border-color); }
.template-card p { min-height: 3.5rem; margin: .75rem 0; white-space: pre-wrap; }
.tenant-select { max-width: 25rem; }
.automation-table { overflow-x: auto; }
.automation-row { display: grid; grid-template-columns: minmax(15rem, 2fr) repeat(5, minmax(7rem, 1fr)); align-items: center; min-width: 52rem; padding: .75rem; border-bottom: 1px solid var(--cui-border-color); }
.automation-row > div { display: flex; flex-direction: column; }
.automation-head { background: var(--cui-tertiary-bg); }
</style>
@@ -0,0 +1,75 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { createGatewayAccount, getPaymentProviders, getPaymentSettings, saveTenantPaymentSetting, updateGatewayAccount } from '@/services/paymentGatewayService'
import { showError, showSuccess } from '@/utils/swal'
const loading = ref(true), saving = ref(false), providers = ref([]), accounts = ref([]), setting = reactive({})
const modal = ref(false), editing = ref(null), form = reactive({})
const tenantAccounts = computed(() => accounts.value.filter((x) => x.scope === 'tenant'))
const platformAccounts = computed(() => accounts.value.filter((x) => x.scope === 'platform' && x.enabled))
const selectedProvider = computed(() => providers.value.find((x) => Number(x.id) === Number(form.payment_provider_id)))
const defaults = () => ({ payment_provider_id: '', scope: 'tenant', name: '', environment: 'sandbox', credentials: {}, webhook_secret: '', settings: {}, enabled: false, is_default: false })
async function load() {
loading.value = true
try {
const [providerResponse, settingResponse] = await Promise.all([getPaymentProviders(), getPaymentSettings()])
providers.value = providerResponse?.data || []; accounts.value = settingResponse?.data?.accounts || []
Object.assign(setting, { payment_mode: 'disabled', fee_bearer: 'customer', default_expiry_minutes: 1440, enabled_methods: [], ...(settingResponse?.data?.setting || {}) })
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat pengaturan pembayaran.') }
finally { loading.value = false }
}
function open(account = null) {
editing.value = account; Object.keys(form).forEach((key) => delete form[key])
Object.assign(form, defaults(), account || {}, { credentials: {}, webhook_secret: '' }); modal.value = true
}
async function saveAccount() {
saving.value = true
try {
if (editing.value) await updateGatewayAccount(editing.value.id, form); else await createGatewayAccount(form)
modal.value = false; showSuccess('Konfigurasi gateway berhasil disimpan.'); await load()
} catch (error) { showError(error?.response?.data?.message || 'Konfigurasi gateway gagal disimpan.') }
finally { saving.value = false }
}
async function saveMode() {
saving.value = true
try { await saveTenantPaymentSetting(setting); showSuccess('Mode pembayaran berhasil disimpan.'); await load() }
catch (error) { showError(error?.response?.data?.message || 'Mode pembayaran gagal disimpan.') }
finally { saving.value = false }
}
onMounted(load)
</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">Setting Payment Gateway</h5><small class="text-body-secondary">Pilih gateway tenant atau gateway global platform</small></div><CButton color="primary" size="sm" @click="open()">+ Gateway</CButton></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">
<CCol :lg="5"><CCard class="h-100"><CCardBody><h6>Mode Pembayaran Tenant</h6>
<CFormLabel>Mode</CFormLabel><CFormSelect v-model="setting.payment_mode" class="mb-3"><option value="disabled">Nonaktif</option><option value="tenant_gateway">Gateway milik tenant</option><option value="platform_gateway">Gateway global platform</option></CFormSelect>
<div v-if="setting.payment_mode === 'tenant_gateway'"><CFormLabel>Gateway Tenant Aktif</CFormLabel><CFormSelect v-model="setting.tenant_gateway_account_id" class="mb-3"><option value="">Pilih gateway</option><option v-for="item in tenantAccounts" :key="item.id" :value="item.id">{{ item.name }} · {{ item.provider?.name }}</option></CFormSelect></div>
<div v-if="setting.payment_mode === 'platform_gateway'"><CFormLabel>Gateway Platform</CFormLabel><CFormSelect v-model="setting.platform_gateway_account_id" class="mb-3"><option value="">Pilih gateway</option><option v-for="item in platformAccounts" :key="item.id" :value="item.id">{{ item.name }} · {{ item.provider?.name }}</option></CFormSelect></div>
<CFormLabel>Penanggung Biaya</CFormLabel><CFormSelect v-model="setting.fee_bearer" class="mb-3"><option value="customer">Customer</option><option value="tenant">Tenant</option><option value="platform">Platform</option></CFormSelect>
<CFormLabel>Kedaluwarsa Pembayaran (menit)</CFormLabel><CFormInput v-model.number="setting.default_expiry_minutes" type="number" min="5" class="mb-3" />
<CButton color="primary" :disabled="saving" @click="saveMode">Simpan Mode</CButton>
</CCardBody></CCard></CCol>
<CCol :lg="7"><CCard><CCardBody><h6>Daftar Akun Gateway</h6><div v-if="!accounts.length" class="text-body-secondary py-4 text-center">Belum ada akun gateway.</div>
<button v-for="item in accounts" :key="item.id" type="button" class="gateway-row" @click="open(item)"><span><strong>{{ item.name }}</strong><small>{{ item.provider?.name }} · {{ item.scope === 'platform' ? 'Global Platform' : 'Tenant' }} · {{ item.environment }}</small></span><CBadge :color="item.enabled ? 'success' : 'secondary'">{{ item.enabled ? 'Aktif' : 'Nonaktif' }}</CBadge></button>
</CCardBody></CCard></CCol>
</CRow>
</div>
<CModal :visible="modal" @close="modal = false"><CModalHeader><CModalTitle>Konfigurasi Gateway</CModalTitle></CModalHeader><CModalBody>
<CFormLabel>Provider</CFormLabel><CFormSelect v-model="form.payment_provider_id" class="mb-2"><option value="">Pilih provider</option><option v-for="item in providers" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect>
<CFormLabel>Lingkup</CFormLabel><CFormSelect v-model="form.scope" class="mb-2"><option value="tenant">Tenant</option><option value="platform">Global Platform</option></CFormSelect>
<CFormLabel>Nama Akun</CFormLabel><CFormInput v-model="form.name" class="mb-2" />
<CFormLabel>Environment</CFormLabel><CFormSelect v-model="form.environment" class="mb-2"><option value="sandbox">Sandbox</option><option value="production">Production</option></CFormSelect>
<div v-for="field in selectedProvider?.credential_schema || []" :key="field.key"><CFormLabel>{{ field.label }}</CFormLabel><CFormInput v-model="form.credentials[field.key]" :type="field.secret ? 'password' : 'text'" :placeholder="editing?.has_credentials ? 'Kosongkan jika tidak diubah' : ''" class="mb-2" /></div>
<CFormLabel>Webhook Secret</CFormLabel><CFormInput v-model="form.webhook_secret" type="password" class="mb-2" />
<CFormCheck v-model="form.enabled" label="Aktif" /><CFormCheck v-if="form.scope === 'platform'" v-model="form.is_default" label="Default platform" />
</CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="saveAccount">Simpan</CButton></CModalFooter></CModal>
</template>
<style scoped>
.gateway-row { display:flex; width:100%; align-items:center; justify-content:space-between; padding:.75rem 0; color:inherit; text-align:left; background:none; border:0; border-bottom:1px solid var(--cui-border-color); }
.gateway-row span { display:flex; flex-direction:column; }.gateway-row small { color:var(--cui-secondary-color); }
</style>
+33
View File
@@ -0,0 +1,33 @@
<script setup>
import { onMounted, ref } from 'vue'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import { getPaymentTransactions } from '@/services/paymentGatewayService'
import { showError } from '@/utils/swal'
const loading = ref(true), loaded = ref(false), items = ref([])
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
const columns = [
{ key: 'transaction_number', label: 'Nomor Transaksi' }, { key: 'customer_label', label: 'Customer/User' },
{ key: 'provider_label', label: 'Provider' }, { key: 'purpose_label', label: 'Tujuan' },
{ key: 'total_label', label: 'Total', sortKey: 'amount' }, { key: 'status', label: 'Status', sortKey: 'status' },
{ key: 'created_label', label: 'Dibuat', sortKey: 'created_at' },
]
const filterFields = [
{ key: 'date_from', label: 'Tanggal Mulai', type: 'date' }, { key: 'date_to', label: 'Tanggal Akhir', type: 'date' },
{ key: 'status', label: 'Status', type: 'select', options: [['created', 'Dibuat'], ['pending', 'Menunggu'], ['paid', 'Lunas'], ['failed', 'Gagal'], ['expired', 'Kedaluwarsa'], ['refunded', 'Refund']] },
{ key: 'purpose', label: 'Tujuan', type: 'select', options: [['invoice_payment', 'Bayar Tagihan'], ['wallet_topup', 'Isi Saldo'], ['deposit', 'Deposit']] },
]
const money = (value) => `Rp ${Number(value || 0).toLocaleString('id-ID')}`
async function fetchData(params = query.value) {
query.value = { ...query.value, ...params }; loading.value = true
try {
const response = await getPaymentTransactions(query.value), rows = response?.data?.data || []
items.value = rows.map((x) => ({ ...x, customer_label: x.customer?.name || x.user?.name || '-', provider_label: x.provider?.name || x.provider_code, purpose_label: { invoice_payment: 'Bayar Tagihan', wallet_topup: 'Isi Saldo', deposit: 'Deposit' }[x.purpose] || x.purpose, total_label: money(x.total_amount), created_label: new Date(x.created_at).toLocaleString('id-ID') }))
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 }
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat log pembayaran.') }
finally { loading.value = false; loaded.value = true }
}
onMounted(fetchData)
</script>
<template><div class="brdvx-page-container"><div class="brdvx-page-header"><h5 class="mb-0 fw-semibold">Log Pembayaran</h5><small class="text-body-secondary">Histori pembayaran customer dan transaksi gateway</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" :filter-fields="filterFields" server-side :pagination="pagination" :loading="loading" @query-change="fetchData" /></div></template>
+157
View File
@@ -0,0 +1,157 @@
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { getTopologyMap } from '@/services/topologyService'
import { showError } from '@/utils/swal.js'
const mapElement = ref(null)
const loading = ref(true)
const counts = ref({ nodes: 0, links: 0 })
let map = null
let resizeObserver = null
let requestSequence = 0
const hybridStyle = {
version: 8,
sources: {
satellite: {
type: 'raster',
tiles: ['https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
maxzoom: 19,
attribution: 'Tiles &copy; Esri and imagery providers',
},
transportation: {
type: 'raster',
tiles: ['https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
maxzoom: 19,
},
labels: {
type: 'raster',
tiles: ['https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
maxzoom: 19,
},
},
layers: [
{ id: 'satellite', type: 'raster', source: 'satellite' },
{ id: 'transportation', type: 'raster', source: 'transportation' },
{ id: 'labels', type: 'raster', source: 'labels' },
],
}
function bbox() {
const bounds = map.getBounds()
return [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join(',')
}
function updateSources(data) {
const nodeData = data?.nodes || { type: 'FeatureCollection', features: [] }
const linkData = data?.links || { type: 'FeatureCollection', features: [] }
counts.value = { nodes: nodeData.features.length, links: linkData.features.length }
map.getSource('topology-nodes')?.setData(nodeData)
map.getSource('topology-links')?.setData(linkData)
}
async function fetchMap() {
if (!map?.isStyleLoaded()) return
const sequence = ++requestSequence
loading.value = true
try {
const response = await getTopologyMap({ bbox: bbox() })
if (sequence === requestSequence) updateSources(response?.data)
} catch (error) {
showError(error?.response?.data?.message || 'Gagal mengambil data peta topologi.')
} finally {
if (sequence === requestSequence) loading.value = false
}
}
function popupContent(properties) {
const root = document.createElement('div')
const title = document.createElement('strong')
const meta = document.createElement('div')
title.textContent = `${properties.code}${properties.name}`
meta.textContent = properties.device_type
? `${properties.device_type} · ${properties.status}`
: `${properties.link_type} · ${properties.status}`
root.append(title, meta)
return root
}
async function initializeMap() {
await nextTick()
map = new maplibregl.Map({
container: mapElement.value,
center: [118, -2.5],
zoom: 4,
style: hybridStyle,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-left')
map.on('load', () => {
map.addSource('topology-links', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addSource('topology-nodes', { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, cluster: true, clusterRadius: 45 })
map.addLayer({
id: 'topology-link-lines', type: 'line', source: 'topology-links',
paint: { 'line-color': ['match', ['get', 'status'], 'damaged', '#dc3545', 'maintenance', '#ffc107', '#29b6f6'], 'line-width': 4, 'line-opacity': .9 },
})
map.addLayer({
id: 'node-clusters', type: 'circle', source: 'topology-nodes', filter: ['has', 'point_count'],
paint: { 'circle-color': '#334155', 'circle-radius': ['step', ['get', 'point_count'], 18, 20, 24, 100, 30], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 },
})
map.addLayer({
id: 'node-cluster-count', type: 'symbol', source: 'topology-nodes', filter: ['has', 'point_count'],
layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-size': 12 }, paint: { 'text-color': '#fff' },
})
map.addLayer({
id: 'topology-node-points', type: 'circle', source: 'topology-nodes', filter: ['!', ['has', 'point_count']],
paint: { 'circle-color': ['get', 'color'], 'circle-radius': 9, 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 },
})
map.on('click', 'node-clusters', async (event) => {
const feature = map.queryRenderedFeatures(event.point, { layers: ['node-clusters'] })[0]
const zoom = await map.getSource('topology-nodes').getClusterExpansionZoom(feature.properties.cluster_id)
map.easeTo({ center: feature.geometry.coordinates, zoom })
})
for (const layer of ['topology-node-points', 'topology-link-lines']) {
map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = '' })
map.on('click', layer, (event) => {
const feature = event.features[0]
const coordinates = feature.geometry.type === 'Point'
? feature.geometry.coordinates
: feature.geometry.coordinates[Math.floor(feature.geometry.coordinates.length / 2)]
new maplibregl.Popup().setLngLat(coordinates).setDOMContent(popupContent(feature.properties)).addTo(map)
})
}
fetchMap()
})
map.on('moveend', fetchMap)
resizeObserver = new ResizeObserver(() => map?.resize())
resizeObserver.observe(mapElement.value)
}
onMounted(initializeMap)
onBeforeUnmount(() => {
resizeObserver?.disconnect()
map?.remove()
map = null
})
</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">Peta Jaringan</h5><small class="text-body-secondary">Visualisasi geografis perangkat dan jalur FTTH</small></div>
<div class="d-flex gap-2"><CBadge color="primary">{{ counts.nodes }} perangkat</CBadge><CBadge color="info">{{ counts.links }} jalur</CBadge></div>
</div>
<div class="topology-map-wrap">
<div ref="mapElement" class="topology-map"></div>
<div v-if="loading" class="map-loading"><CSpinner size="sm" /><span>Memuat area peta...</span></div>
</div>
</div></CCol></CRow>
</template>
<style scoped>
.topology-map-wrap { position: relative; overflow: hidden; border: 1px solid var(--cui-border-color); border-radius: .8rem; }
.topology-map { width: 100%; min-height: calc(100vh - 10rem); }
.map-loading { position: absolute; top: .75rem; left: 50%; display: flex; align-items: center; gap: .5rem; padding: .45rem .7rem; color: #fff; border-radius: .5rem; background: rgba(15, 23, 42, .85); transform: translateX(-50%); }
@media (max-width: 767.98px) { .topology-map { min-height: calc(100vh - 12rem); } }
</style>
+66
View File
@@ -0,0 +1,66 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { getTopologyOptions, traceTopology } from '@/services/topologyService'
import { showError } from '@/utils/swal.js'
const nodes = ref([])
const selectedNodeId = ref('')
const direction = ref('downstream')
const levels = ref([])
const loading = ref(true)
const selectedNode = computed(() => nodes.value.find((node) => Number(node.id) === Number(selectedNodeId.value)))
async function loadOptions() {
loading.value = true
try {
nodes.value = (await getTopologyOptions())?.data?.nodes || []
const pop = nodes.value.find((node) => node.device_type?.code === 'POP')
selectedNodeId.value = pop?.id || nodes.value[0]?.id || ''
if (selectedNodeId.value) await trace()
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat topologi jaringan.') }
finally { loading.value = false }
}
async function trace() {
if (!selectedNodeId.value) return
loading.value = true
try { levels.value = (await traceTopology(selectedNodeId.value, direction.value))?.data || [] }
catch (error) { showError(error?.response?.data?.message || 'Gagal menelusuri jalur topologi.') }
finally { loading.value = false }
}
onMounted(loadOptions)
</script>
<template>
<CRow class="brdvx-page-row g-0 m-0"><CCol :xs="12" class="brdvx-page-column p-0"><div class="brdvx-page-container">
<div class="brdvx-page-header"><h5 class="mb-0 fw-semibold">Topologi Jaringan</h5><small class="text-body-secondary">Penelusuran hubungan logis upstream dan downstream</small></div>
<CCard class="mb-3"><CCardBody><CRow class="g-2 align-items-end">
<CCol :md="7"><CFormLabel>Perangkat Acuan</CFormLabel><CFormSelect v-model="selectedNodeId"><option value="">Pilih perangkat</option><option v-for="node in nodes" :key="node.id" :value="node.id">{{ node.device_type?.code }} · {{ node.code }} {{ node.name }}</option></CFormSelect></CCol>
<CCol :md="3"><CFormLabel>Arah Penelusuran</CFormLabel><CFormSelect v-model="direction"><option value="downstream">Downstream</option><option value="upstream">Upstream</option></CFormSelect></CCol>
<CCol :md="2"><CButton color="primary" class="w-100" @click="trace">Tampilkan</CButton></CCol>
</CRow></CCardBody></CCard>
<div v-if="loading" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat topologi...</p></div>
<CAlert v-else-if="!levels.length" color="info">Belum ada hubungan jaringan dari perangkat yang dipilih.</CAlert>
<div v-else class="network-flow">
<div v-for="(level, index) in levels" :key="level.level" class="network-level">
<div class="level-title">{{ index === 0 ? 'Perangkat acuan' : `Tingkat ${index}` }}</div>
<div class="level-nodes">
<CCard v-for="node in level.nodes" :key="node.id" class="network-node" :style="{ '--node-color': node.device_type?.color || '#2f74c0' }">
<CCardBody><CBadge :style="{ backgroundColor: node.device_type?.color || '#2f74c0' }">{{ node.device_type?.code }}</CBadge><strong>{{ node.name }}</strong><small>{{ node.code }}</small></CCardBody>
</CCard>
</div>
<div v-if="index < levels.length - 1" class="level-arrow"></div>
</div>
</div>
</div></CCol></CRow>
</template>
<style scoped>
.network-flow { display: flex; flex-direction: column; align-items: stretch; gap: .4rem; }
.network-level { text-align: center; }
.level-title { margin-bottom: .5rem; color: var(--cui-secondary-color); font-size: .8rem; font-weight: 600; text-transform: uppercase; }
.level-nodes { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: .75rem; }
.network-node { border-top: 4px solid var(--node-color); }
.network-node :deep(.card-body) { display: flex; flex-direction: column; align-items: center; gap: .35rem; }
.network-node small { color: var(--cui-secondary-color); }
.level-arrow { color: var(--cui-primary); font-size: 1.5rem; line-height: 1.2; }
</style>
+287
View File
@@ -0,0 +1,287 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
import CustomerLocationMap from '@/components/customers/CustomerLocationMap.vue'
import { useAuthStore } from '@/stores/auth'
import { getTenants } from '@/services/tenantService'
import {
createNodePort,
createTopologyResource,
deleteNodePort,
deleteTopologyResource,
getNodePorts,
getTopologyOptions,
getTopologyResource,
getTopologyResources,
updateNodePort,
updateTopologyResource,
} from '@/services/topologyService'
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
const route = useRoute()
const authStore = useAuthStore()
const resource = computed(() => route.meta.topologyResource)
const loading = ref(true), loadedOnce = ref(false), saving = ref(false)
const items = ref([]), tenants = ref([]), deviceTypes = ref([]), nodes = ref([])
const modal = ref(false), detailModal = ref(false), portModal = ref(false)
const editingId = ref(null), detail = ref(null), selectedNode = ref(null), ports = ref([])
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
const form = reactive({}), portForm = reactive({})
const editingPortId = ref(null)
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
const configs = {
'device-type': {
title: 'Kategori Perangkat',
subtitle: 'Master kategori perangkat topologi FTTH',
columns: [
{ key: 'code', label: 'Kode' },
{ key: 'name', label: 'Nama' },
{ key: 'category_label', label: 'Kategori', sortKey: 'category' },
{ key: 'status_label', label: 'Status', sortKey: 'status' },
],
},
node: {
title: 'Perangkat Topologi',
subtitle: 'POP, OTB, JB, ODC, ODP dan splitter jaringan',
columns: [
{ key: 'code', label: 'Kode' },
{ key: 'name', label: 'Nama' },
{ key: 'type_label', label: 'Tipe', sortable: false },
{ key: 'coordinate_label', label: 'Koordinat', sortable: false },
{ key: 'status_label', label: 'Status', sortKey: 'status' },
],
},
link: {
title: 'Jalur Kabel',
subtitle: 'Hubungan fisik atau logis antarperangkat',
columns: [
{ key: 'code', label: 'Kode' },
{ key: 'name', label: 'Nama' },
{ key: 'source_label', label: 'Dari', sortable: false },
{ key: 'target_label', label: 'Ke', sortable: false },
{ key: 'link_type_label', label: 'Jenis', sortKey: 'link_type' },
{ key: 'status_label', label: 'Status', sortKey: 'status' },
],
},
}
const config = computed(() => configs[resource.value])
const actions = computed(() => {
const result = [{ key: 'detail', label: 'Detail', color: 'primary' }]
if (resource.value === 'node') result.push({ key: 'ports', label: 'Port', color: 'info' })
result.push({ key: 'edit', label: 'Edit', color: 'success' }, { key: 'delete', label: 'Hapus', color: 'danger' })
return result
})
const tenantNodes = computed(() => !form.tenant_id ? nodes.value : nodes.value.filter((node) => Number(node.tenant_id) === Number(form.tenant_id)))
const tenantTypes = computed(() => deviceTypes.value.filter((type) => !type.tenant_id || !form.tenant_id || Number(type.tenant_id) === Number(form.tenant_id)))
function defaults() {
return {
tenant_id: '', code: '', name: '', status: 'active',
category: 'distribution', can_have_ports: true, can_have_splitters: false,
color: '#2f74c0', icon: 'cil-location-pin',
device_type_id: '', parent_node_id: '', latitude: '', longitude: '',
address: '', description: '', capacity: '', installed_at: '',
source_node_id: '', target_node_id: '', source_port_id: '', target_port_id: '',
link_type: 'fiber', fiber_type: 'single-mode', core_count: 1,
cable_length: '', measured_length: '',
}
}
function normalize(item) {
const categories = { site: 'Site', termination: 'Terminasi', closure: 'Closure', distribution: 'Distribusi', splitter: 'Splitter', other: 'Lainnya' }
const linkTypes = { fiber: 'Fiber Optik', wireless: 'Wireless', ethernet: 'Ethernet', logical: 'Logical' }
return {
...item,
category_label: categories[item.category] || item.category,
type_label: item.device_type?.name || '-',
coordinate_label: item.latitude && item.longitude ? `${item.latitude}, ${item.longitude}` : '-',
source_label: item.source_node ? `${item.source_node.code}${item.source_node.name}` : '-',
target_label: item.target_node ? `${item.target_node.code}${item.target_node.name}` : '-',
link_type_label: linkTypes[item.link_type] || item.link_type,
status_label: ({ active: 'Aktif', inactive: 'Nonaktif', maintenance: 'Maintenance', damaged: 'Rusak' })[item.status] || item.status,
}
}
async function loadOptions() {
const calls = [getTopologyOptions()]
if (isMaster.value && !tenants.value.length) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
const [response, tenantResponse] = await Promise.all(calls)
deviceTypes.value = response?.data?.device_types || []
nodes.value = response?.data?.nodes || []
if (tenantResponse) tenants.value = tenantResponse?.data?.data || []
}
async function fetchData(params = query.value) {
query.value = { ...query.value, ...params }; loading.value = true
try {
const response = await getTopologyResources(resource.value, query.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 openForm(item = null) {
await loadOptions()
editingId.value = item?.id || null
Object.keys(form).forEach((key) => delete form[key])
Object.assign(form, defaults(), item || {})
modal.value = true
}
function payload() {
const common = { tenant_id: form.tenant_id || null, code: form.code?.trim(), name: form.name?.trim(), status: form.status }
if (resource.value === 'device-type') return {
...common, category: form.category, can_have_ports: form.can_have_ports,
can_have_splitters: form.can_have_splitters, color: form.color, icon: form.icon || null,
}
if (resource.value === 'node') return {
...common, device_type_id: form.device_type_id, parent_node_id: form.parent_node_id || null,
latitude: form.latitude, longitude: form.longitude, address: form.address || null,
description: form.description || null, capacity: form.capacity || null, installed_at: form.installed_at || null,
}
return {
...common, source_node_id: form.source_node_id, target_node_id: form.target_node_id,
source_port_id: form.source_port_id || null, target_port_id: form.target_port_id || null,
link_type: form.link_type, fiber_type: form.link_type === 'fiber' ? form.fiber_type || null : null,
core_count: form.core_count || null, cable_length: form.cable_length || null,
measured_length: form.measured_length || null, installed_at: form.installed_at || null,
}
}
async function save() {
if (!form.code?.trim() || !form.name?.trim()) return showError('Kode dan nama wajib diisi.')
if (isMaster.value && resource.value !== 'device-type' && !form.tenant_id) return showError('Tenant wajib dipilih.')
saving.value = true
try {
if (editingId.value) await updateTopologyResource(resource.value, editingId.value, payload())
else await createTopologyResource(resource.value, payload())
modal.value = false; showSuccess(`${config.value.title} berhasil disimpan.`)
await Promise.all([fetchData(), loadOptions()])
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan data topologi.') }
finally { saving.value = false }
}
async function openDetail(item) {
try { detail.value = normalize((await getTopologyResource(resource.value, item.id))?.data || item); detailModal.value = true }
catch (error) { showError(error?.response?.data?.message || 'Gagal mengambil detail.') }
}
async function remove(item) {
const result = await showConfirm('Hapus Data', `Hapus "${item.name}"?`); if (!result.isConfirmed) return
try { await deleteTopologyResource(resource.value, item.id); showSuccess('Data berhasil dihapus.'); await fetchData() }
catch (error) { showError(error?.response?.data?.message || 'Data tidak dapat dihapus.') }
}
async function openPorts(item) {
selectedNode.value = item
ports.value = (await getNodePorts(item.id))?.data || []
editingPortId.value = null
Object.assign(portForm, { name: '', port_number: ports.value.length + 1, port_type: 'fiber', direction: 'bidirectional', capacity: 1, status: 'available' })
portModal.value = true
}
function editPort(port) {
editingPortId.value = port.id
Object.assign(portForm, port)
}
async function savePort() {
saving.value = true
try {
if (editingPortId.value) await updateNodePort(editingPortId.value, portForm)
else await createNodePort(selectedNode.value.id, portForm)
await openPorts(selectedNode.value); showSuccess('Port berhasil disimpan.')
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan port.') }
finally { saving.value = false }
}
async function removePort(port) {
const result = await showConfirm('Hapus Port', `Hapus port "${port.name}"?`); if (!result.isConfirmed) return
try { await deleteNodePort(port.id); await openPorts(selectedNode.value) }
catch (error) { showError(error?.response?.data?.message || 'Port tidak dapat dihapus.') }
}
function action({ action: key, item }) {
if (key === 'detail') openDetail(item)
if (key === 'ports') openPorts(item)
if (key === 'edit') openForm(item)
if (key === 'delete') remove(item)
}
watch(resource, () => {
loadedOnce.value = false; items.value = []
query.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">
<div><h5 class="mb-0 fw-semibold">{{ config.title }}</h5><small class="text-body-secondary">{{ config.subtitle }}</small></div>
<CButton color="primary" size="sm" @click="openForm()">+ Tambah {{ resource === 'node' ? 'Perangkat' : resource === 'link' ? 'Jalur' : 'Kategori' }}</CButton>
</div>
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
<BaseResponsiveDataView v-else :columns="config.columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" @query-change="fetchData" @action-click="action" />
</div></CCol></CRow>
<CModal :visible="modal" @close="modal = false" size="xl" alignment="center"><CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editingId ? 'Edit' : 'Tambah' }} {{ config.title }}</CModalTitle></CModalHeader><CModalBody><CRow class="g-3">
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant {{ resource === 'device-type' ? '(kosong = global)' : '' }}</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Global / pilih tenant</option><option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">{{ tenant.tenant_name }}</option></CFormSelect></CCol>
<CCol :md="3"><CFormLabel>Kode</CFormLabel><CFormInput v-model="form.code" /></CCol>
<CCol :md="isMaster ? 3 : 6"><CFormLabel>Nama</CFormLabel><CFormInput v-model="form.name" /></CCol>
<template v-if="resource === 'device-type'">
<CCol :md="4"><CFormLabel>Kategori</CFormLabel><CFormSelect v-model="form.category"><option value="site">Site</option><option value="termination">Terminasi</option><option value="closure">Closure</option><option value="distribution">Distribusi</option><option value="splitter">Splitter</option><option value="other">Lainnya</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Warna Marker</CFormLabel><CFormInput v-model="form.color" type="color" /></CCol>
<CCol :md="4"><CFormLabel>Icon</CFormLabel><CFormInput v-model="form.icon" /></CCol>
<CCol :md="6"><CFormCheck v-model="form.can_have_ports" label="Dapat memiliki port" /></CCol>
<CCol :md="6"><CFormCheck v-model="form.can_have_splitters" label="Dapat memiliki splitter" /></CCol>
</template>
<template v-if="resource === 'node'">
<CCol :md="6"><CFormLabel>Tipe Perangkat</CFormLabel><CFormSelect v-model="form.device_type_id"><option value="">Pilih tipe</option><option v-for="type in tenantTypes" :key="type.id" :value="type.id">{{ type.code }} {{ type.name }}</option></CFormSelect></CCol>
<CCol :md="6"><CFormLabel>Parent Area/Perangkat</CFormLabel><CFormSelect v-model="form.parent_node_id"><option value="">Tanpa parent</option><option v-for="node in tenantNodes.filter((x) => x.id !== editingId)" :key="node.id" :value="node.id">{{ node.code }} {{ node.name }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Kapasitas</CFormLabel><CFormInput v-model="form.capacity" type="number" min="0" /></CCol>
<CCol :md="4"><CFormLabel>Tanggal Instalasi</CFormLabel><CFormInput v-model="form.installed_at" type="date" /></CCol>
<CCol :xs="12"><CFormLabel>Alamat</CFormLabel><CFormTextarea v-model="form.address" rows="2" /></CCol>
<CCol :xs="12"><CustomerLocationMap v-model:latitude="form.latitude" v-model:longitude="form.longitude" /></CCol>
<CCol :md="6"><CFormLabel>Latitude</CFormLabel><CFormInput v-model="form.latitude" type="number" step="0.0000001" /></CCol>
<CCol :md="6"><CFormLabel>Longitude</CFormLabel><CFormInput v-model="form.longitude" type="number" step="0.0000001" /></CCol>
</template>
<template v-if="resource === 'link'">
<CCol :md="6"><CFormLabel>Perangkat Asal</CFormLabel><CFormSelect v-model="form.source_node_id"><option value="">Pilih perangkat</option><option v-for="node in tenantNodes" :key="node.id" :value="node.id">{{ node.code }} {{ node.name }}</option></CFormSelect></CCol>
<CCol :md="6"><CFormLabel>Perangkat Tujuan</CFormLabel><CFormSelect v-model="form.target_node_id"><option value="">Pilih perangkat</option><option v-for="node in tenantNodes" :key="node.id" :value="node.id">{{ node.code }} {{ node.name }}</option></CFormSelect></CCol>
<CCol :md="4"><CFormLabel>Jenis Jalur</CFormLabel><CFormSelect v-model="form.link_type"><option value="fiber">Fiber Optik</option><option value="wireless">Wireless</option><option value="ethernet">Ethernet</option><option value="logical">Logical</option></CFormSelect></CCol>
<CCol v-if="form.link_type === 'fiber'" :md="4"><CFormLabel>Jenis Fiber</CFormLabel><CFormInput v-model="form.fiber_type" /></CCol>
<CCol :md="4"><CFormLabel>Jumlah Core</CFormLabel><CFormInput v-model="form.core_count" type="number" min="1" /></CCol>
<CCol :md="4"><CFormLabel>Panjang Rencana (meter)</CFormLabel><CFormInput v-model="form.cable_length" type="number" min="0" /></CCol>
<CCol :md="4"><CFormLabel>Panjang Terukur (meter)</CFormLabel><CFormInput v-model="form.measured_length" type="number" min="0" /></CCol>
<CCol :md="4"><CFormLabel>Tanggal Instalasi</CFormLabel><CFormInput v-model="form.installed_at" type="date" /></CCol>
</template>
<CCol :md="4"><CFormLabel>Status</CFormLabel><CFormSelect v-model="form.status"><option value="active">Aktif</option><option value="inactive">Nonaktif</option><option v-if="resource !== 'device-type'" value="maintenance">Maintenance</option><option v-if="resource !== 'device-type'" value="damaged">Rusak</option></CFormSelect></CCol>
</CRow></CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter></CModal>
<CModal :visible="detailModal" @close="detailModal = false" size="lg"><CModalHeader dismiss @close="detailModal = false"><CModalTitle>Detail {{ config.title }}</CModalTitle></CModalHeader><CModalBody v-if="detail">
<div class="topology-detail-grid"><div v-for="column in config.columns" :key="column.key"><span>{{ column.label }}</span><strong>{{ detail[column.key] || '-' }}</strong></div></div>
<CustomerLocationMap v-if="resource === 'node'" class="mt-3" :latitude="detail.latitude" :longitude="detail.longitude" readonly />
</CModalBody><CModalFooter><CButton color="secondary" @click="detailModal = false">Tutup</CButton></CModalFooter></CModal>
<CModal :visible="portModal" @close="portModal = false" size="xl"><CModalHeader dismiss @close="portModal = false"><CModalTitle>Port {{ selectedNode?.name }}</CModalTitle></CModalHeader><CModalBody>
<CRow class="g-3"><CCol :lg="5"><CCard><CCardBody><h6>{{ editingPortId ? 'Edit' : 'Tambah' }} Port</h6><div class="d-grid gap-2">
<div><CFormLabel>Nama</CFormLabel><CFormInput v-model="portForm.name" /></div>
<div><CFormLabel>Nomor</CFormLabel><CFormInput v-model="portForm.port_number" type="number" min="1" /></div>
<div><CFormLabel>Tipe</CFormLabel><CFormSelect v-model="portForm.port_type"><option value="fiber">Fiber</option><option value="pon">PON</option><option value="ethernet">Ethernet</option><option value="uplink">Uplink</option><option value="splitter">Splitter</option><option value="other">Lainnya</option></CFormSelect></div>
<div><CFormLabel>Arah</CFormLabel><CFormSelect v-model="portForm.direction"><option value="input">Input</option><option value="output">Output</option><option value="bidirectional">Bidirectional</option></CFormSelect></div>
<CButton color="primary" :disabled="saving" @click="savePort">Simpan Port</CButton>
</div></CCardBody></CCard></CCol><CCol :lg="7"><div class="port-grid"><div v-for="port in ports" :key="port.id" class="port-card" :class="`is-${port.status}`"><strong>{{ port.port_number }}. {{ port.name }}</strong><small>{{ port.port_type }} · {{ port.status }}</small><div><CButton size="sm" color="success" variant="outline" @click="editPort(port)">Edit</CButton><CButton size="sm" color="danger" variant="outline" @click="removePort(port)">Hapus</CButton></div></div></div></CCol></CRow>
</CModalBody></CModal>
</template>
<style scoped>
.topology-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem; }
.topology-detail-grid > div { display: flex; flex-direction: column; padding: .75rem; border: 1px solid var(--cui-border-color); border-radius: .6rem; }
.topology-detail-grid span { color: var(--cui-secondary-color); font-size: .8rem; }
.port-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; }
.port-card { display: flex; flex-direction: column; gap: .4rem; padding: .75rem; border: 1px solid var(--cui-border-color); border-left: 4px solid var(--cui-success); border-radius: .6rem; }
.port-card.is-used, .port-card.is-reserved { border-left-color: var(--cui-warning); }
.port-card.is-damaged { border-left-color: var(--cui-danger); }
.port-card > div { display: flex; gap: .4rem; }
@media (max-width: 575.98px) { .topology-detail-grid, .port-grid { grid-template-columns: 1fr; } }
</style>
+171
View File
@@ -0,0 +1,171 @@
<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 { showError, showSuccess } from '@/utils/swal'
const loading = ref(true), wallets = ref([]), activeId = ref(null), ledger = ref([]), modal = ref(''), 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 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 loadLedger(params = query.value) {
if (!active.value) 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) }))
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() {
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 }
}
watch(activeId, loadLedger)
onMounted(loadWallets)
</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>
</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>
<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%;
}
}
</style>