diff --git a/angular.json b/angular.json index 52d5a2f..8e66459 100644 --- a/angular.json +++ b/angular.json @@ -51,8 +51,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "20kB", - "maximumError": "45kB" + "maximumWarning": "35kB", + "maximumError": "60kB" } ], "outputHashing": "all" diff --git a/package-lock.json b/package-lock.json index bcb1605..c562289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "chart.js": "^4.5.1", + "exceljs": "^4.4.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -609,13 +610,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -624,9 +625,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -683,14 +684,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -713,13 +714,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -750,29 +751,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -825,27 +826,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -855,33 +856,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -889,9 +890,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -1354,10 +1355,64 @@ "node": ">=18" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", + "integrity": "sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "retry": "^0.13.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "dev": true, "license": "MIT", "engines": { @@ -1718,29 +1773,6 @@ } } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2390,9 +2422,9 @@ } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2413,18 +2445,18 @@ } }, "node_modules/@npmcli/git": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.1.tgz", - "integrity": "sha512-+XTFxK2jJF/EJJ5SoAzXk3qwIDfvFc5/g+bD274LZ7uY7LE8sTfG6Z8rOanPl2ZEvZWqNvmEdtXC25cE54VcoA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", "dev": true, "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", "semver": "^7.3.5", "which": "^6.0.0" }, @@ -2443,19 +2475,19 @@ } }, "node_modules/@npmcli/git/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2473,13 +2505,13 @@ } }, "node_modules/@npmcli/git/node_modules/which": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", - "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" @@ -2516,9 +2548,9 @@ } }, "node_modules/@npmcli/package-json": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.4.tgz", - "integrity": "sha512-0wInJG3j/K40OJt/33ax47WfWMzZTm6OQxB9cDhTt5huCP2a9g2GnlsxmfN+PulItNPIpPrZ+kfwwUil7eHcZQ==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2528,41 +2560,64 @@ "json-parse-even-better-errors": "^5.0.0", "proc-log": "^6.0.0", "semver": "^7.5.3", - "validate-npm-package-license": "^3.0.4" + "spdx-expression-parse": "^4.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@npmcli/package-json/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@npmcli/package-json/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@npmcli/package-json/node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.2", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@npmcli/package-json/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2592,23 +2647,23 @@ } }, "node_modules/@npmcli/promise-spawn/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@npmcli/promise-spawn/node_modules/which": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", - "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" @@ -2628,9 +2683,9 @@ } }, "node_modules/@npmcli/run-script": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.3.tgz", - "integrity": "sha512-ER2N6itRkzWbbtVmZ9WKaWxVlKlOeBFF1/7xx+KA5J1xKa4JjUwBdb6tDpk0v1qA+d+VDwHI9qmLcXSWcmi+Rw==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", "dev": true, "license": "ISC", "dependencies": { @@ -2638,23 +2693,12 @@ "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", - "proc-log": "^6.0.0", - "which": "^6.0.0" + "proc-log": "^6.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/run-script/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@npmcli/run-script/node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -2665,35 +2709,19 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/run-script/node_modules/which": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", - "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, "dependencies": { - "detect-libc": "^1.0.3", + "detect-libc": "^2.0.3", "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">= 10.0.0" @@ -2703,25 +2731,25 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" } }, "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", "cpu": [ "arm64" ], @@ -2740,9 +2768,9 @@ } }, "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", "cpu": [ "arm64" ], @@ -2761,9 +2789,9 @@ } }, "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", "cpu": [ "x64" ], @@ -2782,9 +2810,9 @@ } }, "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", "cpu": [ "x64" ], @@ -2803,9 +2831,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", "cpu": [ "arm" ], @@ -2824,9 +2852,9 @@ } }, "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", "cpu": [ "arm" ], @@ -2845,9 +2873,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", "cpu": [ "arm64" ], @@ -2866,9 +2894,9 @@ } }, "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", "cpu": [ "arm64" ], @@ -2887,9 +2915,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", "cpu": [ "x64" ], @@ -2908,9 +2936,9 @@ } }, "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", "cpu": [ "x64" ], @@ -2929,9 +2957,9 @@ } }, "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", "cpu": [ "arm64" ], @@ -2950,9 +2978,9 @@ } }, "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", "cpu": [ "ia32" ], @@ -2971,9 +2999,9 @@ } }, "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", "cpu": [ "x64" ], @@ -2991,20 +3019,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher/node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/@parcel/watcher/node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -3470,17 +3484,40 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@tufjs/models/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@tufjs/models/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@tufjs/models/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3514,16 +3551,16 @@ "license": "MIT" }, "node_modules/@types/jasmine": { - "version": "5.1.13", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.13.tgz", - "integrity": "sha512-MYCcDkruFc92LeYZux5BC0dmqo2jk+M5UIZ4/oFnAPCXN9mCcQhLyj7F3/Za7rocVyt5YRr1MmqJqFlvQ9LVcg==", + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.15.tgz", + "integrity": "sha512-ZAC8KjmV2MJxbNTrwXFN+HKeajpXQZp6KpPiR6Aa4XvaEnjP6qh23lL/Rqb7AYzlp3h/rcwDrQ7Gg7q28cQTQg==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", "peer": true, @@ -3647,9 +3684,9 @@ } }, "node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", "dependencies": { @@ -3715,11 +3752,105 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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": "MIT" }, "node_modules/base64id": { @@ -3765,6 +3896,28 @@ "node": ">=14.0.0" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3778,10 +3931,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "dev": true, "license": "MIT", "dependencies": { @@ -3791,7 +3961,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", + "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -3849,7 +4019,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -3870,9 +4039,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -3891,11 +4060,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -3904,6 +4073,39 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3911,6 +4113,23 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3944,28 +4163,51 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/cacache/node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.2", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3973,16 +4215,16 @@ } }, "node_modules/cacache/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4020,9 +4262,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "dev": true, "funding": [ { @@ -4040,6 +4282,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4214,11 +4468,25 @@ "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/connect": { @@ -4357,10 +4625,16 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "dev": true, "license": "MIT", "dependencies": { @@ -4369,6 +4643,35 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" } }, "node_modules/cross-spawn": { @@ -4433,6 +4736,12 @@ "node": ">=4.0" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4577,6 +4886,45 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4585,9 +4933,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.262", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", - "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "dev": true, "license": "ISC" }, @@ -4608,35 +4956,19 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", - "optional": true, "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "once": "^1.4.0" } }, "node_modules/engine.io": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", - "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", "dev": true, "license": "MIT", "dependencies": { @@ -4646,9 +4978,9 @@ "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", - "debug": "~4.3.1", + "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.17.1" + "ws": "~8.18.3" }, "engines": { "node": ">=10.2.0" @@ -4678,24 +5010,6 @@ "node": ">= 0.6" } }, - "node_modules/engine.io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/engine.io/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4920,6 +5234,26 @@ "node": ">=18.0.0" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -4973,13 +5307,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", + "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -4998,6 +5332,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.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", @@ -5054,9 +5401,9 @@ } }, "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dev": true, "license": "MIT", "dependencies": { @@ -5068,13 +5415,17 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", "dev": true, "license": "ISC" }, @@ -5119,6 +5470,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -5151,7 +5508,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -5169,6 +5525,35 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/fstream/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5200,9 +5585,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "dev": true, "license": "MIT", "engines": { @@ -5255,8 +5640,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -5310,7 +5694,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -5366,9 +5749,9 @@ } }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", + "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "dev": true, "license": "MIT", "peer": true, @@ -5390,9 +5773,9 @@ } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5407,9 +5790,9 @@ "license": "MIT" }, "node_modules/htmlparser2": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", - "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -5422,14 +5805,14 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.2.1", - "entities": "^6.0.0" + "domutils": "^3.2.2", + "entities": "^7.0.1" } }, "node_modules/htmlparser2/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -5511,9 +5894,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, "license": "MIT", "dependencies": { @@ -5527,6 +5910,26 @@ "url": "https://opencollective.com/express" } }, + "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-walk": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", @@ -5540,26 +5943,55 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/ignore-walk/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/ignore-walk/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "dev": true, "license": "MIT" }, @@ -5578,7 +6010,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -5589,7 +6020,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -5603,9 +6033,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "dev": true, "license": "MIT", "engines": { @@ -5749,6 +6179,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", @@ -5859,9 +6295,9 @@ "peer": true }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz", + "integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==", "dev": true, "license": "MIT", "funding": { @@ -5952,6 +6388,48 @@ ], "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/karma": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", @@ -6404,6 +6882,63 @@ "node": ">=10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/listr2": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", @@ -6424,9 +6959,9 @@ } }, "node_modules/listr2/node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true, "license": "MIT" }, @@ -6483,6 +7018,85 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -6638,12 +7252,13 @@ } }, "node_modules/make-fetch-happen": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", - "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==", + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.4.tgz", + "integrity": "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==", "dev": true, "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", @@ -6653,7 +7268,6 @@ "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", "ssri": "^13.0.0" }, "engines": { @@ -6703,35 +7317,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -6786,10 +7371,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -6802,18 +7386,17 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -6832,9 +7415,9 @@ } }, "node_modules/minipass-fetch": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.1.tgz", - "integrity": "sha512-yHK8pb0iCGat0lDrs/D6RZmCdaBT64tULXjdxjSMAqoDi18Q3qKEUTHypHQZQd9+FYpIS+lkvpq6C/R6SbUeRw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6846,7 +7429,7 @@ "node": "^20.17.0 || >=22.9.0" }, "optionalDependencies": { - "encoding": "^0.1.13" + "iconv-lite": "^0.7.2" } }, "node_modules/minipass-flush": { @@ -6945,7 +7528,6 @@ "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.6" @@ -6972,9 +7554,9 @@ "license": "MIT" }, "node_modules/msgpackr": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", - "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz", + "integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==", "dev": true, "license": "MIT", "optional": true, @@ -7094,13 +7676,13 @@ } }, "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/node-gyp/node_modules/proc-log": { @@ -7114,13 +7696,13 @@ } }, "node_modules/node-gyp/node_modules/which": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", - "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" @@ -7130,9 +7712,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "dev": true, "license": "MIT" }, @@ -7156,7 +7738,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7215,9 +7796,9 @@ } }, "node_modules/npm-packlist": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.3.tgz", - "integrity": "sha512-zPukTwJMOu5X5uvm0fztwS5Zxyvmk38H/LfidkOMt3gbZVCyro2cD/ETzwzVPcWZA3JOyPznfUN/nkyFiyUbxg==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", "dev": true, "license": "ISC", "dependencies": { @@ -7337,7 +7918,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -7384,9 +7964,9 @@ } }, "node_modules/ordered-binary": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.0.tgz", - "integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", "dev": true, "license": "MIT", "optional": true @@ -7446,6 +8026,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -7527,7 +8113,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7551,9 +8136,9 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -7561,16 +8146,16 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7632,9 +8217,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -7677,6 +8262,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -7691,6 +8282,16 @@ "node": ">=10" } }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7723,9 +8324,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7764,6 +8365,50 @@ "node": ">= 0.10" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -7851,9 +8496,9 @@ } }, "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "dev": true, "license": "MIT", "engines": { @@ -7953,6 +8598,26 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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": "MIT" + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", @@ -8000,6 +8665,18 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -8014,32 +8691,36 @@ } }, "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "statuses": "^2.0.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "dev": true, "license": "MIT", "dependencies": { @@ -8050,8 +8731,18 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -8218,16 +8909,16 @@ } }, "node_modules/socket.io": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", - "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", - "debug": "~4.3.2", + "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" @@ -8237,66 +8928,30 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", - "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "~4.3.4", - "ws": "~8.17.1" - } - }, - "node_modules/socket.io-adapter/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "debug": "~4.4.1", + "ws": "~8.18.3" } }, "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", "dev": true, "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" + "debug": "~4.4.1" }, "engines": { "node": ">=10.0.0" } }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/socket.io/node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -8311,24 +8966,6 @@ "node": ">= 0.6" } }, - "node_modules/socket.io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/socket.io/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -8433,17 +9070,6 @@ "node": ">=0.10.0" } }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", @@ -8452,9 +9078,9 @@ "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8463,16 +9089,16 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, "node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "dev": true, "license": "ISC", "dependencies": { @@ -8520,6 +9146,15 @@ "node": ">=8.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -8539,13 +9174,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -8581,9 +9216,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -8597,6 +9232,22 @@ "node": ">=18" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -8628,7 +9279,6 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, "license": "MIT", "engines": { "node": ">=14.14" @@ -8657,6 +9307,15 @@ "node": ">=0.6" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8789,10 +9448,58 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -8820,6 +9527,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -8830,15 +9543,13 @@ "node": ">= 0.4.0" } }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" } }, "node_modules/validate-npm-package-name": { @@ -9092,13 +9803,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", "engines": { @@ -9117,6 +9827,12 @@ } } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -9175,6 +9891,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/zod": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", diff --git a/package.json b/package.json index 46bb015..b7e9f3b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "chart.js": "^4.5.1", + "exceljs": "^4.4.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3c39d3d..af24341 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -9,6 +9,7 @@ import { Faturamento } from './pages/faturamento/faturamento'; import { authGuard } from './guards/auth.guard'; import { sysadminOrGestorGuard } from './guards/sysadmin-or-gestor.guard'; +import { sysadminOrFinanceiroGuard } from './guards/sysadmin-or-financeiro.guard'; import { sysadminOnlyGuard } from './guards/sysadmin-only.guard'; import { DadosUsuarios } from './pages/dados-usuarios/dados-usuarios'; import { VigenciaComponent } from './pages/vigencia/vigencia'; @@ -19,8 +20,12 @@ import { ChipsControleRecebidos } from './pages/chips-controle-recebidos/chips-c import { Resumo } from './pages/resumo/resumo'; import { Parcelamentos } from './pages/parcelamentos/parcelamentos'; import { Historico } from './pages/historico/historico'; +import { HistoricoLinhas } from './pages/historico-linhas/historico-linhas'; +import { HistoricoChips } from './pages/historico-chips/historico-chips'; import { Perfil } from './pages/perfil/perfil'; import { SystemProvisionUserPage } from './pages/system-provision-user/system-provision-user'; +import { SolicitacoesLinhas } from './pages/solicitacoes-linhas/solicitacoes-linhas'; +import { MveAuditoriaPage } from './pages/mve-auditoria/mve-auditoria'; export const routes: Routes = [ { path: '', component: Home }, @@ -29,15 +34,19 @@ export const routes: Routes = [ { path: 'geral', component: Geral, canActivate: [authGuard], title: 'Geral' }, { path: 'mureg', component: Mureg, canActivate: [authGuard], title: 'Mureg' }, - { path: 'faturamento', component: Faturamento, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Faturamento' }, + { path: 'faturamento', component: Faturamento, canActivate: [authGuard, sysadminOrFinanceiroGuard], title: 'Faturamento' }, { path: 'dadosusuarios', component: DadosUsuarios, canActivate: [authGuard], title: 'Dados dos Usuários' }, { path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' }, { path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' }, { path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' }, { path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Chips Controle Recebidos' }, { path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' }, - { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Parcelamentos' }, + { path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard, sysadminOrFinanceiroGuard], title: 'Parcelamentos' }, { path: 'historico', component: Historico, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico' }, + { path: 'historico-linhas', component: HistoricoLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico de Linhas' }, + { path: 'historico-chips', component: HistoricoChips, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Histórico de Chips' }, + { path: 'solicitacoes', component: SolicitacoesLinhas, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Solicitações' }, + { path: 'auditoria-mve', component: MveAuditoriaPage, canActivate: [authGuard, sysadminOrGestorGuard], title: 'Auditoria MVE' }, { path: 'perfil', component: Perfil, canActivate: [authGuard], title: 'Perfil' }, { path: 'system/fornecer-usuario', diff --git a/src/app/app.ts b/src/app/app.ts index 11b8f61..2a1b14c 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -29,6 +29,8 @@ export class AppComponent { // ✅ rotas internas (LOGADO) que devem esconder footer private readonly loggedPrefixes = [ '/geral', + '/auditoria-mve', + '/solicitacoes-linhas', '/mureg', '/faturamento', '/dadosusuarios', @@ -40,6 +42,8 @@ export class AppComponent { '/resumo', '/parcelamentos', '/historico', + '/historico-linhas', + '/historico-chips', '/perfil', '/system', ]; diff --git a/src/app/components/header/header.html b/src/app/components/header/header.html index cc76c2f..649881f 100644 --- a/src/app/components/header/header.html +++ b/src/app/components/header/header.html @@ -216,22 +216,24 @@ - - Line Gestão -
-
Line
-
Gestão
-
-
- -
- @@ -526,7 +528,7 @@
-
+
Dashboard @@ -536,18 +538,30 @@ Geral + + Auditoria MVE + Mureg - + Faturamento - + Parcelamentos Histórico + + Histórico de Linhas + + + Histórico de Chips + + + Solicitações + Dados PF/PJ diff --git a/src/app/components/header/header.scss b/src/app/components/header/header.scss index 3ad00d1..7bf2839 100644 --- a/src/app/components/header/header.scss +++ b/src/app/components/header/header.scss @@ -196,6 +196,27 @@ $logo-secondary-grey: #757575; display: inline-flex; align-items: center; gap: 6px; color: $text-main; text-decoration: none; font-weight: 600; font-size: 14px; transition: color 0.2s; &:hover { color: $primary; } } +.public-header-layout { + display: flex; + align-items: center; + gap: 24px; + width: 100%; + min-width: 0; +} + +.public-header-layout > .logo-area { + min-width: 0; +} + +.public-header-layout > .nav-links { + flex: 1 1 auto; +} + +.public-header-actions { + margin-left: auto; + flex: 0 0 auto; +} + .header-actions { display: flex; align-items: center; margin-left: auto; justify-content: flex-end; flex: 0 0 auto; } .btn-login-header { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99px; @@ -919,7 +940,17 @@ $logo-secondary-grey: #757575; .side-wordmark__movel { display: none; } -.side-menu-body { padding: 16px; display: flex; flex-direction: column; gap: 4px; } +.side-menu-body { + flex: 1 1 auto; + min-height: 0; + padding: 16px; + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; +} .side-item { padding: 10px 12px; border-radius: 8px; color: $text-main; text-decoration: none; font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 10px; &:hover { background: $bg-light; } @@ -1072,28 +1103,35 @@ $logo-secondary-grey: #757575; --scale: 0.21; } + .public-header-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + } + /* Header público (Home/Login/Register): mantém logo visível e CTA fixo à direita */ - .header-inner > .logo-area { + .public-header-layout > .logo-area { flex: 1 1 auto; min-width: 0; } - .header-inner > .logo-area .lg-wordmark { + .public-header-layout > .logo-area .lg-wordmark { display: block; white-space: nowrap; } - .header-inner > .logo-area .lg-wordmark { + .public-header-layout > .logo-area .lg-wordmark { --scale: 0.19; } - .header-inner > .header-actions { - margin-left: auto; + .public-header-layout > .header-actions { + margin-left: 0; flex: 0 0 auto; justify-content: flex-end; } - .header-inner > .header-actions .btn-login-header { + .public-header-layout > .header-actions .btn-login-header { padding: 7px 10px; gap: 4px; font-size: 12px; @@ -1404,20 +1442,20 @@ $logo-secondary-grey: #757575; } @media (max-width: 420px) { - .header-inner > .logo-area { + .public-header-layout > .logo-area { gap: 5px; } - .header-inner > .logo-area .logo-symbol { + .public-header-layout > .logo-area .logo-symbol { width: 32px; height: 32px; } - .header-inner > .logo-area .lg-wordmark { + .public-header-layout > .logo-area .lg-wordmark { --scale: 0.18; } - .header-inner > .header-actions .btn-login-header { + .public-header-layout > .header-actions .btn-login-header { padding: 6px 8px; font-size: 11px; gap: 3px; diff --git a/src/app/components/header/header.ts b/src/app/components/header/header.ts index 0d78ae7..be50d40 100644 --- a/src/app/components/header/header.ts +++ b/src/app/components/header/header.ts @@ -13,6 +13,7 @@ import { CustomSelectComponent } from '../custom-select/custom-select'; import { Subscription } from 'rxjs'; import { environment } from '../../../environments/environment'; import { confirmActionModal, confirmDeletionWithTyping, showDeletionWarning } from '../../utils/destructive-confirmation'; +import { buildApiBaseUrl } from '../../utils/api-base.util'; @Component({ selector: 'app-header', @@ -34,7 +35,11 @@ export class Header implements AfterViewInit, OnDestroy { isLoggedHeader = false; isHome = false; isSysAdmin = false; + isGestor = false; + isFinanceiro = false; canViewAll = false; + canViewFinancialPages = false; + canViewMveAudit = false; clientTenantDisplayName = ''; private clientTenantNameTenantId: string | null = null; private readonly baseApi: string; @@ -58,10 +63,11 @@ export class Header implements AfterViewInit, OnDestroy { createUserForbidden = false; createUserSuccess = ''; readonly permissionOptions = [ - { value: 'sysadmin', label: 'SysAdmin' }, - { value: 'gestor', label: 'Gestor' }, - { value: 'cliente', label: 'Cliente' }, - ]; + { value: 'sysadmin', label: 'SysAdmin' }, + { value: 'gestor', label: 'Gestor' }, + { value: 'financeiro', label: 'Financeiro' }, + { value: 'cliente', label: 'Cliente' }, +]; manageUsersLoading = false; manageUsersErrors: ApiFieldError[] = []; @@ -93,6 +99,10 @@ export class Header implements AfterViewInit, OnDestroy { '/resumo', '/parcelamentos', '/historico', + '/historico-linhas', + '/historico-chips', + '/solicitacoes', + '/auditoria-mve', '/perfil', '/system', ]; @@ -107,8 +117,7 @@ export class Header implements AfterViewInit, OnDestroy { private hostElement: ElementRef, @Inject(PLATFORM_ID) private platformId: object ) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); this.createUserForm = this.fb.group( { @@ -213,15 +222,23 @@ export class Header implements AfterViewInit, OnDestroy { private syncPermissions() { if (!isPlatformBrowser(this.platformId)) { this.isSysAdmin = false; + this.isGestor = false; + this.isFinanceiro = false; this.canViewAll = false; + this.canViewFinancialPages = false; this.clientTenantDisplayName = ''; this.clientTenantNameTenantId = null; return; } const isSysAdmin = this.authService.hasRole('sysadmin'); const isGestor = this.authService.hasRole('gestor'); + const isFinanceiro = this.authService.hasRole('financeiro'); this.isSysAdmin = isSysAdmin; - this.canViewAll = isSysAdmin || isGestor; + this.isGestor = isGestor; + this.isFinanceiro = isFinanceiro; + this.canViewAll = isSysAdmin || isGestor || isFinanceiro; + this.canViewFinancialPages = isSysAdmin || isFinanceiro; + this.canViewMveAudit = isSysAdmin || isGestor; if (!this.isClientHeader) { this.clientTenantDisplayName = ''; @@ -497,7 +514,10 @@ export class Header implements AfterViewInit, OnDestroy { this.optionsOpen = false; this.notificationsOpen = false; this.isSysAdmin = false; + this.isGestor = false; + this.isFinanceiro = false; this.canViewAll = false; + this.canViewFinancialPages = false; this.router.navigate(['/']); } diff --git a/src/app/components/modal-layer/modal-layer.html b/src/app/components/modal-layer/modal-layer.html new file mode 100644 index 0000000..0e62b4d --- /dev/null +++ b/src/app/components/modal-layer/modal-layer.html @@ -0,0 +1,14 @@ + +
+ +
+ +
+
diff --git a/src/app/components/modal-layer/modal-layer.scss b/src/app/components/modal-layer/modal-layer.scss new file mode 100644 index 0000000..2dad415 --- /dev/null +++ b/src/app/components/modal-layer/modal-layer.scss @@ -0,0 +1,35 @@ +.modal-backdrop-custom { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 9990; + backdrop-filter: blur(4px); +} + +.modal-custom { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 9995; + padding: 16px; +} + +.lg-modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 9995; + padding: 16px; +} + +.lg-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 9990; + backdrop-filter: blur(2px); +} diff --git a/src/app/components/modal-layer/modal-layer.ts b/src/app/components/modal-layer/modal-layer.ts new file mode 100644 index 0000000..264f041 --- /dev/null +++ b/src/app/components/modal-layer/modal-layer.ts @@ -0,0 +1,24 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-modal-layer', + standalone: true, + imports: [CommonModule], + templateUrl: './modal-layer.html', + styleUrls: ['./modal-layer.scss'], +}) +export class ModalLayerComponent { + @Input() open = false; + @Input() showBackdrop = true; + @Input() closeOnOverlay = true; + @Input() backdropClass = 'modal-backdrop-custom'; + @Input() overlayClass = 'modal-custom'; + + @Output() close = new EventEmitter(); + + onOverlayClick(): void { + if (!this.closeOnOverlay) return; + this.close.emit(); + } +} diff --git a/src/app/components/page-modals/chips-controle-modals/chips-controle-modals.html b/src/app/components/page-modals/chips-controle-modals/chips-controle-modals.html new file mode 100644 index 0000000..99a893d --- /dev/null +++ b/src/app/components/page-modals/chips-controle-modals/chips-controle-modals.html @@ -0,0 +1,370 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/components/page-modals/chips-controle-modals/chips-controle-modals.ts b/src/app/components/page-modals/chips-controle-modals/chips-controle-modals.ts new file mode 100644 index 0000000..f1392b0 --- /dev/null +++ b/src/app/components/page-modals/chips-controle-modals/chips-controle-modals.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ModalLayerComponent } from '../../modal-layer/modal-layer'; +import { VmProxyHost } from '../vm-proxy-host'; + +@Component({ + selector: 'app-chips-controle-modals', + standalone: true, + imports: [CommonModule, FormsModule, ModalLayerComponent], + templateUrl: './chips-controle-modals.html', + styleUrls: ['../../../pages/chips-controle-recebidos/chips-controle-recebidos.scss'], +}) +export class ChipsControleModalsComponent extends VmProxyHost { + @Input() set vm(value: any) { + this.attachVm(value); + } +} diff --git a/src/app/components/page-modals/dados-usuarios-modals/dados-usuarios-modals.html b/src/app/components/page-modals/dados-usuarios-modals/dados-usuarios-modals.html new file mode 100644 index 0000000..11537d5 --- /dev/null +++ b/src/app/components/page-modals/dados-usuarios-modals/dados-usuarios-modals.html @@ -0,0 +1,277 @@ + + + + + + + + + + + + + diff --git a/src/app/components/page-modals/dados-usuarios-modals/dados-usuarios-modals.ts b/src/app/components/page-modals/dados-usuarios-modals/dados-usuarios-modals.ts new file mode 100644 index 0000000..1fc5514 --- /dev/null +++ b/src/app/components/page-modals/dados-usuarios-modals/dados-usuarios-modals.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { CustomSelectComponent } from '../../custom-select/custom-select'; +import { ModalLayerComponent } from '../../modal-layer/modal-layer'; +import { VmProxyHost } from '../vm-proxy-host'; + +@Component({ + selector: 'app-dados-usuarios-modals', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent], + templateUrl: './dados-usuarios-modals.html', + styleUrls: ['../../../pages/dados-usuarios/dados-usuarios.scss'], +}) +export class DadosUsuariosModalsComponent extends VmProxyHost { + @Input() set vm(value: any) { + this.attachVm(value); + } +} diff --git a/src/app/components/page-modals/faturamento-modals/faturamento-modals.html b/src/app/components/page-modals/faturamento-modals/faturamento-modals.html new file mode 100644 index 0000000..fbb28e9 --- /dev/null +++ b/src/app/components/page-modals/faturamento-modals/faturamento-modals.html @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + diff --git a/src/app/components/page-modals/faturamento-modals/faturamento-modals.ts b/src/app/components/page-modals/faturamento-modals/faturamento-modals.ts new file mode 100644 index 0000000..34a32bc --- /dev/null +++ b/src/app/components/page-modals/faturamento-modals/faturamento-modals.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ModalLayerComponent } from '../../modal-layer/modal-layer'; +import { VmProxyHost } from '../vm-proxy-host'; + +@Component({ + selector: 'app-faturamento-modals', + standalone: true, + imports: [CommonModule, FormsModule, ModalLayerComponent], + templateUrl: './faturamento-modals.html', + styleUrls: ['../../../pages/faturamento/faturamento.scss'], +}) +export class FaturamentoModalsComponent extends VmProxyHost { + @Input() set vm(value: any) { + this.attachVm(value); + } +} diff --git a/src/app/components/page-modals/geral-modals/geral-modals.html b/src/app/components/page-modals/geral-modals/geral-modals.html new file mode 100644 index 0000000..8ff7fb8 --- /dev/null +++ b/src/app/components/page-modals/geral-modals/geral-modals.html @@ -0,0 +1,1748 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/components/page-modals/geral-modals/geral-modals.ts b/src/app/components/page-modals/geral-modals/geral-modals.ts new file mode 100644 index 0000000..cdc4e0e --- /dev/null +++ b/src/app/components/page-modals/geral-modals/geral-modals.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { CustomSelectComponent } from '../../custom-select/custom-select'; +import { ModalLayerComponent } from '../../modal-layer/modal-layer'; +import { VmProxyHost } from '../vm-proxy-host'; + +@Component({ + selector: 'app-geral-modals', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent], + templateUrl: './geral-modals.html', + styleUrls: ['../../../pages/geral/geral.scss'], +}) +export class GeralModalsComponent extends VmProxyHost { + @Input() set vm(value: any) { + this.attachVm(value); + } +} diff --git a/src/app/components/page-modals/mureg-modals/mureg-modals.html b/src/app/components/page-modals/mureg-modals/mureg-modals.html new file mode 100644 index 0000000..f4a2a7f --- /dev/null +++ b/src/app/components/page-modals/mureg-modals/mureg-modals.html @@ -0,0 +1,309 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/components/page-modals/mureg-modals/mureg-modals.ts b/src/app/components/page-modals/mureg-modals/mureg-modals.ts new file mode 100644 index 0000000..e0f300d --- /dev/null +++ b/src/app/components/page-modals/mureg-modals/mureg-modals.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { CustomSelectComponent } from '../../custom-select/custom-select'; +import { ModalLayerComponent } from '../../modal-layer/modal-layer'; +import { VmProxyHost } from '../vm-proxy-host'; + +@Component({ + selector: 'app-mureg-modals', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent], + templateUrl: './mureg-modals.html', + styleUrls: ['../../../pages/mureg/mureg.scss'], +}) +export class MuregModalsComponent extends VmProxyHost { + @Input() set vm(value: any) { + this.attachVm(value); + } +} diff --git a/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.html b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal.html similarity index 96% rename from src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.html rename to src/app/components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal.html index fd6c79b..1291946 100644 --- a/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.html +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal.html @@ -1,6 +1,10 @@ -
-
-
+ +
-
+
diff --git a/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.scss b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal.scss similarity index 94% rename from src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.scss rename to src/app/components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal.scss index f66ede4..71a1ee0 100644 --- a/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.scss +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal.scss @@ -5,26 +5,6 @@ --focus-ring: var(--pg-focus-ring, 0 0 0 3px rgba(31, 79, 214, 0.22)); } -.lg-backdrop { - position: fixed; - inset: 0; - background: - radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.15), rgba(15, 23, 42, 0.66) 42%), - rgba(15, 23, 42, 0.58); - z-index: 9990; - backdrop-filter: blur(4px); -} - -.lg-modal { - position: fixed; - inset: 0; - z-index: 9995; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; -} - .lg-modal-card { width: min(1040px, 96vw); max-height: 92vh; diff --git a/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.ts b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal.ts similarity index 96% rename from src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.ts rename to src/app/components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal.ts index ab003b4..35dfff9 100644 --- a/src/app/pages/parcelamentos/components/parcelamento-create-modal/parcelamento-create-modal.ts +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal.ts @@ -1,7 +1,8 @@ import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { CustomSelectComponent } from '../../../../components/custom-select/custom-select'; +import { CustomSelectComponent } from '../../../../custom-select/custom-select'; +import { ModalLayerComponent } from '../../../../modal-layer/modal-layer'; export type MonthOption = { value: number; label: string }; @@ -31,7 +32,7 @@ type PreviewRow = { @Component({ selector: 'app-parcelamento-create-modal', standalone: true, - imports: [CommonModule, FormsModule, CustomSelectComponent], + imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent], templateUrl: './parcelamento-create-modal.html', styleUrls: ['./parcelamento-create-modal.scss'], }) diff --git a/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html similarity index 90% rename from src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html rename to src/app/components/page-modals/parcelamento-modals/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html index 1f152f3..012ad1b 100644 --- a/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.html @@ -1,6 +1,10 @@ -
-
-
+ +
-
+
diff --git a/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss similarity index 91% rename from src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss rename to src/app/components/page-modals/parcelamento-modals/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss index 8b610ab..99e5918 100644 --- a/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.scss @@ -4,24 +4,6 @@ --focus-ring: 0 0 0 3px rgba(227, 61, 207, 0.16); } -.lg-backdrop { - position: fixed; - inset: 0; - background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.2), rgba(0, 0, 0, 0.56) 42%); - z-index: 9990; - backdrop-filter: blur(5px); -} - -.lg-modal { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - z-index: 9995; - padding: 16px; -} - .lg-modal-card { background: #ffffff; border: 1px solid rgba(255, 255, 255, 0.88); diff --git a/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts similarity index 88% rename from src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts rename to src/app/components/page-modals/parcelamento-modals/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts index 99a923f..5504042 100644 --- a/src/app/pages/parcelamentos/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamento-detalhamento-anual-modal/parcelamento-detalhamento-anual-modal.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { ModalLayerComponent } from '../../../../modal-layer/modal-layer'; export type AnnualMonthValue = { month: number; @@ -20,7 +21,7 @@ export type AnnualRow = { @Component({ selector: 'app-parcelamento-detalhamento-anual-modal', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, ModalLayerComponent], templateUrl: './parcelamento-detalhamento-anual-modal.html', styleUrls: ['./parcelamento-detalhamento-anual-modal.scss'], }) diff --git a/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.html b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters.html similarity index 100% rename from src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.html rename to src/app/components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters.html diff --git a/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.scss b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters.scss similarity index 100% rename from src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.scss rename to src/app/components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters.scss diff --git a/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.ts b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters.ts similarity index 92% rename from src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.ts rename to src/app/components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters.ts index 546a7bd..bb47c41 100644 --- a/src/app/pages/parcelamentos/components/parcelamentos-filters/parcelamentos-filters.ts +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters.ts @@ -1,7 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { CustomSelectComponent } from '../../../../components/custom-select/custom-select'; +import { CustomSelectComponent } from '../../../../custom-select/custom-select'; export type MonthOption = { value: number; label: string }; diff --git a/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.html b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-kpis/parcelamentos-kpis.html similarity index 100% rename from src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.html rename to src/app/components/page-modals/parcelamento-modals/components/parcelamentos-kpis/parcelamentos-kpis.html diff --git a/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.scss b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-kpis/parcelamentos-kpis.scss similarity index 100% rename from src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.scss rename to src/app/components/page-modals/parcelamento-modals/components/parcelamentos-kpis/parcelamentos-kpis.scss diff --git a/src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.ts b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-kpis/parcelamentos-kpis.ts similarity index 100% rename from src/app/pages/parcelamentos/components/parcelamentos-kpis/parcelamentos-kpis.ts rename to src/app/components/page-modals/parcelamento-modals/components/parcelamentos-kpis/parcelamentos-kpis.ts diff --git a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table.html similarity index 98% rename from src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html rename to src/app/components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table.html index fb8c03c..3b3c311 100644 --- a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.html +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table.html @@ -100,6 +100,7 @@ diff --git a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.scss b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table.scss similarity index 98% rename from src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.scss rename to src/app/components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table.scss index f1c8de2..cd072b7 100644 --- a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.scss +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table.scss @@ -429,14 +429,14 @@ display: inline-flex; align-items: center; gap: 8px; +} - span { - color: var(--pg-text-soft, #64748b); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; - font-weight: 800; - } +.page-size span { + color: var(--pg-text-soft, #64748b); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 800; } .select-glass { diff --git a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table.ts similarity index 93% rename from src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts rename to src/app/components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table.ts index 45a2629..5aa6e27 100644 --- a/src/app/pages/parcelamentos/components/parcelamentos-table/parcelamentos-table.ts +++ b/src/app/components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table.ts @@ -1,7 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { ParcelamentoListItem } from '../../../../services/parcelamentos.service'; +import { ParcelamentoListItem } from '../../../../../services/parcelamentos.service'; export type ParcelamentoSegment = 'todos' | 'ativos' | 'futuros' | 'finalizados'; @@ -26,7 +26,8 @@ export class ParcelamentosTableComponent { @Input() items: ParcelamentoViewItem[] = []; @Input() loading = false; @Input() errorMessage = ''; - @Input() isSysAdmin = false; + @Input() canEdit = false; + @Input() canDelete = false; @Input() segment: ParcelamentoSegment = 'todos'; @Input() segmentCounts: Record = { diff --git a/src/app/components/page-modals/parcelamento-modals/parcelamentos-modals.html b/src/app/components/page-modals/parcelamento-modals/parcelamentos-modals.html new file mode 100644 index 0000000..a943780 --- /dev/null +++ b/src/app/components/page-modals/parcelamento-modals/parcelamentos-modals.html @@ -0,0 +1,180 @@ + + +
+ + + + + +
+
+ + + + + + + + + + + diff --git a/src/app/components/page-modals/parcelamento-modals/parcelamentos-modals.ts b/src/app/components/page-modals/parcelamento-modals/parcelamentos-modals.ts new file mode 100644 index 0000000..282d08a --- /dev/null +++ b/src/app/components/page-modals/parcelamento-modals/parcelamentos-modals.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ModalLayerComponent } from '../../modal-layer/modal-layer'; +import { ParcelamentoCreateModalComponent } from './components/parcelamento-create-modal/parcelamento-create-modal'; +import { VmProxyHost } from '../vm-proxy-host'; + +@Component({ + selector: 'app-parcelamentos-modals', + standalone: true, + imports: [CommonModule, ModalLayerComponent, ParcelamentoCreateModalComponent], + templateUrl: './parcelamentos-modals.html', + styleUrls: ['../../../pages/parcelamentos/parcelamentos.scss'], +}) +export class ParcelamentosModalsComponent extends VmProxyHost { + @Input() set vm(value: any) { + this.attachVm(value); + } +} diff --git a/src/app/components/page-modals/troca-numero-modals/troca-numero-modals.html b/src/app/components/page-modals/troca-numero-modals/troca-numero-modals.html new file mode 100644 index 0000000..6a6d683 --- /dev/null +++ b/src/app/components/page-modals/troca-numero-modals/troca-numero-modals.html @@ -0,0 +1,181 @@ + + + + + + + + + + diff --git a/src/app/components/page-modals/troca-numero-modals/troca-numero-modals.ts b/src/app/components/page-modals/troca-numero-modals/troca-numero-modals.ts new file mode 100644 index 0000000..909ab2f --- /dev/null +++ b/src/app/components/page-modals/troca-numero-modals/troca-numero-modals.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { CustomSelectComponent } from '../../custom-select/custom-select'; +import { ModalLayerComponent } from '../../modal-layer/modal-layer'; +import { VmProxyHost } from '../vm-proxy-host'; + +@Component({ + selector: 'app-troca-numero-modals', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent], + templateUrl: './troca-numero-modals.html', + styleUrls: ['../../../pages/troca-numero/troca-numero.scss'], +}) +export class TrocaNumeroModalsComponent extends VmProxyHost { + @Input() set vm(value: any) { + this.attachVm(value); + } +} diff --git a/src/app/components/page-modals/vigencia-modals/vigencia-modals.html b/src/app/components/page-modals/vigencia-modals/vigencia-modals.html new file mode 100644 index 0000000..68c94c4 --- /dev/null +++ b/src/app/components/page-modals/vigencia-modals/vigencia-modals.html @@ -0,0 +1,262 @@ + + + + + + + + + + + + + diff --git a/src/app/components/page-modals/vigencia-modals/vigencia-modals.ts b/src/app/components/page-modals/vigencia-modals/vigencia-modals.ts new file mode 100644 index 0000000..e72798e --- /dev/null +++ b/src/app/components/page-modals/vigencia-modals/vigencia-modals.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { CustomSelectComponent } from '../../custom-select/custom-select'; +import { ModalLayerComponent } from '../../modal-layer/modal-layer'; +import { VmProxyHost } from '../vm-proxy-host'; + +@Component({ + selector: 'app-vigencia-modals', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent, ModalLayerComponent], + templateUrl: './vigencia-modals.html', + styleUrls: ['../../../pages/vigencia/vigencia.scss'], +}) +export class VigenciaModalsComponent extends VmProxyHost { + @Input() set vm(value: any) { + this.attachVm(value); + } +} diff --git a/src/app/components/page-modals/vm-proxy-host.ts b/src/app/components/page-modals/vm-proxy-host.ts new file mode 100644 index 0000000..f6b0276 --- /dev/null +++ b/src/app/components/page-modals/vm-proxy-host.ts @@ -0,0 +1,52 @@ +export abstract class VmProxyHost { + [key: string]: any; + + private __boundKeys = new Set(); + + protected attachVm(vm: any): void { + if (!vm) return; + + // Bind own enumerable fields so template reads/writes keep source state in sync. + Object.keys(vm).forEach((key) => this.bindField(vm, key)); + + let proto: any = Object.getPrototypeOf(vm); + while (proto && proto !== Object.prototype) { + for (const key of Object.getOwnPropertyNames(proto)) { + if (key === 'constructor' || key.startsWith('ng')) continue; + + const descriptor = Object.getOwnPropertyDescriptor(proto, key); + if (!descriptor) continue; + + if (descriptor.get || descriptor.set) { + this.bindField(vm, key); + continue; + } + + const value = (vm as any)[key]; + if (typeof value === 'function' && !this.__boundKeys.has(key)) { + (this as any)[key] = (...args: any[]) => value.apply(vm, args); + this.__boundKeys.add(key); + } + } + + proto = Object.getPrototypeOf(proto); + } + } + + private bindField(vm: any, key: string): void { + if (this.__boundKeys.has(key)) return; + + if (Object.prototype.hasOwnProperty.call(this, key)) return; + + Object.defineProperty(this, key, { + configurable: true, + enumerable: true, + get: () => (vm as any)[key], + set: (value: unknown) => { + (vm as any)[key] = value; + }, + }); + + this.__boundKeys.add(key); + } +} diff --git a/src/app/guards/sysadmin-or-financeiro.guard.ts b/src/app/guards/sysadmin-or-financeiro.guard.ts new file mode 100644 index 0000000..8ce5e8b --- /dev/null +++ b/src/app/guards/sysadmin-or-financeiro.guard.ts @@ -0,0 +1,27 @@ +import { inject, PLATFORM_ID } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { isPlatformBrowser } from '@angular/common'; +import { AuthService } from '../services/auth.service'; + +export const sysadminOrFinanceiroGuard: CanActivateFn = () => { + const router = inject(Router); + const platformId = inject(PLATFORM_ID); + const authService = inject(AuthService); + + if (!isPlatformBrowser(platformId)) { + // Em SSR não há storage do usuário para validar sessão/perfil. + return true; + } + + const token = authService.token; + if (!token) { + return router.parseUrl('/login'); + } + + const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('financeiro'); + if (!hasAccess) { + return router.parseUrl('/dashboard'); + } + + return true; +}; diff --git a/src/app/guards/sysadmin-or-gestor.guard.ts b/src/app/guards/sysadmin-or-gestor.guard.ts index 252bae1..61b19a5 100644 --- a/src/app/guards/sysadmin-or-gestor.guard.ts +++ b/src/app/guards/sysadmin-or-gestor.guard.ts @@ -18,7 +18,10 @@ export const sysadminOrGestorGuard: CanActivateFn = () => { return router.parseUrl('/login'); } - const hasAccess = authService.hasRole('sysadmin') || authService.hasRole('gestor'); + const hasAccess = + authService.hasRole('sysadmin') || + authService.hasRole('gestor') || + authService.hasRole('financeiro'); if (!hasAccess) { return router.parseUrl('/dashboard'); } diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html index 513d6e6..7e18bfd 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.html @@ -35,6 +35,14 @@
+ -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss index 3c33e6f..8e59abe 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.scss @@ -616,8 +616,6 @@ /* ========================================================== */ /* MODAIS (mantidos) */ /* ========================================================== */ -.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } -.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; } .modal-card.modal-xl-custom { width: min(980px, 92vw); max-height: 82vh; } .modal-card.modal-lg { width: min(720px, 92vw); max-height: 80vh; } diff --git a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts index 2885265..bd38697 100644 --- a/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts +++ b/src/app/pages/chips-controle-recebidos/chips-controle-recebidos.ts @@ -4,8 +4,18 @@ import { FormsModule } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; import { ChipsControleService, ChipVirgemListDto, ControleRecebidoListDto, SortDir, UpdateChipVirgemRequest, UpdateControleRecebidoRequest, CreateChipVirgemRequest, CreateControleRecebidoRequest } from '../../services/chips-controle.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { ChipsControleModalsComponent } from '../../components/page-modals/chips-controle-modals/chips-controle-modals'; import { AuthService } from '../../services/auth.service'; +import { TableExportService } from '../../services/table-export.service'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; +import { firstValueFrom } from 'rxjs'; // Interface para o Agrupamento interface ChipGroup { @@ -35,11 +45,12 @@ type ControleSortKey = @Component({ selector: 'app-chips-controle-recebidos', standalone: true, - imports: [CommonModule, FormsModule, CustomSelectComponent], + imports: [CommonModule, FormsModule, CustomSelectComponent, ChipsControleModalsComponent], templateUrl: './chips-controle-recebidos.html', styleUrls: ['./chips-controle-recebidos.scss'] }) export class ChipsControleRecebidos implements OnInit, OnDestroy { + readonly vm = this; activeTab: 'chips' | 'controle' = 'chips'; // --- Chips --- @@ -86,6 +97,7 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { toastOpen = false; toastMessage = ''; toastType: 'success' | 'danger' = 'success'; + exporting = false; private toastTimer: any = null; chipDetailOpen = false; @@ -124,7 +136,8 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { @Inject(PLATFORM_ID) private platformId: object, private service: ChipsControleService, private http: HttpClient, - private authService: AuthService + private authService: AuthService, + private tableExportService: TableExportService ) {} ngOnInit(): void { @@ -417,6 +430,129 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { this.fetchControle(); } + async onExport(): Promise { + if (this.exporting) return; + this.exporting = true; + + try { + if (this.activeTab === 'chips') { + const baseRows = [...(this.chipsRows ?? [])].sort((a, b) => (a.item ?? 0) - (b.item ?? 0)); + const rows = await this.fetchDetailedChipRowsForExport(baseRows); + if (!rows.length) { + this.showToast('Nenhum registro encontrado para exportar.', 'danger'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `chips_virgens_${timestamp}`, + sheetName: 'ChipsVirgens', + rows, + columns: [ + { header: 'ID', value: (row) => row.id ?? '' }, + { header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 }, + { header: 'Numero do Chip', value: (row) => row.numeroDoChip ?? '' }, + { header: 'Observacoes', value: (row) => row.observacoes ?? '' }, + { header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' }, + { header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' }, + ], + }); + + this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success'); + return; + } + + const baseRows = [...(this.controleRows ?? [])].sort((a, b) => { + const byAno = (this.toNullableNumber(a.ano) ?? 0) - (this.toNullableNumber(b.ano) ?? 0); + if (byAno !== 0) return byAno; + return (this.toNullableNumber(a.item) ?? 0) - (this.toNullableNumber(b.item) ?? 0); + }); + const rows = await this.fetchDetailedControleRowsForExport(baseRows); + + if (!rows.length) { + this.showToast('Nenhum registro encontrado para exportar.', 'danger'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `controle_recebidos_${timestamp}`, + sheetName: 'ControleRecebidos', + rows, + columns: [ + { header: 'ID', value: (row) => row.id ?? '' }, + { header: 'Ano', type: 'number', value: (row) => this.toNullableNumber(row.ano) ?? 0 }, + { header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 }, + { header: 'Nota Fiscal', value: (row) => row.notaFiscal ?? '' }, + { header: 'Chip', value: (row) => row.chip ?? '' }, + { header: 'Serial', value: (row) => row.serial ?? '' }, + { header: 'Conteudo da NF', value: (row) => row.conteudoDaNf ?? '' }, + { header: 'Numero da Linha', value: (row) => row.numeroDaLinha ?? '' }, + { header: 'Valor Unitario', type: 'currency', value: (row) => this.toNullableNumber(row.valorUnit) ?? 0 }, + { header: 'Valor da NF', type: 'currency', value: (row) => this.toNullableNumber(row.valorDaNf) ?? 0 }, + { header: 'Data da NF', type: 'date', value: (row) => row.dataDaNf ?? '' }, + { header: 'Data do Recebimento', type: 'date', value: (row) => row.dataDoRecebimento ?? '' }, + { header: 'Quantidade', type: 'number', value: (row) => this.toNullableNumber(row.quantidade) ?? 0 }, + { header: 'Resumo', type: 'boolean', value: (row) => !!row.isResumo }, + { header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' }, + { header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' }, + ], + }); + + this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success'); + } catch { + this.showToast('Erro ao exportar planilha.', 'danger'); + } finally { + this.exporting = false; + } + } + + private async fetchDetailedChipRowsForExport(rows: ChipVirgemListDto[]): Promise { + if (!rows.length) return []; + + const detailed: ChipVirgemListDto[] = []; + const chunkSize = 12; + + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + const resolved = await Promise.all( + chunk.map(async (row) => { + try { + return await firstValueFrom(this.service.getChipVirgemById(row.id)); + } catch { + return row; + } + }) + ); + detailed.push(...resolved); + } + + return detailed; + } + + private async fetchDetailedControleRowsForExport(rows: ControleRecebidoListDto[]): Promise { + if (!rows.length) return []; + + const detailed: ControleRecebidoListDto[] = []; + const chunkSize = 12; + + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + const resolved = await Promise.all( + chunk.map(async (row) => { + try { + return await firstValueFrom(this.service.getControleRecebidoById(row.id)); + } catch { + return row; + } + }) + ); + detailed.push(...resolved); + } + + return detailed; + } + setControleSort(key: ControleSortKey) { if (this.controleSortBy === key) { this.controleSortDir = this.controleSortDir === 'asc' ? 'desc' : 'asc'; @@ -720,27 +856,19 @@ export class ChipsControleRecebidos implements OnInit, OnDestroy { get activePage() { return this.activeTab === 'chips' ? this.chipsPage : this.controlePage; } get activeTotal() { return this.activeTab === 'chips' ? this.chipsTotal : this.controleTotal; } get activePageSize() { return this.activeTab === 'chips' ? this.chipsPageSize : this.controlePageSize; } - get activeTotalPages() { return Math.max(1, Math.ceil((this.activeTotal || 0) / (this.activePageSize || 10))); } + get activeTotalPages() { return computeTotalPages(this.activeTotal || 0, this.activePageSize || 10); } - get activePageStart() { return this.activeTotal === 0 ? 0 : (this.activePage - 1) * this.activePageSize + 1; } - get activePageEnd() { return this.activeTotal === 0 ? 0 : Math.min(this.activePage * this.activePageSize, this.activeTotal); } + get activePageStart() { return computePageStart(this.activeTotal || 0, this.activePage, this.activePageSize); } + get activePageEnd() { return computePageEnd(this.activeTotal || 0, this.activePage, this.activePageSize); } get activeLoading() { return this.activeTab === 'chips' ? this.chipsLoading : this.controleLoading; } // ✅ novo get activePageNumbers() { - const total = this.activeTotalPages; - const current = this.activePage; - const max = 5; - let start = Math.max(1, current - 2); - let end = Math.min(total, start + (max - 1)); - start = Math.max(1, end - (max - 1)); - const pages = []; - for (let i = start; i <= end; i++) pages.push(i); - return pages; + return buildPageNumbers(this.activePage, this.activeTotalPages); } goToPage(p: number) { - const target = Math.max(1, Math.min(this.activeTotalPages, p)); + const target = clampPage(p, this.activeTotalPages); if (this.activeTab === 'chips') { this.chipsPage = target; diff --git a/src/app/pages/dados-usuarios/dados-usuarios.html b/src/app/pages/dados-usuarios/dados-usuarios.html index a3ebad1..4b468e1 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.html +++ b/src/app/pages/dados-usuarios/dados-usuarios.html @@ -32,6 +32,10 @@ + @@ -88,7 +92,6 @@ Itens por pág:
-
@@ -181,278 +184,4 @@
- - - + diff --git a/src/app/pages/dados-usuarios/dados-usuarios.scss b/src/app/pages/dados-usuarios/dados-usuarios.scss index 5ac4967..8455861 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.scss +++ b/src/app/pages/dados-usuarios/dados-usuarios.scss @@ -350,8 +350,6 @@ .pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; } /* MODALS */ -.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } -.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: clamp(12px, 2.2vw, 20px); } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; min-height: 0; } .modal-xl-custom { width: min(1050px, 95vw); max-height: 86vh; } @keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } diff --git a/src/app/pages/dados-usuarios/dados-usuarios.ts b/src/app/pages/dados-usuarios/dados-usuarios.ts index b94a1c9..325dade 100644 --- a/src/app/pages/dados-usuarios/dados-usuarios.ts +++ b/src/app/pages/dados-usuarios/dados-usuarios.ts @@ -2,7 +2,10 @@ import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { DadosUsuariosModalsComponent } from '../../components/page-modals/dados-usuarios-modals/dados-usuarios-modals'; +import { TableExportService } from '../../services/table-export.service'; import { DadosUsuariosService, @@ -16,6 +19,13 @@ import { import { AuthService } from '../../services/auth.service'; import { LinesService, MobileLineDetail } from '../../services/lines.service'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; type ViewMode = 'lines' | 'groups'; @@ -36,15 +46,17 @@ interface SimpleOption { @Component({ selector: 'app-dados-usuarios', standalone: true, - imports: [CommonModule, FormsModule, CustomSelectComponent], + imports: [CommonModule, FormsModule, CustomSelectComponent, DadosUsuariosModalsComponent], templateUrl: './dados-usuarios.html', styleUrls: ['./dados-usuarios.scss'] }) export class DadosUsuarios implements OnInit { + readonly vm = this; @ViewChild('successToast', { static: false }) successToast!: ElementRef; loading = false; + exporting = false; errorMsg = ''; // Filtros @@ -116,7 +128,8 @@ export class DadosUsuarios implements OnInit { constructor( private service: DadosUsuariosService, private authService: AuthService, - private linesService: LinesService + private linesService: LinesService, + private tableExportService: TableExportService ) {} ngOnInit(): void { @@ -136,26 +149,17 @@ export class DadosUsuarios implements OnInit { } get totalPages(): number { - return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); + return computeTotalPages(this.total || 0, this.pageSize || 10); } - get pageStart(): number { return (this.page - 1) * this.pageSize + 1; } + get pageStart(): number { return computePageStart(this.total || 0, this.page, this.pageSize); } get pageEnd(): number { - const end = this.page * this.pageSize; - return end > this.total ? this.total : end; + return computePageEnd(this.total || 0, this.page, this.pageSize); } get pageNumbers(): number[] { - const total = this.totalPages; - const current = this.page; - const max = 5; - let start = Math.max(1, current - 2); - let end = Math.min(total, start + (max - 1)); - start = Math.max(1, end - (max - 1)); - const pages: number[] = []; - for (let i = start; i <= end; i++) pages.push(i); - return pages; + return buildPageNumbers(this.page, this.totalPages); } fetch(goToPage?: number): void { @@ -256,6 +260,112 @@ export class DadosUsuarios implements OnInit { } clearFilters() { this.search = ''; this.fetch(1); } + + async onExport(): Promise { + if (this.exporting) return; + this.exporting = true; + + try { + const baseRows = await this.fetchAllRowsForExport(); + const rows = await this.fetchDetailedRowsForExport(baseRows); + if (!rows.length) { + this.showToast('Nenhum registro encontrado para exportar.', 'danger'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + const fileName = `dados_usuarios_${this.tipoFilter.toLowerCase()}_${timestamp}`; + + await this.tableExportService.exportAsXlsx({ + fileName, + sheetName: 'DadosUsuarios', + rows, + columns: [ + { header: 'ID', value: (row) => row.id ?? '' }, + { header: 'Tipo', value: (row) => this.normalizeTipo(row) }, + { header: 'Cliente', value: (row) => row.cliente ?? '' }, + { + header: this.tipoFilter === 'PJ' ? 'Razao Social' : 'Nome', + value: (row) => (this.normalizeTipo(row) === 'PJ' ? (row.razaoSocial ?? row.cliente ?? '') : (row.nome ?? row.cliente ?? '')), + }, + { header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 }, + { header: 'Linha', value: (row) => row.linha ?? '' }, + { header: 'CPF', value: (row) => row.cpf ?? '' }, + { header: 'CNPJ', value: (row) => row.cnpj ?? '' }, + { header: 'E-mail', value: (row) => row.email ?? '' }, + { header: 'Celular', value: (row) => row.celular ?? '' }, + { header: 'Telefone Fixo', value: (row) => row.telefoneFixo ?? '' }, + { header: 'RG', value: (row) => row.rg ?? '' }, + { header: 'Endereco', value: (row) => row.endereco ?? '' }, + { header: 'Data de Nascimento', type: 'date', value: (row) => row.dataNascimento ?? '' }, + ], + }); + + this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success'); + } catch { + this.showToast('Erro ao exportar planilha.', 'danger'); + } finally { + this.exporting = false; + } + } + + private async fetchAllRowsForExport(): Promise { + const pageSize = 500; + let page = 1; + let expectedTotal = 0; + const all: UserDataRow[] = []; + + while (page <= 500) { + const response = await firstValueFrom( + this.service.getRows({ + search: this.search?.trim(), + tipo: this.tipoFilter, + page, + pageSize, + sortBy: 'item', + sortDir: 'asc', + }) + ); + + const items = response?.items ?? []; + expectedTotal = response?.total ?? 0; + all.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && all.length >= expectedTotal) break; + page += 1; + } + + return all.sort((a, b) => { + const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' }); + if (byClient !== 0) return byClient; + return (this.toNullableNumber(a.item) ?? 0) - (this.toNullableNumber(b.item) ?? 0); + }); + } + + private async fetchDetailedRowsForExport(rows: UserDataRow[]): Promise { + if (!rows.length) return []; + + const detailed: UserDataRow[] = []; + const chunkSize = 10; + + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + const resolved = await Promise.all( + chunk.map(async (row) => { + try { + return await firstValueFrom(this.service.getById(row.id)); + } catch { + return row; + } + }) + ); + detailed.push(...resolved); + } + + return detailed; + } onPageSizeChange() { this.page = 1; @@ -263,7 +373,7 @@ export class DadosUsuarios implements OnInit { } goToPage(p: number) { - this.page = p; + this.page = clampPage(p, this.totalPages); this.fetch(); } diff --git a/src/app/pages/dashboard/dashboard.html b/src/app/pages/dashboard/dashboard.html index bb9943d..13d6f61 100644 --- a/src/app/pages/dashboard/dashboard.html +++ b/src/app/pages/dashboard/dashboard.html @@ -29,15 +29,53 @@ +
+
+ + Filtro de Operadora +
+
+ +
+
+
-
+
{{ k.title }} - {{ k.value }} - {{ k.hint }} +
+ {{ k.value }} + + + + - + +
@@ -59,7 +97,12 @@
-
+
@@ -100,7 +143,12 @@
-
+
@@ -136,7 +184,12 @@

Status de vencimento atual

-
+
@@ -149,7 +202,12 @@

Linhas com e sem serviço ativo

-
+
@@ -163,7 +221,12 @@

Distribuição da base por faixa de franquia

-
+
@@ -175,7 +238,12 @@

Quantidade de linhas por serviço adicional ativo

-
+
@@ -187,13 +255,65 @@

Quantidade de linhas e-SIM e SIMCARD

-
+
+
+
+

Comparativo VIVO

+

Comparação entre contas da operadora VIVO: MACROPHONY x LINE MÓVEL.

+
+ +
+
+
+
+

Linhas por Empresa

+

Volume total de linhas VIVO por empresa.

+
+
+
+ +
+
+ +
+
+
+

Adicionais por Empresa

+

Comparação de linhas com e sem adicionais pagos.

+
+
+
+ +
+
+
+ +
+ Não há linhas VIVO vinculadas às empresas MACROPHONY ou LINE MÓVEL para o filtro atual. +
+
+

Página Resumo

Indicadores do Resumo focados em quantidade e distribuição de linhas.

@@ -240,22 +360,50 @@
Top Clientes (Qtd. Linhas)
-
+
+ +
Top Planos (Qtd. Linhas)
-
+
+ +
PF vs PJ (Qtd. Linhas)
-
+
+ +
Reserva por DDD
-
+
+ +
@@ -294,7 +442,12 @@

Histórico mensal de mudanças de plano/aparelho

-
+
@@ -306,7 +459,12 @@

Histórico mensal de trocas realizadas

-
+
@@ -319,7 +477,12 @@

Contratos a encerrar por mês

-
+
@@ -345,7 +508,12 @@
-
+
@@ -366,7 +534,12 @@

Quantidade de linhas por faixa de franquia contratada

-
+
@@ -381,7 +554,12 @@

Planos com maior volume na sua operação

-
+
@@ -394,7 +572,12 @@

Apenas usuários de fato (sem bloqueados/aguardando)

-
+
@@ -407,7 +590,12 @@

Distribuição entre e-SIM, SIMCARD e outros

-
+
@@ -434,5 +622,54 @@
+ + diff --git a/src/app/pages/dashboard/dashboard.scss b/src/app/pages/dashboard/dashboard.scss index 80bfabf..f518a56 100644 --- a/src/app/pages/dashboard/dashboard.scss +++ b/src/app/pages/dashboard/dashboard.scss @@ -98,6 +98,72 @@ @media(max-width: 768px) { flex-direction: column; align-items: flex-start; gap: 16px; } } +.operadora-filter-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: -8px 0 22px; + padding: 14px 16px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: var(--shadow-sm); + + @media (max-width: 840px) { + flex-direction: column; + align-items: stretch; + } +} + +.operadora-filter-label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + font-weight: 700; + letter-spacing: 0.02em; + + i { + color: var(--brand); + } +} + +.filter-tabs { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.filter-tab { + border: 1px solid rgba(15, 23, 42, 0.15); + background: #fff; + color: var(--text-muted); + border-radius: 999px; + padding: 6px 12px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + border-color: rgba(227, 61, 207, 0.45); + color: var(--brand); + } + + &.active { + background: var(--brand-soft); + border-color: rgba(227, 61, 207, 0.45); + color: var(--brand); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + .badge-pill { display: inline-flex; align-items: center; @@ -178,6 +244,7 @@ display: flex; flex-direction: column; gap: 12px; + cursor: default; transition: all 0.2s ease; box-shadow: var(--shadow-sm); @@ -189,6 +256,15 @@ } } +.hero-card.hero-card-clickable { + cursor: pointer; +} + +.hero-card.hero-card-clickable:focus-visible { + outline: 2px solid rgba(227, 61, 207, 0.7); + outline-offset: 2px; +} + .hero-icon { width: 40px; height: 40px; @@ -213,11 +289,47 @@ letter-spacing: 0.02em; } +.hero-value-row { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 2px; +} + .hero-value { font-size: 24px; font-weight: 800; color: var(--text-main); - margin-top: 2px; + line-height: 1; +} + +.hero-trend { + min-width: 22px; + height: 22px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 800; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(148, 163, 184, 0.12); + + &.trend-up { + color: #15803d; + background: rgba(34, 197, 94, 0.16); + border-color: rgba(34, 197, 94, 0.22); + } + + &.trend-down { + color: #b91c1c; + background: rgba(239, 68, 68, 0.16); + border-color: rgba(239, 68, 68, 0.22); + } + + &.trend-stable { + color: #64748b; + } } .hero-hint { @@ -382,6 +494,22 @@ &.compact-half { height: 200px; } } +.chart-click-target { + cursor: zoom-in; + border-radius: 12px; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 10px 18px -16px rgba(17, 18, 20, 0.65); + } + + &:focus-visible { + outline: 2px solid rgba(3, 15, 170, 0.26); + outline-offset: 2px; + } +} + .card-adicionais .card-body-adicionais { padding: 14px 16px 12px; display: grid; @@ -620,7 +748,221 @@ @media(max-width: 1080px) { grid-template-columns: 1fr; } } +.vivo-comparison-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + + @media (max-width: 980px) { + grid-template-columns: 1fr; + } +} + +.vivo-comparison-empty { + margin-top: 10px; + padding: 12px 14px; + border-radius: 10px; + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.28); + color: var(--text-muted); + font-size: 12px; + font-weight: 600; +} + +.chart-modal-overlay { + position: fixed; + inset: 0; + z-index: 1200; + background: rgba(10, 14, 35, 0.58); + backdrop-filter: blur(5px); + display: flex; + align-items: center; + justify-content: center; + padding: 28px 20px; +} + +.chart-modal-card { + width: min(1120px, 96vw); + max-height: min(86vh, 860px); + background: #fff; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 18px; + box-shadow: 0 30px 70px -26px rgba(2, 8, 23, 0.65); + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalChartIn 0.22s ease; +} + +.chart-modal-header { + padding: 14px 18px; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.chart-modal-title-wrap { + h3 { + margin: 0; + font-size: 16px; + font-weight: 800; + color: var(--text-main); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-muted); + font-weight: 600; + } +} + +.chart-modal-close { + width: 34px; + height: 34px; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: #fff; + color: rgba(17, 18, 20, 0.7); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: rgba(227, 61, 207, 0.35); + color: var(--brand); + background: var(--brand-soft); + } +} + +.chart-modal-body { + position: relative; + height: min(72vh, 680px); + min-height: 360px; + padding: 14px 16px 16px; +} + +.chart-modal-content { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(300px, 0.75fr); + gap: 14px; + height: 100%; +} + +.chart-modal-visual { + position: relative; + min-height: 0; +} + +.chart-modal-info { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 12px; + background: #f8fafc; + overflow: auto; + display: flex; + flex-direction: column; +} + +.chart-modal-info-head, +.chart-modal-info-row { + display: grid; + grid-template-columns: minmax(120px, 1.2fr) repeat(var(--dataset-cols, 1), minmax(78px, 1fr)); + gap: 10px; + padding: 10px 12px; + align-items: center; +} + +.chart-modal-info-head { + position: sticky; + top: 0; + z-index: 1; + background: #eef2ff; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + font-size: 11px; + font-weight: 800; + color: #334155; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.chart-modal-info-row { + border-bottom: 1px solid rgba(15, 23, 42, 0.06); + font-size: 12px; + color: #0f172a; + font-weight: 600; + background: #fff; + + &:nth-child(odd) { + background: #fdfdff; + } +} + +.chart-modal-info .col-label { + text-align: left; + word-break: break-word; +} + +.chart-modal-info .col-value { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.chart-modal-info-total { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + border-top: 1px solid rgba(15, 23, 42, 0.08); + padding: 10px 12px 12px; + background: #f1f5f9; + font-size: 11px; + font-weight: 700; + color: #334155; +} + +@keyframes modalChartIn { + from { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + /* Utils */ .text-brand { color: var(--brand); } .text-brand-dark { color: #b832a8; } .full-width { width: 100%; } + +@media (max-width: 760px) { + .chart-modal-overlay { + padding: 16px 10px; + } + + .chart-modal-card { + width: 100%; + border-radius: 14px; + } + + .chart-modal-body { + min-height: 300px; + height: min(72vh, 620px); + padding: 10px 10px 12px; + } + + .chart-modal-content { + grid-template-columns: 1fr; + grid-template-rows: minmax(200px, 1fr) minmax(140px, auto); + } + + .chart-modal-info-head, + .chart-modal-info-row { + grid-template-columns: minmax(120px, 1.2fr) repeat(var(--dataset-cols, 1), minmax(72px, 1fr)); + } +} diff --git a/src/app/pages/dashboard/dashboard.ts b/src/app/pages/dashboard/dashboard.ts index d734fed..68fa500 100644 --- a/src/app/pages/dashboard/dashboard.ts +++ b/src/app/pages/dashboard/dashboard.ts @@ -6,11 +6,12 @@ import { ViewChild, ElementRef, Inject, + HostListener, } from '@angular/core'; import { CommonModule, isPlatformBrowser } from '@angular/common'; import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'; import { PLATFORM_ID } from '@angular/core'; -import { RouterModule } from '@angular/router'; +import { RouterModule, Router } from '@angular/router'; import { firstValueFrom } from 'rxjs'; import { environment } from '../../../environments/environment'; @@ -21,16 +22,32 @@ import { LineTotal, } from '../../services/resumo.service'; import { AuthService } from '../../services/auth.service'; +import { buildApiBaseUrl } from '../../utils/api-base.util'; +import { + type AccountCompanyOption, + DEFAULT_ACCOUNT_COMPANIES, + mergeAccountCompaniesWithDefaults, + type OperadoraFiltro, + resolveOperadoraContext, +} from '../../utils/account-operator.util'; // --- Interfaces (Mantidas intactas para não quebrar contrato) --- +type KpiTrendDirection = 'up' | 'down' | 'stable'; + type KpiCard = { key: string; title: string; value: string; icon: string; + trend: KpiTrendDirection; hint?: string; }; +type KpiNavigationTarget = { + route: string; + queryParams?: Record; +}; + type SerieMesDto = { mes: string; total: number; @@ -93,6 +110,7 @@ type DashboardKpisDto = { type DashboardDto = { kpis: DashboardKpisDto; + kpiTrends?: Record | null; topClientes: TopClienteDto[]; serieMuregUltimos12Meses: SerieMesDto[]; serieTrocaUltimos12Meses: SerieMesDto[]; @@ -156,12 +174,18 @@ type DashboardGeralInsightsDto = { }; type DashboardLineListItemDto = { + id?: string | null; + conta?: string | null; + contaEmpresa?: string | null; + empresaConta?: string | null; linha?: string | null; cliente?: string | null; usuario?: string | null; skil?: string | null; planoContrato?: string | null; status?: string | null; + vencConta?: string | null; + franquiaVivo?: number | null; franquiaLine?: number | null; gestaoVozDados?: number | null; skeelo?: number | null; @@ -206,6 +230,61 @@ type ClientDashboardOverview = { outrosStatus: number; }; +type DashboardHistoryEvent = { + mobileLineId: string; + linhaAntiga: string; + linhaNova: string; + date: Date | null; +}; + +type DashboardVivoComparison = { + macrophonyLinhas: number; + lineMovelLinhas: number; + macrophonyComAdicionais: number; + macrophonySemAdicionais: number; + lineMovelComAdicionais: number; + lineMovelSemAdicionais: number; +}; + +type DashboardChartModalKey = + | 'status' + | 'adicionaisComparativo' + | 'vigenciaBuckets' + | 'travel' + | 'linhasFranquia' + | 'adicionaisPagos' + | 'tipoChip' + | 'vivoEmpresasLinhas' + | 'vivoEmpresasAdicionais' + | 'resumoTopClientes' + | 'resumoTopPlanos' + | 'resumoPfPj' + | 'resumoReservaDdd' + | 'mureg12' + | 'troca12' + | 'vigenciaMesAno'; + +type DashboardChartModalMeta = { + title: string; + subtitle?: string; +}; + +type DashboardChartModalInfoCell = { + dataset: string; + valueText: string; + numericValue: number | null; +}; + +type DashboardChartModalInfoRow = { + label: string; + cells: DashboardChartModalInfoCell[]; +}; + +type DashboardChartModalDatasetTotal = { + dataset: string; + totalText: string; +}; + @Component({ selector: 'app-dashboard', standalone: true, @@ -229,10 +308,21 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { @ViewChild('chartResumoTopPlanos') chartResumoTopPlanos?: ElementRef; @ViewChild('chartResumoTopClientes') chartResumoTopClientes?: ElementRef; @ViewChild('chartResumoReservaDdd') chartResumoReservaDdd?: ElementRef; + @ViewChild('chartVivoEmpresasLinhas') chartVivoEmpresasLinhas?: ElementRef; + @ViewChild('chartVivoEmpresasAdicionais') chartVivoEmpresasAdicionais?: ElementRef; + @ViewChild('chartExpandedCanvas') chartExpandedCanvas?: ElementRef; loading = true; errorMsg: string | null = null; isCliente = false; + operadoraFilter: OperadoraFiltro = 'TODOS'; + operadoraFilterLoading = false; + readonly operadoraFilters: Array<{ label: string; value: OperadoraFiltro }> = [ + { label: 'TODOS', value: 'TODOS' }, + { label: 'VIVO', value: 'VIVO' }, + { label: 'CLARO', value: 'CLARO' }, + { label: 'TIM', value: 'TIM' }, + ]; kpis: KpiCard[] = []; @@ -328,6 +418,20 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { pjLinhas: null, totalLinhas: null, }; + vivoComparison: DashboardVivoComparison = { + macrophonyLinhas: 0, + lineMovelLinhas: 0, + macrophonyComAdicionais: 0, + macrophonySemAdicionais: 0, + lineMovelComAdicionais: 0, + lineMovelSemAdicionais: 0, + }; + chartModalOpen = false; + chartModalTitle = ''; + chartModalSubtitle = ''; + chartModalInfoRows: DashboardChartModalInfoRow[] = []; + chartModalDatasetHeaders: string[] = []; + chartModalDatasetTotals: DashboardChartModalDatasetTotal[] = []; private viewReady = false; private dataReady = false; @@ -352,17 +456,73 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { private chartResumoPlanos?: Chart; private chartResumoClientes?: Chart; private chartResumoReserva?: Chart; + private chartVivoComparacaoLinhas?: Chart; + private chartVivoComparacaoAdicionais?: Chart; + private chartExpanded?: Chart; + private chartModalKey: DashboardChartModalKey | null = null; + private bodyOverflowBeforeChartModal: string | null = null; + private readonly chartModalMeta: Record = { + status: { title: 'Status da Base', subtitle: 'Distribuição atual das linhas' }, + adicionaisComparativo: { title: 'Serviços Adicionais', subtitle: 'Comparativo entre linhas com e sem adicionais' }, + vigenciaBuckets: { title: 'Vigência (Buckets)', subtitle: 'Status de vencimento atual' }, + travel: { title: 'Vivo Travel', subtitle: 'Linhas com e sem serviço ativo' }, + linhasFranquia: { title: 'Linhas por Franquia', subtitle: 'Distribuição por faixa de franquia' }, + adicionaisPagos: { title: 'Adicionais Pagos (Serviços)', subtitle: 'Quantidade por serviço adicional ativo' }, + tipoChip: { title: 'Tipo de Chip', subtitle: 'Distribuição entre e-SIM, SIMCARD e outros' }, + vivoEmpresasLinhas: { title: 'Comparativo VIVO: Linhas por Empresa' }, + vivoEmpresasAdicionais: { title: 'Comparativo VIVO: Adicionais por Empresa' }, + resumoTopClientes: { title: 'Resumo: Top Clientes' }, + resumoTopPlanos: { title: 'Resumo: Top Planos' }, + resumoPfPj: { title: 'Resumo: PF vs PJ' }, + resumoReservaDdd: { title: 'Resumo: Reserva por DDD' }, + mureg12: { title: 'MUREG (12 Meses)' }, + troca12: { title: 'Troca de Número (12 Meses)' }, + vigenciaMesAno: { title: 'Vigência (Próx. 12 Meses)' }, + }; + + private dashboardApiCache: DashboardDto | null = null; + private insightsApiCache: DashboardGeralInsightsDto | null = null; + private resumoApiCache: ResumoResponse | null = null; + private readonly fallbackAccountCompanies: AccountCompanyOption[] = DEFAULT_ACCOUNT_COMPANIES.map((group) => ({ + empresa: group.empresa, + contas: [...group.contas], + })); + private accountCompanies: AccountCompanyOption[] = [...this.fallbackAccountCompanies]; + private operatorLinesCache: DashboardLineListItemDto[] = []; + private operatorMuregCache: DashboardHistoryEvent[] = []; + private operatorTrocaCache: DashboardHistoryEvent[] = []; + private filteredLinesCache: DashboardLineListItemDto[] = []; + private operatorDatasetReady = false; + private lineFranquiaCacheById = new Map(); + private kpiTrendMap: Record = {}; private readonly baseApi: string; + private readonly kpiNavigationMap: Record = { + linhas_total: { route: '/geral' }, + linhas_ativas: { route: '/geral' }, + linhas_bloqueadas: { route: '/geral', queryParams: { statusMode: 'blocked' } }, + linhas_reserva: { route: '/geral', queryParams: { skil: 'RESERVA' } }, + franquia_vivo_total: { route: '/geral' }, + franquia_line_total: { route: '/geral' }, + vig_vencidos: { route: '/vigencia' }, + vig_30: { route: '/vigencia' }, + mureg_30: { route: '/mureg' }, + troca_30: { route: '/trocanumero' }, + cadastros_total: { route: '/dadosusuarios' }, + travel_com: { route: '/geral', queryParams: { additionalMode: 'with', additionalServices: 'travel' } }, + adicional_pago: { route: '/geral', queryParams: { additionalMode: 'with' } }, + planos_contratados: { route: '/resumo', queryParams: { tab: 'planos' } }, + usuarios_com_linha: { route: '/dadosusuarios' }, + }; constructor( private http: HttpClient, private resumoService: ResumoService, private authService: AuthService, + private router: Router, @Inject(PLATFORM_ID) private platformId: object ) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } ngOnInit(): void { @@ -370,16 +530,21 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const isSysAdmin = this.authService.hasRole('sysadmin'); const isGestor = this.authService.hasRole('gestor'); - this.isCliente = !(isSysAdmin || isGestor); + const isFinanceiro = this.authService.hasRole('financeiro'); + this.isCliente = !(isSysAdmin || isGestor || isFinanceiro); if (this.isCliente) { this.loadClientDashboardData(); return; } + void this.loadAccountCompaniesForFilters(); this.loadDashboard(); this.loadInsights(); this.loadResumoExecutive(); + void this.preloadOperatorDatasets().catch(() => { + this.operatorDatasetReady = false; + }); } ngAfterViewInit(): void { @@ -389,10 +554,69 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy(): void { + this.closeChartModal(); this.destroyCharts(); this.destroyResumoCharts(); } + get isOperadoraFiltroAtivo(): boolean { + return !this.isCliente && this.operadoraFilter !== 'TODOS'; + } + + get showVivoComparison(): boolean { + return this.isOperadoraFiltroAtivo && this.operadoraFilter === 'VIVO'; + } + + onOperadoraFilterChange(filter: OperadoraFiltro): void { + if (this.operadoraFilter === filter || this.isCliente) return; + this.operadoraFilter = filter; + void this.applyOperadoraFilter(); + } + + @HostListener('document:keydown.escape', ['$event']) + onEscapeKey(event: Event): void { + if (!this.chartModalOpen) return; + if (event instanceof KeyboardEvent) { + event.preventDefault(); + } + this.closeChartModal(); + } + + onChartTargetKeydown(event: Event, key: DashboardChartModalKey): void { + if (!(event instanceof KeyboardEvent)) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + this.openChartModal(key); + } + + openChartModal(key: DashboardChartModalKey): void { + if (!isPlatformBrowser(this.platformId)) return; + + const sourceChart = this.getChartInstanceByModalKey(key); + if (!sourceChart) return; + + this.chartModalKey = key; + this.chartModalTitle = this.chartModalMeta[key]?.title ?? 'Gráfico'; + this.chartModalSubtitle = this.chartModalMeta[key]?.subtitle ?? ''; + this.updateChartModalInfo(sourceChart); + this.chartModalOpen = true; + this.lockBodyScrollForChartModal(); + + requestAnimationFrame(() => this.renderExpandedChart(sourceChart)); + } + + closeChartModal(): void { + this.chartModalOpen = false; + this.chartModalKey = null; + this.chartModalTitle = ''; + this.chartModalSubtitle = ''; + this.chartModalInfoRows = []; + this.chartModalDatasetHeaders = []; + this.chartModalDatasetTotals = []; + this.destroyExpandedChart(); + this.restoreBodyScrollForChartModal(); + } + private async loadDashboard() { this.loading = true; this.errorMsg = null; @@ -400,15 +624,20 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { try { const dto = await this.fetchDashboardReal(); - this.applyDto(dto); + this.dashboardApiCache = dto; + if (this.operadoraFilter === 'TODOS') { + this.applyDto(dto); + } this.dataReady = true; this.loading = false; this.tryBuildCharts(); } catch (error) { + this.dashboardApiCache = null; this.loading = false; this.dashboardRaw = null; + this.kpiTrendMap = {}; this.kpis = []; this.errorMsg = this.isNetworkError(error) ? 'Falha ao carregar o Dashboard. Verifique a conexão.' @@ -425,11 +654,13 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.resumoReady = false; try { - const [operacionais, reservas] = await Promise.all([ + const [operacionais, reservas, dashboardDto] = await Promise.all([ this.fetchAllDashboardLines(false), this.fetchAllDashboardLines(true), + this.fetchDashboardReal().catch(() => null), ]); const allLines = [...operacionais, ...reservas]; + this.syncKpiTrendMap(dashboardDto?.kpiTrends ?? null); this.applyClientLineAggregates(allLines); this.loading = false; @@ -443,6 +674,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.resumoLoading = false; this.resumoReady = false; this.dataReady = false; + this.kpiTrendMap = {}; this.errorMsg = this.isNetworkError(error) ? 'Falha ao carregar o Dashboard. Verifique a conexão.' : 'Falha ao carregar os dados do cliente.'; @@ -650,6 +882,14 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.resumoClientesValues = []; this.resumoReservaLabels = []; this.resumoReservaValues = []; + this.vivoComparison = { + macrophonyLinhas: 0, + lineMovelLinhas: 0, + macrophonyComAdicionais: 0, + macrophonySemAdicionais: 0, + lineMovelComAdicionais: 0, + lineMovelSemAdicionais: 0, + }; this.rebuildPrimaryKpis(); this.destroyCharts(); this.destroyResumoCharts(); @@ -676,15 +916,21 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const url = `${this.baseApi}/dashboard/geral/insights`; this.http.get(url).subscribe({ next: (dto) => { - this.applyInsights(dto || null); + this.insightsApiCache = dto || null; + if (this.operadoraFilter === 'TODOS') { + this.applyInsights(dto || null); + } this.insightsLoading = false; this.tryBuildCharts(); }, error: () => { + this.insightsApiCache = null; this.insightsLoading = false; this.insightsError = 'Falha nos insights.'; - this.clearInsightsData(); - void this.loadFallbackFromLinesIfNeeded(true); + if (this.operadoraFilter === 'TODOS') { + this.clearInsightsData(); + void this.loadFallbackFromLinesIfNeeded(true); + } }, }); } @@ -697,18 +943,28 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.resumoService.getResumo().subscribe({ next: (dto) => { - this.resumo = dto ? this.normalizeResumo(dto) : null; + this.resumoApiCache = dto ? this.normalizeResumo(dto) : null; + if (this.operadoraFilter === 'TODOS') { + this.resumo = this.resumoApiCache; + } this.resumoLoading = false; this.resumoReady = true; - this.buildResumoDerived(); + if (this.operadoraFilter === 'TODOS') { + this.buildResumoDerived(); + } this.tryBuildResumoCharts(); }, error: () => { + this.resumoApiCache = null; this.resumoLoading = false; this.resumoError = 'Falha ao carregar dados do resumo.'; - this.resumo = null; + if (this.operadoraFilter === 'TODOS') { + this.resumo = null; + } this.resumoReady = false; - this.clearResumoDerived(); + if (this.operadoraFilter === 'TODOS') { + this.clearResumoDerived(); + } }, }); } @@ -718,19 +974,838 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { void this.loadClientDashboardData(); return; } + if (this.isOperadoraFiltroAtivo) { + this.applyOperadoraDerivedState(this.filteredLinesCache); + this.tryBuildCharts(); + this.tryBuildResumoCharts(); + return; + } this.buildResumoDerived(); this.tryBuildResumoCharts(); } - private async fetchDashboardReal(): Promise { + private async fetchDashboardReal(operadora: OperadoraFiltro = 'TODOS'): Promise { if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado'); const url = `${this.baseApi}/relatorios/dashboard`; - return await firstValueFrom(this.http.get(url)); + let params = new HttpParams(); + if (operadora !== 'TODOS') { + params = params.set('operadora', operadora); + } + + return await firstValueFrom(this.http.get(url, { params })); + } + + private async fetchInsightsReal(operadora: OperadoraFiltro = 'TODOS'): Promise { + if (!isPlatformBrowser(this.platformId)) throw new Error('SSR não suportado'); + const url = `${this.baseApi}/dashboard/geral/insights`; + let params = new HttpParams(); + if (operadora !== 'TODOS') { + params = params.set('operadora', operadora); + } + + return await firstValueFrom(this.http.get(url, { params })); + } + + private async applyOperadoraFilter(): Promise { + if (this.isCliente) return; + + if (this.operadoraFilter === 'TODOS') { + this.operadoraFilterLoading = false; + this.restoreDashboardFromApiCaches(); + return; + } + + this.operadoraFilterLoading = true; + this.loading = true; + this.resumoLoading = true; + this.errorMsg = null; + this.resumoError = null; + let filtered: DashboardLineListItemDto[] = []; + + try { + await this.preloadOperatorDatasets(); + filtered = this.resolveLinesByOperadora(this.operadoraFilter); + this.applyOperadoraDerivedState(filtered); + const [filteredDashboard, filteredInsights] = await Promise.all([ + this.fetchDashboardReal(this.operadoraFilter), + this.fetchInsightsReal(this.operadoraFilter), + ]); + this.applyDto(filteredDashboard); + this.applyInsights(filteredInsights); + this.insightsError = null; + this.loading = false; + this.resumoLoading = false; + this.dataReady = true; + this.resumoReady = true; + this.tryBuildCharts(); + this.tryBuildResumoCharts(); + } catch { + if (filtered.length > 0) { + this.applyOperadoraDerivedState(filtered, { updateCoreMetrics: true }); + this.dataReady = true; + this.resumoReady = true; + } else { + this.dataReady = false; + this.resumoReady = false; + } + this.loading = false; + this.resumoLoading = false; + this.errorMsg = 'Falha ao aplicar filtro de operadora.'; + } finally { + this.operadoraFilterLoading = false; + } + } + + private restoreDashboardFromApiCaches(): void { + this.loading = false; + this.resumoLoading = false; + this.errorMsg = null; + this.resumoError = null; + + if (this.dashboardApiCache) { + this.applyDto(this.dashboardApiCache); + } else { + void this.loadDashboard(); + } + + if (this.insightsApiCache) { + this.applyInsights(this.insightsApiCache); + } else { + this.loadInsights(); + } + + if (this.resumoApiCache) { + this.resumo = this.normalizeResumo(this.resumoApiCache); + this.resumoReady = true; + this.buildResumoDerived(); + this.tryBuildResumoCharts(); + } else { + this.loadResumoExecutive(); + } + + this.dataReady = true; + this.tryBuildCharts(); + } + + private async loadAccountCompaniesForFilters(): Promise { + try { + const data = await firstValueFrom( + this.http.get(`${this.baseApi}/lines/account-companies`) + ); + const normalized = this.normalizeAccountCompanies(data); + const source = normalized.length > 0 ? normalized : this.fallbackAccountCompanies; + this.accountCompanies = mergeAccountCompaniesWithDefaults(source); + } catch { + this.accountCompanies = mergeAccountCompaniesWithDefaults(this.fallbackAccountCompanies); + } + + if (this.isOperadoraFiltroAtivo && this.operatorDatasetReady) { + const filtered = this.resolveLinesByOperadora(this.operadoraFilter); + this.applyOperadoraDerivedState(filtered); + this.tryBuildCharts(); + this.tryBuildResumoCharts(); + } + } + + private normalizeAccountCompanies( + data: AccountCompanyOption[] | null | undefined + ): AccountCompanyOption[] { + if (!Array.isArray(data)) return []; + + const result: AccountCompanyOption[] = []; + data.forEach((item) => { + const empresa = String(item?.empresa ?? '').trim(); + if (!empresa) return; + + const contas = Array.isArray(item?.contas) + ? Array.from(new Set(item.contas.map((x) => String(x ?? '').trim()).filter(Boolean))) + : []; + + result.push({ empresa, contas }); + }); + + return result; + } + + private async preloadOperatorDatasets(): Promise { + if (this.operatorDatasetReady) return; + + const [lines, muregs, trocas] = await Promise.all([ + this.fetchAllOperatorLines(), + this.fetchAllHistoryEvents('mureg', ['dataDaMureg', 'data_da_mureg', 'DataDaMureg']), + this.fetchAllHistoryEvents('trocanumero', ['dataTroca', 'data_troca', 'DataTroca', 'dataDaTroca']), + ]); + + this.operatorLinesCache = lines; + this.operatorMuregCache = muregs; + this.operatorTrocaCache = trocas; + this.operatorDatasetReady = true; + } + + private async fetchAllOperatorLines(): Promise { + const [allLines, reservaLines] = await Promise.all([ + this.fetchAllDashboardLines(false), + this.fetchAllDashboardLines(true), + ]); + + const merged = [...allLines, ...reservaLines]; + const dedup = new Map(); + + merged.forEach((line, idx) => { + const key = this.getLineDedupKey(line, idx); + if (!key) return; + if (!dedup.has(key)) dedup.set(key, line); + }); + + return Array.from(dedup.values()); + } + + private getLineDedupKey(line: DashboardLineListItemDto, index: number): string { + const lineId = String(this.readNode(line as any, 'id', 'Id') ?? '').trim(); + if (lineId) return `id:${lineId}`; + + const linha = this.normalizeLineDigits(this.readNode(line as any, 'linha', 'Linha')); + const conta = this.readNode(line as any, 'conta', 'Conta'); + const normalizedConta = String(conta ?? '').trim(); + if (linha || normalizedConta) return `line:${linha}|conta:${normalizedConta}`; + + return `idx:${index}`; + } + + private async ensureFranquiaCacheForLines(lines: DashboardLineListItemDto[]): Promise { + if (!Array.isArray(lines) || lines.length === 0) return; + + const idsToLoad: string[] = []; + + lines.forEach((line) => { + const id = String(this.readNode(line as any, 'id', 'Id') ?? '').trim(); + if (!id || this.lineFranquiaCacheById.has(id)) return; + + const franquiaVivoFromList = this.toNumberOrNull( + this.readLineRawField(line, 'franquiaVivo', 'FranquiaVivo') + ); + const franquiaLineFromList = this.toNumberOrNull( + this.readLineRawField(line, 'franquiaLine', 'FranquiaLine') + ); + + if (franquiaVivoFromList !== null || franquiaLineFromList !== null) { + this.lineFranquiaCacheById.set(id, { + franquiaVivo: franquiaVivoFromList, + franquiaLine: franquiaLineFromList, + }); + return; + } + + idsToLoad.push(id); + }); + + if (!idsToLoad.length) return; + + const concurrency = Math.min(20, idsToLoad.length); + let cursor = 0; + + const workers = Array.from({ length: concurrency }, async () => { + while (true) { + const index = cursor++; + if (index >= idsToLoad.length) break; + const id = idsToLoad[index]; + await this.loadFranquiaFromLineDetail(id); + } + }); + + await Promise.all(workers); + } + + private async loadFranquiaFromLineDetail(id: string): Promise { + try { + const detail = await firstValueFrom(this.http.get(`${this.baseApi}/lines/${id}`)); + + const franquiaVivo = this.toNumberOrNull( + this.readUnknownNumericField(detail, 'franquiaVivo', 'FranquiaVivo') + ); + const franquiaLine = this.toNumberOrNull( + this.readUnknownNumericField(detail, 'franquiaLine', 'FranquiaLine') + ); + + this.lineFranquiaCacheById.set(id, { + franquiaVivo, + franquiaLine, + }); + } catch { + this.lineFranquiaCacheById.set(id, { + franquiaVivo: null, + franquiaLine: null, + }); + } + } + + private async fetchAllHistoryEvents( + endpoint: string, + dateKeys: string[] + ): Promise { + const pageSize = 2000; + let page = 1; + const events: DashboardHistoryEvent[] = []; + + while (page <= 500) { + const params = new HttpParams() + .set('page', String(page)) + .set('pageSize', String(pageSize)); + + const response = await firstValueFrom(this.http.get(`${this.baseApi}/${endpoint}`, { params })); + const itemsRaw = this.readNode(response, 'items', 'Items'); + const items = Array.isArray(response) + ? response + : (Array.isArray(itemsRaw) ? itemsRaw : []); + + events.push(...items.map((row: any) => this.mapHistoryEventRow(row, dateKeys))); + + if (Array.isArray(response)) break; + + const total = this.toNumberOrNull(this.readNode(response, 'total', 'Total')); + if (!items.length) break; + if (total !== null && events.length >= total) break; + if (items.length < pageSize) break; + page += 1; + } + + return events; + } + + private mapHistoryEventRow(row: any, dateKeys: string[]): DashboardHistoryEvent { + return { + mobileLineId: String(this.readNode(row, 'mobileLineId', 'MobileLineId', 'mobile_line_id') ?? '').trim(), + linhaAntiga: String(this.readNode(row, 'linhaAntiga', 'LinhaAntiga', 'linha_antiga') ?? ''), + linhaNova: String(this.readNode(row, 'linhaNova', 'LinhaNova', 'linha_nova') ?? ''), + date: this.parseDateValue(this.readNode(row, ...dateKeys)), + }; + } + + private resolveLinesByOperadora(filter: OperadoraFiltro): DashboardLineListItemDto[] { + if (filter === 'TODOS') return [...this.operatorLinesCache]; + + return this.operatorLinesCache.filter((line) => { + const conta = this.readNode(line as any, 'conta', 'Conta'); + const empresaConta = this.readNode( + line as any, + 'contaEmpresa', + 'ContaEmpresa', + 'empresaConta', + 'EmpresaConta', + 'empresa_conta', + 'Empresa_Conta', + 'empresa (conta)', + 'EMPRESA (CONTA)', + 'empresa', + 'Empresa' + ); + const operadora = resolveOperadoraContext({ + conta, + empresaConta, + accountCompanies: this.accountCompanies, + }).operadora; + return operadora === filter; + }); + } + + private applyOperadoraDerivedState( + lines: DashboardLineListItemDto[], + options?: { updateCoreMetrics?: boolean } + ): void { + this.filteredLinesCache = [...lines]; + const shouldUpdateCoreMetrics = options?.updateCoreMetrics === true; + + const now = new Date(); + const clientesSet = new Set(); + const usuariosSet = new Set(); + const planoMap = new Map(); + const clienteMap = new Map(); + const franquiaBandMap = new Map(); + const reservaDddMap = new Map(); + + const additionalCounts = { + gvd: 0, + skeelo: 0, + news: 0, + travel: 0, + sync: 0, + dispositivo: 0, + }; + + let totalLinhas = 0; + let ativas = 0; + let bloqueadas = 0; + let reservas = 0; + let perdaRoubo = 0; + let bloq120 = 0; + let outras = 0; + let franquiaVivoTotalGb = 0; + let franquiaLineTotalGb = 0; + let pfLinhas = 0; + let pjLinhas = 0; + let eSim = 0; + let simCard = 0; + let outrosChip = 0; + let comAdicionais = 0; + let semAdicionais = 0; + let travelCom = 0; + let vigenciaTotal = 0; + + const vigenciaBuckets: VigenciaBucketsDto = { + vencidos: 0, + aVencer0a30: 0, + aVencer31a60: 0, + aVencer61a90: 0, + acima90: 0, + }; + + const vigenciaFuturaMap = new Map(); + const vigenciaAxis = this.buildMonthAxis(0, 12); + vigenciaAxis.keys.forEach((key) => vigenciaFuturaMap.set(key, 0)); + + const lineIds = new Set(); + const lineDigits = new Set(); + + for (const line of lines) { + totalLinhas += 1; + + const lineId = String(this.readNode(line as any, 'id', 'Id') ?? '').trim(); + if (lineId) lineIds.add(lineId); + + const linhaDigits = this.normalizeLineDigits(this.readNode(line as any, 'linha', 'Linha')); + if (linhaDigits) lineDigits.add(linhaDigits); + + const cliente = this.readLineString(line, 'cliente', 'Cliente').trim(); + const usuario = this.readLineString(line, 'usuario', 'Usuario').trim(); + const statusRaw = this.readLineString(line, 'status', 'Status'); + const status = this.normalizeSeriesKey(statusRaw); + const skil = this.normalizeSeriesKey(this.readLineString(line, 'skil', 'Skil')); + const plano = this.readLineString(line, 'planoContrato', 'PlanoContrato').trim(); + const franquiaVivo = this.readLineNumber(line, 'franquiaVivo', 'FranquiaVivo'); + const franquiaLine = this.readLineNumber(line, 'franquiaLine', 'FranquiaLine'); + const tipoChip = this.normalizeChipType(this.readLineString(line, 'tipoDeChip', 'TipoDeChip')); + const isReserva = this.isReservaLine(line); + + if (cliente && !this.normalizeSeriesKey(cliente).includes('RESERVA')) { + clientesSet.add(cliente); + clienteMap.set(cliente, (clienteMap.get(cliente) ?? 0) + 1); + } + if (usuario && this.normalizeSeriesKey(usuario) !== 'RESERVA') { + usuariosSet.add(usuario); + } + + if (isReserva) { + reservas += 1; + const ddd = this.extractDddFromLine(this.readLineString(line, 'linha', 'Linha')) ?? '-'; + reservaDddMap.set(ddd, (reservaDddMap.get(ddd) ?? 0) + 1); + } else if (status.includes('ATIV')) { + ativas += 1; + } else if (status.includes('BLOQUE') || status.includes('PERDA') || status.includes('ROUBO') || status.includes('SUSPEN') || status.includes('CANCEL')) { + bloqueadas += 1; + if (status.includes('PERDA') || status.includes('ROUBO')) { + perdaRoubo += 1; + } else if (status.includes('120')) { + bloq120 += 1; + } else { + outras += 1; + } + } else { + outras += 1; + } + + if (!isReserva) { + const planoKey = plano || 'Sem plano'; + planoMap.set(planoKey, (planoMap.get(planoKey) ?? 0) + 1); + } + + if (skil.includes('FISICA') || skil === 'PF') pfLinhas += 1; + if (skil.includes('JURIDICA') || skil === 'PJ') pjLinhas += 1; + + if (franquiaVivo > 0) franquiaVivoTotalGb += franquiaVivo; + if (franquiaLine > 0) franquiaLineTotalGb += franquiaLine; + const faixa = this.resolveFranquiaLineBand(franquiaLine); + franquiaBandMap.set(faixa, (franquiaBandMap.get(faixa) ?? 0) + 1); + + if (tipoChip === 'ESIM') eSim += 1; + else if (tipoChip === 'SIMCARD') simCard += 1; + else outrosChip += 1; + + const hasPaidAdditional = this.hasAnyPaidAdditional(line); + if (hasPaidAdditional) comAdicionais += 1; + else semAdicionais += 1; + + if (this.readLineNumber(line, 'gestaoVozDados', 'GestaoVozDados') > 0) additionalCounts.gvd += 1; + if (this.readLineNumber(line, 'skeelo', 'Skeelo') > 0) additionalCounts.skeelo += 1; + if (this.readLineNumber(line, 'vivoNewsPlus', 'VivoNewsPlus') > 0) additionalCounts.news += 1; + if (this.readLineNumber(line, 'vivoTravelMundo', 'VivoTravelMundo') > 0) { + additionalCounts.travel += 1; + travelCom += 1; + } + if (this.readLineNumber(line, 'vivoSync', 'VivoSync') > 0) additionalCounts.sync += 1; + if (this.readLineNumber(line, 'vivoGestaoDispositivo', 'VivoGestaoDispositivo') > 0) additionalCounts.dispositivo += 1; + + const vencContaRaw = this.readNode(line as any, 'vencConta', 'VencConta'); + const vencConta = this.parseDateValue(vencContaRaw); + if (vencConta) { + vigenciaTotal += 1; + const diff = Math.floor((this.startOfDay(vencConta).getTime() - this.startOfDay(now).getTime()) / 86400000); + if (diff < 0) vigenciaBuckets.vencidos += 1; + else if (diff <= 30) vigenciaBuckets.aVencer0a30 += 1; + else if (diff <= 60) vigenciaBuckets.aVencer31a60 += 1; + else if (diff <= 90) vigenciaBuckets.aVencer61a90 += 1; + else vigenciaBuckets.acima90 += 1; + + const key = `${vencConta.getFullYear()}-${this.pad2(vencConta.getMonth() + 1)}`; + if (vigenciaFuturaMap.has(key)) { + vigenciaFuturaMap.set(key, (vigenciaFuturaMap.get(key) ?? 0) + 1); + } + } + } + + const muregFiltered = this.filterHistoryEventsByLines(this.operatorMuregCache, lineIds, lineDigits); + const trocaFiltered = this.filterHistoryEventsByLines(this.operatorTrocaCache, lineIds, lineDigits); + + const muregSeries = this.buildHistorySeries(muregFiltered, 11, 12); + const trocaSeries = this.buildHistorySeries(trocaFiltered, 11, 12); + const muregCutoff = this.startOfDay(new Date(now.getTime() - 30 * 86400000)); + const muregsUltimos30Dias = muregFiltered.filter((event) => event.date && this.startOfDay(event.date) >= muregCutoff).length; + const trocasUltimos30Dias = trocaFiltered.filter((event) => event.date && this.startOfDay(event.date) >= muregCutoff).length; + + const franquiaOrder = ['Sem franquia', 'Até 10 GB', '10 a 20 GB', '20 a 50 GB', 'Acima de 50 GB']; + this.franquiaLabels = franquiaOrder; + this.franquiaValues = franquiaOrder.map((label) => franquiaBandMap.get(label) ?? 0); + + this.adicionaisLabels = [...this.adicionaisLabelsPadrao]; + this.adicionaisValues = [ + additionalCounts.gvd, + additionalCounts.skeelo, + additionalCounts.news, + additionalCounts.travel, + additionalCounts.sync, + additionalCounts.dispositivo, + ]; + this.adicionaisTotals = this.adicionaisLabels.map(() => null); + this.travelLabels = ['Com Travel', 'Sem Travel']; + this.travelValues = [travelCom, Math.max(totalLinhas - travelCom, 0)]; + this.tipoChipLabels = ['e-SIM', 'SIMCARD', 'Outros']; + this.tipoChipValues = [eSim, simCard, outrosChip]; + + this.muregLabels = muregSeries.labels; + this.muregValues = muregSeries.values; + this.trocaLabels = trocaSeries.labels; + this.trocaValues = trocaSeries.values; + this.vigenciaLabels = vigenciaAxis.labels; + this.vigenciaValues = vigenciaAxis.keys.map((key) => vigenciaFuturaMap.get(key) ?? 0); + this.vigBuckets = vigenciaBuckets; + + this.statusResumo = { + total: totalLinhas, + ativos: ativas, + bloqueadas, + perdaRoubo, + bloq120, + reservas, + outras, + }; + + this.rebuildAdicionaisComparativo({ + totalLinesWithAnyPaidAdditional: comAdicionais, + totalLinesWithNoPaidAdditional: semAdicionais, + }); + + if (shouldUpdateCoreMetrics) { + this.dashboardRaw = { + totalLinhas, + clientesUnicos: clientesSet.size, + ativos: ativas, + bloqueados: bloqueadas, + reservas, + bloqueadosPerdaRoubo: perdaRoubo, + bloqueados120Dias: bloq120, + bloqueadosOutros: outras, + totalMuregs: muregFiltered.length, + muregsUltimos30Dias, + totalTrocas: trocaFiltered.length, + trocasUltimos30Dias, + totalVigenciaLinhas: vigenciaTotal, + vigenciaVencidos: vigenciaBuckets.vencidos, + vigenciaAVencer30: vigenciaBuckets.aVencer0a30, + userDataRegistros: totalLinhas, + userDataComCpf: 0, + userDataComEmail: 0, + }; + } + + if (shouldUpdateCoreMetrics) { + this.insights = this.buildSyntheticInsights( + totalLinhas, + ativas, + franquiaVivoTotalGb, + franquiaLineTotalGb, + comAdicionais, + semAdicionais, + pfLinhas, + pjLinhas + ); + } + + this.buildResumoDerivedFromLines(lines, clienteMap, planoMap, reservaDddMap, pfLinhas, pjLinhas); + this.buildVivoComparison(lines); + if (shouldUpdateCoreMetrics) { + this.rebuildPrimaryKpis(); + } + + this.insightsError = null; + this.resumoError = null; + this.resumoLoading = false; + this.resumoReady = true; + } + + private buildSyntheticInsights( + totalLinhas: number, + totalAtivas: number, + totalFranquiaVivo: number, + totalFranquiaLine: number, + comAdicionais: number, + semAdicionais: number, + pfLinhas: number, + pjLinhas: number + ): DashboardGeralInsightsDto { + return { + kpis: { + totalLinhas, + totalAtivas, + vivo: { + qtdLinhas: totalLinhas, + totalFranquiaGb: totalFranquiaVivo, + totalFranquiaLine: totalFranquiaLine, + }, + travelMundo: { + comTravel: this.travelValues[0] ?? 0, + semTravel: this.travelValues[1] ?? 0, + totalValue: 0, + }, + adicionais: { + totalLinesWithAnyPaidAdditional: comAdicionais, + totalLinesWithNoPaidAdditional: semAdicionais, + }, + totaisLine: [ + { tipo: 'PF', qtdLinhas: pfLinhas, valorTotalLine: null, lucroTotalLine: null }, + { tipo: 'PJ', qtdLinhas: pjLinhas, valorTotalLine: null, lucroTotalLine: null }, + ], + }, + charts: { + linhasPorFranquia: { + labels: this.franquiaLabels, + values: this.franquiaValues, + }, + adicionaisPagosPorServico: { + labels: this.adicionaisLabels, + values: this.adicionaisValues, + totals: this.adicionaisTotals, + }, + travelMundo: { + labels: this.travelLabels, + values: this.travelValues, + }, + tipoChip: { + labels: this.tipoChipLabels, + values: this.tipoChipValues, + }, + }, + }; + } + + private buildResumoDerivedFromLines( + lines: DashboardLineListItemDto[], + clienteMap: Map, + planoMap: Map, + reservaDddMap: Map, + pfLinhas: number, + pjLinhas: number + ): void { + const topClientes = Array.from(clienteMap.entries()) + .map(([cliente, qtd]) => ({ cliente, linhas: qtd })) + .sort((a, b) => b.linhas - a.linhas || a.cliente.localeCompare(b.cliente, 'pt-BR', { sensitivity: 'base' })) + .slice(0, this.resumoTopN); + + const topPlanos = Array.from(planoMap.entries()) + .map(([plano, qtd]) => ({ plano, linhas: qtd })) + .sort((a, b) => b.linhas - a.linhas || a.plano.localeCompare(b.plano, 'pt-BR', { sensitivity: 'base' })) + .slice(0, this.resumoTopN); + + const topReserva = Array.from(reservaDddMap.entries()) + .map(([ddd, total]) => ({ ddd, total, linhas: total })) + .sort((a, b) => b.total - a.total) + .slice(0, this.resumoTopN); + + this.resumoTopClientes = topClientes; + this.resumoClientesLabels = topClientes.map((item) => item.cliente); + this.resumoClientesValues = topClientes.map((item) => item.linhas); + + this.resumoTopPlanos = topPlanos; + this.resumoPlanosLabels = topPlanos.map((item) => item.plano); + this.resumoPlanosValues = topPlanos.map((item) => item.linhas); + + this.resumoTopReserva = topReserva; + this.resumoReservaLabels = topReserva.map((item) => item.ddd); + this.resumoReservaValues = topReserva.map((item) => item.total); + + this.resumoPfPjLabels = ['Pessoa Física', 'Pessoa Jurídica']; + this.resumoPfPjValues = [pfLinhas, pjLinhas]; + this.resumoDiferencaPjPf = { + pfLinhas, + pjLinhas, + totalLinhas: lines.length, + }; + } + + private buildVivoComparison(lines: DashboardLineListItemDto[]): void { + const comparison: DashboardVivoComparison = { + macrophonyLinhas: 0, + lineMovelLinhas: 0, + macrophonyComAdicionais: 0, + macrophonySemAdicionais: 0, + lineMovelComAdicionais: 0, + lineMovelSemAdicionais: 0, + }; + + lines.forEach((line) => { + const conta = this.readNode(line as any, 'conta', 'Conta'); + const empresaConta = this.readNode( + line as any, + 'contaEmpresa', + 'ContaEmpresa', + 'empresaConta', + 'EmpresaConta', + 'empresa_conta', + 'Empresa_Conta', + 'empresa (conta)', + 'EMPRESA (CONTA)', + 'empresa', + 'Empresa' + ); + + const context = resolveOperadoraContext({ + conta, + empresaConta, + accountCompanies: this.accountCompanies, + }); + if (context.operadora !== 'VIVO') return; + + const hasAdditional = this.hasAnyPaidAdditional(line); + if (context.vivoEmpresaGrupo === 'MACROPHONY') { + comparison.macrophonyLinhas += 1; + if (hasAdditional) comparison.macrophonyComAdicionais += 1; + else comparison.macrophonySemAdicionais += 1; + } else if (context.vivoEmpresaGrupo === 'LINE MOVEL') { + comparison.lineMovelLinhas += 1; + if (hasAdditional) comparison.lineMovelComAdicionais += 1; + else comparison.lineMovelSemAdicionais += 1; + } + }); + + this.vivoComparison = comparison; + } + + private hasAnyPaidAdditional(line: DashboardLineListItemDto): boolean { + return ( + this.readLineNumber(line, 'gestaoVozDados', 'GestaoVozDados') > 0 + || this.readLineNumber(line, 'skeelo', 'Skeelo') > 0 + || this.readLineNumber(line, 'vivoNewsPlus', 'VivoNewsPlus') > 0 + || this.readLineNumber(line, 'vivoTravelMundo', 'VivoTravelMundo') > 0 + || this.readLineNumber(line, 'vivoSync', 'VivoSync') > 0 + || this.readLineNumber(line, 'vivoGestaoDispositivo', 'VivoGestaoDispositivo') > 0 + ); + } + + private filterHistoryEventsByLines( + events: DashboardHistoryEvent[], + lineIds: Set, + lineDigits: Set + ): DashboardHistoryEvent[] { + return events.filter((event) => { + const eventLineId = String(event.mobileLineId ?? '').trim(); + if (eventLineId && lineIds.has(eventLineId)) return true; + + const linhaNova = this.normalizeLineDigits(event.linhaNova); + if (linhaNova && lineDigits.has(linhaNova)) return true; + + const linhaAntiga = this.normalizeLineDigits(event.linhaAntiga); + if (linhaAntiga && lineDigits.has(linhaAntiga)) return true; + + return false; + }); + } + + private buildHistorySeries(events: DashboardHistoryEvent[], monthsBack: number, count: number): { labels: string[]; values: number[] } { + const axis = this.buildMonthAxis(monthsBack, count); + const map = new Map(); + axis.keys.forEach((key) => map.set(key, 0)); + + events.forEach((event) => { + if (!event.date) return; + const key = `${event.date.getFullYear()}-${this.pad2(event.date.getMonth() + 1)}`; + if (!map.has(key)) return; + map.set(key, (map.get(key) ?? 0) + 1); + }); + + return { + labels: axis.labels, + values: axis.keys.map((key) => map.get(key) ?? 0), + }; + } + + private buildMonthAxis(monthsBack: number, count: number): { labels: string[]; keys: string[] } { + const now = new Date(); + const labels: string[] = []; + const keys: string[] = []; + + for (let i = monthsBack; i >= monthsBack - count + 1; i -= 1) { + const month = new Date(now.getFullYear(), now.getMonth() - i, 1); + const key = `${month.getFullYear()}-${this.pad2(month.getMonth() + 1)}`; + const label = month + .toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' }) + .replace('.', '') + .replace(' de ', '/') + .trim(); + keys.push(key); + labels.push(label); + } + + return { labels, keys }; + } + + private parseDateValue(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + + const raw = String(value ?? '').trim(); + if (!raw) return null; + + const br = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})/); + if (br) { + const parsed = new Date(Number(br[3]), Number(br[2]) - 1, Number(br[1])); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + private normalizeLineDigits(value: unknown): string { + return String(value ?? '').replace(/\D/g, '').trim(); + } + + private startOfDay(value: Date): Date { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()); } private applyDto(dto: DashboardDto) { const k = dto.kpis; this.dashboardRaw = k; + this.syncKpiTrendMap(dto.kpiTrends ?? null); this.muregLabels = (dto.serieMuregUltimos12Meses || []).map(x => x.mes); this.muregValues = (dto.serieMuregUltimos12Meses || []).map(x => x.total); @@ -1147,13 +2222,157 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { .replace(/[^A-Z0-9]/g, ''); } + private buildLineFieldAliases(camelCaseKey: string, pascalCaseKey: string): string[] { + const snakeCaseKey = pascalCaseKey + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase(); + const flatCaseKey = snakeCaseKey.replace(/_/g, ''); + const spacedKey = pascalCaseKey.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); + const kebabKey = snakeCaseKey.replace(/_/g, '-'); + + const aliases = new Set([ + camelCaseKey, + pascalCaseKey, + snakeCaseKey, + flatCaseKey, + kebabKey, + spacedKey, + spacedKey.toLowerCase(), + spacedKey.toUpperCase(), + flatCaseKey.toUpperCase(), + ]); + + return Array.from(aliases); + } + + private normalizeLooseFieldKey(value: unknown): string { + return String(value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^A-Za-z0-9]/g, '') + .toUpperCase() + .trim(); + } + + private findLooseFieldValue(source: any, aliases: string[]): any { + if (!source || typeof source !== 'object') return undefined; + const targets = aliases + .map((alias) => this.normalizeLooseFieldKey(alias)) + .filter(Boolean); + if (!targets.length) return undefined; + + const entries = Object.entries(source as Record); + + for (const [entryKey, value] of entries) { + const normalizedEntry = this.normalizeLooseFieldKey(entryKey); + if (!normalizedEntry) continue; + if (targets.includes(normalizedEntry)) return value; + } + + for (const [entryKey, value] of entries) { + const normalizedEntry = this.normalizeLooseFieldKey(entryKey); + if (!normalizedEntry) continue; + if (targets.some((target) => normalizedEntry.includes(target) || target.includes(normalizedEntry))) { + return value; + } + } + + return undefined; + } + + private readLineRawField( + line: DashboardLineListItemDto, + camelCaseKey: keyof DashboardLineListItemDto, + pascalCaseKey: string + ): any { + const aliases = this.buildLineFieldAliases(String(camelCaseKey), pascalCaseKey); + const raw = this.readNode(line as any, ...aliases); + if (raw !== undefined) return raw; + const looseRaw = this.findLooseFieldValue(line as any, aliases); + if (looseRaw !== undefined) return looseRaw; + + const nested = this.readNode( + line as any, + 'financeiro', + 'Financeiro', + 'dadosFinanceiros', + 'DadosFinanceiros' + ); + if (nested && typeof nested === 'object') { + const nestedRaw = this.readNode(nested, ...aliases); + if (nestedRaw !== undefined) return nestedRaw; + return this.findLooseFieldValue(nested, aliases); + } + + return undefined; + } + + private readUnknownNumericField(source: any, camelCaseKey: string, pascalCaseKey: string): number | null { + if (!source || typeof source !== 'object') return null; + const aliases = this.buildLineFieldAliases(camelCaseKey, pascalCaseKey); + const raw = + this.readNode(source, ...aliases) + ?? this.findLooseFieldValue(source, aliases); + if (raw !== undefined) { + return this.toNumberOrNull(raw); + } + + const nested = this.readNode( + source, + 'financeiro', + 'Financeiro', + 'dadosFinanceiros', + 'DadosFinanceiros' + ); + if (nested && typeof nested === 'object') { + const nestedRaw = + this.readNode(nested, ...aliases) + ?? this.findLooseFieldValue(nested, aliases); + return this.toNumberOrNull(nestedRaw); + } + + return null; + } + + private readCachedFranquiaByLine( + line: DashboardLineListItemDto, + camelCaseKey: keyof DashboardLineListItemDto + ): number | null { + const id = String(this.readNode(line as any, 'id', 'Id') ?? '').trim(); + if (!id) return null; + + const cached = this.lineFranquiaCacheById.get(id); + if (!cached) return null; + + if (camelCaseKey === 'franquiaVivo') return cached.franquiaVivo; + if (camelCaseKey === 'franquiaLine') return cached.franquiaLine; + return null; + } + private readLineNumber(line: DashboardLineListItemDto, camelCaseKey: keyof DashboardLineListItemDto, pascalCaseKey: string): number { - const raw = this.readNode(line as any, String(camelCaseKey), pascalCaseKey); - return this.toNumberOrNull(raw) ?? 0; + const raw = this.readLineRawField(line, camelCaseKey, pascalCaseKey); + const value = this.toNumberOrNull(raw); + if (value !== null) return value; + + const cached = this.readCachedFranquiaByLine(line, camelCaseKey); + return cached ?? 0; + } + + private sumLineField( + lines: DashboardLineListItemDto[] | null | undefined, + camelCaseKey: keyof DashboardLineListItemDto, + pascalCaseKey: string + ): number { + if (!Array.isArray(lines) || lines.length === 0) return 0; + return lines.reduce((acc, line) => { + const value = this.readLineNumber(line, camelCaseKey, pascalCaseKey); + if (!Number.isFinite(value)) return acc; + return acc + value; + }, 0); } private readLineString(line: DashboardLineListItemDto, camelCaseKey: keyof DashboardLineListItemDto, pascalCaseKey: string): string { - const raw = this.readNode(line as any, String(camelCaseKey), pascalCaseKey) ?? ''; + const raw = this.readLineRawField(line, camelCaseKey, pascalCaseKey) ?? ''; return String(raw); } @@ -1262,6 +2481,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { title: 'Linhas Ativas', value: this.formatInt(overview.ativas), icon: 'bi bi-check2-circle', + trend: this.getKpiTrend('linhas_ativas'), hint: 'Status ativo', }, { @@ -1269,6 +2489,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { title: 'Franquia Line Total', value: this.formatDataAllowance(overview.franquiaLineTotalGb), icon: 'bi bi-wifi', + trend: this.getKpiTrend('franquia_line_total'), hint: 'Franquia contratada', }, { @@ -1276,6 +2497,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { title: 'Planos Contratados', value: this.formatInt(overview.planosContratados), icon: 'bi bi-diagram-3-fill', + trend: this.getKpiTrend('planos_contratados'), hint: 'Planos ativos na base', }, { @@ -1283,6 +2505,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { title: 'Usuários com Linha', value: this.formatInt(overview.usuariosComLinha), icon: 'bi bi-people-fill', + trend: this.getKpiTrend('usuarios_com_linha'), hint: 'Usuários vinculados', }, ]; @@ -1296,7 +2519,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const add = (key: string, title: string, value: string, icon: string, hint?: string) => { if (used.has(key)) return; used.add(key); - cards.push({ key, title, value, icon, hint }); + cards.push({ key, title, value, icon, trend: this.getKpiTrend(key), hint }); }; const insights = this.insights?.kpis; @@ -1311,13 +2534,15 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { ?? (this.resumo?.vivoLineResumos ?? []).reduce( (acc, row) => acc + (this.toNumberOrNull(row?.franquiaTotal) ?? 0), 0 - ); + ) + ?? (this.isOperadoraFiltroAtivo + ? (this.sumLineField(this.filteredLinesCache, 'franquiaVivo', 'FranquiaVivo') ?? 0) + : 0); add( 'franquia_vivo_total', - 'Total Franquia Vivo', + 'Total Franquia Contratada', this.formatDataAllowance(franquiaVivoTotal), - 'bi bi-diagram-3-fill', - 'Soma das franquias (Geral)' + 'bi bi-diagram-3-fill' ); const franquiaLineTotal = this.toNumberOrNull(insights?.vivo?.totalFranquiaLine) @@ -1325,7 +2550,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { ?? (this.resumo?.vivoLineResumos ?? []).reduce( (acc, row) => acc + (this.toNumberOrNull(row?.franquiaLine) ?? 0), 0 - ); + ) + ?? (this.isOperadoraFiltroAtivo + ? (this.sumLineField(this.filteredLinesCache, 'franquiaLine', 'FranquiaLine') ?? 0) + : 0); add( 'franquia_line_total', 'Total Franquia Line', @@ -1360,6 +2588,27 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.kpis = cards; } + private syncKpiTrendMap(raw: Record | null | undefined): void { + const next: Record = {}; + if (raw && typeof raw === 'object') { + Object.entries(raw).forEach(([key, value]) => { + next[key] = this.normalizeKpiTrend(value); + }); + } + this.kpiTrendMap = next; + } + + private normalizeKpiTrend(value: unknown): KpiTrendDirection { + const token = String(value ?? '').trim().toLowerCase(); + if (token === 'up') return 'up'; + if (token === 'down') return 'down'; + return 'stable'; + } + + private getKpiTrend(key: string): KpiTrendDirection { + return this.kpiTrendMap[key] ?? 'stable'; + } + // --- CHART BUILDERS (Generic) --- private tryBuildCharts() { if (!isPlatformBrowser(this.platformId)) return; @@ -1377,6 +2626,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.chartAdicionaisPagos?.nativeElement, this.chartTipoChip?.nativeElement, this.chartTravelMundo?.nativeElement, + this.chartVivoEmpresasLinhas?.nativeElement, + this.chartVivoEmpresasAdicionais?.nativeElement, ].filter(Boolean) as HTMLCanvasElement[]; if (!canvases.length) return; @@ -1586,6 +2837,83 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } }); } + + if (this.showVivoComparison) { + if (this.chartVivoEmpresasLinhas?.nativeElement) { + this.chartVivoComparacaoLinhas = new Chart(this.chartVivoEmpresasLinhas.nativeElement, { + type: 'bar', + data: { + labels: ['MACROPHONY', 'LINE MÓVEL'], + datasets: [{ + label: 'Linhas', + data: [this.vivoComparison.macrophonyLinhas, this.vivoComparison.lineMovelLinhas], + backgroundColor: [palette.brand, palette.blue], + borderRadius: 8, + borderWidth: 0, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => ` ${this.formatInt(ctx.raw as number)} linhas`, + }, + }, + }, + scales: { + x: { grid: { display: false }, border: { display: false } }, + y: { beginAtZero: true, grid: { color: '#f1f5f9' }, border: { display: false } }, + }, + }, + }); + } + + if (this.chartVivoEmpresasAdicionais?.nativeElement) { + this.chartVivoComparacaoAdicionais = new Chart(this.chartVivoEmpresasAdicionais.nativeElement, { + type: 'bar', + data: { + labels: ['MACROPHONY', 'LINE MÓVEL'], + datasets: [ + { + label: 'Com adicionais', + data: [this.vivoComparison.macrophonyComAdicionais, this.vivoComparison.lineMovelComAdicionais], + backgroundColor: palette.purple, + borderRadius: 6, + borderWidth: 0, + }, + { + label: 'Sem adicionais', + data: [this.vivoComparison.macrophonySemAdicionais, this.vivoComparison.lineMovelSemAdicionais], + backgroundColor: '#cbd5e1', + borderRadius: 6, + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom' }, + tooltip: { + callbacks: { + label: (ctx) => ` ${ctx.dataset.label}: ${this.formatInt(ctx.raw as number)}`, + }, + }, + }, + scales: { + x: { stacked: false, grid: { display: false }, border: { display: false } }, + y: { beginAtZero: true, grid: { color: '#f1f5f9' }, border: { display: false } }, + }, + }, + }); + } + } + + this.refreshExpandedChartIfOpen(); } private buildResumoCharts() { @@ -1671,6 +2999,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { }); } + this.refreshExpandedChartIfOpen(); } // Helper for consistent bar charts @@ -1772,6 +3101,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.chartFranquia?.destroy(); this.chartAdicionais?.destroy(); this.chartTipoChipDistribuicao?.destroy(); + this.chartVivoComparacaoLinhas?.destroy(); + this.chartVivoComparacaoAdicionais?.destroy(); this.chartPie = undefined; this.chartAdicionaisComparativoDoughnut = undefined; @@ -1783,6 +3114,8 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.chartFranquia = undefined; this.chartAdicionais = undefined; this.chartTipoChipDistribuicao = undefined; + this.chartVivoComparacaoLinhas = undefined; + this.chartVivoComparacaoAdicionais = undefined; } private destroyResumoCharts() { @@ -1797,6 +3130,177 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.chartResumoPfPj = undefined; } + private getChartInstanceByModalKey(key: DashboardChartModalKey): Chart | undefined { + switch (key) { + case 'status': return this.chartPie; + case 'adicionaisComparativo': return this.chartAdicionaisComparativoDoughnut; + case 'vigenciaBuckets': return this.chartVigSuper; + case 'travel': return this.chartTravel; + case 'linhasFranquia': return this.chartFranquia; + case 'adicionaisPagos': return this.chartAdicionais; + case 'tipoChip': return this.chartTipoChipDistribuicao; + case 'vivoEmpresasLinhas': return this.chartVivoComparacaoLinhas; + case 'vivoEmpresasAdicionais': return this.chartVivoComparacaoAdicionais; + case 'resumoTopClientes': return this.chartResumoClientes; + case 'resumoTopPlanos': return this.chartResumoPlanos; + case 'resumoPfPj': return this.chartResumoPfPj; + case 'resumoReservaDdd': return this.chartResumoReserva; + case 'mureg12': return this.chartMureg; + case 'troca12': return this.chartTroca; + case 'vigenciaMesAno': return this.chartVigMesAno; + default: return undefined; + } + } + + private refreshExpandedChartIfOpen(): void { + if (!this.chartModalOpen || !this.chartModalKey) return; + const sourceChart = this.getChartInstanceByModalKey(this.chartModalKey); + if (!sourceChart) return; + this.updateChartModalInfo(sourceChart); + this.renderExpandedChart(sourceChart); + } + + private renderExpandedChart(sourceChart: Chart): void { + if (!this.chartModalOpen) return; + + const canvas = this.chartExpandedCanvas?.nativeElement; + if (!canvas) return; + + this.destroyExpandedChart(); + + const sourceConfig = sourceChart.config as any; + const expandedConfig = { + type: sourceConfig.type, + data: this.cloneChartValue(sourceConfig.data), + options: this.cloneChartValue(sourceConfig.options ?? {}), + plugins: this.cloneChartValue(sourceConfig.plugins ?? []), + } as any; + + expandedConfig.options = expandedConfig.options ?? {}; + expandedConfig.options.responsive = true; + expandedConfig.options.maintainAspectRatio = false; + + this.chartExpanded = new Chart(canvas, expandedConfig); + } + + private destroyExpandedChart(): void { + try { + this.chartExpanded?.destroy(); + } catch {} + this.chartExpanded = undefined; + } + + private cloneChartValue(value: T): T { + if (value === null || value === undefined) return value; + if (typeof value === 'function') return value; + if (typeof value !== 'object') return value; + if (value instanceof Date) return new Date(value.getTime()) as T; + if (Array.isArray(value)) { + return value.map((entry) => this.cloneChartValue(entry)) as T; + } + + const output: Record = {}; + Object.entries(value as Record).forEach(([key, entry]) => { + output[key] = this.cloneChartValue(entry); + }); + return output as T; + } + + private updateChartModalInfo(sourceChart: Chart): void { + const labelsRaw = Array.isArray(sourceChart.data?.labels) ? sourceChart.data.labels : []; + const datasetsRaw = Array.isArray(sourceChart.data?.datasets) ? sourceChart.data.datasets : []; + + const datasetHeaders = datasetsRaw.map((dataset, index) => { + const raw = (dataset as any)?.label; + const text = String(raw ?? '').trim(); + return text || `Série ${index + 1}`; + }); + + const maxDataLength = datasetsRaw.reduce((max, dataset) => { + const data = Array.isArray((dataset as any)?.data) ? (dataset as any).data : []; + return Math.max(max, data.length); + }, 0); + + const rowCount = Math.max(labelsRaw.length, maxDataLength); + + const rows: DashboardChartModalInfoRow[] = []; + for (let index = 0; index < rowCount; index += 1) { + const label = this.resolveChartModalRowLabel(labelsRaw, index); + const cells = datasetsRaw.map((dataset, datasetIndex) => { + const dataArray = Array.isArray((dataset as any)?.data) ? (dataset as any).data : []; + const numericValue = this.toChartPointNumber(dataArray[index]); + return { + dataset: datasetHeaders[datasetIndex] ?? `Série ${datasetIndex + 1}`, + valueText: this.formatChartModalValue(numericValue), + numericValue, + }; + }); + rows.push({ label, cells }); + } + + const totals = datasetHeaders.map((dataset, datasetIndex) => { + const sum = rows.reduce((acc, row) => { + const value = row.cells[datasetIndex]?.numericValue; + if (value === null) return acc; + return acc + value; + }, 0); + return { + dataset, + totalText: this.formatChartModalValue(sum), + }; + }); + + this.chartModalDatasetHeaders = datasetHeaders; + this.chartModalInfoRows = rows; + this.chartModalDatasetTotals = totals; + } + + private resolveChartModalRowLabel(labelsRaw: unknown[], index: number): string { + const raw = labelsRaw[index]; + const text = String(raw ?? '').trim(); + if (text) return text; + return `Item ${index + 1}`; + } + + private toChartPointNumber(value: unknown): number | null { + if (value === null || value === undefined) return null; + + const direct = this.toNumberOrNull(value); + if (direct !== null) return direct; + + if (typeof value === 'object') { + const nested = value as Record; + const fromY = this.toNumberOrNull(nested.y); + if (fromY !== null) return fromY; + const fromR = this.toNumberOrNull(nested.r); + if (fromR !== null) return fromR; + } + + return null; + } + + private formatChartModalValue(value: number | null): string { + if (value === null) return '-'; + if (Number.isInteger(value)) return this.formatInt(value); + return value.toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); + } + + private lockBodyScrollForChartModal(): void { + if (!isPlatformBrowser(this.platformId)) return; + const body = window.document.body; + if (this.bodyOverflowBeforeChartModal === null) { + this.bodyOverflowBeforeChartModal = body.style.overflow; + } + body.style.overflow = 'hidden'; + } + + private restoreBodyScrollForChartModal(): void { + if (!isPlatformBrowser(this.platformId)) return; + if (this.bodyOverflowBeforeChartModal === null) return; + window.document.body.style.overflow = this.bodyOverflowBeforeChartModal; + this.bodyOverflowBeforeChartModal = null; + } + private normalizeResumo(data: ResumoResponse): ResumoResponse { // Helper to ensure arrays are arrays return { @@ -1870,7 +3374,31 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { return Number.isNaN(n) ? null : n; } + private pad2(value: number): string { + return value.toString().padStart(2, '0'); + } + trackByKpiKey = (_: number, item: KpiCard) => item.key; + trackByOperadoraFilter = (_: number, item: { value: OperadoraFiltro }) => item.value; + + isKpiClickable(card: KpiCard): boolean { + return !!this.kpiNavigationMap[card.key]; + } + + onKpiClick(card: KpiCard): void { + const target = this.kpiNavigationMap[card.key]; + if (!target) return; + void this.router.navigate([target.route], { + queryParams: target.queryParams + }); + } + + onKpiCardKeydown(event: Event, card: KpiCard): void { + if (!(event instanceof KeyboardEvent)) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + this.onKpiClick(card); + } private getPalette() { return { diff --git a/src/app/pages/faturamento/faturamento.html b/src/app/pages/faturamento/faturamento.html index b8dc60b..9b16bca 100644 --- a/src/app/pages/faturamento/faturamento.html +++ b/src/app/pages/faturamento/faturamento.html @@ -33,7 +33,12 @@ Totais, lucro e comparativo Vivo x Line -
+
+ +
@@ -184,7 +189,6 @@
-
@@ -327,248 +331,5 @@ - - - - + diff --git a/src/app/pages/faturamento/faturamento.scss b/src/app/pages/faturamento/faturamento.scss index 2c9e0ec..bdf4e83 100644 --- a/src/app/pages/faturamento/faturamento.scss +++ b/src/app/pages/faturamento/faturamento.scss @@ -754,24 +754,6 @@ .fw-black { font-weight: 950; } /* MODALS (mantidos do seu arquivo) */ -.modal-backdrop-custom { - position: fixed; - inset: 0; - background: rgba(0,0,0,0.45); - z-index: 9990; - backdrop-filter: blur(4px); -} - -.modal-custom { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - z-index: 9995; - padding: 16px; -} - .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); diff --git a/src/app/pages/faturamento/faturamento.ts b/src/app/pages/faturamento/faturamento.ts index 0c46cbb..826d41c 100644 --- a/src/app/pages/faturamento/faturamento.ts +++ b/src/app/pages/faturamento/faturamento.ts @@ -13,6 +13,7 @@ import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { FaturamentoModalsComponent } from '../../components/page-modals/faturamento-modals/faturamento-modals'; import { BillingService, @@ -25,7 +26,17 @@ import { } from '../../services/billing'; import { AuthService } from '../../services/auth.service'; import { LinesService } from '../../services/lines.service'; +import { TableExportService } from '../../services/table-export.service'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; +import { normalizeAccentInsensitive } from '../../utils/text-normalization.util'; +import { firstValueFrom } from 'rxjs'; interface BillingClientGroup { cliente: string; @@ -38,11 +49,12 @@ interface BillingClientGroup { @Component({ standalone: true, - imports: [CommonModule, FormsModule, HttpClientModule, CustomSelectComponent], + imports: [CommonModule, FormsModule, HttpClientModule, CustomSelectComponent, FaturamentoModalsComponent], templateUrl: './faturamento.html', styleUrls: ['./faturamento.scss'] }) export class Faturamento implements AfterViewInit, OnDestroy { + readonly vm = this; toastMessage = ''; @ViewChild('successToast', { static: false }) successToast!: ElementRef; @@ -54,10 +66,12 @@ export class Faturamento implements AfterViewInit, OnDestroy { private billing: BillingService, private linesService: LinesService, private cdr: ChangeDetectorRef, - private authService: AuthService + private authService: AuthService, + private tableExportService: TableExportService ) {} loading = false; + exporting = false; // filtros searchTerm = ''; @@ -218,15 +232,6 @@ export class Faturamento implements AfterViewInit, OnDestroy { return s ? s : '—'; } - private normalizeText(s: any): string { - return (s ?? '') - .toString() - .trim() - .toUpperCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, ''); - } - private buildGlobalSearchBlob(row: BillingItem): string { const parts = [ row.tipo, @@ -242,13 +247,13 @@ export class Faturamento implements AfterViewInit, OnDestroy { row.formaPagamento, ]; - return this.normalizeText(parts.join(' ')); + return normalizeAccentInsensitive(parts.join(' ')); } private matchesTipo(itemTipo: any, filtro: TipoFiltro): boolean { if (filtro === 'ALL') return true; - const t = this.normalizeText(itemTipo); + const t = normalizeAccentInsensitive(itemTipo); if (filtro === 'PF') return t === 'PF' || t.includes('FISICA'); if (filtro === 'PJ') return t === 'PJ' || t.includes('JURIDICA'); @@ -415,6 +420,85 @@ export class Faturamento implements AfterViewInit, OnDestroy { this.loadAllAndApply(forceReloadAll); } + async onExport(): Promise { + if (this.exporting) return; + this.exporting = true; + + try { + const baseRows = this.getRowsForExport(); + const rows = await this.fetchDetailedRowsForExport(baseRows); + if (!rows.length) { + await this.showToast('Nenhum registro encontrado para exportar.'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + const suffix = this.filterTipo === 'ALL' ? 'todos' : this.filterTipo.toLowerCase(); + await this.tableExportService.exportAsXlsx({ + fileName: `faturamento_${suffix}_${timestamp}`, + sheetName: 'Faturamento', + rows, + columns: [ + { header: 'ID', value: (row) => row.id ?? '' }, + { header: 'Tipo', value: (row) => row.tipo ?? '' }, + { header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 }, + { header: 'Cliente', value: (row) => row.cliente ?? '' }, + { header: 'Qtd Linhas', type: 'number', value: (row) => this.toNullableNumber(row.qtdLinhas) ?? 0 }, + { header: 'Franquia Vivo', type: 'number', value: (row) => this.toNullableNumber(row.franquiaVivo) ?? 0 }, + { header: 'Valor Contrato Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoVivo) ?? 0 }, + { header: 'Franquia Line', type: 'number', value: (row) => this.toNullableNumber(row.franquiaLine) ?? 0 }, + { header: 'Valor Contrato Line', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoLine) ?? 0 }, + { header: 'Lucro', type: 'currency', value: (row) => this.toNullableNumber(row.lucro) ?? 0 }, + { header: 'Aparelho', value: (row) => row.aparelho ?? '' }, + { header: 'Forma de Pagamento', value: (row) => row.formaPagamento ?? '' }, + { header: 'Observacao', value: (row) => this.getObservacao(row) }, + { header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' }, + { header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' }, + ], + }); + + await this.showToast(`Planilha exportada com ${rows.length} registro(s).`); + } catch { + await this.showToast('Erro ao exportar planilha.'); + } finally { + this.exporting = false; + } + } + + private getRowsForExport(): BillingItem[] { + const rows: BillingItem[] = []; + this.rowsByClient.forEach((items) => rows.push(...items)); + + return rows.sort((a, b) => { + const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' }); + if (byClient !== 0) return byClient; + return (this.toNullableNumber(a.item) ?? 0) - (this.toNullableNumber(b.item) ?? 0); + }); + } + + private async fetchDetailedRowsForExport(rows: BillingItem[]): Promise { + if (!rows.length) return []; + + const detailed: BillingItem[] = []; + const chunkSize = 10; + + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + const resolved = await Promise.all( + chunk.map(async (row) => { + try { + return await firstValueFrom(this.billing.getById(row.id)); + } catch { + return row; + } + }) + ); + detailed.push(...resolved); + } + + return detailed; + } + private getAllItems(force = false): Promise { const now = Date.now(); @@ -549,16 +633,16 @@ export class Faturamento implements AfterViewInit, OnDestroy { let arr = [...baseTipo]; if (this.selectedClients.length > 0) { - const set = new Set(this.selectedClients.map((x) => this.normalizeText(x))); - arr = arr.filter((r) => set.has(this.normalizeText(r.cliente))); + const set = new Set(this.selectedClients.map((x) => normalizeAccentInsensitive(x))); + arr = arr.filter((r) => set.has(normalizeAccentInsensitive(r.cliente))); } - const term = this.normalizeText(this.searchTerm); - const resolvedClientsSet = new Set((this.searchResolvedClients ?? []).map((x) => this.normalizeText(x))); + const term = normalizeAccentInsensitive(this.searchTerm); + const resolvedClientsSet = new Set((this.searchResolvedClients ?? []).map((x) => normalizeAccentInsensitive(x))); if (term) { arr = arr.filter((r) => this.buildGlobalSearchBlob(r).includes(term) || - (resolvedClientsSet.size > 0 && resolvedClientsSet.has(this.normalizeText(r.cliente))) + (resolvedClientsSet.size > 0 && resolvedClientsSet.has(normalizeAccentInsensitive(r.cliente))) ); } @@ -576,7 +660,7 @@ export class Faturamento implements AfterViewInit, OnDestroy { totalLinhas += Number(r.qtdLinhas ?? 0) || 0; - const key = this.normalizeText(c); + const key = normalizeAccentInsensitive(c); if (!key) continue; const vivo = Number(r.valorContratoVivo ?? 0) || 0; @@ -652,7 +736,7 @@ export class Faturamento implements AfterViewInit, OnDestroy { } goToPage(p: number) { - this.page = Math.max(1, Math.min(this.totalPages, p)); + this.page = clampPage(p, this.totalPages); this.applyGroupPagination(); this.cdr.detectChanges(); } @@ -666,27 +750,19 @@ export class Faturamento implements AfterViewInit, OnDestroy { } get totalPages() { - return Math.ceil((this.total || 0) / this.pageSize) || 1; + return computeTotalPages(this.total || 0, this.pageSize); } get pageStart() { - return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; + return computePageStart(this.total || 0, this.page, this.pageSize); } get pageEnd() { - return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total); + return computePageEnd(this.total || 0, this.page, this.pageSize); } get pageNumbers() { - const total = this.totalPages; - const current = this.page; - const max = 5; - let start = Math.max(1, current - 2); - let end = Math.min(total, start + (max - 1)); - start = Math.max(1, end - (max - 1)); - const pages: number[] = []; - for (let i = start; i <= end; i++) pages.push(i); - return pages; + return buildPageNumbers(this.page, this.totalPages); } // -------------------------- @@ -795,4 +871,22 @@ export class Faturamento implements AfterViewInit, OnDestroy { const n = Number(value); return Number.isNaN(n) ? null : n; } + + private async showToast(message: string): Promise { + if (!isPlatformBrowser(this.platformId)) return; + this.toastMessage = message; + this.cdr.detectChanges(); + if (!this.successToast?.nativeElement) return; + + try { + const bs = await import('bootstrap'); + const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, { + autohide: true, + delay: 3000, + }); + toastInstance.show(); + } catch (error) { + console.error(error); + } + } } diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 6ba0955..11a4511 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -31,23 +31,43 @@ Tabela de linhas e dados de telefonia -
- +
+
+ + + +
+ + +
-
-
- - - - - - -
- - -
- + + + - - -
- - {{ client }} - - -
- + + + + - - -
- - -
-
- - -
-
-
Modo
-
- - - -
+
+
+
+
-
-
Serviços
-
- +
+ +
+
+ + +
+ + +
+ + +
+
- @@ -275,6 +353,26 @@
+ +
+ + Selecionadas: {{ batchStatusSelectionCount }} + + + +
@@ -312,6 +410,20 @@ {{ reservaSelectedCount > 0 && reservaSelectedCount === groupLines.length ? 'Limpar seleção' : 'Selecionar todas' }} + + -
@@ -398,7 +510,7 @@
- + @@ -517,7 +629,7 @@
- + @@ -557,1641 +669,4 @@
- - - - - - - + diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index e8dda84..2056380 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -123,11 +123,35 @@ .btn-glass { border-radius: 12px; font-weight: 900; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(3, 15, 170, 0.25); color: var(--blue); &:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; } } /* Filtros e Multi-Select */ -.filters-row { display: flex; justify-content: center; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px; } +.filters-stack { + display: flex; + flex-direction: column; + gap: 10px; +} + +.filters-row { + display: flex; + justify-content: center; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + margin-top: 0; + position: relative; + z-index: 30; + overflow: visible; +} + +.filters-row-top { + justify-content: center; +} + +.filters-row-bottom { + justify-content: center; +} .filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); } .filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } } -.client-filter-wrap { position: relative; } +.client-filter-wrap { position: relative; z-index: 40; } .btn-client-filter { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 12px; border: 1px solid rgba(17, 18, 20, 0.08); background: rgba(255, 255, 255, 0.6); color: var(--muted); font-weight: 700; font-size: 0.85rem; backdrop-filter: blur(8px); transition: all 0.2s; min-height: 38px; height: auto; flex-wrap: wrap; &:hover { background: #fff; border-color: var(--blue); color: var(--blue); } &.active, &.has-selection { background: #fff; border-color: var(--brand); } } .chips-container { display: flex; flex-wrap: wrap; gap: 6px; max-width: 400px; } .client-chip { display: inline-flex; align-items: center; background: rgba(227, 61, 207, 0.1); color: var(--brand); border: 1px solid rgba(227, 61, 207, 0.2); border-radius: 6px; padding: 2px 6px; font-size: 0.75rem; font-weight: 800; cursor: default; user-select: none; } @@ -140,6 +164,34 @@ .additional-filter-wrap { position: relative; + z-index: 40; +} + +.operadora-empresa-filters { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; + position: relative; + z-index: 50; +} + +.filter-select-box { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 190px; + position: relative; + z-index: 50; +} + +.filter-select-label { + font-size: 0.66rem; + font-weight: 900; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(17, 18, 20, 0.58); + padding-left: 2px; } .btn-additional-filter { @@ -249,6 +301,18 @@ } } +@media (max-width: 768px) { + .operadora-empresa-filters { + width: 100%; + justify-content: center; + } + + .filter-select-box { + flex: 1 1 220px; + min-width: 0; + } +} + /* KPIs */ .geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } } .geral-kpis.geral-kpis-client { @@ -273,6 +337,29 @@ .controls { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } .search-group { max-width: 270px; border-radius: 12px; overflow: hidden; display: flex; align-items: stretch; background: #fff; border: 1px solid rgba(17, 18, 20, 0.15); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); transition: all 0.2s ease; &:focus-within { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); transform: translateY(-1px); } .input-group-text { background: transparent; border: none; color: var(--muted); padding-left: 14px; padding-right: 8px; display: flex; align-items: center; i { font-size: 1rem; } } .form-control { border: none; background: transparent; padding: 10px 0; font-size: 0.9rem; color: var(--text); box-shadow: none; &::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; } &:focus { outline: none; } } .btn-clear { background: transparent; border: none; color: var(--muted); padding: 0 12px; display: flex; align-items: center; cursor: pointer; transition: color 0.2s; &:hover { color: #dc3545; } i { font-size: 1rem; } } } .page-size { margin-left: auto; @media (max-width: 500px) { margin-left: 0; width: 100%; justify-content: space-between; } } +.batch-status-tools { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-left: auto; + + @media (max-width: 900px) { + margin-left: 0; + width: 100%; + } +} +.batch-status-count { + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(17, 18, 20, 0.62); + padding: 4px 8px; + border-radius: 999px; + border: 1px solid rgba(17, 18, 20, 0.12); + background: rgba(255, 255, 255, 0.8); +} .select-wrapper { position: relative; display: inline-block; min-width: 90px; } .select-glass { background: rgba(255, 255, 255, 0.7); border: 1px solid rgba(17, 18, 20, 0.15); border-radius: 12px; color: var(--blue); font-weight: 800; font-size: 0.9rem; text-align: left; padding: 8px 36px 8px 14px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); cursor: pointer; transition: all 0.2s ease; width: 100%; &:hover { background: #fff; border-color: var(--blue); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(3, 15, 170, 0.1); } &:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.15); } } .select-icon { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--muted); font-size: 0.75rem; transition: transform 0.2s ease; } @@ -500,8 +587,6 @@ /* ========================================================== */ /* 8. MODALS E FORMULÁRIOS COMPLETOS (RESTAURADOS ✅) */ /* ========================================================== */ -.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } -.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } @keyframes modalPop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } @@ -581,6 +666,140 @@ padding: 20px 22px; } } +.modal-card.modal-batch-status { + width: min(1120px, 96vw); + max-height: 92vh; + + .modal-header { + padding: 18px 22px 16px; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + } + + .modal-title { + flex: 1 1 320px; + min-width: 0; + line-height: 1.2; + } + + .batch-status-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + margin-left: auto; + } + + .details-dashboard { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: start; + + @media (max-width: 980px) { + grid-template-columns: 1fr; + gap: 12px; + } + } + + .modal-body { + padding: 18px 22px 22px; + } + + .detail-box { + height: 100%; + } + + .box-body { + padding: 14px 16px 16px; + } + + .form-grid { + gap: 14px 16px; + } + + .form-field { + gap: 8px; + } + + .reserva-confirmation-pills { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + margin-top: 2px; + + .summary-pill { + margin: 0; + white-space: normal; + line-height: 1.25; + } + } + + .batch-status-note { + display: block; + margin-top: 10px; + font-size: 0.8rem; + line-height: 1.4; + color: rgba(17, 18, 20, 0.58); + } + + @media (max-width: 980px) { + .modal-header { + padding: 16px 18px 14px; + } + + .batch-status-header-actions { + width: 100%; + justify-content: flex-end; + } + + .modal-body { + padding: 16px 18px 18px; + } + + .form-grid { + gap: 12px; + } + } + + @media (max-width: 640px) { + .modal-header { + padding: 14px 14px 12px; + } + + .modal-title { + font-size: 1rem; + gap: 10px; + } + + .batch-status-header-actions { + justify-content: stretch; + + .btn { + flex: 1 1 140px; + } + } + + .modal-body { + padding: 14px; + } + + .box-body { + padding: 12px; + } + + .reserva-confirmation-pills { + gap: 8px; + + .summary-pill { + width: 100%; + justify-content: flex-start; + } + } + } +} /* === MODAL DE EDITAR E SEÇÕES (Accordion) === */ /* ✅ Restauração Crítica para "Editar" e "Novo Cliente" */ @@ -711,3 +930,421 @@ div.box-body { padding: 16px; &.compact { padding: 12px 16px; } &.compact-paddin .summary-pill { font-size: 0.72rem; } .batch-validation-banner { align-items: flex-start; } } + +.modal-mve-audit { + width: min(1320px, calc(100vw - 40px)); + max-width: min(1320px, calc(100vw - 40px)); + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.mve-audit-body { + display: grid; + gap: 16px; + overflow: auto; + position: relative; +} + +.mve-intro-card, +.mve-upload-card { + background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(247,249,252,0.98)); + border: 1px solid rgba(17,18,20,0.08); + border-radius: 18px; + padding: 18px; + box-shadow: 0 16px 32px rgba(17,18,20,0.05); +} + +.mve-intro-card { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.mve-intro-title { + font-size: 1rem; + font-weight: 900; + color: var(--text); +} + +.mve-intro-text { + margin-top: 6px; + color: rgba(17,18,20,0.68); + max-width: 760px; + line-height: 1.45; +} + +.mve-intro-meta, +.mve-summary-notes { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.meta-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: 999px; + background: rgba(3,15,170,0.06); + color: rgba(17,18,20,0.76); + font-size: 0.76rem; + font-weight: 800; + border: 1px solid rgba(3,15,170,0.08); + + &.accent { + background: rgba(12,132,78,0.08); + border-color: rgba(12,132,78,0.16); + color: #0c6c43; + } +} + +.mve-section-title { + font-size: 0.94rem; + font-weight: 900; + color: var(--text); + margin-bottom: 12px; +} + +.mve-upload-zone { + display: grid; + gap: 8px; + justify-items: center; + text-align: center; + border: 1.5px dashed rgba(3,15,170,0.22); + border-radius: 18px; + padding: 28px 18px; + background: + radial-gradient(circle at top right, rgba(3,15,170,0.08), transparent 36%), + linear-gradient(180deg, rgba(255,255,255,0.96), rgba(243,247,255,0.92)); + cursor: pointer; + transition: border-color 0.18s ease, transform 0.18s ease; + + &:hover { + border-color: rgba(3,15,170,0.38); + transform: translateY(-1px); + } + + input { + display: none; + } +} + +.upload-icon { + width: 54px; + height: 54px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(3,15,170,0.08); + color: var(--blue); + font-size: 1.4rem; +} + +.upload-title { + font-size: 0.96rem; + font-weight: 900; + color: var(--text); +} + +.upload-subtitle, +.processing-text, +.mve-upload-meta { + color: rgba(17,18,20,0.62); + font-size: 0.82rem; +} + +.mve-upload-actions, +.mve-actions-row, +.confirm-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.mve-upload-actions { + margin-top: 14px; +} + +.mve-processing { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.progress-track { + width: 100%; + height: 8px; + border-radius: 999px; + background: rgba(17,18,20,0.08); + overflow: hidden; +} + +.progress-indeterminate { + display: block; + width: 34%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #030faa, #2b55e3); + animation: mve-progress 1.25s linear infinite; +} + +@keyframes mve-progress { + from { transform: translateX(-120%); } + to { transform: translateX(320%); } +} + +.mve-summary-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 12px; +} + +.mve-summary-card { + background: #fff; + border: 1px solid rgba(17,18,20,0.07); + border-radius: 18px; + padding: 16px 14px; + display: grid; + gap: 6px; + box-shadow: 0 12px 24px rgba(17,18,20,0.05); + + strong { + font-size: 1.36rem; + color: var(--text); + } + + &.is-positive strong { color: #198754; } + &.is-danger strong { color: #b42318; } + &.is-warning strong { color: #b54708; } +} + +.summary-label { + font-size: 0.76rem; + letter-spacing: 0.03em; + text-transform: uppercase; + color: rgba(17,18,20,0.58); + font-weight: 900; +} + +.mve-result-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 14px; + align-items: center; +} + +.mve-filter-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mve-toolbar-right { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.mve-search-group { + min-width: min(420px, 100%); +} + +.mve-sync-banner { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 14px 16px; + border-radius: 16px; + background: rgba(12,132,78,0.08); + border: 1px solid rgba(12,132,78,0.16); + color: #0c6c43; + font-weight: 700; +} + +.mve-empty { + display: grid; + place-items: center; + gap: 8px; + min-height: 180px; + border-radius: 18px; + border: 1px dashed rgba(17,18,20,0.14); + background: rgba(255,255,255,0.72); + color: rgba(17,18,20,0.62); + + i { + font-size: 1.5rem; + color: #198754; + } +} + +.mve-table-wrap { + max-height: min(48vh, 560px); + border-radius: 18px; + background: #fff; +} + +.mve-table { + min-width: 980px; + + thead th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(248,249,250,0.97); + font-size: 0.73rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(17,18,20,0.64); + font-weight: 900; + } +} + +.mve-status-pair, +.mve-cell-stack { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mve-status-pair { + .bi-arrow-right { + font-size: 0.8rem; + } +} + +.mve-issue-tag, +.mve-severity { + display: inline-flex; + align-items: center; + align-self: flex-start; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.74rem; + font-weight: 900; + border: 1px solid transparent; + + &.status { background: rgba(180,35,24,0.09); color: #b42318; border-color: rgba(180,35,24,0.16); } + &.data { background: rgba(181,71,8,0.09); color: #b54708; border-color: rgba(181,71,8,0.16); } + &.system { background: rgba(3,15,170,0.08); color: var(--blue); border-color: rgba(3,15,170,0.15); } + &.report { background: rgba(10,91,168,0.08); color: #0a5ba8; border-color: rgba(10,91,168,0.15); } + &.duplicate { background: rgba(113,46,170,0.08); color: #712eaa; border-color: rgba(113,46,170,0.14); } + &.warning { background: rgba(245,158,11,0.12); color: #9a6700; border-color: rgba(245,158,11,0.16); } + &.neutral { background: rgba(17,18,20,0.06); color: rgba(17,18,20,0.72); border-color: rgba(17,18,20,0.1); } +} + +.mve-severity { + &.critical { background: rgba(180,35,24,0.08); color: #b42318; border-color: rgba(180,35,24,0.15); } + &.medium { background: rgba(181,71,8,0.08); color: #b54708; border-color: rgba(181,71,8,0.14); } + &.warning { background: rgba(245,158,11,0.12); color: #9a6700; border-color: rgba(245,158,11,0.18); } + &.neutral { background: rgba(17,18,20,0.06); color: rgba(17,18,20,0.65); border-color: rgba(17,18,20,0.1); } +} + +.mve-differences-text { + color: rgba(17,18,20,0.78); + line-height: 1.38; + min-width: 260px; +} + +.mve-footer { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.mve-actions-row { + justify-content: flex-end; +} + +.mve-confirm-overlay { + position: sticky; + bottom: 0; + display: flex; + justify-content: flex-end; + padding-top: 8px; +} + +.mve-confirm-card { + width: min(420px, 100%); + background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(245,248,255,0.98)); + border: 1px solid rgba(3,15,170,0.12); + border-radius: 18px; + padding: 18px; + box-shadow: 0 18px 36px rgba(17,18,20,0.12); + display: grid; + gap: 10px; +} + +.confirm-title { + font-size: 1rem; + font-weight: 900; + color: var(--text); +} + +.confirm-text, +.confirm-footnote { + color: rgba(17,18,20,0.68); + line-height: 1.4; +} + +.confirm-summary { + display: grid; + gap: 6px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(3,15,170,0.05); + border: 1px solid rgba(3,15,170,0.08); + font-weight: 700; +} + +@media (max-width: 1200px) { + .mve-summary-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .modal-mve-audit { + width: calc(100vw - 20px); + max-width: calc(100vw - 20px); + max-height: calc(100vh - 20px); + } + + .mve-intro-card, + .mve-result-toolbar, + .mve-footer, + .mve-sync-banner { + flex-direction: column; + align-items: stretch; + } + + .mve-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .mve-toolbar-right, + .mve-actions-row, + .confirm-actions { + width: 100%; + } + + .mve-toolbar-right > *, + .mve-actions-row .btn, + .confirm-actions .btn, + .mve-upload-actions .btn { + flex: 1 1 100%; + } + + .mve-search-group { + min-width: 100%; + } +} diff --git a/src/app/pages/geral/geral.spec.ts b/src/app/pages/geral/geral.spec.ts index 05f32d1..07f461d 100644 --- a/src/app/pages/geral/geral.spec.ts +++ b/src/app/pages/geral/geral.spec.ts @@ -73,4 +73,36 @@ describe('Geral', () => { expect(component.createBatchLines[0].linha).toBe('11888888888'); expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO B'); }); + + it('should apply TIM filter in client-side pipeline using conta TIM textual', () => { + component.filterOperadora = 'TIM'; + component.filterContaEmpresa = ''; + component.filterStatus = 'ALL'; + component.additionalMode = 'ALL'; + component.selectedAdditionalServices = []; + + const filtered = (component as any).applyAdditionalFiltersClientSide([ + { id: '1', item: 1, conta: 'TIM', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' }, + { id: '2', item: 2, conta: '455371844', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' }, + ]); + + expect(filtered.length).toBe(1); + expect(filtered[0].conta).toBe('TIM'); + }); + + it('should combine operadora and empresa filters for VIVO MACROPHONY', () => { + component.filterOperadora = 'VIVO'; + component.filterContaEmpresa = 'VIVO MACROPHONY'; + component.filterStatus = 'ALL'; + component.additionalMode = 'ALL'; + component.selectedAdditionalServices = []; + + const filtered = (component as any).applyAdditionalFiltersClientSide([ + { id: '1', item: 1, conta: '460161507', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' }, + { id: '2', item: 2, conta: '0435288088', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' }, + ]); + + expect(filtered.length).toBe(1); + expect(filtered[0].conta).toBe('460161507'); + }); }); diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 7e78101..8f63072 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -17,14 +17,31 @@ import { HttpParams, HttpErrorResponse } from '@angular/common/http'; -import { NavigationEnd, Router } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { GeralModalsComponent } from '../../components/page-modals/geral-modals/geral-modals'; import { PlanAutoFillService } from '../../services/plan-autofill.service'; import { AuthService } from '../../services/auth.service'; import { TenantSyncService } from '../../services/tenant-sync.service'; +import { TableExportService } from '../../services/table-export.service'; +import { SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service'; +import { + MveAuditService, + type ApplyMveAuditResult, + type MveAuditIssue, + type MveAuditRun, +} from '../../services/mve-audit.service'; import { firstValueFrom, Subscription, filter } from 'rxjs'; import { environment } from '../../../environments/environment'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; +import { buildApiEndpoint } from '../../utils/api-base.util'; import { BATCH_MASS_COLUMN_GUIDE, type BatchMassApplyMode, @@ -35,12 +52,23 @@ import { buildBatchMassPreview, mergeMassRows } from './batch-mass-input.util'; +import { + DEFAULT_ACCOUNT_COMPANIES, + mergeAccountCompaniesWithDefaults, + normalizeConta as normalizeContaValue, + resolveEmpresaByConta, + resolveOperadoraContext, + sameConta as sameContaValue, +} from '../../utils/account-operator.util'; type SortDir = 'asc' | 'desc'; type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP'; type CreateEntryMode = 'SINGLE' | 'BATCH'; type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT'; +type OperadoraFilterMode = 'ALL' | 'VIVO' | 'CLARO' | 'TIM'; type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo'; +type BlockedStatusMode = 'ALL' | 'PERDA_ROUBO' | 'BLOQUEIO_120'; +type SkilFilterMode = 'ALL' | 'PF' | 'PJ' | 'RESERVA' | 'ESTOQUE'; interface LineRow { id: string; @@ -68,6 +96,9 @@ interface ApiPagedResult { interface ApiLineList { id: string; item: number; + conta?: string | null; + contaEmpresa?: string | null; + empresaConta?: string | null; linha: string | null; chip?: string | null; cliente: string | null; @@ -87,6 +118,14 @@ interface ApiLineList { vivoGestaoDispositivo?: number | null; } +interface SmartSearchTargetResolution { + client: string; + skilFilter: SkilFilterMode; + statusFilter: 'ALL' | 'BLOCKED'; + blockedStatusMode: BlockedStatusMode; + requiresFilterAdjustment: boolean; +} + interface ApiLineDetail { id: string; item: number; @@ -262,14 +301,67 @@ interface AssignReservaLinesResultDto { items: AssignReservaLineItemResultDto[]; } +type BatchStatusAction = 'BLOCK' | 'UNBLOCK'; + +interface BatchLineStatusUpdateRequestDto { + action: 'block' | 'unblock'; + blockStatus?: string | null; + applyToAllFiltered: boolean; + lineIds: string[]; + search?: string | null; + skil?: string | null; + clients?: string[]; + additionalMode?: string | null; + additionalServices?: string | null; + usuario?: string | null; +} + +interface BatchLineStatusUpdateItemResultDto { + id: string; + item?: number; + linha?: string | null; + usuario?: string | null; + statusAnterior?: string | null; + statusNovo?: string | null; + success: boolean; + message: string; +} + +interface BatchLineStatusUpdateResultDto { + requested: number; + updated: number; + failed: number; + items: BatchLineStatusUpdateItemResultDto[]; +} + +type MveAuditFilterMode = + | 'ALL' + | 'STATUS' + | 'DATA' + | 'ONLY_IN_SYSTEM' + | 'ONLY_IN_REPORT' + | 'DUPLICATES' + | 'INVALID' + | 'UNKNOWN'; + +type MveAuditApplyMode = 'ALL_SYNCABLE' | 'FILTERED_SYNCABLE'; + +interface MveApplySelectionSummary { + totalIssues: number; + totalStatusIssues: number; + totalDataIssues: number; + totalAffectedLines: number; +} + @Component({ standalone: true, - imports: [CommonModule, FormsModule, CustomSelectComponent], + imports: [CommonModule, FormsModule, CustomSelectComponent, GeralModalsComponent], templateUrl: './geral.html', styleUrls: ['./geral.scss'] }) export class Geral implements OnInit, AfterViewInit, OnDestroy { + readonly vm = this; readonly batchMassColumnGuide = BATCH_MASS_COLUMN_GUIDE; toastMessage = ''; @@ -289,22 +381,20 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private planAutoFill: PlanAutoFillService, private authService: AuthService, private router: Router, - private tenantSyncService: TenantSyncService + private route: ActivatedRoute, + private tenantSyncService: TenantSyncService, + private solicitacoesLinhasService: SolicitacoesLinhasService, + private tableExportService: TableExportService, + private mveAuditService: MveAuditService ) {} - private readonly apiBase = (() => { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; - return `${apiBase}/lines`; - })(); - private readonly templatesApiBase = (() => { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; - return `${apiBase}/templates`; - })(); + private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'lines'); + private readonly templatesApiBase = buildApiEndpoint(environment.apiUrl, 'templates'); loading = false; + exporting = false; isSysAdmin = false; isGestor = false; + isFinanceiro = false; isClientRestricted = false; rows: LineRow[] = []; @@ -314,9 +404,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { loadingLines = false; searchTerm = ''; - filterSkil: 'ALL' | 'PF' | 'PJ' | 'RESERVA' = 'ALL'; + filterSkil: SkilFilterMode = 'ALL'; + filterStatus: 'ALL' | 'BLOCKED' = 'ALL'; + blockedStatusMode: BlockedStatusMode = 'ALL'; additionalMode: AdditionalMode = 'ALL'; selectedAdditionalServices: AdditionalServiceKey[] = []; + filterOperadora: OperadoraFilterMode = 'ALL'; + filterContaEmpresa = ''; readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [ { key: 'gvd', label: 'Gestão Voz e Dados' }, { key: 'skeelo', label: 'Skeelo' }, @@ -325,6 +419,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { { key: 'sync', label: 'Vivo Sync' }, { key: 'dispositivo', label: 'Vivo Gestão Dispositivo' } ]; + readonly operadoraFilterOptions: Array<{ label: string; value: OperadoraFilterMode }> = [ + { label: 'Todas operadoras', value: 'ALL' }, + { label: 'VIVO', value: 'VIVO' }, + { label: 'CLARO', value: 'CLARO' }, + { label: 'TIM', value: 'TIM' }, + ]; clientsList: string[] = []; loadingClientsList = false; @@ -347,6 +447,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { financeOpen = false; editOpen = false; editSaving = false; + requestSaving = false; createOpen = false; createSaving = false; @@ -378,6 +479,26 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { }; reservaTransferLastResult: AssignReservaLinesResultDto | null = null; moveToReservaLastResult: AssignReservaLinesResultDto | null = null; + batchStatusOpen = false; + batchStatusSaving = false; + batchStatusAction: BatchStatusAction = 'BLOCK'; + batchStatusType = ''; + batchStatusUsuario = ''; + batchStatusLastResult: BatchLineStatusUpdateResultDto | null = null; + mveAuditOpen = false; + mveAuditProcessing = false; + mveAuditApplying = false; + mveAuditFile: File | null = null; + mveAuditResult: MveAuditRun | null = null; + mveAuditError = ''; + mveAuditFilter: MveAuditFilterMode = 'ALL'; + mveAuditSearchTerm = ''; + mveAuditPage = 1; + mveAuditPageSize = 10; + mveAuditPageSizeOptions = [10, 25, 50, 100]; + mveAuditApplyConfirmOpen = false; + mveAuditApplyMode: MveAuditApplyMode = 'ALL_SYNCABLE'; + mveAuditApplyLastResult: ApplyMveAuditResult | null = null; detailData: any = null; financeData: any = null; @@ -391,6 +512,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private keepPageOnNextGroupsLoad = false; private searchResolvedClient: string | null = null; + private searchRequestVersion = 0; private kpiRequestVersion = 0; private groupsRequestVersion = 0; private linesRequestVersion = 0; @@ -420,12 +542,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { 'M2M 50MB' ]; - private readonly fallbackAccountCompanies: AccountCompanyOption[] = [ - { empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840'] }, - { empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844'] }, - { empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] }, - { empresa: 'TIM LINE MÓVEL', contas: ['0072046192'] } - ]; + private readonly fallbackAccountCompanies: AccountCompanyOption[] = DEFAULT_ACCOUNT_COMPANIES.map((group) => ({ + empresa: group.empresa, + contas: [...group.contas], + })); accountCompanies: AccountCompanyOption[] = [...this.fallbackAccountCompanies]; loadingAccountCompanies = false; @@ -437,6 +557,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return this.accountCompanies.map((x) => x.empresa); } + get contaEmpresaFilterOptions(): Array<{ label: string; value: string }> { + const empresas = this.getContaEmpresaOptionsByOperadora(this.filterOperadora); + const merged = this.mergeOption(this.filterContaEmpresa, empresas); + return [ + { label: 'Todas empresas', value: '' }, + ...merged.map((empresa) => ({ label: empresa, value: empresa })), + ]; + } + get contaOptionsForCreate(): string[] { return this.getContasByEmpresa(this.createModel?.contaEmpresa); } @@ -594,21 +723,67 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } get isReservaExpandedGroup(): boolean { - return this.filterSkil === 'RESERVA' && !!(this.expandedGroup ?? '').trim(); + return this.isReserveContextFilter() && !!(this.expandedGroup ?? '').trim(); } get isExpandedGroupNamedReserva(): boolean { - return (this.expandedGroup ?? '').toString().trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0; + const group = (this.expandedGroup ?? '').toString().trim(); + return group.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0 + || group.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0; } get hasGroupLineSelectionTools(): boolean { - return !this.isClientRestricted && !!(this.expandedGroup ?? '').trim(); + return this.canManageLines && !!(this.expandedGroup ?? '').trim(); + } + + get canManageLines(): boolean { + return this.isSysAdmin || this.isGestor; } get canMoveSelectedLinesToReserva(): boolean { return this.hasGroupLineSelectionTools && !this.isReservaExpandedGroup && !this.isExpandedGroupNamedReserva; } + get blockedStatusOptions(): string[] { + return this.statusOptions.filter((status) => !this.isActiveStatus(status)); + } + + get batchStatusSelectionCount(): number { + return this.reservaSelectedCount; + } + + get canOpenBatchStatusModal(): boolean { + if (!this.canManageLines) return false; + if (this.loading || this.batchStatusSaving) return false; + return this.batchStatusSelectionCount > 0; + } + + get canSubmitBatchStatusModal(): boolean { + if (this.batchStatusSaving) return false; + if (this.batchStatusSelectionCount <= 0) return false; + if (this.batchStatusAction === 'BLOCK' && !String(this.batchStatusType ?? '').trim()) return false; + return true; + } + + get batchStatusActionLabel(): string { + return this.batchStatusAction === 'BLOCK' ? 'Bloquear' : 'Desbloquear'; + } + + get batchStatusTargetDescription(): string { + return `${this.batchStatusSelectionCount} linha(s) selecionada(s)`; + } + + get batchStatusUserOptions(): string[] { + const users = (this.groupLines ?? []) + .map((x) => (x.usuario ?? '').toString().trim()) + .filter((x) => !!x); + + const current = (this.batchStatusUsuario ?? '').toString().trim(); + if (current) users.push(current); + + return Array.from(new Set(users)).sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })); + } + get reservaSelectedCount(): number { return this.reservaSelectedLineIds.length; } @@ -625,10 +800,17 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const v = (c ?? '').toString().trim(); if (!v) continue; if (v.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0) continue; + if (v.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0) continue; set.add(v); } const current = (this.reservaTransferModel?.clienteDestino ?? '').toString().trim(); - if (current && current.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) !== 0) set.add(current); + if ( + current && + current.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) !== 0 && + current.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) !== 0 + ) { + set.add(current); + } return Array.from(set).sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })); } @@ -644,6 +826,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return this.additionalMode !== 'ALL' || this.selectedAdditionalServices.length > 0; } + get hasOperadoraEmpresaFiltersApplied(): boolean { + return this.filterOperadora !== 'ALL' || !!this.filterContaEmpresa.trim(); + } + + get hasClientSideFiltersApplied(): boolean { + return this.hasAdditionalFiltersApplied || this.filterStatus === 'BLOCKED' || this.hasOperadoraEmpresaFiltersApplied; + } + get additionalModeLabel(): string { if (this.additionalMode === 'WITH') return 'Com adicionais'; if (this.additionalMode === 'WITHOUT') return 'Sem adicionais'; @@ -655,6 +845,112 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { .map((key) => this.additionalServiceOptions.find((x) => x.key === key)?.label ?? key); } + get hasMveAuditResult(): boolean { + return !!this.mveAuditResult; + } + + get filteredMveAuditIssues(): MveAuditIssue[] { + const source = this.mveAuditResult?.issues ?? []; + const search = this.normalizeMveSearchTerm(this.mveAuditSearchTerm); + + return source.filter((issue) => { + if (!this.matchesMveIssueFilter(issue)) { + return false; + } + + if (!search) { + return true; + } + + const haystack = [ + issue.numeroLinha, + issue.issueType, + issue.situation, + issue.systemStatus, + issue.reportStatus, + issue.systemPlan, + issue.reportPlan, + issue.actionSuggestion, + issue.notes, + ...(issue.differences ?? []).flatMap((diff) => [diff.label, diff.systemValue, diff.reportValue]), + ] + .map((value) => this.normalizeMveSearchTerm(value)) + .join(' '); + + return haystack.includes(search); + }); + } + + get mveAuditTotalPages(): number { + return computeTotalPages(this.filteredMveAuditIssues.length, this.mveAuditPageSize); + } + + get mveAuditPageNumbers(): number[] { + return buildPageNumbers(this.mveAuditPage, this.mveAuditTotalPages); + } + + get pagedMveAuditIssues(): MveAuditIssue[] { + const start = computePageStart(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize); + if (start <= 0) { + return this.filteredMveAuditIssues.slice(0, this.mveAuditPageSize); + } + + const offset = start - 1; + return this.filteredMveAuditIssues.slice(offset, offset + this.mveAuditPageSize); + } + + get mveAuditPageStart(): number { + return computePageStart(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize); + } + + get mveAuditPageEnd(): number { + return computePageEnd(this.filteredMveAuditIssues.length, this.mveAuditPage, this.mveAuditPageSize); + } + + get allSyncableMveIssues(): MveAuditIssue[] { + return (this.mveAuditResult?.issues ?? []).filter((issue) => issue.syncable && !issue.applied); + } + + get filteredSyncableMveIssues(): MveAuditIssue[] { + return this.filteredMveAuditIssues.filter((issue) => issue.syncable && !issue.applied); + } + + get selectedMveApplyIssues(): MveAuditIssue[] { + return this.mveAuditApplyMode === 'FILTERED_SYNCABLE' + ? this.filteredSyncableMveIssues + : this.allSyncableMveIssues; + } + + get mveApplySelectionSummary(): MveApplySelectionSummary { + const selected = this.selectedMveApplyIssues; + const affectedLines = new Set(); + let totalStatusIssues = 0; + let totalDataIssues = 0; + + for (const issue of selected) { + if (issue.mobileLineId) affectedLines.add(issue.mobileLineId); + else if (issue.numeroLinha) affectedLines.add(issue.numeroLinha); + + if (this.issueHasStatusDifference(issue)) totalStatusIssues++; + if (this.issueHasDataDifference(issue)) totalDataIssues++; + } + + return { + totalIssues: selected.length, + totalStatusIssues, + totalDataIssues, + totalAffectedLines: affectedLines.size, + }; + } + + get canSubmitMveAudit(): boolean { + return !!this.mveAuditFile && !this.mveAuditProcessing && !this.mveAuditApplying; + } + + get canOpenMveApplyConfirm(): boolean { + return !this.mveAuditApplying && this.mveApplySelectionSummary.totalIssues > 0; + } + // ✅ fecha dropdown ao clicar fora @HostListener('document:click', ['$event']) onDocumentClick(ev: MouseEvent) { @@ -728,10 +1024,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { if (!isPlatformBrowser(this.platformId)) return; this.isSysAdmin = this.authService.hasRole('sysadmin'); this.isGestor = this.authService.hasRole('gestor'); - this.isClientRestricted = !(this.isSysAdmin || this.isGestor); + this.isFinanceiro = this.authService.hasRole('financeiro'); + this.isClientRestricted = !(this.isSysAdmin || this.isGestor || this.isFinanceiro); if (this.isClientRestricted) { this.filterSkil = 'ALL'; + this.filterStatus = 'ALL'; + this.blockedStatusMode = 'ALL'; this.additionalMode = 'ALL'; this.selectedAdditionalServices = []; this.selectedClients = []; @@ -743,6 +1042,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.initAnimations(); setTimeout(() => { + this.applyRouteFilters(this.route.snapshot.queryParams); this.refreshData(); if (!this.isClientRestricted) { this.loadClients(); @@ -763,9 +1063,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.navigationSub = this.router.events .pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)) .subscribe((event) => { - const url = (event.urlAfterRedirects || '').toLowerCase(); + const urlAfterRedirects = event.urlAfterRedirects || ''; + const url = urlAfterRedirects.toLowerCase(); if (!url.includes('/geral')) return; + const parsed = this.router.parseUrl(urlAfterRedirects); + this.applyRouteFilters(parsed.queryParams ?? {}); + this.searchResolvedClient = null; if (!this.isClientRestricted) { this.loadClients(); @@ -782,6 +1086,175 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { }, 100); } + private applyRouteFilters(query: Record): void { + const skil = this.parseQuerySkilFilter(query['skil']); + const reservaMode = this.parseQueryReservaMode(query['reservaMode']); + const resolvedSkil = skil === 'RESERVA' && reservaMode === 'stock' ? 'ESTOQUE' : skil; + if (resolvedSkil && (!this.isClientRestricted || resolvedSkil === 'ALL')) { + this.filterSkil = resolvedSkil; + } + + const status = this.parseQueryStatusFilter(query['statusMode'] ?? query['statusFilter']); + if (status) { + this.filterStatus = status; + } + if (this.filterStatus !== 'BLOCKED') { + this.blockedStatusMode = 'ALL'; + } + + const blockedMode = this.parseQueryBlockedStatusMode(query['blockedMode'] ?? query['blockedType'] ?? query['statusSubtype']); + if (blockedMode) { + this.blockedStatusMode = blockedMode; + this.filterStatus = 'BLOCKED'; + } + + if (!this.isClientRestricted) { + const additionalMode = this.parseQueryAdditionalMode(query['additionalMode']); + if (additionalMode) { + this.additionalMode = additionalMode; + } + + const additionalServices = this.parseQueryAdditionalServices(query['additionalServices']); + if (additionalServices) { + this.selectedAdditionalServices = additionalServices; + } + } + + this.expandedGroup = null; + this.groupLines = []; + this.selectedClients = []; + this.clientSearchTerm = ''; + this.searchTerm = ''; + this.searchResolvedClient = null; + this.page = 1; + } + + private parseQuerySkilFilter(value: unknown): SkilFilterMode | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'ALL' || token === 'TODOS') return 'ALL'; + if (token === 'PF' || token === 'PESSOAFISICA') return 'PF'; + if (token === 'PJ' || token === 'PESSOAJURIDICA') return 'PJ'; + if (token === 'RESERVA' || token === 'RESERVAS') return 'RESERVA'; + if (token === 'ESTOQUE' || token === 'STOCK') return 'ESTOQUE'; + return null; + } + + private parseQueryReservaMode(value: unknown): 'assigned' | 'stock' | 'all' | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'ASSIGNED' || token === 'RESERVA' || token === 'RESERVAS') return 'assigned'; + if (token === 'STOCK' || token === 'ESTOQUE') return 'stock'; + if (token === 'ALL' || token === 'TODOS') return 'all'; + return null; + } + + private parseQueryStatusFilter(value: unknown): 'ALL' | 'BLOCKED' | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'ALL' || token === 'TODOS') return 'ALL'; + if ( + token === 'BLOCKED' || + token === 'BLOQUEADAS' || + token === 'BLOQUEADOS' || + token === 'BLOQUEADA' || + token === 'BLOQUEADO' || + token === 'BLOQUEIO' + ) { + return 'BLOCKED'; + } + return null; + } + + private parseQueryBlockedStatusMode(value: unknown): BlockedStatusMode | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'ALL' || token === 'TODOS') return 'ALL'; + if ( + token === 'PERDAROUBO' || + token === 'PERDAEROUBO' || + token === 'PERDA' || + token === 'ROUBO' + ) { + return 'PERDA_ROUBO'; + } + if ( + token === '120' || + token === '120DIAS' || + token === 'BLOQUEIO120' || + token === 'BLOQUEIO120DIAS' + ) { + return 'BLOQUEIO_120'; + } + return null; + } + + private parseQueryAdditionalMode(value: unknown): AdditionalMode | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'ALL' || token === 'TODOS') return 'ALL'; + if (token === 'WITH' || token === 'COM') return 'WITH'; + if (token === 'WITHOUT' || token === 'SEM') return 'WITHOUT'; + return null; + } + + private parseQueryAdditionalServices(value: unknown): AdditionalServiceKey[] | null { + if (value === undefined || value === null) return null; + const asString = Array.isArray(value) ? value.join(',') : String(value ?? ''); + const chunks = asString + .split(',') + .map((part) => this.mapAdditionalServiceToken(part)) + .filter((part): part is AdditionalServiceKey => !!part); + + const unique = Array.from(new Set(chunks)); + return unique; + } + + private mapAdditionalServiceToken(value: unknown): AdditionalServiceKey | null { + const token = this.normalizeFilterToken(value); + if (!token) return null; + if (token === 'GVD' || token === 'GESTAOVOZDADOS' || token === 'GESTAOVOZEDADOS') return 'gvd'; + if (token === 'SKEELO') return 'skeelo'; + if (token === 'NEWS' || token === 'VIVONEWS' || token === 'VIVONEWSPLUS') return 'news'; + if (token === 'TRAVEL' || token === 'TRAVELMUNDO' || token === 'VIVOTRAVELMUNDO') return 'travel'; + if (token === 'SYNC' || token === 'VIVOSYNC') return 'sync'; + if (token === 'DISPOSITIVO' || token === 'GESTAODISPOSITIVO' || token === 'VIVOGESTAODISPOSITIVO') return 'dispositivo'; + return null; + } + + private normalizeFilterToken(value: unknown): string { + return String(value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^A-Za-z0-9]/g, '') + .toUpperCase() + .trim(); + } + + private isReserveContextFilter(filter: SkilFilterMode = this.filterSkil): boolean { + return filter === 'RESERVA' || filter === 'ESTOQUE'; + } + + private isStockFilter(filter: SkilFilterMode = this.filterSkil): boolean { + return filter === 'ESTOQUE'; + } + + private isStockClientName(value: unknown): boolean { + return (value ?? '').toString().trim().localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0; + } + + private getClientFallbackLabel(emptyFallback = '', filter: SkilFilterMode = this.filterSkil): string { + if (filter === 'ESTOQUE') return 'ESTOQUE'; + if (filter === 'RESERVA') return 'RESERVA'; + return emptyFallback; + } + + private getReservaModeForApi(filter: SkilFilterMode = this.filterSkil): 'assigned' | 'stock' | null { + if (filter === 'ESTOQUE') return 'stock'; + if (filter === 'RESERVA') return 'assigned'; + return null; + } + private async loadPlanRules() { try { await this.planAutoFill.load(); @@ -801,20 +1274,26 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.http.get(`${this.apiBase}/account-companies`).subscribe({ next: (data) => { const normalized = this.normalizeAccountCompanies(data); - this.accountCompanies = - normalized.length > 0 ? normalized : [...this.fallbackAccountCompanies]; + const source = normalized.length > 0 ? normalized : this.fallbackAccountCompanies; + this.accountCompanies = mergeAccountCompaniesWithDefaults(source); this.loadingAccountCompanies = false; + this.syncContaEmpresaFilterByOperadora(); this.syncContaEmpresaSelection(this.createModel); this.syncContaEmpresaSelection(this.editModel); + this.syncContaEmpresaSelection(this.detailData); + this.syncContaEmpresaSelection(this.financeData); this.cdr.detectChanges(); }, error: () => { - this.accountCompanies = [...this.fallbackAccountCompanies]; + this.accountCompanies = mergeAccountCompaniesWithDefaults(this.fallbackAccountCompanies); this.loadingAccountCompanies = false; + this.syncContaEmpresaFilterByOperadora(); this.syncContaEmpresaSelection(this.createModel); this.syncContaEmpresaSelection(this.editModel); + this.syncContaEmpresaSelection(this.detailData); + this.syncContaEmpresaSelection(this.financeData); } }); } @@ -823,7 +1302,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { // ✅ FIX PRINCIPAL: limpeza forçada de backdrops/scroll lock // ============================================================ private anyModalOpen(): boolean { - return !!(this.detailOpen || this.financeOpen || this.editOpen || this.createOpen || this.reservaTransferOpen || this.moveToReservaOpen); + return !!( + this.detailOpen || + this.financeOpen || + this.editOpen || + this.createOpen || + this.reservaTransferOpen || + this.moveToReservaOpen || + this.batchStatusOpen || + this.mveAuditOpen + ); } private cleanupModalArtifacts() { @@ -851,6 +1339,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.createOpen = false; this.reservaTransferOpen = false; this.moveToReservaOpen = false; + this.batchStatusOpen = false; + this.mveAuditOpen = false; + this.mveAuditApplyConfirmOpen = false; this.detailData = null; this.financeData = null; @@ -858,6 +1349,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.aparelhoReciboFile = null; this.editSaving = false; + this.requestSaving = false; this.createSaving = false; this.editModel = null; @@ -869,8 +1361,21 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.batchExcelTemplateDownloading = false; this.reservaTransferSaving = false; this.moveToReservaSaving = false; + this.batchStatusSaving = false; this.reservaTransferLastResult = null; this.moveToReservaLastResult = null; + this.batchStatusLastResult = null; + this.batchStatusUsuario = ''; + this.mveAuditFile = null; + this.mveAuditProcessing = false; + this.mveAuditApplying = false; + this.mveAuditResult = null; + this.mveAuditError = ''; + this.mveAuditFilter = 'ALL'; + this.mveAuditSearchTerm = ''; + this.mveAuditPage = 1; + this.mveAuditApplyMode = 'ALL_SYNCABLE'; + this.mveAuditApplyLastResult = null; // Limpa overlays/locks residuais this.cleanupModalArtifacts(); @@ -891,7 +1396,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { refreshData(opts?: { keepCurrentPage?: boolean }) { const keepCurrentPage = !!opts?.keepCurrentPage; this.keepPageOnNextGroupsLoad = keepCurrentPage; - if (!keepCurrentPage && this.filterSkil === 'RESERVA') { + if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus === 'BLOCKED')) { this.page = 1; } this.searchResolvedClient = null; @@ -904,7 +1409,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const t = (term ?? '').trim(); if (!t) return false; - const digits = t.replace(/\D/g, ''); + const digits = this.normalizeDigits(t); if (!digits) return false; if (digits.length >= 17) return true; // ICCID @@ -913,37 +1418,154 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return false; } - private resolveSearchToClient(term: string): Promise { + private normalizeDigits(value: unknown): string { + return String(value ?? '').replace(/\D/g, ''); + } + + private resolveSkilFilterFromLine(skil: unknown, client: unknown): SkilFilterMode { + if (this.isStockClientName(client)) return 'ESTOQUE'; + const parsed = this.parseQuerySkilFilter(skil); + return parsed ?? 'ALL'; + } + + private findBestSpecificSearchMatch(items: ApiLineList[], term: string): ApiLineList | null { + if (!Array.isArray(items) || items.length === 0) return null; + + const digits = this.normalizeDigits(term); + if (!digits) return null; + + const isIccidSearch = digits.length >= 17; + const exactMatches = items.filter((item) => { + const lineDigits = this.normalizeDigits(item?.linha); + const chipDigits = this.normalizeDigits(item?.chip); + return lineDigits === digits || chipDigits === digits; + }); + if (exactMatches.length > 0) return exactMatches[0]; + + const compatibleMatches = items.filter((item) => { + const lineDigits = this.normalizeDigits(item?.linha); + const chipDigits = this.normalizeDigits(item?.chip); + + if (isIccidSearch) { + return !!chipDigits && ( + chipDigits.endsWith(digits) || + digits.endsWith(chipDigits) || + chipDigits.includes(digits) + ); + } + + return !!lineDigits && ( + lineDigits.endsWith(digits) || + digits.endsWith(lineDigits) || + lineDigits.includes(digits) + ); + }); + + return compatibleMatches[0] ?? items[0] ?? null; + } + + private async findSpecificSearchMatch( + term: string, + options?: { + ignoreCurrentFilters?: boolean; + skilFilter?: SkilFilterMode; + } + ): Promise { const s = (term ?? '').trim(); - if (!s) return Promise.resolve(null); + if (!s) return null; - const pageSize = this.hasAdditionalFiltersApplied ? '500' : '1'; - let params = new HttpParams().set('page', '1').set('pageSize', pageSize).set('search', s); - params = this.applyBaseFilters(params); + let params = new HttpParams() + .set('page', '1') + .set('pageSize', options?.ignoreCurrentFilters ? '200' : '500') + .set('search', s); - if (this.selectedClients.length > 0) { + if (!options?.ignoreCurrentFilters) { + params = this.applyBaseFilters(params); this.selectedClients.forEach((c) => (params = params.append('client', c))); + } else if (options?.skilFilter === 'PF') { + params = params.set('skil', 'PESSOA FÍSICA'); + } else if (options?.skilFilter === 'PJ') { + params = params.set('skil', 'PESSOA JURÍDICA'); + } else if (options?.skilFilter === 'ESTOQUE') { + params = params.set('skil', 'RESERVA').set('reservaMode', 'stock'); + } else if (options?.skilFilter === 'RESERVA') { + params = params.set('skil', 'RESERVA'); } - return new Promise((resolve) => { - this.http.get>(this.apiBase, { params: this.withNoCache(params) }).subscribe({ - next: (res) => { - const source = this.hasAdditionalFiltersApplied - ? this.applyAdditionalFiltersClientSide(res.items ?? []) - : (res.items ?? []); - const first = source[0]; - const client = (first?.cliente ?? '').trim(); - resolve(client || null); - }, - error: () => resolve(null) - }); + try { + const response = await firstValueFrom( + this.http.get>(this.apiBase, { params: this.withNoCache(params) }) + ); + + let items = response?.items ?? []; + if (!options?.ignoreCurrentFilters && this.hasClientSideFiltersApplied) { + items = this.applyAdditionalFiltersClientSide(items); + } + + return this.findBestSpecificSearchMatch(items, s); + } catch { + return null; + } + } + + private buildSmartSearchTarget( + line: ApiLineList, + requiresFilterAdjustment: boolean + ): SmartSearchTargetResolution | null { + if (!line) return null; + + const skilFilter = this.resolveSkilFilterFromLine(line?.skil, line?.cliente); + const blockedStatusMode = this.resolveBlockedStatusMode(line?.status ?? '') ?? 'ALL'; + const client = ((line?.cliente ?? '').toString().trim()) || this.getClientFallbackLabel('SEM CLIENTE', skilFilter); + + return { + client, + skilFilter, + statusFilter: blockedStatusMode === 'ALL' ? 'ALL' : 'BLOCKED', + blockedStatusMode, + requiresFilterAdjustment + }; + } + + private async resolveSmartSearchTarget(term: string): Promise { + const currentContextMatch = await this.findSpecificSearchMatch(term); + if (currentContextMatch) { + return this.buildSmartSearchTarget(currentContextMatch, false); + } + + const globalMatch = await this.findSpecificSearchMatch(term, { ignoreCurrentFilters: true }); + if (globalMatch) { + return this.buildSmartSearchTarget(globalMatch, true); + } + + const reservaMatch = await this.findSpecificSearchMatch(term, { + ignoreCurrentFilters: true, + skilFilter: 'RESERVA' }); + if (reservaMatch) { + return this.buildSmartSearchTarget(reservaMatch, true); + } + + return null; + } + + private applySmartSearchFilters(target: SmartSearchTargetResolution): void { + this.filterSkil = target.skilFilter; + this.filterStatus = target.statusFilter; + this.blockedStatusMode = target.statusFilter === 'BLOCKED' ? target.blockedStatusMode : 'ALL'; + + this.selectedClients = []; + this.clientSearchTerm = ''; + this.additionalMode = 'ALL'; + this.selectedAdditionalServices = []; } onSearch() { if (this.searchTimer) clearTimeout(this.searchTimer); this.searchTimer = setTimeout(async () => { + const requestVersion = ++this.searchRequestVersion; + this.expandedGroup = null; this.groupLines = []; this.page = 1; @@ -951,6 +1573,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const term = (this.searchTerm ?? '').trim(); if (!term) { + if (requestVersion !== this.searchRequestVersion) return; this.searchResolvedClient = null; this.loadKpis(); this.loadGroups(); @@ -958,18 +1581,32 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } if (this.isSpecificSearchTerm(term)) { - const client = await this.resolveSearchToClient(term); + const target = await this.resolveSmartSearchTarget(term); - if (client) { - this.searchResolvedClient = client; + if (requestVersion !== this.searchRequestVersion) return; + + if (target) { + if (target.requiresFilterAdjustment) { + this.applySmartSearchFilters(target); + if (!this.isClientRestricted) { + this.loadClients(); + } + } + + this.searchResolvedClient = target.client; this.loadKpis(); - await this.loadOnlyThisClientGroup(client); - this.expandedGroup = client; - this.fetchGroupLines(client, term); + await this.loadOnlyThisClientGroup(target.client); + + if (requestVersion !== this.searchRequestVersion) return; + + this.expandedGroup = target.client; + this.fetchGroupLines(target.client, term); return; } } + if (requestVersion !== this.searchRequestVersion) return; + this.searchResolvedClient = null; this.loadKpis(); this.loadGroups(); @@ -980,7 +1617,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const requestVersion = ++this.groupsRequestVersion; this.loading = true; - if (this.hasAdditionalFiltersApplied) { + if (this.hasClientSideFiltersApplied) { return this.loadOnlyThisClientGroupFromLines(clientName, requestVersion); } @@ -1047,7 +1684,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.loadingClientsList = true; this.clientsList = []; - if (this.hasAdditionalFiltersApplied) { + if (this.hasClientSideFiltersApplied) { void this.loadClientsFromLines(requestVersion); return; } @@ -1104,7 +1741,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { if (requestVersion !== this.clientsRequestVersion) return; const filteredLines = this.applyAdditionalFiltersClientSide(allLines); - const fallbackClient = this.filterSkil === 'RESERVA' ? 'RESERVA' : ''; + const fallbackClient = this.getClientFallbackLabel(''); const clients = filteredLines .map((x) => ((x.cliente ?? '').toString().trim()) || fallbackClient) .filter((x) => !!x); @@ -1120,7 +1757,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } } - setFilter(type: 'ALL' | 'PF' | 'PJ' | 'RESERVA') { + setFilter(type: SkilFilterMode) { if (this.isClientRestricted && type !== 'ALL') return; const isSameFilter = this.filterSkil === type; @@ -1143,6 +1780,47 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.refreshData(); } + toggleBlockedFilter() { + if (this.filterStatus === 'BLOCKED') { + this.filterStatus = 'ALL'; + this.blockedStatusMode = 'ALL'; + } else { + this.filterStatus = 'BLOCKED'; + } + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.selectedClients = []; + this.clientSearchTerm = ''; + this.page = 1; + + if (!this.isClientRestricted) { + this.loadClients(); + } + + this.refreshData(); + } + + setBlockedStatusMode(mode: Exclude) { + if (this.filterStatus !== 'BLOCKED') { + this.filterStatus = 'BLOCKED'; + } + + this.blockedStatusMode = this.blockedStatusMode === mode ? 'ALL' : mode; + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.selectedClients = []; + this.clientSearchTerm = ''; + this.page = 1; + + if (!this.isClientRestricted) { + this.loadClients(); + } + + this.refreshData(); + } + setAdditionalMode(mode: AdditionalMode) { if (this.isClientRestricted) return; if (this.additionalMode === mode) return; @@ -1189,12 +1867,47 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.refreshData(); } + setOperadoraFilter(mode: OperadoraFilterMode) { + if (this.isClientRestricted) return; + this.filterOperadora = mode; + this.syncContaEmpresaFilterByOperadora(); + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.page = 1; + + this.loadClients(); + this.refreshData(); + } + + setContaEmpresaFilter(empresa: string) { + if (this.isClientRestricted) return; + const next = (empresa ?? '').toString().trim(); + this.filterContaEmpresa = next; + this.expandedGroup = null; + this.groupLines = []; + this.searchResolvedClient = null; + this.page = 1; + + this.loadClients(); + this.refreshData(); + } + private applyBaseFilters(params: HttpParams): HttpParams { let next = params; if (this.filterSkil === 'PF') next = next.set('skil', 'PESSOA FÍSICA'); else if (this.filterSkil === 'PJ') next = next.set('skil', 'PESSOA JURÍDICA'); - else if (this.filterSkil === 'RESERVA') next = next.set('skil', 'RESERVA'); + else if (this.isReserveContextFilter()) { + next = next.set('skil', 'RESERVA'); + const reservaMode = this.getReservaModeForApi(); + if (reservaMode) next = next.set('reservaMode', reservaMode); + } + if (this.filterStatus === 'BLOCKED') { + next = next.set('statusMode', 'blocked'); + if (this.blockedStatusMode === 'PERDA_ROUBO') next = next.set('statusSubtype', 'perda_roubo'); + else if (this.blockedStatusMode === 'BLOQUEIO_120') next = next.set('statusSubtype', '120_dias'); + } if (this.additionalMode === 'WITH') next = next.set('additionalMode', 'with'); else if (this.additionalMode === 'WITHOUT') next = next.set('additionalMode', 'without'); @@ -1232,7 +1945,45 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { (this.getAdditionalValue(line, 'dispositivo') > 0); } + private resolveBlockedStatusMode(status: unknown): Exclude | null { + const normalized = this.normalizeFilterToken(status); + if (!normalized) return null; + + const hasBlockedToken = + normalized.includes('BLOQUE') || + normalized.includes('PERDA') || + normalized.includes('ROUBO') || + normalized.includes('FURTO'); + if (!hasBlockedToken) return null; + + if (normalized.includes('120')) return 'BLOQUEIO_120'; + if (normalized.includes('PERDA') || normalized.includes('ROUBO') || normalized.includes('FURTO')) { + return 'PERDA_ROUBO'; + } + + return 'PERDA_ROUBO'; + } + + private isBlockedStatus(status: unknown): boolean { + return this.resolveBlockedStatusMode(status) !== null; + } + + private matchesBlockedStatusMode(status: unknown): boolean { + const mode = this.resolveBlockedStatusMode(status); + if (!mode) return false; + if (this.blockedStatusMode === 'ALL') return true; + return mode === this.blockedStatusMode; + } + private matchesAdditionalFilters(line: ApiLineList): boolean { + if (this.filterStatus === 'BLOCKED' && !this.matchesBlockedStatusMode(line?.status ?? '')) { + return false; + } + + if (!this.matchesOperadoraContaEmpresaFilters(line)) { + return false; + } + const selected = this.selectedAdditionalServices; const hasSelected = selected.length > 0; @@ -1256,6 +2007,40 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return true; } + private matchesOperadoraContaEmpresaFilters(line: ApiLineList): boolean { + const hasOperadora = this.filterOperadora !== 'ALL'; + const selectedEmpresa = this.filterContaEmpresa.trim(); + const hasEmpresa = !!selectedEmpresa; + if (!hasOperadora && !hasEmpresa) return true; + + const conta = (line as any)?.conta ?? (line as any)?.Conta ?? ''; + const empresaConta = (line as any)?.contaEmpresa + ?? (line as any)?.empresaConta + ?? (line as any)?.ContaEmpresa + ?? (line as any)?.EmpresaConta + ?? (line as any)?.empresa_conta + ?? (line as any)?.Empresa_Conta + ?? (line as any)?.['empresa (conta)'] + ?? (line as any)?.['EMPRESA (CONTA)'] + ?? ''; + + const context = resolveOperadoraContext({ + conta, + empresaConta, + accountCompanies: this.accountCompanies, + }); + + if (hasOperadora && context.operadora !== this.filterOperadora) { + return false; + } + + if (!hasEmpresa) return true; + + const resolvedEmpresa = (context.empresaConta || this.findEmpresaByConta(conta) || '').toString().trim(); + if (!resolvedEmpresa) return false; + return this.normalizeFilterToken(resolvedEmpresa) === this.normalizeFilterToken(selectedEmpresa); + } + private applyAdditionalFiltersClientSide(lines: ApiLineList[]): ApiLineList[] { if (!Array.isArray(lines) || lines.length === 0) return []; return lines.filter((line) => this.matchesAdditionalFilters(line)); @@ -1292,7 +2077,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } private async fetchAllGroupsForKpis(): Promise { - if (this.hasAdditionalFiltersApplied) { + if (this.hasClientSideFiltersApplied) { const lines = await this.fetchLinesForGrouping(); let groups = this.buildGroupsFromLines(lines); @@ -1407,11 +2192,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const keepCurrentPage = this.keepPageOnNextGroupsLoad; this.keepPageOnNextGroupsLoad = false; - if (!keepCurrentPage && this.filterSkil === 'RESERVA' && !hasSelection && !hasResolved) { + if (!keepCurrentPage && (this.isReserveContextFilter() || this.filterStatus === 'BLOCKED') && !hasSelection && !hasResolved) { this.page = 1; } - if (this.hasAdditionalFiltersApplied) { + if (this.hasClientSideFiltersApplied) { void this.loadGroupsFromLines(hasSelection, hasResolved, requestVersion); return; } @@ -1548,7 +2333,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private buildGroupsFromLines(lines: ApiLineList[]): ClientGroupDto[] { const grouped = new Map(); - const fallbackClient = this.filterSkil === 'RESERVA' ? 'RESERVA' : 'SEM CLIENTE'; + const fallbackClient = this.getClientFallbackLabel('SEM CLIENTE'); for (const row of lines ?? []) { const client = ((row?.cliente ?? '').toString().trim()) || fallbackClient; @@ -1569,7 +2354,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const status = ((row?.status ?? '').toString().trim()).toLowerCase(); if (status.includes('ativo')) group.ativos += 1; - if (status.includes('bloque') || status.includes('perda') || status.includes('roubo')) { + if (this.isBlockedStatus(row?.status ?? '')) { group.bloqueados += 1; } } @@ -1580,6 +2365,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private sortGroupsWithReservaFirst(groups: ClientGroupDto[]): ClientGroupDto[] { const list = Array.isArray(groups) ? [...groups] : []; return list.sort((a, b) => { + const aEstoque = this.isStockClientName(a?.cliente); + const bEstoque = this.isStockClientName(b?.cliente); + if (aEstoque !== bEstoque) return aEstoque ? -1 : 1; const aReserva = (a?.cliente || '').trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0; const bReserva = (b?.cliente || '').trim().localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0; if (aReserva !== bReserva) return aReserva ? -1 : 1; @@ -1603,49 +2391,91 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.fetchGroupLines(clientName, useTerm); } + private async fetchAllGroupLines( + clientName: string, + search: string | undefined, + requestVersion: number + ): Promise { + try { + let baseParams = new HttpParams() + .set('client', clientName) + .set('sortBy', 'item') + .set('sortDir', 'asc'); + + baseParams = this.applyBaseFilters(baseParams); + + if (search) { + baseParams = baseParams.set('search', search); + } + + const pageSize = 5000; + let page = 1; + let expectedTotal = 0; + const allItems: ApiLineList[] = []; + + while (page <= 500) { + const params = baseParams + .set('page', String(page)) + .set('pageSize', String(pageSize)); + + const response = await firstValueFrom( + this.http.get>(this.apiBase, { + params: this.withNoCache(params) + }) + ); + + if (requestVersion !== this.linesRequestVersion) return; + + const items = response?.items ?? []; + expectedTotal = this.toInt(response?.total); + allItems.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && allItems.length >= expectedTotal) break; + + page += 1; + } + + if (requestVersion !== this.linesRequestVersion) return; + + const filteredItems = this.applyAdditionalFiltersClientSide(allItems); + this.groupLines = filteredItems.map((x) => this.mapApiLineListToLineRow(x)); + + this.loadingLines = false; + this.cdr.detectChanges(); + } catch { + if (requestVersion !== this.linesRequestVersion) return; + this.loadingLines = false; + await this.showToast('Erro ao carregar linhas do grupo.'); + } + } + + private mapApiLineListToLineRow(x: ApiLineList): LineRow { + return { + id: x.id, + item: String(x.item ?? ''), + linha: x.linha ?? '', + chip: x.chip ?? '', + cliente: x.cliente ?? '', + usuario: x.usuario ?? '', + centroDeCustos: x.centroDeCustos ?? '', + setorNome: x.setorNome ?? '', + aparelhoNome: x.aparelhoNome ?? '', + aparelhoCor: x.aparelhoCor ?? '', + status: x.status ?? '', + skil: x.skil ?? '', + contrato: x.vencConta ?? '' + }; + } + fetchGroupLines(clientName: string, search?: string) { const requestVersion = ++this.linesRequestVersion; this.groupLines = []; this.clearReservaSelection(); this.loadingLines = true; - let params = new HttpParams() - .set('client', clientName) - .set('page', '1') - .set('pageSize', '500') - .set('sortBy', 'item') - .set('sortDir', 'asc'); - params = this.applyBaseFilters(params); - - if (search) params = params.set('search', search); - - this.http.get>(this.apiBase, { params: this.withNoCache(params) }).subscribe({ - next: (res) => { - if (requestVersion !== this.linesRequestVersion) return; - const filteredItems = this.applyAdditionalFiltersClientSide(res.items ?? []); - this.groupLines = filteredItems.map((x) => ({ - id: x.id, - item: String(x.item ?? ''), - linha: x.linha ?? '', - chip: x.chip ?? '', - cliente: x.cliente ?? '', - usuario: x.usuario ?? '', - centroDeCustos: x.centroDeCustos ?? '', - setorNome: x.setorNome ?? '', - aparelhoNome: x.aparelhoNome ?? '', - aparelhoCor: x.aparelhoCor ?? '', - status: x.status ?? '', - skil: x.skil ?? '', - contrato: x.vencConta ?? '' - })); - this.loadingLines = false; - }, - error: () => { - if (requestVersion !== this.linesRequestVersion) return; - this.loadingLines = false; - this.showToast('Erro ao carregar linhas do grupo.'); - } - }); + void this.fetchAllGroupLines(clientName, search, requestVersion); } toggleClientMenu() { @@ -1739,7 +2569,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } goToPage(p: number) { - this.page = Math.max(1, Math.min(this.totalPages, p)); + this.page = clampPage(p, this.totalPages); this.refreshData({ keepCurrentPage: true }); } @@ -1754,7 +2584,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { get totalPages() { if (this.selectedClients.length > 0) return 1; if (this.searchResolvedClient) return 1; - return Math.ceil((this.total || 0) / this.pageSize) || 1; + return computeTotalPages(this.total || 0, this.pageSize); } get filteredCount() { @@ -1762,7 +2592,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } get pageStart() { - return this.filteredCount === 0 ? 0 : (this.page - 1) * this.pageSize + 1; + return computePageStart(this.filteredCount, this.page, this.pageSize); } get pageEnd() { @@ -1780,15 +2610,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } get pageNumbers() { - const total = this.totalPages; - const current = this.page; - const max = 5; - let start = Math.max(1, current - 2); - let end = Math.min(total, start + (max - 1)); - start = Math.max(1, end - (max - 1)); - const pages: number[] = []; - for (let i = start; i <= end; i++) pages.push(i); - return pages; + return buildPageNumbers(this.page, this.totalPages); } clearSearch() { @@ -1800,6 +2622,250 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.refreshData(); } + async onExport(): Promise { + if (this.exporting) return; + this.exporting = true; + + try { + const baseRows = await this.getRowsForExport(); + const rows = await this.getDetailedRowsForExport(baseRows); + if (!rows.length) { + await this.showToast('Nenhum registro encontrado para exportar.'); + return; + } + + const suffix = this.getExportFilterSuffix(); + const timestamp = this.tableExportService.buildTimestamp(); + const fileName = `geral_${suffix}_${timestamp}`; + const templateBuffer = await this.getGeralTemplateBuffer(); + + await this.tableExportService.exportAsXlsx({ + fileName, + sheetName: 'Geral', + templateBuffer, + rows, + columns: [ + { header: 'ID', value: (row) => row.id }, + { header: 'Item', type: 'number', value: (row) => this.toInt(row.item) }, + { header: 'Empresa (Conta)', value: (row) => this.findEmpresaByConta(row.conta) }, + { header: 'Conta', value: (row) => row.conta ?? '' }, + { header: 'Linha', value: (row) => row.linha ?? '' }, + { header: 'Chip', value: (row) => row.chip ?? '' }, + { header: 'Tipo de Chip', value: (row) => row.tipoDeChip ?? '' }, + { header: 'Cliente', value: (row) => row.cliente ?? '' }, + { header: 'Usuario', value: (row) => row.usuario ?? '' }, + { header: 'Centro de Custos', value: (row) => row.centroDeCustos ?? '' }, + { header: 'Setor ID', value: (row) => row.setorId ?? '' }, + { header: 'Setor', value: (row) => row.setorNome ?? '' }, + { header: 'Aparelho ID', value: (row) => row.aparelhoId ?? '' }, + { header: 'Aparelho', value: (row) => row.aparelhoNome ?? '' }, + { header: 'Cor do Aparelho', value: (row) => row.aparelhoCor ?? '' }, + { header: 'IMEI do Aparelho', value: (row) => row.aparelhoImei ?? '' }, + { header: 'NF do Aparelho (anexo)', type: 'boolean', value: (row) => !!row.aparelhoNotaFiscalTemArquivo }, + { header: 'Recibo do Aparelho (anexo)', type: 'boolean', value: (row) => !!row.aparelhoReciboTemArquivo }, + { header: 'Plano Contrato', value: (row) => row.planoContrato ?? '' }, + { header: 'Status', value: (row) => row.status ?? '' }, + { header: 'Tipo (Skil)', value: (row) => row.skil ?? '' }, + { header: 'Modalidade', value: (row) => row.modalidade ?? '' }, + { header: 'Cedente', value: (row) => row.cedente ?? '' }, + { header: 'Solicitante', value: (row) => row.solicitante ?? '' }, + { header: 'Data de Bloqueio', type: 'date', value: (row) => row.dataBloqueio ?? '' }, + { header: 'Data Entrega Operadora', type: 'date', value: (row) => row.dataEntregaOpera ?? '' }, + { header: 'Data Entrega Cliente', type: 'date', value: (row) => row.dataEntregaCliente ?? '' }, + { header: 'Dt. Efetivacao Servico', type: 'date', value: (row) => row.dtEfetivacaoServico ?? '' }, + { header: 'Dt. Termino Fidelizacao', type: 'date', value: (row) => row.dtTerminoFidelizacao ?? '' }, + { header: 'Vencimento da Conta', value: (row) => row.vencConta ?? '' }, + { header: 'Franquia Vivo', type: 'number', value: (row) => this.toNullableNumber(row.franquiaVivo) ?? 0 }, + { header: 'Valor Plano Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorPlanoVivo) ?? 0 }, + { header: 'Gestao Voz e Dados', type: 'currency', value: (row) => this.toNullableNumber(row.gestaoVozDados) ?? 0 }, + { header: 'Skeelo', type: 'currency', value: (row) => this.toNullableNumber(row.skeelo) ?? 0 }, + { header: 'Vivo News Plus', type: 'currency', value: (row) => this.toNullableNumber(row.vivoNewsPlus) ?? 0 }, + { header: 'Vivo Travel Mundo', type: 'currency', value: (row) => this.toNullableNumber(row.vivoTravelMundo) ?? 0 }, + { header: 'Vivo Gestao Dispositivo', type: 'currency', value: (row) => this.toNullableNumber(row.vivoGestaoDispositivo) ?? 0 }, + { header: 'Vivo Sync', type: 'currency', value: (row) => this.toNullableNumber(row.vivoSync) ?? 0 }, + { header: 'Valor Contrato Vivo', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoVivo) ?? 0 }, + { header: 'Franquia Line', type: 'number', value: (row) => this.toNullableNumber(row.franquiaLine) ?? 0 }, + { header: 'Franquia Gestao', type: 'number', value: (row) => this.toNullableNumber(row.franquiaGestao) ?? 0 }, + { header: 'Locacao AP', type: 'currency', value: (row) => this.toNullableNumber(row.locacaoAp) ?? 0 }, + { header: 'Valor Contrato Line', type: 'currency', value: (row) => this.toNullableNumber(row.valorContratoLine) ?? 0 }, + { header: 'Desconto', type: 'currency', value: (row) => this.toNullableNumber(row.desconto) ?? 0 }, + { header: 'Lucro', type: 'currency', value: (row) => this.toNullableNumber(row.lucro) ?? 0 }, + { header: 'Criado Em', type: 'datetime', value: (row) => this.getAnyField(row, ['createdAt', 'CreatedAt']) ?? '' }, + { header: 'Atualizado Em', type: 'datetime', value: (row) => this.getAnyField(row, ['updatedAt', 'UpdatedAt']) ?? '' }, + ], + }); + + await this.showToast(`Planilha exportada com ${rows.length} registro(s).`); + } catch { + await this.showToast('Erro ao exportar a planilha.'); + } finally { + this.exporting = false; + } + } + + private async getRowsForExport(): Promise { + let lines = await this.fetchLinesForGrouping(); + + if (this.selectedClients.length > 0) { + const selected = new Set( + this.selectedClients.map((client) => (client ?? '').toString().trim().toUpperCase()) + ); + lines = lines.filter((line) => selected.has((line.cliente ?? '').toString().trim().toUpperCase())); + } + + const fallbackClient = this.getClientFallbackLabel('SEM CLIENTE'); + const mapped = lines.map((line) => ({ + id: (line.id ?? '').toString(), + item: String(line.item ?? ''), + linha: line.linha ?? '', + chip: line.chip ?? '', + cliente: ((line.cliente ?? '').toString().trim()) || fallbackClient, + usuario: line.usuario ?? '', + centroDeCustos: line.centroDeCustos ?? '', + setorNome: line.setorNome ?? '', + aparelhoNome: line.aparelhoNome ?? '', + aparelhoCor: line.aparelhoCor ?? '', + status: line.status ?? '', + skil: line.skil ?? '', + contrato: line.vencConta ?? '', + })); + + return mapped.sort((a, b) => { + const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' }); + if (byClient !== 0) return byClient; + + const byItem = this.toInt(a.item) - this.toInt(b.item); + if (byItem !== 0) return byItem; + + return (a.linha ?? '').localeCompare(b.linha ?? '', 'pt-BR', { sensitivity: 'base' }); + }); + } + + private async getDetailedRowsForExport(baseRows: LineRow[]): Promise { + if (!baseRows.length) return []; + + const result: ApiLineDetail[] = []; + const chunkSize = 8; + + for (let i = 0; i < baseRows.length; i += chunkSize) { + const chunk = baseRows.slice(i, i + chunkSize); + const fetched = await Promise.all( + chunk.map(async (row) => { + try { + return await firstValueFrom( + this.http.get(`${this.apiBase}/${row.id}`, { + params: this.withNoCache(new HttpParams()), + }) + ); + } catch { + return this.toDetailFallback(row); + } + }) + ); + + result.push(...fetched); + } + + return result; + } + + private toDetailFallback(row: LineRow): ApiLineDetail { + return { + id: row.id, + item: this.toInt(row.item), + qtdLinhas: null, + conta: row.contrato ?? null, + linha: row.linha ?? null, + chip: row.chip ?? null, + tipoDeChip: null, + cliente: row.cliente ?? null, + usuario: row.usuario ?? null, + centroDeCustos: row.centroDeCustos ?? null, + setorId: null, + setorNome: row.setorNome ?? null, + aparelhoId: null, + aparelhoNome: row.aparelhoNome ?? null, + aparelhoCor: row.aparelhoCor ?? null, + aparelhoImei: null, + aparelhoNotaFiscalTemArquivo: false, + aparelhoReciboTemArquivo: false, + planoContrato: null, + status: row.status ?? null, + skil: row.skil ?? null, + modalidade: null, + dataBloqueio: null, + cedente: null, + solicitante: null, + dataEntregaOpera: null, + dataEntregaCliente: null, + dtEfetivacaoServico: null, + dtTerminoFidelizacao: null, + vencConta: row.contrato ?? null, + franquiaVivo: null, + valorPlanoVivo: null, + gestaoVozDados: null, + skeelo: null, + vivoNewsPlus: null, + vivoTravelMundo: null, + vivoGestaoDispositivo: null, + vivoSync: null, + valorContratoVivo: null, + franquiaLine: null, + franquiaGestao: null, + locacaoAp: null, + valorContratoLine: null, + desconto: null, + lucro: null, + }; + } + + private async getGeralTemplateBuffer(): Promise { + try { + const params = new HttpParams().set('_', `${Date.now()}`); + const blob = await firstValueFrom( + this.http.get(`${this.templatesApiBase}/planilha-geral`, { + params, + responseType: 'blob', + }) + ); + return await blob.arrayBuffer(); + } catch { + return null; + } + } + + private getExportFilterSuffix(): string { + const parts: string[] = []; + + if (this.filterSkil === 'PF') parts.push('pf'); + else if (this.filterSkil === 'PJ') parts.push('pj'); + else if (this.filterSkil === 'RESERVA') parts.push('reservas'); + else if (this.filterSkil === 'ESTOQUE') parts.push('estoque'); + else parts.push('todas'); + + if (this.filterStatus === 'BLOCKED') { + if (this.blockedStatusMode === 'PERDA_ROUBO') parts.push('bloq-perda-roubo'); + else if (this.blockedStatusMode === 'BLOQUEIO_120') parts.push('bloq-120'); + else parts.push('bloqueadas'); + } + + if (this.additionalMode === 'WITH') parts.push('com-adicionais'); + else if (this.additionalMode === 'WITHOUT') parts.push('sem-adicionais'); + + if (this.selectedAdditionalServices.length > 0) { + parts.push(this.selectedAdditionalServices.join('-')); + } + + if (this.filterOperadora !== 'ALL') { + parts.push(`operadora-${this.filterOperadora.toLowerCase()}`); + } + if (this.filterContaEmpresa.trim()) { + parts.push(`empresa-${this.normalizeFilterToken(this.filterContaEmpresa).toLowerCase()}`); + } + + return parts.join('_'); + } + async onImportExcel() { if (!this.isSysAdmin) { await this.showToast('Você não tem permissão para importar planilha.'); @@ -1835,6 +2901,236 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { }); } + async openMveAuditModal() { + if (!this.canManageLines) { + await this.showToast('Você não tem permissão para auditar linhas com o relatório MVE.'); + return; + } + + this.mveAuditOpen = true; + this.mveAuditFile = null; + this.mveAuditResult = null; + this.mveAuditError = ''; + this.mveAuditFilter = 'ALL'; + this.mveAuditSearchTerm = ''; + this.mveAuditPage = 1; + this.mveAuditApplyMode = 'ALL_SYNCABLE'; + this.mveAuditApplyConfirmOpen = false; + this.mveAuditApplyLastResult = null; + this.cdr.detectChanges(); + } + + onMveAuditFileSelected(event: Event) { + const file = (event.target as HTMLInputElement | null)?.files?.[0] ?? null; + this.mveAuditError = ''; + this.mveAuditApplyLastResult = null; + + if (!file) { + this.mveAuditFile = null; + return; + } + + if (!file.name.toLowerCase().endsWith('.csv')) { + this.mveAuditFile = null; + this.mveAuditError = 'Selecione um arquivo CSV exportado do MVE da Vivo.'; + return; + } + + if (file.size <= 0) { + this.mveAuditFile = null; + this.mveAuditError = 'O arquivo selecionado está vazio.'; + return; + } + + if (file.size > 20 * 1024 * 1024) { + this.mveAuditFile = null; + this.mveAuditError = 'O arquivo do MVE excede o limite de 20 MB.'; + return; + } + + this.mveAuditFile = file; + } + + clearMveAuditFile() { + this.mveAuditFile = null; + this.mveAuditError = ''; + } + + async submitMveAudit() { + if (!this.canSubmitMveAudit || !this.mveAuditFile) { + return; + } + + this.mveAuditProcessing = true; + this.mveAuditError = ''; + this.mveAuditResult = null; + this.mveAuditApplyLastResult = null; + + try { + this.mveAuditResult = await firstValueFrom(this.mveAuditService.preview(this.mveAuditFile)); + this.mveAuditFilter = 'ALL'; + this.mveAuditSearchTerm = ''; + this.mveAuditPage = 1; + await this.showToast('Auditoria MVE processada com sucesso.'); + } catch (err) { + this.mveAuditError = this.extractHttpMessage(err, 'Não foi possível processar o relatório MVE.'); + } finally { + this.mveAuditProcessing = false; + this.cdr.detectChanges(); + } + } + + setMveAuditFilter(filter: MveAuditFilterMode) { + this.mveAuditFilter = filter; + this.mveAuditPage = 1; + } + + onMveAuditSearchChange() { + this.mveAuditPage = 1; + } + + onMveAuditPageSizeChange() { + this.mveAuditPage = 1; + } + + goToMveAuditPage(page: number) { + this.mveAuditPage = clampPage(page, this.mveAuditTotalPages); + } + + openMveApplyConfirm(mode: MveAuditApplyMode) { + if (!this.hasMveAuditResult) return; + this.mveAuditApplyMode = mode; + if (!this.canOpenMveApplyConfirm) return; + this.mveAuditApplyConfirmOpen = true; + } + + closeMveApplyConfirm() { + this.mveAuditApplyConfirmOpen = false; + } + + async confirmMveApply() { + if (!this.mveAuditResult || !this.canOpenMveApplyConfirm) { + return; + } + + const selectedIssues = this.selectedMveApplyIssues; + this.mveAuditApplying = true; + + try { + const result = await firstValueFrom( + this.mveAuditService.apply( + this.mveAuditResult.id, + this.mveAuditApplyMode === 'FILTERED_SYNCABLE' ? selectedIssues.map((issue) => issue.id) : undefined + ) + ); + + this.mveAuditApplyLastResult = result; + this.mveAuditResult = await firstValueFrom(this.mveAuditService.getById(this.mveAuditResult.id)); + this.mveAuditApplyConfirmOpen = false; + + const label = + result.updatedLines > 0 + ? `${result.updatedLines} linha(s) atualizada(s) com base no MVE.` + : 'Nenhuma linha precisou ser alterada com a sincronização MVE.'; + await this.showToast(label); + this.refreshData({ keepCurrentPage: true }); + } catch (err) { + await this.showToast(this.extractHttpMessage(err, 'Não foi possível aplicar a sincronização MVE.')); + } finally { + this.mveAuditApplying = false; + this.cdr.detectChanges(); + } + } + + async exportMveAuditIssues() { + if (!this.hasMveAuditResult || this.filteredMveAuditIssues.length === 0) { + await this.showToast('Não há inconsistências filtradas para exportar.'); + return; + } + + const headers = [ + 'Numero da linha', + 'Item sistema', + 'Situacao', + 'Tipo', + 'Status sistema', + 'Status relatorio', + 'Plano sistema', + 'Plano relatorio', + 'Diferencas', + 'Acao sugerida', + 'Observacoes', + ]; + + const rows = this.filteredMveAuditIssues.map((issue) => + [ + issue.numeroLinha || '', + issue.systemItem != null ? String(issue.systemItem) : '', + issue.situation || '', + issue.issueType || '', + issue.systemStatus || '', + issue.reportStatus || '', + issue.systemPlan || '', + issue.reportPlan || '', + this.describeMveDifferences(issue), + issue.actionSuggestion || '', + issue.notes || '', + ] + .map((value) => this.escapeCsvValue(value)) + .join(';') + ); + + const content = `${headers.join(';')}\n${rows.join('\n')}`; + const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' }); + const timestamp = this.tableExportService.buildTimestamp(); + this.downloadBlob(blob, `mve_auditoria_${timestamp}.csv`); + await this.showToast(`CSV exportado com ${rows.length} inconsistência(s).`); + } + + describeMveDifferences(issue: MveAuditIssue): string { + const differences = issue.differences ?? []; + if (!differences.length) { + return issue.notes ?? '-'; + } + + return differences + .map((diff) => `${diff.label}: ${diff.systemValue ?? '-'} -> ${diff.reportValue ?? '-'}`) + .join(' | '); + } + + getMveIssueTagClass(issue: MveAuditIssue): string { + switch (issue.issueType) { + case 'STATUS_DIVERGENCE': + case 'STATUS_AND_DATA_DIVERGENCE': + return 'status'; + case 'CHIP_CHANGE_DETECTED': + case 'LINE_CHANGE_DETECTED': + case 'DATA_DIVERGENCE': + return 'data'; + case 'ONLY_IN_SYSTEM': + return 'system'; + case 'ONLY_IN_REPORT': + return 'report'; + case 'DUPLICATE_REPORT': + case 'DUPLICATE_SYSTEM': + return 'duplicate'; + case 'INVALID_ROW': + case 'DDD_CHANGE_REVIEW': + case 'UNKNOWN_STATUS': + return 'warning'; + default: + return 'neutral'; + } + } + + getMveSeverityClass(severity: string | null | undefined): string { + const normalized = (severity ?? '').toString().trim().toUpperCase(); + if (normalized === 'HIGH') return 'critical'; + if (normalized === 'MEDIUM') return 'medium'; + if (normalized === 'WARNING') return 'warning'; + return 'neutral'; + } + private getById(id: string, cb: (d: any) => void) { this.http.get(`${this.apiBase}/${id}`).subscribe({ next: cb, @@ -1847,7 +3143,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.detailData = null; this.cdr.detectChanges(); this.getById(r.id, (d) => { - this.detailData = d; + this.detailData = { + ...d, + contaEmpresa: this.findEmpresaByConta(d?.conta) + }; + this.syncContaEmpresaSelection(this.detailData); this.cdr.detectChanges(); }); } @@ -1863,8 +3163,14 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async onEditar(r: LineRow) { + if (this.isFinanceiro) { + await this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + this.editOpen = true; this.editSaving = false; + this.requestSaving = false; this.editModel = null; this.aparelhoNotaFiscalFile = null; this.aparelhoReciboFile = null; @@ -1942,11 +3248,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async saveEdit() { - if (!this.editingId || !this.editModel) return; + if (this.isFinanceiro) { + await this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + if (!this.editingId || !this.editModel || this.requestSaving) return; this.editSaving = true; const editingId = this.editingId; const shouldUploadAttachments = !!(this.aparelhoNotaFiscalFile || this.aparelhoReciboFile); + const franquiaLineAtual = this.toNullableNumber(this.editModel.franquiaLine); + const franquiaLineSolicitada = this.toNullableNumber(this.editModel.franquiaLineSolicitada); + const shouldRequestFranquia = + this.isClientRestricted && !this.nullableNumberEquals(franquiaLineAtual, franquiaLineSolicitada); let payload: UpdateMobileLineRequest; if (this.isClientRestricted) { @@ -1954,15 +3268,28 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { item: this.toInt(this.editModel.item), usuario: (this.editModel.usuario ?? '').toString(), centroDeCustos: (this.editModel.centroDeCustos ?? '').toString(), + setorNome: (this.editModel.setorNome ?? '').toString(), aparelhoId: (this.editModel.aparelhoId ?? null) as string | null, aparelhoNome: (this.editModel.aparelhoNome ?? '').toString(), aparelhoCor: (this.editModel.aparelhoCor ?? '').toString(), - aparelhoImei: (this.editModel.aparelhoImei ?? '').toString() + aparelhoImei: (this.editModel.aparelhoImei ?? '').toString(), + franquiaLine: franquiaLineAtual }; } else { + const contaEmpresaValidationMessage = this.validateContaEmpresaBinding(this.editModel); + if (contaEmpresaValidationMessage) { + this.editSaving = false; + await this.showToast(contaEmpresaValidationMessage); + return; + } + this.calculateFinancials(this.editModel); - const { contaEmpresa: _contaEmpresa, ...editModelPayload } = this.editModel; + const { + contaEmpresa: _contaEmpresa, + franquiaLineSolicitada: _franquiaLineSolicitada, + ...editModelPayload + } = this.editModel; payload = { ...editModelPayload, @@ -2004,12 +3331,48 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return; } + if (shouldRequestFranquia) { + try { + await firstValueFrom(this.solicitacoesLinhasService.create({ + lineId: editingId, + tipoSolicitacao: 'alteracao-franquia', + franquiaLineNova: franquiaLineSolicitada + })); + } catch (err) { + this.editSaving = false; + this.closeAllModals(); + const msg = + (err as HttpErrorResponse)?.error?.message + || 'Registro atualizado, mas falhou ao enviar a solicitação de franquia.'; + await this.showToast(msg); + + if (this.isGroupMode && this.expandedGroup) { + const term = (this.searchTerm ?? '').trim(); + const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; + this.fetchGroupLines(this.expandedGroup, useTerm); + this.loadGroups(); + this.loadKpis(); + } else { + this.refreshData(); + } + return; + } + } + this.editSaving = false; // fecha e limpa overlay SEMPRE this.closeAllModals(); - await this.showToast(shouldUploadAttachments ? 'Registro e anexos atualizados!' : 'Registro atualizado!'); + if (shouldRequestFranquia) { + await this.showToast( + shouldUploadAttachments + ? 'Registro e anexos atualizados! Solicitação de franquia enviada.' + : 'Registro atualizado! Solicitação de franquia enviada.' + ); + } else { + await this.showToast(shouldUploadAttachments ? 'Registro e anexos atualizados!' : 'Registro atualizado!'); + } if (this.isGroupMode && this.expandedGroup) { const term = (this.searchTerm ?? '').trim(); @@ -2029,6 +3392,31 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { }); } + async requestLineBlock() { + if (!this.isClientRestricted) { + await this.showToast('Somente cliente pode solicitar bloqueio por essa ação.'); + return; + } + + if (!this.editingId || this.requestSaving) return; + + this.requestSaving = true; + try { + await firstValueFrom(this.solicitacoesLinhasService.create({ + lineId: this.editingId, + tipoSolicitacao: 'bloqueio' + })); + + this.requestSaving = false; + this.closeAllModals(); + await this.showToast('Solicitação de bloqueio enviada.'); + } catch (err) { + this.requestSaving = false; + const msg = (err as HttpErrorResponse)?.error?.message || 'Erro ao enviar solicitação de bloqueio.'; + await this.showToast(msg); + } +} + onAparelhoNotaFiscalSelected(event: Event) { const input = event.target as HTMLInputElement | null; this.aparelhoNotaFiscalFile = input?.files && input.files.length > 0 ? input.files[0] : null; @@ -2172,7 +3560,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async onCadastrarLinha() { - if (this.isClientRestricted) { + if (!this.canManageLines) { await this.showToast('Você não tem permissão para cadastrar novos clientes.'); return; } @@ -2185,7 +3573,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } async onAddLineToGroup(clientName: string) { - if (this.isClientRestricted) { + if (!this.canManageLines) { await this.showToast('Você não tem permissão para adicionar linhas.'); return; } @@ -2193,10 +3581,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.createMode = 'NEW_LINE_IN_GROUP'; this.resetCreateModel(); - this.createModel.cliente = clientName; + this.createModel.cliente = this.isStockClientName(clientName) ? 'RESERVA' : clientName; if (this.filterSkil === 'PJ') this.createModel.skil = 'PESSOA JURÍDICA'; - else if (this.filterSkil === 'RESERVA') this.createModel.skil = 'RESERVA'; + else if (this.isReserveContextFilter()) this.createModel.skil = 'RESERVA'; this.syncContaEmpresaSelection(this.createModel); @@ -2981,6 +4369,16 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { if (!hasMatch) model.conta = ''; } + onContaChange(isEdit: boolean) { + const model = isEdit ? this.editModel : this.createModel; + this.syncContaEmpresaSelection(model); + } + + onBatchContaChange(row: any) { + this.syncContaEmpresaSelection(row); + this.onBatchLineDetailsChange(); + } + onDocTypeChange() { this.createModel.docNumber = ''; this.createModel.skil = this.createModel.docType === 'PF' ? 'PESSOA FÍSICA' : 'PESSOA JURÍDICA'; @@ -3167,6 +4565,116 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.reservaSelectedLineIds = []; } + async openBatchStatusModal(action: BatchStatusAction) { + if (!this.canManageLines) { + await this.showToast('Você não tem permissão para bloquear/desbloquear em lote.'); + return; + } + + if (this.batchStatusSelectionCount <= 0) { + await this.showToast('Selecione ao menos uma linha para processar.'); + return; + } + + this.batchStatusAction = action; + this.batchStatusSaving = false; + this.batchStatusLastResult = null; + this.batchStatusUsuario = ''; + + if (action === 'BLOCK') { + const current = (this.batchStatusType ?? '').toString().trim(); + const options = this.blockedStatusOptions; + if (!current || !options.some((x) => x === current)) { + this.batchStatusType = options[0] ?? ''; + } + } else { + this.batchStatusType = ''; + } + + this.batchStatusOpen = true; + this.cdr.detectChanges(); + } + + async submitBatchStatusUpdate() { + if (this.batchStatusSaving) return; + if (!this.canSubmitBatchStatusModal) return; + + const payload = this.buildBatchStatusPayload(); + this.batchStatusSaving = true; + + this.http.post(`${this.apiBase}/batch-status-update`, payload).subscribe({ + next: async (res) => { + this.batchStatusSaving = false; + this.batchStatusLastResult = res; + + const ok = Number(res?.updated ?? 0) || 0; + const failed = Number(res?.failed ?? 0) || 0; + + this.batchStatusOpen = false; + this.clearReservaSelection(); + this.batchStatusUsuario = ''; + + await this.showToast( + failed > 0 + ? `${this.batchStatusActionLabel} em lote concluído com pendências: ${ok} linha(s) processada(s), ${failed} falha(s).` + : `${this.batchStatusActionLabel} em lote concluído: ${ok} linha(s) processada(s).` + ); + + if (this.expandedGroup) { + const term = (this.searchTerm ?? '').trim(); + const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; + this.fetchGroupLines(this.expandedGroup, useTerm); + } + + this.loadGroups(); + this.loadKpis(); + }, + error: async (err: HttpErrorResponse) => { + this.batchStatusSaving = false; + const msg = (err.error as any)?.message || 'Erro ao processar bloqueio/desbloqueio em lote.'; + await this.showToast(msg); + } + }); + } + + private buildBatchStatusPayload(): BatchLineStatusUpdateRequestDto { + const clients = this.searchResolvedClient + ? [this.searchResolvedClient] + : [...this.selectedClients]; + + const normalizedClients = clients + .map((x) => (x ?? '').toString().trim()) + .filter((x) => !!x); + + const userFilter = (this.batchStatusUsuario ?? '').toString().trim(); + + return { + action: this.batchStatusAction === 'BLOCK' ? 'block' : 'unblock', + blockStatus: this.batchStatusAction === 'BLOCK' ? (this.batchStatusType || null) : null, + applyToAllFiltered: false, + lineIds: [...this.reservaSelectedLineIds], + search: (this.searchTerm ?? '').toString().trim() || null, + skil: this.resolveFilterSkilForApi(), + clients: normalizedClients, + additionalMode: this.resolveAdditionalModeForApi(), + additionalServices: this.selectedAdditionalServices.length > 0 ? this.selectedAdditionalServices.join(',') : null, + usuario: userFilter || null + }; + } + + private resolveFilterSkilForApi(): string | null { + if (this.filterSkil === 'PF') return 'PESSOA FÍSICA'; + if (this.filterSkil === 'PJ') return 'PESSOA JURÍDICA'; + if (this.isReserveContextFilter()) return 'RESERVA'; + return null; + } + + private resolveAdditionalModeForApi(): string | null { + if (this.additionalMode === 'WITH') return 'with'; + if (this.additionalMode === 'WITHOUT') return 'without'; + return null; + } + async openReservaTransferModal() { if (!this.isReservaExpandedGroup) { await this.showToast('Abra um grupo no filtro Reserva para selecionar e atribuir linhas.'); @@ -3233,8 +4741,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return; } - if (clienteDestino.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0) { - await this.showToast('O cliente de destino não pode ser RESERVA.'); + if ( + clienteDestino.localeCompare('RESERVA', 'pt-BR', { sensitivity: 'base' }) === 0 || + clienteDestino.localeCompare('ESTOQUE', 'pt-BR', { sensitivity: 'base' }) === 0 + ) { + await this.showToast('O cliente de destino não pode ser RESERVA/ESTOQUE.'); return; } @@ -3555,6 +5066,73 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } } + private matchesMveIssueFilter(issue: MveAuditIssue): boolean { + switch (this.mveAuditFilter) { + case 'STATUS': + return this.issueHasStatusDifference(issue); + case 'DATA': + return this.issueHasDataDifference(issue); + case 'ONLY_IN_SYSTEM': + return issue.issueType === 'ONLY_IN_SYSTEM'; + case 'ONLY_IN_REPORT': + return issue.issueType === 'ONLY_IN_REPORT'; + case 'DUPLICATES': + return issue.issueType === 'DUPLICATE_REPORT' || issue.issueType === 'DUPLICATE_SYSTEM'; + case 'INVALID': + return issue.issueType === 'INVALID_ROW'; + case 'UNKNOWN': + return issue.issueType === 'UNKNOWN_STATUS'; + default: + return true; + } + } + + private issueHasStatusDifference(issue: MveAuditIssue): boolean { + return (issue.differences ?? []).some((difference) => difference.fieldKey === 'status'); + } + + private issueHasDataDifference(issue: MveAuditIssue): boolean { + return (issue.differences ?? []).some( + (difference) => difference.fieldKey !== 'status' && difference.syncable + ); + } + + private normalizeMveSearchTerm(value: unknown): string { + return (value ?? '') + .toString() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); + } + + private escapeCsvValue(value: unknown): string { + const text = (value ?? '').toString().replace(/"/g, '""'); + return `"${text}"`; + } + + private downloadBlob(blob: Blob, fileName: string) { + if (!isPlatformBrowser(this.platformId)) return; + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } + + private extractHttpMessage(error: unknown, fallbackMessage: string): string { + const httpError = error as HttpErrorResponse | null; + return ( + (httpError?.error as { message?: string } | null)?.message || + httpError?.message || + fallbackMessage + ); + } + formatMoney(v: any): string { if (v == null || Number.isNaN(v)) return '-'; return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v); @@ -3588,6 +5166,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return v || '-'; } + private isActiveStatus(status: string | null | undefined): boolean { + const normalized = (status ?? '').toString().trim().toLowerCase(); + if (!normalized) return false; + return normalized.includes('ativo'); + } + private toEditModel(d: ApiLineDetail): any { return { ...d, @@ -3618,6 +5202,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { valorContratoVivo: d.valorContratoVivo ?? null, franquiaLine: d.franquiaLine ?? null, + franquiaLineSolicitada: d.franquiaLine ?? null, franquiaGestao: d.franquiaGestao ?? null, locacaoAp: d.locacaoAp ?? null, valorContratoLine: d.valorContratoLine ?? null, @@ -3660,6 +5245,21 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return Number.isNaN(n) ? null : n; } + private nullableNumberEquals(a: number | null, b: number | null): boolean { + if (a === null || b === null) return a === b; + return Math.abs(a - b) < 0.000001; + } + + private getAnyField(row: unknown, keys: string[]): unknown { + const source = row as Record; + for (const key of keys) { + if (source && source[key] !== undefined && source[key] !== null && source[key] !== '') { + return source[key]; + } + } + return null; + } + private mergeOption(current: any, list: string[]): string[] { const v = (current ?? '').toString().trim(); if (!v) return list; @@ -3711,34 +5311,91 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return found ? [...found.contas] : []; } - private findEmpresaByConta(conta: any): string { - const target = this.normalizeConta(conta); - if (!target) return ''; + private getContaEmpresaOptionsByOperadora(mode: OperadoraFilterMode): string[] { + const empresas = this.mergeOptionList([], this.accountCompanies.map((group) => group?.empresa ?? '')) + .filter((empresa) => !!(empresa ?? '').toString().trim()); - const found = this.accountCompanies.find((group) => - (group.contas ?? []).some((c) => this.sameConta(c, target)) - ); - return found?.empresa ?? ''; + const filtered = mode === 'ALL' + ? empresas + : empresas.filter((empresa) => { + const operadora = resolveOperadoraContext({ + empresaConta: empresa, + accountCompanies: this.accountCompanies, + }).operadora; + return operadora === mode; + }); + + return filtered.sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })); + } + + private syncContaEmpresaFilterByOperadora(): void { + const selected = this.filterContaEmpresa.trim(); + if (!selected) return; + + const available = this.getContaEmpresaOptionsByOperadora(this.filterOperadora); + const normalizedSelected = this.normalizeFilterToken(selected); + const hasSelected = available.some((empresa) => this.normalizeFilterToken(empresa) === normalizedSelected); + + if (!hasSelected) { + this.filterContaEmpresa = ''; + } + } + + private findEmpresaByConta(conta: any): string { + return resolveEmpresaByConta(conta, this.accountCompanies); } private normalizeConta(value: any): string { - const raw = (value ?? '').toString().trim(); - if (!raw) return ''; - if (!/^\d+$/.test(raw)) return raw.toUpperCase(); - const noLeadingZero = raw.replace(/^0+/, ''); - return noLeadingZero || '0'; + return normalizeContaValue(value); } private sameConta(a: any, b: any): boolean { - return this.normalizeConta(a) === this.normalizeConta(b); + return sameContaValue(a, b); } private syncContaEmpresaSelection(model: any) { if (!model) return; + const contaAtual = (model.conta ?? '').toString().trim(); const empresaAtual = (model.contaEmpresa ?? '').toString().trim(); - if (empresaAtual) return; + if (!contaAtual) { + if (!empresaAtual) { + model.contaEmpresa = ''; + } + return; + } - model.contaEmpresa = this.findEmpresaByConta(model.conta); + const empresaPorConta = this.findEmpresaByConta(contaAtual); + if (empresaPorConta) { + model.contaEmpresa = empresaPorConta; + return; + } + + if (!empresaAtual) { + model.contaEmpresa = ''; + } + } + + private validateContaEmpresaBinding(model: any): string | null { + if (!model) return 'Dados da linha inválidos.'; + + this.syncContaEmpresaSelection(model); + + const conta = (model.conta ?? '').toString().trim(); + const contaEmpresa = (model.contaEmpresa ?? '').toString().trim(); + + if (!contaEmpresa) return 'Selecione a Empresa (Conta).'; + if (!conta) return 'Selecione uma Conta.'; + + const empresaPorConta = this.findEmpresaByConta(conta); + if (!empresaPorConta) { + return 'A conta informada não está vinculada a nenhuma Empresa (Conta) cadastrada.'; + } + + if (empresaPorConta.localeCompare(contaEmpresa, 'pt-BR', { sensitivity: 'base' }) !== 0) { + model.contaEmpresa = empresaPorConta; + } + + return null; } } diff --git a/src/app/pages/historico-chips/historico-chips.html b/src/app/pages/historico-chips/historico-chips.html new file mode 100644 index 0000000..be22f7c --- /dev/null +++ b/src/app/pages/historico-chips/historico-chips.html @@ -0,0 +1,274 @@ +
+ +
+ +
+ + + + + +
+
+
+
+
+ Chip +
+ +
+
Histórico de Chips
+ Timeline das alterações feitas em um chip específico. +
+ +
+ + +
+
+ +
+
+
+ + Filtros +
+
+ + +
+
+ +
+
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+ Eventos (filtro) + {{ total }} +
+
+ Trocas de Chip (página) + {{ chipCountInPage }} +
+
+ Trocas de Número (página) + {{ trocaCountInPage }} +
+
+ Status (página) + {{ statusCountInPage }} +
+
+
+ +
+
+
+ +
+ + + +
+ Nenhuma alteração encontrada para os filtros informados. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Data/HoraUsuárioOrigemAçãoResumo da alteraçãoDetalhes
{{ formatDateTime(log.occurredAtUtc) }} +
+ {{ displayUserName(log) }} + {{ log.userEmail || '-' }} +
+
+ {{ log.page || '-' }} + + {{ formatAction(log.action) }} + + +
{{ summary.title }}
+
{{ summary.description }}
+
+ {{ formatChangeValue(summary.before) }} + + {{ formatChangeValue(summary.after) }} +
+
+ DDD: {{ formatChangeValue(summary.beforeDdd) }} {{ formatChangeValue(summary.afterDdd) }} +
+
+
+ +
+
+
+
+ Mudanças de campos +
+ +
+
+
+ {{ change.field }} + + {{ changeTypeLabel(change.changeType) }} + +
+
+ {{ formatChangeValue(change.oldValue) }} + + {{ formatChangeValue(change.newValue) }} +
+
+
+
+ +
Sem mudanças detalhadas nesse evento.
+
+
+
+
+
+
+ + +
+
+
diff --git a/src/app/pages/historico-chips/historico-chips.ts b/src/app/pages/historico-chips/historico-chips.ts new file mode 100644 index 0000000..b7a637b --- /dev/null +++ b/src/app/pages/historico-chips/historico-chips.ts @@ -0,0 +1,554 @@ +import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PLATFORM_ID } from '@angular/core'; +import { CommonModule, isPlatformBrowser } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { + HistoricoService, + AuditLogDto, + AuditChangeType, + AuditFieldChangeDto, + ChipHistoricoQuery +} from '../../services/historico.service'; +import { TableExportService } from '../../services/table-export.service'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; + +interface SelectOption { + value: string; + label: string; +} + +type EventTone = 'mureg' | 'troca' | 'status' | 'linha' | 'chip' | 'generic'; + +interface EventSummary { + title: string; + description: string; + before?: string | null; + after?: string | null; + beforeDdd?: string | null; + afterDdd?: string | null; + tone: EventTone; +} + +@Component({ + selector: 'app-historico-chips', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent], + templateUrl: './historico-chips.html', + styleUrls: ['../historico-linhas/historico-linhas.scss'], +}) +export class HistoricoChips implements OnInit { + @ViewChild('successToast', { static: false }) successToast!: ElementRef; + + logs: AuditLogDto[] = []; + loading = false; + exporting = false; + error = false; + errorMsg = ''; + toastMessage = ''; + + expandedLogId: string | null = null; + + page = 1; + pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; + total = 0; + + filterChip = ''; + filterPageName = ''; + filterAction = ''; + filterUser = ''; + dateFrom = ''; + dateTo = ''; + + readonly pageOptions: SelectOption[] = [ + { value: '', label: 'Todas as origens' }, + { value: 'Geral', label: 'Geral' }, + { value: 'Troca de número', label: 'Troca de número' }, + { value: 'Chips Virgens e Recebidos', label: 'Chips Virgens e Recebidos' }, + ]; + + readonly actionOptions: SelectOption[] = [ + { value: '', label: 'Todas as ações' }, + { value: 'CREATE', label: 'Criação' }, + { value: 'UPDATE', label: 'Atualização' }, + { value: 'DELETE', label: 'Exclusão' }, + ]; + + private readonly summaryCache = new Map(); + private readonly idFieldExceptions = new Set(['iccid']); + + constructor( + private readonly historicoService: HistoricoService, + private readonly cdr: ChangeDetectorRef, + @Inject(PLATFORM_ID) private readonly platformId: object, + private readonly tableExportService: TableExportService + ) {} + + ngOnInit(): void { + this.fetch(); + } + + applyFilters(): void { + this.page = 1; + this.fetch(); + } + + refresh(): void { + this.fetch(); + } + + clearFilters(): void { + this.filterChip = ''; + this.filterPageName = ''; + this.filterAction = ''; + this.filterUser = ''; + this.dateFrom = ''; + this.dateTo = ''; + this.page = 1; + this.logs = []; + this.total = 0; + this.error = false; + this.errorMsg = ''; + this.summaryCache.clear(); + this.fetch(); + } + + onPageSizeChange(): void { + this.page = 1; + this.fetch(); + } + + goToPage(target: number): void { + this.page = clampPage(target, this.totalPages); + this.fetch(); + } + + toggleDetails(log: AuditLogDto, event?: Event): void { + if (event) event.stopPropagation(); + this.expandedLogId = this.expandedLogId === log.id ? null : log.id; + } + + async onExport(): Promise { + if (this.exporting) return; + + this.exporting = true; + try { + const allLogs = await this.fetchAllLogsForExport(); + if (!allLogs.length) { + await this.showToast('Nenhum evento encontrado para exportar.'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `historico_chips_${timestamp}`, + sheetName: 'HistoricoChips', + rows: allLogs, + columns: [ + { header: 'Data/Hora', type: 'datetime', value: (log) => log.occurredAtUtc ?? '' }, + { header: 'Usuario', value: (log) => this.displayUserName(log) }, + { header: 'E-mail', value: (log) => log.userEmail ?? '' }, + { header: 'Origem', value: (log) => log.page ?? '' }, + { header: 'Acao', value: (log) => this.formatAction(log.action) }, + { header: 'Evento', value: (log) => this.summaryFor(log).title }, + { header: 'Resumo', value: (log) => this.summaryFor(log).description }, + { header: 'Valor Anterior', value: (log) => this.summaryFor(log).before ?? '' }, + { header: 'Valor Novo', value: (log) => this.summaryFor(log).after ?? '' }, + { header: 'Mudancas', value: (log) => this.formatChangesSummary(log) }, + ], + }); + + await this.showToast(`Planilha exportada com ${allLogs.length} evento(s).`); + } catch { + await this.showToast('Erro ao exportar histórico de chips.'); + } finally { + this.exporting = false; + } + } + + formatDateTime(value?: string | null): string { + if (!value) return '-'; + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) return '-'; + return dt.toLocaleString('pt-BR'); + } + + displayUserName(log: AuditLogDto): string { + const name = (log.userName || '').trim(); + return name ? name : 'SISTEMA'; + } + + formatAction(action?: string | null): string { + const value = (action || '').toUpperCase(); + if (!value) return '-'; + if (value === 'CREATE') return 'Criação'; + if (value === 'UPDATE') return 'Atualização'; + if (value === 'DELETE') return 'Exclusão'; + return 'Outro'; + } + + actionClass(action?: string | null): string { + const value = (action || '').toUpperCase(); + if (value === 'CREATE') return 'action-create'; + if (value === 'UPDATE') return 'action-update'; + if (value === 'DELETE') return 'action-delete'; + return 'action-default'; + } + + changeTypeLabel(type?: AuditChangeType | string | null): string { + if (!type) return 'Alterado'; + if (type === 'added') return 'Adicionado'; + if (type === 'removed') return 'Removido'; + return 'Alterado'; + } + + changeTypeClass(type?: AuditChangeType | string | null): string { + if (type === 'added') return 'change-added'; + if (type === 'removed') return 'change-removed'; + return 'change-modified'; + } + + formatChangeValue(value?: string | null): string { + if (value === undefined || value === null || value === '') return '-'; + return String(value); + } + + summaryFor(log: AuditLogDto): EventSummary { + const cached = this.summaryCache.get(log.id); + if (cached) return cached; + const summary = this.buildEventSummary(log); + this.summaryCache.set(log.id, summary); + return summary; + } + + toneClass(tone: EventTone): string { + return `tone-${tone}`; + } + + trackByLog(_: number, log: AuditLogDto): string { + return log.id; + } + + trackByField(_: number, change: AuditFieldChangeDto): string { + return `${change.field}-${change.oldValue ?? ''}-${change.newValue ?? ''}`; + } + + visibleChanges(log: AuditLogDto): AuditFieldChangeDto[] { + return this.publicChanges(log); + } + + get normalizedChipTerm(): string { + return (this.filterChip || '').trim(); + } + + get hasChipFilter(): boolean { + return !!this.normalizedChipTerm; + } + + get totalPages(): number { + return computeTotalPages(this.total || 0, this.pageSize); + } + + get pageNumbers(): number[] { + return buildPageNumbers(this.page, this.totalPages); + } + + get pageStart(): number { + return computePageStart(this.total || 0, this.page, this.pageSize); + } + + get pageEnd(): number { + return computePageEnd(this.total || 0, this.page, this.pageSize); + } + + get chipCountInPage(): number { + return this.logs.filter((log) => this.summaryFor(log).tone === 'chip').length; + } + + get trocaCountInPage(): number { + return this.logs.filter((log) => this.summaryFor(log).tone === 'troca').length; + } + + get statusCountInPage(): number { + return this.logs.filter((log) => this.summaryFor(log).tone === 'status').length; + } + + private fetch(): void { + this.loading = true; + this.error = false; + this.errorMsg = ''; + this.expandedLogId = null; + + const query: ChipHistoricoQuery = { + ...this.buildBaseQuery(), + chip: this.normalizedChipTerm || undefined, + page: this.page, + pageSize: this.pageSize, + }; + + this.historicoService.listByChip(query).subscribe({ + next: (res) => { + this.logs = res.items || []; + this.total = res.total || 0; + this.page = res.page || this.page; + this.pageSize = res.pageSize || this.pageSize; + this.loading = false; + this.rebuildSummaryCache(); + }, + error: (err: HttpErrorResponse) => { + this.loading = false; + this.error = true; + this.logs = []; + this.total = 0; + this.summaryCache.clear(); + if (err?.status === 403) { + this.errorMsg = 'Acesso restrito.'; + return; + } + this.errorMsg = 'Erro ao carregar histórico do chip. Tente novamente.'; + } + }); + } + + private async fetchAllLogsForExport(): Promise { + const pageSize = 500; + let page = 1; + let expectedTotal = 0; + const all: AuditLogDto[] = []; + + while (page <= 500) { + const response = await firstValueFrom( + this.historicoService.listByChip({ + ...this.buildBaseQuery(), + chip: this.normalizedChipTerm || undefined, + page, + pageSize, + }) + ); + + const items = response?.items ?? []; + expectedTotal = response?.total ?? 0; + all.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && all.length >= expectedTotal) break; + page += 1; + } + + return all; + } + + private buildBaseQuery(): Omit { + return { + pageName: this.filterPageName || undefined, + action: this.filterAction || undefined, + user: this.filterUser?.trim() || undefined, + dateFrom: this.toIsoDate(this.dateFrom, false) || undefined, + dateTo: this.toIsoDate(this.dateTo, true) || undefined, + }; + } + + private rebuildSummaryCache(): void { + this.summaryCache.clear(); + this.logs.forEach((log) => { + this.summaryCache.set(log.id, this.buildEventSummary(log)); + }); + } + + private buildEventSummary(log: AuditLogDto): EventSummary { + const page = (log.page || '').toLowerCase(); + const entity = (log.entityName || '').toLowerCase(); + + const linhaChange = this.findChange(log, 'linha'); + const statusChange = this.findChange(log, 'status'); + const chipChange = this.findChange(log, 'chip', 'iccid', 'numerodochip'); + const linhaAntiga = this.findChange(log, 'linhaantiga'); + const linhaNova = this.findChange(log, 'linhanova'); + + const trocaLike = entity === 'trocanumeroline' || page.includes('troca'); + if (trocaLike) { + const before = this.firstFilled(linhaAntiga?.newValue, linhaAntiga?.oldValue, linhaChange?.oldValue); + const after = this.firstFilled(linhaNova?.newValue, linhaNova?.oldValue, linhaChange?.newValue); + return { + title: 'Troca de Número', + description: 'Linha antiga substituída por uma nova.', + before, + after, + beforeDdd: this.extractDdd(before), + afterDdd: this.extractDdd(after), + tone: 'troca', + }; + } + + if (chipChange) { + return { + title: 'Alteração de Chip', + description: 'ICCID/chip atualizado na linha.', + before: this.firstFilled(chipChange.oldValue), + after: this.firstFilled(chipChange.newValue), + tone: 'chip', + }; + } + + if (statusChange) { + const oldStatus = this.firstFilled(statusChange.oldValue); + const newStatus = this.firstFilled(statusChange.newValue); + const wasBlocked = this.isBlockedStatus(oldStatus); + const isBlocked = this.isBlockedStatus(newStatus); + let description = 'Status da linha atualizado.'; + if (!wasBlocked && isBlocked) description = 'Linha foi bloqueada.'; + if (wasBlocked && !isBlocked) description = 'Linha foi desbloqueada.'; + return { + title: 'Status da Linha', + description, + before: oldStatus, + after: newStatus, + tone: 'status', + }; + } + + if (linhaChange) { + return { + title: 'Alteração da Linha', + description: 'Número da linha foi atualizado.', + before: this.firstFilled(linhaChange.oldValue), + after: this.firstFilled(linhaChange.newValue), + beforeDdd: this.extractDdd(linhaChange.oldValue), + afterDdd: this.extractDdd(linhaChange.newValue), + tone: 'linha', + }; + } + + const first = this.publicChanges(log)[0]; + if (first) { + return { + title: 'Outras alterações', + description: `Campo ${first.field} foi atualizado.`, + before: this.firstFilled(first.oldValue), + after: this.firstFilled(first.newValue), + tone: 'generic', + }; + } + + return { + title: 'Sem detalhes', + description: 'Não há mudanças detalhadas registradas para este evento.', + tone: 'generic', + }; + } + + private findChange(log: AuditLogDto, ...fields: string[]): AuditFieldChangeDto | null { + if (!fields.length) return null; + const normalizedTargets = new Set(fields.map((field) => this.normalizeField(field))); + return (log.changes || []).find((change) => normalizedTargets.has(this.normalizeField(change.field))) || null; + } + + private normalizeField(value?: string | null): string { + return (value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() + .trim(); + } + + private firstFilled(...values: Array): string | null { + for (const value of values) { + const normalized = (value ?? '').toString().trim(); + if (normalized) return normalized; + } + return null; + } + + private formatChangesSummary(log: AuditLogDto): string { + const changes = this.publicChanges(log); + if (!changes.length) return ''; + return changes + .map((change) => { + const field = change?.field ?? 'campo'; + const oldValue = this.formatChangeValue(change?.oldValue); + const newValue = this.formatChangeValue(change?.newValue); + return `${field}: ${oldValue} -> ${newValue}`; + }) + .join(' | '); + } + + private publicChanges(log: AuditLogDto): AuditFieldChangeDto[] { + return (log?.changes ?? []).filter((change) => !this.isHiddenIdField(change?.field)); + } + + private isHiddenIdField(field?: string | null): boolean { + const normalized = this.normalizeField(field); + if (!normalized) return false; + if (this.idFieldExceptions.has(normalized)) return false; + if (normalized === 'id') return true; + return normalized.endsWith('id'); + } + + private isBlockedStatus(status?: string | null): boolean { + const normalized = (status ?? '').toLowerCase().trim(); + if (!normalized) return false; + return ( + normalized.includes('bloque') || + normalized.includes('perda') || + normalized.includes('roubo') || + normalized.includes('suspens') + ); + } + + private extractDdd(value?: string | null): string | null { + const digits = this.digitsOnly(value); + if (!digits) return null; + + if (digits.startsWith('55') && digits.length >= 12) { + return digits.slice(2, 4); + } + if (digits.length >= 10) { + return digits.slice(0, 2); + } + if (digits.length >= 2) { + return digits.slice(0, 2); + } + return null; + } + + private digitsOnly(value?: string | null): string { + return (value ?? '').replace(/\D/g, ''); + } + + private toIsoDate(value: string, endOfDay: boolean): string | null { + if (!value) return null; + const time = endOfDay ? '23:59:59' : '00:00:00'; + const date = new Date(`${value}T${time}`); + if (isNaN(date.getTime())) return null; + return date.toISOString(); + } + + private async showToast(message: string): Promise { + if (!isPlatformBrowser(this.platformId)) return; + this.toastMessage = message; + this.cdr.detectChanges(); + if (!this.successToast?.nativeElement) return; + + try { + const bs = await import('bootstrap'); + const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, { + autohide: true, + delay: 3000 + }); + toastInstance.show(); + } catch (error) { + console.error(error); + } + } +} diff --git a/src/app/pages/historico-linhas/historico-linhas.html b/src/app/pages/historico-linhas/historico-linhas.html new file mode 100644 index 0000000..a000b4a --- /dev/null +++ b/src/app/pages/historico-linhas/historico-linhas.html @@ -0,0 +1,274 @@ +
+ +
+ +
+ + + + + +
+
+
+
+
+ Linha +
+ +
+
Histórico de Linhas
+ Timeline completa das alterações feitas em uma linha específica. +
+ +
+ + +
+
+ +
+
+
+ + Filtros +
+
+ + +
+
+ +
+
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+ Eventos (filtro) + {{ total }} +
+
+ Status (página) + {{ statusCountInPage }} +
+
+ Trocas de Número (página) + {{ trocaCountInPage }} +
+
+ Mureg (página) + {{ muregCountInPage }} +
+
+
+ +
+
+
+ +
+ + + +
+ Nenhuma alteração encontrada para os filtros informados. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Data/HoraUsuárioOrigemAçãoResumo da alteraçãoDetalhes
{{ formatDateTime(log.occurredAtUtc) }} +
+ {{ displayUserName(log) }} + {{ log.userEmail || '-' }} +
+
+ {{ log.page || '-' }} + + {{ formatAction(log.action) }} + + +
{{ summary.title }}
+
{{ summary.description }}
+
+ {{ formatChangeValue(summary.before) }} + + {{ formatChangeValue(summary.after) }} +
+
+ DDD: {{ formatChangeValue(summary.beforeDdd) }} {{ formatChangeValue(summary.afterDdd) }} +
+
+
+ +
+
+
+
+ Mudanças de campos +
+ +
+
+
+ {{ change.field }} + + {{ changeTypeLabel(change.changeType) }} + +
+
+ {{ formatChangeValue(change.oldValue) }} + + {{ formatChangeValue(change.newValue) }} +
+
+
+
+ +
Sem mudanças detalhadas nesse evento.
+
+
+
+
+
+
+ + +
+
+
diff --git a/src/app/pages/historico-linhas/historico-linhas.scss b/src/app/pages/historico-linhas/historico-linhas.scss new file mode 100644 index 0000000..1fe3352 --- /dev/null +++ b/src/app/pages/historico-linhas/historico-linhas.scss @@ -0,0 +1,648 @@ +:host { + --brand: #e33dcf; + --brand-soft: rgba(227, 61, 207, 0.12); + --blue: #030faa; + --text: #111214; + --muted: rgba(17, 18, 20, 0.64); + --surface: rgba(255, 255, 255, 0.9); + --surface-strong: #ffffff; + --line: rgba(15, 23, 42, 0.11); + --radius-xl: 22px; + --radius-lg: 16px; + --shadow-card: 0 20px 44px rgba(17, 18, 20, 0.1); + + display: block; + font-family: 'Inter', sans-serif; + color: var(--text); + box-sizing: border-box; +} + +.historico-linhas-page { + min-height: 100vh; + padding: 0 12px; + display: flex; + align-items: flex-start; + justify-content: center; + position: relative; + overflow-y: auto; + background: + radial-gradient(900px 420px at 20% 10%, rgba(227, 61, 207, 0.14), transparent 60%), + radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%), + linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%); + + &::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: rgba(255, 255, 255, 0.25); + } +} + +.page-blob { + position: fixed; + pointer-events: none; + border-radius: 999px; + filter: blur(34px); + opacity: 0.55; + z-index: 0; + background: radial-gradient(circle at 30% 30%, rgba(227, 61, 207, 0.55), rgba(227, 61, 207, 0.06)); + animation: floaty 10s ease-in-out infinite; + + &.blob-1 { width: 420px; height: 420px; top: -140px; left: -140px; } + &.blob-2 { width: 520px; height: 520px; top: -220px; right: -240px; animation-duration: 12s; } + &.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 25%; animation-duration: 14s; } + &.blob-4 { width: 520px; height: 520px; bottom: -260px; right: -260px; animation-duration: 16s; opacity: 0.45; } +} + +@keyframes floaty { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(18px, 10px) scale(1.03); } + 100% { transform: translate(0, 0) scale(1); } +} + +.container-geral-responsive { + width: 98% !important; + max-width: 1500px !important; + position: relative; + z-index: 1; + margin-top: 40px; + margin-bottom: 200px; +} + +.geral-card { + border-radius: var(--radius-xl); + overflow: hidden; + background: var(--surface); + border: 1px solid rgba(227, 61, 207, 0.16); + backdrop-filter: blur(12px); + box-shadow: var(--shadow-card); + position: relative; + display: flex; + flex-direction: column; + min-height: 80vh; + + &::before { + content: ''; + position: absolute; + inset: 1px; + border-radius: calc(var(--radius-xl) - 1px); + pointer-events: none; + border: 1px solid rgba(255, 255, 255, 0.65); + opacity: 0.75; + } +} + +.geral-header { + padding: 16px 24px; + border-bottom: 1px solid rgba(17, 18, 20, 0.06); + background: linear-gradient(180deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.2)); + flex-shrink: 0; +} + +.header-row-top { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 12px; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + text-align: center; + gap: 14px; + + .title-badge { justify-self: center; } + .header-actions { justify-self: center; } + } +} + +.title-badge { + justify-self: start; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(227, 61, 207, 0.22); + color: var(--text); + font-size: 13px; + font-weight: 800; + + i { color: var(--brand); } +} + +.header-title { + justify-self: center; + text-align: center; +} + +.title { + font-size: 26px; + font-weight: 950; + letter-spacing: -0.3px; + color: var(--text); + margin-top: 10px; +} + +.subtitle { + color: var(--muted); + font-weight: 700; +} + +.header-actions { + justify-self: end; +} + +.btn-brand { + background-color: var(--brand); + border-color: var(--brand); + color: #fff; + font-weight: 900; + border-radius: 12px; + transition: transform 0.2s, box-shadow 0.2s; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(227, 61, 207, 0.25); + filter: brightness(1.05); + } +} + +.btn-glass { + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(17, 18, 20, 0.16); + color: rgba(17, 18, 20, 0.85); + border-radius: 12px; + font-weight: 700; +} + +.filters-card { + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(17, 18, 20, 0.08); + border-radius: 16px; + padding: 16px; + display: grid; + gap: 14px; + box-shadow: 0 14px 28px rgba(17, 18, 20, 0.08); +} + +.filters-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.filters-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 900; + font-size: 14px; + color: rgba(17, 18, 20, 0.82); +} + +.filters-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.filters-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 12px; +} + +.filter-field { + display: grid; + gap: 6px; + grid-column: span 2; + min-width: 0; + + label { + font-size: 11px; + font-weight: 800; + color: rgba(17, 18, 20, 0.6); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + input { + width: 100%; + height: 40px; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.15); + padding: 0 12px; + font-size: 14px; + background: #fff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + } + + input:focus { + outline: none; + border-color: var(--brand); + box-shadow: 0 0 0 3px rgba(227, 61, 207, 0.12); + } +} + +.line-field { + grid-column: span 4; +} + +.period-field { + grid-column: span 3; +} + +.btn-primary, +.btn-ghost { + height: 38px; + border-radius: 10px; + border: none; + font-weight: 700; + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 14px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--brand), #bc30ac); + color: #fff; + box-shadow: 0 8px 16px rgba(227, 61, 207, 0.24); +} + +.btn-ghost { + background: rgba(15, 23, 42, 0.06); + color: rgba(15, 23, 42, 0.85); +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.kpi-card { + background: var(--surface-strong); + border: 1px solid rgba(17, 18, 20, 0.08); + border-radius: 14px; + padding: 12px 14px; + display: grid; + gap: 6px; + box-shadow: 0 8px 16px rgba(17, 18, 20, 0.06); +} + +.kpi-label { + font-size: 12px; + font-weight: 700; + color: rgba(17, 18, 20, 0.62); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.kpi-value { + font-size: 22px; + line-height: 1; + font-weight: 900; + color: var(--blue); +} + +.geral-body { + padding: 18px 24px; + flex: 1; +} + +.table-wrap { + width: 100%; + background: rgba(255, 255, 255, 0.84); + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 14px; + overflow: hidden; +} + +.table-modern { + margin: 0; + min-width: 980px; + + thead th { + background: linear-gradient(180deg, rgba(3, 15, 170, 0.92), rgba(3, 15, 170, 0.82)); + color: #fff; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + border: none; + padding: 12px; + white-space: nowrap; + } + + tbody td { + border-top: 1px solid rgba(15, 23, 42, 0.08); + vertical-align: middle; + padding: 12px; + background: rgba(255, 255, 255, 0.92); + } + + tbody tr.table-row-item:hover td { + background: rgba(227, 61, 207, 0.05); + } + + tbody tr.table-row-item.expanded td { + background: rgba(227, 61, 207, 0.08); + } +} + +.user-cell { + display: grid; + line-height: 1.2; +} + +.user-name { + font-weight: 800; +} + +.user-email { + color: rgba(17, 18, 20, 0.55); +} + +.origin-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 4px 10px; + background: rgba(3, 15, 170, 0.1); + border: 1px solid rgba(3, 15, 170, 0.2); + color: rgba(3, 15, 170, 0.88); + font-size: 12px; + font-weight: 700; +} + +.badge-action { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 5px 10px; + font-size: 12px; + font-weight: 800; + border: 1px solid transparent; + + &.action-create { + color: #157347; + background: rgba(25, 135, 84, 0.12); + border-color: rgba(25, 135, 84, 0.24); + } + + &.action-update { + color: #0a58ca; + background: rgba(13, 110, 253, 0.12); + border-color: rgba(13, 110, 253, 0.24); + } + + &.action-delete { + color: #b02a37; + background: rgba(220, 53, 69, 0.12); + border-color: rgba(220, 53, 69, 0.24); + } + + &.action-default { + color: #495057; + background: rgba(108, 117, 125, 0.12); + border-color: rgba(108, 117, 125, 0.24); + } +} + +.summary-col { + min-width: 360px; +} + +.summary-title { + font-size: 13px; + font-weight: 900; + margin-bottom: 2px; +} + +.summary-description { + font-size: 12px; + color: rgba(17, 18, 20, 0.66); +} + +.summary-diff { + margin-top: 6px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + border-radius: 10px; + background: rgba(15, 23, 42, 0.05); + padding: 4px 8px; + + .old { + color: #b02a37; + font-weight: 700; + } + + .new { + color: #157347; + font-weight: 700; + } +} + +.summary-ddd { + margin-top: 5px; + font-size: 11px; + color: rgba(17, 18, 20, 0.62); + font-weight: 700; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.tone-mureg { color: #005f73; } +.tone-troca { color: #6f42c1; } +.tone-status { color: #0a58ca; } +.tone-linha { color: #0d6efd; } +.tone-chip { color: #198754; } +.tone-generic { color: #495057; } + +.actions-col { + width: 84px; + text-align: center; +} + +.expand-btn { + width: 34px; + height: 34px; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.15); + background: #fff; + color: rgba(15, 23, 42, 0.85); +} + +.details-row td { + background: rgba(255, 255, 255, 0.94); +} + +.details-panel { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +.details-section { + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 12px; + background: #fff; + padding: 10px 12px; +} + +.section-title { + display: inline-flex; + align-items: center; + gap: 7px; + font-weight: 800; + color: rgba(17, 18, 20, 0.84); + margin-bottom: 8px; +} + +.changes-list { + display: grid; + gap: 8px; +} + +.change-item { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 10px; + padding: 8px; + background: rgba(248, 249, 250, 0.85); +} + +.change-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.change-field { + font-size: 12px; + font-weight: 800; + color: #111; +} + +.change-type { + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + font-weight: 800; + border: 1px solid transparent; + + &.change-added { + background: rgba(25, 135, 84, 0.12); + color: #157347; + border-color: rgba(25, 135, 84, 0.24); + } + + &.change-removed { + background: rgba(220, 53, 69, 0.12); + color: #b02a37; + border-color: rgba(220, 53, 69, 0.24); + } + + &.change-modified { + background: rgba(13, 110, 253, 0.12); + color: #0a58ca; + border-color: rgba(13, 110, 253, 0.24); + } +} + +.change-values { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + + .old { + color: #b02a37; + font-weight: 700; + } + + .new { + color: #157347; + font-weight: 700; + } +} + +.empty-state { + font-size: 13px; + color: rgba(17, 18, 20, 0.62); + font-weight: 600; +} + +.empty-group { + text-align: center; + padding: 28px; + color: rgba(17, 18, 20, 0.65); + font-weight: 700; +} + +.empty-group.helper { + background: rgba(3, 15, 170, 0.05); + border-bottom: 1px solid rgba(3, 15, 170, 0.12); +} + +.geral-footer { + display: none; +} + +.footer-meta { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.pagination-modern .page-link { + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.15); + color: rgba(15, 23, 42, 0.85); + margin: 0 2px; +} + +.pagination-modern .page-item.active .page-link { + background: var(--brand); + border-color: var(--brand); + color: #fff; +} + +@media (max-width: 1200px) { + .filters-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } + .filter-field { grid-column: span 2; } + .line-field { grid-column: span 3; } + .period-field { grid-column: span 3; } + .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .details-panel { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + .geral-header, + .geral-body, + .geral-footer { + padding-left: 14px; + padding-right: 14px; + } + + .filters-grid { grid-template-columns: repeat(1, minmax(0, 1fr)); } + .filter-field, + .line-field { + grid-column: span 1; + } + + .kpi-grid { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } +} diff --git a/src/app/pages/historico-linhas/historico-linhas.ts b/src/app/pages/historico-linhas/historico-linhas.ts new file mode 100644 index 0000000..22c5307 --- /dev/null +++ b/src/app/pages/historico-linhas/historico-linhas.ts @@ -0,0 +1,573 @@ +import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PLATFORM_ID } from '@angular/core'; +import { CommonModule, isPlatformBrowser } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { + HistoricoService, + AuditLogDto, + AuditChangeType, + AuditFieldChangeDto, + LineHistoricoQuery +} from '../../services/historico.service'; +import { TableExportService } from '../../services/table-export.service'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; + +interface SelectOption { + value: string; + label: string; +} + +type EventTone = 'mureg' | 'troca' | 'status' | 'linha' | 'chip' | 'generic'; + +interface EventSummary { + title: string; + description: string; + before?: string | null; + after?: string | null; + beforeDdd?: string | null; + afterDdd?: string | null; + tone: EventTone; +} + +@Component({ + selector: 'app-historico-linhas', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent], + templateUrl: './historico-linhas.html', + styleUrls: ['./historico-linhas.scss'], +}) +export class HistoricoLinhas implements OnInit { + @ViewChild('successToast', { static: false }) successToast!: ElementRef; + + logs: AuditLogDto[] = []; + loading = false; + exporting = false; + error = false; + errorMsg = ''; + toastMessage = ''; + + expandedLogId: string | null = null; + + page = 1; + pageSize = 10; + pageSizeOptions = [10, 20, 50, 100]; + total = 0; + + filterLine = ''; + filterPageName = ''; + filterAction = ''; + filterUser = ''; + dateFrom = ''; + dateTo = ''; + + readonly pageOptions: SelectOption[] = [ + { value: '', label: 'Todas as origens' }, + { value: 'Geral', label: 'Geral' }, + { value: 'Mureg', label: 'Mureg' }, + { value: 'Troca de número', label: 'Troca de número' }, + { value: 'Vigência', label: 'Vigência' }, + { value: 'Parcelamentos', label: 'Parcelamentos' }, + ]; + + readonly actionOptions: SelectOption[] = [ + { value: '', label: 'Todas as ações' }, + { value: 'CREATE', label: 'Criação' }, + { value: 'UPDATE', label: 'Atualização' }, + { value: 'DELETE', label: 'Exclusão' }, + ]; + + private readonly summaryCache = new Map(); + private readonly idFieldExceptions = new Set(['iccid']); + + constructor( + private readonly historicoService: HistoricoService, + private readonly cdr: ChangeDetectorRef, + @Inject(PLATFORM_ID) private readonly platformId: object, + private readonly tableExportService: TableExportService + ) {} + + ngOnInit(): void { + this.fetch(); + } + + applyFilters(): void { + this.page = 1; + this.fetch(); + } + + refresh(): void { + this.fetch(); + } + + clearFilters(): void { + this.filterLine = ''; + this.filterPageName = ''; + this.filterAction = ''; + this.filterUser = ''; + this.dateFrom = ''; + this.dateTo = ''; + this.page = 1; + this.logs = []; + this.total = 0; + this.error = false; + this.errorMsg = ''; + this.summaryCache.clear(); + this.fetch(); + } + + onPageSizeChange(): void { + this.page = 1; + this.fetch(); + } + + goToPage(target: number): void { + this.page = clampPage(target, this.totalPages); + this.fetch(); + } + + toggleDetails(log: AuditLogDto, event?: Event): void { + if (event) event.stopPropagation(); + this.expandedLogId = this.expandedLogId === log.id ? null : log.id; + } + + async onExport(): Promise { + if (this.exporting) return; + + this.exporting = true; + try { + const allLogs = await this.fetchAllLogsForExport(); + if (!allLogs.length) { + await this.showToast('Nenhum evento encontrado para exportar.'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `historico_linhas_${timestamp}`, + sheetName: 'HistoricoLinhas', + rows: allLogs, + columns: [ + { header: 'Data/Hora', type: 'datetime', value: (log) => log.occurredAtUtc ?? '' }, + { header: 'Usuario', value: (log) => this.displayUserName(log) }, + { header: 'E-mail', value: (log) => log.userEmail ?? '' }, + { header: 'Origem', value: (log) => log.page ?? '' }, + { header: 'Acao', value: (log) => this.formatAction(log.action) }, + { header: 'Evento', value: (log) => this.summaryFor(log).title }, + { header: 'Resumo', value: (log) => this.summaryFor(log).description }, + { header: 'Valor Anterior', value: (log) => this.summaryFor(log).before ?? '' }, + { header: 'Valor Novo', value: (log) => this.summaryFor(log).after ?? '' }, + { header: 'DDD Anterior', value: (log) => this.summaryFor(log).beforeDdd ?? '' }, + { header: 'DDD Novo', value: (log) => this.summaryFor(log).afterDdd ?? '' }, + { header: 'Mudancas', value: (log) => this.formatChangesSummary(log) }, + ], + }); + + await this.showToast(`Planilha exportada com ${allLogs.length} evento(s).`); + } catch { + await this.showToast('Erro ao exportar histórico de linhas.'); + } finally { + this.exporting = false; + } + } + + formatDateTime(value?: string | null): string { + if (!value) return '-'; + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) return '-'; + return dt.toLocaleString('pt-BR'); + } + + displayUserName(log: AuditLogDto): string { + const name = (log.userName || '').trim(); + return name ? name : 'SISTEMA'; + } + + formatAction(action?: string | null): string { + const value = (action || '').toUpperCase(); + if (!value) return '-'; + if (value === 'CREATE') return 'Criação'; + if (value === 'UPDATE') return 'Atualização'; + if (value === 'DELETE') return 'Exclusão'; + return 'Outro'; + } + + actionClass(action?: string | null): string { + const value = (action || '').toUpperCase(); + if (value === 'CREATE') return 'action-create'; + if (value === 'UPDATE') return 'action-update'; + if (value === 'DELETE') return 'action-delete'; + return 'action-default'; + } + + changeTypeLabel(type?: AuditChangeType | string | null): string { + if (!type) return 'Alterado'; + if (type === 'added') return 'Adicionado'; + if (type === 'removed') return 'Removido'; + return 'Alterado'; + } + + changeTypeClass(type?: AuditChangeType | string | null): string { + if (type === 'added') return 'change-added'; + if (type === 'removed') return 'change-removed'; + return 'change-modified'; + } + + formatChangeValue(value?: string | null): string { + if (value === undefined || value === null || value === '') return '-'; + return String(value); + } + + summaryFor(log: AuditLogDto): EventSummary { + const cached = this.summaryCache.get(log.id); + if (cached) return cached; + const summary = this.buildEventSummary(log); + this.summaryCache.set(log.id, summary); + return summary; + } + + toneClass(tone: EventTone): string { + return `tone-${tone}`; + } + + trackByLog(_: number, log: AuditLogDto): string { + return log.id; + } + + trackByField(_: number, change: AuditFieldChangeDto): string { + return `${change.field}-${change.oldValue ?? ''}-${change.newValue ?? ''}`; + } + + visibleChanges(log: AuditLogDto): AuditFieldChangeDto[] { + return this.publicChanges(log); + } + + get normalizedLineTerm(): string { + return (this.filterLine || '').trim(); + } + + get hasLineFilter(): boolean { + return !!this.normalizedLineTerm; + } + + get totalPages(): number { + return computeTotalPages(this.total || 0, this.pageSize); + } + + get pageNumbers(): number[] { + return buildPageNumbers(this.page, this.totalPages); + } + + get pageStart(): number { + return computePageStart(this.total || 0, this.page, this.pageSize); + } + + get pageEnd(): number { + return computePageEnd(this.total || 0, this.page, this.pageSize); + } + + get statusCountInPage(): number { + return this.logs.filter((log) => this.summaryFor(log).tone === 'status').length; + } + + get trocaCountInPage(): number { + return this.logs.filter((log) => this.summaryFor(log).tone === 'troca').length; + } + + get muregCountInPage(): number { + return this.logs.filter((log) => this.summaryFor(log).tone === 'mureg').length; + } + + private fetch(): void { + this.loading = true; + this.error = false; + this.errorMsg = ''; + this.expandedLogId = null; + + const query: LineHistoricoQuery = { + ...this.buildBaseQuery(), + line: this.normalizedLineTerm || undefined, + page: this.page, + pageSize: this.pageSize, + }; + + this.historicoService.listByLine(query).subscribe({ + next: (res) => { + this.logs = res.items || []; + this.total = res.total || 0; + this.page = res.page || this.page; + this.pageSize = res.pageSize || this.pageSize; + this.loading = false; + this.rebuildSummaryCache(); + }, + error: (err: HttpErrorResponse) => { + this.loading = false; + this.error = true; + this.logs = []; + this.total = 0; + this.summaryCache.clear(); + if (err?.status === 403) { + this.errorMsg = 'Acesso restrito.'; + return; + } + this.errorMsg = 'Erro ao carregar histórico da linha. Tente novamente.'; + } + }); + } + + private async fetchAllLogsForExport(): Promise { + const pageSize = 500; + let page = 1; + let expectedTotal = 0; + const all: AuditLogDto[] = []; + + while (page <= 500) { + const response = await firstValueFrom( + this.historicoService.listByLine({ + ...this.buildBaseQuery(), + line: this.normalizedLineTerm || undefined, + page, + pageSize, + }) + ); + + const items = response?.items ?? []; + expectedTotal = response?.total ?? 0; + all.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && all.length >= expectedTotal) break; + page += 1; + } + + return all; + } + + private buildBaseQuery(): Omit { + return { + pageName: this.filterPageName || undefined, + action: this.filterAction || undefined, + user: this.filterUser?.trim() || undefined, + dateFrom: this.toIsoDate(this.dateFrom, false) || undefined, + dateTo: this.toIsoDate(this.dateTo, true) || undefined, + }; + } + + private rebuildSummaryCache(): void { + this.summaryCache.clear(); + this.logs.forEach((log) => { + this.summaryCache.set(log.id, this.buildEventSummary(log)); + }); + } + + private buildEventSummary(log: AuditLogDto): EventSummary { + const page = (log.page || '').toLowerCase(); + const entity = (log.entityName || '').toLowerCase(); + + const linhaChange = this.findChange(log, 'linha'); + const statusChange = this.findChange(log, 'status'); + const chipChange = this.findChange(log, 'chip', 'iccid'); + const linhaAntiga = this.findChange(log, 'linhaantiga'); + const linhaNova = this.findChange(log, 'linhanova'); + + const muregLike = entity === 'muregline' || page.includes('mureg'); + if (muregLike) { + const before = this.firstFilled(linhaAntiga?.newValue, linhaAntiga?.oldValue, linhaChange?.oldValue); + const after = this.firstFilled(linhaNova?.newValue, linhaNova?.oldValue, linhaChange?.newValue); + return { + title: 'Troca de Mureg', + description: 'Linha alterada no fluxo de Mureg.', + before, + after, + beforeDdd: this.extractDdd(before), + afterDdd: this.extractDdd(after), + tone: 'mureg', + }; + } + + const trocaLike = entity === 'trocanumeroline' || page.includes('troca'); + if (trocaLike) { + const before = this.firstFilled(linhaAntiga?.newValue, linhaAntiga?.oldValue, linhaChange?.oldValue); + const after = this.firstFilled(linhaNova?.newValue, linhaNova?.oldValue, linhaChange?.newValue); + return { + title: 'Troca de Número', + description: 'Linha antiga substituída por uma nova.', + before, + after, + beforeDdd: this.extractDdd(before), + afterDdd: this.extractDdd(after), + tone: 'troca', + }; + } + + if (statusChange) { + const oldStatus = this.firstFilled(statusChange.oldValue); + const newStatus = this.firstFilled(statusChange.newValue); + const wasBlocked = this.isBlockedStatus(oldStatus); + const isBlocked = this.isBlockedStatus(newStatus); + let description = 'Status da linha atualizado.'; + if (!wasBlocked && isBlocked) description = 'Linha foi bloqueada.'; + if (wasBlocked && !isBlocked) description = 'Linha foi desbloqueada.'; + return { + title: 'Status da Linha', + description, + before: oldStatus, + after: newStatus, + tone: 'status', + }; + } + + if (linhaChange) { + return { + title: 'Alteração da Linha', + description: 'Número da linha foi atualizado.', + before: this.firstFilled(linhaChange.oldValue), + after: this.firstFilled(linhaChange.newValue), + beforeDdd: this.extractDdd(linhaChange.oldValue), + afterDdd: this.extractDdd(linhaChange.newValue), + tone: 'linha', + }; + } + + if (chipChange) { + return { + title: 'Alteração de Chip', + description: 'ICCID/chip atualizado na linha.', + before: this.firstFilled(chipChange.oldValue), + after: this.firstFilled(chipChange.newValue), + tone: 'chip', + }; + } + + const first = this.publicChanges(log)[0]; + if (first) { + return { + title: 'Outras alterações', + description: `Campo ${first.field} foi atualizado.`, + before: this.firstFilled(first.oldValue), + after: this.firstFilled(first.newValue), + tone: 'generic', + }; + } + + return { + title: 'Sem detalhes', + description: 'Não há mudanças detalhadas registradas para este evento.', + tone: 'generic', + }; + } + + private findChange(log: AuditLogDto, ...fields: string[]): AuditFieldChangeDto | null { + if (!fields.length) return null; + const normalizedTargets = new Set(fields.map((field) => this.normalizeField(field))); + return (log.changes || []).find((change) => normalizedTargets.has(this.normalizeField(change.field))) || null; + } + + private normalizeField(value?: string | null): string { + return (value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() + .trim(); + } + + private firstFilled(...values: Array): string | null { + for (const value of values) { + const normalized = (value ?? '').toString().trim(); + if (normalized) return normalized; + } + return null; + } + + private formatChangesSummary(log: AuditLogDto): string { + const changes = this.publicChanges(log); + if (!changes.length) return ''; + return changes + .map((change) => { + const field = change?.field ?? 'campo'; + const oldValue = this.formatChangeValue(change?.oldValue); + const newValue = this.formatChangeValue(change?.newValue); + return `${field}: ${oldValue} -> ${newValue}`; + }) + .join(' | '); + } + + private publicChanges(log: AuditLogDto): AuditFieldChangeDto[] { + return (log?.changes ?? []).filter((change) => !this.isHiddenIdField(change?.field)); + } + + private isHiddenIdField(field?: string | null): boolean { + const normalized = this.normalizeField(field); + if (!normalized) return false; + if (this.idFieldExceptions.has(normalized)) return false; + if (normalized === 'id') return true; + return normalized.endsWith('id'); + } + + private isBlockedStatus(status?: string | null): boolean { + const normalized = (status ?? '').toLowerCase().trim(); + if (!normalized) return false; + return ( + normalized.includes('bloque') || + normalized.includes('perda') || + normalized.includes('roubo') || + normalized.includes('suspens') + ); + } + + private extractDdd(value?: string | null): string | null { + const digits = this.digitsOnly(value); + if (!digits) return null; + + if (digits.startsWith('55') && digits.length >= 12) { + return digits.slice(2, 4); + } + if (digits.length >= 10) { + return digits.slice(0, 2); + } + if (digits.length >= 2) { + return digits.slice(0, 2); + } + return null; + } + + private digitsOnly(value?: string | null): string { + return (value ?? '').replace(/\D/g, ''); + } + + private toIsoDate(value: string, endOfDay: boolean): string | null { + if (!value) return null; + const time = endOfDay ? '23:59:59' : '00:00:00'; + const date = new Date(`${value}T${time}`); + if (isNaN(date.getTime())) return null; + return date.toISOString(); + } + + private async showToast(message: string): Promise { + if (!isPlatformBrowser(this.platformId)) return; + this.toastMessage = message; + this.cdr.detectChanges(); + if (!this.successToast?.nativeElement) return; + + try { + const bs = await import('bootstrap'); + const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, { + autohide: true, + delay: 3000 + }); + toastInstance.show(); + } catch (error) { + console.error(error); + } + } +} diff --git a/src/app/pages/historico/historico.html b/src/app/pages/historico/historico.html index e01f1e8..9cc9940 100644 --- a/src/app/pages/historico/historico.html +++ b/src/app/pages/historico/historico.html @@ -33,6 +33,10 @@ +
diff --git a/src/app/pages/historico/historico.ts b/src/app/pages/historico/historico.ts index 5ce35cf..6dbd52b 100644 --- a/src/app/pages/historico/historico.ts +++ b/src/app/pages/historico/historico.ts @@ -2,9 +2,18 @@ import { Component, OnInit, ElementRef, ViewChild, ChangeDetectorRef, Inject, PL import { CommonModule, isPlatformBrowser } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { HistoricoService, AuditLogDto, AuditChangeType, HistoricoQuery } from '../../services/historico.service'; +import { TableExportService } from '../../services/table-export.service'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; interface SelectOption { value: string; @@ -23,6 +32,7 @@ export class Historico implements OnInit { logs: AuditLogDto[] = []; loading = false; + exporting = false; error = false; errorMsg = ''; toastMessage = ''; @@ -65,7 +75,8 @@ export class Historico implements OnInit { constructor( private historicoService: HistoricoService, private cdr: ChangeDetectorRef, - @Inject(PLATFORM_ID) private platformId: object + @Inject(PLATFORM_ID) private platformId: object, + private tableExportService: TableExportService ) {} ngOnInit(): void { @@ -111,35 +122,66 @@ export class Historico implements OnInit { this.fetch(); } + async onExport(): Promise { + if (this.exporting) return; + this.exporting = true; + + try { + const logs = await this.fetchAllLogsForExport(); + if (!logs.length) { + await this.showToast('Nenhum registro encontrado para exportar.'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `historico_${timestamp}`, + sheetName: 'Historico', + rows: logs, + columns: [ + { header: 'ID', value: (log) => log.id ?? '' }, + { header: 'Data/Hora', type: 'datetime', value: (log) => log.occurredAtUtc ?? '' }, + { header: 'Usuario', value: (log) => this.displayUserName(log) }, + { header: 'E-mail', value: (log) => log.userEmail ?? '' }, + { header: 'Pagina', value: (log) => log.page ?? '' }, + { header: 'Acao', value: (log) => this.formatAction(log.action) }, + { header: 'Entidade', value: (log) => this.displayEntity(log) }, + { header: 'Id Entidade', value: (log) => log.entityId ?? '' }, + { header: 'Metodo HTTP', value: (log) => log.requestMethod ?? '' }, + { header: 'Endpoint', value: (log) => log.requestPath ?? '' }, + { header: 'IP', value: (log) => log.ipAddress ?? '' }, + { header: 'Mudancas', value: (log) => this.formatChangesSummary(log) }, + { header: 'Qtd Mudancas', type: 'number', value: (log) => log.changes?.length ?? 0 }, + ], + }); + + await this.showToast(`Planilha exportada com ${logs.length} registro(s).`); + } catch { + await this.showToast('Erro ao exportar planilha.'); + } finally { + this.exporting = false; + } + } + goToPage(p: number): void { - this.page = Math.max(1, Math.min(this.totalPages, p)); + this.page = clampPage(p, this.totalPages); this.fetch(); } get totalPages(): number { - return Math.ceil((this.total || 0) / this.pageSize) || 1; + return computeTotalPages(this.total || 0, this.pageSize); } get pageNumbers(): number[] { - const total = this.totalPages; - const current = this.page; - const max = 5; - let start = Math.max(1, current - 2); - let end = Math.min(total, start + (max - 1)); - start = Math.max(1, end - (max - 1)); - - const pages: number[] = []; - for (let i = start; i <= end; i++) pages.push(i); - return pages; + return buildPageNumbers(this.page, this.totalPages); } get pageStart(): number { - return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; + return computePageStart(this.total || 0, this.page, this.pageSize); } get pageEnd(): number { - if (this.total === 0) return 0; - return Math.min(this.page * this.pageSize, this.total); + return computePageEnd(this.total || 0, this.page, this.pageSize); } toggleDetails(log: AuditLogDto, event?: Event): void { @@ -217,14 +259,9 @@ export class Historico implements OnInit { this.expandedLogId = null; const query: HistoricoQuery = { + ...this.buildBaseQuery(), page: this.page, pageSize: this.pageSize, - pageName: this.filterPageName || undefined, - action: this.filterAction || undefined, - user: this.filterUser?.trim() || undefined, - search: this.filterSearch?.trim() || undefined, - dateFrom: this.toIsoDate(this.dateFrom, false) || undefined, - dateTo: this.toIsoDate(this.dateTo, true) || undefined, }; this.historicoService.list(query).subscribe({ @@ -247,6 +284,58 @@ export class Historico implements OnInit { }); } + private async fetchAllLogsForExport(): Promise { + const pageSize = 500; + let page = 1; + let expectedTotal = 0; + const all: AuditLogDto[] = []; + + while (page <= 500) { + const response = await firstValueFrom( + this.historicoService.list({ + ...this.buildBaseQuery(), + page, + pageSize, + }) + ); + + const items = response?.items ?? []; + expectedTotal = response?.total ?? 0; + all.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && all.length >= expectedTotal) break; + page += 1; + } + + return all; + } + + private buildBaseQuery(): Omit { + return { + pageName: this.filterPageName || undefined, + action: this.filterAction || undefined, + user: this.filterUser?.trim() || undefined, + search: this.filterSearch?.trim() || undefined, + dateFrom: this.toIsoDate(this.dateFrom, false) || undefined, + dateTo: this.toIsoDate(this.dateTo, true) || undefined, + }; + } + + private formatChangesSummary(log: AuditLogDto): string { + const changes = log?.changes ?? []; + if (!changes.length) return ''; + return changes + .map((change) => { + const field = change?.field ?? 'campo'; + const oldValue = this.formatChangeValue(change?.oldValue); + const newValue = this.formatChangeValue(change?.newValue); + return `${field}: ${oldValue} -> ${newValue}`; + }) + .join(' | '); + } + private toIsoDate(value: string, endOfDay: boolean): string | null { if (!value) return null; const time = endOfDay ? '23:59:59' : '00:00:00'; diff --git a/src/app/pages/mureg/mureg.html b/src/app/pages/mureg/mureg.html index 58c0764..ba4d93b 100644 --- a/src/app/pages/mureg/mureg.html +++ b/src/app/pages/mureg/mureg.html @@ -31,7 +31,11 @@
- +
@@ -173,10 +177,10 @@ - - @@ -218,316 +222,4 @@ - - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/app/pages/mureg/mureg.scss b/src/app/pages/mureg/mureg.scss index 3e30c8f..186021a 100644 --- a/src/app/pages/mureg/mureg.scss +++ b/src/app/pages/mureg/mureg.scss @@ -277,8 +277,6 @@ .pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; } /* MODALS */ -.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } -.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; display: flex; flex-direction: column; animation: modalPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); width: min(850px, 100%); max-height: 90vh; } .modal-card.modal-xl-custom { width: min(920px, 90vw); max-height: 78vh; } .modal-card.modal-sm { width: min(480px, 100%); } diff --git a/src/app/pages/mureg/mureg.ts b/src/app/pages/mureg/mureg.ts index a09f8f8..aee7365 100644 --- a/src/app/pages/mureg/mureg.ts +++ b/src/app/pages/mureg/mureg.ts @@ -10,10 +10,22 @@ import { import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpParams } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { AuthService } from '../../services/auth.service'; import { LinesService } from '../../services/lines.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { TableExportService } from '../../services/table-export.service'; import { environment } from '../../../environments/environment'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; +import { MuregModalsComponent } from '../../components/page-modals/mureg-modals/mureg-modals'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; +import { buildApiEndpoint } from '../../utils/api-base.util'; type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente'; @@ -75,15 +87,28 @@ interface MuregDetailDto { statusNaGeral: string | null; } +type MuregExportRow = MuregRow & { + usuario?: string | null; + skil?: string | null; + linhaAtualNaGeral?: string | null; + chipNaGeral?: string | null; + contaNaGeral?: string | null; + statusNaGeral?: string | null; + createdAt?: string | null; + updatedAt?: string | null; +}; + @Component({ standalone: true, - imports: [CommonModule, FormsModule, CustomSelectComponent], + imports: [CommonModule, FormsModule, CustomSelectComponent, MuregModalsComponent], templateUrl: './mureg.html', styleUrls: ['./mureg.scss'] }) export class Mureg implements AfterViewInit { + readonly vm = this; toastMessage = ''; loading = false; + exporting = false; @ViewChild('successToast', { static: false }) successToast!: ElementRef; @@ -91,14 +116,12 @@ export class Mureg implements AfterViewInit { @Inject(PLATFORM_ID) private platformId: object, private http: HttpClient, private cdr: ChangeDetectorRef, - private linesService: LinesService + private authService: AuthService, + private linesService: LinesService, + private tableExportService: TableExportService ) {} - private readonly apiBase = (() => { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; - return `${apiBase}/mureg`; - })(); + private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'mureg'); // ====== DATA ====== clientGroups: ClientGroup[] = []; @@ -162,9 +185,20 @@ export class Mureg implements AfterViewInit { clienteInfo: '' }; + isSysAdmin = false; + isGestor = false; + isFinanceiro = false; + + get canManageRecords(): boolean { + return this.isSysAdmin || this.isGestor; + } + async ngAfterViewInit() { if (!isPlatformBrowser(this.platformId)) return; this.initAnimations(); + this.isSysAdmin = this.authService.hasRole('sysadmin'); + this.isGestor = this.authService.hasRole('gestor'); + this.isFinanceiro = this.authService.hasRole('financeiro'); setTimeout(() => { this.preloadClients(); // ✅ já deixa o select pronto this.refresh(); @@ -184,6 +218,147 @@ export class Mureg implements AfterViewInit { this.loadForGroups(); } + async onExport(): Promise { + if (this.exporting) return; + this.exporting = true; + + try { + const baseRows = await this.fetchAllRowsForExport(); + const rows = await this.fetchDetailedRowsForExport(baseRows); + if (!rows.length) { + await this.showToast('Nenhum registro encontrado para exportar.'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `mureg_${timestamp}`, + sheetName: 'Mureg', + rows, + columns: [ + { header: 'ID', value: (row) => row.id ?? '' }, + { header: 'Cliente', value: (row) => row.cliente }, + { header: 'Usuario', value: (row) => row.usuario ?? '' }, + { header: 'Skil', value: (row) => row.skil ?? '' }, + { header: 'Item', type: 'number', value: (row) => this.toIntOrZero(row.item) }, + { header: 'Linha Antiga', value: (row) => row.linhaAntiga }, + { header: 'Linha Nova', value: (row) => row.linhaNova }, + { header: 'ICCID', value: (row) => row.iccid }, + { header: 'Data da Mureg', type: 'date', value: (row) => row.dataDaMureg }, + { header: 'Situacao', value: (row) => (this.isTroca(row) ? 'TROCA' : 'SEM TROCA') }, + { header: 'Linha ID (Geral)', value: (row) => row.mobileLineId ?? '' }, + { header: 'Linha Atual na Geral', value: (row) => row.linhaAtualNaGeral ?? '' }, + { header: 'Chip na Geral', value: (row) => row.chipNaGeral ?? '' }, + { header: 'Conta na Geral', value: (row) => row.contaNaGeral ?? '' }, + { header: 'Status na Geral', value: (row) => row.statusNaGeral ?? '' }, + { header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' }, + { header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' }, + ], + }); + + await this.showToast(`Planilha exportada com ${rows.length} registro(s).`); + } catch { + await this.showToast('Erro ao exportar planilha.'); + } finally { + this.exporting = false; + } + } + + private async fetchAllRowsForExport(): Promise { + const pageSize = 2000; + let page = 1; + let expectedTotal = 0; + const rows: MuregRow[] = []; + + while (page <= 500) { + const params = new HttpParams() + .set('page', String(page)) + .set('pageSize', String(pageSize)) + .set('search', (this.searchTerm ?? '').trim()) + .set('sortBy', 'cliente') + .set('sortDir', 'asc'); + + const response = await firstValueFrom( + this.http.get | any[]>(this.apiBase, { params }) + ); + + const items = Array.isArray(response) ? response : (response.items ?? []); + const normalized = items.map((item: any, idx: number) => this.normalizeRow(item, rows.length + idx)); + rows.push(...normalized); + expectedTotal = Array.isArray(response) ? 0 : Number(response.total ?? 0); + + if (Array.isArray(response)) break; + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && rows.length >= expectedTotal) break; + page += 1; + } + + return rows.sort((a, b) => { + const byClient = (a.cliente ?? '').localeCompare(b.cliente ?? '', 'pt-BR', { sensitivity: 'base' }); + if (byClient !== 0) return byClient; + + const byItem = this.toIntOrZero(a.item) - this.toIntOrZero(b.item); + if (byItem !== 0) return byItem; + + return (a.linhaNova ?? '').localeCompare(b.linhaNova ?? '', 'pt-BR', { sensitivity: 'base' }); + }); + } + + private async fetchDetailedRowsForExport(rows: MuregRow[]): Promise { + if (!rows.length) return []; + + const result: MuregExportRow[] = []; + const chunkSize = 10; + + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + const detailedChunk = await Promise.all( + chunk.map(async (row) => { + try { + const detail = await firstValueFrom(this.http.get(`${this.apiBase}/${row.id}`)); + const merged: MuregExportRow = { + ...row, + item: detail.item !== undefined && detail.item !== null ? String(detail.item) : row.item, + linhaAntiga: detail.linhaAntiga ?? row.linhaAntiga, + linhaNova: detail.linhaNova ?? row.linhaNova, + iccid: detail.iccid ?? row.iccid, + dataDaMureg: detail.dataDaMureg ?? row.dataDaMureg, + cliente: detail.cliente ?? row.cliente, + mobileLineId: detail.mobileLineId ?? row.mobileLineId, + usuario: detail.usuario ?? null, + skil: detail.skil ?? null, + linhaAtualNaGeral: detail.linhaAtualNaGeral ?? null, + chipNaGeral: detail.chipNaGeral ?? null, + contaNaGeral: detail.contaNaGeral ?? null, + statusNaGeral: detail.statusNaGeral ?? null, + createdAt: this.getRawField(detail, ['createdAt', 'CreatedAt']) ?? this.getRawField(row.raw, ['createdAt', 'CreatedAt']), + updatedAt: this.getRawField(detail, ['updatedAt', 'UpdatedAt']) ?? this.getRawField(row.raw, ['updatedAt', 'UpdatedAt']), + }; + + return merged; + } catch { + return { + ...row, + usuario: this.getRawField(row.raw, ['usuario', 'Usuario']), + skil: this.getRawField(row.raw, ['skil', 'Skil']), + linhaAtualNaGeral: this.getRawField(row.raw, ['linhaAtualNaGeral', 'LinhaAtualNaGeral']), + chipNaGeral: this.getRawField(row.raw, ['chipNaGeral', 'ChipNaGeral']), + contaNaGeral: this.getRawField(row.raw, ['contaNaGeral', 'ContaNaGeral']), + statusNaGeral: this.getRawField(row.raw, ['statusNaGeral', 'StatusNaGeral']), + createdAt: this.getRawField(row.raw, ['createdAt', 'CreatedAt']), + updatedAt: this.getRawField(row.raw, ['updatedAt', 'UpdatedAt']), + }; + } + }) + ); + + result.push(...detailedChunk); + } + + return result; + } + onSearch() { if (this.searchTimer) clearTimeout(this.searchTimer); this.searchTimer = setTimeout(() => { @@ -208,29 +383,20 @@ export class Mureg implements AfterViewInit { } goToPage(p: number) { - this.page = Math.max(1, Math.min(this.totalPages, p)); + this.page = clampPage(p, this.totalPages); this.applyPagination(); } - get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; } + get totalPages() { return computeTotalPages(this.total || 0, this.pageSize); } get pageNumbers() { - const total = this.totalPages; - const current = this.page; - const max = 5; - let start = Math.max(1, current - 2); - let end = Math.min(total, start + (max - 1)); - start = Math.max(1, end - (max - 1)); - const pages: number[] = []; - for (let i = start; i <= end; i++) pages.push(i); - return pages; + return buildPageNumbers(this.page, this.totalPages); } - get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; } + get pageStart() { return computePageStart(this.total || 0, this.page, this.pageSize); } get pageEnd() { - if (this.total === 0) return 0; - return Math.min(this.page * this.pageSize, this.total); + return computePageEnd(this.total || 0, this.page, this.pageSize); } trackById(_: number, row: MuregRow) { return row.id; } @@ -468,6 +634,11 @@ export class Mureg implements AfterViewInit { // CREATE MODAL // ======================================================================= onCreate() { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + this.preloadClients(); this.createOpen = true; @@ -480,7 +651,7 @@ export class Mureg implements AfterViewInit { linhaAntiga: '', linhaNova: '', iccid: '', - dataDaMureg: '', + dataDaMureg: this.nowDateInput(), clienteInfo: '' }; @@ -507,6 +678,11 @@ export class Mureg implements AfterViewInit { } saveCreate() { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + const mobileLineId = String(this.createModel.mobileLineId ?? '').trim(); const linhaNova = String(this.createModel.linhaNova ?? '').trim(); @@ -523,7 +699,7 @@ export class Mureg implements AfterViewInit { linhaAntiga: (this.createModel.linhaAntiga ?? '') || null, linhaNova: (this.createModel.linhaNova ?? '') || null, iccid: (this.createModel.iccid ?? '') || null, - dataDaMureg: this.dateInputToIso(this.createModel.dataDaMureg) + dataDaMureg: new Date().toISOString() }; if (!payload.item || payload.item <= 0) delete payload.item; @@ -547,6 +723,11 @@ export class Mureg implements AfterViewInit { // EDIT MODAL // ======================================================================= onEditar(r: MuregRow) { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + this.preloadClients(); this.editOpen = true; @@ -614,6 +795,11 @@ export class Mureg implements AfterViewInit { } saveEdit() { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + if (!this.editModel || !this.editModel.id) return; const mobileLineId = String(this.editModel.mobileLineId ?? '').trim(); @@ -688,6 +874,11 @@ export class Mureg implements AfterViewInit { // DELETE MODAL // ======================================================================= onDelete(row: MuregRow) { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + this.deleteTarget = row; this.deleteOpen = true; this.deleteSaving = false; @@ -700,6 +891,11 @@ export class Mureg implements AfterViewInit { } async confirmDelete() { + if (!this.canManageRecords) { + await this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + if (!this.deleteTarget?.id) return; if (!(await confirmDeletionWithTyping('esta Mureg'))) return; @@ -758,6 +954,14 @@ export class Mureg implements AfterViewInit { return dt.toISOString(); } + private nowDateInput(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + private extractApiMessage(err: any): string | null { try { const m1 = err?.error?.message; @@ -770,6 +974,15 @@ export class Mureg implements AfterViewInit { } } + private getRawField(source: any, keys: string[]): string | null { + for (const key of keys) { + const value = source?.[key]; + if (value === undefined || value === null || String(value).trim() === '') continue; + return String(value); + } + return null; + } + displayValue(key: MuregKey, v: any): string { if (v === null || v === undefined || String(v).trim() === '') return '-'; diff --git a/src/app/pages/mve-auditoria/mve-auditoria.html b/src/app/pages/mve-auditoria/mve-auditoria.html new file mode 100644 index 0000000..26ccc58 --- /dev/null +++ b/src/app/pages/mve-auditoria/mve-auditoria.html @@ -0,0 +1,331 @@ +
+ +
+ +
+ + + + + +
+
+ + +
+
+
+ No sistema + {{ audit.summary.totalSystemLines }} +
+
+ No relatorio + {{ audit.summary.totalReportLines }} +
+
+ Sem diferenca + {{ audit.summary.totalConciliated }} +
+
+ Com diferenca + {{ totalDifferencesCount }} +
+
+ Prontas para atualizar + {{ syncableIssues.length }} +
+
+ Revisão manual + {{ manualReviewIssuesCount }} +
+
+ +
+ Só no sistema: {{ audit.summary.totalOnlyInSystem }} + Só no relatório: {{ audit.summary.totalOnlyInReport }} + Avisos/ignorados: {{ ignoredIssuesCount }} +
+ +
+
+
+ + + +
+ +
+ + + + +
+
+ +
+
+ + +
+ + +
+
+ +
+ +
Nenhuma divergência encontrada para o filtro atual.
+
+ +
+ + + + + + + + + + + + + + + + + + + +
NúmeroSistemaRelatórioSituaçãoAção
+
+ {{ issue.numeroLinha || issue.reportSnapshot?.numeroLinha || issue.systemSnapshot?.numeroLinha || '-' }} +
+
+
+
+ Sistema + Cadastro atual +
+
+
+ Linha + Alterada +
+ {{ formatValue(issue.systemSnapshot?.numeroLinha) }} +
+
+
+ Chip + Alterado +
+ {{ formatValue(issue.systemSnapshot?.chip) }} +
+
+
+ Status + Divergente +
+ {{ statusLabel(issue.systemStatus) }} +
+
+
+
+
+ Relatório + Importado do MVE +
+
+
+ Linha + Nova +
+ {{ formatValue(issue.reportSnapshot?.numeroLinha) }} +
+
+
+ Chip + Novo +
+ {{ formatValue(issue.reportSnapshot?.chip) }} +
+
+
+ Status + MVE +
+ {{ statusLabel(issue.reportStatus) }} +
+
+
+
+
+ {{ issueKindLabel(issue) }} +
+
+
+
{{ issue.notes }}
+
+
+
+ Pode atualizar + Atualizada + Revisar +
+
+
+ + +
+ + +
+ +
Nenhuma conferencia carregada ainda.
+ Envie o relatorio da Vivo para ver divergências de status, linha e chip antes de atualizar o sistema. +
+
+
+
+
diff --git a/src/app/pages/mve-auditoria/mve-auditoria.scss b/src/app/pages/mve-auditoria/mve-auditoria.scss new file mode 100644 index 0000000..2945256 --- /dev/null +++ b/src/app/pages/mve-auditoria/mve-auditoria.scss @@ -0,0 +1,949 @@ +:host { + --brand: #e33dcf; + --brand-deep: #972688; + --brand-soft: rgba(227, 61, 207, 0.1); + --ink: #17161d; + --muted: rgba(23, 22, 29, 0.68); + --surface: rgba(255, 255, 255, 0.84); + --surface-strong: rgba(255, 255, 255, 0.94); + --line: rgba(23, 22, 29, 0.08); + --shadow: 0 24px 60px rgba(24, 17, 33, 0.12); + --success: #198754; + --danger: #dc3545; + --warning: #ffb200; + + display: block; + color: var(--ink); + font-family: 'Inter', sans-serif; +} + +.mve-page { + min-height: 100vh; + position: relative; + overflow: hidden; + padding: 0 14px 120px; + background: + radial-gradient(720px 380px at 12% 8%, rgba(227, 61, 207, 0.16), transparent 60%), + radial-gradient(620px 360px at 88% 12%, rgba(3, 15, 170, 0.08), transparent 58%), + linear-gradient(180deg, #fff 0%, #f6f4f8 100%); +} + +.page-blob { + position: fixed; + border-radius: 999px; + filter: blur(48px); + pointer-events: none; + opacity: 0.42; + z-index: 0; + background: radial-gradient(circle at 30% 30%, rgba(227, 61, 207, 0.6), rgba(227, 61, 207, 0.04)); + + &.blob-1 { width: 420px; height: 420px; top: -180px; left: -120px; } + &.blob-2 { width: 520px; height: 520px; top: -220px; right: -220px; } + &.blob-3 { width: 360px; height: 360px; bottom: -180px; left: 20%; } + &.blob-4 { width: 420px; height: 420px; bottom: -200px; right: 12%; } +} + +.page-shell { + position: relative; + z-index: 1; + width: min(1480px, 98vw); + margin: 38px auto 0; +} + +.page-card { + background: var(--surface); + border: 1px solid rgba(255, 255, 255, 0.72); + border-radius: 28px; + box-shadow: var(--shadow); + backdrop-filter: blur(12px); + overflow: hidden; +} + +.page-header { + padding: 22px 24px 18px; + border-bottom: 1px solid var(--line); + background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.72)); + display: grid; + justify-items: center; +} + +.page-header > * { + width: min(1120px, 100%); +} + +.header-top { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + align-items: center; + justify-items: center; + text-align: center; +} + +.title-badge { + justify-self: center; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(227, 61, 207, 0.22); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55); + font-size: 13px; + font-weight: 800; + + i { + color: var(--brand); + } +} + +.header-title { + text-align: center; +} + +.title { + font-size: 28px; + font-weight: 950; + letter-spacing: -0.04em; + color: var(--ink); +} + +.subtitle { + color: var(--muted); + font-weight: 700; +} + +.header-actions { + justify-self: center; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; +} + +.btn { + border-radius: 14px; + font-weight: 800; +} + +.btn-glass { + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(23, 22, 29, 0.08); + color: var(--ink); +} + +.btn-brand { + background: linear-gradient(135deg, var(--brand), var(--brand-deep)); + border: 0; + color: #fff; + box-shadow: 0 12px 24px rgba(151, 38, 136, 0.24); +} + +.intro-card, +.upload-card, +.secondary-notes, +.toolbar, +.summary-grid, +.table-wrap, +.status-empty, +.empty-state { + margin-top: 18px; +} + +.intro-card, +.upload-card, +.toolbar, +.table-wrap, +.status-empty, +.empty-state { + background: var(--surface-strong); + border: 1px solid var(--line); + border-radius: 20px; + box-shadow: 0 18px 34px rgba(24, 17, 33, 0.08); +} + +.intro-card { + padding: 18px 20px; + display: grid; + gap: 14px; + text-align: center; +} + +.intro-title, +.section-title { + font-size: 14px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--brand-deep); +} + +.intro-text, +.section-subtitle { + color: var(--muted); + line-height: 1.55; +} + +.intro-meta, +.secondary-notes { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.meta-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: 999px; + background: rgba(24, 17, 33, 0.05); + border: 1px solid rgba(24, 17, 33, 0.08); + font-size: 12px; + font-weight: 700; + color: rgba(24, 17, 33, 0.78); +} + +.upload-card { + padding: 20px; + text-align: center; +} + +.section-head { + display: flex; + justify-content: center; + gap: 12px; + align-items: flex-start; +} + +.upload-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + margin-top: 16px; + align-items: center; +} + +.upload-zone { + display: grid; + place-items: center; + gap: 8px; + min-height: 180px; + padding: 20px; + border-radius: 22px; + border: 1px dashed rgba(227, 61, 207, 0.35); + background: + linear-gradient(135deg, rgba(227, 61, 207, 0.06), rgba(255, 255, 255, 0.85)), + #fff; + text-align: center; + cursor: pointer; + + input { + display: none; + } +} + +.upload-icon { + width: 62px; + height: 62px; + display: grid; + place-items: center; + border-radius: 20px; + background: rgba(227, 61, 207, 0.12); + color: var(--brand-deep); + font-size: 28px; +} + +.upload-title { + font-size: 18px; + font-weight: 900; + color: var(--ink); +} + +.upload-subtitle { + max-width: 540px; + color: var(--muted); + line-height: 1.45; +} + +.upload-actions { + display: grid; + gap: 10px; + justify-content: center; +} + +.apply-banner { + margin-top: 14px; + padding: 12px 14px; + border-radius: 16px; + background: rgba(25, 135, 84, 0.1); + border: 1px solid rgba(25, 135, 84, 0.18); + color: #11653d; + font-weight: 700; +} + +.page-body { + padding: 0 24px 24px; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 14px; +} + +.summary-card { + background: rgba(255, 255, 255, 0.94); + border: 1px solid var(--line); + border-radius: 20px; + padding: 18px; + display: grid; + gap: 10px; + box-shadow: 0 16px 30px rgba(24, 17, 33, 0.08); + + strong { + font-size: 30px; + line-height: 1; + letter-spacing: -0.05em; + } + + &.is-positive strong { + color: var(--success); + } + + &.is-danger strong { + color: var(--danger); + } + + &.is-brand strong { + color: var(--brand-deep); + } +} + +.summary-label { + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.toolbar { + padding: 14px 16px; + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} + +.toolbar-left { + display: grid; + gap: 12px; +} + +.view-tabs { + display: inline-flex; + gap: 8px; + flex-wrap: wrap; +} + +.type-filters { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.type-filter { + border: 1px solid rgba(24, 17, 33, 0.08); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 244, 248, 0.92)); + color: var(--ink); + border-radius: 18px; + padding: 10px 14px; + min-width: 152px; + display: grid; + gap: 2px; + justify-items: start; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72); + + span { + font-size: 11px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + } + + strong { + font-size: 16px; + letter-spacing: -0.03em; + } + + &.active { + transform: translateY(-1px); + } + + &.is-all.active { + background: rgba(151, 38, 136, 0.12); + border-color: rgba(151, 38, 136, 0.24); + color: var(--brand-deep); + } + + &.is-line.active { + background: rgba(3, 15, 170, 0.1); + border-color: rgba(3, 15, 170, 0.2); + color: #030faa; + } + + &.is-chip.active { + background: rgba(255, 178, 0, 0.14); + border-color: rgba(255, 178, 0, 0.26); + color: #8c6200; + } + + &.is-status.active { + background: rgba(220, 53, 69, 0.12); + border-color: rgba(220, 53, 69, 0.22); + color: #9f1d2d; + } +} + +.filter-tab { + border: 1px solid rgba(24, 17, 33, 0.08); + background: rgba(24, 17, 33, 0.04); + color: var(--ink); + border-radius: 999px; + padding: 8px 14px; + font-size: 12px; + font-weight: 800; + + &.active { + background: rgba(227, 61, 207, 0.14); + border-color: rgba(227, 61, 207, 0.24); + color: var(--brand-deep); + } +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.search-group { + min-width: min(360px, 86vw); +} + +.page-size-select { + min-width: 140px; +} + +.table-wrap { + overflow: hidden; + max-width: 1120px; + margin-left: auto; + margin-right: auto; +} + +.table-modern { + margin: 0; + width: 100%; + table-layout: fixed; + + thead th:nth-child(1) { + width: 16%; + } + + thead th:nth-child(2), + thead th:nth-child(3) { + width: 24%; + } + + thead th:nth-child(4) { + width: 24%; + } + + thead th:nth-child(5) { + width: 12%; + } + + thead th { + background: #faf7fc; + border-bottom: 1px solid var(--line); + color: rgba(24, 17, 33, 0.72); + font-size: 12px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 14px 16px; + text-align: center; + } + + tbody tr { + transition: background-color 160ms ease; + + &:hover { + background: rgba(151, 38, 136, 0.03); + } + + &.row-applied { + background: rgba(25, 135, 84, 0.03); + } + } + + tbody td { + padding: 16px; + border-top: 1px solid rgba(24, 17, 33, 0.06); + vertical-align: middle; + text-align: center; + } +} + +.cell-line, +.cell-situation, +.cell-action { + text-align: center; +} + +.cell-compare { + text-align: left; +} + +.line-cell-stack { + display: grid; + gap: 10px; + justify-items: center; +} + +.line-number-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 120px; + padding: 8px 14px; + border-radius: 999px; + background: rgba(3, 15, 170, 0.08); + border: 1px solid rgba(3, 15, 170, 0.18); + color: #030faa; + font-size: 0.83rem; + font-weight: 950; + letter-spacing: 0.02em; + box-shadow: + 0 8px 18px rgba(3, 15, 170, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.7); +} + +.issue-kind-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 900; + line-height: 1; + border: 1px solid transparent; +} + +.issue-kind-badge { + &.is-line { + background: rgba(3, 15, 170, 0.1); + border-color: rgba(3, 15, 170, 0.2); + color: #030faa; + } + + &.is-chip { + background: rgba(255, 178, 0, 0.16); + border-color: rgba(255, 178, 0, 0.26); + color: #8c6200; + } + + &.is-status { + background: rgba(220, 53, 69, 0.12); + border-color: rgba(220, 53, 69, 0.2); + color: #9f1d2d; + } + + &.is-review, + &.is-neutral { + background: rgba(24, 17, 33, 0.06); + border-color: rgba(24, 17, 33, 0.08); + color: rgba(24, 17, 33, 0.7); + } +} + +.status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 14px; + border-radius: 999px; + font-size: 12px; + font-weight: 900; + border: 1px solid transparent; + + &.is-active { + background: rgba(25, 135, 84, 0.12); + color: #11653d; + border-color: rgba(25, 135, 84, 0.18); + } + + &.is-blocked { + background: rgba(220, 53, 69, 0.12); + color: #9f1d2d; + border-color: rgba(220, 53, 69, 0.18); + } + + &.is-neutral { + background: rgba(24, 17, 33, 0.06); + color: rgba(24, 17, 33, 0.72); + border-color: rgba(24, 17, 33, 0.08); + } +} + +.issue-card { + display: grid; + gap: 10px; + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(24, 17, 33, 0.08); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 246, 250, 0.92)); + box-shadow: 0 14px 28px rgba(24, 17, 33, 0.06); + text-align: left; +} + +.issue-card-system { + border-color: rgba(3, 15, 170, 0.12); +} + +.issue-card-report { + border-color: rgba(151, 38, 136, 0.14); +} + +.issue-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(24, 17, 33, 0.08); +} + +.issue-card-eyebrow { + font-size: 11px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--brand-deep); +} + +.issue-card-caption { + font-size: 12px; + font-weight: 700; + color: var(--muted); +} + +.issue-row { + display: grid; + gap: 8px; + padding: 11px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.86); + border: 1px solid rgba(24, 17, 33, 0.06); + + &.is-different { + background: linear-gradient(180deg, rgba(227, 61, 207, 0.08), rgba(255, 255, 255, 0.96)); + border-color: rgba(227, 61, 207, 0.22); + box-shadow: inset 0 0 0 1px rgba(227, 61, 207, 0.08); + } +} + +.issue-row-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.issue-label { + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.field-diff-flag { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + background: rgba(227, 61, 207, 0.12); + color: var(--brand-deep); + font-size: 10px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.issue-value, +.issue-notes { + font-size: 13px; + line-height: 1.45; + color: rgba(24, 17, 33, 0.8); +} + +.issue-value { + font-weight: 800; + word-break: break-word; +} + +.issue-notes { + color: var(--muted); + text-align: center; +} + +.situation-card { + display: grid; + gap: 12px; + padding: 14px; + border-radius: 18px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(248, 246, 250, 0.93)); + box-shadow: 0 14px 28px rgba(24, 17, 33, 0.05); + justify-items: center; + text-align: center; + + &.is-applied { + background: linear-gradient(180deg, rgba(25, 135, 84, 0.06), rgba(255, 255, 255, 0.98)); + } +} + +.situation-top { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; +} + +.action-card { + display: grid; + gap: 10px; + justify-items: center; +} + +.sync-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 900; + + &.ready { + background: rgba(255, 178, 0, 0.16); + color: #8c6200; + font-size: 11px; + } + + &.applied { + background: rgba(25, 135, 84, 0.14); + color: #11653d; + } + + &.muted { + background: rgba(24, 17, 33, 0.06); + color: rgba(24, 17, 33, 0.62); + } +} + +.page-footer { + margin-top: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.status-empty, +.empty-state { + padding: 42px 20px; + text-align: center; + color: var(--muted); + display: grid; + gap: 8px; + place-items: center; + + i { + font-size: 34px; + color: var(--brand-deep); + } +} + +.empty-state small { + max-width: 560px; + line-height: 1.5; +} + +@media (max-width: 1100px) { + .header-top { + grid-template-columns: 1fr; + text-align: center; + } + + .title-badge, + .header-actions { + justify-self: center; + } + + .summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .upload-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .page-shell { + width: calc(100vw - 16px); + margin-top: 18px; + } + + .page-header, + .page-body { + padding-left: 14px; + padding-right: 14px; + } + + .summary-grid { + grid-template-columns: 1fr; + } + + .page-card { + border-radius: 20px; + } + + .page-header > *, + .page-body > * { + width: 100%; + } + + .toolbar-right, + .header-actions { + width: 100%; + } + + .header-actions .btn, + .upload-actions .btn { + width: 100%; + justify-content: center; + } + + .upload-zone { + min-height: 150px; + padding: 18px 14px; + } + + .upload-title { + font-size: 16px; + } + + .intro-meta, + .secondary-notes, + .view-tabs, + .toolbar, + .type-filters { + justify-content: center; + } + + .view-tabs { + width: 100%; + } + + .toolbar-left { + width: 100%; + } + + .type-filters { + width: 100%; + } + + .filter-tab { + flex: 1 1 120px; + justify-content: center; + text-align: center; + } + + .type-filter { + flex: 1 1 152px; + min-width: 0; + } + + .search-group, + .page-size-select { + width: 100%; + min-width: 0; + } + + .table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border-radius: 18px; + } + + .table-modern { + min-width: 1180px; + } + + .line-number-chip { + min-width: 108px; + font-size: 0.78rem; + padding: 7px 12px; + } + + .page-footer { + justify-content: center; + text-align: center; + } +} + +@media (max-width: 420px) { + .mve-page { + padding-left: 8px; + padding-right: 8px; + } + + .page-header, + .page-body { + padding-left: 12px; + padding-right: 12px; + } + + .title { + font-size: 24px; + } + + .subtitle { + font-size: 0.88rem; + } + + .title-badge, + .meta-pill, + .filter-tab, + .status-pill, + .sync-badge { + font-size: 11px; + } + + .page-footer .pagination { + justify-content: center; + flex-wrap: wrap; + } +} diff --git a/src/app/pages/mve-auditoria/mve-auditoria.ts b/src/app/pages/mve-auditoria/mve-auditoria.ts new file mode 100644 index 0000000..c30128a --- /dev/null +++ b/src/app/pages/mve-auditoria/mve-auditoria.ts @@ -0,0 +1,477 @@ +import { Component, ChangeDetectorRef, ElementRef, Inject, OnInit, PLATFORM_ID, ViewChild } from '@angular/core'; +import { CommonModule, isPlatformBrowser } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +import { + MveAuditService, + type ApplyMveAuditResult, + type MveAuditIssue, + type MveAuditRun, +} from '../../services/mve-audit.service'; +import { confirmActionModal } from '../../utils/destructive-confirmation'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages, +} from '../../utils/pagination.util'; + +type MveIssueViewMode = 'PENDING' | 'APPLIED' | 'ALL'; +type MveIssueCategory = 'ALL' | 'STATUS' | 'LINE' | 'CHIP'; + +@Component({ + selector: 'app-mve-auditoria', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './mve-auditoria.html', + styleUrls: ['./mve-auditoria.scss'], +}) +export class MveAuditoriaPage implements OnInit { + @ViewChild('feedbackToast', { static: false }) feedbackToast?: ElementRef; + + loadingLatest = false; + processing = false; + syncing = false; + selectedFile: File | null = null; + auditResult: MveAuditRun | null = null; + applyResult: ApplyMveAuditResult | null = null; + errorMessage = ''; + toastMessage = ''; + + searchTerm = ''; + viewMode: MveIssueViewMode = 'PENDING'; + issueCategory: MveIssueCategory = 'ALL'; + page = 1; + pageSize = 20; + readonly pageSizeOptions = [10, 20, 50, 100]; + + constructor( + private readonly mveAuditService: MveAuditService, + private readonly cdr: ChangeDetectorRef, + @Inject(PLATFORM_ID) private readonly platformId: object + ) {} + + ngOnInit(): void { + this.errorMessage = ''; + const cachedRun = this.mveAuditService.getCachedRun(); + if (cachedRun) { + this.auditResult = cachedRun; + return; + } + + void this.restoreCachedAudit(); + } + + get hasAuditResult(): boolean { + return !!this.auditResult; + } + + get syncableIssues(): MveAuditIssue[] { + return this.relevantIssues.filter((issue) => issue.syncable && !issue.applied); + } + + get relevantIssues(): MveAuditIssue[] { + const issues = this.auditResult?.issues ?? []; + return issues + .filter((issue) => this.issueHasRelevantDifference(issue)) + .sort((left, right) => Number(left.applied) - Number(right.applied)); + } + + get filteredIssues(): MveAuditIssue[] { + const query = this.normalizeSearch(this.searchTerm); + return this.relevantIssues.filter((issue) => { + if (this.viewMode === 'PENDING' && issue.applied) return false; + if (this.viewMode === 'APPLIED' && !issue.applied) return false; + if (!this.matchesIssueCategory(issue)) return false; + if (!query) return true; + + const haystack = [ + issue.numeroLinha, + issue.issueType, + issue.actionSuggestion, + issue.systemStatus, + issue.reportStatus, + issue.systemSnapshot?.numeroLinha, + issue.reportSnapshot?.numeroLinha, + issue.systemSnapshot?.chip, + issue.reportSnapshot?.chip, + issue.situation, + issue.notes, + ...(issue.differences ?? []).flatMap((difference) => [ + difference.label, + difference.systemValue, + difference.reportValue, + ]), + ] + .map((value) => this.normalizeSearch(value)) + .join(' '); + + return haystack.includes(query); + }); + } + + get pagedIssues(): MveAuditIssue[] { + const offset = (this.page - 1) * this.pageSize; + return this.filteredIssues.slice(offset, offset + this.pageSize); + } + + get totalPages(): number { + return computeTotalPages(this.filteredIssues.length, this.pageSize); + } + + get pageNumbers(): number[] { + return buildPageNumbers(this.page, this.totalPages); + } + + get pageStart(): number { + return computePageStart(this.filteredIssues.length, this.page, this.pageSize); + } + + get pageEnd(): number { + return computePageEnd(this.filteredIssues.length, this.page, this.pageSize); + } + + get totalDifferencesCount(): number { + if (!this.auditResult) return 0; + return this.auditResult.summary.totalStatusDivergences + this.auditResult.summary.totalDataDivergences; + } + + get manualReviewIssuesCount(): number { + return this.relevantIssues.filter((issue) => !issue.syncable && !issue.applied).length; + } + + get statusIssuesCount(): number { + return this.relevantIssues.filter((issue) => this.hasDifference(issue, 'status')).length; + } + + get lineIssuesCount(): number { + return this.relevantIssues.filter((issue) => this.hasDifference(issue, 'line')).length; + } + + get chipIssuesCount(): number { + return this.relevantIssues.filter((issue) => this.hasDifference(issue, 'chip')).length; + } + + get ignoredIssuesCount(): number { + if (!this.auditResult) return 0; + const summary = this.auditResult.summary; + return ( + summary.totalOnlyInSystem + + summary.totalOnlyInReport + + summary.totalDuplicateReportLines + + summary.totalDuplicateSystemLines + + summary.totalInvalidRows + + summary.totalUnknownStatuses + ); + } + + async loadLatestAudit(): Promise { + this.loadingLatest = true; + this.errorMessage = ''; + + try { + this.auditResult = await firstValueFrom(this.mveAuditService.getLatest()); + this.applyResult = null; + this.issueCategory = 'ALL'; + this.page = 1; + } catch (error) { + const httpError = error as HttpErrorResponse | null; + if (httpError?.status !== 404) { + this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel carregar a ultima conferencia.'); + } + this.auditResult = null; + } finally { + this.loadingLatest = false; + } + } + + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement | null; + const file = input?.files?.[0] ?? null; + + this.errorMessage = ''; + this.applyResult = null; + + if (!file) { + this.selectedFile = null; + return; + } + + if (!file.name.toLowerCase().endsWith('.csv')) { + this.selectedFile = null; + this.errorMessage = 'Selecione o relatorio exportado pela Vivo.'; + return; + } + + if (file.size <= 0) { + this.selectedFile = null; + this.errorMessage = 'O arquivo selecionado está vazio.'; + return; + } + + if (file.size > 20 * 1024 * 1024) { + this.selectedFile = null; + this.errorMessage = 'O arquivo excede o limite de 20 MB.'; + return; + } + + this.selectedFile = file; + } + + clearSelectedFile(): void { + this.selectedFile = null; + this.errorMessage = ''; + } + + async processAudit(): Promise { + if (!this.selectedFile || this.processing || this.syncing) { + return; + } + + this.processing = true; + this.errorMessage = ''; + this.applyResult = null; + + try { + this.auditResult = await firstValueFrom(this.mveAuditService.preview(this.selectedFile)); + this.issueCategory = 'ALL'; + this.page = 1; + this.viewMode = 'PENDING'; + this.searchTerm = ''; + await this.showToast('Relatorio conferido com sucesso.'); + } catch (error) { + this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel conferir o relatorio.'); + } finally { + this.processing = false; + } + } + + async syncIssues(): Promise { + if (!this.auditResult || this.syncableIssues.length === 0 || this.syncing) { + return; + } + + const confirmed = await confirmActionModal({ + title: 'Atualizar sistema', + message: `${this.syncableIssues.length} ocorrência(s) sincronizável(is) serão aplicadas com base no relatório da Vivo.`, + confirmLabel: 'Atualizar agora', + cancelLabel: 'Cancelar', + tone: 'warning', + }); + + if (!confirmed) { + return; + } + + this.syncing = true; + this.errorMessage = ''; + + try { + this.applyResult = await firstValueFrom(this.mveAuditService.apply(this.auditResult.id)); + this.auditResult = await firstValueFrom(this.mveAuditService.getById(this.auditResult.id)); + this.viewMode = 'ALL'; + this.page = 1; + await this.showToast('Atualizações aplicadas com sucesso.'); + } catch (error) { + this.errorMessage = this.extractHttpMessage(error, 'Nao foi possivel atualizar o sistema.'); + } finally { + this.syncing = false; + } + } + + onSearchChange(): void { + this.page = 1; + } + + onPageSizeChange(): void { + this.page = 1; + } + + setViewMode(mode: MveIssueViewMode): void { + this.viewMode = mode; + this.page = 1; + } + + setIssueCategory(category: MveIssueCategory): void { + this.issueCategory = category; + this.page = 1; + } + + goToPage(page: number): void { + this.page = clampPage(page, this.totalPages); + } + + trackByIssue(_: number, issue: MveAuditIssue): string { + return issue.id; + } + + formatDateTime(value?: string | null): string { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return new Intl.DateTimeFormat('pt-BR', { + dateStyle: 'short', + timeStyle: 'short', + }).format(date); + } + + statusClass(status: string | null | undefined): string { + const normalized = (status ?? '').toLowerCase(); + if (normalized.includes('bloq') || normalized.includes('perda') || normalized.includes('roubo')) return 'is-blocked'; + if (normalized.includes('ativo')) return 'is-active'; + return 'is-neutral'; + } + + statusLabel(status: string | null | undefined): string { + const value = (status ?? '').trim(); + return value || '-'; + } + + hasDifference(issue: MveAuditIssue, fieldKey: string): boolean { + return (issue.differences ?? []).some((difference) => difference.fieldKey === fieldKey); + } + + formatValue(value?: string | null): string { + const normalized = (value ?? '').trim(); + return normalized || '-'; + } + + issueKindLabel(issue: MveAuditIssue): string { + const hasLine = this.hasDifference(issue, 'line'); + const hasChip = this.hasDifference(issue, 'chip'); + const hasStatus = this.hasDifference(issue, 'status'); + + if (hasLine && hasStatus) return 'Troca de linha + status'; + if (hasChip && hasStatus) return 'Troca de chip + status'; + if (hasLine) return 'Troca de linha'; + if (hasChip) return 'Troca de chip'; + if (hasStatus) return 'Status'; + if (issue.issueType === 'DDD_CHANGE_REVIEW') return 'Revisão de DDD'; + return 'Revisão'; + } + + issueKindClass(issue: MveAuditIssue): string { + if (this.hasDifference(issue, 'line')) return 'is-line'; + if (this.hasDifference(issue, 'chip')) return 'is-chip'; + if (this.hasDifference(issue, 'status')) return 'is-status'; + return issue.syncable ? 'is-neutral' : 'is-review'; + } + + situationClass(issue: MveAuditIssue): string { + if (issue.applied) return 'is-applied'; + if (!issue.syncable) return 'is-review'; + return this.issueKindClass(issue); + } + + severityClass(severity: string | null | undefined): string { + const normalized = (severity ?? '').trim().toUpperCase(); + if (normalized === 'HIGH') return 'is-high'; + if (normalized === 'MEDIUM') return 'is-medium'; + if (normalized === 'WARNING') return 'is-warning'; + return 'is-neutral'; + } + + severityLabel(severity: string | null | undefined): string { + const normalized = (severity ?? '').trim().toUpperCase(); + if (normalized === 'HIGH') return 'Alta'; + if (normalized === 'MEDIUM') return 'Media'; + if (normalized === 'WARNING') return 'Aviso'; + return 'Info'; + } + + describeIssue(issue: MveAuditIssue): string { + const differences = issue.differences ?? []; + if (!differences.length) { + return issue.notes?.trim() || 'Sem diferenças detalhadas.'; + } + + return differences + .map((difference) => `${difference.label}: ${this.formatValue(difference.systemValue)} -> ${this.formatValue(difference.reportValue)}`) + .join(' | '); + } + + private issueHasRelevantDifference(issue: MveAuditIssue): boolean { + return (issue.differences ?? []).length > 0; + } + + private async restoreCachedAudit(): Promise { + const cachedRunId = this.mveAuditService.getCachedRunId(); + if (!cachedRunId) { + return; + } + + this.loadingLatest = true; + + try { + const restoredRun = await firstValueFrom(this.mveAuditService.restoreCachedRun()); + if (!restoredRun) { + return; + } + + this.auditResult = restoredRun; + this.applyResult = null; + this.issueCategory = 'ALL'; + this.page = 1; + } finally { + this.loadingLatest = false; + } + } + + private matchesIssueCategory(issue: MveAuditIssue): boolean { + switch (this.issueCategory) { + case 'STATUS': + return this.hasDifference(issue, 'status'); + case 'LINE': + return this.hasDifference(issue, 'line'); + case 'CHIP': + return this.hasDifference(issue, 'chip'); + default: + return true; + } + } + + private normalizeSearch(value: unknown): string { + return (value ?? '') + .toString() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); + } + + private extractHttpMessage(error: unknown, fallbackMessage: string): string { + const httpError = error as HttpErrorResponse | null; + if (httpError?.status === 0) { + return 'A API do LineGestao nao respondeu em http://localhost:5298. Inicie o backend e tente novamente.'; + } + + return ( + (httpError?.error as { message?: string } | null)?.message || + httpError?.message || + fallbackMessage + ); + } + + private async showToast(message: string): Promise { + if (!isPlatformBrowser(this.platformId)) return; + + this.toastMessage = message; + this.cdr.detectChanges(); + + if (!this.feedbackToast?.nativeElement) return; + + try { + const bootstrap = await import('bootstrap'); + const instance = bootstrap.Toast.getOrCreateInstance(this.feedbackToast.nativeElement, { + autohide: true, + delay: 3200, + }); + instance.show(); + } catch { + // ignora falha de feedback visual + } + } +} diff --git a/src/app/pages/notificacoes/notificacoes.html b/src/app/pages/notificacoes/notificacoes.html index a9517bc..fa6e752 100644 --- a/src/app/pages/notificacoes/notificacoes.html +++ b/src/app/pages/notificacoes/notificacoes.html @@ -69,7 +69,7 @@ + +
{{ toastMessage }}
+ + +
@@ -65,7 +79,8 @@ [total]="total" [pageSize]="pageSize" [pageSizeOptions]="pageSizeOptions" - [isSysAdmin]="isSysAdmin" + [canEdit]="$any(canManageRecords)" + [canDelete]="$any(isSysAdmin)" (segmentChange)="setSegment($event)" (detail)="openDetails($event)" (edit)="openEdit($event)" @@ -77,175 +92,4 @@
- -
-
-
- - - - - -
-
- - - - - - - - -
-
- -
+ diff --git a/src/app/pages/parcelamentos/parcelamentos.scss b/src/app/pages/parcelamentos/parcelamentos.scss index 6c25851..d606fec 100644 --- a/src/app/pages/parcelamentos/parcelamentos.scss +++ b/src/app/pages/parcelamentos/parcelamentos.scss @@ -208,6 +208,13 @@ box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08); } +.btn-export-glass { + color: var(--pg-primary); + background: rgba(255, 255, 255, 0.68); + border-color: rgba(31, 79, 214, 0.24); + font-weight: 900; +} + .btn-danger { color: #fff; background: linear-gradient(145deg, #cf3131, #a91f1f); @@ -226,24 +233,10 @@ color: var(--pg-primary-strong); } -.lg-backdrop { - position: fixed; - inset: 0; - background: - radial-gradient(circle at 15% 0%, rgba(31, 79, 214, 0.16), rgba(15, 23, 42, 0.64) 42%), - rgba(15, 23, 42, 0.6); - z-index: 9990; - backdrop-filter: blur(4px); -} - -.lg-modal { - position: fixed; - inset: 0; - z-index: 9995; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; +.btn-export-glass:hover { + background: #fff; + border-color: rgba(227, 61, 207, 0.3); + color: #e33dcf; } .lg-modal-card { diff --git a/src/app/pages/parcelamentos/parcelamentos.ts b/src/app/pages/parcelamentos/parcelamentos.ts index 0b2d3f1..30ec4bc 100644 --- a/src/app/pages/parcelamentos/parcelamentos.ts +++ b/src/app/pages/parcelamentos/parcelamentos.ts @@ -2,8 +2,10 @@ import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { environment } from '../../../environments/environment'; -import { finalize, Subscription, timeout } from 'rxjs'; +import { finalize, Subscription, firstValueFrom, timeout } from 'rxjs'; +import { ParcelamentosModalsComponent } from '../../components/page-modals/parcelamento-modals/parcelamentos-modals'; import { AuthService } from '../../services/auth.service'; +import { TableExportService } from '../../services/table-export.service'; import { ParcelamentosService, ParcelamentoListItem, @@ -18,27 +20,35 @@ import { import { ParcelamentosKpisComponent, ParcelamentoKpi, -} from './components/parcelamentos-kpis/parcelamentos-kpis'; +} from '../../components/page-modals/parcelamento-modals/components/parcelamentos-kpis/parcelamentos-kpis'; import { ParcelamentosFiltersComponent, ParcelamentosFiltersModel, FilterChip, -} from './components/parcelamentos-filters/parcelamentos-filters'; +} from '../../components/page-modals/parcelamento-modals/components/parcelamentos-filters/parcelamentos-filters'; import { ParcelamentosTableComponent, ParcelamentoSegment, ParcelamentoViewItem, -} from './components/parcelamentos-table/parcelamentos-table'; +} from '../../components/page-modals/parcelamento-modals/components/parcelamentos-table/parcelamentos-table'; import { - ParcelamentoCreateModalComponent, ParcelamentoCreateModel, -} from './components/parcelamento-create-modal/parcelamento-create-modal'; +} from '../../components/page-modals/parcelamento-modals/components/parcelamento-create-modal/parcelamento-create-modal'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; +import { normalizeAccentInsensitive } from '../../utils/text-normalization.util'; type MonthOption = { value: number; label: string }; type ParcelamentoStatus = 'ativos' | 'futuros' | 'finalizados'; type AnnualMonthValue = { month: number; label: string; value: number | null }; type AnnualRow = { year: number; total: number; months: AnnualMonthValue[] }; +type ParcelamentoExportRow = ParcelamentoViewItem & Partial; @Component({ selector: 'app-parcelamentos', @@ -46,17 +56,23 @@ type AnnualRow = { year: number; total: number; months: AnnualMonthValue[] }; imports: [ CommonModule, FormsModule, + ParcelamentosModalsComponent, ParcelamentosKpisComponent, ParcelamentosFiltersComponent, ParcelamentosTableComponent, - ParcelamentoCreateModalComponent, ], templateUrl: './parcelamentos.html', styleUrls: ['./parcelamentos.scss'], }) export class Parcelamentos implements OnInit, OnDestroy { + readonly vm = this; loading = false; + exporting = false; errorMessage = ''; + toastOpen = false; + toastMessage = ''; + toastType: 'success' | 'danger' = 'success'; + private toastTimer: ReturnType | null = null; debugMode = !environment.production; @@ -88,6 +104,12 @@ export class Parcelamentos implements OnInit, OnDestroy { activeChips: FilterChip[] = []; isSysAdmin = false; + isGestor = false; + isFinanceiro = false; + + get canManageRecords(): boolean { + return this.isSysAdmin || this.isGestor; + } detailOpen = false; detailLoading = false; @@ -137,7 +159,8 @@ export class Parcelamentos implements OnInit, OnDestroy { constructor( private parcelamentosService: ParcelamentosService, - private authService: AuthService + private authService: AuthService, + private tableExportService: TableExportService ) {} ngOnInit(): void { @@ -147,6 +170,7 @@ export class Parcelamentos implements OnInit, OnDestroy { ngOnDestroy(): void { this.cancelDetailRequest(); + if (this.toastTimer) clearTimeout(this.toastTimer); } @HostListener('document:keydown.escape') @@ -159,30 +183,24 @@ export class Parcelamentos implements OnInit, OnDestroy { private syncPermissions(): void { this.isSysAdmin = this.authService.hasRole('sysadmin'); + this.isGestor = this.authService.hasRole('gestor'); + this.isFinanceiro = this.authService.hasRole('financeiro'); } get totalPages(): number { - return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); + return computeTotalPages(this.total || 0, this.pageSize || 10); } get pageNumbers(): number[] { - const total = this.totalPages; - const current = this.page; - const max = 5; - let start = Math.max(1, current - 2); - let end = Math.min(total, start + (max - 1)); - start = Math.max(1, end - (max - 1)); - const pages: number[] = []; - for (let i = start; i <= end; i++) pages.push(i); - return pages; + return buildPageNumbers(this.page, this.totalPages); } get pageStart(): number { - return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; + return computePageStart(this.total || 0, this.page, this.pageSize); } get pageEnd(): number { - return this.total === 0 ? 0 : Math.min(this.page * this.pageSize, this.total); + return computePageEnd(this.total || 0, this.page, this.pageSize); } get competenciaInvalid(): boolean { @@ -273,6 +291,50 @@ export class Parcelamentos implements OnInit, OnDestroy { this.load(); } + async onExport(): Promise { + if (this.exporting) return; + this.exporting = true; + + try { + const baseRows = await this.fetchAllItemsForExport(); + const rows = await this.fetchDetailedItemsForExport(baseRows); + if (!rows.length) { + this.showToast('Nenhum registro encontrado para exportar.', 'danger'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `parcelamentos_${this.activeSegment}_${timestamp}`, + sheetName: 'Parcelamentos', + rows, + columns: [ + { header: 'ID', value: (row) => row.id ?? '' }, + { header: 'Ano Ref', type: 'number', value: (row) => this.toNumber(row.anoRef) ?? 0 }, + { header: 'Item', type: 'number', value: (row) => this.toNumber(row.item) ?? 0 }, + { header: 'Linha', value: (row) => row.linha ?? '' }, + { header: 'Cliente', value: (row) => row.cliente ?? '' }, + { header: 'Status', value: (row) => row.statusLabel }, + { header: 'Parcela Atual', type: 'number', value: (row) => this.toNumber(row.parcelaAtual) ?? 0 }, + { header: 'Total Parcelas', type: 'number', value: (row) => this.toNumber(row.totalParcelas) ?? 0 }, + { header: 'Qt Parcelas', value: (row) => row.qtParcelas ?? '' }, + { header: 'Valor Cheio', type: 'currency', value: (row) => this.toNumber(row.valorCheio) ?? 0 }, + { header: 'Desconto', type: 'currency', value: (row) => this.toNumber(row.desconto) ?? 0 }, + { header: 'Valor c/ Desconto', type: 'currency', value: (row) => this.toNumber(row.valorComDesconto) ?? 0 }, + { header: 'Valor Parcela', type: 'currency', value: (row) => this.toNumber(row.valorParcela) ?? 0 }, + { header: 'Parcelas Mensais', value: (row) => this.stringifyParcelasMensais(row.parcelasMensais) }, + { header: 'Detalhamento Anual', value: (row) => this.stringifyAnnualRows(row.annualRows) }, + ], + }); + + this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success'); + } catch { + this.showToast('Erro ao exportar planilha.', 'danger'); + } finally { + this.exporting = false; + } + } + onPageSizeChange(size: number): void { this.pageSize = size; this.page = 1; @@ -280,7 +342,7 @@ export class Parcelamentos implements OnInit, OnDestroy { } goToPage(p: number): void { - this.page = Math.max(1, Math.min(this.totalPages, p)); + this.page = clampPage(p, this.totalPages); this.load(); } @@ -356,6 +418,11 @@ export class Parcelamentos implements OnInit, OnDestroy { } openCreateModal(): void { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger'); + return; + } + this.createModel = this.buildCreateModel(); this.createError = ''; this.createOpen = true; @@ -368,6 +435,11 @@ export class Parcelamentos implements OnInit, OnDestroy { } saveNewParcelamento(model: ParcelamentoCreateModel): void { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger'); + return; + } + if (this.createSaving) return; this.createSaving = true; this.createError = ''; @@ -386,6 +458,11 @@ export class Parcelamentos implements OnInit, OnDestroy { } openEdit(item: ParcelamentoListItem): void { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger'); + return; + } + const id = this.getItemId(item); if (!id) return; this.editOpen = true; @@ -421,6 +498,11 @@ export class Parcelamentos implements OnInit, OnDestroy { } saveEditParcelamento(model: ParcelamentoCreateModel): void { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.', 'danger'); + return; + } + if (this.editSaving || !this.editModel || !this.editId) return; this.editSaving = true; this.editError = ''; @@ -669,7 +751,7 @@ export class Parcelamentos implements OnInit, OnDestroy { } private applySearch(list: ParcelamentoViewItem[], term: string): ParcelamentoViewItem[] { - const search = this.normalizeText(term); + const search = normalizeAccentInsensitive(term); if (!search) return list; return list.filter((item) => { const payload = [ @@ -681,10 +763,129 @@ export class Parcelamentos implements OnInit, OnDestroy { ] .map((v) => (v ?? '').toString()) .join(' '); - return this.normalizeText(payload).includes(search); + return normalizeAccentInsensitive(payload).includes(search); }); } + private async fetchAllItemsForExport(): Promise { + const anoRef = this.parseNumber(this.filters.anoRef); + const competenciaAno = this.parseNumber(this.filters.competenciaAno); + const competenciaMes = this.parseNumber(this.filters.competenciaMes); + const sendCompetencia = competenciaAno !== null && competenciaMes !== null; + + const pageSize = 500; + let page = 1; + let expectedTotal = 0; + const allItems: ParcelamentoListItem[] = []; + + while (page <= 500) { + const response = await firstValueFrom( + this.parcelamentosService.list({ + anoRef: anoRef ?? undefined, + linha: this.filters.linha?.trim() || undefined, + cliente: this.filters.cliente?.trim() || undefined, + competenciaAno: sendCompetencia ? competenciaAno ?? undefined : undefined, + competenciaMes: sendCompetencia ? competenciaMes ?? undefined : undefined, + page, + pageSize, + }) + ); + + const normalized = this.normalizeListResponse(response); + allItems.push(...normalized.items); + expectedTotal = normalized.total; + + if (normalized.items.length === 0) break; + if (normalized.items.length < pageSize) break; + if (expectedTotal > 0 && allItems.length >= expectedTotal) break; + page += 1; + } + + const base = allItems.map((item) => this.toViewItem(item)); + const searched = this.applySearch(base, this.filters.search); + return this.activeSegment === 'todos' + ? searched + : searched.filter((item) => item.status === this.activeSegment); + } + + private async fetchDetailedItemsForExport(rows: ParcelamentoViewItem[]): Promise { + if (!rows.length) return []; + + const detailedRows: ParcelamentoExportRow[] = []; + const chunkSize = 10; + + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + const resolved = await Promise.all( + chunk.map(async (row) => { + const id = this.getItemId(row); + if (!id) return row; + try { + const detailRes = await firstValueFrom(this.parcelamentosService.getById(id)); + const detail = this.normalizeDetail(detailRes); + return { + ...row, + ...detail, + }; + } catch { + return row; + } + }) + ); + detailedRows.push(...resolved); + } + + return detailedRows; + } + + private stringifyParcelasMensais(parcelas?: ParcelamentoParcela[] | null): string { + if (!parcelas?.length) return ''; + return parcelas + .map((parcela) => { + const competencia = (parcela.competencia ?? '').toString().trim(); + const valor = this.toNumber(parcela.valor); + const valorFmt = valor === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor); + return `${competencia || '-'}: ${valorFmt}`; + }) + .join(' | '); + } + + private stringifyAnnualRows(rows?: ParcelamentoAnnualRow[] | null): string { + if (!rows?.length) return ''; + return rows + .map((row) => { + const year = this.parseNumber(row.year); + const total = this.toNumber(row.total); + const totalFmt = total === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(total); + + const months = (row.months ?? []) + .map((month) => { + const monthNum = this.parseNumber(month.month); + const monthValue = this.toNumber(month.valor); + const monthLabel = monthNum ? String(monthNum).padStart(2, '0') : '--'; + const monthFmt = monthValue === null ? '-' : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(monthValue); + return `${monthLabel}:${monthFmt}`; + }) + .join(', '); + + return `${year ?? '----'} (Total ${totalFmt})${months ? ` [${months}]` : ''}`; + }) + .join(' | '); + } + + private normalizeListResponse(response: any): { items: ParcelamentoListItem[]; total: number } { + const anyRes: any = response ?? {}; + const items = Array.isArray(anyRes.items) + ? anyRes.items.filter(Boolean) + : Array.isArray(anyRes.Items) + ? anyRes.Items.filter(Boolean) + : []; + const total = typeof anyRes.total === 'number' + ? anyRes.total + : (typeof anyRes.Total === 'number' ? anyRes.Total : 0); + return { items, total }; + } + private resolveStatus(item: ParcelamentoListItem): ParcelamentoStatus { const total = this.toNumber(item.totalParcelas); const atual = this.toNumber(item.parcelaAtual); @@ -982,13 +1183,12 @@ export class Parcelamentos implements OnInit, OnDestroy { return Number.isNaN(n) ? null : n; } - private normalizeText(value: any): string { - return (value ?? '') - .toString() - .trim() - .toUpperCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, ''); + private showToast(message: string, type: 'success' | 'danger'): void { + this.toastMessage = message; + this.toastType = type; + this.toastOpen = true; + if (this.toastTimer) clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastOpen = false), 3000); } private onlyDigits(value: string): string { diff --git a/src/app/pages/resumo/resumo.html b/src/app/pages/resumo/resumo.html index 6edb6da..24b7125 100644 --- a/src/app/pages/resumo/resumo.html +++ b/src/app/pages/resumo/resumo.html @@ -1,4 +1,14 @@ 
+
+
+
+ LineGestao + +
+
{{ toastMessage }}
+
+
+
@@ -118,9 +128,9 @@ {{ macrophonyCompact ? 'Expandir' : 'Compactar' }} -
@@ -437,9 +447,9 @@ {{ group.compact ? 'Expandir' : 'Compactar' }} - diff --git a/src/app/pages/resumo/resumo.scss b/src/app/pages/resumo/resumo.scss index 80a7539..2f3f3f6 100644 --- a/src/app/pages/resumo/resumo.scss +++ b/src/app/pages/resumo/resumo.scss @@ -190,6 +190,19 @@ font-size: 12px; } +.btn-export-glass { + color: var(--blue); + border-color: rgba(3, 15, 170, 0.22); + background: rgba(255, 255, 255, 0.72); + font-weight: 800; +} + +.btn-export-glass:hover:not(:disabled) { + border-color: var(--brand); + color: var(--brand); + background: #fff; +} + .btn-icon { width: 32px; height: 32px; diff --git a/src/app/pages/resumo/resumo.ts b/src/app/pages/resumo/resumo.ts index 3a7706d..7efaf4e 100644 --- a/src/app/pages/resumo/resumo.ts +++ b/src/app/pages/resumo/resumo.ts @@ -31,7 +31,17 @@ import { ReservaPorDdd, ReservaTotal } from '../../services/resumo.service'; +import { TableExportService, type ExportCellType } from '../../services/table-export.service'; import { environment } from '../../../environments/environment'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; +import { normalizeAccentInsensitive } from '../../utils/text-normalization.util'; +import { buildApiBaseUrl } from '../../utils/api-base.util'; type ResumoTab = 'planos' | 'clientes' | 'totais' | 'reserva'; @@ -85,6 +95,11 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { loading = false; errorMessage = ''; + toastOpen = false; + toastMessage = ''; + toastType: 'success' | 'danger' = 'success'; + private toastTimer: ReturnType | null = null; + private exportingKeys = new Set(); resumo: ResumoResponse | null = null; activeTab: ResumoTab = 'planos'; @@ -139,10 +154,10 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { private resumoService: ResumoService, private route: ActivatedRoute, private router: Router, - private cdr: ChangeDetectorRef + private cdr: ChangeDetectorRef, + private tableExportService: TableExportService ) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); this.initTables(); this.initGroupTables(); // Default chart configuration for Enterprise look @@ -172,6 +187,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { ngOnDestroy(): void { Object.values(this.charts).forEach(c => c?.destroy()); + if (this.toastTimer) clearTimeout(this.toastTimer); } setTab(tab: ResumoTab): void { @@ -636,7 +652,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { toggleMacrophonyGroup(key: string) { if (this.macrophonyOpen.has(key)) this.macrophonyOpen.delete(key); else this.macrophonyOpen.add(key); } openMacrophonyDetail(g: MacrophonyGroup) { this.macrophonyDetailGroup = g; this.macrophonyDetailOpen = true; } closeMacrophonyDetail() { this.macrophonyDetailOpen = false; this.macrophonyDetailGroup = null; } - goToMacrophonyPage(p: number) { this.macrophonyPage = p; this.updateMacrophonyView(); } + goToMacrophonyPage(p: number) { this.macrophonyPage = clampPage(p, this.macrophonyTotalPages); this.updateMacrophonyView(); } onGroupedSearch(g: GroupedTableState, value?: string) { if (typeof value === 'string') g.search = value; @@ -644,25 +660,19 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { this.updateGroupView(g); } toggleGroupedCompact(g: GroupedTableState) { g.compact = !g.compact; } - exportGroupedCsv(g: GroupedTableState, file: string) { this.exportCsv(g.table, file); } + exportGroupedCsv(g: GroupedTableState, file: string) { void this.exportTableAsXlsx(g.table, file); } isGroupedOpen(g: GroupedTableState, key: string) { return g.open.has(key); } toggleGroupedOpen(g: GroupedTableState, key: string) { if (g.open.has(key)) g.open.delete(key); else g.open.add(key); } openGroupedDetail(g: GroupedTableState, item: GroupItem) { g.detailGroup = item; g.detailOpen = true; } closeGroupedDetail(g: GroupedTableState) { g.detailOpen = false; g.detailGroup = null; } - getGroupedPageStart(g: GroupedTableState) { return g.filtered.length ? ((g.page - 1) * g.pageSize + 1) : 0; } - getGroupedPageEnd(g: GroupedTableState) { return g.filtered.length ? Math.min(g.page * g.pageSize, g.filtered.length) : 0; } + getGroupedPageStart(g: GroupedTableState) { return computePageStart(g.filtered.length, g.page, g.pageSize); } + getGroupedPageEnd(g: GroupedTableState) { return computePageEnd(g.filtered.length, g.page, g.pageSize); } getGroupedPageNumbers(g: GroupedTableState) { - const total = this.getGroupedTotalPages(g); - if (total <= 1) return [1]; - const current = Math.min(Math.max(g.page, 1), total); - const start = Math.max(1, current - 2); - const end = Math.min(total, start + 4); - const adjustedStart = Math.max(1, end - 4); - return Array.from({ length: end - adjustedStart + 1 }, (_, i) => adjustedStart + i); + return buildPageNumbers(g.page, this.getGroupedTotalPages(g)); } - getGroupedTotalPages(g: GroupedTableState) { return Math.max(1, Math.ceil(g.filtered.length / g.pageSize)); } + getGroupedTotalPages(g: GroupedTableState) { return computeTotalPages(g.filtered.length, g.pageSize); } goToGroupedPage(g: GroupedTableState, p: number) { - g.page = Math.min(this.getGroupedTotalPages(g), Math.max(1, p)); + g.page = clampPage(p, this.getGroupedTotalPages(g)); this.updateGroupView(g); } getTableRowClass(_: TableState, __: T) { return false; } @@ -677,6 +687,10 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { return normalized === 'true' || normalized === '1' || normalized === 'sim'; } + isExporting(key: string): boolean { + return this.exportingKeys.has(key); + } + private initTables() { const hideMoneyColumns = (cols: TableColumn[]) => this.showFinancial ? cols : cols.filter((c) => c.type !== 'money'); @@ -1045,12 +1059,12 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { }); this.macrophonyGroups = groups; - const search = this.normalizeText(this.macrophonySearch); + const search = normalizeAccentInsensitive(this.macrophonySearch); this.macrophonyFiltered = !search ? groups : groups.filter((group) => - this.normalizeText(group.plano).includes(search) || - this.normalizeText(group.gbLabel).includes(search) + normalizeAccentInsensitive(group.plano).includes(search) || + normalizeAccentInsensitive(group.gbLabel).includes(search) ); const totalPages = Math.max(1, Math.ceil(this.macrophonyFiltered.length / this.macrophonyPageSize)); @@ -1086,12 +1100,12 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { if (group.groupSort) groups.sort(group.groupSort); group.groups = groups; - const search = this.normalizeText(group.search); + const search = normalizeAccentInsensitive(group.search); group.filtered = !search ? groups : groups.filter((g) => - this.normalizeText(g.title).includes(search) || - this.normalizeText(g.subtitle).includes(search) + normalizeAccentInsensitive(g.title).includes(search) || + normalizeAccentInsensitive(g.subtitle).includes(search) ); const totalPages = Math.max(1, Math.ceil(group.filtered.length / group.pageSize)); @@ -1106,15 +1120,6 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { } } - private normalizeText(value: any): string { - return (value ?? '') - .toString() - .trim() - .toUpperCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, ''); - } - private sumGroup(rows: T[], getter: (row: T) => any): number { return rows.reduce((acc, row) => acc + (this.toNumber(getter(row)) ?? 0), 0); } @@ -1133,7 +1138,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { rows.forEach((row) => { const planoContrato = (row.planoContrato ?? '-').toString().trim() || '-'; - const key = this.normalizeText(planoContrato); + const key = normalizeAccentInsensitive(planoContrato); const gb = this.extractGbFromPlanName(planoContrato) ?? this.toNumber(row.gb ?? row.franquiaGb); @@ -1214,78 +1219,59 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { return Number.isNaN(parsed) ? null : parsed; } - private exportCsv(table: TableState, filename: string) { + private async exportTableAsXlsx(table: TableState, fileKey: string): Promise { if (!isPlatformBrowser(this.platformId)) return; + if (this.exportingKeys.has(fileKey)) return; + const rows = table.data ?? []; - const columns = table.columns ?? []; - const generatedAt = new Date().toLocaleString('pt-BR'); - const escapeHtml = (value: string) => - value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + if (!rows.length) { + this.showToast('Nenhum registro encontrado para exportar.', 'danger'); + return; + } - const headerHtml = columns - .map((column) => `${escapeHtml(column.label)}`) - .join(''); + this.exportingKeys.add(fileKey); + try { + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `${fileKey}_${timestamp}`, + sheetName: table.label || 'Resumo', + rows, + columns: (table.columns ?? []).map((column) => ({ + header: column.label, + type: this.mapColumnType(column.type), + value: (row: T) => this.getExportColumnValue(column, row), + })), + }); - const bodyHtml = rows - .map((row, index) => { - const cells = columns - .map((column) => { - const value = this.formatCell(column, row); - const toneClass = column.tone ? this.getToneClass(column.value(row)) : ''; - const alignClass = column.align === 'right' ? 'text-right' : column.align === 'center' ? 'text-center' : ''; - const classes = [alignClass, toneClass].filter(Boolean).join(' '); - return `${escapeHtml(String(value))}`; - }) - .join(''); - return `${cells}`; - }) - .join(''); + this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success'); + } catch { + this.showToast('Erro ao exportar planilha.', 'danger'); + } finally { + this.exportingKeys.delete(fileKey); + } + } - const html = ` - - - - - - -
${escapeHtml(table.label || 'Resumo')}
-
Exportado em ${escapeHtml(generatedAt)} | Total de linhas: ${rows.length}
- - - ${headerHtml} - - - ${bodyHtml} - -
- -`; + private getExportColumnValue(column: TableColumn, row: T): unknown { + const rawValue = column.value(row); + if (column.type === 'money' || column.type === 'number' || column.type === 'gb') { + const numeric = this.toNumber(rawValue); + if (numeric !== null) return numeric; + } + return this.formatCell(column, row); + } - const blob = new Blob([`\uFEFF${html}`], { type: 'application/vnd.ms-excel;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${filename}.xls`; - a.click(); - URL.revokeObjectURL(url); + private mapColumnType(type: TableColumn['type']): ExportCellType { + if (type === 'money') return 'currency'; + if (type === 'number' || type === 'gb') return 'number'; + return 'text'; + } + + private showToast(message: string, type: 'success' | 'danger'): void { + this.toastMessage = message; + this.toastType = type; + this.toastOpen = true; + if (this.toastTimer) clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastOpen = false), 3000); } private getReservaPorDddChartData(): Array<{ label: string; totalLinhas: number }> { @@ -1310,7 +1296,7 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { return Array.from(map.entries()).map(([label, totalLinhas]) => ({ label, totalLinhas })); } - exportMacrophonyCsv() { this.exportCsv(this.tableMacrophony, 'macrophony-planos'); } + exportMacrophonyCsv() { void this.exportTableAsXlsx(this.tableMacrophony, 'macrophony-planos'); } findLineTotal(k: string[]): LineTotal | null { const keys = k.map((item) => item.toUpperCase()); const list = this.getEffectiveLineTotais(); @@ -1335,19 +1321,13 @@ export class Resumo implements OnInit, AfterViewInit, OnDestroy { return Array.isArray(this.resumo?.lineTotais) ? (this.resumo?.lineTotais ?? []) : []; } - get macrophonyPageStart() { return (this.macrophonyPage - 1) * this.macrophonyPageSize + 1; } - get macrophonyPageEnd() { return Math.min(this.macrophonyPage * this.macrophonyPageSize, this.macrophonyFilteredGroups.length); } + get macrophonyPageStart() { return computePageStart(this.macrophonyFilteredGroups.length, this.macrophonyPage, this.macrophonyPageSize); } + get macrophonyPageEnd() { return computePageEnd(this.macrophonyFilteredGroups.length, this.macrophonyPage, this.macrophonyPageSize); } get macrophonyFilteredGroups() { return this.macrophonyFiltered; } get macrophonyPageNumbers() { - const total = this.macrophonyTotalPages; - if (total <= 1) return [1]; - const current = Math.min(Math.max(this.macrophonyPage, 1), total); - const start = Math.max(1, current - 2); - const end = Math.min(total, start + 4); - const adjustedStart = Math.max(1, end - 4); - return Array.from({ length: end - adjustedStart + 1 }, (_, i) => adjustedStart + i); + return buildPageNumbers(this.macrophonyPage, this.macrophonyTotalPages); } - get macrophonyTotalPages() { return Math.max(1, Math.ceil(this.macrophonyFiltered.length / this.macrophonyPageSize)); } + get macrophonyTotalPages() { return computeTotalPages(this.macrophonyFiltered.length, this.macrophonyPageSize); } get planosTotals() { return this.resumo?.macrophonyTotals; } get contratosTotals() { return this.resumo?.planoContratoTotal; } get clientesTotals() { return this.resumo?.vivoLineTotals; } diff --git a/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.html b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.html new file mode 100644 index 0000000..73098c7 --- /dev/null +++ b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.html @@ -0,0 +1,138 @@ +
+
+
+
+
+
+ Administração +
+ +
+
Solicitações
+ Pedidos de alteração de franquia e bloqueio enviados pelos usuários. +
+ +
+ +
+
+ +
+
+ + + + + +
+ +
+ + Itens por pág: + +
+ +
+
+ +
+ Mostrando {{ pageStart }}-{{ pageEnd }} de {{ total }} +
+
+
+ +
+
+
+ +
+ +
+ {{ errorMsg }} +
+ +
+ Nenhuma solicitação encontrada. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DATACLIENTELINHAUSUARIO LINHATIPOFRANQUIA ANTESFRANQUIA DEPOISDESCRICAO
+
+ {{ formatDate(item.createdAt) }} + {{ formatTime(item.createdAt) }} +
+
{{ item.tenantNome || '-' }}{{ item.linha || '-' }}{{ item.usuarioLinha || '-' }} + {{ tipoLabel(item.tipoSolicitacao) }} + {{ formatFranquia(item.franquiaLineAtual) }}{{ formatFranquia(item.franquiaLineNova) }} + {{ descricao(item) }} +
+
+
+ + +
+
+
diff --git a/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.scss b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.scss new file mode 100644 index 0000000..f4e16fb --- /dev/null +++ b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.scss @@ -0,0 +1,368 @@ +:host { + --brand: #e33dcf; + --text: #111214; + --card-bg: rgba(255, 255, 255, 0.88); + --card-border: 1px solid rgba(227, 61, 207, 0.14); + --stroke: rgba(16, 24, 40, 0.08); + --soft: rgba(17, 18, 20, 0.64); + + display: block; + color: var(--text); +} + +.solicitacoes-page { + min-height: 100vh; + padding: 0 0 18px; + background: + radial-gradient(920px 420px at 15% 8%, rgba(227, 61, 207, 0.1), transparent 60%), + radial-gradient(860px 420px at 85% 20%, rgba(3, 15, 170, 0.07), transparent 60%), + linear-gradient(180deg, #ffffff 0%, #f5f6fb 70%); +} + +.container-geral-responsive { + width: calc(100vw - 2px); + max-width: none; + margin: 16px auto 24px; + position: relative; + z-index: 1; +} + +.geral-card { + border-radius: 22px; + overflow: hidden; + background: var(--card-bg); + border: var(--card-border); + backdrop-filter: blur(8px); + box-shadow: 0 18px 40px rgba(17, 18, 20, 0.1); + display: flex; + flex-direction: column; + min-height: 64vh; +} + +.geral-header { + padding: 16px 22px 14px; + border-bottom: 1px solid var(--stroke); + background: linear-gradient(180deg, rgba(227, 61, 207, 0.05), rgba(255, 255, 255, 0.16)); +} + +.header-row-top { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 12px; + align-items: center; +} + +.title-badge { + justify-self: start; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid rgba(227, 61, 207, 0.2); + background: rgba(255, 255, 255, 0.8); + font-weight: 800; + font-size: 12px; +} + +.header-title { + justify-self: center; + text-align: center; +} + +.title { + font-size: 24px; + font-weight: 900; + letter-spacing: -0.01em; +} + +.subtitle { + color: var(--soft); + font-weight: 600; +} + +.header-actions { + justify-self: end; +} + +.btn-brand { + background: var(--brand); + border-color: var(--brand); + color: #fff; + font-weight: 800; +} + +.controls { + display: grid; + grid-template-columns: minmax(320px, 1.3fr) auto auto; + align-items: center; + gap: 14px; +} + +.search-group { + max-width: 700px; + width: 100%; + border-radius: 13px; + border: 1px solid rgba(16, 24, 40, 0.16); + overflow: hidden; + box-shadow: 0 4px 10px rgba(16, 24, 40, 0.05); + background: #fff; + + .input-group-text, + .form-control, + .btn-clear { + border: none; + } + + .input-group-text { + color: rgba(17, 18, 20, 0.55); + } + + .form-control { + font-size: 13px; + } +} + +.geral-body { + flex: 1; + padding: 0 4px 10px; + overflow: hidden; +} + +.table-wrap { + width: 100%; + min-width: 0; + max-width: 100%; + min-height: clamp(260px, 44vh, 500px); + max-height: 60vh; + border: 1px solid rgba(16, 24, 40, 0.1); + border-radius: 14px; + overflow-x: hidden; + overflow-y: auto; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 251, 255, 0.96) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75); +} + +.table-modern { + width: 100%; + min-width: 0; + table-layout: fixed; +} + +.table-modern col.col-date { width: 9%; } +.table-modern col.col-cliente { width: 14%; } +.table-modern col.col-linha { width: 10%; } +.table-modern col.col-usuario { width: 15%; } +.table-modern col.col-tipo { width: 14%; } +.table-modern col.col-franquia { width: 12%; } +.table-modern col.col-descricao { width: 14%; } + +.table-modern thead th { + position: sticky; + top: 0; + z-index: 2; + background: #f6f8fc; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(17, 18, 20, 0.74); + padding: 10px 12px; + border-bottom: 1px solid #e4e9f3; + white-space: normal; + word-break: break-word; + line-height: 1.2; +} + +.table-modern thead th:nth-child(6), +.table-modern thead th:nth-child(7), +.table-modern tbody td:nth-child(6), +.table-modern tbody td:nth-child(7) { + min-width: 0; + text-align: center; +} + +.table-modern tbody td { + font-size: 13px; + padding: 11px 12px; + border-bottom: 1px solid #ebeff6; + vertical-align: middle; + white-space: normal; + word-break: break-word; + color: #1a1c20; +} + +.table-modern tbody tr:nth-child(even) { + background: rgba(243, 247, 253, 0.56); +} + +.table-modern tbody tr:hover { + background: #eef5ff; +} + +.date-cell { + display: flex; + flex-direction: column; + line-height: 1.15; + gap: 3px; +} + +.date-main { + font-weight: 700; + color: #1a1c20; +} + +.date-sub { + font-size: 11px; + color: rgba(17, 18, 20, 0.55); +} + +.mono-cell { + font-family: "JetBrains Mono", "Consolas", "Monaco", monospace; + font-variant-numeric: tabular-nums; + font-size: 12px; + font-weight: 700; + white-space: normal; + word-break: break-all; +} + +.cell-ellipsis { + overflow: visible; + text-overflow: unset; + white-space: normal; +} + +.type-badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 5px 10px; + font-size: 11px; + font-weight: 800; + line-height: 1; + border: 1px solid transparent; + white-space: nowrap; +} + +.type-badge--franquia { + color: #1e4e8f; + background: rgba(44, 121, 232, 0.14); + border-color: rgba(44, 121, 232, 0.24); +} + +.type-badge--bloqueio { + color: #8f2f2f; + background: rgba(221, 74, 74, 0.14); + border-color: rgba(221, 74, 74, 0.25); +} + +.type-badge--default { + color: #5b6173; + background: rgba(118, 127, 154, 0.14); + border-color: rgba(118, 127, 154, 0.24); +} + +.franquia-cell { + text-align: center; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.message-cell { + white-space: normal; +} + +.message-text { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + line-height: 1.32; + color: rgba(17, 18, 20, 0.8); + word-break: break-word; +} + +.table-summary { + justify-self: end; + font-size: 12px; + color: rgba(17, 18, 20, 0.64); + font-weight: 600; + + strong { + color: #111214; + font-weight: 800; + } +} + +.empty-group { + padding: 36px 18px; + text-align: center; + color: rgba(17, 18, 20, 0.64); +} + +.geral-footer { + display: none; +} + +.table-wrap::-webkit-scrollbar { + height: 10px; + width: 10px; +} + +.table-wrap::-webkit-scrollbar-track { + background: rgba(17, 18, 20, 0.06); +} + +.table-wrap::-webkit-scrollbar-thumb { + background: rgba(103, 114, 143, 0.45); + border-radius: 999px; +} + +@media (max-width: 1200px) { + .controls { + grid-template-columns: 1fr auto; + } + + .table-summary { + grid-column: 1 / -1; + justify-self: start; + } + + .table-modern col.col-date { width: 10%; } + .table-modern col.col-cliente { width: 13%; } + .table-modern col.col-linha { width: 9%; } + .table-modern col.col-usuario { width: 14%; } + .table-modern col.col-tipo { width: 14%; } + .table-modern col.col-franquia { width: 13%; } + .table-modern col.col-descricao { width: 14%; } + + .table-modern thead th, + .table-modern tbody td { + padding: 9px 8px; + font-size: 11px; + } +} + +@media (max-width: 992px) { + .header-row-top { + grid-template-columns: 1fr; + text-align: center; + } + + .title-badge, + .header-title, + .header-actions { + justify-self: center; + } + + .controls { + grid-template-columns: 1fr; + align-items: start; + } + + .message-text { + -webkit-line-clamp: 3; + } +} diff --git a/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.ts b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.ts new file mode 100644 index 0000000..978f34a --- /dev/null +++ b/src/app/pages/solicitacoes-linhas/solicitacoes-linhas.ts @@ -0,0 +1,186 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { SolicitacaoLinhaDto, SolicitacoesLinhasService } from '../../services/solicitacoes-linhas.service'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; + +@Component({ + selector: 'app-solicitacoes-linhas', + standalone: true, + imports: [CommonModule, FormsModule, CustomSelectComponent], + templateUrl: './solicitacoes-linhas.html', + styleUrls: ['./solicitacoes-linhas.scss'], +}) +export class SolicitacoesLinhas implements OnInit, OnDestroy { + items: SolicitacaoLinhaDto[] = []; + loading = false; + errorMsg = ''; + + page = 1; + pageSize = 20; + pageSizeOptions = [10, 20, 50, 100]; + total = 0; + + search = ''; + private searchTimer: ReturnType | null = null; + + constructor(private readonly solicitacoesService: SolicitacoesLinhasService) {} + + ngOnInit(): void { + this.fetch(1); + } + + ngOnDestroy(): void { + if (this.searchTimer) { + clearTimeout(this.searchTimer); + this.searchTimer = null; + } + } + + refresh(): void { + this.fetch(); + } + + onSearchChange(): void { + if (this.searchTimer) { + clearTimeout(this.searchTimer); + } + + this.searchTimer = setTimeout(() => { + this.page = 1; + this.fetch(); + }, 300); + } + + clearSearch(): void { + this.search = ''; + this.page = 1; + this.fetch(); + } + + onPageSizeChange(): void { + this.page = 1; + this.fetch(); + } + + goToPage(pageNumber: number): void { + this.page = clampPage(pageNumber, this.totalPages); + this.fetch(); + } + + get totalPages(): number { + return computeTotalPages(this.total || 0, this.pageSize); + } + + get pageNumbers(): number[] { + return buildPageNumbers(this.page, this.totalPages); + } + + get pageStart(): number { + return computePageStart(this.total || 0, this.page, this.pageSize); + } + + get pageEnd(): number { + return computePageEnd(this.total || 0, this.page, this.pageSize); + } + + private parseDate(value?: string | null): Date | null { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; + } + + formatDateTime(value?: string | null): string { + const d = this.parseDate(value); + if (!d) return '-'; + return d.toLocaleString('pt-BR'); + } + + formatDate(value?: string | null): string { + const d = this.parseDate(value); + if (!d) return '-'; + return d.toLocaleDateString('pt-BR'); + } + + formatTime(value?: string | null): string { + const d = this.parseDate(value); + if (!d) return '--:--'; + return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); + } + + formatFranquiaValor(value?: number | null): string { + if (value === null || value === undefined) return '-'; + return new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value); + } + + formatFranquia(value?: number | null): string { + const formatted = this.formatFranquiaValor(value); + return formatted === '-' ? '-' : `${formatted} GB`; + } + + tipoLabel(value?: string | null): string { + const v = (value ?? '').toString().trim().toUpperCase(); + if (v === 'ALTERACAO_FRANQUIA') return 'Alteração de franquia'; + if (v === 'BLOQUEIO') return 'Bloqueio'; + return v || '-'; + } + + tipoBadgeClass(value?: string | null): string { + const v = (value ?? '').toString().trim().toUpperCase(); + if (v === 'ALTERACAO_FRANQUIA') return 'type-badge type-badge--franquia'; + if (v === 'BLOQUEIO') return 'type-badge type-badge--bloqueio'; + return 'type-badge type-badge--default'; + } + + trackBySolicitacao(_: number, item: SolicitacaoLinhaDto): string { + return item.id; + } + + descricao(item: SolicitacaoLinhaDto): string { + const tipo = (item.tipoSolicitacao ?? '').toString().trim().toUpperCase(); + const linha = (item.linha ?? '').toString().trim(); + + if (tipo === 'ALTERACAO_FRANQUIA') { + return `Mudanca de franquia de ${this.formatFranquiaValor(item.franquiaLineAtual)} para ${this.formatFranquiaValor(item.franquiaLineNova)}`; + } + + if (tipo === 'BLOQUEIO') { + return `Bloqueio da linha ${linha || '-'}`; + } + + return (item.mensagem ?? '').toString().trim() || '-'; + } + + private fetch(goToPage?: number): void { + if (goToPage) this.page = goToPage; + this.loading = true; + this.errorMsg = ''; + + this.solicitacoesService + .list({ + page: this.page, + pageSize: this.pageSize, + search: this.search?.trim() || undefined, + }) + .subscribe({ + next: (res) => { + this.items = res.items || []; + this.total = res.total || 0; + this.page = res.page || this.page; + this.pageSize = res.pageSize || this.pageSize; + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMsg = 'Não foi possível carregar as solicitações.'; + }, + }); + } +} diff --git a/src/app/pages/troca-numero/troca-numero.html b/src/app/pages/troca-numero/troca-numero.html index 6e31ffb..c8fa43f 100644 --- a/src/app/pages/troca-numero/troca-numero.html +++ b/src/app/pages/troca-numero/troca-numero.html @@ -31,7 +31,11 @@
- +
@@ -86,7 +90,6 @@ Itens por pág:
-
@@ -153,7 +156,7 @@ {{ r.observacao || '-' }}
-
@@ -194,184 +197,4 @@
- - - - - - - - - + diff --git a/src/app/pages/troca-numero/troca-numero.scss b/src/app/pages/troca-numero/troca-numero.scss index 8c6fa2e..4e94435 100644 --- a/src/app/pages/troca-numero/troca-numero.scss +++ b/src/app/pages/troca-numero/troca-numero.scss @@ -534,9 +534,6 @@ } /* MODALS */ -.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9990; backdrop-filter: blur(4px); } -.modal-custom { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } - .modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.8); diff --git a/src/app/pages/troca-numero/troca-numero.ts b/src/app/pages/troca-numero/troca-numero.ts index b27b637..7264919 100644 --- a/src/app/pages/troca-numero/troca-numero.ts +++ b/src/app/pages/troca-numero/troca-numero.ts @@ -10,8 +10,20 @@ import { import { isPlatformBrowser, CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpParams } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { AuthService } from '../../services/auth.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { TableExportService } from '../../services/table-export.service'; import { environment } from '../../../environments/environment'; +import { TrocaNumeroModalsComponent } from '../../components/page-modals/troca-numero-modals/troca-numero-modals'; +import { + buildPageNumbers, + clampPage, + computePageEnd, + computePageStart, + computeTotalPages +} from '../../utils/pagination.util'; +import { buildApiEndpoint } from '../../utils/api-base.util'; type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao'; @@ -56,34 +68,30 @@ interface LineOptionDto { @Component({ standalone: true, - imports: [CommonModule, FormsModule, CustomSelectComponent], + imports: [CommonModule, FormsModule, CustomSelectComponent, TrocaNumeroModalsComponent], templateUrl: './troca-numero.html', styleUrls: ['./troca-numero.scss'] }) export class TrocaNumero implements AfterViewInit { + readonly vm = this; toastMessage = ''; loading = false; + exporting = false; @ViewChild('successToast', { static: false }) successToast!: ElementRef; constructor( @Inject(PLATFORM_ID) private platformId: object, private http: HttpClient, - private cdr: ChangeDetectorRef + private cdr: ChangeDetectorRef, + private authService: AuthService, + private tableExportService: TableExportService ) {} - private readonly apiBase = (() => { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; - return `${apiBase}/trocanumero`; - })(); + private readonly apiBase = buildApiEndpoint(environment.apiUrl, 'trocanumero'); /** ✅ base do GERAL (para buscar clientes/linhas no modal) */ - private readonly linesApiBase = (() => { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; - return `${apiBase}/lines`; - })(); + private readonly linesApiBase = buildApiEndpoint(environment.apiUrl, 'lines'); // ====== DATA ====== groups: GroupItem[] = []; @@ -132,9 +140,20 @@ export class TrocaNumero implements AfterViewInit { loadingClients = false; loadingLines = false; + isSysAdmin = false; + isGestor = false; + isFinanceiro = false; + + get canManageRecords(): boolean { + return this.isSysAdmin || this.isGestor; + } + async ngAfterViewInit() { if (!isPlatformBrowser(this.platformId)) return; this.initAnimations(); + this.isSysAdmin = this.authService.hasRole('sysadmin'); + this.isGestor = this.authService.hasRole('gestor'); + this.isFinanceiro = this.authService.hasRole('financeiro'); setTimeout(() => this.refresh()); } @@ -151,6 +170,90 @@ export class TrocaNumero implements AfterViewInit { this.loadForGroups(); } + async onExport(): Promise { + if (this.exporting) return; + this.exporting = true; + + try { + const rows = await this.fetchAllRowsForExport(); + if (!rows.length) { + await this.showToast('Nenhum registro encontrado para exportar.'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `troca_numero_${timestamp}`, + sheetName: 'TrocaNumero', + rows, + columns: [ + { header: 'ID', value: (row) => row.id ?? '' }, + { header: 'Motivo', value: (row) => row.motivo }, + { header: 'Cliente', value: (row) => this.getRawField(row, ['cliente', 'Cliente']) ?? '' }, + { header: 'Usuario', value: (row) => this.getRawField(row, ['usuario', 'Usuario']) ?? '' }, + { header: 'Skil', value: (row) => this.getRawField(row, ['skil', 'Skil']) ?? '' }, + { header: 'Item', type: 'number', value: (row) => this.toNumberOrNull(row.item) ?? 0 }, + { header: 'Linha Antiga', value: (row) => row.linhaAntiga }, + { header: 'Linha Nova', value: (row) => row.linhaNova }, + { header: 'ICCID', value: (row) => row.iccid }, + { header: 'Data da Troca', type: 'date', value: (row) => row.dataTroca }, + { header: 'Observacao', value: (row) => row.observacao }, + { header: 'Situacao', value: (row) => (this.isTroca(row) ? 'TROCA' : 'SEM TROCA') }, + { header: 'Linha ID (Geral)', value: (row) => this.getRawField(row, ['mobileLineId', 'MobileLineId']) ?? '' }, + { header: 'Criado Em', type: 'datetime', value: (row) => this.getRawField(row, ['createdAt', 'CreatedAt']) ?? '' }, + { header: 'Atualizado Em', type: 'datetime', value: (row) => this.getRawField(row, ['updatedAt', 'UpdatedAt']) ?? '' }, + ], + }); + + await this.showToast(`Planilha exportada com ${rows.length} registro(s).`); + } catch { + await this.showToast('Erro ao exportar planilha.'); + } finally { + this.exporting = false; + } + } + + private async fetchAllRowsForExport(): Promise { + const pageSize = 2000; + let page = 1; + let expectedTotal = 0; + const rows: TrocaRow[] = []; + + while (page <= 500) { + const params = new HttpParams() + .set('page', String(page)) + .set('pageSize', String(pageSize)) + .set('search', (this.searchTerm ?? '').trim()) + .set('sortBy', 'motivo') + .set('sortDir', 'asc'); + + const response = await firstValueFrom( + this.http.get | any[]>(this.apiBase, { params }) + ); + + const items = Array.isArray(response) ? response : (response.items ?? []); + const normalized = items.map((item: any, idx: number) => this.normalizeRow(item, rows.length + idx)); + rows.push(...normalized); + expectedTotal = Array.isArray(response) ? 0 : Number(response.total ?? 0); + + if (Array.isArray(response)) break; + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && rows.length >= expectedTotal) break; + page += 1; + } + + return rows.sort((a, b) => { + const byMotivo = (a.motivo ?? '').localeCompare(b.motivo ?? '', 'pt-BR', { sensitivity: 'base' }); + if (byMotivo !== 0) return byMotivo; + + const byItem = (this.toNumberOrNull(a.item) ?? 0) - (this.toNumberOrNull(b.item) ?? 0); + if (byItem !== 0) return byItem; + + return (a.linhaNova ?? '').localeCompare(b.linhaNova ?? '', 'pt-BR', { sensitivity: 'base' }); + }); + } + onSearch() { if (this.searchTimer) clearTimeout(this.searchTimer); this.searchTimer = setTimeout(() => { @@ -175,29 +278,19 @@ export class TrocaNumero implements AfterViewInit { } goToPage(p: number) { - this.page = Math.max(1, Math.min(this.totalPages, p)); + this.page = clampPage(p, this.totalPages); this.applyPagination(); } - get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; } + get totalPages() { return computeTotalPages(this.total || 0, this.pageSize); } get pageNumbers() { - const total = this.totalPages; - const current = this.page; - const max = 5; - let start = Math.max(1, current - 2); - let end = Math.min(total, start + (max - 1)); - start = Math.max(1, end - (max - 1)); - - const pages: number[] = []; - for (let i = start; i <= end; i++) pages.push(i); - return pages; + return buildPageNumbers(this.page, this.totalPages); } - get pageStart() { return this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; } + get pageStart() { return computePageStart(this.total || 0, this.page, this.pageSize); } get pageEnd() { - if (this.total === 0) return 0; - return Math.min(this.page * this.pageSize, this.total); + return computePageEnd(this.total || 0, this.page, this.pageSize); } trackById(_: number, row: TrocaRow) { return row.id; } @@ -414,6 +507,11 @@ export class TrocaNumero implements AfterViewInit { // ====== MODAL EDIÇÃO ====== onEditar(r: TrocaRow) { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + this.editOpen = true; this.editSaving = false; @@ -436,6 +534,11 @@ export class TrocaNumero implements AfterViewInit { } saveEdit() { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + if (!this.editModel || !this.editModel.id) return; this.editSaving = true; @@ -467,6 +570,11 @@ export class TrocaNumero implements AfterViewInit { // ====== MODAL CRIAÇÃO ====== onCreate() { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + this.createOpen = true; this.createSaving = false; @@ -496,6 +604,11 @@ export class TrocaNumero implements AfterViewInit { } saveCreate() { + if (!this.canManageRecords) { + this.showToast('Perfil Financeiro possui acesso somente leitura.'); + return; + } + // ✅ validações do "beber do GERAL" if (!String(this.selectedCliente ?? '').trim()) { this.showToast('Selecione um Cliente do GERAL.'); @@ -542,6 +655,15 @@ export class TrocaNumero implements AfterViewInit { return Number.isFinite(n) ? n : null; } + private getRawField(row: TrocaRow, keys: string[]): string | null { + for (const key of keys) { + const value = row?.raw?.[key]; + if (value === undefined || value === null || String(value).trim() === '') continue; + return String(value); + } + return null; + } + private isoToDateInput(iso: string | null | undefined): string { if (!iso) return ''; const dt = new Date(iso); diff --git a/src/app/pages/vigencia/vigencia.html b/src/app/pages/vigencia/vigencia.html index 1ae2d25..fa2b8bf 100644 --- a/src/app/pages/vigencia/vigencia.html +++ b/src/app/pages/vigencia/vigencia.html @@ -24,6 +24,10 @@ Controle de contratos e fidelização
+ @@ -188,268 +192,4 @@
-
- -
- -
- - -
- -
- - -
- -
- - -
- -
- + diff --git a/src/app/pages/vigencia/vigencia.scss b/src/app/pages/vigencia/vigencia.scss index 5e41891..c28c00a 100644 --- a/src/app/pages/vigencia/vigencia.scss +++ b/src/app/pages/vigencia/vigencia.scss @@ -372,14 +372,6 @@ .pagination-modern .page-item.active .page-link { background-color: var(--blue); border-color: var(--blue); color: #fff; } /* MODAL */ -.lg-backdrop { - position: fixed; - inset: 0; - background: radial-gradient(circle at 20% 0%, rgba(227, 61, 207, 0.18), rgba(0, 0, 0, 0.55) 45%); - z-index: 9990; - backdrop-filter: blur(5px); -} -.lg-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 9995; padding: 16px; } .lg-modal-card { background: #ffffff; border: 1px solid rgba(255,255,255,0.86); diff --git a/src/app/pages/vigencia/vigencia.ts b/src/app/pages/vigencia/vigencia.ts index 31d1148..0bcd584 100644 --- a/src/app/pages/vigencia/vigencia.ts +++ b/src/app/pages/vigencia/vigencia.ts @@ -3,13 +3,16 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; -import { Subscription } from 'rxjs'; +import { Subscription, firstValueFrom } from 'rxjs'; import { VigenciaService, VigenciaRow, VigenciaClientGroup, PagedResult, UpdateVigenciaRequest } from '../../services/vigencia.service'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; import { AuthService } from '../../services/auth.service'; import { LinesService, MobileLineDetail } from '../../services/lines.service'; import { PlanAutoFillService } from '../../services/plan-autofill.service'; +import { TableExportService } from '../../services/table-export.service'; import { confirmDeletionWithTyping } from '../../utils/destructive-confirmation'; +import { computeTotalPages } from '../../utils/pagination.util'; +import { VigenciaModalsComponent } from '../../components/page-modals/vigencia-modals/vigencia-modals'; type SortDir = 'asc' | 'desc'; type ToastType = 'success' | 'danger'; @@ -26,12 +29,14 @@ interface LineOptionDto { @Component({ selector: 'app-vigencia', standalone: true, - imports: [CommonModule, FormsModule, CustomSelectComponent], + imports: [CommonModule, FormsModule, CustomSelectComponent, VigenciaModalsComponent], templateUrl: './vigencia.html', styleUrls: ['./vigencia.scss'], }) export class VigenciaComponent implements OnInit, OnDestroy { + readonly vm = this; loading = false; + exporting = false; errorMsg = ''; // Filtros @@ -113,7 +118,8 @@ export class VigenciaComponent implements OnInit, OnDestroy { private authService: AuthService, private linesService: LinesService, private planAutoFill: PlanAutoFillService, - private route: ActivatedRoute + private route: ActivatedRoute, + private tableExportService: TableExportService ) {} ngOnInit(): void { @@ -157,7 +163,7 @@ export class VigenciaComponent implements OnInit, OnDestroy { } get totalPages(): number { - return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10))); + return computeTotalPages(this.total || 0, this.pageSize || 10); } fetch(goToPage?: number): void { @@ -295,6 +301,107 @@ export class VigenciaComponent implements OnInit, OnDestroy { this.fetch(1); } + async onExport(): Promise { + if (this.exporting) return; + this.exporting = true; + + try { + const baseRows = await this.fetchAllRowsForExport(); + const rows = await this.fetchDetailedRowsForExport(baseRows); + if (!rows.length) { + this.showToast('Nenhum registro encontrado para exportar.', 'danger'); + return; + } + + const timestamp = this.tableExportService.buildTimestamp(); + await this.tableExportService.exportAsXlsx({ + fileName: `vigencia_${timestamp}`, + sheetName: 'Vigencia', + rows, + columns: [ + { header: 'ID', value: (row) => row.id ?? '' }, + { header: 'Item', type: 'number', value: (row) => this.toNullableNumber(row.item) ?? 0 }, + { header: 'Linha', value: (row) => row.linha ?? '' }, + { header: 'Conta', value: (row) => row.conta ?? '' }, + { header: 'Cliente', value: (row) => row.cliente ?? '' }, + { header: 'Usuario', value: (row) => row.usuario ?? '' }, + { header: 'Plano', value: (row) => row.planoContrato ?? '' }, + { header: 'Efetivacao', type: 'date', value: (row) => row.dtEfetivacaoServico ?? '' }, + { header: 'Termino Fidelizacao', type: 'date', value: (row) => row.dtTerminoFidelizacao ?? '' }, + { header: 'Status', value: (row) => (this.isVencido(row.dtTerminoFidelizacao) ? 'Vencido' : 'Ativo') }, + { header: 'Auto Renovacao (anos)', type: 'number', value: (row) => this.toNullableNumber(row.autoRenewYears) ?? 0 }, + { header: 'Auto Renovacao Referencia', type: 'date', value: (row) => row.autoRenewReferenceEndDate ?? '' }, + { header: 'Auto Renovacao Configurada Em', type: 'datetime', value: (row) => row.autoRenewConfiguredAt ?? '' }, + { header: 'Ultima Auto Renovacao', type: 'datetime', value: (row) => row.lastAutoRenewedAt ?? '' }, + { header: 'Total', type: 'currency', value: (row) => this.toNullableNumber(row.total) ?? 0 }, + { header: 'Criado Em', type: 'datetime', value: (row) => row.createdAt ?? '' }, + { header: 'Atualizado Em', type: 'datetime', value: (row) => row.updatedAt ?? '' }, + ], + }); + + this.showToast(`Planilha exportada com ${rows.length} registro(s).`, 'success'); + } catch { + this.showToast('Erro ao exportar planilha.', 'danger'); + } finally { + this.exporting = false; + } + } + + private async fetchAllRowsForExport(): Promise { + const pageSize = 500; + let page = 1; + let expectedTotal = 0; + const all: VigenciaRow[] = []; + + while (page <= 500) { + const response = await firstValueFrom( + this.vigenciaService.getVigencia({ + search: this.search?.trim(), + client: this.client?.trim(), + page, + pageSize, + sortBy: 'item', + sortDir: 'asc', + }) + ); + + const items = response?.items ?? []; + expectedTotal = response?.total ?? 0; + all.push(...items); + + if (items.length === 0) break; + if (items.length < pageSize) break; + if (expectedTotal > 0 && all.length >= expectedTotal) break; + page += 1; + } + + return all; + } + + private async fetchDetailedRowsForExport(rows: VigenciaRow[]): Promise { + if (!rows.length) return []; + + const detailedRows: VigenciaRow[] = []; + const chunkSize = 10; + + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + const resolved = await Promise.all( + chunk.map(async (row) => { + try { + return await firstValueFrom(this.vigenciaService.getById(row.id)); + } catch { + return row; + } + }) + ); + + detailedRows.push(...resolved); + } + + return detailedRows; + } + scheduleAutoRenew(row: VigenciaRow): void { if (!row?.id) return; const years = 2; diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index 89a9b5a..bd292e3 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { environment } from '../../environments/environment'; import { BehaviorSubject } from 'rxjs'; import { tap } from 'rxjs/operators'; +import { MveAuditService } from './mve-audit.service'; export interface RegisterPayload { name: string; @@ -43,7 +44,10 @@ export class AuthService { private readonly tokenExpiresAtKey = 'tokenExpiresAt'; private readonly rememberMeHours = 6; - constructor(private http: HttpClient) { + constructor( + private http: HttpClient, + private readonly mveAuditService: MveAuditService + ) { this.syncUserProfileFromToken(); } @@ -65,10 +69,12 @@ export class AuthService { logout() { if (typeof window === 'undefined') { + this.mveAuditService.clearCache(); this.userProfileSubject.next(null); return; } + this.mveAuditService.clearCache(); this.clearTokenStorage(localStorage); this.clearTokenStorage(sessionStorage); this.userProfileSubject.next(null); @@ -76,6 +82,7 @@ export class AuthService { setToken(token: string, rememberMe = false) { if (typeof window === 'undefined') return; + this.mveAuditService.clearCache(); this.clearTokenStorage(localStorage); this.clearTokenStorage(sessionStorage); diff --git a/src/app/services/billing.ts b/src/app/services/billing.ts index a03c960..a3070bc 100644 --- a/src/app/services/billing.ts +++ b/src/app/services/billing.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, map } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export type SortDir = 'asc' | 'desc'; export type TipoCliente = 'PF' | 'PJ'; @@ -75,8 +76,7 @@ export class BillingService { private readonly baseUrl: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + const apiBase = buildApiBaseUrl(environment.apiUrl); this.baseUrl = `${apiBase}/billing`; } diff --git a/src/app/services/chips-controle.service.ts b/src/app/services/chips-controle.service.ts index 16423a7..2afafb4 100644 --- a/src/app/services/chips-controle.service.ts +++ b/src/app/services/chips-controle.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export type SortDir = 'asc' | 'desc'; @@ -71,8 +72,7 @@ export class ChipsControleService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } getChipsVirgens(opts: { diff --git a/src/app/services/dados-usuarios.service.ts b/src/app/services/dados-usuarios.service.ts index 0e78ddc..cd3c8d0 100644 --- a/src/app/services/dados-usuarios.service.ts +++ b/src/app/services/dados-usuarios.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export type SortDir = 'asc' | 'desc'; @@ -75,8 +76,7 @@ export class DadosUsuariosService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } getGroups(opts: { diff --git a/src/app/services/historico.service.ts b/src/app/services/historico.service.ts index 2fb0c5c..83fd96c 100644 --- a/src/app/services/historico.service.ts +++ b/src/app/services/historico.service.ts @@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE'; export type AuditChangeType = 'added' | 'modified' | 'removed'; @@ -50,13 +51,36 @@ export interface HistoricoQuery { pageSize?: number; } +export interface LineHistoricoQuery { + line?: string; + pageName?: string; + action?: AuditAction | string; + user?: string; + search?: string; + dateFrom?: string; + dateTo?: string; + page?: number; + pageSize?: number; +} + +export interface ChipHistoricoQuery { + chip?: string; + pageName?: string; + action?: AuditAction | string; + user?: string; + search?: string; + dateFrom?: string; + dateTo?: string; + page?: number; + pageSize?: number; +} + @Injectable({ providedIn: 'root' }) export class HistoricoService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } list(params: HistoricoQuery): Observable> { @@ -74,4 +98,36 @@ export class HistoricoService { return this.http.get>(`${this.baseApi}/historico`, { params: httpParams }); } + + listByLine(params: LineHistoricoQuery): Observable> { + let httpParams = new HttpParams(); + if (params.line) httpParams = httpParams.set('line', params.line); + if (params.pageName) httpParams = httpParams.set('pageName', params.pageName); + if (params.action) httpParams = httpParams.set('action', params.action); + if (params.user) httpParams = httpParams.set('user', params.user); + if (params.search) httpParams = httpParams.set('search', params.search); + if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom); + if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo); + + httpParams = httpParams.set('page', String(params.page || 1)); + httpParams = httpParams.set('pageSize', String(params.pageSize || 10)); + + return this.http.get>(`${this.baseApi}/historico/linhas`, { params: httpParams }); + } + + listByChip(params: ChipHistoricoQuery): Observable> { + let httpParams = new HttpParams(); + if (params.chip) httpParams = httpParams.set('chip', params.chip); + if (params.pageName) httpParams = httpParams.set('pageName', params.pageName); + if (params.action) httpParams = httpParams.set('action', params.action); + if (params.user) httpParams = httpParams.set('user', params.user); + if (params.search) httpParams = httpParams.set('search', params.search); + if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom); + if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo); + + httpParams = httpParams.set('page', String(params.page || 1)); + httpParams = httpParams.set('pageSize', String(params.pageSize || 10)); + + return this.http.get>(`${this.baseApi}/historico/chips`, { params: httpParams }); + } } diff --git a/src/app/services/lines.service.ts b/src/app/services/lines.service.ts index 022570c..5b18d45 100644 --- a/src/app/services/lines.service.ts +++ b/src/app/services/lines.service.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export interface PagedResult { page: number; @@ -72,8 +73,7 @@ export class LinesService { private readonly baseUrl: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + const apiBase = buildApiBaseUrl(environment.apiUrl); this.baseUrl = `${apiBase}/lines`; } diff --git a/src/app/services/mve-audit.service.ts b/src/app/services/mve-audit.service.ts new file mode 100644 index 0000000..3bedad1 --- /dev/null +++ b/src/app/services/mve-audit.service.ts @@ -0,0 +1,189 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; + +export const MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY = 'linegestao.mveAudit.currentRunId'; + +export interface MveAuditSummary { + totalSystemLines: number; + totalReportLines: number; + totalConciliated: number; + totalStatusDivergences: number; + totalDataDivergences: number; + totalOnlyInSystem: number; + totalOnlyInReport: number; + totalDuplicateReportLines: number; + totalDuplicateSystemLines: number; + totalInvalidRows: number; + totalUnknownStatuses: number; + totalSyncableIssues: number; + appliedIssuesCount: number; + appliedLinesCount: number; + appliedFieldsCount: number; +} + +export interface MveAuditSnapshot { + numeroLinha?: string | null; + statusLinha?: string | null; + statusConta?: string | null; + planoLinha?: string | null; + dataAtivacao?: string | null; + terminoContrato?: string | null; + chip?: string | null; + conta?: string | null; + cnpj?: string | null; + modeloAparelho?: string | null; + fabricante?: string | null; + servicosAtivos?: string[]; +} + +export interface MveAuditDifference { + fieldKey: string; + label: string; + systemValue?: string | null; + reportValue?: string | null; + syncable: boolean; +} + +export interface MveAuditIssue { + id: string; + sourceRowNumber?: number | null; + numeroLinha: string; + mobileLineId?: string | null; + systemItem?: number | null; + issueType: string; + situation: string; + severity: string; + syncable: boolean; + applied: boolean; + actionSuggestion?: string | null; + notes?: string | null; + systemStatus?: string | null; + reportStatus?: string | null; + systemPlan?: string | null; + reportPlan?: string | null; + systemSnapshot?: MveAuditSnapshot | null; + reportSnapshot?: MveAuditSnapshot | null; + differences: MveAuditDifference[]; +} + +export interface MveAuditRun { + id: string; + fileName?: string | null; + fileEncoding?: string | null; + status: string; + importedAtUtc: string; + appliedAtUtc?: string | null; + appliedByUserName?: string | null; + appliedByUserEmail?: string | null; + summary: MveAuditSummary; + issues: MveAuditIssue[]; +} + +export interface ApplyMveAuditResult { + auditRunId: string; + requestedIssues: number; + appliedIssues: number; + updatedLines: number; + updatedFields: number; + skippedIssues: number; +} + +@Injectable({ providedIn: 'root' }) +export class MveAuditService { + private readonly baseUrl: string; + private currentRun: MveAuditRun | null = null; + + constructor(private readonly http: HttpClient) { + const apiBase = buildApiBaseUrl(environment.apiUrl); + this.baseUrl = `${apiBase}/mve-audit`; + } + + preview(file: File): Observable { + const form = new FormData(); + form.append('file', file); + return this.http + .post(`${this.baseUrl}/preview`, form) + .pipe(tap((run) => this.cacheRun(run))); + } + + getById(id: string): Observable { + return this.http + .get(`${this.baseUrl}/${id}`) + .pipe(tap((run) => this.cacheRun(run))); + } + + getLatest(): Observable { + return this.http + .get(`${this.baseUrl}/latest`) + .pipe(tap((run) => this.cacheRun(run))); + } + + apply(runId: string, issueIds?: string[]): Observable { + return this.http.post(`${this.baseUrl}/${runId}/apply`, { + issueIds: issueIds && issueIds.length > 0 ? issueIds : null, + }); + } + + getCachedRun(): MveAuditRun | null { + return this.currentRun; + } + + getCachedRunId(): string | null { + if (this.currentRun?.id) { + return this.currentRun.id; + } + + if (typeof window === 'undefined') { + return null; + } + + return sessionStorage.getItem(MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY); + } + + restoreCachedRun(): Observable { + if (this.currentRun) { + return of(this.currentRun); + } + + const runId = this.getCachedRunId(); + if (!runId) { + return of(null); + } + + return this.getById(runId).pipe( + catchError(() => { + this.clearCache(); + return of(null); + }) + ); + } + + clearCache(): void { + this.currentRun = null; + + if (typeof window === 'undefined') { + return; + } + + sessionStorage.removeItem(MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY); + } + + private cacheRun(run: MveAuditRun | null | undefined): void { + if (!run?.id) { + this.clearCache(); + return; + } + + this.currentRun = run; + + if (typeof window === 'undefined') { + return; + } + + sessionStorage.setItem(MVE_AUDIT_CURRENT_RUN_ID_STORAGE_KEY, run.id); + } +} diff --git a/src/app/services/notifications.service.ts b/src/app/services/notifications.service.ts index b78119d..6db4393 100644 --- a/src/app/services/notifications.service.ts +++ b/src/app/services/notifications.service.ts @@ -3,6 +3,7 @@ import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable, Subject, tap } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export type NotificationTipo = 'AVencer' | 'Vencido' | 'RenovacaoAutomatica' | string; @@ -40,8 +41,7 @@ export class NotificationsService { readonly events$ = this.eventsSubject.asObservable(); constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } list(): Observable { diff --git a/src/app/services/parcelamentos.service.ts b/src/app/services/parcelamentos.service.ts index 1a3a9b2..484c034 100644 --- a/src/app/services/parcelamentos.service.ts +++ b/src/app/services/parcelamentos.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export interface PagedResult { page: number; @@ -76,8 +77,7 @@ export class ParcelamentosService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } list(filters: { diff --git a/src/app/services/profile.service.ts b/src/app/services/profile.service.ts index 3879844..f6ec137 100644 --- a/src/app/services/profile.service.ts +++ b/src/app/services/profile.service.ts @@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export type ProfileMeDto = { id: string; @@ -26,8 +27,7 @@ export class ProfileService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } getMe(): Observable { diff --git a/src/app/services/resumo.service.ts b/src/app/services/resumo.service.ts index 507872f..011dd6e 100644 --- a/src/app/services/resumo.service.ts +++ b/src/app/services/resumo.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export interface MacrophonyPlan { planoContrato?: string | null; @@ -119,8 +120,7 @@ export class ResumoService { private readonly apiBase: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.apiBase = buildApiBaseUrl(environment.apiUrl); } getResumo() { diff --git a/src/app/services/solicitacoes-linhas.service.ts b/src/app/services/solicitacoes-linhas.service.ts new file mode 100644 index 0000000..228e5de --- /dev/null +++ b/src/app/services/solicitacoes-linhas.service.ts @@ -0,0 +1,61 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; + +export interface PagedResult { + page: number; + pageSize: number; + total: number; + items: T[]; +} + +export interface SolicitacaoLinhaDto { + id: string; + tenantId: string; + tenantNome?: string | null; + mobileLineId?: string | null; + linha?: string | null; + usuarioLinha?: string | null; + tipoSolicitacao: string; + franquiaLineAtual?: number | null; + franquiaLineNova?: number | null; + solicitanteNome?: string | null; + mensagem: string; + status: string; + createdAt: string; +} + +export interface SolicitacaoLinhaCreatePayload { + lineId: string; + tipoSolicitacao: 'alteracao-franquia' | 'bloqueio'; + franquiaLineNova?: number | null; +} + +@Injectable({ providedIn: 'root' }) +export class SolicitacoesLinhasService { + private readonly baseUrl: string; + + constructor(private readonly http: HttpClient) { + const apiBase = buildApiBaseUrl(environment.apiUrl); + this.baseUrl = `${apiBase}/solicitacoes-linhas`; + } + + list(params?: { page?: number; pageSize?: number; search?: string }): Observable> { + let httpParams = new HttpParams() + .set('page', String(params?.page ?? 1)) + .set('pageSize', String(params?.pageSize ?? 20)); + + const search = (params?.search ?? '').trim(); + if (search) { + httpParams = httpParams.set('search', search); + } + + return this.http.get>(this.baseUrl, { params: httpParams }); + } + + create(payload: SolicitacaoLinhaCreatePayload): Observable { + return this.http.post(this.baseUrl, payload); + } +} diff --git a/src/app/services/sysadmin.service.ts b/src/app/services/sysadmin.service.ts index b59eefa..4b01ab6 100644 --- a/src/app/services/sysadmin.service.ts +++ b/src/app/services/sysadmin.service.ts @@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export type SystemTenantDto = { tenantId: string; @@ -34,8 +35,7 @@ export class SysadminService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } listTenants(params?: ListSystemTenantsParams): Observable { diff --git a/src/app/services/table-export.service.ts b/src/app/services/table-export.service.ts new file mode 100644 index 0000000..8c9f72d --- /dev/null +++ b/src/app/services/table-export.service.ts @@ -0,0 +1,462 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; + +export type ExportCellType = 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'boolean'; + +export interface TableExportColumn { + header: string; + key?: string; + type?: ExportCellType; + width?: number; + value: (row: T, index: number) => unknown; +} + +export interface TableExportRequest { + fileName: string; + sheetName?: string; + columns: TableExportColumn[]; + rows: T[]; + templateBuffer?: ArrayBuffer | null; +} + +type CellStyleSnapshot = { + font?: Partial; + fill?: import('exceljs').Fill; + border?: Partial; + alignment?: Partial; +}; + +type TemplateStyleSnapshot = { + headerStyles: CellStyleSnapshot[]; + bodyStyle?: CellStyleSnapshot; + bodyAltStyle?: CellStyleSnapshot; + columnWidths: Array; +}; + +@Injectable({ providedIn: 'root' }) +export class TableExportService { + private readonly templatesApiBase = (() => { + const apiBase = buildApiBaseUrl(environment.apiUrl); + return `${apiBase}/templates`; + })(); + private defaultTemplateBufferPromise: Promise | null = null; + private cachedDefaultTemplateStyle?: TemplateStyleSnapshot; + + constructor(private readonly http: HttpClient) {} + + async exportAsXlsx(request: TableExportRequest): Promise { + const ExcelJS = await import('exceljs'); + const templateBuffer = request.templateBuffer ?? (await this.getDefaultTemplateBuffer()); + const templateStyle = await this.resolveTemplateStyle(ExcelJS, templateBuffer); + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(this.sanitizeSheetName(request.sheetName || 'Dados')); + + const rawColumns = request.columns ?? []; + const columns = rawColumns.filter((column) => !this.shouldExcludeColumnByHeader(column.header)); + const rows = request.rows ?? []; + + if (!columns.length) { + throw new Error('Nenhuma coluna exportavel apos remover ITEM/ID.'); + } + + const headerValues = columns.map((c) => c.header ?? ''); + sheet.addRow(headerValues); + + rows.forEach((row, rowIndex) => { + const values = columns.map((column) => this.normalizeValue(column.value(row, rowIndex), column.type)); + sheet.addRow(values); + }); + + this.applyHeaderStyle(sheet, columns.length, templateStyle); + this.applyBodyStyle(sheet, columns, rows.length, templateStyle); + this.applyColumnWidths(sheet, columns, rows, templateStyle); + this.applyAutoFilter(sheet, columns.length); + sheet.views = [{ state: 'frozen', ySplit: 1 }]; + + const extensionSafeName = this.ensureXlsxExtension(request.fileName); + const buffer = await workbook.xlsx.writeBuffer(); + this.downloadBuffer(buffer, extensionSafeName); + } + + buildTimestamp(date: Date = new Date()): string { + const year = date.getFullYear(); + const month = this.pad2(date.getMonth() + 1); + const day = this.pad2(date.getDate()); + const hour = this.pad2(date.getHours()); + const minute = this.pad2(date.getMinutes()); + return `${year}-${month}-${day}_${hour}-${minute}`; + } + + private applyHeaderStyle( + sheet: import('exceljs').Worksheet, + columnCount: number, + templateStyle?: TemplateStyleSnapshot, + ): void { + const headerRow = sheet.getRow(1); + headerRow.height = 24; + + for (let col = 1; col <= columnCount; col += 1) { + const cell = headerRow.getCell(col); + const templateCell = this.getTemplateStyleByIndex(templateStyle, col - 1); + cell.font = this.cloneStyle(templateCell?.font) || { bold: true, color: { argb: 'FFFFFFFF' }, name: 'Calibri', size: 11 }; + cell.fill = this.cloneStyle(templateCell?.fill) || { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF0A58CA' }, + }; + cell.alignment = this.cloneStyle(templateCell?.alignment) || { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = this.cloneStyle(templateCell?.border) || this.getDefaultBorder(); + } + } + + private applyBodyStyle( + sheet: import('exceljs').Worksheet, + columns: TableExportColumn[], + rowCount: number, + templateStyle?: TemplateStyleSnapshot, + ): void { + for (let rowIndex = 2; rowIndex <= rowCount + 1; rowIndex += 1) { + const row = sheet.getRow(rowIndex); + const isEven = (rowIndex - 1) % 2 === 0; + const templateRowStyle = isEven + ? (templateStyle?.bodyAltStyle ?? templateStyle?.bodyStyle) + : (templateStyle?.bodyStyle ?? templateStyle?.bodyAltStyle); + + columns.forEach((column, columnIndex) => { + const cell = row.getCell(columnIndex + 1); + cell.font = this.cloneStyle(templateRowStyle?.font) || { name: 'Calibri', size: 11, color: { argb: 'FF1F2937' } }; + cell.border = this.cloneStyle(templateRowStyle?.border) || this.getDefaultBorder(); + cell.alignment = this.cloneStyle(templateRowStyle?.alignment) || this.getAlignment(column.type); + + if (templateRowStyle?.fill) { + const fill = this.cloneStyle(templateRowStyle.fill); + if (fill) cell.fill = fill; + } else if (isEven) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF7FAFF' }, + }; + } + + if (column.type === 'number') cell.numFmt = '#,##0.00'; + if (column.type === 'currency') cell.numFmt = '"R$" #,##0.00'; + if (column.type === 'date') cell.numFmt = 'dd/mm/yyyy'; + if (column.type === 'datetime') cell.numFmt = 'dd/mm/yyyy hh:mm'; + }); + } + } + + private applyColumnWidths( + sheet: import('exceljs').Worksheet, + columns: TableExportColumn[], + rows: T[], + templateStyle?: TemplateStyleSnapshot, + ): void { + columns.forEach((column, columnIndex) => { + if (column.width && column.width > 0) { + sheet.getColumn(columnIndex + 1).width = column.width; + return; + } + + const templateWidth = templateStyle?.columnWidths?.[columnIndex]; + if (templateWidth && templateWidth > 0) { + sheet.getColumn(columnIndex + 1).width = templateWidth; + return; + } + + const headerLength = (column.header ?? '').length; + let maxLength = headerLength; + + rows.forEach((row, rowIndex) => { + const value = column.value(row, rowIndex); + const printable = this.toPrintableValue(value, column.type); + if (printable.length > maxLength) maxLength = printable.length; + }); + + const target = Math.max(12, Math.min(maxLength + 3, 48)); + sheet.getColumn(columnIndex + 1).width = target; + }); + } + + private applyAutoFilter(sheet: import('exceljs').Worksheet, columnCount: number): void { + if (columnCount <= 0) return; + sheet.autoFilter = { + from: { row: 1, column: 1 }, + to: { row: 1, column: columnCount }, + }; + } + + private normalizeValue(value: unknown, type?: ExportCellType): string | number | Date | boolean | null { + if (value === null || value === undefined || value === '') return null; + + if (type === 'number' || type === 'currency') { + const numeric = this.toNumber(value); + return numeric ?? String(value); + } + + if (type === 'date' || type === 'datetime') { + const parsedDate = this.toDate(value); + return parsedDate ?? String(value); + } + + if (type === 'boolean') { + if (typeof value === 'boolean') return value; + return this.normalizeBoolean(value); + } + + return String(value); + } + + private toPrintableValue(value: unknown, type?: ExportCellType): string { + if (value === null || value === undefined || value === '') return ''; + + if (type === 'date' || type === 'datetime') { + const parsedDate = this.toDate(value); + if (!parsedDate) return String(value); + const datePart = `${this.pad2(parsedDate.getDate())}/${this.pad2(parsedDate.getMonth() + 1)}/${parsedDate.getFullYear()}`; + if (type === 'date') return datePart; + return `${datePart} ${this.pad2(parsedDate.getHours())}:${this.pad2(parsedDate.getMinutes())}`; + } + + if (type === 'number' || type === 'currency') { + const numeric = this.toNumber(value); + if (numeric === null) return String(value); + if (type === 'currency') { + return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(numeric); + } + return new Intl.NumberFormat('pt-BR').format(numeric); + } + + if (type === 'boolean') { + return this.normalizeBoolean(value) ? 'Sim' : 'Nao'; + } + + return String(value); + } + + private toNumber(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + const normalized = trimmed + .replace(/[^\d,.-]/g, '') + .replace(/\.(?=\d{3}(\D|$))/g, '') + .replace(',', '.'); + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; + } + + private toDate(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + + const brDate = trimmed.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2}))?$/); + if (brDate) { + const day = Number(brDate[1]); + const month = Number(brDate[2]) - 1; + const year = Number(brDate[3]); + const hour = Number(brDate[4] ?? '0'); + const minute = Number(brDate[5] ?? '0'); + const parsedBr = new Date(year, month, day, hour, minute); + return Number.isNaN(parsedBr.getTime()) ? null : parsedBr; + } + + const parsed = new Date(trimmed); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + return null; + } + + private normalizeBoolean(value: unknown): boolean { + if (typeof value === 'boolean') return value; + const normalized = String(value ?? '') + .trim() + .toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'sim' || normalized === 'yes'; + } + + private ensureXlsxExtension(fileName: string): string { + const safe = (fileName ?? 'export').trim() || 'export'; + return safe.toLowerCase().endsWith('.xlsx') ? safe : `${safe}.xlsx`; + } + + private sanitizeSheetName(name: string): string { + const safe = (name ?? 'Dados').replace(/[\\/*?:[\]]/g, '').trim(); + return (safe || 'Dados').slice(0, 31); + } + + private shouldExcludeColumnByHeader(header: string | undefined): boolean { + const normalized = this.normalizeHeader(header); + if (!normalized) return false; + + const tokens = normalized.split(/[^a-z0-9]+/).filter(Boolean); + if (!tokens.length) return false; + + return tokens.includes('id') || tokens.includes('item'); + } + + private normalizeHeader(value: string | undefined): string { + return (value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); + } + + private downloadBuffer(buffer: ArrayBuffer, fileName: string): void { + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + } + + private getAlignment(type?: ExportCellType): Partial { + if (type === 'number' || type === 'currency') { + return { vertical: 'middle', horizontal: 'right' }; + } + if (type === 'boolean') { + return { vertical: 'middle', horizontal: 'center' }; + } + return { vertical: 'middle', horizontal: 'left', wrapText: true }; + } + + private getDefaultBorder(): Partial { + return { + top: { style: 'thin', color: { argb: 'FFD6DCE8' } }, + left: { style: 'thin', color: { argb: 'FFD6DCE8' } }, + right: { style: 'thin', color: { argb: 'FFD6DCE8' } }, + bottom: { style: 'thin', color: { argb: 'FFD6DCE8' } }, + }; + } + + private pad2(value: number): string { + return value.toString().padStart(2, '0'); + } + + private async extractTemplateStyle( + excelJsModule: typeof import('exceljs'), + templateBuffer: ArrayBuffer | null, + ): Promise { + if (!templateBuffer) return undefined; + + try { + const workbook = new excelJsModule.Workbook(); + await workbook.xlsx.load(templateBuffer); + const sheet = workbook.getWorksheet(1); + if (!sheet) return undefined; + + const headerRow = sheet.getRow(1); + const headerCount = Math.max(headerRow.actualCellCount, 1); + const headerStyles: CellStyleSnapshot[] = []; + for (let col = 1; col <= headerCount; col += 1) { + headerStyles.push(this.captureCellStyle(headerRow.getCell(col))); + } + + const bodyStyle = this.captureFirstStyledCellRow(sheet.getRow(2)); + const bodyAltStyle = this.captureFirstStyledCellRow(sheet.getRow(3)); + const columnWidths = (sheet.columns ?? []).map((column) => column.width); + + return { headerStyles, bodyStyle, bodyAltStyle, columnWidths }; + } catch { + return undefined; + } + } + + private async resolveTemplateStyle( + excelJsModule: typeof import('exceljs'), + templateBuffer: ArrayBuffer | null, + ): Promise { + if (templateBuffer) { + const style = await this.extractTemplateStyle(excelJsModule, templateBuffer); + if (style) this.cachedDefaultTemplateStyle = style; + return style; + } + + return this.cachedDefaultTemplateStyle; + } + + private async getDefaultTemplateBuffer(): Promise { + if (this.defaultTemplateBufferPromise) { + return this.defaultTemplateBufferPromise; + } + + this.defaultTemplateBufferPromise = this.fetchDefaultTemplateBuffer(); + const buffer = await this.defaultTemplateBufferPromise; + if (!buffer) this.defaultTemplateBufferPromise = null; + return buffer; + } + + private async fetchDefaultTemplateBuffer(): Promise { + try { + const params = new HttpParams().set('_', `${Date.now()}`); + const blob = await firstValueFrom( + this.http.get(`${this.templatesApiBase}/planilha-geral`, { + params, + responseType: 'blob', + }) + ); + return await blob.arrayBuffer(); + } catch { + return null; + } + } + + private captureFirstStyledCellRow(row: import('exceljs').Row): CellStyleSnapshot | undefined { + if (!row) return undefined; + const cellCount = Math.max(row.actualCellCount, 1); + for (let col = 1; col <= cellCount; col += 1) { + const captured = this.captureCellStyle(row.getCell(col)); + if (captured.font || captured.fill || captured.border || captured.alignment) { + return captured; + } + } + return undefined; + } + + private captureCellStyle(cell: import('exceljs').Cell): CellStyleSnapshot { + return { + font: this.cloneStyle(cell.font), + fill: this.cloneStyle(cell.fill), + border: this.cloneStyle(cell.border), + alignment: this.cloneStyle(cell.alignment), + }; + } + + private getTemplateStyleByIndex(style: TemplateStyleSnapshot | undefined, index: number): CellStyleSnapshot | undefined { + if (!style || !style.headerStyles.length) return undefined; + return style.headerStyles[index] ?? style.headerStyles[style.headerStyles.length - 1]; + } + + private cloneStyle(value: T | undefined): T | undefined { + if (!value) return undefined; + try { + return JSON.parse(JSON.stringify(value)) as T; + } catch { + return value; + } + } +} diff --git a/src/app/services/users.service.ts b/src/app/services/users.service.ts index 115f3ce..8dd2c9d 100644 --- a/src/app/services/users.service.ts +++ b/src/app/services/users.service.ts @@ -3,8 +3,9 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; -export type UserPermission = 'sysadmin' | 'gestor' | 'cliente'; +export type UserPermission = 'sysadmin' | 'gestor' | 'financeiro' | 'cliente'; export type UserDto = { id: string; @@ -60,8 +61,7 @@ export class UsersService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } create(payload: CreateUserPayload): Observable { diff --git a/src/app/services/vigencia.service.ts b/src/app/services/vigencia.service.ts index eb65938..c6bcb54 100644 --- a/src/app/services/vigencia.service.ts +++ b/src/app/services/vigencia.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; +import { buildApiBaseUrl } from '../utils/api-base.util'; export type SortDir = 'asc' | 'desc'; @@ -76,8 +77,7 @@ export class VigenciaService { private readonly baseApi: string; constructor(private http: HttpClient) { - const raw = (environment.apiUrl || '').replace(/\/+$/, ''); - this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; + this.baseApi = buildApiBaseUrl(environment.apiUrl); } getVigencia(opts: { search?: string; client?: string; page?: number; pageSize?: number; sortBy?: string; sortDir?: SortDir; }): Observable> { diff --git a/src/app/utils/account-operator.util.spec.ts b/src/app/utils/account-operator.util.spec.ts new file mode 100644 index 0000000..ed1c014 --- /dev/null +++ b/src/app/utils/account-operator.util.spec.ts @@ -0,0 +1,80 @@ +import { + DEFAULT_ACCOUNT_COMPANIES, + mergeAccountCompaniesWithDefaults, + normalizeConta, + resolveEmpresaByConta, + resolveOperadoraContext, + sameConta, +} from './account-operator.util'; + +describe('account-operator.util', () => { + it('normaliza contas removendo zeros a esquerda', () => { + expect(normalizeConta('0455371844')).toBe('455371844'); + expect(normalizeConta('000187890982')).toBe('187890982'); + }); + + it('compara contas normalizadas', () => { + expect(sameConta('0435288088', '435288088')).toBeTrue(); + expect(sameConta('172593311', '172593840')).toBeFalse(); + }); + + it('resolve empresa por conta com regras deterministicas obrigatorias', () => { + expect(resolveEmpresaByConta('455371844', [])).toBe('VIVO MACROPHONY'); + expect(resolveEmpresaByConta('460161507', [])).toBe('VIVO MACROPHONY'); + expect(resolveEmpresaByConta('187890982', [])).toBe('CLARO LINE MÓVEL'); + expect(resolveEmpresaByConta('TIM', [])).toBe('TIM LINE MÓVEL'); + }); + + it('mescla lista da API com defaults sem perder contas obrigatorias', () => { + const merged = mergeAccountCompaniesWithDefaults([ + { empresa: 'VIVO MACROPHONY', contas: ['0430237019'] }, + ]); + + const vivo = merged.find((group) => group.empresa === 'VIVO MACROPHONY'); + const contas = (vivo?.contas ?? []).map((value) => normalizeConta(value)); + + expect(contas).toContain(normalizeConta('455371844')); + expect(contas).toContain(normalizeConta('460161507')); + expect(contas).toContain(normalizeConta('0430237019')); + }); + + it('classifica operadora e grupo da vivo por contexto', () => { + const vivo = resolveOperadoraContext({ + conta: '455371844', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + expect(vivo.operadora).toBe('VIVO'); + expect(vivo.vivoEmpresaGrupo).toBe('MACROPHONY'); + + const claro = resolveOperadoraContext({ + conta: '187890982', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + expect(claro.operadora).toBe('CLARO'); + expect(claro.vivoEmpresaGrupo).toBeNull(); + + const tim = resolveOperadoraContext({ + empresaConta: 'TIM LINE MÓVEL', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + expect(tim.operadora).toBe('TIM'); + + const timByConta = resolveOperadoraContext({ + conta: 'TIM', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + expect(timByConta.operadora).toBe('TIM'); + }); + + it('prioriza mapeamento deterministico por conta mesmo com empresa da linha divergente', () => { + const vivoDeterministico = resolveOperadoraContext({ + conta: '455371844', + empresaConta: 'VIVO LINE MÓVEL', + accountCompanies: DEFAULT_ACCOUNT_COMPANIES, + }); + + expect(vivoDeterministico.operadora).toBe('VIVO'); + expect(vivoDeterministico.empresaConta).toBe('VIVO MACROPHONY'); + expect(vivoDeterministico.vivoEmpresaGrupo).toBe('MACROPHONY'); + }); +}); diff --git a/src/app/utils/account-operator.util.ts b/src/app/utils/account-operator.util.ts new file mode 100644 index 0000000..3dab6fc --- /dev/null +++ b/src/app/utils/account-operator.util.ts @@ -0,0 +1,176 @@ +import { normalizeAccentInsensitive } from './text-normalization.util'; + +export type OperadoraNome = 'VIVO' | 'CLARO' | 'TIM' | 'OUTRA'; +export type OperadoraFiltro = 'TODOS' | 'VIVO' | 'CLARO' | 'TIM'; +export type VivoEmpresaGrupo = 'MACROPHONY' | 'LINE MOVEL' | 'OUTRA'; + +export interface AccountCompanyOption { + empresa: string; + contas: string[]; +} + +export interface OperadoraResolution { + operadora: OperadoraNome; + empresaConta: string; + vivoEmpresaGrupo: VivoEmpresaGrupo | null; +} + +export const DEFAULT_ACCOUNT_COMPANIES: AccountCompanyOption[] = [ + { empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840', '187890982'] }, + { empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844', '455371844', '460161507'] }, + { empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] }, + { empresa: 'TIM LINE MÓVEL', contas: ['TIM'] }, +]; + +const DEFAULT_EMPRESA_BY_CONTA = buildDefaultEmpresaByConta(); + +function buildDefaultEmpresaByConta(): Map { + const result = new Map(); + + DEFAULT_ACCOUNT_COMPANIES.forEach((group) => { + (group.contas ?? []).forEach((conta) => { + const normalized = normalizeConta(conta); + if (!normalized) return; + result.set(normalized, group.empresa); + }); + }); + + return result; +} + +function normalizeEmpresaKey(value: unknown): string { + return normalizeAccentInsensitive(value, 'upper').replace(/[^A-Z0-9]/g, ''); +} + +function normalizeContas(contas: unknown): string[] { + if (!Array.isArray(contas)) return []; + + const result: string[] = []; + const seen = new Set(); + + contas.forEach((value) => { + const trimmed = String(value ?? '').trim(); + if (!trimmed) return; + const normalized = normalizeConta(trimmed); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + result.push(trimmed); + }); + + return result; +} + +export function normalizeConta(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + + if (!/^\d+$/.test(raw)) { + return normalizeAccentInsensitive(raw, 'upper'); + } + + const noLeadingZero = raw.replace(/^0+/, ''); + return noLeadingZero || '0'; +} + +export function sameConta(a: unknown, b: unknown): boolean { + return normalizeConta(a) === normalizeConta(b); +} + +export function mergeAccountCompaniesWithDefaults( + source: AccountCompanyOption[] | null | undefined +): AccountCompanyOption[] { + const merged = new Map(); + const contaSeenByEmpresa = new Map>(); + + const addGroup = (empresaRaw: unknown, contasRaw: unknown) => { + const empresa = String(empresaRaw ?? '').trim(); + if (!empresa) return; + + const key = normalizeEmpresaKey(empresa); + const contas = normalizeContas(contasRaw); + + if (!merged.has(key)) { + merged.set(key, { empresa, contas: [] }); + contaSeenByEmpresa.set(key, new Set()); + } + + const record = merged.get(key); + const seen = contaSeenByEmpresa.get(key); + if (!record || !seen) return; + + contas.forEach((conta) => { + const normalized = normalizeConta(conta); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + record.contas.push(conta); + }); + }; + + (source ?? []).forEach((group) => addGroup(group?.empresa, group?.contas)); + DEFAULT_ACCOUNT_COMPANIES.forEach((group) => addGroup(group.empresa, group.contas)); + + return Array.from(merged.values()); +} + +export function resolveEmpresaByConta( + conta: unknown, + accountCompanies: AccountCompanyOption[] | null | undefined +): string { + const target = normalizeConta(conta); + if (!target) return ''; + + const deterministic = DEFAULT_EMPRESA_BY_CONTA.get(target); + if (deterministic) return deterministic; + + const found = (accountCompanies ?? []).find((group) => + (group.contas ?? []).some((candidate) => sameConta(candidate, target)) + ); + return found?.empresa ?? ''; +} + +function resolveOperadoraByEmpresa(empresa: unknown): OperadoraNome { + const normalized = normalizeEmpresaKey(empresa); + if (!normalized) return 'OUTRA'; + if (normalized.includes('CLARO')) return 'CLARO'; + if (normalized.includes('TIM')) return 'TIM'; + if (normalized.includes('VIVO') || normalized.includes('MACROPHONY')) return 'VIVO'; + return 'OUTRA'; +} + +function resolveVivoEmpresaGrupo(empresa: unknown): VivoEmpresaGrupo { + const normalized = normalizeEmpresaKey(empresa); + if (!normalized) return 'OUTRA'; + if (normalized.includes('MACROPHONY')) return 'MACROPHONY'; + if (normalized.includes('LINEMOVEL') || normalized.includes('LINEMOV')) return 'LINE MOVEL'; + return 'OUTRA'; +} + +export function resolveOperadoraContext(input: { + conta?: unknown; + empresaConta?: unknown; + accountCompanies?: AccountCompanyOption[] | null; +}): OperadoraResolution { + const contaRaw = String(input.conta ?? '').trim(); + const contaEmpresaRaw = String(input.empresaConta ?? '').trim(); + const empresaFromConta = resolveEmpresaByConta(input.conta, input.accountCompanies); + // Regras por conta (determinísticas) têm prioridade sobre texto livre da linha. + const empresaConta = empresaFromConta || contaEmpresaRaw; + + let operadora = resolveOperadoraByEmpresa(empresaConta); + if (operadora === 'OUTRA' && empresaFromConta) { + operadora = resolveOperadoraByEmpresa(empresaFromConta); + } + if (operadora === 'OUTRA' && contaRaw) { + operadora = resolveOperadoraByEmpresa(contaRaw); + } + + const vivoEmpresaGrupo = operadora === 'VIVO' + ? resolveVivoEmpresaGrupo(empresaConta || empresaFromConta || contaRaw) + : null; + + return { + operadora, + empresaConta: empresaConta || '', + vivoEmpresaGrupo, + }; +} diff --git a/src/app/utils/api-base.util.ts b/src/app/utils/api-base.util.ts new file mode 100644 index 0000000..b5269ff --- /dev/null +++ b/src/app/utils/api-base.util.ts @@ -0,0 +1,11 @@ +export function buildApiBaseUrl(apiUrl: string | null | undefined): string { + const raw = (apiUrl ?? '').toString().trim().replace(/\/+$/, ''); + if (!raw) return '/api'; + return raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`; +} + +export function buildApiEndpoint(apiUrl: string | null | undefined, resourcePath: string): string { + const base = buildApiBaseUrl(apiUrl); + const cleanedPath = (resourcePath ?? '').toString().trim().replace(/^\/+/, ''); + return cleanedPath ? `${base}/${cleanedPath}` : base; +} diff --git a/src/app/utils/pagination.util.ts b/src/app/utils/pagination.util.ts new file mode 100644 index 0000000..3e49a16 --- /dev/null +++ b/src/app/utils/pagination.util.ts @@ -0,0 +1,35 @@ +export function computeTotalPages(total: number, pageSize: number): number { + const safeTotal = Number.isFinite(total) ? Math.max(0, total) : 0; + const safeSize = Number.isFinite(pageSize) ? Math.max(1, pageSize) : 1; + return Math.max(1, Math.ceil(safeTotal / safeSize)); +} + +export function clampPage(page: number, totalPages: number): number { + return Math.max(1, Math.min(totalPages, page)); +} + +export function buildPageNumbers(currentPage: number, totalPages: number, maxVisible = 5): number[] { + const safeTotal = Math.max(1, totalPages); + const safeCurrent = clampPage(currentPage, safeTotal); + const safeMax = Math.max(1, maxVisible); + + let start = Math.max(1, safeCurrent - Math.floor(safeMax / 2)); + let end = Math.min(safeTotal, start + (safeMax - 1)); + start = Math.max(1, end - (safeMax - 1)); + + const pages: number[] = []; + for (let i = start; i <= end; i += 1) pages.push(i); + return pages; +} + +export function computePageStart(total: number, page: number, pageSize: number): number { + if (!Number.isFinite(total) || total <= 0) return 0; + return (clampPage(page, computeTotalPages(total, pageSize)) - 1) * Math.max(1, pageSize) + 1; +} + +export function computePageEnd(total: number, page: number, pageSize: number): number { + if (!Number.isFinite(total) || total <= 0) return 0; + const safeSize = Math.max(1, pageSize); + const safePage = clampPage(page, computeTotalPages(total, safeSize)); + return Math.min(safePage * safeSize, total); +} diff --git a/src/app/utils/text-normalization.util.ts b/src/app/utils/text-normalization.util.ts new file mode 100644 index 0000000..31a1ff3 --- /dev/null +++ b/src/app/utils/text-normalization.util.ts @@ -0,0 +1,16 @@ +export type NormalizeCaseMode = 'upper' | 'lower' | 'none'; + +export function normalizeAccentInsensitive( + value: unknown, + caseMode: NormalizeCaseMode = 'upper' +): string { + const normalized = (value ?? '') + .toString() + .trim() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + + if (caseMode === 'lower') return normalized.toLowerCase(); + if (caseMode === 'none') return normalized; + return normalized.toUpperCase(); +} diff --git a/tsconfig.app.json b/tsconfig.app.json index ef19921..a2b81e4 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", + "noPropertyAccessFromIndexSignature": false, "types": [ "node" ]