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
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:
Generated
+551
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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">×</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 © 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">← 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>
|
||||
@@ -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')">← 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>
|
||||
@@ -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 {{customer.name}}.</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 {{customer.name}} atau {{invoice.total}}.</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>
|
||||
@@ -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>
|
||||
@@ -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 © 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user