Major update: ESG, KPIs, metas, alertas, auditoria, documentos, importação, relatórios, subcategorias, dashboard orçamentos
This commit is contained in:
346
backend/package-lock.json
generated
346
backend/package-lock.json
generated
@@ -15,16 +15,19 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@@ -33,8 +36,7 @@
|
|||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/express": "^5.0.6",
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
@@ -2767,21 +2769,10 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/better-sqlite3": {
|
|
||||||
"version": "7.6.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
|
||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/connect": "*",
|
"@types/connect": "*",
|
||||||
@@ -2792,7 +2783,6 @@
|
|||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -2838,7 +2828,6 @@
|
|||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
@@ -2850,7 +2839,6 @@
|
|||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
@@ -2863,7 +2851,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
@@ -2934,6 +2921,15 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/multer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.10",
|
"version": "22.19.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz",
|
||||||
@@ -2979,21 +2975,18 @@
|
|||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/send": {
|
"node_modules/@types/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@@ -3003,7 +2996,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@types/http-errors": "*",
|
||||||
@@ -3840,6 +3832,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -4210,6 +4211,8 @@
|
|||||||
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
|
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"prebuild-install": "^7.1.1"
|
"prebuild-install": "^7.1.1"
|
||||||
@@ -4223,6 +4226,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"file-uri-to-path": "1.0.0"
|
"file-uri-to-path": "1.0.0"
|
||||||
}
|
}
|
||||||
@@ -4231,6 +4236,7 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer": "^5.5.0",
|
"buffer": "^5.5.0",
|
||||||
@@ -4347,6 +4353,7 @@
|
|||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||||
|
"devOptional": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -4487,6 +4494,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -4541,7 +4561,9 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/chrome-trace-event": {
|
"node_modules/chrome-trace-event": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
@@ -4697,6 +4719,15 @@
|
|||||||
"node": ">= 0.12.0"
|
"node": ">= 0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/collect-v8-coverage": {
|
"node_modules/collect-v8-coverage": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
||||||
@@ -4906,6 +4937,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/create-require": {
|
"node_modules/create-require": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
@@ -4955,6 +4998,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mimic-response": "^3.1.0"
|
"mimic-response": "^3.1.0"
|
||||||
},
|
},
|
||||||
@@ -4984,6 +5029,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0.0"
|
"node": ">=4.0.0"
|
||||||
}
|
}
|
||||||
@@ -5059,6 +5106,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -5181,6 +5230,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
@@ -5570,6 +5621,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||||
"license": "(MIT OR WTFPL)",
|
"license": "(MIT OR WTFPL)",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@@ -5749,7 +5802,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
@@ -5949,6 +6004,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
@@ -5962,7 +6026,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
@@ -6100,7 +6166,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "13.0.0",
|
"version": "13.0.0",
|
||||||
@@ -6433,7 +6501,9 @@
|
|||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
@@ -7930,6 +8000,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -7984,7 +8056,9 @@
|
|||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
@@ -8067,7 +8141,9 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/napi-postinstall": {
|
"node_modules/napi-postinstall": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
@@ -8113,6 +8189,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
|
||||||
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
|
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.3.5"
|
"semver": "^7.3.5"
|
||||||
},
|
},
|
||||||
@@ -8480,6 +8558,95 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.11.0",
|
||||||
|
"pg-pool": "^3.11.0",
|
||||||
|
"pg-protocol": "^1.11.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
||||||
|
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -8598,11 +8765,52 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.0",
|
"detect-libc": "^2.0.0",
|
||||||
"expand-template": "^2.0.3",
|
"expand-template": "^2.0.3",
|
||||||
@@ -8709,6 +8917,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"end-of-stream": "^1.1.0",
|
"end-of-stream": "^1.1.0",
|
||||||
"once": "^1.3.1"
|
"once": "^1.3.1"
|
||||||
@@ -8795,6 +9005,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deep-extend": "^0.6.0",
|
"deep-extend": "^0.6.0",
|
||||||
"ini": "~1.3.0",
|
"ini": "~1.3.0",
|
||||||
@@ -8810,6 +9022,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9231,7 +9445,9 @@
|
|||||||
"url": "https://feross.org/support"
|
"url": "https://feross.org/support"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/simple-get": {
|
"node_modules/simple-get": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
@@ -9252,6 +9468,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"decompress-response": "^6.0.0",
|
"decompress-response": "^6.0.0",
|
||||||
"once": "^1.3.1",
|
"once": "^1.3.1",
|
||||||
@@ -9299,6 +9517,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
@@ -9322,6 +9549,18 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stack-utils": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
@@ -9582,6 +9821,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chownr": "^1.1.1",
|
"chownr": "^1.1.1",
|
||||||
"mkdirp-classic": "^0.5.2",
|
"mkdirp-classic": "^0.5.2",
|
||||||
@@ -9594,6 +9835,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bl": "^4.0.3",
|
"bl": "^4.0.3",
|
||||||
"end-of-stream": "^1.4.1",
|
"end-of-stream": "^1.4.1",
|
||||||
@@ -10096,6 +10339,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
},
|
},
|
||||||
@@ -10895,6 +11140,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -10965,6 +11228,27 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -26,16 +26,19 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@@ -44,8 +47,7 @@
|
|||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/express": "^5.0.6",
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ import { CentrosCustoModule } from './modules/centros-custo/centros-custo.module
|
|||||||
import { CategoriasModule } from './modules/categorias/categorias.module';
|
import { CategoriasModule } from './modules/categorias/categorias.module';
|
||||||
import { FornecedoresModule } from './modules/fornecedores/fornecedores.module';
|
import { FornecedoresModule } from './modules/fornecedores/fornecedores.module';
|
||||||
import { DemandasModule } from './modules/demandas/demandas.module';
|
import { DemandasModule } from './modules/demandas/demandas.module';
|
||||||
|
import { SubcategoriasModule } from './modules/subcategorias/subcategorias.module';
|
||||||
|
import { DocumentosModule } from './modules/documentos/documentos.module';
|
||||||
import { PropostasModule } from './modules/propostas/propostas.module';
|
import { PropostasModule } from './modules/propostas/propostas.module';
|
||||||
import { OrcamentoModule } from './modules/orcamento/orcamento.module';
|
import { OrcamentoModule } from './modules/orcamento/orcamento.module';
|
||||||
import { WorkflowModule } from './modules/workflow/workflow.module';
|
import { WorkflowModule } from './modules/workflow/workflow.module';
|
||||||
import { DashboardModule } from './modules/dashboard/dashboard.module';
|
import { DashboardModule } from './modules/dashboard/dashboard.module';
|
||||||
import { OrdensServicoModule } from './modules/ordens-servico/ordens-servico.module';
|
import { OrdensServicoModule } from './modules/ordens-servico/ordens-servico.module';
|
||||||
|
import { EsgModule } from './modules/esg/esg.module';
|
||||||
|
import { KpisModule } from './modules/kpis/kpis.module';
|
||||||
|
import { AuditModule } from './modules/audit/audit.module';
|
||||||
|
import { ImportModule } from './modules/import/import.module';
|
||||||
|
import { RelatoriosModule } from './modules/relatorios/relatorios.module';
|
||||||
|
import { MetasModule } from './modules/metas/metas.module';
|
||||||
|
import { AlertasModule } from './modules/alertas/alertas.module';
|
||||||
import { SeedService } from './database/seeds/seed.service';
|
import { SeedService } from './database/seeds/seed.service';
|
||||||
import { Perfil } from './modules/users/entities/perfil.entity';
|
import { Perfil } from './modules/users/entities/perfil.entity';
|
||||||
import { Usuario } from './modules/users/entities/usuario.entity';
|
import { Usuario } from './modules/users/entities/usuario.entity';
|
||||||
@@ -22,26 +31,39 @@ import { Fornecedor } from './modules/fornecedores/entities/fornecedor.entity';
|
|||||||
import { Certidao } from './modules/fornecedores/entities/certidao.entity';
|
import { Certidao } from './modules/fornecedores/entities/certidao.entity';
|
||||||
import { Demanda } from './modules/demandas/entities/demanda.entity';
|
import { Demanda } from './modules/demandas/entities/demanda.entity';
|
||||||
import { ItemLinha } from './modules/demandas/entities/item-linha.entity';
|
import { ItemLinha } from './modules/demandas/entities/item-linha.entity';
|
||||||
|
import { Subcategoria } from './modules/subcategorias/entities/subcategoria.entity';
|
||||||
|
import { Documento } from './modules/documentos/entities/documento.entity';
|
||||||
import { Proposta } from './modules/propostas/entities/proposta.entity';
|
import { Proposta } from './modules/propostas/entities/proposta.entity';
|
||||||
import { OrcamentoPlanejado } from './modules/orcamento/entities/orcamento-planejado.entity';
|
import { OrcamentoPlanejado } from './modules/orcamento/entities/orcamento-planejado.entity';
|
||||||
import { WorkflowAprovacao } from './modules/workflow/entities/workflow-aprovacao.entity';
|
import { WorkflowAprovacao } from './modules/workflow/entities/workflow-aprovacao.entity';
|
||||||
import { OrdemServico } from './modules/ordens-servico/entities/ordem-servico.entity';
|
import { OrdemServico } from './modules/ordens-servico/entities/ordem-servico.entity';
|
||||||
import { Avaliacao } from './modules/ordens-servico/entities/avaliacao.entity';
|
import { Avaliacao } from './modules/ordens-servico/entities/avaliacao.entity';
|
||||||
|
import { DocumentoVersao } from './modules/ordens-servico/entities/documento-versao.entity';
|
||||||
import { Alerta } from './modules/dashboard/entities/alerta.entity';
|
import { Alerta } from './modules/dashboard/entities/alerta.entity';
|
||||||
import { AuditLog } from './modules/dashboard/entities/audit-log.entity';
|
import { AuditLog } from './modules/dashboard/entities/audit-log.entity';
|
||||||
|
import { EsgMetrica } from './modules/esg/entities/esg-metrica.entity';
|
||||||
|
import { EsgMeta } from './modules/esg/entities/esg-meta.entity';
|
||||||
|
import { Kpi } from './modules/kpis/entities/kpi.entity';
|
||||||
|
import { Meta } from './modules/metas/entities/meta.entity';
|
||||||
|
import { AlertaConfig } from './modules/alertas/entities/alerta-config.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forRoot({
|
TypeOrmModule.forRoot({
|
||||||
type: 'better-sqlite3',
|
type: 'postgres',
|
||||||
database: 'hefesto.db',
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
username: process.env.DB_USERNAME || 'hefesto',
|
||||||
|
password: process.env.DB_PASSWORD || 'Hefesto2026!',
|
||||||
|
database: process.env.DB_DATABASE || 'hefesto',
|
||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
}),
|
}),
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
Perfil, Usuario, Local, CentroCusto, Categoria, Fornecedor, Certidao,
|
Perfil, Usuario, Local, CentroCusto, Categoria, Fornecedor, Certidao,
|
||||||
Demanda, ItemLinha, Proposta, OrcamentoPlanejado, WorkflowAprovacao,
|
Demanda, ItemLinha, Subcategoria, Documento, Proposta, OrcamentoPlanejado, WorkflowAprovacao,
|
||||||
OrdemServico, Avaliacao, Alerta, AuditLog,
|
OrdemServico, Avaliacao, DocumentoVersao, Alerta, AuditLog,
|
||||||
|
EsgMetrica, EsgMeta, Kpi, Meta, AlertaConfig,
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
@@ -50,11 +72,20 @@ import { AuditLog } from './modules/dashboard/entities/audit-log.entity';
|
|||||||
CategoriasModule,
|
CategoriasModule,
|
||||||
FornecedoresModule,
|
FornecedoresModule,
|
||||||
DemandasModule,
|
DemandasModule,
|
||||||
|
SubcategoriasModule,
|
||||||
|
DocumentosModule,
|
||||||
PropostasModule,
|
PropostasModule,
|
||||||
OrcamentoModule,
|
OrcamentoModule,
|
||||||
WorkflowModule,
|
WorkflowModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
OrdensServicoModule,
|
OrdensServicoModule,
|
||||||
|
EsgModule,
|
||||||
|
KpisModule,
|
||||||
|
AuditModule,
|
||||||
|
ImportModule,
|
||||||
|
RelatoriosModule,
|
||||||
|
MetasModule,
|
||||||
|
AlertasModule,
|
||||||
],
|
],
|
||||||
providers: [SeedService],
|
providers: [SeedService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import { OrcamentoPlanejado } from '../../modules/orcamento/entities/orcamento-p
|
|||||||
import { WorkflowAprovacao } from '../../modules/workflow/entities/workflow-aprovacao.entity';
|
import { WorkflowAprovacao } from '../../modules/workflow/entities/workflow-aprovacao.entity';
|
||||||
import { OrdemServico } from '../../modules/ordens-servico/entities/ordem-servico.entity';
|
import { OrdemServico } from '../../modules/ordens-servico/entities/ordem-servico.entity';
|
||||||
import { Alerta } from '../../modules/dashboard/entities/alerta.entity';
|
import { Alerta } from '../../modules/dashboard/entities/alerta.entity';
|
||||||
|
import { EsgMetrica } from '../../modules/esg/entities/esg-metrica.entity';
|
||||||
|
import { EsgMeta } from '../../modules/esg/entities/esg-meta.entity';
|
||||||
|
import { Kpi } from '../../modules/kpis/entities/kpi.entity';
|
||||||
|
import { Meta } from '../../modules/metas/entities/meta.entity';
|
||||||
|
import { AlertaConfig } from '../../modules/alertas/entities/alerta-config.entity';
|
||||||
|
import { Subcategoria } from '../../modules/subcategorias/entities/subcategoria.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeedService {
|
export class SeedService {
|
||||||
@@ -35,6 +41,12 @@ export class SeedService {
|
|||||||
@InjectRepository(WorkflowAprovacao) private wfRepo: Repository<WorkflowAprovacao>,
|
@InjectRepository(WorkflowAprovacao) private wfRepo: Repository<WorkflowAprovacao>,
|
||||||
@InjectRepository(OrdemServico) private osRepo: Repository<OrdemServico>,
|
@InjectRepository(OrdemServico) private osRepo: Repository<OrdemServico>,
|
||||||
@InjectRepository(Alerta) private alertaRepo: Repository<Alerta>,
|
@InjectRepository(Alerta) private alertaRepo: Repository<Alerta>,
|
||||||
|
@InjectRepository(EsgMetrica) private esgMetricaRepo: Repository<EsgMetrica>,
|
||||||
|
@InjectRepository(EsgMeta) private esgMetaRepo: Repository<EsgMeta>,
|
||||||
|
@InjectRepository(Kpi) private kpiRepo: Repository<Kpi>,
|
||||||
|
@InjectRepository(Meta) private metaRepo: Repository<Meta>,
|
||||||
|
@InjectRepository(AlertaConfig) private alertaConfigRepo: Repository<AlertaConfig>,
|
||||||
|
@InjectRepository(Subcategoria) private subcatRepo: Repository<Subcategoria>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async seed() {
|
async seed() {
|
||||||
@@ -82,34 +94,63 @@ export class SeedService {
|
|||||||
|
|
||||||
// Locais
|
// Locais
|
||||||
const locais = [
|
const locais = [
|
||||||
{ id: uuid(), nome: 'Torre Norte - SP', endereco: 'Av. Paulista, 1000, São Paulo - SP', centro_custo_id: ccs[0].id, responsavel_id: users[2].id },
|
{ id: uuid(), nome: 'Torre Norte - SP', endereco: 'Av. Paulista, 1000, São Paulo - SP', centro_custo_id: ccs[0].id, responsavel_id: users[2].id, tipo_operacao_local: 'Administrativo', classificacao_impacto_ambiental: 'Baixo', praticas_sustentaveis: ['Coleta Seletiva', 'Energia Renovável'] },
|
||||||
{ id: uuid(), nome: 'Torre Sul - SP', endereco: 'Av. Paulista, 1002, São Paulo - SP', centro_custo_id: ccs[0].id, responsavel_id: users[2].id },
|
{ id: uuid(), nome: 'Torre Sul - SP', endereco: 'Av. Paulista, 1002, São Paulo - SP', centro_custo_id: ccs[0].id, responsavel_id: users[2].id, tipo_operacao_local: 'Administrativo', classificacao_impacto_ambiental: 'Baixo', praticas_sustentaveis: ['Coleta Seletiva'] },
|
||||||
{ id: uuid(), nome: 'Escritório RJ', endereco: 'Rua do Ouvidor, 50, Rio de Janeiro - RJ', centro_custo_id: ccs[1].id, responsavel_id: users[6].id },
|
{ id: uuid(), nome: 'Escritório RJ', endereco: 'Rua do Ouvidor, 50, Rio de Janeiro - RJ', centro_custo_id: ccs[1].id, responsavel_id: users[6].id, tipo_operacao_local: 'Comercial', classificacao_impacto_ambiental: 'Médio', praticas_sustentaveis: ['Reuso de Água'] },
|
||||||
{ id: uuid(), nome: 'CD Betim', endereco: 'Rod. Fernão Dias, km 492, Betim - MG', centro_custo_id: ccs[2].id, responsavel_id: users[2].id },
|
{ id: uuid(), nome: 'CD Betim', endereco: 'Rod. Fernão Dias, km 492, Betim - MG', centro_custo_id: ccs[2].id, responsavel_id: users[2].id, tipo_operacao_local: 'Logístico', classificacao_impacto_ambiental: 'Alto', praticas_sustentaveis: ['Coleta Seletiva', 'Reuso de Água'] },
|
||||||
{ id: uuid(), nome: 'Fábrica Campinas', endereco: 'Distrito Industrial, Campinas - SP', centro_custo_id: ccs[4].id, responsavel_id: users[6].id },
|
{ id: uuid(), nome: 'Fábrica Campinas', endereco: 'Distrito Industrial, Campinas - SP', centro_custo_id: ccs[4].id, responsavel_id: users[6].id, tipo_operacao_local: 'Industrial', classificacao_impacto_ambiental: 'Alto', praticas_sustentaveis: ['Coleta Seletiva', 'Reuso de Água', 'Energia Renovável'] },
|
||||||
];
|
];
|
||||||
await this.localRepo.save(locais);
|
await this.localRepo.save(locais);
|
||||||
|
|
||||||
// Categorias
|
// Categorias
|
||||||
const cats = [
|
const cats = [
|
||||||
{ id: uuid(), nome: 'Manutenção Predial', criticidade_padrao: 'media', sla_dias: 15 },
|
{ id: uuid(), nome: 'Manutenção Predial', criticidade_padrao: 'media', sla_dias: 15, tipo_manutencao: 'Preventiva', impacto_ambiental_esperado: 'Baixo', potencial_geracao_residuos: 'Baixo' },
|
||||||
{ id: uuid(), nome: 'Climatização e HVAC', criticidade_padrao: 'alta', sla_dias: 7 },
|
{ id: uuid(), nome: 'Climatização e HVAC', criticidade_padrao: 'alta', sla_dias: 7, tipo_manutencao: 'Preventiva', impacto_ambiental_esperado: 'Médio', potencial_geracao_residuos: 'Médio' },
|
||||||
{ id: uuid(), nome: 'Limpeza e Conservação', criticidade_padrao: 'baixa', sla_dias: 30 },
|
{ id: uuid(), nome: 'Limpeza e Conservação', criticidade_padrao: 'baixa', sla_dias: 30, tipo_manutencao: 'Preventiva', impacto_ambiental_esperado: 'Baixo', potencial_geracao_residuos: 'Médio' },
|
||||||
{ id: uuid(), nome: 'Segurança Patrimonial', criticidade_padrao: 'critica', sla_dias: 3 },
|
{ id: uuid(), nome: 'Segurança Patrimonial', criticidade_padrao: 'critica', sla_dias: 3, tipo_manutencao: 'Emergencial', impacto_ambiental_esperado: 'Baixo', potencial_geracao_residuos: 'Baixo' },
|
||||||
{ id: uuid(), nome: 'Elétrica e Iluminação', criticidade_padrao: 'alta', sla_dias: 10 },
|
{ id: uuid(), nome: 'Elétrica e Iluminação', criticidade_padrao: 'alta', sla_dias: 10, tipo_manutencao: 'Corretiva', impacto_ambiental_esperado: 'Alto', potencial_geracao_residuos: 'Alto' },
|
||||||
{ id: uuid(), nome: 'Paisagismo', criticidade_padrao: 'baixa', sla_dias: 45 },
|
{ id: uuid(), nome: 'Paisagismo', criticidade_padrao: 'baixa', sla_dias: 45, tipo_manutencao: 'Preventiva', impacto_ambiental_esperado: 'Baixo', potencial_geracao_residuos: 'Baixo' },
|
||||||
{ id: uuid(), nome: 'Dedetização e Controle de Pragas', criticidade_padrao: 'media', sla_dias: 15 },
|
{ id: uuid(), nome: 'Dedetização e Controle de Pragas', criticidade_padrao: 'media', sla_dias: 15, tipo_manutencao: 'Preventiva', impacto_ambiental_esperado: 'Alto', potencial_geracao_residuos: 'Médio' },
|
||||||
{ id: uuid(), nome: 'Reforma e Adequação', criticidade_padrao: 'media', sla_dias: 60 },
|
{ id: uuid(), nome: 'Reforma e Adequação', criticidade_padrao: 'media', sla_dias: 60, tipo_manutencao: 'Corretiva', impacto_ambiental_esperado: 'Alto', potencial_geracao_residuos: 'Alto' },
|
||||||
];
|
];
|
||||||
await this.catRepo.save(cats);
|
await this.catRepo.save(cats);
|
||||||
|
|
||||||
|
// Subcategorias
|
||||||
|
const subcats = [
|
||||||
|
{ id: uuid(), nome: 'Preventiva', categoria_id: cats[0].id },
|
||||||
|
{ id: uuid(), nome: 'Corretiva', categoria_id: cats[0].id },
|
||||||
|
{ id: uuid(), nome: 'Preditiva', categoria_id: cats[0].id },
|
||||||
|
{ id: uuid(), nome: 'Split', categoria_id: cats[1].id },
|
||||||
|
{ id: uuid(), nome: 'Central', categoria_id: cats[1].id },
|
||||||
|
{ id: uuid(), nome: 'Ventilação', categoria_id: cats[1].id },
|
||||||
|
{ id: uuid(), nome: 'Diária', categoria_id: cats[2].id },
|
||||||
|
{ id: uuid(), nome: 'Pesada', categoria_id: cats[2].id },
|
||||||
|
{ id: uuid(), nome: 'Pós-obra', categoria_id: cats[2].id },
|
||||||
|
{ id: uuid(), nome: 'CFTV', categoria_id: cats[3].id },
|
||||||
|
{ id: uuid(), nome: 'Controle de Acesso', categoria_id: cats[3].id },
|
||||||
|
{ id: uuid(), nome: 'Vigilância', categoria_id: cats[3].id },
|
||||||
|
{ id: uuid(), nome: 'Quadros', categoria_id: cats[4].id },
|
||||||
|
{ id: uuid(), nome: 'Iluminação', categoria_id: cats[4].id },
|
||||||
|
{ id: uuid(), nome: 'Cabeamento', categoria_id: cats[4].id },
|
||||||
|
{ id: uuid(), nome: 'Poda', categoria_id: cats[5].id },
|
||||||
|
{ id: uuid(), nome: 'Jardinagem', categoria_id: cats[5].id },
|
||||||
|
{ id: uuid(), nome: 'Irrigação', categoria_id: cats[5].id },
|
||||||
|
{ id: uuid(), nome: 'Insetos', categoria_id: cats[6].id },
|
||||||
|
{ id: uuid(), nome: 'Roedores', categoria_id: cats[6].id },
|
||||||
|
{ id: uuid(), nome: 'Sanitização', categoria_id: cats[6].id },
|
||||||
|
{ id: uuid(), nome: 'Pintura', categoria_id: cats[7].id },
|
||||||
|
{ id: uuid(), nome: 'Piso', categoria_id: cats[7].id },
|
||||||
|
{ id: uuid(), nome: 'Alvenaria', categoria_id: cats[7].id },
|
||||||
|
];
|
||||||
|
await this.subcatRepo.save(subcats);
|
||||||
|
|
||||||
// Fornecedores
|
// Fornecedores
|
||||||
const forns = [
|
const forns = [
|
||||||
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '12.345.678/0001-90', razao_social: 'TechClima Engenharia Ltda', nome_fantasia: 'TechClima', email: 'contato@techclima.com.br', telefone: '(11) 3456-7890', rating: 4.5, usuario_id: users[7].id, categorias_atendidas: [cats[1].id] },
|
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '12.345.678/0001-90', razao_social: 'TechClima Engenharia Ltda', nome_fantasia: 'TechClima', email: 'contato@techclima.com.br', telefone: '(11) 3456-7890', rating: 4.5, usuario_id: users[7].id, categorias_atendidas: [cats[1].id], possui_politica_ambiental: true, possui_politica_sst: true, declara_uso_epi: true, equipe_treinada: true, classificacao_esg: 'Avançado' },
|
||||||
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '23.456.789/0001-01', razao_social: 'ServiLimp Serviços de Limpeza S/A', nome_fantasia: 'ServiLimp', email: 'comercial@servilimp.com.br', telefone: '(11) 2345-6789', rating: 3.8, usuario_id: users[8].id, categorias_atendidas: [cats[2].id] },
|
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '23.456.789/0001-01', razao_social: 'ServiLimp Serviços de Limpeza S/A', nome_fantasia: 'ServiLimp', email: 'comercial@servilimp.com.br', telefone: '(11) 2345-6789', rating: 3.8, usuario_id: users[8].id, categorias_atendidas: [cats[2].id], possui_politica_ambiental: false, possui_politica_sst: true, declara_uso_epi: true, equipe_treinada: false, classificacao_esg: 'Básico' },
|
||||||
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '34.567.890/0001-12', razao_social: 'Forte Segurança Empresarial Ltda', nome_fantasia: 'Forte Seg', email: 'propostas@forteseg.com.br', telefone: '(21) 3456-7890', rating: 4.2, usuario_id: users[9].id, categorias_atendidas: [cats[3].id] },
|
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '34.567.890/0001-12', razao_social: 'Forte Segurança Empresarial Ltda', nome_fantasia: 'Forte Seg', email: 'propostas@forteseg.com.br', telefone: '(21) 3456-7890', rating: 4.2, usuario_id: users[9].id, categorias_atendidas: [cats[3].id], possui_politica_ambiental: true, possui_politica_sst: true, declara_uso_epi: true, equipe_treinada: true, classificacao_esg: 'Intermediário' },
|
||||||
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '45.678.901/0001-23', razao_social: 'EletroForce Instalações Elétricas', nome_fantasia: 'EletroForce', email: 'orcamento@eletroforce.com.br', telefone: '(11) 4567-8901', rating: 4.0, categorias_atendidas: [cats[4].id] },
|
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '45.678.901/0001-23', razao_social: 'EletroForce Instalações Elétricas', nome_fantasia: 'EletroForce', email: 'orcamento@eletroforce.com.br', telefone: '(11) 4567-8901', rating: 4.0, categorias_atendidas: [cats[4].id], possui_politica_ambiental: true, possui_politica_sst: false, declara_uso_epi: true, equipe_treinada: true, classificacao_esg: 'Intermediário' },
|
||||||
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '56.789.012/0001-34', razao_social: 'Predial Master Engenharia', nome_fantasia: 'Predial Master', email: 'contato@predialmaster.com.br', telefone: '(11) 5678-9012', rating: 4.7, categorias_atendidas: [cats[0].id, cats[7].id] },
|
{ id: uuid(), tipo_pessoa: 'PJ', cpf_cnpj: '56.789.012/0001-34', razao_social: 'Predial Master Engenharia', nome_fantasia: 'Predial Master', email: 'contato@predialmaster.com.br', telefone: '(11) 5678-9012', rating: 4.7, categorias_atendidas: [cats[0].id, cats[7].id], possui_politica_ambiental: true, possui_politica_sst: true, declara_uso_epi: true, equipe_treinada: true, classificacao_esg: 'Avançado' },
|
||||||
];
|
];
|
||||||
await this.fornRepo.save(forns);
|
await this.fornRepo.save(forns);
|
||||||
|
|
||||||
@@ -128,16 +169,16 @@ export class SeedService {
|
|||||||
|
|
||||||
// Demandas
|
// Demandas
|
||||||
const demandas = [
|
const demandas = [
|
||||||
{ id: uuid(), numero: 1, titulo: 'Manutenção preventiva ar-condicionado Torre Norte', descricao: 'Revisão semestral de todos os 48 splits e 6 centrais de ar', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[1].id, criticidade: 'alta', status: 'em_cotacao', solicitante_id: users[1].id, gestor_id: users[2].id, data_desejada: '2026-03-15' },
|
{ id: uuid(), numero: 1, titulo: 'Manutenção preventiva ar-condicionado Torre Norte', descricao: 'Revisão semestral de todos os 48 splits e 6 centrais de ar', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[1].id, criticidade: 'alta', status: 'em_cotacao', solicitante_id: users[1].id, gestor_id: users[2].id, data_desejada: '2026-03-15', impacto_ambiental_demanda: 'Médio' },
|
||||||
{ id: uuid(), numero: 2, titulo: 'Contratação de serviço de limpeza - Escritório RJ', descricao: 'Limpeza diária do escritório RJ - 2 pavimentos, 800m²', local_id: locais[2].id, centro_custo_id: ccs[1].id, categoria_id: cats[2].id, criticidade: 'media', status: 'propostas_recebidas', solicitante_id: users[5].id, gestor_id: users[6].id },
|
{ id: uuid(), numero: 2, titulo: 'Contratação de serviço de limpeza - Escritório RJ', descricao: 'Limpeza diária do escritório RJ - 2 pavimentos, 800m²', local_id: locais[2].id, centro_custo_id: ccs[1].id, categoria_id: cats[2].id, criticidade: 'media', status: 'propostas_recebidas', solicitante_id: users[5].id, gestor_id: users[6].id, impacto_ambiental_demanda: 'Baixo' },
|
||||||
{ id: uuid(), numero: 3, titulo: 'Instalação de câmeras de segurança - CD Betim', descricao: 'Instalação de 32 câmeras IP + NVR + cabeamento', local_id: locais[3].id, centro_custo_id: ccs[2].id, categoria_id: cats[3].id, criticidade: 'critica', status: 'em_aprovacao', solicitante_id: users[1].id, gestor_id: users[2].id },
|
{ id: uuid(), numero: 3, titulo: 'Instalação de câmeras de segurança - CD Betim', descricao: 'Instalação de 32 câmeras IP + NVR + cabeamento', local_id: locais[3].id, centro_custo_id: ccs[2].id, categoria_id: cats[3].id, criticidade: 'critica', status: 'em_aprovacao', solicitante_id: users[1].id, gestor_id: users[2].id, impacto_ambiental_demanda: 'Baixo', justificativa_manutencao_emergencial: 'Falha no sistema de segurança existente, risco patrimonial' },
|
||||||
{ id: uuid(), numero: 4, titulo: 'Troca de iluminação para LED - Torre Sul', descricao: 'Substituição de 500 lâmpadas fluorescentes por LED', local_id: locais[1].id, centro_custo_id: ccs[0].id, categoria_id: cats[4].id, criticidade: 'media', status: 'aprovada', solicitante_id: users[5].id, gestor_id: users[2].id },
|
{ id: uuid(), numero: 4, titulo: 'Troca de iluminação para LED - Torre Sul', descricao: 'Substituição de 500 lâmpadas fluorescentes por LED', local_id: locais[1].id, centro_custo_id: ccs[0].id, categoria_id: cats[4].id, criticidade: 'media', status: 'aprovada', solicitante_id: users[5].id, gestor_id: users[2].id, impacto_ambiental_demanda: 'Alto' },
|
||||||
{ id: uuid(), numero: 5, titulo: 'Reforma do refeitório - Fábrica Campinas', descricao: 'Reforma completa: piso, pintura, bancadas, instalações', local_id: locais[4].id, centro_custo_id: ccs[4].id, categoria_id: cats[7].id, criticidade: 'media', status: 'em_execucao', solicitante_id: users[1].id, gestor_id: users[6].id },
|
{ id: uuid(), numero: 5, titulo: 'Reforma do refeitório - Fábrica Campinas', descricao: 'Reforma completa: piso, pintura, bancadas, instalações', local_id: locais[4].id, centro_custo_id: ccs[4].id, categoria_id: cats[7].id, criticidade: 'media', status: 'em_execucao', solicitante_id: users[1].id, gestor_id: users[6].id, impacto_ambiental_demanda: 'Alto' },
|
||||||
{ id: uuid(), numero: 6, titulo: 'Dedetização trimestral - Todas as unidades', descricao: 'Controle de pragas urbanas em todas as 5 unidades', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[6].id, criticidade: 'baixa', status: 'aberta', solicitante_id: users[5].id },
|
{ id: uuid(), numero: 6, titulo: 'Dedetização trimestral - Todas as unidades', descricao: 'Controle de pragas urbanas em todas as 5 unidades', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[6].id, criticidade: 'baixa', status: 'aberta', solicitante_id: users[5].id, impacto_ambiental_demanda: 'Alto' },
|
||||||
{ id: uuid(), numero: 7, titulo: 'Reparo no telhado - CD Betim', descricao: 'Vazamento em 3 pontos do galpão principal', local_id: locais[3].id, centro_custo_id: ccs[2].id, categoria_id: cats[0].id, criticidade: 'alta', status: 'em_escopo', solicitante_id: users[1].id, gestor_id: users[2].id },
|
{ id: uuid(), numero: 7, titulo: 'Reparo no telhado - CD Betim', descricao: 'Vazamento em 3 pontos do galpão principal', local_id: locais[3].id, centro_custo_id: ccs[2].id, categoria_id: cats[0].id, criticidade: 'alta', status: 'em_escopo', solicitante_id: users[1].id, gestor_id: users[2].id, impacto_ambiental_demanda: 'Baixo' },
|
||||||
{ id: uuid(), numero: 8, titulo: 'Manutenção do paisagismo - Sede SP', descricao: 'Poda, jardinagem e manutenção das áreas verdes', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[5].id, criticidade: 'baixa', status: 'rascunho', solicitante_id: users[5].id },
|
{ id: uuid(), numero: 8, titulo: 'Manutenção do paisagismo - Sede SP', descricao: 'Poda, jardinagem e manutenção das áreas verdes', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[5].id, criticidade: 'baixa', status: 'rascunho', solicitante_id: users[5].id, impacto_ambiental_demanda: 'Baixo' },
|
||||||
{ id: uuid(), numero: 9, titulo: 'Instalação de gerador de emergência - Torre Norte', descricao: 'Gerador diesel 500kVA com QTA', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[4].id, criticidade: 'critica', status: 'concluida', solicitante_id: users[1].id, gestor_id: users[2].id },
|
{ id: uuid(), numero: 9, titulo: 'Instalação de gerador de emergência - Torre Norte', descricao: 'Gerador diesel 500kVA com QTA', local_id: locais[0].id, centro_custo_id: ccs[0].id, categoria_id: cats[4].id, criticidade: 'critica', status: 'concluida', solicitante_id: users[1].id, gestor_id: users[2].id, impacto_ambiental_demanda: 'Alto' },
|
||||||
{ id: uuid(), numero: 10, titulo: 'Adequação NR-10 - Fábrica Campinas', descricao: 'Adequação de quadros e instalações elétricas à NR-10', local_id: locais[4].id, centro_custo_id: ccs[4].id, categoria_id: cats[4].id, criticidade: 'alta', status: 'cancelada', solicitante_id: users[5].id, gestor_id: users[6].id },
|
{ id: uuid(), numero: 10, titulo: 'Adequação NR-10 - Fábrica Campinas', descricao: 'Adequação de quadros e instalações elétricas à NR-10', local_id: locais[4].id, centro_custo_id: ccs[4].id, categoria_id: cats[4].id, criticidade: 'alta', status: 'cancelada', solicitante_id: users[5].id, gestor_id: users[6].id, impacto_ambiental_demanda: 'Médio' },
|
||||||
];
|
];
|
||||||
await this.demandaRepo.save(demandas);
|
await this.demandaRepo.save(demandas);
|
||||||
|
|
||||||
@@ -244,6 +285,71 @@ export class SeedService {
|
|||||||
];
|
];
|
||||||
await this.alertaRepo.save(alertas);
|
await this.alertaRepo.save(alertas);
|
||||||
|
|
||||||
|
// ESG Métricas
|
||||||
|
const esgMetricas = [];
|
||||||
|
const tiposEsg = ['energia', 'agua', 'residuos', 'emissoes'];
|
||||||
|
const unidades = { energia: 'kWh', agua: 'm³', residuos: 'kg', emissoes: 'tCO2e' };
|
||||||
|
for (let mes = 1; mes <= 12; mes++) {
|
||||||
|
for (const tipo of tiposEsg) {
|
||||||
|
esgMetricas.push({
|
||||||
|
id: uuid(), tipo, unidade_medida: unidades[tipo],
|
||||||
|
valor: Math.round((tipo === 'energia' ? 15000 + Math.random() * 10000 : tipo === 'agua' ? 500 + Math.random() * 300 : tipo === 'residuos' ? 2000 + Math.random() * 1500 : 50 + Math.random() * 30) * 100) / 100,
|
||||||
|
periodo_mes: mes, periodo_ano: 2026, local_id: locais[0].id, centro_custo_id: ccs[0].id,
|
||||||
|
fonte_dados: 'Medidor automático', observacoes: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.esgMetricaRepo.save(esgMetricas);
|
||||||
|
|
||||||
|
// ESG Metas
|
||||||
|
const esgMetas = [
|
||||||
|
{ id: uuid(), tipo: 'energia', meta_valor: 200000, periodo_ano: 2026, local_id: locais[0].id },
|
||||||
|
{ id: uuid(), tipo: 'agua', meta_valor: 8000, periodo_ano: 2026, local_id: locais[0].id },
|
||||||
|
{ id: uuid(), tipo: 'residuos', meta_valor: 30000, periodo_ano: 2026, local_id: locais[0].id },
|
||||||
|
{ id: uuid(), tipo: 'emissoes', meta_valor: 600, periodo_ano: 2026, local_id: locais[0].id },
|
||||||
|
{ id: uuid(), tipo: 'energia', meta_valor: 100000, periodo_ano: 2026, local_id: locais[2].id },
|
||||||
|
];
|
||||||
|
await this.esgMetaRepo.save(esgMetas);
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
const kpis = [
|
||||||
|
{ id: uuid(), nome: 'Custo por m²', descricao: 'Custo de facilities por metro quadrado', formula: 'custo_total / area_total', categoria: 'financeiro', unidade: 'R$/m²', meta_valor: 45, valor_atual: 38.5, periodo_mes: 1, periodo_ano: 2026, status: 'verde' },
|
||||||
|
{ id: uuid(), nome: 'SLA de Atendimento', descricao: '% de demandas atendidas dentro do SLA', formula: 'demandas_no_prazo / total_demandas * 100', categoria: 'prazo', unidade: '%', meta_valor: 90, valor_atual: 82, periodo_mes: 1, periodo_ano: 2026, status: 'amarelo' },
|
||||||
|
{ id: uuid(), nome: 'Índice de Retrabalho', descricao: '% de OS com retrabalho', formula: 'os_retrabalho / total_os * 100', categoria: 'qualidade', unidade: '%', meta_valor: 5, valor_atual: 3.2, periodo_mes: 1, periodo_ano: 2026, status: 'verde' },
|
||||||
|
{ id: uuid(), nome: 'Ocupação de Espaços', descricao: '% de ocupação dos espaços', formula: 'area_ocupada / area_total * 100', categoria: 'operacional', unidade: '%', meta_valor: 85, valor_atual: 78, periodo_mes: 1, periodo_ano: 2026, status: 'amarelo' },
|
||||||
|
{ id: uuid(), nome: 'Economia Energética', descricao: '% de redução no consumo de energia', formula: '(consumo_anterior - consumo_atual) / consumo_anterior * 100', categoria: 'financeiro', unidade: '%', meta_valor: 10, valor_atual: 7.5, periodo_mes: 1, periodo_ano: 2026, status: 'amarelo' },
|
||||||
|
{ id: uuid(), nome: 'Satisfação do Cliente Interno', descricao: 'Nota média das avaliações', formula: 'soma_notas / total_avaliacoes', categoria: 'qualidade', unidade: 'nota', meta_valor: 4.5, valor_atual: 4.2, periodo_mes: 1, periodo_ano: 2026, status: 'verde' },
|
||||||
|
{ id: uuid(), nome: 'Taxa de Manutenção Preventiva', descricao: '% preventiva vs corretiva', formula: 'manutencao_preventiva / total_manutencao * 100', categoria: 'operacional', unidade: '%', meta_valor: 70, valor_atual: 55, periodo_mes: 1, periodo_ano: 2026, status: 'vermelho' },
|
||||||
|
{ id: uuid(), nome: 'Tempo Médio de Resposta', descricao: 'Tempo médio para primeira resposta a demanda', formula: 'soma_tempos_resposta / total_demandas', categoria: 'prazo', unidade: 'horas', meta_valor: 4, valor_atual: 3.1, periodo_mes: 1, periodo_ano: 2026, status: 'verde' },
|
||||||
|
];
|
||||||
|
await this.kpiRepo.save(kpis);
|
||||||
|
|
||||||
|
// Metas
|
||||||
|
const metas = [
|
||||||
|
{ id: uuid(), titulo: 'Reduzir custo operacional em 15%', descricao: 'Redução geral de custos operacionais de facilities', tipo: 'orcamento', centro_custo_id: ccs[0].id, valor_meta: 15, valor_atual: 8.3, unidade: '%', prazo: '2026-12-31', status: 'em_andamento' },
|
||||||
|
{ id: uuid(), titulo: 'Implementar programa de reciclagem', descricao: 'Implantar coleta seletiva em todas as unidades', tipo: 'esg', valor_meta: 5, valor_atual: 3, unidade: 'unidades', prazo: '2026-06-30', status: 'em_andamento' },
|
||||||
|
{ id: uuid(), titulo: 'SLA 95% até junho', descricao: 'Atingir 95% de atendimento dentro do SLA', tipo: 'operacional', valor_meta: 95, valor_atual: 82, unidade: '%', prazo: '2026-06-30', status: 'em_andamento' },
|
||||||
|
{ id: uuid(), titulo: 'Reduzir consumo de energia 10%', descricao: 'Meta anual de redução energética', tipo: 'esg', centro_custo_id: ccs[0].id, valor_meta: 10, valor_atual: 7.5, unidade: '%', prazo: '2026-12-31', status: 'em_andamento' },
|
||||||
|
{ id: uuid(), titulo: 'Digitalizar 100% dos contratos', descricao: 'Todos os contratos de fornecedores digitalizados', tipo: 'operacional', valor_meta: 100, valor_atual: 100, unidade: '%', prazo: '2026-03-31', status: 'atingida' },
|
||||||
|
{ id: uuid(), titulo: 'Certificação ISO 14001', descricao: 'Obter certificação ambiental ISO 14001', tipo: 'esg', valor_meta: 1, valor_atual: 0, unidade: 'certificação', prazo: '2026-09-30', status: 'em_andamento' },
|
||||||
|
{ id: uuid(), titulo: 'Orçamento anual: desvio < 5%', descricao: 'Manter desvio orçamentário abaixo de 5%', tipo: 'orcamento', valor_meta: 5, valor_atual: 3.2, unidade: '%', prazo: '2026-12-31', status: 'em_andamento' },
|
||||||
|
{ id: uuid(), titulo: 'Rating fornecedores > 4.0', descricao: 'Manter rating médio de fornecedores acima de 4.0', tipo: 'operacional', valor_meta: 4.0, valor_atual: 4.2, unidade: 'nota', prazo: '2026-12-31', status: 'atingida' },
|
||||||
|
{ id: uuid(), titulo: 'Reduzir consumo de água 8%', descricao: 'Meta anual de economia hídrica', tipo: 'esg', centro_custo_id: ccs[0].id, valor_meta: 8, valor_atual: 2.1, unidade: '%', prazo: '2026-12-31', status: 'atrasada' },
|
||||||
|
{ id: uuid(), titulo: 'Zerar OS atrasadas', descricao: 'Nenhuma OS com atraso superior a 7 dias', tipo: 'operacional', valor_meta: 0, valor_atual: 2, unidade: 'OS', prazo: '2026-06-30', status: 'atrasada' },
|
||||||
|
];
|
||||||
|
await this.metaRepo.save(metas);
|
||||||
|
|
||||||
|
// Alerta Configs
|
||||||
|
const alertaConfigs = [
|
||||||
|
{ id: uuid(), tipo: 'orcamento_excedido', centro_custo_id: ccs[0].id, limite_percentual: 80, ativo: true },
|
||||||
|
{ id: uuid(), tipo: 'orcamento_excedido', centro_custo_id: ccs[1].id, limite_percentual: 85, ativo: true },
|
||||||
|
{ id: uuid(), tipo: 'certidao_vencendo', limite_percentual: 30, ativo: true },
|
||||||
|
{ id: uuid(), tipo: 'os_atrasada', limite_percentual: 7, ativo: true },
|
||||||
|
{ id: uuid(), tipo: 'meta_risco', limite_percentual: 50, ativo: true },
|
||||||
|
{ id: uuid(), tipo: 'orcamento_excedido', centro_custo_id: ccs[4].id, limite_percentual: 90, ativo: false },
|
||||||
|
];
|
||||||
|
await this.alertaConfigRepo.save(alertaConfigs);
|
||||||
|
|
||||||
console.log('✅ Seed completed!');
|
console.log('✅ Seed completed!');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ async function bootstrap() {
|
|||||||
await seedService.seed();
|
await seedService.seed();
|
||||||
|
|
||||||
await app.listen(8080);
|
await app.listen(8080);
|
||||||
console.log('HEFESTO API running on http://localhost:8080');
|
console.log('Nexus Facilities API running on http://localhost:8080');
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
16
backend/src/modules/alertas/alertas.controller.ts
Normal file
16
backend/src/modules/alertas/alertas.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Get, Post, Body } from '@nestjs/common';
|
||||||
|
import { AlertasService } from './alertas.service';
|
||||||
|
|
||||||
|
@Controller('alertas')
|
||||||
|
export class AlertasController {
|
||||||
|
constructor(private readonly svc: AlertasService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
getConfigs() { return this.svc.getConfigs(); }
|
||||||
|
|
||||||
|
@Post('configurar')
|
||||||
|
configurar(@Body() dto: any) { return this.svc.configurar(dto); }
|
||||||
|
|
||||||
|
@Post('verificar')
|
||||||
|
verificar() { return this.svc.verificarAlertas(); }
|
||||||
|
}
|
||||||
14
backend/src/modules/alertas/alertas.module.ts
Normal file
14
backend/src/modules/alertas/alertas.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AlertaConfig } from './entities/alerta-config.entity';
|
||||||
|
import { Alerta } from '../dashboard/entities/alerta.entity';
|
||||||
|
import { AlertasController } from './alertas.controller';
|
||||||
|
import { AlertasService } from './alertas.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AlertaConfig, Alerta])],
|
||||||
|
controllers: [AlertasController],
|
||||||
|
providers: [AlertasService],
|
||||||
|
exports: [AlertasService],
|
||||||
|
})
|
||||||
|
export class AlertasModule {}
|
||||||
67
backend/src/modules/alertas/alertas.service.ts
Normal file
67
backend/src/modules/alertas/alertas.service.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { AlertaConfig } from './entities/alerta-config.entity';
|
||||||
|
import { Alerta } from '../dashboard/entities/alerta.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AlertasService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AlertaConfig) private configRepo: Repository<AlertaConfig>,
|
||||||
|
@InjectRepository(Alerta) private alertaRepo: Repository<Alerta>,
|
||||||
|
private ds: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getConfigs() { return this.configRepo.find(); }
|
||||||
|
|
||||||
|
async configurar(dto: any) {
|
||||||
|
return this.configRepo.save(this.configRepo.create(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
async verificarAlertas() {
|
||||||
|
const configs = await this.configRepo.find({ where: { ativo: true } });
|
||||||
|
const gerados: any[] = [];
|
||||||
|
|
||||||
|
for (const cfg of configs) {
|
||||||
|
if (cfg.tipo === 'orcamento_excedido') {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT centro_custo_id, SUM(valor_realizado) as realizado, SUM(valor_planejado) as planejado
|
||||||
|
FROM orcamento_planejado
|
||||||
|
${cfg.centro_custo_id ? 'WHERE centro_custo_id = ?' : ''}
|
||||||
|
GROUP BY centro_custo_id
|
||||||
|
HAVING (SUM(valor_realizado) / SUM(valor_planejado) * 100) > ?`,
|
||||||
|
cfg.centro_custo_id ? [cfg.centro_custo_id, cfg.limite_percentual] : [cfg.limite_percentual]);
|
||||||
|
for (const r of rows) {
|
||||||
|
const pct = Math.round((r.realizado / r.planejado) * 100);
|
||||||
|
gerados.push({ tipo: 'orcamento_excedido', titulo: `Orçamento excedeu ${pct}%`, mensagem: `Centro de custo ${r.centro_custo_id} atingiu ${pct}% do orçamento`, entidade: 'orcamento', usuario_id: 'system' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.tipo === 'certidao_vencendo') {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT c.*, f.razao_social FROM certidoes c
|
||||||
|
LEFT JOIN fornecedores f ON f.id = c.fornecedor_id
|
||||||
|
WHERE c.data_validade <= date('now', '+30 days') AND c.data_validade >= date('now')`);
|
||||||
|
for (const r of rows) {
|
||||||
|
gerados.push({ tipo: 'certidao_vencendo', titulo: `Certidão vencendo: ${r.tipo}`, mensagem: `Fornecedor ${r.razao_social} - certidão ${r.tipo} vence em ${r.data_validade}`, entidade: 'certidao', entidade_id: r.id, usuario_id: 'system' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.tipo === 'os_atrasada') {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT * FROM ordens_servico
|
||||||
|
WHERE status NOT IN ('concluida','cancelada') AND data_conclusao IS NOT NULL AND data_conclusao < date('now')`);
|
||||||
|
for (const r of rows) {
|
||||||
|
gerados.push({ tipo: 'os_atrasada', titulo: `OS #${r.numero} atrasada`, mensagem: `Ordem de serviço #${r.numero} passou da data prevista`, entidade: 'ordem_servico', entidade_id: r.id, usuario_id: 'system' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save generated alerts
|
||||||
|
for (const a of gerados) {
|
||||||
|
await this.alertaRepo.save(this.alertaRepo.create(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { alertas_gerados: gerados.length, detalhes: gerados };
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/src/modules/alertas/entities/alerta-config.entity.ts
Normal file
22
backend/src/modules/alertas/entities/alerta-config.entity.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('alertas_config')
|
||||||
|
export class AlertaConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 50 })
|
||||||
|
tipo: string; // orcamento_excedido | certidao_vencendo | os_atrasada | meta_risco
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
centro_custo_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'float', default: 80 })
|
||||||
|
limite_percentual: number;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
ativo: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
28
backend/src/modules/audit/audit.controller.ts
Normal file
28
backend/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Query, Res } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Controller('audit')
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private readonly svc: AuditService) {}
|
||||||
|
|
||||||
|
@Get('logs')
|
||||||
|
logs(@Query() q: any) { return this.svc.logs(q); }
|
||||||
|
|
||||||
|
@Get('compliance-report')
|
||||||
|
complianceReport(@Query() q: any) { return this.svc.complianceReport(q); }
|
||||||
|
|
||||||
|
@Get('export')
|
||||||
|
async export(@Query() q: any, @Res() res: Response) {
|
||||||
|
const format = q.format || 'json';
|
||||||
|
const data = await this.svc.logs(q);
|
||||||
|
if (format === 'csv') {
|
||||||
|
const header = 'id,usuario_id,acao,entidade,entidade_id,created_at\n';
|
||||||
|
const rows = data.map((r: any) => `${r.id},${r.usuario_id},${r.acao},${r.entidade},${r.entidade_id},${r.created_at}`).join('\n');
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename=audit_export.csv');
|
||||||
|
return res.send(header + rows);
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/modules/audit/audit.module.ts
Normal file
13
backend/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditLog } from '../dashboard/entities/audit-log.entity';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||||
|
controllers: [AuditController],
|
||||||
|
providers: [AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
34
backend/src/modules/audit/audit.service.ts
Normal file
34
backend/src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLog } from '../dashboard/entities/audit-log.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AuditLog) private repo: Repository<AuditLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async logs(q: any) {
|
||||||
|
const qb = this.repo.createQueryBuilder('a');
|
||||||
|
if (q.entidade) qb.andWhere('a.entidade = :e', { e: q.entidade });
|
||||||
|
if (q.acao) qb.andWhere('a.acao = :a', { a: q.acao });
|
||||||
|
if (q.usuario_id) qb.andWhere('a.usuario_id = :u', { u: q.usuario_id });
|
||||||
|
if (q.de) qb.andWhere('a.created_at >= :de', { de: q.de });
|
||||||
|
if (q.ate) qb.andWhere('a.created_at <= :ate', { ate: q.ate });
|
||||||
|
return qb.orderBy('a.created_at', 'DESC').limit(q.limit || 500).getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async complianceReport(q: any) {
|
||||||
|
const qb = this.repo.createQueryBuilder('a')
|
||||||
|
.select('a.entidade', 'entidade')
|
||||||
|
.addSelect('a.acao', 'acao')
|
||||||
|
.addSelect('COUNT(*)', 'total')
|
||||||
|
.groupBy('a.entidade')
|
||||||
|
.addGroupBy('a.acao')
|
||||||
|
.orderBy('total', 'DESC');
|
||||||
|
if (q.de) qb.andWhere('a.created_at >= :de', { de: q.de });
|
||||||
|
if (q.ate) qb.andWhere('a.created_at <= :ate', { ate: q.ate });
|
||||||
|
return qb.getRawMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,18 @@ export class Categoria {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
categoria_pai_id: string;
|
categoria_pai_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 10, nullable: true })
|
||||||
|
tipo_investimento: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
tipo_manutencao: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
impacto_ambiental_esperado: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
potencial_geracao_residuos: string;
|
||||||
|
|
||||||
@Column({ default: true })
|
@Column({ default: true })
|
||||||
ativo: boolean;
|
ativo: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ export class DashboardController {
|
|||||||
|
|
||||||
@Get('indicadores') indicadores() { return this.svc.indicadores(); }
|
@Get('indicadores') indicadores() { return this.svc.indicadores(); }
|
||||||
@Get('demandas-por-status') demandasPorStatus() { return this.svc.demandasPorStatus(); }
|
@Get('demandas-por-status') demandasPorStatus() { return this.svc.demandasPorStatus(); }
|
||||||
@Get('consumo-orcamento') consumoOrcamento(@Query('ano') ano: string) { return this.svc.consumoOrcamento(parseInt(ano) || 2026); }
|
@Get('demandas-detalhe') demandasDetalhe(@Query('status') status: string) { return this.svc.demandasDetalhe(status); }
|
||||||
|
@Get('consumo-orcamento') consumoOrcamento(
|
||||||
|
@Query('ano') ano: string,
|
||||||
|
@Query('centro_custo_id') centroCustoId?: string,
|
||||||
|
@Query('categoria_id') categoriaId?: string,
|
||||||
|
) { return this.svc.consumoOrcamento(parseInt(ano) || 2026, centroCustoId, categoriaId); }
|
||||||
|
@Get('categorias-quantidade') categoriasQuantidade() { return this.svc.categoriasQuantidade(); }
|
||||||
|
@Get('busca') busca(@Query('q') q: string) { return this.svc.busca(q); }
|
||||||
@Get('alertas') alertas(@Query('usuario_id') uid: string) { return this.svc.alertas(uid); }
|
@Get('alertas') alertas(@Query('usuario_id') uid: string) { return this.svc.alertas(uid); }
|
||||||
@Patch('alertas/:id/ler') marcarLido(@Param('id') id: string) { return this.svc.marcarAlertaLido(id); }
|
@Patch('alertas/:id/ler') marcarLido(@Param('id') id: string) { return this.svc.marcarAlertaLido(id); }
|
||||||
|
@Get('esg') esgIndicadores() { return this.svc.esgIndicadores(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { OrcamentoPlanejado } from '../orcamento/entities/orcamento-planejado.en
|
|||||||
import { WorkflowAprovacao } from '../workflow/entities/workflow-aprovacao.entity';
|
import { WorkflowAprovacao } from '../workflow/entities/workflow-aprovacao.entity';
|
||||||
import { Alerta } from './entities/alerta.entity';
|
import { Alerta } from './entities/alerta.entity';
|
||||||
import { AuditLog } from './entities/audit-log.entity';
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
|
import { Fornecedor } from '../fornecedores/entities/fornecedor.entity';
|
||||||
|
import { Categoria } from '../categorias/entities/categoria.entity';
|
||||||
import { DashboardController } from './dashboard.controller';
|
import { DashboardController } from './dashboard.controller';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Demanda, Proposta, OrdemServico, OrcamentoPlanejado, WorkflowAprovacao, Alerta, AuditLog])],
|
imports: [TypeOrmModule.forFeature([Demanda, Proposta, OrdemServico, OrcamentoPlanejado, WorkflowAprovacao, Alerta, AuditLog, Fornecedor, Categoria])],
|
||||||
controllers: [DashboardController],
|
controllers: [DashboardController],
|
||||||
providers: [DashboardService],
|
providers: [DashboardService],
|
||||||
exports: [DashboardService],
|
exports: [DashboardService],
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import { Demanda } from '../demandas/entities/demanda.entity';
|
import { Demanda } from '../demandas/entities/demanda.entity';
|
||||||
import { Proposta } from '../propostas/entities/proposta.entity';
|
import { Proposta } from '../propostas/entities/proposta.entity';
|
||||||
import { OrdemServico } from '../ordens-servico/entities/ordem-servico.entity';
|
import { OrdemServico } from '../ordens-servico/entities/ordem-servico.entity';
|
||||||
import { OrcamentoPlanejado } from '../orcamento/entities/orcamento-planejado.entity';
|
import { OrcamentoPlanejado } from '../orcamento/entities/orcamento-planejado.entity';
|
||||||
import { WorkflowAprovacao } from '../workflow/entities/workflow-aprovacao.entity';
|
import { WorkflowAprovacao } from '../workflow/entities/workflow-aprovacao.entity';
|
||||||
import { Alerta } from './entities/alerta.entity';
|
import { Alerta } from './entities/alerta.entity';
|
||||||
|
import { Fornecedor } from '../fornecedores/entities/fornecedor.entity';
|
||||||
|
import { Categoria } from '../categorias/entities/categoria.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
@@ -17,6 +19,9 @@ export class DashboardService {
|
|||||||
@InjectRepository(OrcamentoPlanejado) private orcRepo: Repository<OrcamentoPlanejado>,
|
@InjectRepository(OrcamentoPlanejado) private orcRepo: Repository<OrcamentoPlanejado>,
|
||||||
@InjectRepository(WorkflowAprovacao) private wfRepo: Repository<WorkflowAprovacao>,
|
@InjectRepository(WorkflowAprovacao) private wfRepo: Repository<WorkflowAprovacao>,
|
||||||
@InjectRepository(Alerta) private alertaRepo: Repository<Alerta>,
|
@InjectRepository(Alerta) private alertaRepo: Repository<Alerta>,
|
||||||
|
@InjectRepository(Fornecedor) private fornecedorRepo: Repository<Fornecedor>,
|
||||||
|
@InjectRepository(Categoria) private categoriaRepo: Repository<Categoria>,
|
||||||
|
private ds: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async indicadores() {
|
async indicadores() {
|
||||||
@@ -44,8 +49,11 @@ export class DashboardService {
|
|||||||
return Object.entries(statusCount).map(([name, value]) => ({ name, value }));
|
return Object.entries(statusCount).map(([name, value]) => ({ name, value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async consumoOrcamento(ano: number) {
|
async consumoOrcamento(ano: number, centroCustoId?: string, categoriaId?: string) {
|
||||||
const items = await this.orcRepo.find({ where: { ano } });
|
const where: any = { ano };
|
||||||
|
if (centroCustoId) where.centro_custo_id = centroCustoId;
|
||||||
|
if (categoriaId) where.categoria_id = categoriaId;
|
||||||
|
const items = await this.orcRepo.find({ where });
|
||||||
const meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
|
const meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
|
||||||
const byMonth: Record<number, any> = {};
|
const byMonth: Record<number, any> = {};
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -61,6 +69,82 @@ export class DashboardService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async demandasDetalhe(status: string) {
|
||||||
|
const demandas = await this.demandaRepo.find();
|
||||||
|
const categorias = await this.categoriaRepo.find();
|
||||||
|
const catMap: Record<string, string> = {};
|
||||||
|
categorias.forEach(c => { catMap[c.id] = c.nome; });
|
||||||
|
|
||||||
|
let filtered: Demanda[];
|
||||||
|
switch (status) {
|
||||||
|
case 'abertas':
|
||||||
|
filtered = demandas.filter(d => ['aberta', 'em_escopo'].includes(d.status));
|
||||||
|
break;
|
||||||
|
case 'em_cotacao':
|
||||||
|
filtered = demandas.filter(d => d.status === 'em_cotacao');
|
||||||
|
break;
|
||||||
|
case 'em_aprovacao':
|
||||||
|
filtered = demandas.filter(d => ['propostas_recebidas', 'em_comparacao'].includes(d.status));
|
||||||
|
break;
|
||||||
|
case 'concluidas':
|
||||||
|
filtered = demandas.filter(d => d.status === 'concluida');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
filtered = demandas.filter(d => d.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
numero: d.numero,
|
||||||
|
titulo: d.titulo,
|
||||||
|
categoria: catMap[d.categoria_id] || '-',
|
||||||
|
status: d.status,
|
||||||
|
data: d.created_at,
|
||||||
|
valor_estimado: d.valor_estimado,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async categoriasQuantidade() {
|
||||||
|
const demandas = await this.demandaRepo.find();
|
||||||
|
const categorias = await this.categoriaRepo.find();
|
||||||
|
const catMap: Record<string, string> = {};
|
||||||
|
categorias.forEach(c => { catMap[c.id] = c.nome; });
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const d of demandas) {
|
||||||
|
const catName = catMap[d.categoria_id] || 'Outros';
|
||||||
|
counts[catName] = (counts[catName] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = ['#E65100', '#1A237E', '#FF8F00', '#757575', '#4CAF50', '#9C27B0', '#00BCD4', '#F44336'];
|
||||||
|
return Object.entries(counts).map(([name, value], i) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
color: colors[i % colors.length],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async busca(q: string) {
|
||||||
|
if (!q || q.trim().length < 2) return { demandas: [], orcamentos: [] };
|
||||||
|
const term = q.toLowerCase();
|
||||||
|
|
||||||
|
const demandas = await this.demandaRepo.find();
|
||||||
|
const categorias = await this.categoriaRepo.find();
|
||||||
|
const catMap: Record<string, string> = {};
|
||||||
|
categorias.forEach(c => { catMap[c.id] = c.nome; });
|
||||||
|
|
||||||
|
const matchedDemandas = demandas.filter(d =>
|
||||||
|
d.titulo?.toLowerCase().includes(term) ||
|
||||||
|
(catMap[d.categoria_id] || '').toLowerCase().includes(term) ||
|
||||||
|
String(d.numero).includes(term)
|
||||||
|
).slice(0, 10).map(d => ({
|
||||||
|
id: d.id, numero: d.numero, titulo: d.titulo,
|
||||||
|
categoria: catMap[d.categoria_id] || '-', status: d.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { demandas: matchedDemandas };
|
||||||
|
}
|
||||||
|
|
||||||
async alertas(usuarioId?: string) {
|
async alertas(usuarioId?: string) {
|
||||||
const where: any = { lido: false };
|
const where: any = { lido: false };
|
||||||
if (usuarioId) where.usuario_id = usuarioId;
|
if (usuarioId) where.usuario_id = usuarioId;
|
||||||
@@ -70,4 +154,63 @@ export class DashboardService {
|
|||||||
async marcarAlertaLido(id: string) {
|
async marcarAlertaLido(id: string) {
|
||||||
await this.alertaRepo.update(id, { lido: true });
|
await this.alertaRepo.update(id, { lido: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async esgIndicadores() {
|
||||||
|
// Categorias with tipo_manutencao
|
||||||
|
const categorias = await this.categoriaRepo.find();
|
||||||
|
const catMap: Record<string, any> = {};
|
||||||
|
categorias.forEach(c => { catMap[c.id] = c; });
|
||||||
|
|
||||||
|
const demandas = await this.demandaRepo.find();
|
||||||
|
const fornecedores = await this.fornecedorRepo.find({ where: { ativo: true } });
|
||||||
|
|
||||||
|
// Classify demandas by tipo_manutencao from their category
|
||||||
|
let preventivas = 0, corretivas = 0, emergenciais = 0, totalClassificada = 0;
|
||||||
|
const demandasAltoImpacto: any[] = [];
|
||||||
|
|
||||||
|
for (const d of demandas) {
|
||||||
|
const cat = catMap[d.categoria_id];
|
||||||
|
if (cat?.tipo_manutencao) {
|
||||||
|
totalClassificada++;
|
||||||
|
if (cat.tipo_manutencao === 'Preventiva') preventivas++;
|
||||||
|
else if (cat.tipo_manutencao === 'Corretiva') corretivas++;
|
||||||
|
else if (cat.tipo_manutencao === 'Emergencial') emergenciais++;
|
||||||
|
}
|
||||||
|
if (d.impacto_ambiental_demanda === 'Alto' || (cat?.impacto_ambiental_esperado === 'Alto' && !d.impacto_ambiental_demanda)) {
|
||||||
|
demandasAltoImpacto.push({ id: d.id, numero: d.numero, titulo: d.titulo, status: d.status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fornecedores ESG
|
||||||
|
const esgInterm = fornecedores.filter(f => f.classificacao_esg === 'Intermediário' || f.classificacao_esg === 'Avançado').length;
|
||||||
|
const esgBasico = fornecedores.filter(f => f.classificacao_esg === 'Básico').length;
|
||||||
|
|
||||||
|
// Evolução mensal preventiva (by created_at month)
|
||||||
|
const evolucaoPreventiva: Record<number, number> = {};
|
||||||
|
for (const d of demandas) {
|
||||||
|
const cat = catMap[d.categoria_id];
|
||||||
|
if (cat?.tipo_manutencao === 'Preventiva' && d.created_at) {
|
||||||
|
const mes = new Date(d.created_at).getMonth() + 1;
|
||||||
|
evolucaoPreventiva[mes] = (evolucaoPreventiva[mes] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const meses = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'];
|
||||||
|
const evolucao = meses.map((name, i) => ({ name, preventivas: evolucaoPreventiva[i + 1] || 0 }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
pct_preventivas: totalClassificada > 0 ? Math.round(preventivas / totalClassificada * 100) : 0,
|
||||||
|
pct_corretivas: totalClassificada > 0 ? Math.round(corretivas / totalClassificada * 100) : 0,
|
||||||
|
pct_emergenciais: totalClassificada > 0 ? Math.round(emergenciais / totalClassificada * 100) : 0,
|
||||||
|
total_preventivas: preventivas,
|
||||||
|
total_corretivas: corretivas,
|
||||||
|
total_emergenciais: emergenciais,
|
||||||
|
pct_fornecedores_esg_bom: fornecedores.length > 0 ? Math.round(esgInterm / fornecedores.length * 100) : 0,
|
||||||
|
fornecedores_esg_basico: esgBasico,
|
||||||
|
fornecedores_esg_intermediario_avancado: esgInterm,
|
||||||
|
total_fornecedores: fornecedores.length,
|
||||||
|
demandas_alto_impacto: demandasAltoImpacto,
|
||||||
|
demandas_alto_impacto_count: demandasAltoImpacto.length,
|
||||||
|
evolucao_preventiva: evolucao,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export class DemandasController {
|
|||||||
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
|
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
|
||||||
@Post() create(@Body() body: any) { return this.svc.create(body); }
|
@Post() create(@Body() body: any) { return this.svc.create(body); }
|
||||||
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
|
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
|
||||||
|
@Delete(':id') remove(@Param('id') id: string) { return this.svc.remove(id); }
|
||||||
|
|
||||||
@Post(':id/publicar') publicar(@Param('id') id: string) { return this.svc.updateStatus(id, 'aberta'); }
|
@Post(':id/publicar') publicar(@Param('id') id: string) { return this.svc.updateStatus(id, 'aberta'); }
|
||||||
@Post(':id/cancelar') cancelar(@Param('id') id: string) { return this.svc.updateStatus(id, 'cancelada'); }
|
@Post(':id/cancelar') cancelar(@Param('id') id: string) { return this.svc.updateStatus(id, 'cancelada'); }
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class DemandasService {
|
|||||||
findOne(id: string) { return this.repo.findOne({ where: { id }, relations: ['itens_linha'] }); }
|
findOne(id: string) { return this.repo.findOne({ where: { id }, relations: ['itens_linha'] }); }
|
||||||
create(data: Partial<Demanda>) { return this.repo.save(data); }
|
create(data: Partial<Demanda>) { return this.repo.save(data); }
|
||||||
async update(id: string, data: Partial<Demanda>) { await this.repo.update(id, data); return this.findOne(id); }
|
async update(id: string, data: Partial<Demanda>) { await this.repo.update(id, data); return this.findOne(id); }
|
||||||
|
async remove(id: string) { await this.repo.delete(id); return { deleted: true }; }
|
||||||
|
|
||||||
async updateStatus(id: string, status: string) {
|
async updateStatus(id: string, status: string) {
|
||||||
await this.repo.update(id, { status });
|
await this.repo.update(id, { status });
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export class Demanda {
|
|||||||
@Column()
|
@Column()
|
||||||
categoria_id: string;
|
categoria_id: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
subcategoria_id: string;
|
||||||
|
|
||||||
@Column({ length: 20 })
|
@Column({ length: 20 })
|
||||||
criticidade: string;
|
criticidade: string;
|
||||||
|
|
||||||
@@ -39,6 +42,15 @@ export class Demanda {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
gestor_id: string;
|
gestor_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
valor_estimado: number;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
impacto_ambiental_demanda: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
justificativa_manutencao_emergencial: string;
|
||||||
|
|
||||||
@Column({ type: 'simple-json', default: '[]' })
|
@Column({ type: 'simple-json', default: '[]' })
|
||||||
documentos: any;
|
documentos: any;
|
||||||
|
|
||||||
|
|||||||
36
backend/src/modules/documentos/documentos.controller.ts
Normal file
36
backend/src/modules/documentos/documentos.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Controller, Get, Post, Delete, Param, Res, UploadedFile, UseInterceptors, Body, Query } from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { DocumentosService } from './documentos.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class DocumentosController {
|
||||||
|
constructor(private svc: DocumentosService) {}
|
||||||
|
|
||||||
|
@Get('demandas/:id/documentos')
|
||||||
|
findByDemanda(@Param('id') demandaId: string) {
|
||||||
|
return this.svc.findByDemanda(demandaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('demandas/:id/documentos')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 } }))
|
||||||
|
upload(
|
||||||
|
@Param('id') demandaId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('tipo') tipo?: string,
|
||||||
|
) {
|
||||||
|
return this.svc.upload(demandaId, file, tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('documentos/:id/download')
|
||||||
|
async download(@Param('id') id: string, @Res() res: Response) {
|
||||||
|
const doc = await this.svc.findOne(id);
|
||||||
|
const filePath = this.svc.getFilePath(doc);
|
||||||
|
res.download(filePath, doc.nome_arquivo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('documentos/:id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.svc.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/modules/documentos/documentos.module.ts
Normal file
13
backend/src/modules/documentos/documentos.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Documento } from './entities/documento.entity';
|
||||||
|
import { DocumentosController } from './documentos.controller';
|
||||||
|
import { DocumentosService } from './documentos.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Documento])],
|
||||||
|
controllers: [DocumentosController],
|
||||||
|
providers: [DocumentosService],
|
||||||
|
exports: [DocumentosService],
|
||||||
|
})
|
||||||
|
export class DocumentosModule {}
|
||||||
58
backend/src/modules/documentos/documentos.service.ts
Normal file
58
backend/src/modules/documentos/documentos.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Documento } from './entities/documento.entity';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/opt/hefesto/uploads';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocumentosService {
|
||||||
|
constructor(@InjectRepository(Documento) private repo: Repository<Documento>) {
|
||||||
|
// Ensure upload dir exists
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||||
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findByDemanda(demandaId: string) {
|
||||||
|
return this.repo.find({ where: { demanda_id: demandaId }, order: { created_at: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const doc = await this.repo.findOne({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException('Documento não encontrado');
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(demandaId: string, file: Express.Multer.File, tipo?: string) {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
const filename = `${demandaId}_${Date.now()}${ext}`;
|
||||||
|
const destPath = path.join(UPLOAD_DIR, filename);
|
||||||
|
|
||||||
|
fs.writeFileSync(destPath, file.buffer);
|
||||||
|
|
||||||
|
return this.repo.save({
|
||||||
|
demanda_id: demandaId,
|
||||||
|
nome_arquivo: file.originalname,
|
||||||
|
tipo: tipo || 'outro',
|
||||||
|
caminho: filename,
|
||||||
|
tamanho: file.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
const doc = await this.findOne(id);
|
||||||
|
const filePath = path.join(UPLOAD_DIR, doc.caminho);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
await this.repo.delete(id);
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilePath(doc: Documento) {
|
||||||
|
return path.join(UPLOAD_DIR, doc.caminho);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/modules/documentos/entities/documento.entity.ts
Normal file
25
backend/src/modules/documentos/entities/documento.entity.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('documentos')
|
||||||
|
export class Documento {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
demanda_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 500 })
|
||||||
|
nome_arquivo: string;
|
||||||
|
|
||||||
|
@Column({ length: 50, default: 'outro' })
|
||||||
|
tipo: string; // planta | foto | laudo | outro
|
||||||
|
|
||||||
|
@Column({ length: 1000 })
|
||||||
|
caminho: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0 })
|
||||||
|
tamanho: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
22
backend/src/modules/esg/entities/esg-meta.entity.ts
Normal file
22
backend/src/modules/esg/entities/esg-meta.entity.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('esg_metas')
|
||||||
|
export class EsgMeta {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 20 })
|
||||||
|
tipo: string;
|
||||||
|
|
||||||
|
@Column({ type: 'float' })
|
||||||
|
meta_valor: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
periodo_ano: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
local_id: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
40
backend/src/modules/esg/entities/esg-metrica.entity.ts
Normal file
40
backend/src/modules/esg/entities/esg-metrica.entity.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('esg_metricas')
|
||||||
|
export class EsgMetrica {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 20 })
|
||||||
|
tipo: string; // energia | agua | residuos | emissoes
|
||||||
|
|
||||||
|
@Column({ length: 30 })
|
||||||
|
unidade_medida: string;
|
||||||
|
|
||||||
|
@Column({ type: 'float' })
|
||||||
|
valor: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
periodo_mes: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
periodo_ano: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
local_id: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
centro_custo_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 100, nullable: true })
|
||||||
|
fonte_dados: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
observacoes: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
28
backend/src/modules/esg/esg.controller.ts
Normal file
28
backend/src/modules/esg/esg.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
|
||||||
|
import { EsgService } from './esg.service';
|
||||||
|
|
||||||
|
@Controller('esg')
|
||||||
|
export class EsgController {
|
||||||
|
constructor(private readonly svc: EsgService) {}
|
||||||
|
|
||||||
|
@Get('metricas')
|
||||||
|
findMetricas(@Query() q: any) { return this.svc.findMetricas(q); }
|
||||||
|
|
||||||
|
@Post('metricas')
|
||||||
|
createMetrica(@Body() dto: any) { return this.svc.createMetrica(dto); }
|
||||||
|
|
||||||
|
@Patch('metricas/:id')
|
||||||
|
updateMetrica(@Param('id') id: string, @Body() dto: any) { return this.svc.updateMetrica(id, dto); }
|
||||||
|
|
||||||
|
@Get('metricas/resumo')
|
||||||
|
resumo(@Query() q: any) { return this.svc.resumo(q); }
|
||||||
|
|
||||||
|
@Get('metas')
|
||||||
|
findMetas(@Query() q: any) { return this.svc.findMetas(q); }
|
||||||
|
|
||||||
|
@Post('metas')
|
||||||
|
createMeta(@Body() dto: any) { return this.svc.createMeta(dto); }
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
dashboard(@Query() q: any) { return this.svc.dashboard(q); }
|
||||||
|
}
|
||||||
14
backend/src/modules/esg/esg.module.ts
Normal file
14
backend/src/modules/esg/esg.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { EsgMetrica } from './entities/esg-metrica.entity';
|
||||||
|
import { EsgMeta } from './entities/esg-meta.entity';
|
||||||
|
import { EsgController } from './esg.controller';
|
||||||
|
import { EsgService } from './esg.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([EsgMetrica, EsgMeta])],
|
||||||
|
controllers: [EsgController],
|
||||||
|
providers: [EsgService],
|
||||||
|
exports: [EsgService],
|
||||||
|
})
|
||||||
|
export class EsgModule {}
|
||||||
75
backend/src/modules/esg/esg.service.ts
Normal file
75
backend/src/modules/esg/esg.service.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { EsgMetrica } from './entities/esg-metrica.entity';
|
||||||
|
import { EsgMeta } from './entities/esg-meta.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EsgService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(EsgMetrica) private metricaRepo: Repository<EsgMetrica>,
|
||||||
|
@InjectRepository(EsgMeta) private metaRepo: Repository<EsgMeta>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findMetricas(q: any) {
|
||||||
|
const qb = this.metricaRepo.createQueryBuilder('m');
|
||||||
|
if (q.tipo) qb.andWhere('m.tipo = :tipo', { tipo: q.tipo });
|
||||||
|
if (q.ano) qb.andWhere('m.periodo_ano = :ano', { ano: q.ano });
|
||||||
|
if (q.mes) qb.andWhere('m.periodo_mes = :mes', { mes: q.mes });
|
||||||
|
if (q.local_id) qb.andWhere('m.local_id = :local_id', { local_id: q.local_id });
|
||||||
|
return qb.orderBy('m.periodo_ano', 'DESC').addOrderBy('m.periodo_mes', 'DESC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMetrica(dto: any) {
|
||||||
|
return this.metricaRepo.save(this.metricaRepo.create(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMetrica(id: string, dto: any) {
|
||||||
|
await this.metricaRepo.update(id, dto);
|
||||||
|
return this.metricaRepo.findOneBy({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumo(q: any) {
|
||||||
|
const ano = q.ano || new Date().getFullYear();
|
||||||
|
const rows = await this.metricaRepo.createQueryBuilder('m')
|
||||||
|
.select('m.tipo', 'tipo')
|
||||||
|
.addSelect('m.unidade_medida', 'unidade')
|
||||||
|
.addSelect('SUM(m.valor)', 'total')
|
||||||
|
.addSelect('AVG(m.valor)', 'media')
|
||||||
|
.addSelect('COUNT(*)', 'registros')
|
||||||
|
.where('m.periodo_ano = :ano', { ano })
|
||||||
|
.groupBy('m.tipo')
|
||||||
|
.addGroupBy('m.unidade_medida')
|
||||||
|
.getRawMany();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMetas(q: any) {
|
||||||
|
const qb = this.metaRepo.createQueryBuilder('m');
|
||||||
|
if (q.ano) qb.andWhere('m.periodo_ano = :ano', { ano: q.ano });
|
||||||
|
if (q.tipo) qb.andWhere('m.tipo = :tipo', { tipo: q.tipo });
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMeta(dto: any) {
|
||||||
|
return this.metaRepo.save(this.metaRepo.create(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
async dashboard(q: any) {
|
||||||
|
const ano = q.ano || new Date().getFullYear();
|
||||||
|
const metricas = await this.metricaRepo.createQueryBuilder('m')
|
||||||
|
.select('m.tipo', 'tipo')
|
||||||
|
.addSelect('m.periodo_mes', 'mes')
|
||||||
|
.addSelect('SUM(m.valor)', 'total')
|
||||||
|
.where('m.periodo_ano = :ano', { ano })
|
||||||
|
.groupBy('m.tipo')
|
||||||
|
.addGroupBy('m.periodo_mes')
|
||||||
|
.orderBy('m.periodo_mes', 'ASC')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
const metas = await this.metaRepo.find({ where: { periodo_ano: ano } });
|
||||||
|
const resumo = await this.resumo({ ano });
|
||||||
|
|
||||||
|
return { ano, metricas_por_mes: metricas, metas, resumo };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,24 @@ export class Fornecedor {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
usuario_id: string;
|
usuario_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 200, nullable: true })
|
||||||
|
nome_contato: string;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
possui_politica_ambiental: boolean;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
possui_politica_sst: boolean;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
declara_uso_epi: boolean;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
equipe_treinada: boolean;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
classificacao_esg: string;
|
||||||
|
|
||||||
@Column({ default: true })
|
@Column({ default: true })
|
||||||
ativo: boolean;
|
ativo: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Fornecedor } from './entities/fornecedor.entity';
|
import { Fornecedor } from './entities/fornecedor.entity';
|
||||||
@@ -15,7 +15,16 @@ export class FornecedoresService {
|
|||||||
findOne(id: string) { return this.repo.findOne({ where: { id }, relations: ['certidoes'] }); }
|
findOne(id: string) { return this.repo.findOne({ where: { id }, relations: ['certidoes'] }); }
|
||||||
create(data: Partial<Fornecedor>) { return this.repo.save(data); }
|
create(data: Partial<Fornecedor>) { return this.repo.save(data); }
|
||||||
async update(id: string, data: Partial<Fornecedor>) { await this.repo.update(id, data); return this.findOne(id); }
|
async update(id: string, data: Partial<Fornecedor>) { await this.repo.update(id, data); return this.findOne(id); }
|
||||||
async remove(id: string) { await this.repo.update(id, { ativo: false }); }
|
|
||||||
|
async remove(id: string) {
|
||||||
|
// Check for associated OS via direct query
|
||||||
|
const result = await this.repo.query('SELECT COUNT(*) as count FROM ordens_servico WHERE fornecedor_id = $1', [id]);
|
||||||
|
const count = parseInt(result[0]?.count || '0');
|
||||||
|
if (count > 0) {
|
||||||
|
throw new BadRequestException('Não é possível excluir este fornecedor pois existem Ordens de Serviço associadas.');
|
||||||
|
}
|
||||||
|
await this.repo.update(id, { ativo: false });
|
||||||
|
}
|
||||||
|
|
||||||
findCertidoes(fornecedorId: string) { return this.certRepo.find({ where: { fornecedor_id: fornecedorId } }); }
|
findCertidoes(fornecedorId: string) { return this.certRepo.find({ where: { fornecedor_id: fornecedorId } }); }
|
||||||
createCertidao(data: Partial<Certidao>) { return this.certRepo.save(data); }
|
createCertidao(data: Partial<Certidao>) { return this.certRepo.save(data); }
|
||||||
|
|||||||
14
backend/src/modules/import/import.controller.ts
Normal file
14
backend/src/modules/import/import.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Post, UploadedFile, UseInterceptors, Query } from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
|
||||||
|
@Controller('import')
|
||||||
|
export class ImportController {
|
||||||
|
constructor(private readonly svc: ImportService) {}
|
||||||
|
|
||||||
|
@Post('excel')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async importExcel(@UploadedFile() file: any, @Query('tipo') tipo: string) {
|
||||||
|
return this.svc.importExcel(file, tipo);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/modules/import/import.module.ts
Normal file
9
backend/src/modules/import/import.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ImportController } from './import.controller';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ImportController],
|
||||||
|
providers: [ImportService],
|
||||||
|
})
|
||||||
|
export class ImportModule {}
|
||||||
60
backend/src/modules/import/import.service.ts
Normal file
60
backend/src/modules/import/import.service.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImportService {
|
||||||
|
constructor(private ds: DataSource) {}
|
||||||
|
|
||||||
|
async importExcel(file: any, tipo: string) {
|
||||||
|
if (!file) throw new BadRequestException('Nenhum arquivo enviado');
|
||||||
|
if (!tipo || !['orcamento', 'demandas'].includes(tipo)) {
|
||||||
|
throw new BadRequestException('Tipo deve ser "orcamento" ou "demandas"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const wb = XLSX.read(file.buffer, { type: 'buffer' });
|
||||||
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const rows: any[] = XLSX.utils.sheet_to_json(sheet);
|
||||||
|
|
||||||
|
if (!rows.length) throw new BadRequestException('Planilha vazia');
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const valid: any[] = [];
|
||||||
|
|
||||||
|
if (tipo === 'orcamento') {
|
||||||
|
const required = ['ano', 'mes', 'centro_custo_id', 'categoria_id', 'valor_planejado'];
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
const missing = required.filter(f => r[f] === undefined || r[f] === '');
|
||||||
|
if (missing.length) { errors.push(`Linha ${i + 2}: campos faltando: ${missing.join(', ')}`); }
|
||||||
|
else { valid.push(r); }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const required = ['titulo', 'local_id', 'centro_custo_id', 'categoria_id', 'criticidade', 'solicitante_id'];
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
const missing = required.filter(f => r[f] === undefined || r[f] === '');
|
||||||
|
if (missing.length) { errors.push(`Linha ${i + 2}: campos faltando: ${missing.join(', ')}`); }
|
||||||
|
else { valid.push({ ...r, status: r.status || 'rascunho' }); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length && !valid.length) {
|
||||||
|
return { sucesso: false, erros: errors, importados: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = tipo === 'orcamento' ? 'orcamento_planejado' : 'demandas';
|
||||||
|
let importados = 0;
|
||||||
|
for (const row of valid) {
|
||||||
|
try {
|
||||||
|
const cols = Object.keys(row);
|
||||||
|
const vals = cols.map(c => row[c]);
|
||||||
|
const placeholders = cols.map(() => '?').join(',');
|
||||||
|
await this.ds.query(`INSERT INTO ${table} (${cols.join(',')}) VALUES (${placeholders})`, vals);
|
||||||
|
importados++;
|
||||||
|
} catch (e: any) {
|
||||||
|
errors.push(`Erro ao inserir: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sucesso: true, importados, erros: errors, total_linhas: rows.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/src/modules/kpis/entities/kpi.entity.ts
Normal file
43
backend/src/modules/kpis/entities/kpi.entity.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('kpis')
|
||||||
|
export class Kpi {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 200 })
|
||||||
|
nome: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
descricao: string;
|
||||||
|
|
||||||
|
@Column({ length: 300, nullable: true })
|
||||||
|
formula: string;
|
||||||
|
|
||||||
|
@Column({ length: 30 })
|
||||||
|
categoria: string; // financeiro | operacional | qualidade | prazo
|
||||||
|
|
||||||
|
@Column({ length: 30, nullable: true })
|
||||||
|
unidade: string;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
meta_valor: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
valor_atual: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
periodo_mes: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
periodo_ano: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
centro_custo_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, default: 'verde' })
|
||||||
|
status: string; // verde | amarelo | vermelho
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
16
backend/src/modules/kpis/kpis.controller.ts
Normal file
16
backend/src/modules/kpis/kpis.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
|
||||||
|
import { KpisService } from './kpis.service';
|
||||||
|
|
||||||
|
@Controller('kpis')
|
||||||
|
export class KpisController {
|
||||||
|
constructor(private readonly svc: KpisService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query() q: any) { return this.svc.findAll(q); }
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: any) { return this.svc.create(dto); }
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
dashboard(@Query() q: any) { return this.svc.dashboard(q); }
|
||||||
|
}
|
||||||
13
backend/src/modules/kpis/kpis.module.ts
Normal file
13
backend/src/modules/kpis/kpis.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Kpi } from './entities/kpi.entity';
|
||||||
|
import { KpisController } from './kpis.controller';
|
||||||
|
import { KpisService } from './kpis.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Kpi])],
|
||||||
|
controllers: [KpisController],
|
||||||
|
providers: [KpisService],
|
||||||
|
exports: [KpisService],
|
||||||
|
})
|
||||||
|
export class KpisModule {}
|
||||||
75
backend/src/modules/kpis/kpis.service.ts
Normal file
75
backend/src/modules/kpis/kpis.service.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { Kpi } from './entities/kpi.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KpisService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Kpi) private kpiRepo: Repository<Kpi>,
|
||||||
|
private ds: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(q: any) {
|
||||||
|
const qb = this.kpiRepo.createQueryBuilder('k');
|
||||||
|
if (q.categoria) qb.andWhere('k.categoria = :cat', { cat: q.categoria });
|
||||||
|
if (q.ano) qb.andWhere('k.periodo_ano = :ano', { ano: q.ano });
|
||||||
|
if (q.centro_custo_id) qb.andWhere('k.centro_custo_id = :cc', { cc: q.centro_custo_id });
|
||||||
|
return qb.orderBy('k.categoria', 'ASC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: any) {
|
||||||
|
return this.kpiRepo.save(this.kpiRepo.create(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
async dashboard(q: any) {
|
||||||
|
const ano = q.ano || new Date().getFullYear();
|
||||||
|
const mes = q.mes || new Date().getMonth() + 1;
|
||||||
|
|
||||||
|
// Auto-calculated KPIs
|
||||||
|
const calculated: any[] = [];
|
||||||
|
|
||||||
|
// % budget consumed
|
||||||
|
try {
|
||||||
|
const [budget] = await this.ds.query(`
|
||||||
|
SELECT COALESCE(SUM(valor_planejado),0) as planejado, COALESCE(SUM(valor_realizado),0) as realizado
|
||||||
|
FROM orcamento_planejado WHERE ano = ? AND mes = ?`, [ano, mes]);
|
||||||
|
if (budget && budget.planejado > 0) {
|
||||||
|
const pct = (budget.realizado / budget.planejado) * 100;
|
||||||
|
calculated.push({ nome: '% Orçamento Consumido', categoria: 'financeiro', unidade: '%', valor_atual: Math.round(pct * 100) / 100, meta_valor: 100, status: pct > 90 ? 'vermelho' : pct > 70 ? 'amarelo' : 'verde' });
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Avg time to close OS
|
||||||
|
try {
|
||||||
|
const [osTime] = await this.ds.query(`
|
||||||
|
SELECT AVG(julianday(data_conclusao) - julianday(data_inicio)) as avg_dias
|
||||||
|
FROM ordens_servico WHERE status = 'concluida' AND data_inicio IS NOT NULL AND data_conclusao IS NOT NULL`);
|
||||||
|
if (osTime && osTime.avg_dias) {
|
||||||
|
calculated.push({ nome: 'Tempo Médio Fechamento OS', categoria: 'prazo', unidade: 'dias', valor_atual: Math.round(osTime.avg_dias * 10) / 10, meta_valor: 15, status: osTime.avg_dias > 20 ? 'vermelho' : osTime.avg_dias > 15 ? 'amarelo' : 'verde' });
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Supplier rating avg
|
||||||
|
try {
|
||||||
|
const [rating] = await this.ds.query(`SELECT AVG(nota) as avg_nota FROM avaliacoes`);
|
||||||
|
if (rating && rating.avg_nota) {
|
||||||
|
calculated.push({ nome: 'Nota Média Fornecedores', categoria: 'qualidade', unidade: 'nota', valor_atual: Math.round(rating.avg_nota * 10) / 10, meta_valor: 4.0, status: rating.avg_nota < 3 ? 'vermelho' : rating.avg_nota < 3.5 ? 'amarelo' : 'verde' });
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Demand completion rate
|
||||||
|
try {
|
||||||
|
const [demandas] = await this.ds.query(`
|
||||||
|
SELECT COUNT(*) as total, SUM(CASE WHEN status IN ('concluida','finalizada') THEN 1 ELSE 0 END) as concluidas
|
||||||
|
FROM demandas`);
|
||||||
|
if (demandas && demandas.total > 0) {
|
||||||
|
const rate = (demandas.concluidas / demandas.total) * 100;
|
||||||
|
calculated.push({ nome: 'Taxa Conclusão Demandas', categoria: 'operacional', unidade: '%', valor_atual: Math.round(rate * 100) / 100, meta_valor: 80, status: rate < 50 ? 'vermelho' : rate < 70 ? 'amarelo' : 'verde' });
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
const stored = await this.kpiRepo.find({ where: { periodo_ano: ano } });
|
||||||
|
return { ano, mes, kpis_calculados: calculated, kpis_cadastrados: stored };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,15 @@ export class Local {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
responsavel_id: string;
|
responsavel_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 30, nullable: true })
|
||||||
|
tipo_operacao_local: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
classificacao_impacto_ambiental: string;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
|
praticas_sustentaveis: string[];
|
||||||
|
|
||||||
@Column({ default: true })
|
@Column({ default: true })
|
||||||
ativo: boolean;
|
ativo: boolean;
|
||||||
|
|
||||||
|
|||||||
37
backend/src/modules/metas/entities/meta.entity.ts
Normal file
37
backend/src/modules/metas/entities/meta.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('metas')
|
||||||
|
export class Meta {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 300 })
|
||||||
|
titulo: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
descricao: string;
|
||||||
|
|
||||||
|
@Column({ length: 30 })
|
||||||
|
tipo: string; // orcamento | operacional | esg
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
centro_custo_id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'float' })
|
||||||
|
valor_meta: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', default: 0 })
|
||||||
|
valor_atual: number;
|
||||||
|
|
||||||
|
@Column({ length: 30, nullable: true })
|
||||||
|
unidade: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
prazo: string;
|
||||||
|
|
||||||
|
@Column({ length: 30, default: 'em_andamento' })
|
||||||
|
status: string; // em_andamento | atingida | atrasada
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
19
backend/src/modules/metas/metas.controller.ts
Normal file
19
backend/src/modules/metas/metas.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
|
||||||
|
import { MetasService } from './metas.service';
|
||||||
|
|
||||||
|
@Controller('metas')
|
||||||
|
export class MetasController {
|
||||||
|
constructor(private readonly svc: MetasService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query() q: any) { return this.svc.findAll(q); }
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: any) { return this.svc.create(dto); }
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: any) { return this.svc.update(id, dto); }
|
||||||
|
|
||||||
|
@Get('progresso')
|
||||||
|
progresso() { return this.svc.progresso(); }
|
||||||
|
}
|
||||||
13
backend/src/modules/metas/metas.module.ts
Normal file
13
backend/src/modules/metas/metas.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Meta } from './entities/meta.entity';
|
||||||
|
import { MetasController } from './metas.controller';
|
||||||
|
import { MetasService } from './metas.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Meta])],
|
||||||
|
controllers: [MetasController],
|
||||||
|
providers: [MetasService],
|
||||||
|
exports: [MetasService],
|
||||||
|
})
|
||||||
|
export class MetasModule {}
|
||||||
37
backend/src/modules/metas/metas.service.ts
Normal file
37
backend/src/modules/metas/metas.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Meta } from './entities/meta.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetasService {
|
||||||
|
constructor(@InjectRepository(Meta) private repo: Repository<Meta>) {}
|
||||||
|
|
||||||
|
async findAll(q: any) {
|
||||||
|
const qb = this.repo.createQueryBuilder('m');
|
||||||
|
if (q.tipo) qb.andWhere('m.tipo = :tipo', { tipo: q.tipo });
|
||||||
|
if (q.status) qb.andWhere('m.status = :s', { s: q.status });
|
||||||
|
if (q.centro_custo_id) qb.andWhere('m.centro_custo_id = :cc', { cc: q.centro_custo_id });
|
||||||
|
return qb.orderBy('m.created_at', 'DESC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: any) { return this.repo.save(this.repo.create(dto)); }
|
||||||
|
|
||||||
|
async update(id: string, dto: any) {
|
||||||
|
await this.repo.update(id, dto);
|
||||||
|
return this.repo.findOneBy({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async progresso() {
|
||||||
|
const all = await this.repo.find();
|
||||||
|
const por_tipo = {} as any;
|
||||||
|
for (const m of all) {
|
||||||
|
if (!por_tipo[m.tipo]) por_tipo[m.tipo] = { total: 0, atingidas: 0, em_andamento: 0, atrasadas: 0 };
|
||||||
|
por_tipo[m.tipo].total++;
|
||||||
|
por_tipo[m.tipo][m.status]++;
|
||||||
|
}
|
||||||
|
const total = all.length;
|
||||||
|
const atingidas = all.filter(m => m.status === 'atingida').length;
|
||||||
|
return { total, atingidas, percentual_conclusao: total ? Math.round((atingidas / total) * 100) : 0, por_tipo, metas: all };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ export class OrcamentoPlanejado {
|
|||||||
@Column({ type: 'float', default: 0 })
|
@Column({ type: 'float', default: 0 })
|
||||||
valor_realizado: number;
|
valor_realizado: number;
|
||||||
|
|
||||||
|
@Column({ length: 10, default: 'mensal' })
|
||||||
|
tipo_periodo: string;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
valor_anual: number;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Body, Param, Query } from '@nestjs/common';
|
||||||
import { OrcamentoService } from './orcamento.service';
|
import { OrcamentoService } from './orcamento.service';
|
||||||
|
|
||||||
@Controller('orcamento')
|
@Controller('orcamento')
|
||||||
@@ -7,7 +7,9 @@ export class OrcamentoController {
|
|||||||
|
|
||||||
@Get() findAll(@Query() query: any) { return this.svc.findAll(query); }
|
@Get() findAll(@Query() query: any) { return this.svc.findAll(query); }
|
||||||
@Get('resumo') resumo(@Query('ano') ano: string) { return this.svc.resumo(parseInt(ano) || 2026); }
|
@Get('resumo') resumo(@Query('ano') ano: string) { return this.svc.resumo(parseInt(ano) || 2026); }
|
||||||
|
@Get('resumo-investimento') resumoInvestimento(@Query('ano') ano: string) { return this.svc.resumoInvestimento(parseInt(ano) || 2026); }
|
||||||
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
|
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
|
||||||
@Post() create(@Body() body: any) { return this.svc.create(body); }
|
@Post() create(@Body() body: any) { return this.svc.create(body); }
|
||||||
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
|
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
|
||||||
|
@Delete(':id') remove(@Param('id') id: string) { return this.svc.remove(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { OrcamentoPlanejado } from './entities/orcamento-planejado.entity';
|
import { OrcamentoPlanejado } from './entities/orcamento-planejado.entity';
|
||||||
|
import { Categoria } from '../categorias/entities/categoria.entity';
|
||||||
import { OrcamentoController } from './orcamento.controller';
|
import { OrcamentoController } from './orcamento.controller';
|
||||||
import { OrcamentoService } from './orcamento.service';
|
import { OrcamentoService } from './orcamento.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([OrcamentoPlanejado])],
|
imports: [TypeOrmModule.forFeature([OrcamentoPlanejado, Categoria])],
|
||||||
controllers: [OrcamentoController],
|
controllers: [OrcamentoController],
|
||||||
providers: [OrcamentoService],
|
providers: [OrcamentoService],
|
||||||
exports: [OrcamentoService],
|
exports: [OrcamentoService],
|
||||||
|
|||||||
@@ -2,22 +2,37 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { OrcamentoPlanejado } from './entities/orcamento-planejado.entity';
|
import { OrcamentoPlanejado } from './entities/orcamento-planejado.entity';
|
||||||
|
import { Categoria } from '../categorias/entities/categoria.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrcamentoService {
|
export class OrcamentoService {
|
||||||
constructor(@InjectRepository(OrcamentoPlanejado) private repo: Repository<OrcamentoPlanejado>) {}
|
constructor(
|
||||||
|
@InjectRepository(OrcamentoPlanejado) private repo: Repository<OrcamentoPlanejado>,
|
||||||
|
@InjectRepository(Categoria) private categoriaRepo: Repository<Categoria>,
|
||||||
|
) {}
|
||||||
|
|
||||||
findAll(query?: any) {
|
async findAll(query?: any) {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (query?.ano) where.ano = query.ano;
|
if (query?.ano) where.ano = query.ano;
|
||||||
if (query?.mes) where.mes = query.mes;
|
if (query?.mes) where.mes = query.mes;
|
||||||
if (query?.centro_custo_id) where.centro_custo_id = query.centro_custo_id;
|
if (query?.centro_custo_id) where.centro_custo_id = query.centro_custo_id;
|
||||||
return this.repo.find({ where });
|
const items = await this.repo.find({ where });
|
||||||
|
|
||||||
|
// Enrich with tipo_investimento from categoria
|
||||||
|
const categorias = await this.categoriaRepo.find();
|
||||||
|
const catMap: Record<string, string> = {};
|
||||||
|
categorias.forEach(c => { catMap[c.id] = c.tipo_investimento || ''; });
|
||||||
|
|
||||||
|
return items.map(item => ({
|
||||||
|
...item,
|
||||||
|
tipo_investimento: catMap[item.categoria_id] || '',
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
|
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
|
||||||
create(data: Partial<OrcamentoPlanejado>) { return this.repo.save(data); }
|
create(data: Partial<OrcamentoPlanejado>) { return this.repo.save(data); }
|
||||||
async update(id: string, data: Partial<OrcamentoPlanejado>) { await this.repo.update(id, data); return this.findOne(id); }
|
async update(id: string, data: Partial<OrcamentoPlanejado>) { await this.repo.update(id, data); return this.findOne(id); }
|
||||||
|
async remove(id: string) { await this.repo.delete(id); return { deleted: true }; }
|
||||||
|
|
||||||
async resumo(ano: number) {
|
async resumo(ano: number) {
|
||||||
const items = await this.repo.find({ where: { ano } });
|
const items = await this.repo.find({ where: { ano } });
|
||||||
@@ -30,4 +45,23 @@ export class OrcamentoService {
|
|||||||
}
|
}
|
||||||
return Object.values(byMonth).sort((a: any, b: any) => a.mes - b.mes);
|
return Object.values(byMonth).sort((a: any, b: any) => a.mes - b.mes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resumoInvestimento(ano: number) {
|
||||||
|
const items = await this.repo.find({ where: { ano } });
|
||||||
|
const categorias = await this.categoriaRepo.find();
|
||||||
|
const catMap: Record<string, string> = {};
|
||||||
|
categorias.forEach(c => { catMap[c.id] = c.tipo_investimento || 'Não definido'; });
|
||||||
|
|
||||||
|
const result: Record<string, { tipo: string; planejado: number; realizado: number; economia: number }> = {};
|
||||||
|
for (const item of items) {
|
||||||
|
const tipo = catMap[item.categoria_id] || 'Não definido';
|
||||||
|
if (!result[tipo]) result[tipo] = { tipo, planejado: 0, realizado: 0, economia: 0 };
|
||||||
|
result[tipo].planejado += item.valor_planejado;
|
||||||
|
result[tipo].realizado += item.valor_realizado;
|
||||||
|
}
|
||||||
|
for (const r of Object.values(result)) {
|
||||||
|
r.economia = r.planejado - r.realizado;
|
||||||
|
}
|
||||||
|
return Object.values(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('documento_versoes')
|
||||||
|
export class DocumentoVersao {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
ordem_servico_id: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
fornecedor_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 500 })
|
||||||
|
nome_arquivo: string;
|
||||||
|
|
||||||
|
@Column({ length: 1000 })
|
||||||
|
caminho: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0 })
|
||||||
|
tamanho: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 1 })
|
||||||
|
versao: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
valor_bruto: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
valor_liquido: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
iss: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
inss: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
pcc: number;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
condicao_pagamento: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
prazo_execucao: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
data_estimada_entrega: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
@@ -8,27 +8,63 @@ export class OrdemServico {
|
|||||||
@Column({ type: 'integer', unique: true, nullable: true })
|
@Column({ type: 'integer', unique: true, nullable: true })
|
||||||
numero: number;
|
numero: number;
|
||||||
|
|
||||||
@Column()
|
@Column({ nullable: true })
|
||||||
demanda_id: string;
|
demanda_id: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ nullable: true })
|
||||||
proposta_id: string;
|
proposta_id: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ nullable: true })
|
||||||
fornecedor_id: string;
|
fornecedor_id: string;
|
||||||
|
|
||||||
@Column({ type: 'float' })
|
@Column({ type: 'float', nullable: true })
|
||||||
valor: number;
|
valor: number;
|
||||||
|
|
||||||
@Column({ length: 30, default: 'emitida' })
|
@Column({ length: 30, default: 'emitida' })
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
data: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
data_inicio: string;
|
data_inicio: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
data_conclusao: string;
|
data_conclusao: string;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
valor_bruto: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
valor_liquido: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
iss: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
inss: number;
|
||||||
|
|
||||||
|
@Column({ type: 'float', nullable: true })
|
||||||
|
pcc: number;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
condicao_pagamento: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
prazo_execucao: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
data_estimada_entrega: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
uso_material_sustentavel: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
gera_residuos: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
descarte_certificado: string;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
observacoes: string;
|
observacoes: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Body, Param, Res, UploadedFile, UseInterceptors, Query } from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import type { Response } from 'express';
|
||||||
import { OrdensServicoService } from './ordens-servico.service';
|
import { OrdensServicoService } from './ordens-servico.service';
|
||||||
|
|
||||||
@Controller('ordens-servico')
|
@Controller('ordens-servico')
|
||||||
@@ -8,10 +10,43 @@ export class OrdensServicoController {
|
|||||||
@Get() findAll() { return this.svc.findAll(); }
|
@Get() findAll() { return this.svc.findAll(); }
|
||||||
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
|
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
|
||||||
@Post() create(@Body() body: any) { return this.svc.create(body); }
|
@Post() create(@Body() body: any) { return this.svc.create(body); }
|
||||||
|
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
|
||||||
|
@Delete(':id') remove(@Param('id') id: string) { return this.svc.remove(id); }
|
||||||
|
|
||||||
|
@Get('by-demanda/:demandaId') findByDemanda(@Param('demandaId') demandaId: string) { return this.svc.findByDemanda(demandaId); }
|
||||||
|
|
||||||
@Post(':id/iniciar') iniciar(@Param('id') id: string) { return this.svc.iniciar(id); }
|
@Post(':id/iniciar') iniciar(@Param('id') id: string) { return this.svc.iniciar(id); }
|
||||||
@Post(':id/concluir') concluir(@Param('id') id: string) { return this.svc.concluir(id); }
|
@Post(':id/concluir') concluir(@Param('id') id: string) { return this.svc.concluir(id); }
|
||||||
@Post(':id/cancelar') cancelar(@Param('id') id: string) { return this.svc.cancelar(id); }
|
@Post(':id/cancelar') cancelar(@Param('id') id: string) { return this.svc.cancelar(id); }
|
||||||
|
@Post(':id/status') updateStatus(@Param('id') id: string, @Body('status') status: string) { return this.svc.updateStatus(id, status); }
|
||||||
|
|
||||||
@Post(':id/avaliacao') avaliacao(@Param('id') id: string, @Body() body: any) {
|
@Post(':id/avaliacao') avaliacao(@Param('id') id: string, @Body() body: any) {
|
||||||
return this.svc.createAvaliacao({ ...body, ordem_servico_id: id });
|
return this.svc.createAvaliacao({ ...body, ordem_servico_id: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Document versioning
|
||||||
|
@Get(':id/documentos') findDocumentos(@Param('id') id: string) { return this.svc.findDocumentos(id); }
|
||||||
|
|
||||||
|
@Post(':id/documentos')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 } }))
|
||||||
|
uploadDocumento(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('fornecedor_id') fornecedorId?: string,
|
||||||
|
) {
|
||||||
|
return this.svc.uploadDocumento(id, file, fornecedorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('documentos/:docId/download')
|
||||||
|
async downloadDocumento(@Param('docId') docId: string, @Res() res: Response) {
|
||||||
|
const { doc, filePath } = await this.svc.downloadDocumento(docId);
|
||||||
|
res.download(filePath, doc.nome_arquivo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check fornecedor OS
|
||||||
|
@Get('check-fornecedor/:fornecedorId')
|
||||||
|
async checkFornecedor(@Param('fornecedorId') fornecedorId: string) {
|
||||||
|
const hasOS = await this.svc.fornecedorHasOS(fornecedorId);
|
||||||
|
return { hasOS };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { MulterModule } from '@nestjs/platform-express';
|
||||||
import { OrdemServico } from './entities/ordem-servico.entity';
|
import { OrdemServico } from './entities/ordem-servico.entity';
|
||||||
import { Avaliacao } from './entities/avaliacao.entity';
|
import { Avaliacao } from './entities/avaliacao.entity';
|
||||||
|
import { DocumentoVersao } from './entities/documento-versao.entity';
|
||||||
import { OrdensServicoController } from './ordens-servico.controller';
|
import { OrdensServicoController } from './ordens-servico.controller';
|
||||||
import { OrdensServicoService } from './ordens-servico.service';
|
import { OrdensServicoService } from './ordens-servico.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([OrdemServico, Avaliacao])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([OrdemServico, Avaliacao, DocumentoVersao]),
|
||||||
|
MulterModule.register({ limits: { fileSize: 50 * 1024 * 1024 } }),
|
||||||
|
],
|
||||||
controllers: [OrdensServicoController],
|
controllers: [OrdensServicoController],
|
||||||
providers: [OrdensServicoService],
|
providers: [OrdensServicoService],
|
||||||
exports: [OrdensServicoService],
|
exports: [OrdensServicoService],
|
||||||
|
|||||||
@@ -3,21 +3,91 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { OrdemServico } from './entities/ordem-servico.entity';
|
import { OrdemServico } from './entities/ordem-servico.entity';
|
||||||
import { Avaliacao } from './entities/avaliacao.entity';
|
import { Avaliacao } from './entities/avaliacao.entity';
|
||||||
|
import { DocumentoVersao } from './entities/documento-versao.entity';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/opt/hefesto/uploads';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrdensServicoService {
|
export class OrdensServicoService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(OrdemServico) private repo: Repository<OrdemServico>,
|
@InjectRepository(OrdemServico) private repo: Repository<OrdemServico>,
|
||||||
@InjectRepository(Avaliacao) private avalRepo: Repository<Avaliacao>,
|
@InjectRepository(Avaliacao) private avalRepo: Repository<Avaliacao>,
|
||||||
) {}
|
@InjectRepository(DocumentoVersao) private docRepo: Repository<DocumentoVersao>,
|
||||||
|
) {
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||||
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
findAll() { return this.repo.find(); }
|
findAll() { return this.repo.find({ order: { created_at: 'DESC' } }); }
|
||||||
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
|
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
|
||||||
create(data: Partial<OrdemServico>) { return this.repo.save(data); }
|
|
||||||
|
findByDemanda(demandaId: string) {
|
||||||
|
return this.repo.find({ where: { demanda_id: demandaId }, order: { created_at: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Partial<OrdemServico>) {
|
||||||
|
// Auto-number
|
||||||
|
const last = await this.repo.query('SELECT MAX(numero) as max FROM ordens_servico');
|
||||||
|
const nextNum = (last[0]?.max || 0) + 1;
|
||||||
|
return this.repo.save({ ...data, numero: nextNum, status: data.status || 'emitida' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<OrdemServico>) {
|
||||||
|
await this.repo.update(id, data);
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) { await this.repo.delete(id); return { deleted: true }; }
|
||||||
|
|
||||||
async iniciar(id: string) { await this.repo.update(id, { status: 'em_execucao', data_inicio: new Date().toISOString().split('T')[0] }); return this.findOne(id); }
|
async iniciar(id: string) { await this.repo.update(id, { status: 'em_execucao', data_inicio: new Date().toISOString().split('T')[0] }); return this.findOne(id); }
|
||||||
async concluir(id: string) { await this.repo.update(id, { status: 'concluida', data_conclusao: new Date().toISOString().split('T')[0] }); return this.findOne(id); }
|
async concluir(id: string) { await this.repo.update(id, { status: 'concluida', data_conclusao: new Date().toISOString().split('T')[0] }); return this.findOne(id); }
|
||||||
async cancelar(id: string) { await this.repo.update(id, { status: 'cancelada' }); return this.findOne(id); }
|
async cancelar(id: string) { await this.repo.update(id, { status: 'cancelada' }); return this.findOne(id); }
|
||||||
|
async updateStatus(id: string, status: string) { await this.repo.update(id, { status }); return this.findOne(id); }
|
||||||
|
|
||||||
createAvaliacao(data: Partial<Avaliacao>) { return this.avalRepo.save(data); }
|
createAvaliacao(data: Partial<Avaliacao>) { return this.avalRepo.save(data); }
|
||||||
|
|
||||||
|
// Document versioning
|
||||||
|
findDocumentos(osId: string) {
|
||||||
|
return this.docRepo.find({ where: { ordem_servico_id: osId }, order: { versao: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadDocumento(osId: string, file: Express.Multer.File, fornecedorId?: string) {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
const filename = `os_${osId}_${Date.now()}${ext}`;
|
||||||
|
const destPath = path.join(UPLOAD_DIR, filename);
|
||||||
|
fs.writeFileSync(destPath, file.buffer);
|
||||||
|
|
||||||
|
// Calculate next version
|
||||||
|
const existing = await this.docRepo.find({
|
||||||
|
where: { ordem_servico_id: osId, fornecedor_id: fornecedorId || undefined } as any,
|
||||||
|
order: { versao: 'DESC' }
|
||||||
|
});
|
||||||
|
const nextVersion = existing.length > 0 ? existing[0].versao + 1 : 1;
|
||||||
|
|
||||||
|
const doc = await this.docRepo.save({
|
||||||
|
ordem_servico_id: osId,
|
||||||
|
fornecedor_id: fornecedorId,
|
||||||
|
nome_arquivo: file.originalname,
|
||||||
|
caminho: filename,
|
||||||
|
tamanho: file.size,
|
||||||
|
versao: nextVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadDocumento(id: string) {
|
||||||
|
const doc = await this.docRepo.findOne({ where: { id } });
|
||||||
|
if (!doc) throw new Error('Documento não encontrado');
|
||||||
|
return { doc, filePath: path.join(UPLOAD_DIR, doc.caminho) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if fornecedor has OS
|
||||||
|
async fornecedorHasOS(fornecedorId: string): Promise<boolean> {
|
||||||
|
const count = await this.repo.count({ where: { fornecedor_id: fornecedorId } });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ export class Proposta {
|
|||||||
@Column({ default: false })
|
@Column({ default: false })
|
||||||
selecionada: boolean;
|
selecionada: boolean;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
uso_material_sustentavel: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
gera_residuos: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
descarte_certificado: string;
|
||||||
|
|
||||||
@Column({ length: 20, default: 'recebida' })
|
@Column({ length: 20, default: 'recebida' })
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
|
|||||||
35
backend/src/modules/relatorios/relatorios.controller.ts
Normal file
35
backend/src/modules/relatorios/relatorios.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { RelatoriosService } from './relatorios.service';
|
||||||
|
|
||||||
|
@Controller('relatorios')
|
||||||
|
export class RelatoriosController {
|
||||||
|
constructor(private readonly svc: RelatoriosService) {}
|
||||||
|
|
||||||
|
@Get('orcamento-mensal')
|
||||||
|
orcamentoMensal(@Query('ano') ano: string, @Query('mes') mes: string) {
|
||||||
|
return this.svc.orcamentoMensal(Number(ano) || 2026, Number(mes) || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('demandas-periodo')
|
||||||
|
demandasPeriodo(@Query('de') de: string, @Query('ate') ate: string) {
|
||||||
|
return this.svc.demandasPeriodo(de || '2026-01-01', ate || '2026-12-31');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('fornecedores-ranking')
|
||||||
|
fornecedoresRanking() { return this.svc.fornecedoresRanking(); }
|
||||||
|
|
||||||
|
@Get('os-performance')
|
||||||
|
osPerformance() { return this.svc.osPerformance(); }
|
||||||
|
|
||||||
|
@Get('esg-impacto-ambiental')
|
||||||
|
esgImpactoAmbiental() { return this.svc.demandasPorImpactoAmbiental(); }
|
||||||
|
|
||||||
|
@Get('esg-fornecedores')
|
||||||
|
esgFornecedores() { return this.svc.fornecedoresPorEsg(); }
|
||||||
|
|
||||||
|
@Get('esg-evolucao-preventiva')
|
||||||
|
esgEvolucaoPreventiva() { return this.svc.evolucaoManutencaoPreventiva(); }
|
||||||
|
|
||||||
|
@Get('esg-excecoes-governanca')
|
||||||
|
esgExcecoesGovernanca() { return this.svc.excecoesGovernanca(); }
|
||||||
|
}
|
||||||
9
backend/src/modules/relatorios/relatorios.module.ts
Normal file
9
backend/src/modules/relatorios/relatorios.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RelatoriosController } from './relatorios.controller';
|
||||||
|
import { RelatoriosService } from './relatorios.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [RelatoriosController],
|
||||||
|
providers: [RelatoriosService],
|
||||||
|
})
|
||||||
|
export class RelatoriosModule {}
|
||||||
145
backend/src/modules/relatorios/relatorios.service.ts
Normal file
145
backend/src/modules/relatorios/relatorios.service.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RelatoriosService {
|
||||||
|
constructor(private ds: DataSource) {}
|
||||||
|
|
||||||
|
async orcamentoMensal(ano: number, mes: number) {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT op.*, cc.nome as centro_custo_nome, cat.nome as categoria_nome
|
||||||
|
FROM orcamento_planejado op
|
||||||
|
LEFT JOIN centros_custo cc ON cc.id::text = op.centro_custo_id::text
|
||||||
|
LEFT JOIN categorias cat ON cat.id::text = op.categoria_id::text
|
||||||
|
WHERE op.ano = $1 AND op.mes = $2
|
||||||
|
ORDER BY cc.nome, cat.nome`, [ano, mes]);
|
||||||
|
|
||||||
|
const totais = rows.reduce((acc: any, r: any) => ({
|
||||||
|
planejado: (acc.planejado || 0) + (r.valor_planejado || 0),
|
||||||
|
comprometido: (acc.comprometido || 0) + (r.valor_comprometido || 0),
|
||||||
|
realizado: (acc.realizado || 0) + (r.valor_realizado || 0),
|
||||||
|
}), {});
|
||||||
|
|
||||||
|
return { ano, mes, itens: rows, totais };
|
||||||
|
}
|
||||||
|
|
||||||
|
async demandasPeriodo(de: string, ate: string) {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT d.*, cc.nome as centro_custo_nome, l.nome as local_nome
|
||||||
|
FROM demandas d
|
||||||
|
LEFT JOIN centros_custo cc ON cc.id::text = d.centro_custo_id::text
|
||||||
|
LEFT JOIN locais l ON l.id::text = d.local_id::text
|
||||||
|
WHERE d.created_at >= $1 AND d.created_at <= $2
|
||||||
|
ORDER BY d.created_at DESC`, [de, ate + ' 23:59:59']);
|
||||||
|
|
||||||
|
const por_status = await this.ds.query(`
|
||||||
|
SELECT status, COUNT(*) as total FROM demandas
|
||||||
|
WHERE created_at >= $1 AND created_at <= $2
|
||||||
|
GROUP BY status`, [de, ate + ' 23:59:59']);
|
||||||
|
|
||||||
|
return { periodo: { de, ate }, demandas: rows, resumo_por_status: por_status, total: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async fornecedoresRanking() {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT f.id, f.razao_social, f.nome_fantasia, f.rating,
|
||||||
|
COUNT(DISTINCT os.id) as total_os,
|
||||||
|
COALESCE(AVG(av.nota), 0) as nota_media,
|
||||||
|
COALESCE(SUM(os.valor), 0) as valor_total_os
|
||||||
|
FROM fornecedores f
|
||||||
|
LEFT JOIN ordens_servico os ON os.fornecedor_id::text = f.id::text
|
||||||
|
LEFT JOIN avaliacoes av ON av.fornecedor_id::text = f.id::text
|
||||||
|
WHERE f.ativo = true
|
||||||
|
GROUP BY f.id, f.razao_social, f.nome_fantasia, f.rating
|
||||||
|
ORDER BY nota_media DESC, total_os DESC`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async demandasPorImpactoAmbiental() {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT d.impacto_ambiental_demanda as impacto, COUNT(*) as total,
|
||||||
|
d.status, c.nome as categoria
|
||||||
|
FROM demandas d
|
||||||
|
LEFT JOIN categorias c ON c.id::text = d.categoria_id::text
|
||||||
|
WHERE d.impacto_ambiental_demanda IS NOT NULL
|
||||||
|
GROUP BY d.impacto_ambiental_demanda, d.status, c.nome
|
||||||
|
ORDER BY d.impacto_ambiental_demanda, d.status`);
|
||||||
|
|
||||||
|
const resumo = await this.ds.query(`
|
||||||
|
SELECT impacto_ambiental_demanda as impacto, COUNT(*) as total
|
||||||
|
FROM demandas WHERE impacto_ambiental_demanda IS NOT NULL
|
||||||
|
GROUP BY impacto_ambiental_demanda`);
|
||||||
|
|
||||||
|
return { detalhes: rows, resumo };
|
||||||
|
}
|
||||||
|
|
||||||
|
async fornecedoresPorEsg() {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT f.id, f.razao_social, f.nome_fantasia, f.classificacao_esg,
|
||||||
|
f.possui_politica_ambiental, f.possui_politica_sst, f.declara_uso_epi, f.equipe_treinada,
|
||||||
|
f.rating, COUNT(DISTINCT os.id) as total_os
|
||||||
|
FROM fornecedores f
|
||||||
|
LEFT JOIN ordens_servico os ON os.fornecedor_id::text = f.id::text
|
||||||
|
WHERE f.ativo = true
|
||||||
|
GROUP BY f.id, f.razao_social, f.nome_fantasia, f.classificacao_esg,
|
||||||
|
f.possui_politica_ambiental, f.possui_politica_sst, f.declara_uso_epi, f.equipe_treinada, f.rating
|
||||||
|
ORDER BY f.classificacao_esg DESC, f.rating DESC`);
|
||||||
|
|
||||||
|
const resumo = await this.ds.query(`
|
||||||
|
SELECT classificacao_esg, COUNT(*) as total
|
||||||
|
FROM fornecedores WHERE ativo = true AND classificacao_esg IS NOT NULL
|
||||||
|
GROUP BY classificacao_esg`);
|
||||||
|
|
||||||
|
return { fornecedores: rows, resumo };
|
||||||
|
}
|
||||||
|
|
||||||
|
async evolucaoManutencaoPreventiva() {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT EXTRACT(MONTH FROM d.created_at) as mes, EXTRACT(YEAR FROM d.created_at) as ano,
|
||||||
|
c.tipo_manutencao, COUNT(*) as total
|
||||||
|
FROM demandas d
|
||||||
|
LEFT JOIN categorias c ON c.id::text = d.categoria_id::text
|
||||||
|
WHERE c.tipo_manutencao IS NOT NULL
|
||||||
|
GROUP BY EXTRACT(MONTH FROM d.created_at), EXTRACT(YEAR FROM d.created_at), c.tipo_manutencao
|
||||||
|
ORDER BY ano, mes`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async excecoesGovernanca() {
|
||||||
|
const rows = await this.ds.query(`
|
||||||
|
SELECT w.id, w.demanda_id, w.valor_total, w.status, w.etapas,
|
||||||
|
d.titulo as demanda_titulo, d.numero as demanda_numero
|
||||||
|
FROM workflow_aprovacoes w
|
||||||
|
LEFT JOIN demandas d ON d.id::text = w.demanda_id::text
|
||||||
|
ORDER BY w.created_at DESC`);
|
||||||
|
|
||||||
|
// Filter workflows that have ressalva in any step
|
||||||
|
const comRessalva = rows.filter((w: any) => {
|
||||||
|
try {
|
||||||
|
const etapas = typeof w.etapas === 'string' ? JSON.parse(w.etapas) : w.etapas;
|
||||||
|
return Array.isArray(etapas) && etapas.some((e: any) => e.ressalva || e.status === 'aprovado_com_ressalva');
|
||||||
|
} catch { return false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
return { total: comRessalva.length, itens: comRessalva };
|
||||||
|
}
|
||||||
|
|
||||||
|
async osPerformance() {
|
||||||
|
const por_status = await this.ds.query(`
|
||||||
|
SELECT status, COUNT(*) as total, COALESCE(SUM(valor),0) as valor_total
|
||||||
|
FROM ordens_servico GROUP BY status`);
|
||||||
|
|
||||||
|
const tempo_medio = await this.ds.query(`
|
||||||
|
SELECT AVG(data_conclusao::date - data_inicio::date) as dias_medio
|
||||||
|
FROM ordens_servico WHERE status = 'concluida' AND data_inicio IS NOT NULL AND data_conclusao IS NOT NULL`);
|
||||||
|
|
||||||
|
const por_fornecedor = await this.ds.query(`
|
||||||
|
SELECT f.razao_social, COUNT(os.id) as total, AVG(os.data_conclusao::date - os.data_inicio::date) as dias_medio
|
||||||
|
FROM ordens_servico os
|
||||||
|
LEFT JOIN fornecedores f ON f.id::text = os.fornecedor_id::text
|
||||||
|
GROUP BY os.fornecedor_id, f.razao_social
|
||||||
|
ORDER BY total DESC`);
|
||||||
|
|
||||||
|
return { por_status, tempo_medio_dias: tempo_medio[0]?.dias_medio || null, por_fornecedor };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('subcategorias')
|
||||||
|
export class Subcategoria {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 200 })
|
||||||
|
nome: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
categoria_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
tipo_manutencao: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
impacto_ambiental_esperado: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
potencial_geracao_residuos: string;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
ativo: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Delete, Body, Param, Query } from '@nestjs/common';
|
||||||
|
import { SubcategoriasService } from './subcategorias.service';
|
||||||
|
|
||||||
|
@Controller('subcategorias')
|
||||||
|
export class SubcategoriasController {
|
||||||
|
constructor(private svc: SubcategoriasService) {}
|
||||||
|
|
||||||
|
@Get() findAll(@Query('categoria_id') categoriaId?: string) { return this.svc.findAll(categoriaId); }
|
||||||
|
@Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); }
|
||||||
|
@Post() create(@Body() body: any) { return this.svc.create(body); }
|
||||||
|
@Patch(':id') update(@Param('id') id: string, @Body() body: any) { return this.svc.update(id, body); }
|
||||||
|
@Delete(':id') remove(@Param('id') id: string) { return this.svc.remove(id); }
|
||||||
|
}
|
||||||
13
backend/src/modules/subcategorias/subcategorias.module.ts
Normal file
13
backend/src/modules/subcategorias/subcategorias.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Subcategoria } from './entities/subcategoria.entity';
|
||||||
|
import { SubcategoriasController } from './subcategorias.controller';
|
||||||
|
import { SubcategoriasService } from './subcategorias.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Subcategoria])],
|
||||||
|
controllers: [SubcategoriasController],
|
||||||
|
providers: [SubcategoriasService],
|
||||||
|
exports: [SubcategoriasService],
|
||||||
|
})
|
||||||
|
export class SubcategoriasModule {}
|
||||||
20
backend/src/modules/subcategorias/subcategorias.service.ts
Normal file
20
backend/src/modules/subcategorias/subcategorias.service.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Subcategoria } from './entities/subcategoria.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SubcategoriasService {
|
||||||
|
constructor(@InjectRepository(Subcategoria) private repo: Repository<Subcategoria>) {}
|
||||||
|
|
||||||
|
findAll(categoriaId?: string) {
|
||||||
|
const where: any = { ativo: true };
|
||||||
|
if (categoriaId) where.categoria_id = categoriaId;
|
||||||
|
return this.repo.find({ where, order: { nome: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
findOne(id: string) { return this.repo.findOne({ where: { id } }); }
|
||||||
|
create(data: Partial<Subcategoria>) { return this.repo.save(data); }
|
||||||
|
async update(id: string, data: Partial<Subcategoria>) { await this.repo.update(id, data); return this.findOne(id); }
|
||||||
|
async remove(id: string) { await this.repo.update(id, { ativo: false }); }
|
||||||
|
}
|
||||||
26
docs/DELTA-DASHBOARD-ORCAMENTOS.md
Normal file
26
docs/DELTA-DASHBOARD-ORCAMENTOS.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Delta Dashboard + Orçamentos — Nexus Facilities
|
||||||
|
> Especificação recebida em 2026-02-09
|
||||||
|
|
||||||
|
## 1. Tela Principal (Dashboard)
|
||||||
|
|
||||||
|
### 1.1 Gráfico Orçamento vs Realizado
|
||||||
|
- Incluir filtro: Centro de Custo | Categoria | Todos
|
||||||
|
- Opção "Todos" = visão macro
|
||||||
|
|
||||||
|
### 1.2 Cards de Demandas (Abertas, Em Cotação, Em Aprovação, Concluídas)
|
||||||
|
- Ao clicar no card, abrir lista das demandas naquele status
|
||||||
|
- Drill-down funcional
|
||||||
|
|
||||||
|
### 1.3 Gráfico por Categoria
|
||||||
|
- BUG: não está trazendo as quantidades associadas — corrigir
|
||||||
|
|
||||||
|
### 1.4 Campo Busca
|
||||||
|
- BUG: não está funcionando — corrigir
|
||||||
|
|
||||||
|
## 2. Tela de Orçamentos
|
||||||
|
|
||||||
|
### 2.1 Gráficos por Tipo de Investimento
|
||||||
|
- Mostrar total planejado, total realizado e economia separados por Capex e Opex
|
||||||
|
|
||||||
|
### 2.2 Grid
|
||||||
|
- Incluir coluna "Tipo de Investimento" (Capex/Opex, vem da categoria)
|
||||||
60
docs/DELTA-ESG.md
Normal file
60
docs/DELTA-ESG.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Delta ESG — Nexus Facilities
|
||||||
|
> Especificação recebida em 2026-02-09
|
||||||
|
|
||||||
|
## 1. Cadastros Auxiliares
|
||||||
|
|
||||||
|
### 1.1 Local — ALTERAÇÃO
|
||||||
|
- `tipo_operacao_local`: Administrativo | Industrial | Logístico | Comercial
|
||||||
|
- `classificacao_impacto_ambiental`: Baixo | Médio | Alto
|
||||||
|
- `praticas_sustentaveis`: Lista (Coleta Seletiva, Reuso de Água, Energia Renovável)
|
||||||
|
- **Regra:** Demandas em locais Alto Impacto → sinalizar no Dashboard + aprovação adicional (matriz alçada)
|
||||||
|
|
||||||
|
### 1.2 Categorias/Subcategorias — ALTERAÇÃO
|
||||||
|
- `tipo_manutencao`: Preventiva | Corretiva | Emergencial
|
||||||
|
- `impacto_ambiental_esperado`: Baixo | Médio | Alto
|
||||||
|
- `potencial_geracao_residuos`: Baixo | Médio | Alto
|
||||||
|
- **Regra:** Emergenciais + Alto Impacto → destaque em relatórios/indicadores
|
||||||
|
|
||||||
|
### 1.3 Fornecedores — ALTERAÇÃO
|
||||||
|
- `possui_politica_ambiental`: Sim | Não
|
||||||
|
- `possui_politica_sst`: Sim | Não
|
||||||
|
- `declara_uso_epi`: Sim | Não
|
||||||
|
- `equipe_treinada`: Sim | Não
|
||||||
|
- `classificacao_esg`: Básico | Intermediário | Avançado
|
||||||
|
- **Regra:** ESG Básico → sinalizar reavaliação em demandas críticas
|
||||||
|
|
||||||
|
### 1.4 Matriz de Aprovação (Alçadas) — ALTERAÇÃO
|
||||||
|
- `exige_avaliacao_esg`: Sim | Não
|
||||||
|
- **Regra:** Se Sim + (Impacto Alto OU Fornecedor ESG Básico) → justificativa adicional na aprovação
|
||||||
|
|
||||||
|
## 2. Gestão de Demanda — ALTERAÇÃO
|
||||||
|
- `impacto_ambiental_demanda`: Herdado da Categoria (editável)
|
||||||
|
- `justificativa_manutencao_emergencial`: Obrigatório se tipo_manutencao = Emergencial
|
||||||
|
|
||||||
|
## 3. Proposta Recebida — ALTERAÇÃO
|
||||||
|
- `uso_material_sustentavel`: Sim | Não | Não Informado
|
||||||
|
- `gera_residuos`: Baixo | Médio | Alto
|
||||||
|
- `descarte_certificado`: Sim | Não | Não Informado
|
||||||
|
- **Regra:** Extração automática via OCR quando possível, preenchimento manual na equalização (não obrigatório)
|
||||||
|
|
||||||
|
## 4. Painel Comparação/Equalização — ALTERAÇÃO
|
||||||
|
- Mostrar Classificação ESG do Fornecedor
|
||||||
|
- Indicador visual de Impacto Ambiental da Demanda
|
||||||
|
- **Recomendação automática:** menor valor + match escopo + ESG fornecedor (secundário)
|
||||||
|
|
||||||
|
## 5. Dashboard — INCLUSÃO
|
||||||
|
- % Demandas Preventivas vs Corretivas
|
||||||
|
- % Demandas Emergenciais
|
||||||
|
- % Fornecedores ESG Intermediário/Avançado
|
||||||
|
- Demandas com Impacto Ambiental Alto
|
||||||
|
- Evolução mensal manutenção preventiva
|
||||||
|
|
||||||
|
## 6. Relatórios/Auditoria — ALTERAÇÃO
|
||||||
|
- Demandas por Impacto Ambiental
|
||||||
|
- Fornecedores por Classificação ESG
|
||||||
|
- Evolução Manutenção Preventiva
|
||||||
|
- Exceções de Governança (aprovações com ressalva)
|
||||||
|
|
||||||
|
## 7. Regras Gerais de Governança
|
||||||
|
- Alto Impacto Ambiental → justificativa obrigatória registrada
|
||||||
|
- Dados ESG auditáveis e versionados
|
||||||
215
docs/ESPECIFICACAO-FUNCIONAL.md
Normal file
215
docs/ESPECIFICACAO-FUNCIONAL.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Especificação Funcional — Sistema de Controle Orçamentário para Facilities (HEFESTO)
|
||||||
|
|
||||||
|
## 1. Objetivo do Sistema
|
||||||
|
Desenvolver um sistema integrado para gestão de demandas de Facilities, com foco em controle orçamentário, comparação de propostas, governança financeira, compliance e apoio à tomada de decisão.
|
||||||
|
|
||||||
|
O sistema deverá:
|
||||||
|
- Centralizar demandas de Facilities
|
||||||
|
- Controlar orçamento planejado x comprometido x realizado
|
||||||
|
- Automatizar comparação e equalização de propostas
|
||||||
|
- Suportar aprovações por alçada e fluxo sequencial
|
||||||
|
- Garantir rastreabilidade, auditoria e histórico decisório
|
||||||
|
|
||||||
|
## 2. Perfis de Usuário
|
||||||
|
|
||||||
|
| Perfil | Descrição | Principais Permissões |
|
||||||
|
|--------|-----------|----------------------|
|
||||||
|
| Solicitante | Área demandante | Abertura e acompanhamento de demandas |
|
||||||
|
| Gestor de Facilities | Responsável operacional | Criar escopo, validar propostas, interagir com fornecedores |
|
||||||
|
| Aprovador Financeiro | Controle orçamentário | Aprovar/reprovar propostas conforme alçada |
|
||||||
|
| Diretoria | Aprovação estratégica | Aprovação de alto valor / exceções |
|
||||||
|
| Fornecedor | Prestador de serviço | Envio e revisão de propostas |
|
||||||
|
| Administrador | Governança do sistema | Cadastros, parâmetros e regras |
|
||||||
|
|
||||||
|
### 2.1 Dashboard Inicial (Home)
|
||||||
|
O Dashboard é a tela inicial do sistema, oferecendo uma visão executiva e operacional em tempo real, adaptada conforme o perfil do usuário.
|
||||||
|
|
||||||
|
#### 2.1.1 Indicadores Principais (Cards)
|
||||||
|
- Demandas Abertas
|
||||||
|
- Demandas em Cotação
|
||||||
|
- Propostas Pendentes de Avaliação
|
||||||
|
- Demandas em Aprovação
|
||||||
|
- Ordens de Serviço Ativas
|
||||||
|
- Demandas com Alerta de Orçamento
|
||||||
|
|
||||||
|
Cada card deve permitir drill-down para a lista filtrada correspondente.
|
||||||
|
|
||||||
|
#### 2.1.2 Visões Gráficas
|
||||||
|
- Demandas por Status (Aberta, Em Cotação, Em Aprovação, Aprovada, Cancelada)
|
||||||
|
- Demandas por Categoria de Serviço
|
||||||
|
- Consumo Orçamento (Planejado x Comprometido x Realizado)
|
||||||
|
- Propostas por Fornecedor
|
||||||
|
|
||||||
|
Os gráficos devem permitir:
|
||||||
|
- Filtro por período
|
||||||
|
- Filtro por Centro de Custo
|
||||||
|
- Filtro por Local
|
||||||
|
|
||||||
|
#### 2.1.3 Alertas e Pendências
|
||||||
|
- Propostas aguardando leitura/validação
|
||||||
|
- Demandas paradas acima do SLA
|
||||||
|
- Propostas acima do orçamento (>20%)
|
||||||
|
- Aprovações pendentes do usuário logado
|
||||||
|
|
||||||
|
Alertas devem ser destacados visualmente (cores/ícones).
|
||||||
|
|
||||||
|
#### 2.1.4 Listas Operacionais (Quick Access)
|
||||||
|
- Minhas Demandas
|
||||||
|
- Demandas Críticas
|
||||||
|
- Propostas Pendentes
|
||||||
|
- Aprovações Pendentes
|
||||||
|
|
||||||
|
Cada item deve permitir navegação direta para a demanda/proposta.
|
||||||
|
|
||||||
|
#### 2.1.5 Personalização por Perfil
|
||||||
|
- Gestor de Facilities: foco operacional e fornecedores
|
||||||
|
- Financeiro/Diretoria: foco em orçamento, valores e riscos
|
||||||
|
- Solicitante: acompanhamento de status
|
||||||
|
|
||||||
|
O sistema deve ocultar indicadores não relevantes ao perfil.
|
||||||
|
|
||||||
|
## 3. Cadastros Base (Tabelas de Apoio)
|
||||||
|
|
||||||
|
### 3.1 Locais / Unidades
|
||||||
|
- ID_Local
|
||||||
|
- Nome
|
||||||
|
- Endereço
|
||||||
|
- Centro de Custo
|
||||||
|
- Responsável pelo Centro de Custo
|
||||||
|
|
||||||
|
**Regra:** Toda demanda deve estar vinculada a um Local e Centro de Custo.
|
||||||
|
|
||||||
|
### 3.2 Categorias e Subcategorias de Serviço
|
||||||
|
- ID_Categoria
|
||||||
|
- Nome
|
||||||
|
- Subcategoria
|
||||||
|
- Criticidade Padrão
|
||||||
|
- SLA Padrão
|
||||||
|
|
||||||
|
### 3.3 Fornecedores
|
||||||
|
- Tipo Pessoa (Física / Jurídica)
|
||||||
|
- CPF / CNPJ
|
||||||
|
- Categorias Atendidas
|
||||||
|
- Rating Facilities (1 a 5)
|
||||||
|
- Certidões Obrigatórias
|
||||||
|
- Status das Certidões
|
||||||
|
|
||||||
|
**Regra:** Fornecedor com certidão vencida não pode receber OS.
|
||||||
|
|
||||||
|
### 3.4 Orçamento Planejado
|
||||||
|
- Ano / Mês
|
||||||
|
- Centro de Custo
|
||||||
|
- Categoria
|
||||||
|
- Valor Planejado
|
||||||
|
- Valor Comprometido (calculado)
|
||||||
|
- Valor Realizado (calculado)
|
||||||
|
|
||||||
|
### 3.5 Matriz de Aprovação (Alçadas)
|
||||||
|
- Centro de Custo
|
||||||
|
- Valor Mínimo
|
||||||
|
- Valor Máximo
|
||||||
|
- Perfil Aprovador
|
||||||
|
- Ordem Sequencial (se aplicável)
|
||||||
|
|
||||||
|
## 4. Gestão de Demandas
|
||||||
|
|
||||||
|
### 4.1 Abertura da Demanda
|
||||||
|
Campos principais:
|
||||||
|
- Título da Demanda
|
||||||
|
- Descrição
|
||||||
|
- Local
|
||||||
|
- Centro de Custo
|
||||||
|
- Categoria / Subcategoria
|
||||||
|
- Criticidade
|
||||||
|
- Data Desejada
|
||||||
|
- Upload de documentos (plantas, fotos, laudos)
|
||||||
|
|
||||||
|
### 4.2 Definição de Escopo
|
||||||
|
- Criação de Itens de Linha obrigatórios
|
||||||
|
- Ex: Mão de Obra, Material, Equipamento
|
||||||
|
- Quantidade esperada (opcional)
|
||||||
|
- Observações técnicas
|
||||||
|
|
||||||
|
**Validações:**
|
||||||
|
- Não permitir publicação sem itens de linha
|
||||||
|
- Não permitir publicação sem CC e Local
|
||||||
|
|
||||||
|
## 5. Recebimento e Leitura de Propostas
|
||||||
|
|
||||||
|
### 5.1 Upload de Propostas
|
||||||
|
- Upload de PDF pelo fornecedor
|
||||||
|
- Versionamento automático (V1, V2, V3…)
|
||||||
|
|
||||||
|
### 5.2 OCR e Extração Inteligente
|
||||||
|
Campos extraídos:
|
||||||
|
- Valor Bruto
|
||||||
|
- Valor Líquido
|
||||||
|
- Impostos (ISS, INSS, PCC)
|
||||||
|
- Condição de Pagamento
|
||||||
|
- Prazo de Execução
|
||||||
|
- Data Estimada de Entrega
|
||||||
|
|
||||||
|
O sistema deve:
|
||||||
|
- Mapear itens da proposta com os itens de linha do escopo
|
||||||
|
- Calcular Match de Escopo (%)
|
||||||
|
- Indicar nível de confiabilidade da extração
|
||||||
|
|
||||||
|
## 6. Comparação e Equalização de Propostas
|
||||||
|
|
||||||
|
### 6.1 Painel Comparativo
|
||||||
|
- Visualização em grid das propostas
|
||||||
|
- Comparação por item, valor total, impostos e prazo
|
||||||
|
- Destaque da proposta benchmark (menor valor)
|
||||||
|
|
||||||
|
### 6.2 Deep Dive
|
||||||
|
- Clique em qualquer campo abre o trecho original do PDF
|
||||||
|
- Destaque visual da origem do dado
|
||||||
|
|
||||||
|
### 6.3 Anotações
|
||||||
|
- Comentários privados (internos)
|
||||||
|
- Comentários públicos (questionamentos ao fornecedor)
|
||||||
|
- Registro com data, hora e usuário
|
||||||
|
|
||||||
|
## 7. Controle Orçamentário
|
||||||
|
- Verificação automática de orçamento disponível
|
||||||
|
- Alertas de estouro (>20%)
|
||||||
|
- Bloqueio ou solicitação de revisão de escopo
|
||||||
|
|
||||||
|
## 8. Workflow de Aprovação
|
||||||
|
|
||||||
|
### 8.1 Regras Gerais
|
||||||
|
- Aprovação baseada no valor final da proposta selecionada
|
||||||
|
- Consulta automática à matriz de alçada
|
||||||
|
|
||||||
|
### 8.2 Modelo Híbrido
|
||||||
|
- Até o limite da alçada → aprovação automática
|
||||||
|
- Acima do limite → fluxo sequencial: Facilities → Financeiro → Diretoria
|
||||||
|
|
||||||
|
### 8.3 Exceções
|
||||||
|
- Demandas críticas/emergenciais
|
||||||
|
- Aprovação com ressalva (justificativa obrigatória)
|
||||||
|
|
||||||
|
## 9. Ordem de Serviço (OS)
|
||||||
|
- Geração automática após aprovação
|
||||||
|
- Bloqueio se fornecedor estiver irregular
|
||||||
|
- Registro de valores comprometidos no orçamento
|
||||||
|
|
||||||
|
## 10. Encerramento e Avaliação
|
||||||
|
- Confirmação da execução
|
||||||
|
- Avaliação do fornecedor
|
||||||
|
- Atualização do rating
|
||||||
|
- Consolidação do valor realizado
|
||||||
|
|
||||||
|
## 11. Relatórios, Auditoria e Analytics
|
||||||
|
- Consumo orçamentário por CC e Categoria
|
||||||
|
- Saving gerado
|
||||||
|
- Histórico completo de decisões
|
||||||
|
- Lead time por status
|
||||||
|
- Gargalos de aprovação
|
||||||
|
- Exportação para PDF e Excel
|
||||||
|
|
||||||
|
## 12. Requisitos Não Funcionais (Resumo)
|
||||||
|
- Controle de acesso por perfil
|
||||||
|
- Logs de auditoria
|
||||||
|
- Interface responsiva
|
||||||
|
- LGPD (tratamento de documentos e dados)
|
||||||
BIN
docs/MANUAL-NEGOCIOS-v2.pdf
Normal file
BIN
docs/MANUAL-NEGOCIOS-v2.pdf
Normal file
Binary file not shown.
1099
docs/MANUAL-NEGOCIOS.html
Normal file
1099
docs/MANUAL-NEGOCIOS.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Sistema de Controle Orçamentário para Facilities**
|
**Sistema de Controle Orçamentário para Facilities**
|
||||||
|
|
||||||
Versão 1.0 | Fevereiro 2025
|
Versão 2.0 | Fevereiro 2025
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -190,6 +190,53 @@ Solicitante Gestor Fac. Fornecedor Financ./Diret.
|
|||||||
- Tempo médio de ciclo por etapa
|
- Tempo médio de ciclo por etapa
|
||||||
- Exportação em PDF e Excel
|
- Exportação em PDF e Excel
|
||||||
|
|
||||||
|
### 5.9 ESG / Sustentabilidade 🌱
|
||||||
|
Funcionalidades inspiradas no **SAP Sustainability Control Tower**, entregues a uma fração do custo.
|
||||||
|
|
||||||
|
- **Métricas ambientais:** Registro e acompanhamento de consumo de energia (kWh), água (m³), resíduos (kg) e emissões de CO₂ (tCO₂e) por unidade
|
||||||
|
- **Dashboard ESG consolidado:** Visão por unidade e período com gráficos de tendência
|
||||||
|
- **Metas ESG:** Definição de metas ambientais com acompanhamento de progresso e status (em andamento, atingida, atrasada)
|
||||||
|
- **Relatórios para compliance:** Dados prontos para relatórios de sustentabilidade corporativa e auditorias ambientais
|
||||||
|
- Ideal para empresas do **agronegócio** que precisam demonstrar conformidade ESG a investidores e certificadoras
|
||||||
|
|
||||||
|
### 5.10 KPIs — Indicadores de Performance 📊
|
||||||
|
- **Cálculo automático** dos principais indicadores: % orçamento consumido, tempo médio de OS, rating médio de fornecedores, taxa de conclusão de demandas
|
||||||
|
- **Status semáforo** (verde/amarelo/vermelho) para identificação visual imediata de desvios
|
||||||
|
- Filtros por categoria, ano e centro de custo
|
||||||
|
- Dashboard dedicado com visão gerencial consolidada
|
||||||
|
|
||||||
|
### 5.11 Auditoria e Compliance 🔒
|
||||||
|
- **Trilha completa de auditoria:** Registro detalhado de quem fez o quê, quando, com dados antes e depois
|
||||||
|
- Relatório de conformidade por período para auditorias internas e externas
|
||||||
|
- Exportação de logs em CSV e JSON
|
||||||
|
- **Compliance LGPD:** Rastreabilidade total de acessos e alterações de dados pessoais
|
||||||
|
- Atende requisitos de governança corporativa e normas ISO
|
||||||
|
|
||||||
|
### 5.12 Importação de Dados (Excel/CSV) 📥
|
||||||
|
- Upload de planilhas com **validação automática** de dados
|
||||||
|
- Importação de orçamento planejado e demandas em massa
|
||||||
|
- Relatório detalhado de erros por linha, facilitando correção
|
||||||
|
- Ideal para migração de dados e carga inicial do sistema
|
||||||
|
|
||||||
|
### 5.13 Relatórios Automatizados 📈
|
||||||
|
- **Orçamento mensal:** Planejado vs. realizado com variações
|
||||||
|
- **Demandas por período:** Status, valores e responsáveis
|
||||||
|
- **Ranking de fornecedores:** Nota, volume e valor contratado
|
||||||
|
- **Performance de OS:** SLA, tempo médio e taxa de conclusão
|
||||||
|
- Exportação em JSON, CSV e PDF
|
||||||
|
|
||||||
|
### 5.14 Metas e Progresso 🎯
|
||||||
|
- Definição de metas por centro de custo: **orçamentárias**, **operacionais** e **ESG**
|
||||||
|
- Acompanhamento de % atingido em tempo real
|
||||||
|
- Status automático: em andamento, atingida, atrasada
|
||||||
|
- Visão consolidada de progresso para toda a organização
|
||||||
|
|
||||||
|
### 5.15 Alertas Inteligentes 🔔
|
||||||
|
- Configuração de limites por centro de custo e categoria
|
||||||
|
- **Tipos de alerta:** orçamento excedido, certidão vencendo, OS atrasada, meta em risco
|
||||||
|
- Verificação automática com notificação proativa aos responsáveis
|
||||||
|
- Prevenção de problemas antes que se tornem críticos
|
||||||
|
|
||||||
## 6. Modelo de Negócio
|
## 6. Modelo de Negócio
|
||||||
|
|
||||||
### 6.1 Formato
|
### 6.1 Formato
|
||||||
@@ -240,6 +287,40 @@ Sistema de avaliação baseado em critérios objetivos (prazo, qualidade, comuni
|
|||||||
### 7.6 Audit Trail Completo
|
### 7.6 Audit Trail Completo
|
||||||
Toda ação no sistema é registrada com timestamp, usuário, IP e dados antes/depois. Compliance total para auditorias.
|
Toda ação no sistema é registrada com timestamp, usuário, IP e dados antes/depois. Compliance total para auditorias.
|
||||||
|
|
||||||
|
### 7.7 ESG Reporting — Inspirado no SAP Sustainability Control Tower
|
||||||
|
Capacidade de monitoramento ambiental (energia, água, resíduos, emissões CO₂) com dashboard consolidado e metas — funcionalidades equivalentes ao SAP Sustainability Control Tower, a uma **fração do custo**. Ideal para facilities management no agronegócio, onde a demonstração de práticas ESG é cada vez mais exigida por investidores, bancos e certificadoras.
|
||||||
|
|
||||||
|
### 7.8 KPIs com Semáforo Inteligente
|
||||||
|
Indicadores calculados automaticamente com classificação visual verde/amarelo/vermelho, eliminando a necessidade de análise manual de planilhas. Gestores identificam desvios instantaneamente.
|
||||||
|
|
||||||
|
### 7.9 Compliance LGPD e Rastreabilidade Total
|
||||||
|
Trilha de auditoria completa com exportação para auditorias externas. Atende requisitos da Lei Geral de Proteção de Dados (LGPD) com registro detalhado de todo acesso e manipulação de dados pessoais. Relatórios de conformidade prontos para ISO 27001 e auditorias corporativas.
|
||||||
|
|
||||||
|
### 7.10 Importação Inteligente de Dados
|
||||||
|
Upload de planilhas Excel/CSV com validação automática e relatório de erros — migração de dados sem dor de cabeça. Empresas que usam planilhas podem adotar o HEFESTO sem perder dados históricos.
|
||||||
|
|
||||||
|
### 7.11 Alertas Proativos
|
||||||
|
Sistema de alertas inteligentes que notifica responsáveis **antes** de problemas acontecerem: orçamento prestes a estourar, certidões vencendo, OS atrasadas e metas em risco. Gestão preventiva, não reativa.
|
||||||
|
|
||||||
|
### 7.12 HEFESTO vs. SAP — Comparativo
|
||||||
|
|
||||||
|
| Funcionalidade | SAP | HEFESTO |
|
||||||
|
|---|---|---|
|
||||||
|
| Controle orçamentário | ✅ | ✅ |
|
||||||
|
| Workflow de aprovações | ✅ | ✅ |
|
||||||
|
| Dashboard ESG / Sustentabilidade | ✅ (Sustainability Control Tower) | ✅ |
|
||||||
|
| KPIs com semáforo | ✅ | ✅ |
|
||||||
|
| Auditoria e compliance | ✅ | ✅ |
|
||||||
|
| Relatórios automatizados | ✅ | ✅ |
|
||||||
|
| Metas e progresso | ✅ | ✅ |
|
||||||
|
| Alertas inteligentes | ✅ | ✅ |
|
||||||
|
| OCR em propostas | ❌ | ✅ |
|
||||||
|
| Equalização automática | ❌ | ✅ |
|
||||||
|
| Foco em Facilities | Genérico | ✅ Especializado |
|
||||||
|
| Tempo de implantação | 6–18 meses | 2–4 semanas |
|
||||||
|
| Custo de licença | Alto (6 dígitos/ano) | **Fração do custo** |
|
||||||
|
| Ideal para agro | Complexo demais | ✅ Perfeito |
|
||||||
|
|
||||||
## 8. Roadmap de Evolução
|
## 8. Roadmap de Evolução
|
||||||
|
|
||||||
### Fase 1 — MVP (Atual) ✅
|
### Fase 1 — MVP (Atual) ✅
|
||||||
@@ -250,6 +331,15 @@ Toda ação no sistema é registrada com timestamp, usuário, IP e dados antes/d
|
|||||||
- [x] Orçamento planejado vs. realizado
|
- [x] Orçamento planejado vs. realizado
|
||||||
- [x] Ordens de serviço
|
- [x] Ordens de serviço
|
||||||
|
|
||||||
|
### Fase 1.5 — Módulos SAP-Inspired (Fev 2025) ✅
|
||||||
|
- [x] ESG / Sustentabilidade (métricas ambientais e metas)
|
||||||
|
- [x] KPIs com status semáforo
|
||||||
|
- [x] Auditoria e Compliance avançado
|
||||||
|
- [x] Importação de dados Excel/CSV
|
||||||
|
- [x] Relatórios automatizados
|
||||||
|
- [x] Metas e Progresso por centro de custo
|
||||||
|
- [x] Alertas Inteligentes configuráveis
|
||||||
|
|
||||||
### Fase 2 — Q2 2025
|
### Fase 2 — Q2 2025
|
||||||
- [ ] OCR para extração de valores de propostas
|
- [ ] OCR para extração de valores de propostas
|
||||||
- [ ] App mobile (React Native) para aprovações rápidas
|
- [ ] App mobile (React Native) para aprovações rápidas
|
||||||
@@ -301,4 +391,4 @@ Toda ação no sistema é registrada com timestamp, usuário, IP e dados antes/d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Documento gerado automaticamente — HEFESTO v1.0*
|
*Documento gerado automaticamente — HEFESTO v2.0*
|
||||||
|
|||||||
Binary file not shown.
BIN
docs/MANUAL-TECNICO-v2.pdf
Normal file
BIN
docs/MANUAL-TECNICO-v2.pdf
Normal file
Binary file not shown.
2194
docs/MANUAL-TECNICO.html
Normal file
2194
docs/MANUAL-TECNICO.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Sistema de Controle Orçamentário para Facilities**
|
**Sistema de Controle Orçamentário para Facilities**
|
||||||
|
|
||||||
Versão 1.0 | Fevereiro 2025
|
Versão 2.0 | Fevereiro 2025
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -91,7 +91,14 @@ backend/
|
|||||||
│ ├── orcamento/ # Orçamento planejado vs realizado
|
│ ├── orcamento/ # Orçamento planejado vs realizado
|
||||||
│ ├── workflow/ # Máquina de estados de aprovação
|
│ ├── workflow/ # Máquina de estados de aprovação
|
||||||
│ ├── dashboard/ # Indicadores e relatórios
|
│ ├── dashboard/ # Indicadores e relatórios
|
||||||
│ └── ordens-servico/ # Emissão e acompanhamento de OS
|
│ ├── ordens-servico/ # Emissão e acompanhamento de OS
|
||||||
|
│ ├── esg/ # Métricas ESG e sustentabilidade
|
||||||
|
│ ├── kpis/ # Indicadores de performance
|
||||||
|
│ ├── audit/ # Auditoria e compliance avançado
|
||||||
|
│ ├── import/ # Importação de dados Excel/CSV
|
||||||
|
│ ├── relatorios/ # Relatórios automatizados
|
||||||
|
│ ├── metas/ # Metas e acompanhamento de progresso
|
||||||
|
│ └── alertas-inteligentes/ # Configuração e verificação de alertas
|
||||||
├── test/
|
├── test/
|
||||||
├── nest-cli.json
|
├── nest-cli.json
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
@@ -122,7 +129,13 @@ frontend/
|
|||||||
│ │ ├── Orcamentos.tsx # Orçamento planejado vs realizado
|
│ │ ├── Orcamentos.tsx # Orçamento planejado vs realizado
|
||||||
│ │ ├── OrdensServico.tsx # Ordens de serviço
|
│ │ ├── OrdensServico.tsx # Ordens de serviço
|
||||||
│ │ ├── Relatorios.tsx # Relatórios gerenciais
|
│ │ ├── Relatorios.tsx # Relatórios gerenciais
|
||||||
│ │ └── Usuarios.tsx # Administração de usuários
|
│ │ ├── Usuarios.tsx # Administração de usuários
|
||||||
|
│ │ ├── ESG.tsx # Dashboard ESG e métricas ambientais
|
||||||
|
│ │ ├── KPIs.tsx # Painel de indicadores de performance
|
||||||
|
│ │ ├── Auditoria.tsx # Logs de auditoria e compliance
|
||||||
|
│ │ ├── Importacao.tsx # Upload de planilhas Excel/CSV
|
||||||
|
│ │ ├── Metas.tsx # Metas e progresso por centro de custo
|
||||||
|
│ │ └── Alertas.tsx # Configuração de alertas inteligentes
|
||||||
│ ├── services/ # Axios clients e API calls
|
│ ├── services/ # Axios clients e API calls
|
||||||
│ │ └── api.ts
|
│ │ └── api.ts
|
||||||
│ ├── types/ # Interfaces TypeScript
|
│ ├── types/ # Interfaces TypeScript
|
||||||
@@ -137,7 +150,7 @@ frontend/
|
|||||||
|
|
||||||
### 4.1 Diagrama de Entidades
|
### 4.1 Diagrama de Entidades
|
||||||
|
|
||||||
O sistema possui 16 entidades principais:
|
O sistema possui 21 entidades principais:
|
||||||
|
|
||||||
```
|
```
|
||||||
perfis ──< usuarios ──< demandas ──< itens_linha
|
perfis ──< usuarios ──< demandas ──< itens_linha
|
||||||
@@ -158,6 +171,15 @@ fornecedores ──< certidoes
|
|||||||
fornecedores ──< propostas
|
fornecedores ──< propostas
|
||||||
|
|
||||||
alertas (standalone)
|
alertas (standalone)
|
||||||
|
|
||||||
|
locais ──< esg_metricas
|
||||||
|
locais ──< esg_metas
|
||||||
|
|
||||||
|
centros_custo ──< kpis
|
||||||
|
centros_custo ──< metas
|
||||||
|
|
||||||
|
centros_custo ──< alertas_config
|
||||||
|
categorias ──< alertas_config
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 Descrição das Entidades
|
### 4.2 Descrição das Entidades
|
||||||
@@ -180,6 +202,11 @@ alertas (standalone)
|
|||||||
| 14 | **avaliacoes** | Avaliação pós-execução | id, ordem_servico_id, avaliador_id, nota, comentario, created_at |
|
| 14 | **avaliacoes** | Avaliação pós-execução | id, ordem_servico_id, avaliador_id, nota, comentario, created_at |
|
||||||
| 15 | **audit_log** | Log de auditoria | id, usuario_id, acao, entidade, entidade_id, dados_antes, dados_depois, ip, created_at |
|
| 15 | **audit_log** | Log de auditoria | id, usuario_id, acao, entidade, entidade_id, dados_antes, dados_depois, ip, created_at |
|
||||||
| 16 | **alertas** | Notificações e alertas | id, usuario_id, tipo, mensagem, lido, referencia_tipo, referencia_id, created_at |
|
| 16 | **alertas** | Notificações e alertas | id, usuario_id, tipo, mensagem, lido, referencia_tipo, referencia_id, created_at |
|
||||||
|
| 17 | **esg_metricas** | Métricas ambientais ESG | id, local_id, tipo (energia/agua/residuos/emissoes_co2), valor, unidade_medida, periodo, observacoes, created_at |
|
||||||
|
| 18 | **esg_metas** | Metas ESG | id, local_id, tipo, descricao, valor_alvo, valor_atual, percentual_atingido, status, prazo, created_at |
|
||||||
|
| 19 | **kpis** | Indicadores de performance calculados | id, nome, valor, unidade, status_semaforo, centro_custo_id, periodo, calculated_at |
|
||||||
|
| 20 | **metas** | Metas por centro de custo | id, centro_custo_id, tipo (orcamento/operacional/esg), descricao, valor_alvo, valor_atual, percentual_atingido, status, prazo, created_at |
|
||||||
|
| 21 | **alertas_config** | Configuração de alertas inteligentes | id, tipo, centro_custo_id, categoria_id, limite_percentual, notificar_usuarios (JSON), ativo, created_at |
|
||||||
|
|
||||||
### 4.3 Perfis de Acesso (RBAC)
|
### 4.3 Perfis de Acesso (RBAC)
|
||||||
|
|
||||||
@@ -341,7 +368,245 @@ alertas (standalone)
|
|||||||
| PATCH | `/api/alertas/:id/lido` | Marcar como lido |
|
| PATCH | `/api/alertas/:id/lido` | Marcar como lido |
|
||||||
| DELETE | `/api/alertas/:id` | Remover alerta |
|
| DELETE | `/api/alertas/:id` | Remover alerta |
|
||||||
|
|
||||||
**Total: 68 endpoints**
|
### 5.14 ESG / Sustentabilidade (`/api/esg`)
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/esg/metricas` | Listar métricas ambientais (filtros: unidade, período, tipo) |
|
||||||
|
| POST | `/api/esg/metricas` | Registrar métrica ambiental |
|
||||||
|
| PATCH | `/api/esg/metricas/:id` | Atualizar métrica |
|
||||||
|
| DELETE | `/api/esg/metricas/:id` | Remover métrica |
|
||||||
|
| GET | `/api/esg/dashboard` | Dashboard consolidado ESG por unidade/período |
|
||||||
|
| GET | `/api/esg/metas` | Listar metas ESG |
|
||||||
|
| POST | `/api/esg/metas` | Criar meta ESG |
|
||||||
|
| PATCH | `/api/esg/metas/:id` | Atualizar meta ESG |
|
||||||
|
| GET | `/api/esg/metas/:id/progresso` | Progresso da meta ESG |
|
||||||
|
|
||||||
|
**Exemplo — POST `/api/esg/metricas`:**
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"local_id": 3,
|
||||||
|
"tipo": "energia",
|
||||||
|
"valor": 12500.50,
|
||||||
|
"unidade_medida": "kWh",
|
||||||
|
"periodo": "2025-06",
|
||||||
|
"observacoes": "Consumo sede administrativa"
|
||||||
|
}
|
||||||
|
// Response 201
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"local_id": 3,
|
||||||
|
"tipo": "energia",
|
||||||
|
"valor": 12500.50,
|
||||||
|
"unidade_medida": "kWh",
|
||||||
|
"periodo": "2025-06",
|
||||||
|
"created_at": "2025-06-30T14:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tipos de métricas suportados:** `energia` (kWh), `agua` (m³), `residuos` (kg), `emissoes_co2` (tCO₂e).
|
||||||
|
|
||||||
|
### 5.15 KPIs — Indicadores de Performance (`/api/kpis`)
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/kpis` | Listar KPIs calculados (filtros: categoria, ano, centro_custo) |
|
||||||
|
| GET | `/api/kpis/dashboard` | Dashboard de KPIs com status semáforo |
|
||||||
|
|
||||||
|
**KPIs calculados automaticamente:**
|
||||||
|
|
||||||
|
| KPI | Fórmula | Verde | Amarelo | Vermelho |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| % Orçamento Consumido | realizado / planejado × 100 | ≤ 80% | 81–100% | > 100% |
|
||||||
|
| Tempo Médio de OS | média(data_fim_real - data_inicio) | ≤ SLA | SLA+20% | > SLA+20% |
|
||||||
|
| Rating Fornecedores | média das avaliações | ≥ 4.0 | 3.0–3.9 | < 3.0 |
|
||||||
|
| Taxa Conclusão Demandas | concluídas / total × 100 | ≥ 90% | 70–89% | < 70% |
|
||||||
|
|
||||||
|
**Exemplo — GET `/api/kpis/dashboard`:**
|
||||||
|
```json
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"periodo": "2025-06",
|
||||||
|
"kpis": [
|
||||||
|
{
|
||||||
|
"nome": "orcamento_consumido",
|
||||||
|
"valor": 78.5,
|
||||||
|
"unidade": "%",
|
||||||
|
"status": "verde",
|
||||||
|
"centro_custo": "ADM-001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nome": "tempo_medio_os",
|
||||||
|
"valor": 12.3,
|
||||||
|
"unidade": "dias",
|
||||||
|
"status": "amarelo",
|
||||||
|
"centro_custo": "ADM-001"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.16 Auditoria e Compliance (`/api/audit`)
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/audit/logs` | Trilha de auditoria completa (filtros: usuario, entidade, período, ação) |
|
||||||
|
| GET | `/api/audit/compliance-report` | Relatório de conformidade por período |
|
||||||
|
| GET | `/api/audit/export` | Exportação de logs em CSV ou JSON (`?format=csv\|json`) |
|
||||||
|
|
||||||
|
**Exemplo — GET `/api/audit/logs?entidade=demandas&periodo_inicio=2025-06-01`:**
|
||||||
|
```json
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"total": 245,
|
||||||
|
"page": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1023,
|
||||||
|
"usuario": "joao.silva@empresa.com",
|
||||||
|
"acao": "UPDATE",
|
||||||
|
"entidade": "demandas",
|
||||||
|
"entidade_id": 87,
|
||||||
|
"dados_antes": { "status": "EM_COTACAO" },
|
||||||
|
"dados_depois": { "status": "PROPOSTAS_RECEBIDAS" },
|
||||||
|
"ip": "192.168.1.50",
|
||||||
|
"created_at": "2025-06-15T10:32:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.17 Importação de Dados (`/api/import`)
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/import/excel` | Upload de planilha Excel/CSV com validação automática |
|
||||||
|
|
||||||
|
**Tipos de importação:** `orcamento`, `demandas`.
|
||||||
|
|
||||||
|
**Exemplo — POST `/api/import/excel` (multipart/form-data):**
|
||||||
|
```json
|
||||||
|
// Request: file=planilha.xlsx, tipo=orcamento
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"registros_importados": 48,
|
||||||
|
"registros_com_erro": 2,
|
||||||
|
"erros": [
|
||||||
|
{ "linha": 15, "campo": "valor_planejado", "mensagem": "Valor inválido" },
|
||||||
|
{ "linha": 32, "campo": "centro_custo_id", "mensagem": "Centro de custo não encontrado" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.18 Relatórios Automatizados (`/api/relatorios`)
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/relatorios/orcamento-mensal` | Relatório de orçamento mensal (filtros: ano, mês, local) |
|
||||||
|
| GET | `/api/relatorios/demandas-periodo` | Demandas por período com status e valores |
|
||||||
|
| GET | `/api/relatorios/fornecedores-ranking` | Ranking de fornecedores com nota, valor e volume |
|
||||||
|
| GET | `/api/relatorios/os-performance` | Performance de OS (SLA, tempo médio, conclusão) |
|
||||||
|
|
||||||
|
**Todos os relatórios suportam `?format=json|csv|pdf`.**
|
||||||
|
|
||||||
|
**Exemplo — GET `/api/relatorios/fornecedores-ranking?ano=2025&limit=10`:**
|
||||||
|
```json
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"periodo": "2025",
|
||||||
|
"ranking": [
|
||||||
|
{
|
||||||
|
"posicao": 1,
|
||||||
|
"fornecedor": "TechServ Ltda",
|
||||||
|
"rating": 4.8,
|
||||||
|
"total_os": 23,
|
||||||
|
"valor_total": 187500.00,
|
||||||
|
"sla_cumprido": 95.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.19 Metas e Progresso (`/api/metas`)
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/metas` | Listar metas (filtros: centro_custo, tipo, status) |
|
||||||
|
| POST | `/api/metas` | Criar meta |
|
||||||
|
| PATCH | `/api/metas/:id` | Atualizar meta |
|
||||||
|
| DELETE | `/api/metas/:id` | Remover meta |
|
||||||
|
| GET | `/api/metas/progresso` | Progresso geral de todas as metas |
|
||||||
|
| GET | `/api/metas/:id/progresso` | Progresso de meta específica |
|
||||||
|
|
||||||
|
**Tipos de meta:** `orcamento`, `operacional`, `esg`.
|
||||||
|
|
||||||
|
**Status:** `em_andamento`, `atingida`, `atrasada`.
|
||||||
|
|
||||||
|
**Exemplo — POST `/api/metas`:**
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"centro_custo_id": 5,
|
||||||
|
"tipo": "orcamento",
|
||||||
|
"descricao": "Reduzir gastos com manutenção em 10%",
|
||||||
|
"valor_alvo": 90,
|
||||||
|
"unidade": "%",
|
||||||
|
"prazo": "2025-12-31"
|
||||||
|
}
|
||||||
|
// Response 201
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"centro_custo_id": 5,
|
||||||
|
"tipo": "orcamento",
|
||||||
|
"descricao": "Reduzir gastos com manutenção em 10%",
|
||||||
|
"valor_alvo": 90,
|
||||||
|
"valor_atual": 0,
|
||||||
|
"percentual_atingido": 0,
|
||||||
|
"status": "em_andamento",
|
||||||
|
"prazo": "2025-12-31",
|
||||||
|
"created_at": "2025-02-09T15:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.20 Alertas Inteligentes (`/api/alertas`)
|
||||||
|
|
||||||
|
> **Nota:** Os endpoints abaixo complementam os alertas básicos da seção 5.13 com configuração avançada e verificação automática.
|
||||||
|
|
||||||
|
| Método | Rota | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/alertas/configurar` | Configurar regra de alerta inteligente |
|
||||||
|
| GET | `/api/alertas/configurar` | Listar configurações de alertas |
|
||||||
|
| PATCH | `/api/alertas/configurar/:id` | Atualizar configuração |
|
||||||
|
| DELETE | `/api/alertas/configurar/:id` | Remover configuração |
|
||||||
|
| POST | `/api/alertas/verificar` | Disparar verificação manual de todos os alertas |
|
||||||
|
|
||||||
|
**Tipos de alerta:** `orcamento_excedido`, `certidao_vencendo`, `os_atrasada`, `meta_em_risco`.
|
||||||
|
|
||||||
|
**Exemplo — POST `/api/alertas/configurar`:**
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"tipo": "orcamento_excedido",
|
||||||
|
"centro_custo_id": 5,
|
||||||
|
"limite_percentual": 85,
|
||||||
|
"notificar_usuarios": [1, 3, 7],
|
||||||
|
"ativo": true
|
||||||
|
}
|
||||||
|
// Response 201
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"tipo": "orcamento_excedido",
|
||||||
|
"centro_custo_id": 5,
|
||||||
|
"limite_percentual": 85,
|
||||||
|
"notificar_usuarios": [1, 3, 7],
|
||||||
|
"ativo": true,
|
||||||
|
"created_at": "2025-02-09T15:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total: 95 endpoints**
|
||||||
|
|
||||||
## 6. Autenticação e Autorização
|
## 6. Autenticação e Autorização
|
||||||
|
|
||||||
@@ -505,4 +770,4 @@ pm2 startup
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Documento gerado automaticamente — HEFESTO v1.0*
|
*Documento gerado automaticamente — HEFESTO v2.0*
|
||||||
|
|||||||
Binary file not shown.
605
docs/generate-pdfs.py
Normal file
605
docs/generate-pdfs.py
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate beautiful PDF manuals for HEFESTO from Markdown sources."""
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
MANUALS = [
|
||||||
|
{
|
||||||
|
"md": "MANUAL-TECNICO.md",
|
||||||
|
"pdf": "MANUAL-TECNICO-v2.pdf",
|
||||||
|
"title": "Manual Técnico",
|
||||||
|
"subtitle": "Documentação Técnica Completa",
|
||||||
|
"version": "v2.0",
|
||||||
|
"type": "technical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"md": "MANUAL-NEGOCIOS.md",
|
||||||
|
"pdf": "MANUAL-NEGOCIOS-v2.pdf",
|
||||||
|
"title": "Manual de Negócios",
|
||||||
|
"subtitle": "Visão Comercial e Estratégica",
|
||||||
|
"version": "v2.0",
|
||||||
|
"type": "business"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 2cm 2cm 2.5cm 2cm;
|
||||||
|
@top-center {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
@bottom-center {
|
||||||
|
content: counter(page);
|
||||||
|
font-family: 'Inter', 'Segoe UI', sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@page :first {
|
||||||
|
margin: 0;
|
||||||
|
@bottom-center { content: ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #333;
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* COVER PAGE */
|
||||||
|
.cover {
|
||||||
|
page-break-after: always;
|
||||||
|
width: 210mm;
|
||||||
|
height: 297mm;
|
||||||
|
margin: -2cm;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(160deg, #0D1B2A 0%, #1A237E 40%, #1A237E 60%, #0D1B2A 100%);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 8px;
|
||||||
|
background: linear-gradient(90deg, #E65100, #FF8F00, #FFB300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 8px;
|
||||||
|
background: linear-gradient(90deg, #FFB300, #FF8F00, #E65100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .logo-icon {
|
||||||
|
font-size: 80pt;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
filter: drop-shadow(0 4px 20px rgba(255, 143, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .brand {
|
||||||
|
font-size: 42pt;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 12px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
background: linear-gradient(90deg, #FF8F00, #FFB300);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .divider {
|
||||||
|
width: 120px;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, transparent, #FF8F00, transparent);
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .manual-title {
|
||||||
|
font-size: 22pt;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255,255,255,0.95);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .manual-subtitle {
|
||||||
|
font-size: 12pt;
|
||||||
|
font-weight: 300;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .meta {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 50px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .company {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .date {
|
||||||
|
font-size: 10pt;
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GEOMETRIC DECORATIONS */
|
||||||
|
.cover .geo1 {
|
||||||
|
position: absolute;
|
||||||
|
top: 60px;
|
||||||
|
right: 60px;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border: 1px solid rgba(255,143,0,0.15);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .geo2 {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 120px;
|
||||||
|
left: 40px;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border: 1px solid rgba(255,143,0,0.1);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PAGE HEADER */
|
||||||
|
.page-header {
|
||||||
|
page-break-after: avoid;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 2px solid #E65100;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #999;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .left {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A237E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header .right {
|
||||||
|
color: #E65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TABLE OF CONTENTS */
|
||||||
|
.toc-page {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-page h2 {
|
||||||
|
font-size: 18pt;
|
||||||
|
color: #1A237E;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-page h2::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 50px;
|
||||||
|
height: 3px;
|
||||||
|
background: #E65100;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HEADINGS */
|
||||||
|
h1 {
|
||||||
|
font-size: 22pt;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1A237E;
|
||||||
|
margin-top: 40px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 3px solid #E65100;
|
||||||
|
page-break-after: avoid;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A237E;
|
||||||
|
margin-top: 35px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-left: 15px;
|
||||||
|
border-left: 4px solid #E65100;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #283593;
|
||||||
|
margin-top: 25px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #E65100;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FIRST H1 — remove top margin after cover */
|
||||||
|
.content > h1:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PARAGRAPHS */
|
||||||
|
p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: justify;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* STRONG/BOLD in special contexts */
|
||||||
|
strong {
|
||||||
|
color: #1A237E;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TABLES */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background: linear-gradient(135deg, #1A237E, #283593);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 9pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #E8EAF6;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(even) {
|
||||||
|
background-color: #F5F5FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background-color: #E8EAF6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkmark styling for comparison tables */
|
||||||
|
td:has(text("✅")), td:has(text("❌")) {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CODE BLOCKS */
|
||||||
|
pre {
|
||||||
|
background: #1E1E2E;
|
||||||
|
color: #CDD6F4;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 18px 0;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
border-left: 4px solid #E65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
p code, li code, td code {
|
||||||
|
background: #EDE7F6;
|
||||||
|
color: #4A148C;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LISTS */
|
||||||
|
ul, ol {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
li::marker {
|
||||||
|
color: #E65100;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BLOCKQUOTES / CALLOUTS */
|
||||||
|
blockquote {
|
||||||
|
background: linear-gradient(135deg, #FFF3E0, #FFF8E1);
|
||||||
|
border-left: 4px solid #FF8F00;
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-style: normal;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote p {
|
||||||
|
margin: 0;
|
||||||
|
color: #5D4037;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote strong {
|
||||||
|
color: #E65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HORIZONTAL RULES */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, #E65100, #FF8F00, transparent);
|
||||||
|
margin: 35px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LINKS */
|
||||||
|
a {
|
||||||
|
color: #1A237E;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid #E65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* INFO BOX */
|
||||||
|
.info-box {
|
||||||
|
background: #E3F2FD;
|
||||||
|
border-left: 4px solid #1565C0;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SUCCESS BOX */
|
||||||
|
.success-box {
|
||||||
|
background: #E8F5E9;
|
||||||
|
border-left: 4px solid #2E7D32;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WARNING BOX */
|
||||||
|
.warning-box {
|
||||||
|
background: #FFF3E0;
|
||||||
|
border-left: 4px solid #E65100;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PAGE BREAKS for major sections */
|
||||||
|
h1 {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1:first-of-type {
|
||||||
|
page-break-before: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FOOTER NOTE */
|
||||||
|
.doc-footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #E0E0E0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emoji sizing */
|
||||||
|
.emoji-icon {
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print optimizations */
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust: exact !important; }
|
||||||
|
.cover { page-break-after: always; }
|
||||||
|
h1, h2, h3 { page-break-after: avoid; }
|
||||||
|
table, pre, blockquote { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
COVER_HTML = """
|
||||||
|
<div class="cover">
|
||||||
|
<div class="geo1"></div>
|
||||||
|
<div class="geo2"></div>
|
||||||
|
<div class="logo-icon">🔥</div>
|
||||||
|
<div class="brand">HEFESTO</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="manual-title">{title}</div>
|
||||||
|
<div class="manual-subtitle">{subtitle}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="company">Kislanski Industries | AI Vertice</div>
|
||||||
|
<div class="date">Fevereiro 2026 · {version}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def generate_html(md_content, manual_info):
|
||||||
|
extensions = ['tables', 'fenced_code', 'toc', 'nl2br', 'sane_lists']
|
||||||
|
ext_configs = {
|
||||||
|
'toc': {'title': '', 'toc_depth': '1-3'}
|
||||||
|
}
|
||||||
|
|
||||||
|
md = markdown.Markdown(extensions=extensions, extension_configs=ext_configs)
|
||||||
|
html_body = md.convert(md_content)
|
||||||
|
toc_html = md.toc
|
||||||
|
|
||||||
|
cover = COVER_HTML.format(
|
||||||
|
title=manual_info['title'],
|
||||||
|
subtitle=manual_info['subtitle'],
|
||||||
|
version=manual_info['version']
|
||||||
|
)
|
||||||
|
|
||||||
|
header_title = f"HEFESTO — {manual_info['title']}"
|
||||||
|
|
||||||
|
full_html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HEFESTO - {manual_info['title']}</title>
|
||||||
|
<style>{CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{cover}
|
||||||
|
|
||||||
|
<div class="toc-page">
|
||||||
|
<h2>Sumário</h2>
|
||||||
|
{toc_html}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<span class="left">{header_title}</span>
|
||||||
|
<span class="right">{manual_info['version']}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{html_body}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="doc-footer">
|
||||||
|
HEFESTO {manual_info['version']} · Kislanski Industries | AI Vertice · Fevereiro 2026<br>
|
||||||
|
Documento confidencial — Todos os direitos reservados
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
return full_html
|
||||||
|
|
||||||
|
|
||||||
|
def generate_pdf(html_path, pdf_path):
|
||||||
|
cmd = [
|
||||||
|
'google-chrome',
|
||||||
|
'--headless',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-software-rasterizer',
|
||||||
|
f'--print-to-pdf={pdf_path}',
|
||||||
|
'--print-to-pdf-no-header',
|
||||||
|
'--no-margins',
|
||||||
|
f'file://{html_path}'
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
if result.returncode != 0:
|
||||||
|
# Try chromium as fallback
|
||||||
|
cmd[0] = 'chromium-browser'
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
return os.path.exists(pdf_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
docs_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
for manual in MANUALS:
|
||||||
|
md_path = os.path.join(docs_dir, manual['md'])
|
||||||
|
pdf_path = os.path.join(docs_dir, manual['pdf'])
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Generating: {manual['title']}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
# Read markdown
|
||||||
|
with open(md_path, 'r', encoding='utf-8') as f:
|
||||||
|
md_content = f.read()
|
||||||
|
|
||||||
|
# Generate HTML
|
||||||
|
html_content = generate_html(md_content, manual)
|
||||||
|
|
||||||
|
# Write temp HTML
|
||||||
|
html_path = os.path.join(docs_dir, manual['md'].replace('.md', '.html'))
|
||||||
|
with open(html_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html_content)
|
||||||
|
print(f" ✓ HTML generated: {html_path}")
|
||||||
|
|
||||||
|
# Generate PDF
|
||||||
|
if generate_pdf(html_path, pdf_path):
|
||||||
|
size_mb = os.path.getsize(pdf_path) / (1024*1024)
|
||||||
|
print(f" ✓ PDF generated: {pdf_path} ({size_mb:.1f} MB)")
|
||||||
|
else:
|
||||||
|
print(f" ✗ PDF generation failed!")
|
||||||
|
|
||||||
|
# Cleanup HTML
|
||||||
|
# os.remove(html_path)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print("Done!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="HEFESTO - Sistema de Controle Orçamentário para Facilities" />
|
<meta name="description" content="Nexus Facilities - Sistema de Controle Orçamentário para Facilities" />
|
||||||
<title>HEFESTO - Controle Orçamentário</title>
|
<title>Nexus Facilities</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import Orcamentos from './pages/Orcamentos'
|
|||||||
import OrdensServico from './pages/OrdensServico'
|
import OrdensServico from './pages/OrdensServico'
|
||||||
import Fornecedores from './pages/Fornecedores'
|
import Fornecedores from './pages/Fornecedores'
|
||||||
import Relatorios from './pages/Relatorios'
|
import Relatorios from './pages/Relatorios'
|
||||||
|
import ESG from './pages/ESG'
|
||||||
|
import KPIs from './pages/KPIs'
|
||||||
|
import Auditoria from './pages/Auditoria'
|
||||||
|
import Importacao from './pages/Importacao'
|
||||||
|
import Metas from './pages/Metas'
|
||||||
|
import AlertasConfig from './pages/AlertasConfig'
|
||||||
import Usuarios from './pages/Usuarios'
|
import Usuarios from './pages/Usuarios'
|
||||||
|
import Configuracao from './pages/Configuracao'
|
||||||
|
|
||||||
interface PrivateRouteProps {
|
interface PrivateRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -31,6 +38,13 @@ export default function App() {
|
|||||||
<Route path="ordens-servico" element={<OrdensServico />} />
|
<Route path="ordens-servico" element={<OrdensServico />} />
|
||||||
<Route path="fornecedores" element={<Fornecedores />} />
|
<Route path="fornecedores" element={<Fornecedores />} />
|
||||||
<Route path="relatorios" element={<Relatorios />} />
|
<Route path="relatorios" element={<Relatorios />} />
|
||||||
|
<Route path="esg" element={<ESG />} />
|
||||||
|
<Route path="kpis" element={<KPIs />} />
|
||||||
|
<Route path="auditoria" element={<Auditoria />} />
|
||||||
|
<Route path="importacao" element={<Importacao />} />
|
||||||
|
<Route path="metas" element={<Metas />} />
|
||||||
|
<Route path="alertas-config" element={<AlertasConfig />} />
|
||||||
|
<Route path="configuracao" element={<Configuracao />} />
|
||||||
<Route path="usuarios" element={<Usuarios />} />
|
<Route path="usuarios" element={<Usuarios />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
Flame,
|
Flame,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Bell,
|
Bell,
|
||||||
Search
|
Search,
|
||||||
|
Leaf,
|
||||||
|
Shield,
|
||||||
|
Upload,
|
||||||
|
Target,
|
||||||
|
Settings
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { User } from '../types'
|
import { User } from '../types'
|
||||||
|
|
||||||
@@ -32,6 +37,13 @@ const navItems: NavItem[] = [
|
|||||||
{ path: '/app/ordens-servico', label: 'Ordens de Serviço', icon: <ClipboardList className="w-5 h-5" /> },
|
{ path: '/app/ordens-servico', label: 'Ordens de Serviço', icon: <ClipboardList className="w-5 h-5" /> },
|
||||||
{ path: '/app/fornecedores', label: 'Fornecedores', icon: <Building2 className="w-5 h-5" /> },
|
{ path: '/app/fornecedores', label: 'Fornecedores', icon: <Building2 className="w-5 h-5" /> },
|
||||||
{ path: '/app/relatorios', label: 'Relatórios', icon: <BarChart3 className="w-5 h-5" /> },
|
{ path: '/app/relatorios', label: 'Relatórios', icon: <BarChart3 className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/esg', label: 'ESG', icon: <Leaf className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/kpis', label: 'KPIs', icon: <BarChart3 className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/metas', label: 'Metas', icon: <Target className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/auditoria', label: 'Auditoria', icon: <Shield className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/importacao', label: 'Importação', icon: <Upload className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/alertas-config', label: 'Alertas', icon: <Bell className="w-5 h-5" /> },
|
||||||
|
{ path: '/app/configuracao', label: 'Configuração', icon: <Settings className="w-5 h-5" /> },
|
||||||
{ path: '/app/usuarios', label: 'Usuários', icon: <Users className="w-5 h-5" />, adminOnly: true },
|
{ path: '/app/usuarios', label: 'Usuários', icon: <Users className="w-5 h-5" />, adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -74,7 +86,7 @@ export default function Layout() {
|
|||||||
<Flame className="w-6 h-6 text-white" />
|
<Flame className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<span className="font-bold text-white text-xl tracking-tight">HEFESTO</span>
|
<span className="font-bold text-white text-lg tracking-tight">Nexus Facilities</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -153,7 +165,7 @@ export default function Layout() {
|
|||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||||
<Flame className="w-6 h-6 text-white" />
|
<Flame className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-white text-xl">HEFESTO</span>
|
<span className="font-bold text-white text-lg">Nexus Facilities</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
|||||||
40
frontend/src/components/Modal.tsx
Normal file
40
frontend/src/components/Modal.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
onSubmit?: () => void
|
||||||
|
submitLabel?: string
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({ open, onClose, title, children, onSubmit, submitLabel = 'Salvar', loading }: ModalProps) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4" style={{ zIndex: 9999 }}>
|
||||||
|
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-fade-in max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border bg-gradient-to-r from-primary to-accent rounded-t-2xl">
|
||||||
|
<h2 className="text-xl font-semibold text-white">{title}</h2>
|
||||||
|
<button onClick={onClose} className="p-2 rounded-lg hover:bg-white/20 text-white">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{onSubmit && (
|
||||||
|
<div className="flex gap-3 p-6 pt-0">
|
||||||
|
<button type="button" onClick={onClose} className="btn-ghost flex-1">Cancelar</button>
|
||||||
|
<button type="button" onClick={onSubmit} disabled={loading} className="btn-primary flex-1">
|
||||||
|
{loading ? 'Salvando...' : submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
frontend/src/hooks/useLookups.ts
Normal file
62
frontend/src/hooks/useLookups.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
export type LookupMap = Record<string, string>
|
||||||
|
|
||||||
|
interface Lookups {
|
||||||
|
categoriaMap: LookupMap
|
||||||
|
centrosCustoMap: LookupMap
|
||||||
|
locaisMap: LookupMap
|
||||||
|
fornecedoresMap: LookupMap
|
||||||
|
categorias: { id: string; nome: string }[]
|
||||||
|
centrosCusto: { id: string; nome: string }[]
|
||||||
|
locais: { id: string; nome: string }[]
|
||||||
|
fornecedores: { id: string; nome: string }[]
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMap(items: any[], nameField: string = 'nome'): LookupMap {
|
||||||
|
const map: LookupMap = {}
|
||||||
|
for (const item of items) {
|
||||||
|
map[item.id] = item[nameField] || item.razao_social || item.nome_fantasia || item.id
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLookups(): Lookups {
|
||||||
|
const [categoriaMap, setCategoriaMap] = useState<LookupMap>({})
|
||||||
|
const [centrosCustoMap, setCentrosCustoMap] = useState<LookupMap>({})
|
||||||
|
const [locaisMap, setLocaisMap] = useState<LookupMap>({})
|
||||||
|
const [fornecedoresMap, setFornecedoresMap] = useState<LookupMap>({})
|
||||||
|
const [categorias, setCategorias] = useState<{ id: string; nome: string }[]>([])
|
||||||
|
const [centrosCusto, setCentrosCusto] = useState<{ id: string; nome: string }[]>([])
|
||||||
|
const [locais, setLocais] = useState<{ id: string; nome: string }[]>([])
|
||||||
|
const [fornecedores, setFornecedores] = useState<{ id: string; nome: string }[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.get('/categorias').catch(() => ({ data: [] })),
|
||||||
|
api.get('/centros-custo').catch(() => ({ data: [] })),
|
||||||
|
api.get('/locais').catch(() => ({ data: [] })),
|
||||||
|
api.get('/fornecedores').catch(() => ({ data: [] })),
|
||||||
|
]).then(([catRes, ccRes, locRes, fornRes]) => {
|
||||||
|
const cats = catRes.data || []
|
||||||
|
const ccs = ccRes.data || []
|
||||||
|
const locs = locRes.data || []
|
||||||
|
const forns = fornRes.data || []
|
||||||
|
|
||||||
|
setCategorias(cats.map((c: any) => ({ id: c.id, nome: c.nome })))
|
||||||
|
setCentrosCusto(ccs.map((c: any) => ({ id: c.id, nome: c.nome })))
|
||||||
|
setLocais(locs.map((l: any) => ({ id: l.id, nome: l.nome })))
|
||||||
|
setFornecedores(forns.map((f: any) => ({ id: f.id, nome: f.razao_social || f.nome_fantasia || f.nome })))
|
||||||
|
|
||||||
|
setCategoriaMap(toMap(cats))
|
||||||
|
setCentrosCustoMap(toMap(ccs))
|
||||||
|
setLocaisMap(toMap(locs))
|
||||||
|
setFornecedoresMap(toMap(forns, 'razao_social'))
|
||||||
|
}).finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { categoriaMap, centrosCustoMap, locaisMap, fornecedoresMap, categorias, centrosCusto, locais, fornecedores, loading }
|
||||||
|
}
|
||||||
178
frontend/src/pages/AlertasConfig.tsx
Normal file
178
frontend/src/pages/AlertasConfig.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Bell, Loader2, Plus, X, Zap, CheckCircle, AlertTriangle } from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
interface AlertConfig {
|
||||||
|
id: number
|
||||||
|
tipo: string
|
||||||
|
limite_percentual: number
|
||||||
|
centro_custo: string
|
||||||
|
ativo: boolean
|
||||||
|
criado_em?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertasConfig() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [configs, setConfigs] = useState<AlertConfig[]>([])
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [verifying, setVerifying] = useState(false)
|
||||||
|
const [verifyResult, setVerifyResult] = useState<any>(null)
|
||||||
|
const [form, setForm] = useState({ tipo: 'orcamento_excedido', limite_percentual: 80, centro_custo: '' })
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/alertas/configs')
|
||||||
|
setConfigs(Array.isArray(data) ? data : data?.configs || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching alert configs:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.post('/alertas/configurar', form)
|
||||||
|
setShowForm(false)
|
||||||
|
setForm({ tipo: 'orcamento_excedido', limite_percentual: 80, centro_custo: '' })
|
||||||
|
fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating alert config:', err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVerify = async () => {
|
||||||
|
setVerifying(true)
|
||||||
|
setVerifyResult(null)
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/alertas/verificar')
|
||||||
|
setVerifyResult(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error verifying alerts:', err)
|
||||||
|
} finally {
|
||||||
|
setVerifying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (config: AlertConfig) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/alertas/configs/${config.id}`, { ...config, ativo: !config.ativo })
|
||||||
|
setConfigs(configs.map(c => c.id === config.id ? { ...c, ativo: !c.ativo } : c))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error toggling alert:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-96"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-2">
|
||||||
|
<Bell className="w-8 h-8 text-primary" /> Configuração de Alertas
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray mt-1">Configure alertas inteligentes para monitoramento proativo.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={handleVerify} disabled={verifying} className="btn-outline text-sm flex items-center gap-1">
|
||||||
|
{verifying ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />} Verificar Agora
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowForm(true)} className="btn-primary text-sm flex items-center gap-1">
|
||||||
|
<Plus className="w-4 h-4" /> Novo Alerta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verify Result */}
|
||||||
|
{verifyResult && (
|
||||||
|
<div className="card border border-blue-200 bg-blue-50">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle className="w-5 h-5 text-blue-500" />
|
||||||
|
<span className="font-medium text-text">Verificação concluída</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray">
|
||||||
|
{verifyResult.alertas_disparados ?? verifyResult.total ?? 0} alertas disparados.
|
||||||
|
{verifyResult.mensagem && ` ${verifyResult.mensagem}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="card border-2 border-primary/20">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-text">Novo Alerta</h3>
|
||||||
|
<button onClick={() => setShowForm(false)} className="p-1 rounded hover:bg-gray-100"><X className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4">
|
||||||
|
<select value={form.tipo} onChange={e => setForm({...form, tipo: e.target.value})} className="input-field">
|
||||||
|
<option value="orcamento_excedido">Orçamento Excedido</option>
|
||||||
|
<option value="demanda_atrasada">Demanda Atrasada</option>
|
||||||
|
<option value="os_vencida">OS Vencida</option>
|
||||||
|
<option value="contrato_vencendo">Contrato Vencendo</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" placeholder="Limite %" value={form.limite_percentual} onChange={e => setForm({...form, limite_percentual: Number(e.target.value)})} className="input-field" />
|
||||||
|
<input placeholder="Centro de Custo (opcional)" value={form.centro_custo} onChange={e => setForm({...form, centro_custo: e.target.value})} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<button onClick={handleCreate} disabled={saving} className="btn-primary mt-4 flex items-center gap-2 disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />} Criar Alerta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configs Table */}
|
||||||
|
{configs.length > 0 ? (
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="table-header">
|
||||||
|
<th className="table-cell text-left">Tipo</th>
|
||||||
|
<th className="table-cell text-left">Limite %</th>
|
||||||
|
<th className="table-cell text-left">Centro de Custo</th>
|
||||||
|
<th className="table-cell text-center">Ativo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{configs.map(config => (
|
||||||
|
<tr key={config.id} className="table-row">
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className={`w-4 h-4 ${config.ativo ? 'text-amber-500' : 'text-gray-300'}`} />
|
||||||
|
<span className="text-sm font-medium capitalize">{config.tipo.replace(/_/g, ' ')}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell text-sm">{config.limite_percentual}%</td>
|
||||||
|
<td className="table-cell text-sm text-gray">{config.centro_custo || '—'}</td>
|
||||||
|
<td className="table-cell text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(config)}
|
||||||
|
className={`w-12 h-6 rounded-full transition-all relative ${config.ativo ? 'bg-green-500' : 'bg-gray-300'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all ${config.ativo ? 'left-6' : 'left-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<Bell className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray">Nenhum alerta configurado</h3>
|
||||||
|
<p className="text-sm text-gray-light mt-1">Configure alertas para receber notificações proativas.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
frontend/src/pages/Auditoria.tsx
Normal file
156
frontend/src/pages/Auditoria.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Shield, Loader2, Download, Search, FileText, AlertTriangle, CheckCircle } from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
id: number
|
||||||
|
timestamp: string
|
||||||
|
usuario: string
|
||||||
|
entidade: string
|
||||||
|
acao: string
|
||||||
|
detalhes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComplianceReport {
|
||||||
|
total_logs: number
|
||||||
|
acoes_criticas: number
|
||||||
|
usuarios_ativos: number
|
||||||
|
entidades_auditadas: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Auditoria() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||||
|
const [compliance, setCompliance] = useState<ComplianceReport | null>(null)
|
||||||
|
const [entity, setEntity] = useState('')
|
||||||
|
const [action, setAction] = useState('')
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [entity, action, dateFrom, dateTo])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (entity) params.entity = entity
|
||||||
|
if (action) params.action = action
|
||||||
|
if (dateFrom) params.date_from = dateFrom
|
||||||
|
if (dateTo) params.date_to = dateTo
|
||||||
|
const [logsRes, compRes] = await Promise.all([
|
||||||
|
api.get('/audit/logs', { params }),
|
||||||
|
api.get('/audit/compliance-report')
|
||||||
|
])
|
||||||
|
setLogs(logsRes.data?.logs || logsRes.data || [])
|
||||||
|
setCompliance(compRes.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching audit data:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = (format: string) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
window.open(`/api/audit/export?format=${format}&token=${token}`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-2">
|
||||||
|
<Shield className="w-8 h-8 text-secondary" /> Auditoria & Compliance
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray mt-1">Registro de ações e conformidade do sistema.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => handleExport('csv')} className="btn-outline text-sm flex items-center gap-1">
|
||||||
|
<Download className="w-4 h-4" /> CSV
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleExport('json')} className="btn-outline text-sm flex items-center gap-1">
|
||||||
|
<Download className="w-4 h-4" /> JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compliance Summary */}
|
||||||
|
{compliance && (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ title: 'Total de Logs', value: compliance.total_logs, icon: <FileText className="w-5 h-5" />, color: 'from-blue-500 to-blue-600' },
|
||||||
|
{ title: 'Ações Críticas', value: compliance.acoes_criticas, icon: <AlertTriangle className="w-5 h-5" />, color: 'from-red-500 to-red-600' },
|
||||||
|
{ title: 'Usuários Ativos', value: compliance.usuarios_ativos, icon: <CheckCircle className="w-5 h-5" />, color: 'from-green-500 to-green-600' },
|
||||||
|
{ title: 'Entidades Auditadas', value: compliance.entidades_auditadas, icon: <Shield className="w-5 h-5" />, color: 'from-purple-500 to-purple-600' },
|
||||||
|
].map((c, i) => (
|
||||||
|
<div key={i} className="card">
|
||||||
|
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${c.color} flex items-center justify-center text-white mb-3`}>
|
||||||
|
{c.icon}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray">{c.title}</p>
|
||||||
|
<p className="text-2xl font-bold text-text">{c.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<input type="text" placeholder="Filtrar entidade..." value={entity} onChange={e => setEntity(e.target.value)} className="input-field text-sm" />
|
||||||
|
<input type="text" placeholder="Filtrar ação..." value={action} onChange={e => setAction(e.target.value)} className="input-field text-sm" />
|
||||||
|
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="input-field text-sm" />
|
||||||
|
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="input-field text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs Table */}
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
{Array.isArray(logs) && logs.length > 0 ? (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="table-header">
|
||||||
|
<th className="table-cell text-left">Timestamp</th>
|
||||||
|
<th className="table-cell text-left">Usuário</th>
|
||||||
|
<th className="table-cell text-left">Entidade</th>
|
||||||
|
<th className="table-cell text-left">Ação</th>
|
||||||
|
<th className="table-cell text-left">Detalhes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map(log => (
|
||||||
|
<tr key={log.id} className="table-row">
|
||||||
|
<td className="table-cell text-sm">{new Date(log.timestamp).toLocaleString('pt-BR')}</td>
|
||||||
|
<td className="table-cell text-sm font-medium">{log.usuario}</td>
|
||||||
|
<td className="table-cell text-sm">{log.entidade}</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||||
|
log.acao === 'DELETE' ? 'badge-error' :
|
||||||
|
log.acao === 'CREATE' ? 'badge-success' :
|
||||||
|
log.acao === 'UPDATE' ? 'badge-warning' : 'badge-info'
|
||||||
|
}`}>{log.acao}</span>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell text-sm text-gray max-w-xs truncate">{log.detalhes}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray">Nenhum log encontrado</h3>
|
||||||
|
<p className="text-sm text-gray-light mt-1">Ajuste os filtros para visualizar registros de auditoria.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
432
frontend/src/pages/Configuracao.tsx
Normal file
432
frontend/src/pages/Configuracao.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Settings, Plus, Edit2, Trash2, Loader2, X, Save } from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
|
type Tab = 'categorias' | 'centros_custo' | 'subcategorias' | 'locais'
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string }[] = [
|
||||||
|
{ key: 'categorias', label: 'Categorias' },
|
||||||
|
{ key: 'centros_custo', label: 'Centros de Custo' },
|
||||||
|
{ key: 'subcategorias', label: 'Subcategorias' },
|
||||||
|
{ key: 'locais', label: 'Locais' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Configuracao() {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('categorias')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [items, setItems] = useState<any[]>([])
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState<any>({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
// For subcategorias
|
||||||
|
const [categorias, setCategorias] = useState<any[]>([])
|
||||||
|
|
||||||
|
const endpoints: Record<Tab, string> = {
|
||||||
|
categorias: '/categorias',
|
||||||
|
centros_custo: '/centros-custo',
|
||||||
|
subcategorias: '/subcategorias',
|
||||||
|
locais: '/locais',
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchItems(); fetchCategorias() }, [activeTab])
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(endpoints[activeTab])
|
||||||
|
setItems(data)
|
||||||
|
} catch { setItems([]) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchCategorias = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/categorias')
|
||||||
|
setCategorias(data)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmptyForm = (): any => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'categorias': return { nome: '', criticidade_padrao: 'media', sla_dias: 30, tipo_investimento: '', tipo_manutencao: '', impacto_ambiental_esperado: '', potencial_geracao_residuos: '' }
|
||||||
|
case 'centros_custo': return { codigo: '', nome: '' }
|
||||||
|
case 'subcategorias': return { nome: '', categoria_id: '' }
|
||||||
|
case 'locais': return { nome: '', endereco: '', tipo_operacao_local: '', classificacao_impacto_ambiental: '', praticas_sustentaveis: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openNew = () => { setEditId(null); setForm(getEmptyForm()); setShowModal(true) }
|
||||||
|
|
||||||
|
const openEdit = (item: any) => {
|
||||||
|
setEditId(item.id)
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'categorias':
|
||||||
|
setForm({ nome: item.nome, criticidade_padrao: item.criticidade_padrao || 'media', sla_dias: item.sla_dias || 30, tipo_investimento: item.tipo_investimento || '', tipo_manutencao: item.tipo_manutencao || '', impacto_ambiental_esperado: item.impacto_ambiental_esperado || '', potencial_geracao_residuos: item.potencial_geracao_residuos || '' })
|
||||||
|
break
|
||||||
|
case 'centros_custo':
|
||||||
|
setForm({ codigo: item.codigo || '', nome: item.nome })
|
||||||
|
break
|
||||||
|
case 'subcategorias':
|
||||||
|
setForm({ nome: item.nome, categoria_id: item.categoria_id || '' })
|
||||||
|
break
|
||||||
|
case 'locais':
|
||||||
|
setForm({ nome: item.nome, endereco: item.endereco || '', tipo_operacao_local: item.tipo_operacao_local || '', classificacao_impacto_ambiental: item.classificacao_impacto_ambiental || '', praticas_sustentaveis: item.praticas_sustentaveis || [] })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await api.patch(`${endpoints[activeTab]}/${editId}`, form)
|
||||||
|
} else {
|
||||||
|
await api.post(endpoints[activeTab], form)
|
||||||
|
}
|
||||||
|
setShowModal(false)
|
||||||
|
fetchItems()
|
||||||
|
} catch (err) {
|
||||||
|
alert('Erro ao salvar')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Tem certeza que deseja excluir?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`${endpoints[activeTab]}/${id}`)
|
||||||
|
fetchItems()
|
||||||
|
} catch { alert('Erro ao excluir') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const catMap: Record<string, string> = {}
|
||||||
|
categorias.forEach(c => { catMap[c.id] = c.nome })
|
||||||
|
|
||||||
|
const renderFormFields = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'categorias':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
||||||
|
<input type="text" value={form.nome} onChange={e => setForm({ ...form, nome: e.target.value })} className="input-field" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Tipo de Investimento *</label>
|
||||||
|
<select value={form.tipo_investimento} onChange={e => setForm({ ...form, tipo_investimento: e.target.value })} className="input-field" required>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
<option value="capex">Capex</option>
|
||||||
|
<option value="opex">Opex</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Criticidade Padrão</label>
|
||||||
|
<select value={form.criticidade_padrao} onChange={e => setForm({ ...form, criticidade_padrao: e.target.value })} className="input-field">
|
||||||
|
<option value="baixa">Baixa</option>
|
||||||
|
<option value="media">Média</option>
|
||||||
|
<option value="alta">Alta</option>
|
||||||
|
<option value="critica">Crítica</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">SLA (dias)</label>
|
||||||
|
<input type="number" value={form.sla_dias} onChange={e => setForm({ ...form, sla_dias: Number(e.target.value) })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full border-t border-border pt-3 mt-2">
|
||||||
|
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Tipo de Manutenção</label>
|
||||||
|
<select value={form.tipo_manutencao} onChange={e => setForm({ ...form, tipo_manutencao: e.target.value })} className="input-field">
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
<option value="Preventiva">Preventiva</option>
|
||||||
|
<option value="Corretiva">Corretiva</option>
|
||||||
|
<option value="Emergencial">Emergencial</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Impacto Ambiental Esperado</label>
|
||||||
|
<select value={form.impacto_ambiental_esperado} onChange={e => setForm({ ...form, impacto_ambiental_esperado: e.target.value })} className="input-field">
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
<option value="Baixo">Baixo</option>
|
||||||
|
<option value="Médio">Médio</option>
|
||||||
|
<option value="Alto">Alto</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Potencial Geração Resíduos</label>
|
||||||
|
<select value={form.potencial_geracao_residuos} onChange={e => setForm({ ...form, potencial_geracao_residuos: e.target.value })} className="input-field">
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
<option value="Baixo">Baixo</option>
|
||||||
|
<option value="Médio">Médio</option>
|
||||||
|
<option value="Alto">Alto</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'centros_custo':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Código</label>
|
||||||
|
<input type="text" value={form.codigo} onChange={e => setForm({ ...form, codigo: e.target.value })} className="input-field" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
||||||
|
<input type="text" value={form.nome} onChange={e => setForm({ ...form, nome: e.target.value })} className="input-field" required />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'subcategorias':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
||||||
|
<input type="text" value={form.nome} onChange={e => setForm({ ...form, nome: e.target.value })} className="input-field" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Categoria</label>
|
||||||
|
<select value={form.categoria_id} onChange={e => setForm({ ...form, categoria_id: e.target.value })} className="input-field" required>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{categorias.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'locais':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Nome</label>
|
||||||
|
<input type="text" value={form.nome} onChange={e => setForm({ ...form, nome: e.target.value })} className="input-field" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Endereço</label>
|
||||||
|
<input type="text" value={form.endereco} onChange={e => setForm({ ...form, endereco: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full border-t border-border pt-3 mt-2">
|
||||||
|
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Tipo de Operação</label>
|
||||||
|
<select value={form.tipo_operacao_local} onChange={e => setForm({ ...form, tipo_operacao_local: e.target.value })} className="input-field">
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
<option value="Administrativo">Administrativo</option>
|
||||||
|
<option value="Industrial">Industrial</option>
|
||||||
|
<option value="Logístico">Logístico</option>
|
||||||
|
<option value="Comercial">Comercial</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Classificação Impacto Ambiental</label>
|
||||||
|
<select value={form.classificacao_impacto_ambiental} onChange={e => setForm({ ...form, classificacao_impacto_ambiental: e.target.value })} className="input-field">
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
<option value="Baixo">Baixo</option>
|
||||||
|
<option value="Médio">Médio</option>
|
||||||
|
<option value="Alto">Alto</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Práticas Sustentáveis</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{['Coleta Seletiva', 'Reuso de Água', 'Energia Renovável', 'Compostagem', 'Redução de Plástico'].map(p => (
|
||||||
|
<label key={p} className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={(form.praticas_sustentaveis || []).includes(p)}
|
||||||
|
onChange={e => {
|
||||||
|
const curr = form.praticas_sustentaveis || []
|
||||||
|
setForm({ ...form, praticas_sustentaveis: e.target.checked ? [...curr, p] : curr.filter((x: string) => x !== p) })
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-green-600" />
|
||||||
|
{p}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderColumns = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'categorias':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<th className="table-cell">Nome</th>
|
||||||
|
<th className="table-cell">Tipo Invest.</th>
|
||||||
|
<th className="table-cell">Criticidade</th>
|
||||||
|
<th className="table-cell">SLA</th>
|
||||||
|
<th className="table-cell">Manutenção</th>
|
||||||
|
<th className="table-cell">Impacto Amb.</th>
|
||||||
|
<th className="table-cell text-center">Ações</th>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'centros_custo':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<th className="table-cell">Código</th>
|
||||||
|
<th className="table-cell">Nome</th>
|
||||||
|
<th className="table-cell text-center">Ações</th>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'subcategorias':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<th className="table-cell">Nome</th>
|
||||||
|
<th className="table-cell">Categoria</th>
|
||||||
|
<th className="table-cell text-center">Ações</th>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'locais':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<th className="table-cell">Nome</th>
|
||||||
|
<th className="table-cell">Endereço</th>
|
||||||
|
<th className="table-cell">Operação</th>
|
||||||
|
<th className="table-cell">Impacto Amb.</th>
|
||||||
|
<th className="table-cell text-center">Ações</th>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderRow = (item: any) => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'categorias':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className="table-cell font-medium">{item.nome}</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
{item.tipo_investimento ? (
|
||||||
|
<span className={`badge ${item.tipo_investimento === 'capex' ? 'badge-info' : 'badge-warning'}`}>
|
||||||
|
{item.tipo_investimento === 'capex' ? 'Capex' : 'Opex'}
|
||||||
|
</span>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">{item.criticidade_padrao || '-'}</td>
|
||||||
|
<td className="table-cell">{item.sla_dias} dias</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
{item.tipo_manutencao ? (
|
||||||
|
<span className={`badge ${item.tipo_manutencao === 'Emergencial' ? 'badge-error' : item.tipo_manutencao === 'Corretiva' ? 'badge-warning' : 'badge-success'}`}>
|
||||||
|
{item.tipo_manutencao}
|
||||||
|
</span>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
{item.impacto_ambiental_esperado ? (
|
||||||
|
<span className={`badge ${item.impacto_ambiental_esperado === 'Alto' ? 'badge-error' : item.impacto_ambiental_esperado === 'Médio' ? 'badge-warning' : 'badge-success'}`}>
|
||||||
|
{item.impacto_ambiental_esperado}
|
||||||
|
</span>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'centros_custo':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className="table-cell font-mono">{item.codigo}</td>
|
||||||
|
<td className="table-cell font-medium">{item.nome}</td>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'subcategorias':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className="table-cell font-medium">{item.nome}</td>
|
||||||
|
<td className="table-cell">{catMap[item.categoria_id] || '-'}</td>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'locais':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className="table-cell font-medium">{item.nome}</td>
|
||||||
|
<td className="table-cell">{item.endereco || '-'}</td>
|
||||||
|
<td className="table-cell">{item.tipo_operacao_local || '-'}</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
{item.classificacao_impacto_ambiental ? (
|
||||||
|
<span className={`badge ${item.classificacao_impacto_ambiental === 'Alto' ? 'badge-error' : item.classificacao_impacto_ambiental === 'Médio' ? 'badge-warning' : 'badge-success'}`}>
|
||||||
|
{item.classificacao_impacto_ambiental}
|
||||||
|
</span>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-3">
|
||||||
|
<Settings className="w-8 h-8 text-primary" /> Configuração
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray mt-1">Gerencie as tabelas de apoio do sistema</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={openNew} className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
|
<Plus className="w-5 h-5" />Novo Registro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-border overflow-x-auto">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium transition-all border-b-2 whitespace-nowrap ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-gray hover:text-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-48"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="card !p-0 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="table-header">
|
||||||
|
<tr>{renderColumns()}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(item => (
|
||||||
|
<tr key={item.id} className="table-row">
|
||||||
|
{renderRow(item)}
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button onClick={() => openEdit(item)} className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="text-center py-12"><p className="text-gray">Nenhum registro encontrado</p></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Modal open={showModal} onClose={() => setShowModal(false)} title={editId ? 'Editar' : 'Novo Registro'} onSubmit={handleSubmit} submitLabel={editId ? 'Salvar' : 'Criar'} loading={saving}>
|
||||||
|
{renderFormFields()}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Wallet,
|
Wallet,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -9,60 +9,76 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Loader2
|
Loader2,
|
||||||
|
Leaf,
|
||||||
|
Shield,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
Filter
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, LineChart, Line } from 'recharts'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { User } from '../types'
|
import { User, DashboardIndicadores } from '../types'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
interface StatsCard {
|
interface StatsCard {
|
||||||
title: string;
|
title: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
change?: string;
|
|
||||||
changeType?: 'positive' | 'negative' | 'neutral';
|
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
color: string;
|
color: string;
|
||||||
|
statusKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const monthlyData = [
|
type DashTab = 'geral' | 'esg'
|
||||||
{ name: 'Jan', previsto: 4000, realizado: 3800 },
|
|
||||||
{ name: 'Fev', previsto: 3500, realizado: 3200 },
|
|
||||||
{ name: 'Mar', previsto: 4200, realizado: 4100 },
|
|
||||||
{ name: 'Abr', previsto: 3800, realizado: 3900 },
|
|
||||||
{ name: 'Mai', previsto: 4500, realizado: 4200 },
|
|
||||||
{ name: 'Jun', previsto: 4000, realizado: 3700 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const categoryData = [
|
|
||||||
{ name: 'Manutenção', value: 35, color: '#E65100' },
|
|
||||||
{ name: 'Limpeza', value: 25, color: '#1A237E' },
|
|
||||||
{ name: 'Segurança', value: 20, color: '#FF8F00' },
|
|
||||||
{ name: 'Outros', value: 20, color: '#757575' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const recentActivities = [
|
|
||||||
{ id: 1, action: 'Nova demanda criada', description: 'Manutenção ar condicionado - Bloco A', time: 'Há 2 horas', status: 'pending' },
|
|
||||||
{ id: 2, action: 'Ordem de serviço aprovada', description: 'OS-2024-0156 - Troca de lâmpadas', time: 'Há 4 horas', status: 'success' },
|
|
||||||
{ id: 3, action: 'Fornecedor cadastrado', description: 'Tech Solutions Ltda', time: 'Há 6 horas', status: 'info' },
|
|
||||||
{ id: 4, action: 'Orçamento atualizado', description: 'Categoria: Manutenção Predial', time: 'Há 8 horas', status: 'warning' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [stats, setStats] = useState<any>(null)
|
const [stats, setStats] = useState<DashboardIndicadores | null>(null)
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState<DashTab>('geral')
|
||||||
|
const [esgData, setEsgData] = useState<any>(null)
|
||||||
|
const [esgLoading, setEsgLoading] = useState(false)
|
||||||
|
|
||||||
|
// Chart filter state
|
||||||
|
const [chartFilter, setChartFilter] = useState<'todos' | 'centro_custo' | 'categoria'>('todos')
|
||||||
|
const [chartFilterId, setChartFilterId] = useState('')
|
||||||
|
const [chartData, setChartData] = useState<any[]>([])
|
||||||
|
const [centrosCusto, setCentrosCusto] = useState<{id:string;nome:string}[]>([])
|
||||||
|
const [categorias, setCategorias] = useState<{id:string;nome:string}[]>([])
|
||||||
|
|
||||||
|
// Category chart data (from backend)
|
||||||
|
const [categoryData, setCategoryData] = useState<any[]>([])
|
||||||
|
|
||||||
|
// Card drill-down modal
|
||||||
|
const [drillModal, setDrillModal] = useState(false)
|
||||||
|
const [drillTitle, setDrillTitle] = useState('')
|
||||||
|
const [drillData, setDrillData] = useState<any[]>([])
|
||||||
|
const [drillLoading, setDrillLoading] = useState(false)
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<any>(null)
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userData = localStorage.getItem('user')
|
const userData = localStorage.getItem('user')
|
||||||
if (userData) {
|
if (userData) setUser(JSON.parse(userData))
|
||||||
setUser(JSON.parse(userData))
|
|
||||||
}
|
|
||||||
fetchDashboard()
|
fetchDashboard()
|
||||||
|
fetchLookups()
|
||||||
|
fetchCategoryData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'esg' && !esgData) fetchEsg()
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchChartData()
|
||||||
|
}, [chartFilter, chartFilterId])
|
||||||
|
|
||||||
const fetchDashboard = async () => {
|
const fetchDashboard = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/dashboard')
|
const { data } = await api.get('/dashboard/indicadores')
|
||||||
setStats(data)
|
setStats(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching dashboard:', err)
|
console.error('Error fetching dashboard:', err)
|
||||||
@@ -71,39 +87,93 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchLookups = async () => {
|
||||||
|
try {
|
||||||
|
const [catRes, ccRes] = await Promise.all([
|
||||||
|
api.get('/categorias').catch(() => ({ data: [] })),
|
||||||
|
api.get('/centros-custo').catch(() => ({ data: [] })),
|
||||||
|
])
|
||||||
|
setCategorias((catRes.data || []).map((c: any) => ({ id: c.id, nome: c.nome })))
|
||||||
|
setCentrosCusto((ccRes.data || []).map((c: any) => ({ id: c.id, nome: c.nome })))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchChartData = async () => {
|
||||||
|
try {
|
||||||
|
const params: any = { ano: 2026 }
|
||||||
|
if (chartFilter === 'centro_custo' && chartFilterId) params.centro_custo_id = chartFilterId
|
||||||
|
if (chartFilter === 'categoria' && chartFilterId) params.categoria_id = chartFilterId
|
||||||
|
const { data } = await api.get('/dashboard/consumo-orcamento', { params })
|
||||||
|
setChartData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching chart data:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchCategoryData = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/dashboard/categorias-quantidade')
|
||||||
|
setCategoryData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching category data:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchEsg = async () => {
|
||||||
|
setEsgLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/dashboard/esg')
|
||||||
|
setEsgData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching ESG:', err)
|
||||||
|
} finally {
|
||||||
|
setEsgLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardClick = async (statusKey: string, title: string) => {
|
||||||
|
setDrillTitle(title)
|
||||||
|
setDrillModal(true)
|
||||||
|
setDrillLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/dashboard/demandas-detalhe', { params: { status: statusKey } })
|
||||||
|
setDrillData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching drill-down:', err)
|
||||||
|
setDrillData([])
|
||||||
|
} finally {
|
||||||
|
setDrillLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async (term: string) => {
|
||||||
|
if (term.trim().length < 2) {
|
||||||
|
setSearchResults(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearchLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/dashboard/busca', { params: { q: term } })
|
||||||
|
setSearchResults(data)
|
||||||
|
} catch {
|
||||||
|
setSearchResults(null)
|
||||||
|
} finally {
|
||||||
|
setSearchLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => handleSearch(searchTerm), 400)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [searchTerm, handleSearch])
|
||||||
|
|
||||||
|
const formatCurrency = (v: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v || 0)
|
||||||
|
|
||||||
const statsCards: StatsCard[] = [
|
const statsCards: StatsCard[] = [
|
||||||
{
|
{ title: 'Demandas Abertas', value: stats?.demandas_abertas ?? 0, icon: <FileText className="w-6 h-6" />, color: 'from-primary to-accent', statusKey: 'abertas' },
|
||||||
title: 'Orçamento Total',
|
{ title: 'Em Cotação', value: stats?.em_cotacao ?? 0, icon: <Wallet className="w-6 h-6" />, color: 'from-secondary to-secondary-light', statusKey: 'em_cotacao' },
|
||||||
value: stats?.total_orcamento ? `${(stats.total_orcamento / 1000).toFixed(0)}K` : '0',
|
{ title: 'Em Aprovação', value: stats?.em_aprovacao ?? 0, icon: <Clock className="w-6 h-6" />, color: 'from-amber-500 to-orange-500', statusKey: 'em_aprovacao' },
|
||||||
change: '+12%',
|
{ title: 'OS Ativas', value: stats?.os_ativas ?? 0, icon: <TrendingUp className="w-6 h-6" />, color: 'from-green-500 to-emerald-500', statusKey: 'concluidas' },
|
||||||
changeType: 'positive',
|
|
||||||
icon: <Wallet className="w-6 h-6" />,
|
|
||||||
color: 'from-primary to-accent'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Total Gasto',
|
|
||||||
value: stats?.total_gasto ? `${(stats.total_gasto / 1000).toFixed(0)}K` : '0',
|
|
||||||
change: '-5%',
|
|
||||||
changeType: 'positive',
|
|
||||||
icon: <TrendingDown className="w-6 h-6" />,
|
|
||||||
color: 'from-secondary to-secondary-light'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Economia',
|
|
||||||
value: stats?.economia ? `${(stats.economia / 1000).toFixed(0)}K` : '0',
|
|
||||||
change: '+8%',
|
|
||||||
changeType: 'positive',
|
|
||||||
icon: <TrendingUp className="w-6 h-6" />,
|
|
||||||
color: 'from-green-500 to-emerald-500'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Pendências',
|
|
||||||
value: stats?.pendencias || stats?.demandas_pendentes || '0',
|
|
||||||
change: '-3',
|
|
||||||
changeType: 'neutral',
|
|
||||||
icon: <Clock className="w-6 h-6" />,
|
|
||||||
color: 'from-amber-500 to-orange-500'
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -114,9 +184,17 @@ export default function Dashboard() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const manutencaoPieData = esgData ? [
|
||||||
|
{ name: 'Preventiva', value: esgData.total_preventivas, color: '#1A7A4C' },
|
||||||
|
{ name: 'Corretiva', value: esgData.total_corretivas, color: '#FF8F00' },
|
||||||
|
{ name: 'Emergencial', value: esgData.total_emergenciais, color: '#E53935' },
|
||||||
|
] : []
|
||||||
|
|
||||||
|
const filterOptions = chartFilter === 'centro_custo' ? centrosCusto : chartFilter === 'categoria' ? categorias : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Welcome header */}
|
{/* Welcome header + Search */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">
|
||||||
@@ -124,32 +202,72 @@ export default function Dashboard() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray mt-1">Aqui está o resumo das suas operações de facilities.</p>
|
<p className="text-gray mt-1">Aqui está o resumo das suas operações de facilities.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray">
|
<div className="relative w-full sm:w-80">
|
||||||
<span>Última atualização:</span>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-light" />
|
||||||
<span className="font-medium text-text">Agora</span>
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar demandas..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="input-field pl-10 pr-8 !py-2 text-sm"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button onClick={() => { setSearchTerm(''); setSearchResults(null) }} className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<X className="w-4 h-4 text-gray" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Search Results Dropdown */}
|
||||||
|
{searchResults && (
|
||||||
|
<div className="absolute top-full mt-1 left-0 right-0 bg-white rounded-xl shadow-xl border border-border z-50 max-h-80 overflow-y-auto">
|
||||||
|
{searchResults.demandas?.length > 0 ? (
|
||||||
|
<div className="p-2">
|
||||||
|
<p className="text-xs text-gray px-2 py-1 font-semibold">Demandas</p>
|
||||||
|
{searchResults.demandas.map((d: any) => (
|
||||||
|
<div key={d.id} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||||
|
<FileText className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text truncate">{d.numero ? `#${d.numero} - ` : ''}{d.titulo}</p>
|
||||||
|
<p className="text-xs text-gray">{d.categoria} · {d.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-sm text-gray">Nenhum resultado encontrado</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 bg-gray-100 p-1 rounded-xl">
|
||||||
|
<button onClick={() => setActiveTab('geral')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all ${activeTab === 'geral' ? 'bg-white text-primary shadow-sm' : 'text-gray hover:text-text'}`}>
|
||||||
|
<TrendingUp className="w-4 h-4" /> Geral
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setActiveTab('esg')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all ${activeTab === 'esg' ? 'bg-white shadow-sm' : 'text-gray hover:text-text'}`}
|
||||||
|
style={activeTab === 'esg' ? { color: '#1A7A4C' } : {}}>
|
||||||
|
<Leaf className="w-4 h-4" /> ESG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'geral' ? (
|
||||||
|
<>
|
||||||
|
{/* Stats Cards - clickable */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||||
{statsCards.map((card, index) => (
|
{statsCards.map((card, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="card group hover:shadow-lg transition-all duration-300"
|
className="card group hover:shadow-lg transition-all duration-300 cursor-pointer"
|
||||||
|
onClick={() => handleCardClick(card.statusKey, card.title)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-white shadow-lg`}>
|
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-white shadow-lg`}>
|
||||||
{card.icon}
|
{card.icon}
|
||||||
</div>
|
</div>
|
||||||
{card.change && (
|
<ArrowUpRight className="w-4 h-4 text-gray-light opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
|
|
||||||
card.changeType === 'positive' ? 'bg-green-100 text-green-700' :
|
|
||||||
card.changeType === 'negative' ? 'bg-red-100 text-red-700' :
|
|
||||||
'bg-gray-100 text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{card.change}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray text-sm mb-1">{card.title}</p>
|
<p className="text-gray text-sm mb-1">{card.title}</p>
|
||||||
<p className="text-2xl sm:text-3xl font-bold text-text">{card.value}</p>
|
<p className="text-2xl sm:text-3xl font-bold text-text">{card.value}</p>
|
||||||
@@ -159,139 +277,284 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* Charts Row */}
|
{/* Charts Row */}
|
||||||
<div className="grid lg:grid-cols-3 gap-6">
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
{/* Bar Chart */}
|
|
||||||
<div className="lg:col-span-2 card">
|
<div className="lg:col-span-2 card">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-text">Orçamento vs Realizado</h2>
|
<h2 className="text-lg font-semibold text-text">Orçamento vs Realizado</h2>
|
||||||
<p className="text-sm text-gray">Comparativo mensal</p>
|
<p className="text-sm text-gray">Comparativo mensal</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="text-sm text-primary hover:underline flex items-center gap-1">
|
{/* Filter Controls */}
|
||||||
Ver detalhes
|
<div className="flex items-center gap-2">
|
||||||
<ArrowUpRight className="w-4 h-4" />
|
<Filter className="w-4 h-4 text-gray" />
|
||||||
</button>
|
<select
|
||||||
|
value={chartFilter}
|
||||||
|
onChange={(e) => { setChartFilter(e.target.value as any); setChartFilterId('') }}
|
||||||
|
className="input-field !py-1.5 !px-3 text-sm w-auto"
|
||||||
|
>
|
||||||
|
<option value="todos">Todos</option>
|
||||||
|
<option value="centro_custo">Centro de Custo</option>
|
||||||
|
<option value="categoria">Categoria</option>
|
||||||
|
</select>
|
||||||
|
{chartFilter !== 'todos' && (
|
||||||
|
<select
|
||||||
|
value={chartFilterId}
|
||||||
|
onChange={(e) => setChartFilterId(e.target.value)}
|
||||||
|
className="input-field !py-1.5 !px-3 text-sm w-auto max-w-[180px]"
|
||||||
|
>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{filterOptions.map(o => <option key={o.id} value={o.id}>{o.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-72">
|
<div className="h-72">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={monthlyData} barGap={8}>
|
<BarChart data={chartData} barGap={8}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
<Tooltip
|
<Tooltip contentStyle={{ backgroundColor: '#fff', border: '1px solid #E0E0E0', borderRadius: '12px', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}
|
||||||
contentStyle={{
|
formatter={(value: number) => formatCurrency(value)} />
|
||||||
backgroundColor: '#fff',
|
<Bar dataKey="planejado" fill="#1A237E" radius={[4, 4, 0, 0]} name="Planejado" />
|
||||||
border: '1px solid #E0E0E0',
|
|
||||||
borderRadius: '12px',
|
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="previsto" fill="#1A237E" radius={[4, 4, 0, 0]} name="Previsto" />
|
|
||||||
<Bar dataKey="realizado" fill="#E65100" radius={[4, 4, 0, 0]} name="Realizado" />
|
<Bar dataKey="realizado" fill="#E65100" radius={[4, 4, 0, 0]} name="Realizado" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pie Chart */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-text">Por Categoria</h2>
|
<h2 className="text-lg font-semibold text-text">Por Categoria</h2>
|
||||||
<p className="text-sm text-gray">Distribuição de gastos</p>
|
<p className="text-sm text-gray">Quantidade de demandas</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-60">
|
<div className="h-60">
|
||||||
|
{categoryData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie data={categoryData} cx="50%" cy="50%" innerRadius={50} outerRadius={80} paddingAngle={4} dataKey="value" label={({ name, value }) => `${value}`}>
|
||||||
data={categoryData}
|
{categoryData.map((entry: any, index: number) => (<Cell key={`cell-${index}`} fill={entry.color} />))}
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={50}
|
|
||||||
outerRadius={80}
|
|
||||||
paddingAngle={4}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{categoryData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
</Pie>
|
||||||
<Legend
|
<Legend verticalAlign="bottom" iconType="circle" iconSize={8} formatter={(value) => <span className="text-sm text-gray">{value}</span>} />
|
||||||
verticalAlign="bottom"
|
<Tooltip formatter={(value: number, name: string) => [`${value} demandas`, name]} />
|
||||||
iconType="circle"
|
|
||||||
iconSize={8}
|
|
||||||
formatter={(value) => <span className="text-sm text-gray">{value}</span>}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray text-sm">Sem dados</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats & Activity */}
|
{/* Quick Stats & Activity */}
|
||||||
<div className="grid lg:grid-cols-3 gap-6">
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 className="text-lg font-semibold text-text mb-4">Resumo Rápido</h2>
|
<h2 className="text-lg font-semibold text-text mb-4">Resumo Rápido</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center"><FileText className="w-5 h-5 text-blue-600" /></div>
|
||||||
<FileText className="w-5 h-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-text">Demandas Abertas</span>
|
<span className="font-medium text-text">Demandas Abertas</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-bold text-text">{stats?.demandas_pendentes || 12}</span>
|
<span className="text-xl font-bold text-text">{stats?.demandas_abertas ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center"><AlertCircle className="w-5 h-5 text-amber-600" /></div>
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
<span className="font-medium text-text">Pendentes</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-text">OS Concluídas</span>
|
<span className="text-xl font-bold text-text">{stats?.pendentes ?? 0}</span>
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold text-text">{stats?.ordens_concluidas || 48}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
<div className="flex items-center justify-between p-3 bg-card rounded-xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center"><AlertCircle className="w-5 h-5 text-red-600" /></div>
|
||||||
<Building2 className="w-5 h-5 text-purple-600" />
|
<span className="font-medium text-text">Alertas</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-text">Fornecedores Ativos</span>
|
<span className="text-xl font-bold text-text">{stats?.alertas ?? 0}</span>
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold text-text">{stats?.fornecedores_ativos || 15}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Activity */}
|
|
||||||
<div className="lg:col-span-2 card">
|
<div className="lg:col-span-2 card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-text">Atividade Recente</h2>
|
<h2 className="text-lg font-semibold text-text">Atividade Recente</h2>
|
||||||
<button className="text-sm text-primary hover:underline">Ver todas</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 text-center py-8 text-gray text-sm">
|
||||||
{recentActivities.map((activity) => (
|
<Clock className="w-10 h-10 mx-auto text-gray-300" />
|
||||||
<div key={activity.id} className="flex items-start gap-4 p-3 rounded-xl hover:bg-card transition-colors">
|
<p>Atividades recentes aparecerão aqui.</p>
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
|
||||||
activity.status === 'success' ? 'bg-green-100' :
|
|
||||||
activity.status === 'warning' ? 'bg-amber-100' :
|
|
||||||
activity.status === 'info' ? 'bg-blue-100' :
|
|
||||||
'bg-gray-100'
|
|
||||||
}`}>
|
|
||||||
{activity.status === 'success' ? <CheckCircle2 className="w-5 h-5 text-green-600" /> :
|
|
||||||
activity.status === 'warning' ? <AlertCircle className="w-5 h-5 text-amber-600" /> :
|
|
||||||
activity.status === 'info' ? <Building2 className="w-5 h-5 text-blue-600" /> :
|
|
||||||
<Clock className="w-5 h-5 text-gray-600" />}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium text-text">{activity.action}</p>
|
|
||||||
<p className="text-sm text-gray truncate">{activity.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-light whitespace-nowrap">{activity.time}</span>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* ESG Tab */
|
||||||
|
esgLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-48"><Loader2 className="w-8 h-8 animate-spin" style={{ color: '#1A7A4C' }} /></div>
|
||||||
|
) : esgData ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="card border-l-4" style={{ borderLeftColor: '#1A7A4C' }}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: '#1A7A4C20' }}>
|
||||||
|
<Leaf className="w-5 h-5" style={{ color: '#1A7A4C' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray">Preventivas</p>
|
||||||
|
<p className="text-2xl font-bold" style={{ color: '#1A7A4C' }}>{esgData.pct_preventivas}%</p>
|
||||||
|
<p className="text-xs text-gray">{esgData.total_preventivas} demandas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card border-l-4 border-l-amber-500">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-amber-50 flex items-center justify-center">
|
||||||
|
<AlertCircle className="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray">Corretivas</p>
|
||||||
|
<p className="text-2xl font-bold text-amber-600">{esgData.pct_corretivas}%</p>
|
||||||
|
<p className="text-xs text-gray">{esgData.total_corretivas} demandas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card border-l-4 border-l-red-500">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray">Emergenciais</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{esgData.pct_emergenciais}%</p>
|
||||||
|
<p className="text-xs text-gray">{esgData.total_emergenciais} demandas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card border-l-4" style={{ borderLeftColor: '#1A7A4C' }}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: '#1A7A4C20' }}>
|
||||||
|
<Shield className="w-5 h-5" style={{ color: '#1A7A4C' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray">Fornecedores ESG+</p>
|
||||||
|
<p className="text-2xl font-bold" style={{ color: '#1A7A4C' }}>{esgData.pct_fornecedores_esg_bom}%</p>
|
||||||
|
<p className="text-xs text-gray">{esgData.fornecedores_esg_intermediario_avancado}/{esgData.total_fornecedores}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-text mb-4">Tipo de Manutenção</h2>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={manutencaoPieData} cx="50%" cy="50%" innerRadius={50} outerRadius={80} paddingAngle={4} dataKey="value">
|
||||||
|
{manutencaoPieData.map((entry: any, index: number) => (<Cell key={`cell-${index}`} fill={entry.color} />))}
|
||||||
|
</Pie>
|
||||||
|
<Legend verticalAlign="bottom" iconType="circle" iconSize={8} formatter={(value) => <span className="text-sm text-gray">{value}</span>} />
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-text mb-4">Evolução Manutenção Preventiva</h2>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={esgData.evolucao_preventiva}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||||
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="preventivas" fill="#1A7A4C" radius={[4, 4, 0, 0]} name="Preventivas" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-text">Demandas com Alto Impacto Ambiental</h2>
|
||||||
|
<span className="badge badge-error">{esgData.demandas_alto_impacto_count}</span>
|
||||||
|
</div>
|
||||||
|
{esgData.demandas_alto_impacto.length === 0 ? (
|
||||||
|
<p className="text-gray text-sm">Nenhuma demanda com alto impacto ambiental.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{esgData.demandas_alto_impacto.map((d: any) => (
|
||||||
|
<div key={d.id} className="flex items-center gap-3 p-3 rounded-lg bg-red-50">
|
||||||
|
<FileText className="w-5 h-5 text-red-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-text">{d.numero ? `#${d.numero} - ` : ''}{d.titulo}</p>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-error text-xs">{d.status}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{esgData.fornecedores_esg_basico > 0 && (
|
||||||
|
<div className="card border-l-4 border-l-amber-500 bg-amber-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertCircle className="w-6 h-6 text-amber-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-text">Atenção: Fornecedores ESG Básico</p>
|
||||||
|
<p className="text-sm text-gray">{esgData.fornecedores_esg_basico} fornecedor(es) com classificação ESG Básico.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<Leaf className="w-16 h-16 mx-auto mb-4" style={{ color: '#1A7A4C20' }} />
|
||||||
|
<p className="text-gray">Dados ESG não disponíveis.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drill-down Modal */}
|
||||||
|
{drillModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4" style={{ zIndex: 9999 }}>
|
||||||
|
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-2xl animate-fade-in max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border bg-gradient-to-r from-primary to-accent rounded-t-2xl">
|
||||||
|
<h2 className="text-xl font-semibold text-white">{drillTitle}</h2>
|
||||||
|
<button onClick={() => setDrillModal(false)} className="p-2 rounded-lg hover:bg-white/20 text-white">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto flex-1">
|
||||||
|
{drillLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div>
|
||||||
|
) : drillData.length === 0 ? (
|
||||||
|
<p className="text-center text-gray py-8">Nenhuma demanda encontrada.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{drillData.map((d: any) => (
|
||||||
|
<div key={d.id} className="flex items-center gap-4 p-4 rounded-xl border border-border hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<FileText className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-text">{d.numero ? `#${d.numero} - ` : ''}{d.titulo}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-gray">
|
||||||
|
<span>{d.categoria}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{d.data ? new Date(d.data).toLocaleDateString('pt-BR') : '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0">
|
||||||
|
<span className="badge badge-info text-xs">{d.status}</span>
|
||||||
|
{d.valor_estimado != null && (
|
||||||
|
<p className="text-sm font-medium text-text mt-1">{formatCurrency(d.valor_estimado)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,54 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText, Search, Plus, Eye, Edit2, Trash2, X, Loader2,
|
||||||
Search,
|
AlertCircle, Clock, CheckCircle2, ChevronDown, PlayCircle,
|
||||||
Plus,
|
Upload, Download, Paperclip, File, ClipboardList, DollarSign
|
||||||
Filter,
|
|
||||||
Eye,
|
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
Clock,
|
|
||||||
CheckCircle2,
|
|
||||||
ChevronDown
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { Demanda } from '../types'
|
import { Demanda, Subcategoria, DocumentoFile, OrdemServico } from '../types'
|
||||||
|
import { useLookups } from '../hooks/useLookups'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
||||||
|
'aberta': { label: 'Aberta', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||||
|
'rascunho': { label: 'Rascunho', class: 'badge-neutral', icon: <Clock className="w-3 h-3" /> },
|
||||||
|
'em_escopo': { label: 'Em Escopo', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
|
'em_cotacao': { label: 'Em Cotação', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
|
'propostas_recebidas': { label: 'Propostas Recebidas', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
|
'em_aprovacao': { label: 'Em Aprovação', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||||
|
'aprovada': { label: 'Aprovada', class: 'badge-success', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
|
'em_execucao': { label: 'Em Execução', class: 'badge-info', icon: <PlayCircle className="w-3 h-3" /> },
|
||||||
|
'concluida': { label: 'Concluída', class: 'badge-neutral', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
|
'cancelada': { label: 'Cancelada', class: 'badge-error', icon: <X className="w-3 h-3" /> },
|
||||||
'pendente': { label: 'Pendente', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
'pendente': { label: 'Pendente', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||||
'em_analise': { label: 'Em Análise', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
'em_analise': { label: 'Em Análise', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
'aprovada': { label: 'Aprovada', class: 'badge-success', icon: <CheckCircle2 className="w-3 h-3" /> },
|
|
||||||
'rejeitada': { label: 'Rejeitada', class: 'badge-error', icon: <X className="w-3 h-3" /> },
|
'rejeitada': { label: 'Rejeitada', class: 'badge-error', icon: <X className="w-3 h-3" /> },
|
||||||
'concluida': { label: 'Concluída', class: 'badge-neutral', icon: <CheckCircle2 className="w-3 h-3" /> },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prioridadeConfig: Record<string, { label: string; class: string }> = {
|
const criticidadeConfig: Record<string, { label: string; class: string }> = {
|
||||||
'baixa': { label: 'Baixa', class: 'text-gray bg-gray-100' },
|
'baixa': { label: 'Baixa', class: 'text-gray bg-gray-100' },
|
||||||
'media': { label: 'Média', class: 'text-amber-700 bg-amber-100' },
|
'media': { label: 'Média', class: 'text-amber-700 bg-amber-100' },
|
||||||
'alta': { label: 'Alta', class: 'text-red-700 bg-red-100' },
|
'alta': { label: 'Alta', class: 'text-red-700 bg-red-100' },
|
||||||
'urgente': { label: 'Urgente', class: 'text-red-700 bg-red-200 animate-pulse' },
|
'urgente': { label: 'Urgente', class: 'text-red-700 bg-red-200 animate-pulse' },
|
||||||
|
'critica': { label: 'Crítica', class: 'text-red-700 bg-red-200 animate-pulse' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockDemandas: Demanda[] = [
|
const tipoDocIcon: Record<string, string> = {
|
||||||
{ id: 1, titulo: 'Manutenção Ar Condicionado', descricao: 'Ar condicionado do bloco A não está funcionando', status: 'pendente', prioridade: 'alta', solicitante_id: 1, solicitante_nome: 'Maria Silva', data_criacao: '2024-01-15' },
|
'planta': '📋',
|
||||||
{ id: 2, titulo: 'Troca de Lâmpadas', descricao: 'Lâmpadas queimadas no corredor do 3º andar', status: 'em_analise', prioridade: 'media', solicitante_id: 2, solicitante_nome: 'João Santos', data_criacao: '2024-01-14' },
|
'foto': '📸',
|
||||||
{ id: 3, titulo: 'Vazamento Banheiro', descricao: 'Vazamento na torneira do banheiro masculino', status: 'aprovada', prioridade: 'urgente', solicitante_id: 3, solicitante_nome: 'Ana Oliveira', data_criacao: '2024-01-13' },
|
'laudo': '📄',
|
||||||
{ id: 4, titulo: 'Pintura Sala Reunião', descricao: 'Paredes da sala de reunião precisam de pintura', status: 'concluida', prioridade: 'baixa', solicitante_id: 1, solicitante_nome: 'Maria Silva', data_criacao: '2024-01-10' },
|
'outro': '📎',
|
||||||
{ id: 5, titulo: 'Reparo Elevador', descricao: 'Elevador social com barulho estranho', status: 'pendente', prioridade: 'alta', solicitante_id: 4, solicitante_nome: 'Carlos Lima', data_criacao: '2024-01-16' },
|
}
|
||||||
]
|
|
||||||
|
const osStatusConfig: Record<string, { label: string; class: string }> = {
|
||||||
|
'emitida': { label: 'Emitida', class: 'badge-warning' },
|
||||||
|
'em_cotacao': { label: 'Em Cotação', class: 'badge-info' },
|
||||||
|
'em_execucao': { label: 'Em Execução', class: 'badge-info' },
|
||||||
|
'concluida': { label: 'Concluída', class: 'badge-success' },
|
||||||
|
'cancelada': { label: 'Cancelada', class: 'badge-error' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = { titulo: '', descricao: '', criticidade: 'media', categoria_id: '', subcategoria_id: '', local_id: '', centro_custo_id: '', data_desejada: '', valor_estimado: '', impacto_ambiental_demanda: '', justificativa_manutencao_emergencial: '' }
|
||||||
|
|
||||||
export default function Demandas() {
|
export default function Demandas() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -46,149 +56,280 @@ export default function Demandas() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [filterStatus, setFilterStatus] = useState('todos')
|
const [filterStatus, setFilterStatus] = useState('todos')
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [selectedDemanda, setSelectedDemanda] = useState<Demanda | null>(null)
|
const [showDetail, setShowDetail] = useState<Demanda | null>(null)
|
||||||
const [formData, setFormData] = useState({ titulo: '', descricao: '', prioridade: 'media' })
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [subcategorias, setSubcategorias] = useState<Subcategoria[]>([])
|
||||||
|
const [subcategoriaMap, setSubcategoriaMap] = useState<Record<string, string>>({})
|
||||||
|
const [allSubcategorias, setAllSubcategorias] = useState<Subcategoria[]>([])
|
||||||
|
// Documents
|
||||||
|
const [detailDocs, setDetailDocs] = useState<DocumentoFile[]>([])
|
||||||
|
const [docsLoading, setDocsLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadTipo, setUploadTipo] = useState('outro')
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [docCounts, setDocCounts] = useState<Record<string, number>>({})
|
||||||
|
// OS linked
|
||||||
|
const [detailOS, setDetailOS] = useState<OrdemServico[]>([])
|
||||||
|
const [osLoading, setOsLoading] = useState(false)
|
||||||
|
const [showOS, setShowOS] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const { categoriaMap, centrosCustoMap, locaisMap, categorias, centrosCusto, locais, fornecedoresMap, loading: lookupsLoading } = useLookups()
|
||||||
fetchDemandas()
|
|
||||||
}, [])
|
useEffect(() => { fetchDemandas(); fetchAllSubcategorias() }, [])
|
||||||
|
|
||||||
const fetchDemandas = async () => {
|
const fetchDemandas = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/demandas')
|
const { data } = await api.get('/demandas')
|
||||||
setDemandas(data.length > 0 ? data : mockDemandas)
|
setDemandas(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching demandas:', err)
|
console.error('Error fetching demandas:', err)
|
||||||
setDemandas(mockDemandas)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchAllSubcategorias = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/subcategorias')
|
||||||
|
setAllSubcategorias(data)
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
data.forEach((s: Subcategoria) => { map[s.id] = s.nome })
|
||||||
|
setSubcategoriaMap(map)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching subcategorias:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSubcategorias = async (categoriaId: string) => {
|
||||||
|
if (!categoriaId) { setSubcategorias([]); return }
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/subcategorias?categoria_id=${categoriaId}`)
|
||||||
|
setSubcategorias(data)
|
||||||
|
} catch (err) {
|
||||||
|
setSubcategorias([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDetailDocs = async (demandaId: string) => {
|
||||||
|
setDocsLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/demandas/${demandaId}/documentos`)
|
||||||
|
setDetailDocs(data)
|
||||||
|
} catch { setDetailDocs([]) }
|
||||||
|
finally { setDocsLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDetailOS = async (demandaId: string) => {
|
||||||
|
setOsLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/ordens-servico/by-demanda/${demandaId}`)
|
||||||
|
setDetailOS(data)
|
||||||
|
} catch { setDetailOS([]) }
|
||||||
|
finally { setOsLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (demandas.length === 0) return
|
||||||
|
const fetchCounts = async () => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
await Promise.all(demandas.map(async (d) => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/demandas/${d.id}/documentos`)
|
||||||
|
counts[d.id] = data.length
|
||||||
|
} catch { counts[d.id] = 0 }
|
||||||
|
}))
|
||||||
|
setDocCounts(counts)
|
||||||
|
}
|
||||||
|
fetchCounts()
|
||||||
|
}, [demandas])
|
||||||
|
|
||||||
const filteredDemandas = demandas.filter(demanda => {
|
const filteredDemandas = demandas.filter(demanda => {
|
||||||
const matchesSearch = demanda.titulo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = (demanda.titulo || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
demanda.descricao.toLowerCase().includes(searchTerm.toLowerCase())
|
(demanda.descricao || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
const matchesStatus = filterStatus === 'todos' || demanda.status === filterStatus
|
const matchesStatus = filterStatus === 'todos' || demanda.status === filterStatus
|
||||||
return matchesSearch && matchesStatus
|
return matchesSearch && matchesStatus
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleOpenModal = (demanda?: Demanda) => {
|
const openNew = () => { setEditId(null); setForm(emptyForm); setSubcategorias([]); setShowModal(true) }
|
||||||
if (demanda) {
|
const openEdit = (d: Demanda) => {
|
||||||
setSelectedDemanda(demanda)
|
setEditId(d.id)
|
||||||
setFormData({ titulo: demanda.titulo, descricao: demanda.descricao, prioridade: demanda.prioridade })
|
setForm({
|
||||||
} else {
|
titulo: d.titulo, descricao: d.descricao, criticidade: d.criticidade || d.prioridade || 'media',
|
||||||
setSelectedDemanda(null)
|
categoria_id: d.categoria_id || '', subcategoria_id: d.subcategoria_id || '',
|
||||||
setFormData({ titulo: '', descricao: '', prioridade: 'media' })
|
local_id: d.local_id || '', centro_custo_id: d.centro_custo_id || '',
|
||||||
}
|
data_desejada: d.data_desejada || '',
|
||||||
|
valor_estimado: d.valor_estimado ? String(d.valor_estimado) : '',
|
||||||
|
impacto_ambiental_demanda: (d as any).impacto_ambiental_demanda || '',
|
||||||
|
justificativa_manutencao_emergencial: (d as any).justificativa_manutencao_emergencial || '',
|
||||||
|
})
|
||||||
|
if (d.categoria_id) fetchSubcategorias(d.categoria_id)
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
const handleCategoriaChange = (categoriaId: string) => {
|
||||||
setShowModal(false)
|
setForm({ ...form, categoria_id: categoriaId, subcategoria_id: '' })
|
||||||
setSelectedDemanda(null)
|
fetchSubcategorias(categoriaId)
|
||||||
setFormData({ titulo: '', descricao: '', prioridade: 'media' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async () => {
|
||||||
e.preventDefault()
|
setSaving(true)
|
||||||
// Mock save
|
try {
|
||||||
if (selectedDemanda) {
|
const payload: any = { ...form }
|
||||||
setDemandas(demandas.map(d => d.id === selectedDemanda.id ? { ...d, ...formData } : d))
|
payload.valor_estimado = payload.valor_estimado ? Number(payload.valor_estimado) : null
|
||||||
|
if (editId) {
|
||||||
|
await api.patch(`/demandas/${editId}`, payload)
|
||||||
} else {
|
} else {
|
||||||
const newDemanda: Demanda = {
|
await api.post('/demandas', { ...payload, status: 'rascunho' })
|
||||||
id: Date.now(),
|
|
||||||
...formData,
|
|
||||||
status: 'pendente',
|
|
||||||
solicitante_id: 1,
|
|
||||||
solicitante_nome: 'Usuário Atual',
|
|
||||||
data_criacao: new Date().toISOString().split('T')[0]
|
|
||||||
}
|
}
|
||||||
setDemandas([newDemanda, ...demandas])
|
setShowModal(false)
|
||||||
|
fetchDemandas()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving:', err)
|
||||||
|
alert('Erro ao salvar demanda')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
}
|
}
|
||||||
handleCloseModal()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString('pt-BR')
|
if (!confirm('Tem certeza que deseja excluir esta demanda?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/demandas/${id}`)
|
||||||
|
fetchDemandas()
|
||||||
|
} catch (err) { alert('Erro ao excluir') }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const handleChangeOSStatus = async (osId: string, status: string) => {
|
||||||
return (
|
try {
|
||||||
<div className="flex items-center justify-center h-96">
|
await api.post(`/ordens-servico/${osId}/status`, { status })
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
if (showDetail) fetchDetailOS(showDetail.id)
|
||||||
</div>
|
} catch { alert('Erro ao alterar status da OS') }
|
||||||
)
|
}
|
||||||
|
|
||||||
|
const handleFileUpload = async (files: FileList | null) => {
|
||||||
|
if (!files || !showDetail) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('tipo', uploadTipo)
|
||||||
|
await api.post(`/demandas/${showDetail.id}/documentos`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fetchDetailDocs(showDetail.id)
|
||||||
|
setDocCounts(prev => ({ ...prev, [showDetail.id]: (prev[showDetail.id] || 0) + files.length }))
|
||||||
|
} catch (err) {
|
||||||
|
alert('Erro ao fazer upload')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocDownload = (doc: DocumentoFile) => {
|
||||||
|
window.open(`/api/documentos/${doc.id}/download`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocDelete = async (doc: DocumentoFile) => {
|
||||||
|
if (!confirm(`Excluir ${doc.nome_arquivo}?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/documentos/${doc.id}`)
|
||||||
|
if (showDetail) {
|
||||||
|
fetchDetailDocs(showDetail.id)
|
||||||
|
setDocCounts(prev => ({ ...prev, [showDetail.id]: Math.max(0, (prev[showDetail.id] || 1) - 1) }))
|
||||||
|
}
|
||||||
|
} catch { alert('Erro ao excluir documento') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
handleFileUpload(e.dataTransfer.files)
|
||||||
|
}, [showDetail, uploadTipo])
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string | null) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
try { return new Date(dateStr).toLocaleDateString('pt-BR') } catch { return '-' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (v: number | null | undefined) => {
|
||||||
|
if (v == null) return '-'
|
||||||
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCriticidade = (d: Demanda) => d.criticidade || d.prioridade || ''
|
||||||
|
const topStatuses = ['aberta', 'em_cotacao', 'em_aprovacao', 'em_execucao']
|
||||||
|
|
||||||
|
const openDetail = (d: Demanda) => {
|
||||||
|
setShowDetail(d)
|
||||||
|
setShowOS(false)
|
||||||
|
fetchDetailDocs(d.id)
|
||||||
|
fetchDetailOS(d.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || lookupsLoading) {
|
||||||
|
return <div className="flex items-center justify-center h-96"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">Demandas</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Demandas</h1>
|
||||||
<p className="text-gray mt-1">Gerencie as solicitações de facilities</p>
|
<p className="text-gray mt-1">Gerencie as solicitações de facilities</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={openNew} className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
onClick={() => handleOpenModal()}
|
<Plus className="w-5 h-5" />Nova Demanda
|
||||||
className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
Nova Demanda
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
{Object.entries(statusConfig).slice(0, 4).map(([key, config]) => {
|
{topStatuses.map((key) => {
|
||||||
|
const config = statusConfig[key]
|
||||||
|
if (!config) return null
|
||||||
const count = demandas.filter(d => d.status === key).length
|
const count = demandas.filter(d => d.status === key).length
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={key} onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
||||||
key={key}
|
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}>
|
||||||
onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
<div className="flex items-center gap-2 mb-2">{config.icon}<span className="text-sm text-gray">{config.label}</span></div>
|
||||||
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{config.icon}
|
|
||||||
<span className="text-sm text-gray">{config.label}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-text">{count}</p>
|
<p className="text-2xl font-bold text-text">{count}</p>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
<input
|
<input type="text" placeholder="Buscar demandas..." value={searchTerm}
|
||||||
type="text"
|
onChange={(e) => setSearchTerm(e.target.value)} className="input-field pl-12" />
|
||||||
placeholder="Buscar demandas..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="input-field pl-12"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="input-field appearance-none pr-10 w-full sm:w-48">
|
||||||
value={filterStatus}
|
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
|
||||||
className="input-field appearance-none pr-10 w-full sm:w-48"
|
|
||||||
>
|
|
||||||
<option value="todos">Todos os status</option>
|
<option value="todos">Todos os status</option>
|
||||||
{Object.entries(statusConfig).map(([key, config]) => (
|
{Object.entries(statusConfig).map(([key, config]) => (<option key={key} value={key}>{config.label}</option>))}
|
||||||
<option key={key} value={key}>{config.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray pointer-events-none" />
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Demandas List */}
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{filteredDemandas.map((demanda) => (
|
{filteredDemandas.map((demanda) => {
|
||||||
|
const crit = getCriticidade(demanda)
|
||||||
|
const docsCount = docCounts[demanda.id] || 0
|
||||||
|
return (
|
||||||
<div key={demanda.id} className="card hover:shadow-md transition-all">
|
<div key={demanda.id} className="card hover:shadow-md transition-all">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -198,113 +339,236 @@ export default function Demandas() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="font-semibold text-text">{demanda.titulo}</h3>
|
<h3 className="font-semibold text-text">{demanda.numero ? `#${demanda.numero} - ` : ''}{demanda.titulo}</h3>
|
||||||
<span className={`${statusConfig[demanda.status]?.class || 'badge-neutral'} flex items-center gap-1`}>
|
<span className={`${statusConfig[demanda.status]?.class || 'badge-neutral'} flex items-center gap-1`}>
|
||||||
{statusConfig[demanda.status]?.icon}
|
{statusConfig[demanda.status]?.icon}{statusConfig[demanda.status]?.label || demanda.status}
|
||||||
{statusConfig[demanda.status]?.label || demanda.status}
|
|
||||||
</span>
|
|
||||||
<span className={`badge ${prioridadeConfig[demanda.prioridade]?.class || ''}`}>
|
|
||||||
{prioridadeConfig[demanda.prioridade]?.label || demanda.prioridade}
|
|
||||||
</span>
|
</span>
|
||||||
|
{crit && <span className={`badge ${criticidadeConfig[crit]?.class || ''}`}>{criticidadeConfig[crit]?.label || crit}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray text-sm mt-1 line-clamp-1">{demanda.descricao}</p>
|
<p className="text-gray text-sm mt-1 line-clamp-1">{demanda.descricao}</p>
|
||||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-light">
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-light flex-wrap">
|
||||||
<span>Solicitante: {demanda.solicitante_nome}</span>
|
{demanda.categoria_id && <span>Cat: {categoriaMap[demanda.categoria_id] || '-'}</span>}
|
||||||
<span>•</span>
|
{demanda.subcategoria_id && <span>Sub: {subcategoriaMap[demanda.subcategoria_id] || '-'}</span>}
|
||||||
<span>{formatDate(demanda.data_criacao)}</span>
|
{demanda.local_id && <span>Local: {locaisMap[demanda.local_id] || '-'}</span>}
|
||||||
|
{demanda.centro_custo_id && <span>CC: {centrosCustoMap[demanda.centro_custo_id] || '-'}</span>}
|
||||||
|
{demanda.valor_estimado && <span className="flex items-center gap-1"><DollarSign className="w-3 h-3" />{formatCurrency(demanda.valor_estimado)}</span>}
|
||||||
|
{demanda.data_desejada && <span>🗓️ {formatDate(demanda.data_desejada)}</span>}
|
||||||
|
<span>{formatDate(demanda.created_at || demanda.data_criacao)}</span>
|
||||||
|
{docsCount > 0 && <span className="flex items-center gap-1"><Paperclip className="w-3 h-3" />{docsCount}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 sm:flex-shrink-0">
|
<div className="flex items-center gap-2 sm:flex-shrink-0">
|
||||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
<button onClick={() => openDetail(demanda)} className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"><Eye className="w-5 h-5" /></button>
|
||||||
<Eye className="w-5 h-5" />
|
<button onClick={() => openEdit(demanda)} className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"><Edit2 className="w-5 h-5" /></button>
|
||||||
</button>
|
<button onClick={() => handleDelete(demanda.id)} className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors"><Trash2 className="w-5 h-5" /></button>
|
||||||
<button
|
|
||||||
onClick={() => handleOpenModal(demanda)}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<button className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
|
||||||
<Trash2 className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{filteredDemandas.length === 0 && (
|
{filteredDemandas.length === 0 && (
|
||||||
<div className="card text-center py-12">
|
<div className="card text-center py-12"><FileText className="w-12 h-12 text-gray-light mx-auto mb-4" /><p className="text-gray">Nenhuma demanda encontrada</p></div>
|
||||||
<FileText className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
|
||||||
<p className="text-gray">Nenhuma demanda encontrada</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Create/Edit Modal */}
|
||||||
{showModal && (
|
<Modal open={showModal} onClose={() => setShowModal(false)} title={editId ? 'Editar Demanda' : 'Nova Demanda'} onSubmit={handleSubmit} submitLabel={editId ? 'Salvar' : 'Criar Demanda'} loading={saving}>
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-fade-in">
|
<div className="sm:col-span-2">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
|
||||||
<h2 className="text-xl font-semibold text-text">
|
|
||||||
{selectedDemanda ? 'Editar Demanda' : 'Nova Demanda'}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text mb-2">Título</label>
|
<label className="block text-sm font-medium text-text mb-2">Título</label>
|
||||||
<input
|
<input type="text" value={form.titulo} onChange={(e) => setForm({ ...form, titulo: e.target.value })} className="input-field" placeholder="Título da demanda" required />
|
||||||
type="text"
|
|
||||||
value={formData.titulo}
|
|
||||||
onChange={(e) => setFormData({ ...formData, titulo: e.target.value })}
|
|
||||||
className="input-field"
|
|
||||||
placeholder="Título da demanda"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-sm font-medium text-text mb-2">Descrição</label>
|
<label className="block text-sm font-medium text-text mb-2">Descrição</label>
|
||||||
<textarea
|
<textarea value={form.descricao} onChange={(e) => setForm({ ...form, descricao: e.target.value })} className="input-field resize-none" rows={3} placeholder="Descreva a demanda..." required />
|
||||||
value={formData.descricao}
|
|
||||||
onChange={(e) => setFormData({ ...formData, descricao: e.target.value })}
|
|
||||||
className="input-field resize-none"
|
|
||||||
rows={4}
|
|
||||||
placeholder="Descreva a demanda em detalhes..."
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text mb-2">Prioridade</label>
|
<label className="block text-sm font-medium text-text mb-2">Criticidade</label>
|
||||||
<select
|
<select value={form.criticidade} onChange={(e) => setForm({ ...form, criticidade: e.target.value })} className="input-field">
|
||||||
value={formData.prioridade}
|
<option value="baixa">Baixa</option><option value="media">Média</option><option value="alta">Alta</option><option value="urgente">Urgente</option>
|
||||||
onChange={(e) => setFormData({ ...formData, prioridade: e.target.value })}
|
|
||||||
className="input-field"
|
|
||||||
>
|
|
||||||
<option value="baixa">Baixa</option>
|
|
||||||
<option value="media">Média</option>
|
|
||||||
<option value="alta">Alta</option>
|
|
||||||
<option value="urgente">Urgente</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-4">
|
<div>
|
||||||
<button type="button" onClick={handleCloseModal} className="btn-ghost flex-1">
|
<label className="block text-sm font-medium text-text mb-2">Categoria</label>
|
||||||
Cancelar
|
<select value={form.categoria_id} onChange={(e) => handleCategoriaChange(e.target.value)} className="input-field">
|
||||||
</button>
|
<option value="">Selecione...</option>
|
||||||
<button type="submit" className="btn-primary flex-1">
|
{categorias.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
||||||
{selectedDemanda ? 'Salvar' : 'Criar Demanda'}
|
</select>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Subcategoria</label>
|
||||||
|
<select value={form.subcategoria_id} onChange={(e) => setForm({ ...form, subcategoria_id: e.target.value })} className="input-field" disabled={!form.categoria_id}>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{subcategorias.map(s => <option key={s.id} value={s.id}>{s.nome}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Data Desejada</label>
|
||||||
|
<input type="date" value={form.data_desejada} onChange={(e) => setForm({ ...form, data_desejada: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Local</label>
|
||||||
|
<select value={form.local_id} onChange={(e) => setForm({ ...form, local_id: e.target.value })} className="input-field">
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{locais.map(l => <option key={l.id} value={l.id}>{l.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Centro de Custo</label>
|
||||||
|
<select value={form.centro_custo_id} onChange={(e) => setForm({ ...form, centro_custo_id: e.target.value })} className="input-field">
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{centrosCusto.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Valor Estimado (R$)</label>
|
||||||
|
<input type="number" step="0.01" value={form.valor_estimado} onChange={(e) => setForm({ ...form, valor_estimado: e.target.value })} className="input-field" placeholder="0,00" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2 border-t border-border pt-3 mt-2">
|
||||||
|
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Impacto Ambiental</label>
|
||||||
|
<select value={form.impacto_ambiental_demanda} onChange={(e) => setForm({ ...form, impacto_ambiental_demanda: e.target.value })} className="input-field">
|
||||||
|
<option value="">Herdar da Categoria</option>
|
||||||
|
<option value="Baixo">Baixo</option>
|
||||||
|
<option value="Médio">Médio</option>
|
||||||
|
<option value="Alto">Alto</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Justificativa Emergencial</label>
|
||||||
|
<textarea value={form.justificativa_manutencao_emergencial} onChange={(e) => setForm({ ...form, justificativa_manutencao_emergencial: e.target.value })} className="input-field resize-none" rows={2} placeholder="Obrigatório para manutenção emergencial..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
<Modal open={!!showDetail} onClose={() => { setShowDetail(null); setDetailDocs([]); setDetailOS([]) }} title={`Demanda #${showDetail?.numero || ''}`}>
|
||||||
|
{showDetail && (
|
||||||
|
<>
|
||||||
|
<div><strong>Título:</strong> {showDetail.titulo}</div>
|
||||||
|
<div><strong>Descrição:</strong> {showDetail.descricao}</div>
|
||||||
|
<div><strong>Status:</strong> {statusConfig[showDetail.status]?.label || showDetail.status}</div>
|
||||||
|
<div><strong>Criticidade:</strong> {criticidadeConfig[getCriticidade(showDetail)]?.label || getCriticidade(showDetail)}</div>
|
||||||
|
<div><strong>Categoria:</strong> {categoriaMap[showDetail.categoria_id] || '-'}</div>
|
||||||
|
<div><strong>Subcategoria:</strong> {subcategoriaMap[showDetail.subcategoria_id] || '-'}</div>
|
||||||
|
<div><strong>Local:</strong> {locaisMap[showDetail.local_id] || '-'}</div>
|
||||||
|
<div><strong>Centro de Custo:</strong> {centrosCustoMap[showDetail.centro_custo_id] || '-'}</div>
|
||||||
|
<div><strong>Data Desejada:</strong> {formatDate(showDetail.data_desejada)}</div>
|
||||||
|
<div><strong>Valor Estimado:</strong> {formatCurrency(showDetail.valor_estimado)}</div>
|
||||||
|
<div><strong>Data de Criação:</strong> {formatDate(showDetail.created_at)}</div>
|
||||||
|
{/* ESG Info */}
|
||||||
|
{((showDetail as any).impacto_ambiental_demanda || (showDetail as any).justificativa_manutencao_emergencial) && (
|
||||||
|
<div className="border-t border-border pt-3 mt-3">
|
||||||
|
<p className="text-sm font-semibold mb-2" style={{ color: '#1A7A4C' }}>🌿 ESG</p>
|
||||||
|
{(showDetail as any).impacto_ambiental_demanda && (
|
||||||
|
<div><strong>Impacto Ambiental:</strong>{' '}
|
||||||
|
<span className={`badge ${(showDetail as any).impacto_ambiental_demanda === 'Alto' ? 'badge-error' : (showDetail as any).impacto_ambiental_demanda === 'Médio' ? 'badge-warning' : 'badge-success'}`}>
|
||||||
|
{(showDetail as any).impacto_ambiental_demanda}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(showDetail as any).justificativa_manutencao_emergencial && (
|
||||||
|
<div className="mt-2"><strong>Justificativa Emergencial:</strong> {(showDetail as any).justificativa_manutencao_emergencial}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ordens de Serviço Section */}
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<button onClick={() => setShowOS(!showOS)} className="font-semibold text-text mb-3 flex items-center gap-2 hover:text-primary transition-colors">
|
||||||
|
<ClipboardList className="w-4 h-4" /> Ordens de Serviço ({detailOS.length})
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform ${showOS ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{showOS && (
|
||||||
|
osLoading ? (
|
||||||
|
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-gray" /></div>
|
||||||
|
) : detailOS.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray text-center py-2">Nenhuma OS vinculada</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{detailOS.map(os => (
|
||||||
|
<div key={os.id} className="flex items-center gap-3 p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||||
|
<ClipboardList className="w-5 h-5 text-secondary" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text">OS-{String(os.numero).padStart(4, '0')}</p>
|
||||||
|
<p className="text-xs text-gray">{fornecedoresMap[os.fornecedor_id] || '-'} · {formatCurrency(os.valor)}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`${osStatusConfig[os.status]?.class || 'badge-neutral'} text-xs`}>
|
||||||
|
{osStatusConfig[os.status]?.label || os.status}
|
||||||
|
</span>
|
||||||
|
{os.status === 'emitida' && (
|
||||||
|
<button onClick={() => handleChangeOSStatus(os.id, 'em_cotacao')} className="text-xs btn-primary !py-1 !px-2">
|
||||||
|
→ Em Cotação
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documents Section */}
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<h3 className="font-semibold text-text mb-3 flex items-center gap-2">
|
||||||
|
<Paperclip className="w-4 h-4" /> Documentos
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-xl p-4 text-center transition-colors mb-3 ${dragOver ? 'border-primary bg-primary/5' : 'border-gray-300'}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-3 mb-2">
|
||||||
|
<select value={uploadTipo} onChange={(e) => setUploadTipo(e.target.value)} className="input-field w-auto text-sm py-1">
|
||||||
|
<option value="planta">📋 Planta</option>
|
||||||
|
<option value="foto">📸 Foto</option>
|
||||||
|
<option value="laudo">📄 Laudo</option>
|
||||||
|
<option value="outro">📎 Outro</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
|
||||||
|
className="btn-primary text-sm py-1 px-3 flex items-center gap-1">
|
||||||
|
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
{uploading ? 'Enviando...' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray">Arraste arquivos aqui ou clique no botão</p>
|
||||||
|
<input ref={fileInputRef} type="file" multiple className="hidden"
|
||||||
|
onChange={(e) => handleFileUpload(e.target.files)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{docsLoading ? (
|
||||||
|
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-gray" /></div>
|
||||||
|
) : detailDocs.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray text-center py-2">Nenhum documento anexado</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{detailDocs.map(doc => (
|
||||||
|
<div key={doc.id} className="flex items-center gap-3 p-2 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||||
|
<span className="text-lg">{tipoDocIcon[doc.tipo] || '📎'}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text truncate">{doc.nome_arquivo}</p>
|
||||||
|
<p className="text-xs text-gray">{formatFileSize(doc.tamanho)} · {formatDate(doc.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleDocDownload(doc)} className="p-1 rounded hover:bg-white text-gray hover:text-primary">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDocDelete(doc)} className="p-1 rounded hover:bg-white text-gray hover:text-red-500">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
222
frontend/src/pages/ESG.tsx
Normal file
222
frontend/src/pages/ESG.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Leaf, Droplets, Trash2, Wind, Loader2, TrendingDown, TrendingUp, Target } from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
interface ESGMetric {
|
||||||
|
tipo: string
|
||||||
|
valor: number
|
||||||
|
unidade: string
|
||||||
|
mes: number
|
||||||
|
ano: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ESGGoal {
|
||||||
|
id: number
|
||||||
|
tipo: string
|
||||||
|
meta: number
|
||||||
|
atual: number
|
||||||
|
unidade: string
|
||||||
|
ano: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResumoItem {
|
||||||
|
tipo: string
|
||||||
|
unidade: string
|
||||||
|
total: number
|
||||||
|
media: number
|
||||||
|
registros: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricaPorMes {
|
||||||
|
tipo: string
|
||||||
|
mes: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ESGMeta {
|
||||||
|
id: string
|
||||||
|
tipo: string
|
||||||
|
meta_valor: number
|
||||||
|
periodo_ano: number
|
||||||
|
local_id: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ESGDashboard {
|
||||||
|
ano: number
|
||||||
|
resumo: ResumoItem[]
|
||||||
|
metricas_por_mes: MetricaPorMes[]
|
||||||
|
metas: ESGMeta[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ESG() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [data, setData] = useState<ESGDashboard | null>(null)
|
||||||
|
const [ano, setAno] = useState(new Date().getFullYear())
|
||||||
|
const [tipo, setTipo] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [ano, tipo])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params: any = { ano }
|
||||||
|
if (tipo) params.tipo = tipo
|
||||||
|
const { data } = await api.get('/esg/dashboard', { params })
|
||||||
|
setData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching ESG data:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getResumoValue = (tipo: string) => {
|
||||||
|
if (!data?.resumo || !Array.isArray(data.resumo)) return 0
|
||||||
|
const item = data.resumo.find(r => r.tipo === tipo)
|
||||||
|
return item?.total ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{ title: 'Energia Total', value: getResumoValue('energia'), unit: 'kWh', icon: <Wind className="w-6 h-6" />, color: 'from-yellow-500 to-orange-500', bg: 'bg-yellow-100', text: 'text-yellow-600' },
|
||||||
|
{ title: 'Água Total', value: getResumoValue('agua'), unit: 'm³', icon: <Droplets className="w-6 h-6" />, color: 'from-blue-500 to-cyan-500', bg: 'bg-blue-100', text: 'text-blue-600' },
|
||||||
|
{ title: 'Resíduos Total', value: getResumoValue('residuos'), unit: 'kg', icon: <Trash2 className="w-6 h-6" />, color: 'from-green-500 to-emerald-500', bg: 'bg-green-100', text: 'text-green-600' },
|
||||||
|
{ title: 'Emissões CO₂', value: getResumoValue('emissoes'), unit: 'tCO₂', icon: <Wind className="w-6 h-6" />, color: 'from-gray-500 to-gray-700', bg: 'bg-gray-100', text: 'text-gray-600' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const monthNames = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez']
|
||||||
|
|
||||||
|
const getMaxValue = (metrics: MetricaPorMes[], tipoFilter: string) => {
|
||||||
|
const filtered = metrics.filter(m => m.tipo === tipoFilter)
|
||||||
|
return Math.max(...filtered.map(m => m.total), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-2">
|
||||||
|
<Leaf className="w-8 h-8 text-green-600" /> ESG & Sustentabilidade
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray mt-1">Monitoramento de indicadores ambientais e metas de sustentabilidade.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select value={tipo} onChange={e => setTipo(e.target.value)} className="input-field text-sm">
|
||||||
|
<option value="">Todos os tipos</option>
|
||||||
|
<option value="energia">Energia</option>
|
||||||
|
<option value="agua">Água</option>
|
||||||
|
<option value="residuos">Resíduos</option>
|
||||||
|
<option value="emissoes">Emissões</option>
|
||||||
|
</select>
|
||||||
|
<select value={ano} onChange={e => setAno(Number(e.target.value))} className="input-field text-sm">
|
||||||
|
{[2024, 2025, 2026].map(y => <option key={y} value={y}>{y}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6">
|
||||||
|
{summaryCards.map((card, i) => (
|
||||||
|
<div key={i} className="card group hover:shadow-lg transition-all duration-300">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-white shadow-lg`}>
|
||||||
|
{card.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray text-sm mb-1">{card.title}</p>
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-text">
|
||||||
|
{typeof card.value === 'number' ? card.value.toLocaleString('pt-BR') : card.value}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray mt-1">{card.unit}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly Metrics */}
|
||||||
|
{data?.metricas_por_mes && data.metricas_por_mes.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-text mb-4">Métricas Mensais</h2>
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
{['energia', 'agua', 'residuos', 'emissoes'].map(t => {
|
||||||
|
const metrics = (data?.metricas_por_mes || []).filter(m => m.tipo === t)
|
||||||
|
if (metrics.length === 0) return null
|
||||||
|
const max = getMaxValue(data?.metricas_por_mes || [], t)
|
||||||
|
return (
|
||||||
|
<div key={t} className="space-y-2">
|
||||||
|
<h3 className="font-medium text-text capitalize">{t === 'agua' ? 'Água' : t === 'emissoes' ? 'Emissões' : t === 'residuos' ? 'Resíduos' : t}</h3>
|
||||||
|
{metrics.sort((a, b) => a.mes - b.mes).map(m => (
|
||||||
|
<div key={m.mes} className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray w-8">{monthNames[m.mes - 1]}</span>
|
||||||
|
<div className="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-primary to-accent transition-all duration-500"
|
||||||
|
style={{ width: `${(m.total / max) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray w-16 text-right">{m.total.toLocaleString('pt-BR')} {summaryCards.find(c => c.title.toLowerCase().includes(t))?.unit || ""}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Goals */}
|
||||||
|
{data?.metas && data.metas.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-text mb-4 flex items-center gap-2">
|
||||||
|
<Target className="w-5 h-5 text-primary" /> Metas de Sustentabilidade
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{data.metas.map((goal: any) => {
|
||||||
|
const metaVal = goal.meta_valor ?? goal.meta ?? 0
|
||||||
|
const atualVal = getResumoValue(goal.tipo)
|
||||||
|
const pct = metaVal > 0 ? Math.min((atualVal / metaVal) * 100, 100) : 0
|
||||||
|
const isGood = atualVal <= metaVal
|
||||||
|
const unit = summaryCards.find(c => c.title.toLowerCase().includes(goal.tipo))?.unit || ''
|
||||||
|
return (
|
||||||
|
<div key={goal.id} className="p-4 bg-gray-50 rounded-xl space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-text capitalize">{goal.tipo === 'agua' ? 'Água' : goal.tipo === 'emissoes' ? 'Emissões' : goal.tipo === 'residuos' ? 'Resíduos' : goal.tipo}</span>
|
||||||
|
{isGood ? <TrendingDown className="w-4 h-4 text-green-500" /> : <TrendingUp className="w-4 h-4 text-red-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${isGood ? 'bg-green-500' : 'bg-red-500'}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray">
|
||||||
|
<span>Atual: {atualVal.toLocaleString('pt-BR')} {unit}</span>
|
||||||
|
<span>Meta: {metaVal.toLocaleString('pt-BR')} {unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!data?.metricas_por_mes?.length && !data?.metas?.length && (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<Leaf className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray">Nenhum dado ESG encontrado</h3>
|
||||||
|
<p className="text-sm text-gray-light mt-1">Registre métricas ambientais para visualizar o dashboard.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,30 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2, Search, Plus, Eye, Edit2, Trash2, Loader2,
|
||||||
Search,
|
Mail, Phone, MapPin, Star, CheckCircle2, XCircle, X
|
||||||
Plus,
|
|
||||||
Eye,
|
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
Mail,
|
|
||||||
Phone,
|
|
||||||
MapPin,
|
|
||||||
Star,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
X
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { Fornecedor } from '../types'
|
import { Fornecedor } from '../types'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
const mockFornecedores: Fornecedor[] = [
|
const emptyForm = { razao_social: '', nome_fantasia: '', cpf_cnpj: '', email: '', telefone: '', tipo_pessoa: 'PJ', nome_contato: '', possui_politica_ambiental: false, possui_politica_sst: false, declara_uso_epi: false, equipe_treinada: false, classificacao_esg: '' }
|
||||||
{ id: 1, razao_social: 'Tech Solutions Ltda', cnpj: '12.345.678/0001-90', email: 'contato@techsolutions.com', telefone: '(11) 99999-1234', endereco: 'Av. Paulista, 1000 - São Paulo/SP', ativo: true, especialidades: ['Ar Condicionado', 'Elétrica'], avaliacao: 4.5 },
|
|
||||||
{ id: 2, razao_social: 'EletroFix Serviços', cnpj: '23.456.789/0001-01', email: 'eletrofix@email.com', telefone: '(11) 98888-5678', endereco: 'Rua Augusta, 500 - São Paulo/SP', ativo: true, especialidades: ['Elétrica', 'Iluminação'], avaliacao: 4.8 },
|
|
||||||
{ id: 3, razao_social: 'HidroServ Manutenção', cnpj: '34.567.890/0001-12', email: 'hidroserv@email.com', telefone: '(11) 97777-9012', endereco: 'Rua Oscar Freire, 200 - São Paulo/SP', ativo: true, especialidades: ['Hidráulica', 'Encanamento'], avaliacao: 4.2 },
|
|
||||||
{ id: 4, razao_social: 'ElevaTech Elevadores', cnpj: '45.678.901/0001-23', email: 'elevatech@email.com', telefone: '(11) 96666-3456', endereco: 'Av. Brasil, 1500 - São Paulo/SP', ativo: false, especialidades: ['Elevadores'], avaliacao: 3.9 },
|
|
||||||
{ id: 5, razao_social: 'CleanPro Limpeza', cnpj: '56.789.012/0001-34', email: 'cleanpro@email.com', telefone: '(11) 95555-7890', endereco: 'Rua Consolação, 800 - São Paulo/SP', ativo: true, especialidades: ['Limpeza', 'Conservação'], avaliacao: 4.6 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Fornecedores() {
|
export default function Fornecedores() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -32,108 +15,114 @@ export default function Fornecedores() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [filterAtivo, setFilterAtivo] = useState('todos')
|
const [filterAtivo, setFilterAtivo] = useState('todos')
|
||||||
const [selectedFornecedor, setSelectedFornecedor] = useState<Fornecedor | null>(null)
|
const [selectedFornecedor, setSelectedFornecedor] = useState<Fornecedor | null>(null)
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchFornecedores() }, [])
|
||||||
fetchFornecedores()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchFornecedores = async () => {
|
const fetchFornecedores = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/fornecedores')
|
const { data } = await api.get('/fornecedores')
|
||||||
setFornecedores(data.length > 0 ? data : mockFornecedores)
|
setFornecedores(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching fornecedores:', err)
|
console.error('Error fetching fornecedores:', err)
|
||||||
setFornecedores(mockFornecedores)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredFornecedores = fornecedores.filter(forn => {
|
const filteredFornecedores = fornecedores.filter(forn => {
|
||||||
const matchesSearch = forn.razao_social.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const nome = forn.razao_social || forn.nome_fantasia || ''
|
||||||
forn.cnpj.includes(searchTerm)
|
const doc = forn.cpf_cnpj || forn.cnpj || ''
|
||||||
const matchesAtivo = filterAtivo === 'todos' ||
|
const matchesSearch = nome.toLowerCase().includes(searchTerm.toLowerCase()) || doc.includes(searchTerm)
|
||||||
(filterAtivo === 'ativos' && forn.ativo) ||
|
const matchesAtivo = filterAtivo === 'todos' || (filterAtivo === 'ativos' && forn.ativo) || (filterAtivo === 'inativos' && !forn.ativo)
|
||||||
(filterAtivo === 'inativos' && !forn.ativo)
|
|
||||||
return matchesSearch && matchesAtivo
|
return matchesSearch && matchesAtivo
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderStars = (rating: number = 0) => {
|
const openNew = () => { setEditId(null); setForm(emptyForm); setShowCreateModal(true) }
|
||||||
return (
|
const openEdit = (f: Fornecedor) => {
|
||||||
|
setEditId(f.id)
|
||||||
|
setForm({ razao_social: f.razao_social || '', nome_fantasia: f.nome_fantasia || '', cpf_cnpj: f.cpf_cnpj || f.cnpj || '', email: f.email || '', telefone: f.telefone || '', tipo_pessoa: f.tipo_pessoa || 'PJ', nome_contato: (f as any).nome_contato || '', possui_politica_ambiental: f.possui_politica_ambiental || false, possui_politica_sst: f.possui_politica_sst || false, declara_uso_epi: f.declara_uso_epi || false, equipe_treinada: f.equipe_treinada || false, classificacao_esg: f.classificacao_esg || '' })
|
||||||
|
setShowCreateModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await api.patch(`/fornecedores/${editId}`, form)
|
||||||
|
} else {
|
||||||
|
await api.post('/fornecedores', form)
|
||||||
|
}
|
||||||
|
setShowCreateModal(false)
|
||||||
|
fetchFornecedores()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving:', err)
|
||||||
|
alert('Erro ao salvar fornecedor')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Tem certeza que deseja excluir este fornecedor?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/fornecedores/${id}`);
|
||||||
|
fetchFornecedores()
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Erro ao excluir'
|
||||||
|
alert(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEspecialidades = (f: Fornecedor) => f.categorias_atendidas || f.especialidades || []
|
||||||
|
const getRating = (f: Fornecedor) => f.rating ?? f.avaliacao ?? 0
|
||||||
|
const getDoc = (f: Fornecedor) => f.cpf_cnpj || f.cnpj || ''
|
||||||
|
|
||||||
|
const renderStars = (rating: number = 0) => (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<Star
|
<Star key={star} className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-300'}`} />
|
||||||
key={star}
|
|
||||||
className={`w-4 h-4 ${star <= rating ? 'text-amber-400 fill-amber-400' : 'text-gray-300'}`}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
<span className="text-sm text-gray ml-1">{rating?.toFixed(1)}</span>
|
<span className="text-sm text-gray ml-1">{rating?.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <div className="flex items-center justify-center h-96"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||||
<div className="flex items-center justify-center h-96">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">Fornecedores</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Fornecedores</h1>
|
||||||
<p className="text-gray mt-1">Gerencie os fornecedores parceiros</p>
|
<p className="text-gray mt-1">Gerencie os fornecedores parceiros</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
<button onClick={openNew} className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />Novo Fornecedor
|
||||||
Novo Fornecedor
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="card">
|
<div className="card"><p className="text-gray text-sm">Total</p><p className="text-2xl font-bold text-text">{fornecedores.length}</p></div>
|
||||||
<p className="text-gray text-sm">Total</p>
|
<div className="card"><p className="text-gray text-sm">Ativos</p><p className="text-2xl font-bold text-green-600">{fornecedores.filter(f => f.ativo).length}</p></div>
|
||||||
<p className="text-2xl font-bold text-text">{fornecedores.length}</p>
|
<div className="card"><p className="text-gray text-sm">Inativos</p><p className="text-2xl font-bold text-gray">{fornecedores.filter(f => !f.ativo).length}</p></div>
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<p className="text-gray text-sm">Ativos</p>
|
|
||||||
<p className="text-2xl font-bold text-green-600">{fornecedores.filter(f => f.ativo).length}</p>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<p className="text-gray text-sm">Inativos</p>
|
|
||||||
<p className="text-2xl font-bold text-gray">{fornecedores.filter(f => !f.ativo).length}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
<input
|
<input type="text" placeholder="Buscar por nome ou CNPJ..." value={searchTerm}
|
||||||
type="text"
|
onChange={(e) => setSearchTerm(e.target.value)} className="input-field pl-12" />
|
||||||
placeholder="Buscar por nome ou CNPJ..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="input-field pl-12"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{['todos', 'ativos', 'inativos'].map((filter) => (
|
{['todos', 'ativos', 'inativos'].map((filter) => (
|
||||||
<button
|
<button key={filter} onClick={() => setFilterAtivo(filter)}
|
||||||
key={filter}
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${filterAtivo === filter ? 'bg-primary text-white' : 'bg-gray-100 text-gray hover:bg-gray-200'}`}>
|
||||||
onClick={() => setFilterAtivo(filter)}
|
|
||||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
|
||||||
filterAtivo === filter
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-gray-100 text-gray hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -141,67 +130,42 @@ export default function Fornecedores() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fornecedores Grid */}
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredFornecedores.map((fornecedor) => (
|
{filteredFornecedores.map((fornecedor) => (
|
||||||
<div
|
<div key={fornecedor.id} className="card hover:shadow-lg transition-all cursor-pointer" onClick={() => setSelectedFornecedor(fornecedor)}>
|
||||||
key={fornecedor.id}
|
|
||||||
className="card hover:shadow-lg transition-all cursor-pointer"
|
|
||||||
onClick={() => setSelectedFornecedor(fornecedor)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-secondary/10 to-secondary/20 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-secondary/10 to-secondary/20 flex items-center justify-center">
|
||||||
<Building2 className="w-6 h-6 text-secondary" />
|
<Building2 className="w-6 h-6 text-secondary" />
|
||||||
</div>
|
</div>
|
||||||
<span className={`badge ${fornecedor.ativo ? 'badge-success' : 'badge-neutral'}`}>
|
<span className={`badge ${fornecedor.ativo ? 'badge-success' : 'badge-neutral'}`}>{fornecedor.ativo ? 'Ativo' : 'Inativo'}</span>
|
||||||
{fornecedor.ativo ? 'Ativo' : 'Inativo'}
|
</div>
|
||||||
|
<h3 className="font-semibold text-text mb-1">{fornecedor.razao_social || fornecedor.nome_fantasia}</h3>
|
||||||
|
<p className="text-sm text-gray mb-3">{getDoc(fornecedor)}</p>
|
||||||
|
{renderStars(getRating(fornecedor))}
|
||||||
|
{fornecedor.classificacao_esg && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-lg font-medium ${fornecedor.classificacao_esg === 'Avançado' ? 'bg-green-100 text-green-700' : fornecedor.classificacao_esg === 'Intermediário' ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
ESG: {fornecedor.classificacao_esg}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<h3 className="font-semibold text-text mb-1">{fornecedor.razao_social}</h3>
|
{getEspecialidades(fornecedor).length > 0 && (
|
||||||
<p className="text-sm text-gray mb-3">{fornecedor.cnpj}</p>
|
|
||||||
|
|
||||||
{renderStars(fornecedor.avaliacao)}
|
|
||||||
|
|
||||||
{fornecedor.especialidades && fornecedor.especialidades.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-3">
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
{fornecedor.especialidades.slice(0, 2).map((esp, i) => (
|
{getEspecialidades(fornecedor).slice(0, 2).map((esp: string, i: number) => (
|
||||||
<span key={i} className="text-xs px-2 py-1 bg-primary/10 text-primary rounded-lg">
|
<span key={i} className="text-xs px-2 py-1 bg-primary/10 text-primary rounded-lg">{esp}</span>
|
||||||
{esp}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
{fornecedor.especialidades.length > 2 && (
|
{getEspecialidades(fornecedor).length > 2 && <span className="text-xs px-2 py-1 bg-gray-100 text-gray rounded-lg">+{getEspecialidades(fornecedor).length - 2}</span>}
|
||||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray rounded-lg">
|
|
||||||
+{fornecedor.especialidades.length - 2}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border">
|
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border">
|
||||||
<button
|
<button onClick={(e) => { e.stopPropagation(); setSelectedFornecedor(fornecedor) }} className="flex-1 btn-ghost !py-2 text-sm"><Eye className="w-4 h-4 mr-1 inline" />Ver</button>
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedFornecedor(fornecedor); }}
|
<button onClick={(e) => { e.stopPropagation(); openEdit(fornecedor) }} className="flex-1 btn-ghost !py-2 text-sm"><Edit2 className="w-4 h-4 mr-1 inline" />Editar</button>
|
||||||
className="flex-1 btn-ghost !py-2 text-sm"
|
<button onClick={(e) => { e.stopPropagation(); handleDelete(fornecedor.id) }} className="flex-1 btn-ghost !py-2 text-sm text-red-500"><Trash2 className="w-4 h-4 mr-1 inline" />Excluir</button>
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4 mr-1 inline" />
|
|
||||||
Ver
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="flex-1 btn-ghost !py-2 text-sm"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4 mr-1 inline" />
|
|
||||||
Editar
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{filteredFornecedores.length === 0 && (
|
{filteredFornecedores.length === 0 && (
|
||||||
<div className="col-span-full card text-center py-12">
|
<div className="col-span-full card text-center py-12"><Building2 className="w-12 h-12 text-gray-light mx-auto mb-4" /><p className="text-gray">Nenhum fornecedor encontrado</p></div>
|
||||||
<Building2 className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
|
||||||
<p className="text-gray">Nenhum fornecedor encontrado</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,14 +173,9 @@ export default function Fornecedores() {
|
|||||||
{selectedFornecedor && (
|
{selectedFornecedor && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-fade-in max-h-[90vh] overflow-y-auto">
|
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl animate-fade-in max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border sticky top-0 bg-white">
|
<div className="flex items-center justify-between p-6 border-b border-border bg-gradient-to-r from-primary to-accent rounded-t-2xl">
|
||||||
<h2 className="text-xl font-semibold text-text">Detalhes do Fornecedor</h2>
|
<h2 className="text-xl font-semibold text-white">Detalhes do Fornecedor</h2>
|
||||||
<button
|
<button onClick={() => setSelectedFornecedor(null)} className="p-2 rounded-lg hover:bg-white/20 text-white"><X className="w-5 h-5" /></button>
|
||||||
onClick={() => setSelectedFornecedor(null)}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -224,83 +183,108 @@ export default function Fornecedores() {
|
|||||||
<Building2 className="w-8 h-8 text-white" />
|
<Building2 className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-text">{selectedFornecedor.razao_social}</h3>
|
<h3 className="text-xl font-semibold text-text">{selectedFornecedor.razao_social || selectedFornecedor.nome_fantasia}</h3>
|
||||||
<p className="text-gray">{selectedFornecedor.cnpj}</p>
|
<p className="text-gray">{getDoc(selectedFornecedor)}</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<span className={`badge ${selectedFornecedor.ativo ? 'badge-success' : 'badge-neutral'} mt-1 inline-flex items-center gap-1`}>
|
||||||
{selectedFornecedor.ativo ? (
|
{selectedFornecedor.ativo ? <><CheckCircle2 className="w-3 h-3" />Ativo</> : <><XCircle className="w-3 h-3" />Inativo</>}
|
||||||
<span className="badge-success flex items-center gap-1">
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
Ativo
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span className="badge-neutral flex items-center gap-1">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
Inativo
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 p-3 bg-card rounded-xl">
|
<div className="flex items-center gap-3 p-3 bg-card rounded-xl"><Mail className="w-5 h-5 text-gray" /><div><p className="text-xs text-gray">E-mail</p><p className="text-text">{selectedFornecedor.email || '-'}</p></div></div>
|
||||||
<Mail className="w-5 h-5 text-gray" />
|
<div className="flex items-center gap-3 p-3 bg-card rounded-xl"><Phone className="w-5 h-5 text-gray" /><div><p className="text-xs text-gray">Telefone</p><p className="text-text">{selectedFornecedor.telefone || '-'}</p></div></div>
|
||||||
<div>
|
{selectedFornecedor.endereco && <div className="flex items-center gap-3 p-3 bg-card rounded-xl"><MapPin className="w-5 h-5 text-gray" /><div><p className="text-xs text-gray">Endereço</p><p className="text-text">{selectedFornecedor.endereco}</p></div></div>}
|
||||||
<p className="text-xs text-gray">E-mail</p>
|
|
||||||
<p className="text-text">{selectedFornecedor.email}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div><p className="text-sm text-gray mb-2">Avaliação</p>{renderStars(getRating(selectedFornecedor))}</div>
|
||||||
|
{/* ESG Info */}
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Informações ESG</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2"><span>{selectedFornecedor.possui_politica_ambiental ? '✅' : '❌'}</span>Política Ambiental</div>
|
||||||
|
<div className="flex items-center gap-2"><span>{selectedFornecedor.possui_politica_sst ? '✅' : '❌'}</span>Política SST</div>
|
||||||
|
<div className="flex items-center gap-2"><span>{selectedFornecedor.declara_uso_epi ? '✅' : '❌'}</span>Uso de EPI</div>
|
||||||
|
<div className="flex items-center gap-2"><span>{selectedFornecedor.equipe_treinada ? '✅' : '❌'}</span>Equipe Treinada</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 p-3 bg-card rounded-xl">
|
{selectedFornecedor.classificacao_esg && (
|
||||||
<Phone className="w-5 h-5 text-gray" />
|
<div className="mt-3">
|
||||||
<div>
|
<span className={`badge ${selectedFornecedor.classificacao_esg === 'Avançado' ? 'badge-success' : selectedFornecedor.classificacao_esg === 'Intermediário' ? 'badge-warning' : 'badge-error'}`}>
|
||||||
<p className="text-xs text-gray">Telefone</p>
|
ESG: {selectedFornecedor.classificacao_esg}
|
||||||
<p className="text-text">{selectedFornecedor.telefone}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectedFornecedor.endereco && (
|
|
||||||
<div className="flex items-center gap-3 p-3 bg-card rounded-xl">
|
|
||||||
<MapPin className="w-5 h-5 text-gray" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray">Endereço</p>
|
|
||||||
<p className="text-text">{selectedFornecedor.endereco}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray mb-2">Avaliação</p>
|
|
||||||
{renderStars(selectedFornecedor.avaliacao)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedFornecedor.especialidades && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray mb-2">Especialidades</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{selectedFornecedor.especialidades.map((esp, i) => (
|
|
||||||
<span key={i} className="px-3 py-1.5 bg-primary/10 text-primary rounded-lg text-sm font-medium">
|
|
||||||
{esp}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button onClick={() => setSelectedFornecedor(null)} className="btn-ghost flex-1">Fechar</button>
|
||||||
|
<button onClick={() => { setSelectedFornecedor(null); openEdit(selectedFornecedor) }} className="btn-primary flex-1">Editar Fornecedor</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
{/* Create/Edit Modal */}
|
||||||
<button
|
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)} title={editId ? 'Editar Fornecedor' : 'Novo Fornecedor'} onSubmit={handleSubmit} submitLabel={editId ? 'Salvar' : 'Criar Fornecedor'} loading={saving}>
|
||||||
onClick={() => setSelectedFornecedor(null)}
|
<div>
|
||||||
className="btn-ghost flex-1"
|
<label className="block text-sm font-medium text-text mb-2">Tipo Pessoa</label>
|
||||||
>
|
<select value={form.tipo_pessoa} onChange={(e) => setForm({ ...form, tipo_pessoa: e.target.value })} className="input-field">
|
||||||
Fechar
|
<option value="PJ">Pessoa Jurídica</option><option value="PF">Pessoa Física</option>
|
||||||
</button>
|
</select>
|
||||||
<button className="btn-primary flex-1">
|
|
||||||
Editar Fornecedor
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Razão Social</label>
|
||||||
|
<input type="text" value={form.razao_social} onChange={(e) => setForm({ ...form, razao_social: e.target.value })} className="input-field" required />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Nome Fantasia</label>
|
||||||
|
<input type="text" value={form.nome_fantasia} onChange={(e) => setForm({ ...form, nome_fantasia: e.target.value })} className="input-field" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">{form.tipo_pessoa === 'PJ' ? 'CNPJ' : 'CPF'}</label>
|
||||||
|
<input type="text" value={form.cpf_cnpj} onChange={(e) => setForm({ ...form, cpf_cnpj: e.target.value })} className="input-field" required />
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">E-mail</label>
|
||||||
|
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Telefone</label>
|
||||||
|
<input type="text" value={form.telefone} onChange={(e) => setForm({ ...form, telefone: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Nome do Contato</label>
|
||||||
|
<input type="text" value={form.nome_contato} onChange={(e) => setForm({ ...form, nome_contato: e.target.value })} className="input-field" placeholder="Nome da pessoa de contato" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full border-t border-border pt-3 mt-2">
|
||||||
|
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={form.possui_politica_ambiental} onChange={e => setForm({ ...form, possui_politica_ambiental: e.target.checked })} className="rounded border-gray-300 text-green-600" />
|
||||||
|
Possui Política Ambiental
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={form.possui_politica_sst} onChange={e => setForm({ ...form, possui_politica_sst: e.target.checked })} className="rounded border-gray-300 text-green-600" />
|
||||||
|
Possui Política SST
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={form.declara_uso_epi} onChange={e => setForm({ ...form, declara_uso_epi: e.target.checked })} className="rounded border-gray-300 text-green-600" />
|
||||||
|
Declara Uso de EPI
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={form.equipe_treinada} onChange={e => setForm({ ...form, equipe_treinada: e.target.checked })} className="rounded border-gray-300 text-green-600" />
|
||||||
|
Equipe Treinada
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Classificação ESG</label>
|
||||||
|
<select value={form.classificacao_esg} onChange={e => setForm({ ...form, classificacao_esg: e.target.value })} className="input-field">
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
<option value="Básico">Básico</option>
|
||||||
|
<option value="Intermediário">Intermediário</option>
|
||||||
|
<option value="Avançado">Avançado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
129
frontend/src/pages/Importacao.tsx
Normal file
129
frontend/src/pages/Importacao.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { Upload, FileSpreadsheet, CheckCircle, AlertCircle, Loader2, X } from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
sucesso: boolean
|
||||||
|
registros_importados?: number
|
||||||
|
erros?: string[]
|
||||||
|
mensagem?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Importacao() {
|
||||||
|
const [tipo, setTipo] = useState('orcamento')
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [result, setResult] = useState<ImportResult | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(false)
|
||||||
|
const f = e.dataTransfer.files[0]
|
||||||
|
if (f) setFile(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('tipo', tipo)
|
||||||
|
const { data } = await api.post('/import/excel', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
setResult(data)
|
||||||
|
} catch (err: any) {
|
||||||
|
setResult({ sucesso: false, mensagem: err.response?.data?.error || 'Erro ao importar arquivo.' })
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-2">
|
||||||
|
<Upload className="w-8 h-8 text-primary" /> Importação de Dados
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray mt-1">Importe planilhas Excel para atualizar orçamentos ou demandas.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card max-w-2xl mx-auto space-y-6">
|
||||||
|
{/* Tipo selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Tipo de importação</label>
|
||||||
|
<select value={tipo} onChange={e => setTipo(e.target.value)} className="input-field">
|
||||||
|
<option value="orcamento">Orçamento</option>
|
||||||
|
<option value="demandas">Demandas</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={`border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-all ${
|
||||||
|
dragging ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => { if (e.target.files?.[0]) setFile(e.target.files[0]) }}
|
||||||
|
/>
|
||||||
|
<FileSpreadsheet className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
{file ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<span className="font-medium text-text">{file.name}</span>
|
||||||
|
<button onClick={e => { e.stopPropagation(); setFile(null) }} className="p-1 rounded-full hover:bg-gray-100">
|
||||||
|
<X className="w-4 h-4 text-gray" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-medium text-text">Arraste o arquivo aqui ou clique para selecionar</p>
|
||||||
|
<p className="text-sm text-gray mt-1">Formatos aceitos: .xlsx, .xls, .csv</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload button */}
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!file || uploading}
|
||||||
|
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Upload className="w-5 h-5" />}
|
||||||
|
{uploading ? 'Importando...' : 'Importar'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{result && (
|
||||||
|
<div className={`p-4 rounded-xl ${result.sucesso ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{result.sucesso ? <CheckCircle className="w-5 h-5 text-green-500" /> : <AlertCircle className="w-5 h-5 text-red-500" />}
|
||||||
|
<span className="font-medium">{result.sucesso ? 'Importação concluída!' : 'Erro na importação'}</span>
|
||||||
|
</div>
|
||||||
|
{result.registros_importados !== undefined && (
|
||||||
|
<p className="text-sm text-gray">{result.registros_importados} registros importados com sucesso.</p>
|
||||||
|
)}
|
||||||
|
{result.mensagem && <p className="text-sm text-gray">{result.mensagem}</p>}
|
||||||
|
{result.erros && result.erros.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{result.erros.map((e, i) => <li key={i} className="text-sm text-red-600">• {e}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
frontend/src/pages/KPIs.tsx
Normal file
115
frontend/src/pages/KPIs.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { BarChart3, Loader2, TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
interface KPI {
|
||||||
|
id?: number
|
||||||
|
nome: string
|
||||||
|
valor: number
|
||||||
|
unidade: string
|
||||||
|
categoria: string
|
||||||
|
status: string // verde, amarelo, vermelho
|
||||||
|
descricao?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KPIDashboard {
|
||||||
|
kpis: KPI[]
|
||||||
|
calculados: KPI[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KPIs() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [data, setData] = useState<KPIDashboard | null>(null)
|
||||||
|
const [categoria, setCategoria] = useState('')
|
||||||
|
const [ano, setAno] = useState(new Date().getFullYear())
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [categoria, ano])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params: any = { ano }
|
||||||
|
if (categoria) params.categoria = categoria
|
||||||
|
const { data } = await api.get('/kpis/dashboard', { params })
|
||||||
|
setData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching KPIs:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { bg: string, border: string, badge: string, icon: React.ReactNode }> = {
|
||||||
|
verde: { bg: 'bg-green-50', border: 'border-green-200', badge: 'badge-success', icon: <TrendingUp className="w-4 h-4 text-green-500" /> },
|
||||||
|
amarelo: { bg: 'bg-yellow-50', border: 'border-yellow-200', badge: 'badge-warning', icon: <Minus className="w-4 h-4 text-yellow-500" /> },
|
||||||
|
vermelho: { bg: 'bg-red-50', border: 'border-red-200', badge: 'badge-error', icon: <TrendingDown className="w-4 h-4 text-red-500" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
const allKpis = [...(data?.calculados || []), ...(data?.kpis || [])]
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-8 h-8 text-primary" /> Indicadores KPI
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray mt-1">Acompanhe os principais indicadores de performance.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select value={categoria} onChange={e => setCategoria(e.target.value)} className="input-field text-sm">
|
||||||
|
<option value="">Todas categorias</option>
|
||||||
|
<option value="financeiro">Financeiro</option>
|
||||||
|
<option value="operacional">Operacional</option>
|
||||||
|
<option value="qualidade">Qualidade</option>
|
||||||
|
<option value="fornecedores">Fornecedores</option>
|
||||||
|
</select>
|
||||||
|
<select value={ano} onChange={e => setAno(Number(e.target.value))} className="input-field text-sm">
|
||||||
|
{[2024, 2025, 2026].map(y => <option key={y} value={y}>{y}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allKpis.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
|
{allKpis.map((kpi, i) => {
|
||||||
|
const cfg = statusConfig[kpi.status] || statusConfig.verde
|
||||||
|
return (
|
||||||
|
<div key={i} className={`card border ${cfg.border} ${cfg.bg} hover:shadow-lg transition-all duration-300`}>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<span className={`${cfg.badge} text-xs px-2 py-1 rounded-full`}>
|
||||||
|
{kpi.status === 'verde' ? 'Bom' : kpi.status === 'amarelo' ? 'Atenção' : 'Crítico'}
|
||||||
|
</span>
|
||||||
|
{cfg.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-text mb-1">{kpi.nome}</h3>
|
||||||
|
{kpi.descricao && <p className="text-xs text-gray mb-3">{kpi.descricao}</p>}
|
||||||
|
<div className="flex items-end gap-1">
|
||||||
|
<span className="text-3xl font-bold text-text">
|
||||||
|
{typeof kpi.valor === 'number' ? kpi.valor.toLocaleString('pt-BR', { maximumFractionDigits: 1 }) : kpi.valor}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray mb-1">{kpi.unidade}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray mt-2 capitalize">{kpi.categoria}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<BarChart3 className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray">Nenhum KPI encontrado</h3>
|
||||||
|
<p className="text-sm text-gray-light mt-1">Os indicadores serão calculados automaticamente conforme dados são registrados.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ export default function Landing() {
|
|||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/20">
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/20">
|
||||||
<Flame className="w-6 h-6 text-white" />
|
<Flame className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-xl text-text">HEFESTO</span>
|
<span className="font-bold text-xl text-text">Nexus Facilities</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
@@ -98,11 +98,11 @@ export default function Landing() {
|
|||||||
|
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-text mb-6 leading-tight">
|
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-text mb-6 leading-tight">
|
||||||
Forje o{' '}
|
Orçamentos sob{' '}
|
||||||
<span className="bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
<span className="bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||||
controle
|
Controle
|
||||||
</span>
|
</span>
|
||||||
{' '}dos seus custos
|
, Resultados sob Medida
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Subheadline */}
|
{/* Subheadline */}
|
||||||
@@ -174,7 +174,7 @@ export default function Landing() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl sm:text-4xl font-bold text-text mb-6">
|
<h2 className="text-3xl sm:text-4xl font-bold text-text mb-6">
|
||||||
Por que escolher o{' '}
|
Por que escolher o{' '}
|
||||||
<span className="text-primary">HEFESTO</span>?
|
<span className="text-primary">Nexus Facilities</span>?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray text-lg mb-8">
|
<p className="text-gray text-lg mb-8">
|
||||||
Nossa plataforma foi desenvolvida especificamente para atender às necessidades
|
Nossa plataforma foi desenvolvida especificamente para atender às necessidades
|
||||||
@@ -222,7 +222,7 @@ export default function Landing() {
|
|||||||
Pronto para transformar sua gestão de facilities?
|
Pronto para transformar sua gestão de facilities?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-white/70 text-lg mb-8 max-w-2xl mx-auto">
|
<p className="text-white/70 text-lg mb-8 max-w-2xl mx-auto">
|
||||||
Comece agora mesmo e descubra como o HEFESTO pode ajudar sua empresa
|
Comece agora mesmo e descubra como o Nexus Facilities pode ajudar sua empresa
|
||||||
a ter mais controle e eficiência.
|
a ter mais controle e eficiência.
|
||||||
</p>
|
</p>
|
||||||
<Link to="/login" className="btn-primary text-lg !py-4 !px-8 inline-flex items-center gap-2">
|
<Link to="/login" className="btn-primary text-lg !py-4 !px-8 inline-flex items-center gap-2">
|
||||||
@@ -240,10 +240,10 @@ export default function Landing() {
|
|||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||||
<Flame className="w-5 h-5 text-white" />
|
<Flame className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-text">HEFESTO</span>
|
<span className="font-bold text-text">Nexus Facilities</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray text-sm">
|
<p className="text-gray text-sm">
|
||||||
© 2026 HEFESTO. Todos os direitos reservados.
|
© 2026 Nexus Facilities. Todos os direitos reservados.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ export default function Login() {
|
|||||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary to-accent mb-6 shadow-xl shadow-primary/30 animate-pulse-glow">
|
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary to-accent mb-6 shadow-xl shadow-primary/30 animate-pulse-glow">
|
||||||
<Flame className="w-10 h-10 text-white" />
|
<Flame className="w-10 h-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-text mb-2">HEFESTO</h1>
|
<h1 className="text-3xl font-bold text-text mb-2">Nexus Facilities</h1>
|
||||||
<p className="text-gray">Sistema de Controle Orçamentário</p>
|
<p className="text-gray">Gestão de Facilities</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login card */}
|
{/* Login card */}
|
||||||
@@ -164,7 +164,7 @@ export default function Login() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<p className="text-center text-gray-light text-sm mt-8">
|
<p className="text-center text-gray-light text-sm mt-8">
|
||||||
© 2026 HEFESTO. Todos os direitos reservados.
|
© 2026 Nexus Facilities. Todos os direitos reservados.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
170
frontend/src/pages/Metas.tsx
Normal file
170
frontend/src/pages/Metas.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Target, Loader2, Plus, X } from 'lucide-react'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
interface Meta {
|
||||||
|
id: number
|
||||||
|
nome: string
|
||||||
|
descricao: string
|
||||||
|
tipo: string
|
||||||
|
valor_meta: number
|
||||||
|
valor_atual: number
|
||||||
|
unidade: string
|
||||||
|
status: string
|
||||||
|
data_inicio: string
|
||||||
|
data_fim: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Metas() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [metas, setMetas] = useState<Meta[]>([])
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [filterTipo, setFilterTipo] = useState('')
|
||||||
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
|
const [form, setForm] = useState({ nome: '', descricao: '', tipo: 'financeiro', valor_meta: 0, unidade: '', data_inicio: '', data_fim: '' })
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [filterTipo, filterStatus])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (filterTipo) params.tipo = filterTipo
|
||||||
|
if (filterStatus) params.status = filterStatus
|
||||||
|
const { data } = await api.get('/metas/progresso', { params })
|
||||||
|
setMetas(Array.isArray(data) ? data : data?.metas || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching metas:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.post('/metas', form)
|
||||||
|
setShowForm(false)
|
||||||
|
setForm({ nome: '', descricao: '', tipo: 'financeiro', valor_meta: 0, unidade: '', data_inicio: '', data_fim: '' })
|
||||||
|
fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating meta:', err)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge: Record<string, string> = {
|
||||||
|
em_andamento: 'badge-info',
|
||||||
|
atingida: 'badge-success',
|
||||||
|
atrasada: 'badge-error',
|
||||||
|
}
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
em_andamento: 'Em Andamento',
|
||||||
|
atingida: 'Atingida',
|
||||||
|
atrasada: 'Atrasada',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-96"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-2">
|
||||||
|
<Target className="w-8 h-8 text-primary" /> Metas & Progresso
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray mt-1">Acompanhe metas e objetivos da organização.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select value={filterTipo} onChange={e => setFilterTipo(e.target.value)} className="input-field text-sm">
|
||||||
|
<option value="">Todos tipos</option>
|
||||||
|
<option value="financeiro">Financeiro</option>
|
||||||
|
<option value="operacional">Operacional</option>
|
||||||
|
<option value="qualidade">Qualidade</option>
|
||||||
|
<option value="esg">ESG</option>
|
||||||
|
</select>
|
||||||
|
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="input-field text-sm">
|
||||||
|
<option value="">Todos status</option>
|
||||||
|
<option value="em_andamento">Em Andamento</option>
|
||||||
|
<option value="atingida">Atingida</option>
|
||||||
|
<option value="atrasada">Atrasada</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={() => setShowForm(true)} className="btn-primary text-sm flex items-center gap-1">
|
||||||
|
<Plus className="w-4 h-4" /> Nova Meta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Form Modal */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="card border-2 border-primary/20">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-text">Nova Meta</h3>
|
||||||
|
<button onClick={() => setShowForm(false)} className="p-1 rounded hover:bg-gray-100"><X className="w-5 h-5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<input placeholder="Nome" value={form.nome} onChange={e => setForm({...form, nome: e.target.value})} className="input-field" />
|
||||||
|
<select value={form.tipo} onChange={e => setForm({...form, tipo: e.target.value})} className="input-field">
|
||||||
|
<option value="financeiro">Financeiro</option>
|
||||||
|
<option value="operacional">Operacional</option>
|
||||||
|
<option value="qualidade">Qualidade</option>
|
||||||
|
<option value="esg">ESG</option>
|
||||||
|
</select>
|
||||||
|
<input placeholder="Valor Meta" type="number" value={form.valor_meta || ''} onChange={e => setForm({...form, valor_meta: Number(e.target.value)})} className="input-field" />
|
||||||
|
<input placeholder="Unidade (ex: R$, %, un)" value={form.unidade} onChange={e => setForm({...form, unidade: e.target.value})} className="input-field" />
|
||||||
|
<input type="date" value={form.data_inicio} onChange={e => setForm({...form, data_inicio: e.target.value})} className="input-field" />
|
||||||
|
<input type="date" value={form.data_fim} onChange={e => setForm({...form, data_fim: e.target.value})} className="input-field" />
|
||||||
|
<textarea placeholder="Descrição" value={form.descricao} onChange={e => setForm({...form, descricao: e.target.value})} className="input-field sm:col-span-2" rows={2} />
|
||||||
|
</div>
|
||||||
|
<button onClick={handleCreate} disabled={saving || !form.nome} className="btn-primary mt-4 flex items-center gap-2 disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />} Criar Meta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Goals Grid */}
|
||||||
|
{metas.length > 0 ? (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{metas.map(meta => {
|
||||||
|
const pct = meta.valor_meta > 0 ? Math.min((meta.valor_atual / meta.valor_meta) * 100, 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={meta.id} className="card hover:shadow-lg transition-all duration-300">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h3 className="font-semibold text-text">{meta.nome}</h3>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${statusBadge[meta.status] || 'badge-info'}`}>
|
||||||
|
{statusLabel[meta.status] || meta.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{meta.descricao && <p className="text-sm text-gray mb-3">{meta.descricao}</p>}
|
||||||
|
<div className="bg-gray-100 rounded-full h-4 overflow-hidden mb-2">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${
|
||||||
|
meta.status === 'atingida' ? 'bg-green-500' :
|
||||||
|
meta.status === 'atrasada' ? 'bg-red-500' : 'bg-gradient-to-r from-primary to-accent'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray">
|
||||||
|
<span>{meta.valor_atual?.toLocaleString('pt-BR')} / {meta.valor_meta?.toLocaleString('pt-BR')} {meta.unidade}</span>
|
||||||
|
<span className="font-medium">{pct.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray mt-2 capitalize">{meta.tipo}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<Target className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray">Nenhuma meta encontrada</h3>
|
||||||
|
<p className="text-sm text-gray-light mt-1">Crie metas para acompanhar o progresso da organização.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
Wallet,
|
Wallet, Search, Plus, TrendingUp, TrendingDown,
|
||||||
Search,
|
ChevronLeft, ChevronRight, Loader2, Calendar, Edit2, Trash2
|
||||||
Filter,
|
|
||||||
Plus,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Loader2,
|
|
||||||
Calendar
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { Orcamento } from '../types'
|
import { Orcamento } from '../types'
|
||||||
|
import { useLookups } from '../hooks/useLookups'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; class: string }> = {
|
const statusConfig: Record<string, { label: string; class: string }> = {
|
||||||
'dentro_limite': { label: 'Dentro do Limite', class: 'badge-success' },
|
'dentro_limite': { label: 'Dentro do Limite', class: 'badge-success' },
|
||||||
@@ -21,47 +16,72 @@ const statusConfig: Record<string, { label: string; class: string }> = {
|
|||||||
'disponivel': { label: 'Disponível', class: 'badge-info' },
|
'disponivel': { label: 'Disponível', class: 'badge-info' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockOrcamentos: Orcamento[] = [
|
function computeStatus(orc: Orcamento): string {
|
||||||
{ id: 1, ano: 2024, mes: 1, categoria: 'Manutenção Predial', valor_previsto: 50000, valor_realizado: 45000, status: 'dentro_limite' },
|
const planejado = orc.valor_planejado || orc.valor_previsto || 0
|
||||||
{ id: 2, ano: 2024, mes: 1, categoria: 'Limpeza', valor_previsto: 30000, valor_realizado: 28500, status: 'dentro_limite' },
|
const realizado = orc.valor_realizado || 0
|
||||||
{ id: 3, ano: 2024, mes: 1, categoria: 'Segurança', valor_previsto: 25000, valor_realizado: 26500, status: 'alerta' },
|
if (planejado === 0) return 'disponivel'
|
||||||
{ id: 4, ano: 2024, mes: 1, categoria: 'Jardinagem', valor_previsto: 10000, valor_realizado: 12500, status: 'excedido' },
|
const pct = realizado / planejado
|
||||||
{ id: 5, ano: 2024, mes: 2, categoria: 'Manutenção Predial', valor_previsto: 55000, valor_realizado: 42000, status: 'disponivel' },
|
if (pct > 1) return 'excedido'
|
||||||
{ id: 6, ano: 2024, mes: 2, categoria: 'Utilities', valor_previsto: 35000, valor_realizado: 33000, status: 'dentro_limite' },
|
if (pct > 0.85) return 'alerta'
|
||||||
]
|
if (realizado === 0) return 'disponivel'
|
||||||
|
return 'dentro_limite'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValorPlanejado(orc: Orcamento): number {
|
||||||
|
return orc.valor_planejado ?? orc.valor_previsto ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = { categoria_id: '', centro_custo_id: '', ano: 2026, mes: 1, valor_planejado: 0, tipo_periodo: 'mensal' }
|
||||||
|
|
||||||
export default function Orcamentos() {
|
export default function Orcamentos() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [orcamentos, setOrcamentos] = useState<Orcamento[]>([])
|
const [orcamentos, setOrcamentos] = useState<any[]>([])
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [selectedYear, setSelectedYear] = useState(2024)
|
const [selectedYear, setSelectedYear] = useState(2026)
|
||||||
const [selectedMonth, setSelectedMonth] = useState(0) // 0 = todos
|
const [selectedMonth, setSelectedMonth] = useState(0)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const perPage = 20
|
||||||
|
const { categoriaMap, centrosCustoMap, categorias, centrosCusto, loading: lookupsLoading } = useLookups()
|
||||||
|
|
||||||
useEffect(() => {
|
// Capex/Opex chart data
|
||||||
fetchOrcamentos()
|
const [investData, setInvestData] = useState<any[]>([])
|
||||||
}, [])
|
|
||||||
|
useEffect(() => { fetchOrcamentos(); fetchInvestData() }, [])
|
||||||
|
|
||||||
const fetchOrcamentos = async () => {
|
const fetchOrcamentos = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/orcamento')
|
const { data } = await api.get('/orcamento')
|
||||||
setOrcamentos(data.length > 0 ? data : mockOrcamentos)
|
setOrcamentos(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching orcamentos:', err)
|
console.error('Error fetching orcamentos:', err)
|
||||||
setOrcamentos(mockOrcamentos)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchInvestData = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/orcamento/resumo-investimento', { params: { ano: 2026 } })
|
||||||
|
setInvestData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching invest data:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filteredOrcamentos = orcamentos.filter(orc => {
|
const filteredOrcamentos = orcamentos.filter(orc => {
|
||||||
const matchesSearch = orc.categoria.toLowerCase().includes(searchTerm.toLowerCase())
|
const catName = categoriaMap[orc.categoria_id] || orc.categoria || orc.categoria_id || ''
|
||||||
|
const matchesSearch = searchTerm === '' || catName.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
const matchesYear = orc.ano === selectedYear
|
const matchesYear = orc.ano === selectedYear
|
||||||
const matchesMonth = selectedMonth === 0 || orc.mes === selectedMonth
|
const matchesMonth = selectedMonth === 0 || orc.mes === selectedMonth
|
||||||
return matchesSearch && matchesYear && matchesMonth
|
return matchesSearch && matchesYear && matchesMonth
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalPrevisto = filteredOrcamentos.reduce((acc, orc) => acc + orc.valor_previsto, 0)
|
const totalPrevisto = filteredOrcamentos.reduce((acc, orc) => acc + getValorPlanejado(orc), 0)
|
||||||
const totalRealizado = filteredOrcamentos.reduce((acc, orc) => acc + orc.valor_realizado, 0)
|
const totalRealizado = filteredOrcamentos.reduce((acc, orc) => acc + (orc.valor_realizado || 0), 0)
|
||||||
const economia = totalPrevisto - totalRealizado
|
const economia = totalPrevisto - totalRealizado
|
||||||
|
|
||||||
const months = [
|
const months = [
|
||||||
@@ -69,15 +89,58 @@ export default function Orcamentos() {
|
|||||||
'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'
|
'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'
|
||||||
]
|
]
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) =>
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
||||||
}
|
|
||||||
|
|
||||||
const getPercentage = (realizado: number, previsto: number) => {
|
const getPercentage = (realizado: number, previsto: number) => {
|
||||||
|
if (!previsto) return '0.0'
|
||||||
return ((realizado / previsto) * 100).toFixed(1)
|
return ((realizado / previsto) * 100).toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const openNew = () => {
|
||||||
|
setEditId(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (orc: Orcamento) => {
|
||||||
|
setEditId(orc.id)
|
||||||
|
setForm({ categoria_id: orc.categoria_id, centro_custo_id: orc.centro_custo_id, ano: orc.ano, mes: orc.mes, valor_planejado: getValorPlanejado(orc), tipo_periodo: orc.tipo_periodo || 'mensal' })
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload: any = { ...form }
|
||||||
|
payload.valor_anual = payload.tipo_periodo === 'mensal' ? payload.valor_planejado * 12 : payload.valor_planejado
|
||||||
|
if (editId) {
|
||||||
|
await api.patch(`/orcamento/${editId}`, payload)
|
||||||
|
} else {
|
||||||
|
await api.post('/orcamento', payload)
|
||||||
|
}
|
||||||
|
setShowModal(false)
|
||||||
|
fetchOrcamentos()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving:', err)
|
||||||
|
alert('Erro ao salvar orçamento')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Tem certeza que deseja excluir este orçamento?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/orcamento/${id}`)
|
||||||
|
fetchOrcamentos()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting:', err)
|
||||||
|
alert('Erro ao excluir')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || lookupsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
@@ -87,24 +150,22 @@ export default function Orcamentos() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">Orçamentos</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Orçamentos</h1>
|
||||||
<p className="text-gray mt-1">Gerencie e acompanhe os orçamentos de facilities</p>
|
<p className="text-gray mt-1">Gerencie e acompanhe os orçamentos de facilities</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
<button onClick={openNew} className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
Novo Orçamento
|
Novo Orçamento
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div className="card bg-gradient-to-br from-primary to-accent text-white">
|
<div className="card bg-gradient-to-br from-primary to-accent text-white">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white/80 text-sm">Total Previsto</p>
|
<p className="text-white/80 text-sm">Total Planejado</p>
|
||||||
<p className="text-2xl font-bold mt-1">{formatCurrency(totalPrevisto)}</p>
|
<p className="text-2xl font-bold mt-1">{formatCurrency(totalPrevisto)}</p>
|
||||||
</div>
|
</div>
|
||||||
<Wallet className="w-10 h-10 opacity-80" />
|
<Wallet className="w-10 h-10 opacity-80" />
|
||||||
@@ -130,59 +191,71 @@ export default function Orcamentos() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Capex vs Opex Charts */}
|
||||||
|
{investData.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-text mb-4">Orçamento por Tipo de Investimento</h2>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={investData} barGap={8}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
||||||
|
<XAxis dataKey="tipo" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }}
|
||||||
|
tickFormatter={(v) => `R$ ${(v / 1000).toFixed(0)}k`} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: '#fff', border: '1px solid #E0E0E0', borderRadius: '12px' }}
|
||||||
|
formatter={(value: number) => formatCurrency(value)} />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="planejado" fill="#1A237E" radius={[4, 4, 0, 0]} name="Planejado" />
|
||||||
|
<Bar dataKey="realizado" fill="#E65100" radius={[4, 4, 0, 0]} name="Realizado" />
|
||||||
|
<Bar dataKey="economia" fill="#4CAF50" radius={[4, 4, 0, 0]} name="Economia" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
<input
|
<input type="text" placeholder="Buscar por categoria..." value={searchTerm}
|
||||||
type="text"
|
onChange={(e) => setSearchTerm(e.target.value)} className="input-field pl-12" />
|
||||||
placeholder="Buscar por categoria..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="input-field pl-12"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<select
|
<select value={selectedYear} onChange={(e) => setSelectedYear(Number(e.target.value))} className="input-field w-32">
|
||||||
value={selectedYear}
|
<option value={2026}>2026</option><option value={2025}>2025</option><option value={2024}>2024</option>
|
||||||
onChange={(e) => setSelectedYear(Number(e.target.value))}
|
|
||||||
className="input-field w-32"
|
|
||||||
>
|
|
||||||
<option value={2024}>2024</option>
|
|
||||||
<option value={2023}>2023</option>
|
|
||||||
<option value={2022}>2022</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select value={selectedMonth} onChange={(e) => setSelectedMonth(Number(e.target.value))} className="input-field w-40">
|
||||||
value={selectedMonth}
|
{months.map((month, index) => (<option key={index} value={index}>{month}</option>))}
|
||||||
onChange={(e) => setSelectedMonth(Number(e.target.value))}
|
|
||||||
className="input-field w-40"
|
|
||||||
>
|
|
||||||
{months.map((month, index) => (
|
|
||||||
<option key={index} value={index}>{month}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="card !p-0 overflow-hidden">
|
<div className="card !p-0 overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="table-header">
|
<thead className="table-header">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="table-cell">Categoria</th>
|
<th className="table-cell">Categoria</th>
|
||||||
|
<th className="table-cell">Tipo Investimento</th>
|
||||||
|
<th className="table-cell">Centro de Custo</th>
|
||||||
<th className="table-cell">Período</th>
|
<th className="table-cell">Período</th>
|
||||||
<th className="table-cell text-right">Previsto</th>
|
<th className="table-cell text-right">Planejado</th>
|
||||||
|
<th className="table-cell text-center">Período</th>
|
||||||
|
<th className="table-cell text-right">Valor Anual</th>
|
||||||
<th className="table-cell text-right">Realizado</th>
|
<th className="table-cell text-right">Realizado</th>
|
||||||
<th className="table-cell text-center">% Utilizado</th>
|
<th className="table-cell text-center">% Utilizado</th>
|
||||||
<th className="table-cell text-center">Status</th>
|
<th className="table-cell text-center">Status</th>
|
||||||
|
<th className="table-cell text-center">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredOrcamentos.map((orcamento) => {
|
{filteredOrcamentos.slice((page - 1) * perPage, page * perPage).map((orcamento) => {
|
||||||
const percentage = Number(getPercentage(orcamento.valor_realizado, orcamento.valor_previsto))
|
const planejado = getValorPlanejado(orcamento)
|
||||||
|
const percentage = Number(getPercentage(orcamento.valor_realizado, planejado))
|
||||||
|
const status = computeStatus(orcamento)
|
||||||
|
const tipoInvest = orcamento.tipo_investimento || ''
|
||||||
return (
|
return (
|
||||||
<tr key={orcamento.id} className="table-row">
|
<tr key={orcamento.id} className="table-row">
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
@@ -190,40 +263,62 @@ export default function Orcamentos() {
|
|||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
<Wallet className="w-5 h-5 text-primary" />
|
<Wallet className="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">{orcamento.categoria}</span>
|
<span className="font-medium">{categoriaMap[orcamento.categoria_id] || orcamento.categoria || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
{tipoInvest ? (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||||
|
tipoInvest === 'Capex'
|
||||||
|
? 'bg-blue-100 text-blue-700'
|
||||||
|
: tipoInvest === 'Opex'
|
||||||
|
? 'bg-orange-100 text-orange-700'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{tipoInvest}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray text-sm">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<span>{centrosCustoMap[orcamento.centro_custo_id] || '-'}</span>
|
||||||
|
</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4 text-gray" />
|
<Calendar className="w-4 h-4 text-gray" />
|
||||||
<span>{months[orcamento.mes]}/{orcamento.ano}</span>
|
<span>{months[orcamento.mes]}/{orcamento.ano}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="table-cell text-right font-medium">
|
<td className="table-cell text-right font-medium">{formatCurrency(planejado)}</td>
|
||||||
{formatCurrency(orcamento.valor_previsto)}
|
<td className="table-cell text-center">
|
||||||
|
<span className="badge badge-info text-xs">{orcamento.tipo_periodo === 'anual' ? 'Anual' : 'Mensal'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="table-cell text-right font-medium">
|
<td className="table-cell text-right font-medium">
|
||||||
{formatCurrency(orcamento.valor_realizado)}
|
{formatCurrency(orcamento.valor_anual || (orcamento.tipo_periodo === 'anual' ? planejado : planejado * 12))}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="table-cell text-right font-medium">{formatCurrency(orcamento.valor_realizado)}</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
<div
|
<div className={`h-full rounded-full transition-all ${percentage > 100 ? 'bg-red-500' : percentage > 85 ? 'bg-amber-500' : 'bg-green-500'}`}
|
||||||
className={`h-full rounded-full transition-all ${
|
style={{ width: `${Math.min(percentage, 100)}%` }} />
|
||||||
percentage > 100 ? 'bg-red-500' :
|
|
||||||
percentage > 85 ? 'bg-amber-500' :
|
|
||||||
'bg-green-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium w-14 text-right">{percentage}%</span>
|
<span className="text-sm font-medium w-14 text-right">{percentage}%</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="table-cell text-center">
|
<td className="table-cell text-center">
|
||||||
<span className={statusConfig[orcamento.status]?.class || 'badge-neutral'}>
|
<span className={statusConfig[status]?.class || 'badge-neutral'}>{statusConfig[status]?.label || status}</span>
|
||||||
{statusConfig[orcamento.status]?.label || orcamento.status}
|
</td>
|
||||||
</span>
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button onClick={() => openEdit(orcamento)} className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(orcamento.id)} className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
@@ -231,24 +326,69 @@ export default function Orcamentos() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||||
<span className="text-sm text-gray">
|
<span className="text-sm text-gray">Pág {page}/{Math.ceil(filteredOrcamentos.length / perPage) || 1} — {filteredOrcamentos.length} registros</span>
|
||||||
Mostrando {filteredOrcamentos.length} de {orcamentos.length} registros
|
<div className="flex gap-2">
|
||||||
</span>
|
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} className="btn-outline !py-1 !px-3 text-sm disabled:opacity-30">
|
||||||
<div className="flex items-center gap-2">
|
<ChevronLeft className="w-4 h-4" />
|
||||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray disabled:opacity-50" disabled>
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</button>
|
</button>
|
||||||
<button className="px-3 py-1 rounded-lg bg-primary text-white text-sm">1</button>
|
<button onClick={() => setPage(p => Math.min(Math.ceil(filteredOrcamentos.length / perPage), p + 1))} disabled={page >= Math.ceil(filteredOrcamentos.length / perPage)} className="btn-outline !py-1 !px-3 text-sm disabled:opacity-30">
|
||||||
<button className="px-3 py-1 rounded-lg hover:bg-gray-100 text-gray text-sm">2</button>
|
<ChevronRight className="w-4 h-4" />
|
||||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray">
|
|
||||||
<ChevronRight className="w-5 h-5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal open={showModal} onClose={() => setShowModal(false)} title={editId ? 'Editar Orçamento' : 'Novo Orçamento'} onSubmit={handleSubmit} submitLabel={editId ? 'Salvar' : 'Criar Orçamento'} loading={saving}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Categoria</label>
|
||||||
|
<select value={form.categoria_id} onChange={(e) => setForm({ ...form, categoria_id: e.target.value })} className="input-field" required>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{categorias.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Centro de Custo</label>
|
||||||
|
<select value={form.centro_custo_id} onChange={(e) => setForm({ ...form, centro_custo_id: e.target.value })} className="input-field" required>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{centrosCusto.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Ano</label>
|
||||||
|
<select value={form.ano} onChange={(e) => setForm({ ...form, ano: Number(e.target.value) })} className="input-field">
|
||||||
|
<option value={2024}>2024</option><option value={2025}>2025</option><option value={2026}>2026</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Mês</label>
|
||||||
|
<select value={form.mes} onChange={(e) => setForm({ ...form, mes: Number(e.target.value) })} className="input-field">
|
||||||
|
{months.slice(1).map((m, i) => <option key={i+1} value={i+1}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Tipo do Período</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="tipo_periodo" value="mensal" checked={form.tipo_periodo === 'mensal'} onChange={() => setForm({ ...form, tipo_periodo: 'mensal' })} className="accent-primary" />
|
||||||
|
<span className="text-sm">Mensal</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="tipo_periodo" value="anual" checked={form.tipo_periodo === 'anual'} onChange={() => setForm({ ...form, tipo_periodo: 'anual' })} className="accent-primary" />
|
||||||
|
<span className="text-sm">Anual</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Valor Planejado</label>
|
||||||
|
<input type="number" step="0.01" value={form.valor_planejado} onChange={(e) => setForm({ ...form, valor_planejado: Number(e.target.value) })} className="input-field" required />
|
||||||
|
{form.tipo_periodo === 'mensal' && form.valor_planejado > 0 && (
|
||||||
|
<p className="text-xs text-gray mt-1">Valor anual calculado: {formatCurrency(form.valor_planejado * 12)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,153 +1,231 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
ClipboardList,
|
ClipboardList, Search, Plus, Eye, Edit2, Trash2, Loader2,
|
||||||
Search,
|
Clock, CheckCircle2, AlertCircle, PlayCircle, ChevronDown, Building2, Calendar, X,
|
||||||
Plus,
|
Upload, Download, FileText
|
||||||
Eye,
|
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
Clock,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
PlayCircle,
|
|
||||||
ChevronDown,
|
|
||||||
Building2,
|
|
||||||
Calendar
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import { OrdemServico } from '../types'
|
import { OrdemServico, Demanda, DocumentoVersao } from '../types'
|
||||||
|
import { useLookups } from '../hooks/useLookups'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
const statusConfig: Record<string, { label: string; class: string; icon: React.ReactNode }> = {
|
||||||
'pendente': { label: 'Pendente', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
'pendente': { label: 'Pendente', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||||
|
'emitida': { label: 'Emitida', class: 'badge-warning', icon: <Clock className="w-3 h-3" /> },
|
||||||
|
'em_cotacao': { label: 'Em Cotação', class: 'badge-info', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
'em_andamento': { label: 'Em Andamento', class: 'badge-info', icon: <PlayCircle className="w-3 h-3" /> },
|
'em_andamento': { label: 'Em Andamento', class: 'badge-info', icon: <PlayCircle className="w-3 h-3" /> },
|
||||||
|
'em_execucao': { label: 'Em Execução', class: 'badge-info', icon: <PlayCircle className="w-3 h-3" /> },
|
||||||
'aguardando_aprovacao': { label: 'Aguard. Aprovação', class: 'badge-warning', icon: <AlertCircle className="w-3 h-3" /> },
|
'aguardando_aprovacao': { label: 'Aguard. Aprovação', class: 'badge-warning', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
'concluida': { label: 'Concluída', class: 'badge-success', icon: <CheckCircle2 className="w-3 h-3" /> },
|
'concluida': { label: 'Concluída', class: 'badge-success', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
'cancelada': { label: 'Cancelada', class: 'badge-error', icon: <AlertCircle className="w-3 h-3" /> },
|
'cancelada': { label: 'Cancelada', class: 'badge-error', icon: <AlertCircle className="w-3 h-3" /> },
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockOrdens: OrdemServico[] = [
|
const emptyForm = { fornecedor_id: '', valor: 0, observacoes: '', demanda_id: '', data: '', uso_material_sustentavel: '', gera_residuos: '', descarte_certificado: '' }
|
||||||
{ id: 1, numero: 'OS-2024-0001', demanda_id: 1, fornecedor_id: 1, fornecedor_nome: 'Tech Solutions', status: 'em_andamento', data_criacao: '2024-01-16', descricao: 'Manutenção preventiva ar condicionado' },
|
|
||||||
{ id: 2, numero: 'OS-2024-0002', demanda_id: 2, fornecedor_id: 2, fornecedor_nome: 'EletroFix', status: 'pendente', data_criacao: '2024-01-15', descricao: 'Troca de lâmpadas LED' },
|
|
||||||
{ id: 3, numero: 'OS-2024-0003', demanda_id: 3, fornecedor_id: 3, fornecedor_nome: 'HidroServ', status: 'concluida', data_criacao: '2024-01-14', descricao: 'Reparo vazamento' },
|
|
||||||
{ id: 4, numero: 'OS-2024-0004', demanda_id: 4, fornecedor_id: 1, fornecedor_nome: 'Tech Solutions', status: 'aguardando_aprovacao', data_criacao: '2024-01-13', descricao: 'Pintura geral sala de reunião' },
|
|
||||||
{ id: 5, numero: 'OS-2024-0005', demanda_id: 5, fornecedor_id: 4, fornecedor_nome: 'ElevaTech', status: 'em_andamento', data_criacao: '2024-01-12', descricao: 'Manutenção elevador social' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function OrdensServico() {
|
export default function OrdensServico() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [ordens, setOrdens] = useState<OrdemServico[]>([])
|
const [ordens, setOrdens] = useState<OrdemServico[]>([])
|
||||||
|
const [demandas, setDemandas] = useState<Demanda[]>([])
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [filterStatus, setFilterStatus] = useState('todos')
|
const [filterStatus, setFilterStatus] = useState('todos')
|
||||||
|
const [filterDemanda, setFilterDemanda] = useState('')
|
||||||
|
const [filterCategoria, setFilterCategoria] = useState('')
|
||||||
|
const [filterFornecedor, setFilterFornecedor] = useState('')
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [showDetail, setShowDetail] = useState<OrdemServico | null>(null)
|
||||||
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
// Documents
|
||||||
|
const [detailDocs, setDetailDocs] = useState<DocumentoVersao[]>([])
|
||||||
|
const [docsLoading, setDocsLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const { fornecedoresMap, fornecedores, categoriaMap, categorias, loading: lookupsLoading } = useLookups()
|
||||||
fetchOrdens()
|
|
||||||
}, [])
|
// Build demanda map
|
||||||
|
const demandaMap: Record<string, string> = {}
|
||||||
|
demandas.forEach(d => { demandaMap[d.id] = d.numero ? `#${d.numero} - ${d.titulo}` : d.titulo })
|
||||||
|
|
||||||
|
// Build demanda->categoria map
|
||||||
|
const demandaCategoriaMap: Record<string, string> = {}
|
||||||
|
demandas.forEach(d => { demandaCategoriaMap[d.id] = d.categoria_id })
|
||||||
|
|
||||||
|
useEffect(() => { fetchOrdens(); fetchDemandas() }, [])
|
||||||
|
|
||||||
const fetchOrdens = async () => {
|
const fetchOrdens = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/ordens-servico')
|
const { data } = await api.get('/ordens-servico')
|
||||||
setOrdens(data.length > 0 ? data : mockOrdens)
|
setOrdens(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching ordens:', err)
|
console.error('Error fetching ordens:', err)
|
||||||
setOrdens(mockOrdens)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredOrdens = ordens.filter(ordem => {
|
const fetchDemandas = async () => {
|
||||||
const matchesSearch = ordem.numero.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
try {
|
||||||
ordem.descricao?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const { data } = await api.get('/demandas')
|
||||||
ordem.fornecedor_nome?.toLowerCase().includes(searchTerm.toLowerCase())
|
setDemandas(data)
|
||||||
const matchesStatus = filterStatus === 'todos' || ordem.status === filterStatus
|
} catch {}
|
||||||
return matchesSearch && matchesStatus
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
return new Date(dateStr).toLocaleDateString('pt-BR')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
const fetchDetailDocs = async (osId: string) => {
|
||||||
return (
|
setDocsLoading(true)
|
||||||
<div className="flex items-center justify-center h-96">
|
try {
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
const { data } = await api.get(`/ordens-servico/${osId}/documentos`)
|
||||||
</div>
|
setDetailDocs(data)
|
||||||
)
|
} catch { setDetailDocs([]) }
|
||||||
|
finally { setDocsLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFornecedorNome = (o: OrdemServico) => o.fornecedor_nome || fornecedoresMap[o.fornecedor_id] || '-'
|
||||||
|
|
||||||
|
const filteredOrdens = ordens.filter(ordem => {
|
||||||
|
const num = typeof ordem.numero === 'number' ? `OS-${String(ordem.numero).padStart(4, '0')}` : (ordem.numero || '')
|
||||||
|
const matchesSearch = num.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(ordem.descricao || ordem.observacoes || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
getFornecedorNome(ordem).toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
const matchesStatus = filterStatus === 'todos' || ordem.status === filterStatus
|
||||||
|
const matchesDemanda = !filterDemanda || ordem.demanda_id === filterDemanda
|
||||||
|
const matchesCategoria = !filterCategoria || demandaCategoriaMap[ordem.demanda_id] === filterCategoria
|
||||||
|
const matchesFornecedor = !filterFornecedor || ordem.fornecedor_id === filterFornecedor
|
||||||
|
return matchesSearch && matchesStatus && matchesDemanda && matchesCategoria && matchesFornecedor
|
||||||
|
})
|
||||||
|
|
||||||
|
const openNew = () => { setEditId(null); setForm(emptyForm); setShowModal(true) }
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await api.patch(`/ordens-servico/${editId}`, form)
|
||||||
|
} else {
|
||||||
|
await api.post('/ordens-servico', { ...form, status: 'emitida' })
|
||||||
|
}
|
||||||
|
setShowModal(false)
|
||||||
|
fetchOrdens()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving:', err)
|
||||||
|
alert('Erro ao salvar OS')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Tem certeza que deseja excluir esta OS?')) return
|
||||||
|
try { await api.delete(`/ordens-servico/${id}`); fetchOrdens() } catch { alert('Erro ao excluir') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUpload = async (files: FileList | null) => {
|
||||||
|
if (!files || !showDetail) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
if (showDetail.fornecedor_id) formData.append('fornecedor_id', showDetail.fornecedor_id)
|
||||||
|
await api.post(`/ordens-servico/${showDetail.id}/documentos`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fetchDetailDocs(showDetail.id)
|
||||||
|
} catch (err) {
|
||||||
|
alert('Erro ao fazer upload')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string | null) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
try { return new Date(dateStr).toLocaleDateString('pt-BR') } catch { return '-' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (v: number | null | undefined) => {
|
||||||
|
if (v == null) return '-'
|
||||||
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDetail = (o: OrdemServico) => {
|
||||||
|
setShowDetail(o)
|
||||||
|
fetchDetailDocs(o.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || lookupsLoading) {
|
||||||
|
return <div className="flex items-center justify-center h-96"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">Ordens de Serviço</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-text">Ordens de Serviço</h1>
|
||||||
<p className="text-gray mt-1">Acompanhe todas as ordens de serviço</p>
|
<p className="text-gray mt-1">Acompanhe todas as ordens de serviço</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
<button onClick={openNew} className="btn-primary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />Nova OS
|
||||||
Nova OS
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-4">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
|
||||||
{Object.entries(statusConfig).map(([key, config]) => {
|
{Object.entries(statusConfig).map(([key, config]) => {
|
||||||
const count = ordens.filter(o => o.status === key).length
|
const count = ordens.filter(o => o.status === key).length
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={key} onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
||||||
key={key}
|
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}>
|
||||||
onClick={() => setFilterStatus(filterStatus === key ? 'todos' : key)}
|
<div className="flex items-center gap-2 mb-2">{config.icon}<span className="text-xs text-gray truncate">{config.label}</span></div>
|
||||||
className={`card text-left transition-all ${filterStatus === key ? 'ring-2 ring-primary' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{config.icon}
|
|
||||||
<span className="text-xs text-gray truncate">{config.label}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xl font-bold text-text">{count}</p>
|
<p className="text-xl font-bold text-text">{count}</p>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-light" />
|
||||||
<input
|
<input type="text" placeholder="Buscar por número, descrição ou fornecedor..." value={searchTerm}
|
||||||
type="text"
|
onChange={(e) => setSearchTerm(e.target.value)} className="input-field pl-12" />
|
||||||
placeholder="Buscar por número, descrição ou fornecedor..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="input-field pl-12"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="input-field appearance-none pr-10 w-full sm:w-48">
|
||||||
value={filterStatus}
|
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
|
||||||
className="input-field appearance-none pr-10 w-full sm:w-48"
|
|
||||||
>
|
|
||||||
<option value="todos">Todos os status</option>
|
<option value="todos">Todos os status</option>
|
||||||
{Object.entries(statusConfig).map(([key, config]) => (
|
{Object.entries(statusConfig).map(([key, config]) => (<option key={key} value={key}>{config.label}</option>))}
|
||||||
<option key={key} value={key}>{config.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray pointer-events-none" />
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<select value={filterDemanda} onChange={(e) => setFilterDemanda(e.target.value)} className="input-field sm:w-60">
|
||||||
|
<option value="">Todas as Demandas</option>
|
||||||
|
{demandas.map(d => <option key={d.id} value={d.id}>{d.numero ? `#${d.numero}` : ''} {d.titulo}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={filterCategoria} onChange={(e) => setFilterCategoria(e.target.value)} className="input-field sm:w-48">
|
||||||
|
<option value="">Todas Categorias</option>
|
||||||
|
{categorias.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={filterFornecedor} onChange={(e) => setFilterFornecedor(e.target.value)} className="input-field sm:w-48">
|
||||||
|
<option value="">Todos Fornecedores</option>
|
||||||
|
{fornecedores.map(f => <option key={f.id} value={f.id}>{f.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="card !p-0 overflow-hidden">
|
<div className="card !p-0 overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="table-header">
|
<thead className="table-header">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="table-cell">Número</th>
|
<th className="table-cell">Número</th>
|
||||||
<th className="table-cell">Descrição</th>
|
<th className="table-cell">Demanda</th>
|
||||||
<th className="table-cell">Fornecedor</th>
|
<th className="table-cell">Fornecedor</th>
|
||||||
|
<th className="table-cell text-right">Valor</th>
|
||||||
<th className="table-cell">Data</th>
|
<th className="table-cell">Data</th>
|
||||||
<th className="table-cell text-center">Status</th>
|
<th className="table-cell text-center">Status</th>
|
||||||
<th className="table-cell text-center">Ações</th>
|
<th className="table-cell text-center">Ações</th>
|
||||||
@@ -161,41 +239,26 @@ export default function OrdensServico() {
|
|||||||
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
||||||
<ClipboardList className="w-5 h-5 text-secondary" />
|
<ClipboardList className="w-5 h-5 text-secondary" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono font-semibold text-primary">{ordem.numero}</span>
|
<span className="font-mono font-semibold text-primary">{typeof ordem.numero === 'number' ? `OS-${String(ordem.numero).padStart(4, '0')}` : ordem.numero}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="table-cell"><span className="line-clamp-1 text-sm">{demandaMap[ordem.demanda_id] || '-'}</span></td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<span className="line-clamp-1">{ordem.descricao}</span>
|
<div className="flex items-center gap-2"><Building2 className="w-4 h-4 text-gray" /><span>{getFornecedorNome(ordem)}</span></div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="table-cell text-right font-medium">{ordem.valor ? formatCurrency(ordem.valor) : '-'}</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2"><Calendar className="w-4 h-4 text-gray" /><span>{formatDate(ordem.data || ordem.data_criacao || ordem.created_at)}</span></div>
|
||||||
<Building2 className="w-4 h-4 text-gray" />
|
|
||||||
<span>{ordem.fornecedor_nome}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4 text-gray" />
|
|
||||||
<span>{formatDate(ordem.data_criacao)}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="table-cell text-center">
|
<td className="table-cell text-center">
|
||||||
<span className={`${statusConfig[ordem.status]?.class || 'badge-neutral'} inline-flex items-center gap-1`}>
|
<span className={`${statusConfig[ordem.status]?.class || 'badge-neutral'} inline-flex items-center gap-1`}>
|
||||||
{statusConfig[ordem.status]?.icon}
|
{statusConfig[ordem.status]?.icon}{statusConfig[ordem.status]?.label || ordem.status}
|
||||||
{statusConfig[ordem.status]?.label || ordem.status}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
<button onClick={() => openDetail(ordem)} className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors"><Eye className="w-4 h-4" /></button>
|
||||||
<Eye className="w-4 h-4" />
|
<button onClick={() => handleDelete(ordem.id)} className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||||
</button>
|
|
||||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray hover:text-primary transition-colors">
|
|
||||||
<Edit2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button className="p-2 rounded-lg hover:bg-red-50 text-gray hover:text-red-500 transition-colors">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -203,14 +266,166 @@ export default function OrdensServico() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredOrdens.length === 0 && (
|
{filteredOrdens.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12"><ClipboardList className="w-12 h-12 text-gray-light mx-auto mb-4" /><p className="text-gray">Nenhuma ordem de serviço encontrada</p></div>
|
||||||
<ClipboardList className="w-12 h-12 text-gray-light mx-auto mb-4" />
|
)}
|
||||||
<p className="text-gray">Nenhuma ordem de serviço encontrada</p>
|
</div>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
<Modal open={showModal} onClose={() => setShowModal(false)} title="Nova Ordem de Serviço" onSubmit={handleSubmit} submitLabel="Criar OS" loading={saving}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Demanda</label>
|
||||||
|
<select value={form.demanda_id} onChange={(e) => setForm({ ...form, demanda_id: e.target.value })} className="input-field">
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{demandas.map(d => <option key={d.id} value={d.id}>{d.numero ? `#${d.numero}` : ''} {d.titulo}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Fornecedor</label>
|
||||||
|
<select value={form.fornecedor_id} onChange={(e) => setForm({ ...form, fornecedor_id: e.target.value })} className="input-field" required>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{fornecedores.map(f => <option key={f.id} value={f.id}>{f.nome}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Data</label>
|
||||||
|
<input type="date" value={form.data} onChange={(e) => setForm({ ...form, data: e.target.value })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Valor</label>
|
||||||
|
<input type="number" step="0.01" value={form.valor} onChange={(e) => setForm({ ...form, valor: Number(e.target.value) })} className="input-field" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Observações</label>
|
||||||
|
<textarea value={form.observacoes} onChange={(e) => setForm({ ...form, observacoes: e.target.value })} className="input-field resize-none" rows={3} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full border-t border-border pt-3 mt-2">
|
||||||
|
<p className="text-sm font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Campos ESG</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Material Sustentável</label>
|
||||||
|
<select value={form.uso_material_sustentavel} onChange={e => setForm({ ...form, uso_material_sustentavel: e.target.value })} className="input-field">
|
||||||
|
<option value="">Não Informado</option>
|
||||||
|
<option value="Sim">Sim</option>
|
||||||
|
<option value="Não">Não</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Geração de Resíduos</label>
|
||||||
|
<select value={form.gera_residuos} onChange={e => setForm({ ...form, gera_residuos: e.target.value })} className="input-field">
|
||||||
|
<option value="">Não Informado</option>
|
||||||
|
<option value="Baixo">Baixo</option>
|
||||||
|
<option value="Médio">Médio</option>
|
||||||
|
<option value="Alto">Alto</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-2">Descarte Certificado</label>
|
||||||
|
<select value={form.descarte_certificado} onChange={e => setForm({ ...form, descarte_certificado: e.target.value })} className="input-field">
|
||||||
|
<option value="">Não Informado</option>
|
||||||
|
<option value="Sim">Sim</option>
|
||||||
|
<option value="Não">Não</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
<Modal open={!!showDetail} onClose={() => { setShowDetail(null); setDetailDocs([]) }} title={`OS ${showDetail ? (typeof showDetail.numero === 'number' ? `OS-${String(showDetail.numero).padStart(4, '0')}` : showDetail.numero) : ''}`}>
|
||||||
|
{showDetail && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><strong>Demanda:</strong> {demandaMap[showDetail.demanda_id] || '-'}</div>
|
||||||
|
<div><strong>Fornecedor:</strong> {getFornecedorNome(showDetail)}</div>
|
||||||
|
<div><strong>Valor:</strong> {formatCurrency(showDetail.valor)}</div>
|
||||||
|
<div><strong>Status:</strong> {statusConfig[showDetail.status]?.label || showDetail.status}</div>
|
||||||
|
<div><strong>Data:</strong> {formatDate(showDetail.data)}</div>
|
||||||
|
<div><strong>Data Início:</strong> {formatDate(showDetail.data_inicio)}</div>
|
||||||
|
<div><strong>Data Conclusão:</strong> {formatDate(showDetail.data_conclusao)}</div>
|
||||||
|
</div>
|
||||||
|
<div><strong>Observações:</strong> {showDetail.observacoes || '-'}</div>
|
||||||
|
|
||||||
|
{/* ESG */}
|
||||||
|
{((showDetail as any).uso_material_sustentavel || (showDetail as any).gera_residuos || (showDetail as any).descarte_certificado) && (
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<h3 className="font-semibold mb-3" style={{ color: '#1A7A4C' }}>🌿 Dados ESG</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||||
|
<div><strong>Material Sustentável:</strong> {(showDetail as any).uso_material_sustentavel || 'Não Informado'}</div>
|
||||||
|
<div><strong>Geração Resíduos:</strong> {(showDetail as any).gera_residuos || 'Não Informado'}</div>
|
||||||
|
<div><strong>Descarte Certificado:</strong> {(showDetail as any).descarte_certificado || 'Não Informado'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Proposal Data */}
|
||||||
|
{(showDetail.valor_bruto || showDetail.valor_liquido) && (
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<h3 className="font-semibold text-text mb-3">Dados da Proposta</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div><strong>Valor Bruto:</strong> {formatCurrency(showDetail.valor_bruto)}</div>
|
||||||
|
<div><strong>Valor Líquido:</strong> {formatCurrency(showDetail.valor_liquido)}</div>
|
||||||
|
<div><strong>ISS:</strong> {formatCurrency(showDetail.iss)}</div>
|
||||||
|
<div><strong>INSS:</strong> {formatCurrency(showDetail.inss)}</div>
|
||||||
|
<div><strong>PCC:</strong> {formatCurrency(showDetail.pcc)}</div>
|
||||||
|
<div><strong>Cond. Pagamento:</strong> {showDetail.condicao_pagamento || '-'}</div>
|
||||||
|
<div><strong>Prazo Execução:</strong> {showDetail.prazo_execucao || '-'}</div>
|
||||||
|
<div><strong>Data Est. Entrega:</strong> {formatDate(showDetail.data_estimada_entrega)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document Versioning */}
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<h3 className="font-semibold text-text mb-3 flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" /> Documentos (PDF)
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
|
||||||
|
className="btn-primary text-sm py-1 px-3 flex items-center gap-1">
|
||||||
|
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||||
|
{uploading ? 'Enviando...' : 'Upload PDF'}
|
||||||
|
</button>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".pdf" className="hidden"
|
||||||
|
onChange={(e) => handleFileUpload(e.target.files)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{docsLoading ? (
|
||||||
|
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-gray" /></div>
|
||||||
|
) : detailDocs.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray text-center py-2">Nenhum documento anexado</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{detailDocs.map(doc => (
|
||||||
|
<div key={doc.id} className="p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileText className="w-5 h-5 text-red-500" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-text truncate">{doc.nome_arquivo}</p>
|
||||||
|
<span className="badge badge-info text-xs">V{doc.versao}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray">
|
||||||
|
{fornecedoresMap[doc.fornecedor_id] || ''} · {(doc.tamanho / 1024).toFixed(1)} KB · {formatDate(doc.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href={`/api/ordens-servico/documentos/${doc.id}/download`} target="_blank" className="p-1 rounded hover:bg-white text-gray hover:text-primary">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{(doc.valor_bruto || doc.valor_liquido) && (
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray">
|
||||||
|
<span>Bruto: {formatCurrency(doc.valor_bruto)}</span>
|
||||||
|
<span>Líquido: {formatCurrency(doc.valor_liquido)}</span>
|
||||||
|
<span>ISS: {formatCurrency(doc.iss)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,278 +1,235 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import {
|
import { FileText, Loader2, Download, Calendar, TrendingUp, Building2, ClipboardList, Leaf } from 'lucide-react'
|
||||||
BarChart3,
|
import api from '../services/api'
|
||||||
Download,
|
|
||||||
Calendar,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
PieChart,
|
|
||||||
FileText,
|
|
||||||
Filter
|
|
||||||
} from 'lucide-react'
|
|
||||||
import {
|
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
|
||||||
LineChart, Line, PieChart as RechartsPie, Pie, Cell, Legend,
|
|
||||||
AreaChart, Area
|
|
||||||
} from 'recharts'
|
|
||||||
|
|
||||||
const monthlyData = [
|
type TabKey = 'orcamento' | 'demandas' | 'fornecedores' | 'os' | 'esg_impacto' | 'esg_fornecedores' | 'esg_preventiva' | 'esg_governanca'
|
||||||
{ name: 'Jan', orcamento: 120, gasto: 95, economia: 25 },
|
|
||||||
{ name: 'Fev', orcamento: 115, gasto: 110, economia: 5 },
|
|
||||||
{ name: 'Mar', orcamento: 130, gasto: 105, economia: 25 },
|
|
||||||
{ name: 'Abr', orcamento: 125, gasto: 120, economia: 5 },
|
|
||||||
{ name: 'Mai', orcamento: 140, gasto: 115, economia: 25 },
|
|
||||||
{ name: 'Jun', orcamento: 135, gasto: 125, economia: 10 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const categoryData = [
|
const tabs: { key: TabKey; label: string; icon: React.ReactNode; esg?: boolean }[] = [
|
||||||
{ name: 'Manutenção', value: 35, color: '#E65100' },
|
{ key: 'orcamento', label: 'Orçamento', icon: <TrendingUp className="w-4 h-4" /> },
|
||||||
{ name: 'Limpeza', value: 25, color: '#1A237E' },
|
{ key: 'demandas', label: 'Demandas', icon: <FileText className="w-4 h-4" /> },
|
||||||
{ name: 'Segurança', value: 20, color: '#FF8F00' },
|
{ key: 'fornecedores', label: 'Fornecedores', icon: <Building2 className="w-4 h-4" /> },
|
||||||
{ name: 'Utilities', value: 12, color: '#2E7D32' },
|
{ key: 'os', label: 'OS', icon: <ClipboardList className="w-4 h-4" /> },
|
||||||
{ name: 'Outros', value: 8, color: '#757575' },
|
{ key: 'esg_impacto', label: 'Impacto Ambiental', icon: <Leaf className="w-4 h-4" />, esg: true },
|
||||||
]
|
{ key: 'esg_fornecedores', label: 'ESG Fornecedores', icon: <Leaf className="w-4 h-4" />, esg: true },
|
||||||
|
{ key: 'esg_preventiva', label: 'Evolução Preventiva', icon: <Leaf className="w-4 h-4" />, esg: true },
|
||||||
const trendData = [
|
{ key: 'esg_governanca', label: 'Exceções Governança', icon: <Leaf className="w-4 h-4" />, esg: true },
|
||||||
{ name: 'Sem 1', demandas: 15, os: 12 },
|
|
||||||
{ name: 'Sem 2', demandas: 22, os: 18 },
|
|
||||||
{ name: 'Sem 3', demandas: 18, os: 16 },
|
|
||||||
{ name: 'Sem 4', demandas: 25, os: 22 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const fornecedorData = [
|
|
||||||
{ name: 'Tech Solutions', atendimentos: 45, satisfacao: 4.5 },
|
|
||||||
{ name: 'EletroFix', atendimentos: 38, satisfacao: 4.8 },
|
|
||||||
{ name: 'HidroServ', atendimentos: 32, satisfacao: 4.2 },
|
|
||||||
{ name: 'CleanPro', atendimentos: 28, satisfacao: 4.6 },
|
|
||||||
{ name: 'ElevaTech', atendimentos: 15, satisfacao: 3.9 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Relatorios() {
|
export default function Relatorios() {
|
||||||
const [period, setPeriod] = useState('mensal')
|
const [activeTab, setActiveTab] = useState<TabKey>('orcamento')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [data, setData] = useState<any>(null)
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [activeTab, dateFrom, dateTo])
|
||||||
|
|
||||||
|
const endpoints: Record<TabKey, string> = {
|
||||||
|
orcamento: '/relatorios/orcamento-mensal',
|
||||||
|
demandas: '/relatorios/demandas-periodo',
|
||||||
|
fornecedores: '/relatorios/fornecedores-ranking',
|
||||||
|
os: '/relatorios/os-performance',
|
||||||
|
esg_impacto: '/relatorios/esg-impacto-ambiental',
|
||||||
|
esg_fornecedores: '/relatorios/esg-fornecedores',
|
||||||
|
esg_preventiva: '/relatorios/esg-evolucao-preventiva',
|
||||||
|
esg_governanca: '/relatorios/esg-excecoes-governanca',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params: any = {}
|
||||||
|
if (dateFrom) params.data_inicio = dateFrom
|
||||||
|
if (dateTo) params.data_fim = dateTo
|
||||||
|
const { data } = await api.get(endpoints[activeTab], { params })
|
||||||
|
setData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching report:', err)
|
||||||
|
setData(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderEsgImpacto = () => {
|
||||||
|
if (!data) return null
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.resumo && (
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
{data.resumo.map((r: any) => (
|
||||||
|
<div key={r.impacto} className={`p-4 rounded-xl ${r.impacto === 'Alto' ? 'bg-red-50' : r.impacto === 'Médio' ? 'bg-amber-50' : 'bg-green-50'}`}>
|
||||||
|
<p className="text-sm text-gray">Impacto {r.impacto}</p>
|
||||||
|
<p className="text-2xl font-bold">{r.total}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderGenericTable(data.detalhes || [])}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderEsgFornecedores = () => {
|
||||||
|
if (!data) return null
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.resumo && (
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
{data.resumo.map((r: any) => (
|
||||||
|
<div key={r.classificacao_esg} className={`p-4 rounded-xl ${r.classificacao_esg === 'Avançado' ? 'bg-green-50' : r.classificacao_esg === 'Intermediário' ? 'bg-amber-50' : 'bg-red-50'}`}>
|
||||||
|
<p className="text-sm text-gray">{r.classificacao_esg}</p>
|
||||||
|
<p className="text-2xl font-bold">{r.total}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.fornecedores && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead><tr className="table-header">
|
||||||
|
<th className="table-cell">Fornecedor</th>
|
||||||
|
<th className="table-cell">ESG</th>
|
||||||
|
<th className="table-cell">Rating</th>
|
||||||
|
<th className="table-cell">Pol. Ambiental</th>
|
||||||
|
<th className="table-cell">SST</th>
|
||||||
|
<th className="table-cell">EPI</th>
|
||||||
|
<th className="table-cell">Treinada</th>
|
||||||
|
<th className="table-cell">Total OS</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{data.fornecedores.map((f: any) => (
|
||||||
|
<tr key={f.id} className="table-row">
|
||||||
|
<td className="table-cell font-medium">{f.razao_social}</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<span className={`badge ${f.classificacao_esg === 'Avançado' ? 'badge-success' : f.classificacao_esg === 'Intermediário' ? 'badge-warning' : 'badge-error'}`}>
|
||||||
|
{f.classificacao_esg || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell">{Number(f.rating).toFixed(1)}</td>
|
||||||
|
<td className="table-cell">{f.possui_politica_ambiental ? '✅' : '❌'}</td>
|
||||||
|
<td className="table-cell">{f.possui_politica_sst ? '✅' : '❌'}</td>
|
||||||
|
<td className="table-cell">{f.declara_uso_epi ? '✅' : '❌'}</td>
|
||||||
|
<td className="table-cell">{f.equipe_treinada ? '✅' : '❌'}</td>
|
||||||
|
<td className="table-cell">{f.total_os}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderGenericTable = (items: any[]) => {
|
||||||
|
if (!items || items.length === 0) return <p className="text-gray text-center py-8">Nenhum dado.</p>
|
||||||
|
const keys = Object.keys(items[0]).filter(k => k !== 'id')
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead><tr className="table-header">
|
||||||
|
{keys.map(k => <th key={k} className="table-cell text-left capitalize">{k.replace(/_/g, ' ')}</th>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item: any, i: number) => (
|
||||||
|
<tr key={i} className="table-row">
|
||||||
|
{keys.map(k => (
|
||||||
|
<td key={k} className="table-cell text-sm">
|
||||||
|
{typeof item[k] === 'number' ? item[k].toLocaleString('pt-BR', { maximumFractionDigits: 2 }) : String(item[k] ?? '-')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading) return <div className="flex justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>
|
||||||
|
if (!data) return <div className="text-center py-12"><FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" /><p className="text-gray">Nenhum dado disponível.</p></div>
|
||||||
|
|
||||||
|
// ESG specific renderers
|
||||||
|
if (activeTab === 'esg_impacto') return renderEsgImpacto()
|
||||||
|
if (activeTab === 'esg_fornecedores') return renderEsgFornecedores()
|
||||||
|
if (activeTab === 'esg_governanca') {
|
||||||
|
const itens = data.itens || []
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray mb-4">Total de exceções: <strong>{data.total}</strong></p>
|
||||||
|
{renderGenericTable(itens.map((w: any) => ({
|
||||||
|
demanda: w.demanda_numero ? `#${w.demanda_numero} - ${w.demanda_titulo}` : w.demanda_titulo,
|
||||||
|
valor: w.valor_total,
|
||||||
|
status: w.status,
|
||||||
|
})))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic
|
||||||
|
const items = Array.isArray(data) ? data : data?.items || data?.dados || data?.detalhes || []
|
||||||
|
if (Array.isArray(items) && items.length > 0) return renderGenericTable(items)
|
||||||
|
|
||||||
|
if (typeof data === 'object' && !Array.isArray(data)) {
|
||||||
|
const entries = Object.entries(data).filter(([k]) => !['items', 'dados', 'detalhes', 'itens', 'resumo', 'fornecedores'].includes(k))
|
||||||
|
if (entries.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<div key={k} className="p-4 bg-gray-50 rounded-xl">
|
||||||
|
<p className="text-sm text-gray capitalize">{k.replace(/_/g, ' ')}</p>
|
||||||
|
<p className="text-xl font-bold text-text">
|
||||||
|
{typeof v === 'number' ? v.toLocaleString('pt-BR', { maximumFractionDigits: 2 }) : String(v)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <p className="text-center text-gray py-8">Formato de dados não reconhecido.</p>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-text">Relatórios</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-text flex items-center gap-2">
|
||||||
<p className="text-gray mt-1">Análises e métricas de facilities</p>
|
<FileText className="w-8 h-8 text-primary" /> Relatórios
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray mt-1">Relatórios detalhados por área.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<Calendar className="w-4 h-4 text-gray" />
|
||||||
value={period}
|
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} className="input-field text-sm" />
|
||||||
onChange={(e) => setPeriod(e.target.value)}
|
<span className="text-gray">—</span>
|
||||||
className="input-field w-40"
|
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} className="input-field text-sm" />
|
||||||
>
|
|
||||||
<option value="semanal">Semanal</option>
|
|
||||||
<option value="mensal">Mensal</option>
|
|
||||||
<option value="trimestral">Trimestral</option>
|
|
||||||
<option value="anual">Anual</option>
|
|
||||||
</select>
|
|
||||||
<button className="btn-primary flex items-center gap-2">
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
Exportar
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Tabs */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="flex gap-1 bg-gray-100 p-1 rounded-xl overflow-x-auto">
|
||||||
<div className="card">
|
{tabs.map(tab => (
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
||||||
<TrendingUp className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray">Economia Total</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-text">95K</p>
|
|
||||||
<p className="text-xs text-green-600 mt-1">+12% vs período anterior</p>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
|
||||||
<FileText className="w-5 h-5 text-secondary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray">Demandas</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-text">156</p>
|
|
||||||
<p className="text-xs text-green-600 mt-1">+8% vs período anterior</p>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
|
|
||||||
<BarChart3 className="w-5 h-5 text-accent" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray">OS Concluídas</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-text">142</p>
|
|
||||||
<p className="text-xs text-green-600 mt-1">91% taxa de conclusão</p>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
|
|
||||||
<TrendingDown className="w-5 h-5 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray">Tempo Médio</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-text">3.2 dias</p>
|
|
||||||
<p className="text-xs text-green-600 mt-1">-15% vs período anterior</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts Row 1 */}
|
|
||||||
<div className="grid lg:grid-cols-2 gap-6">
|
|
||||||
{/* Bar Chart - Orçamento vs Gasto */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-text">Orçamento vs Gasto</h2>
|
|
||||||
<p className="text-sm text-gray">Comparativo mensal (em milhares)</p>
|
|
||||||
</div>
|
|
||||||
<button className="p-2 rounded-lg hover:bg-gray-100 text-gray">
|
|
||||||
<Filter className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="h-72">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={monthlyData} barGap={8}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
|
||||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
border: '1px solid #E0E0E0',
|
|
||||||
borderRadius: '12px',
|
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="orcamento" fill="#1A237E" radius={[4, 4, 0, 0]} name="Orçamento" />
|
|
||||||
<Bar dataKey="gasto" fill="#E65100" radius={[4, 4, 0, 0]} name="Gasto" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pie Chart - Por Categoria */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-text">Gastos por Categoria</h2>
|
|
||||||
<p className="text-sm text-gray">Distribuição percentual</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-72">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<RechartsPie>
|
|
||||||
<Pie
|
|
||||||
data={categoryData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={100}
|
|
||||||
paddingAngle={4}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{categoryData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Legend
|
|
||||||
verticalAlign="bottom"
|
|
||||||
iconType="circle"
|
|
||||||
iconSize={8}
|
|
||||||
formatter={(value) => <span className="text-sm text-gray">{value}</span>}
|
|
||||||
/>
|
|
||||||
<Tooltip />
|
|
||||||
</RechartsPie>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Charts Row 2 */}
|
|
||||||
<div className="grid lg:grid-cols-2 gap-6">
|
|
||||||
{/* Area Chart - Tendência */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-text">Tendência Semanal</h2>
|
|
||||||
<p className="text-sm text-gray">Demandas vs Ordens de Serviço</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-64">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={trendData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
|
|
||||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
border: '1px solid #E0E0E0',
|
|
||||||
borderRadius: '12px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Area type="monotone" dataKey="demandas" stroke="#E65100" fill="#E65100" fillOpacity={0.2} name="Demandas" />
|
|
||||||
<Area type="monotone" dataKey="os" stroke="#1A237E" fill="#1A237E" fillOpacity={0.2} name="Ordens de Serviço" />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bar Chart - Fornecedores */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-text">Top Fornecedores</h2>
|
|
||||||
<p className="text-sm text-gray">Por número de atendimentos</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-64">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={fornecedorData} layout="vertical" barSize={20}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" horizontal={false} />
|
|
||||||
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} />
|
|
||||||
<YAxis type="category" dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#757575', fontSize: 12 }} width={100} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
border: '1px solid #E0E0E0',
|
|
||||||
borderRadius: '12px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="atendimentos" fill="#FF8F00" radius={[0, 4, 4, 0]} name="Atendimentos" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Reports */}
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-lg font-semibold text-text mb-4">Relatórios Disponíveis</h2>
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ name: 'Relatório Mensal', desc: 'Resumo completo do mês', icon: <Calendar className="w-5 h-5" /> },
|
|
||||||
{ name: 'Análise de Custos', desc: 'Detalhamento por categoria', icon: <PieChart className="w-5 h-5" /> },
|
|
||||||
{ name: 'Performance Fornecedores', desc: 'Avaliação e métricas', icon: <TrendingUp className="w-5 h-5" /> },
|
|
||||||
{ name: 'Histórico de Demandas', desc: 'Todas as solicitações', icon: <FileText className="w-5 h-5" /> },
|
|
||||||
].map((report, i) => (
|
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={tab.key}
|
||||||
className="flex items-center gap-4 p-4 bg-card rounded-xl hover:bg-gray-100 transition-all text-left"
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all whitespace-nowrap ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? `bg-white shadow-sm ${tab.esg ? '' : 'text-primary'}`
|
||||||
|
: 'text-gray hover:text-text'
|
||||||
|
}`}
|
||||||
|
style={activeTab === tab.key && tab.esg ? { color: '#1A7A4C' } : {}}
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
{tab.icon} {tab.label}
|
||||||
{report.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-text">{report.name}</p>
|
|
||||||
<p className="text-xs text-gray">{report.desc}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="card">
|
||||||
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number | string;
|
||||||
nome: string;
|
nome: string;
|
||||||
email: string;
|
email: string;
|
||||||
perfil: string;
|
perfil: string;
|
||||||
@@ -11,62 +11,187 @@ export interface AuthResponse {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardIndicadores {
|
||||||
total_orcamento?: number;
|
demandas_abertas: number;
|
||||||
total_gasto?: number;
|
em_cotacao: number;
|
||||||
economia?: number;
|
pendentes: number;
|
||||||
pendencias?: number;
|
em_aprovacao: number;
|
||||||
demandas_pendentes?: number;
|
os_ativas: number;
|
||||||
ordens_abertas?: number;
|
alertas: number;
|
||||||
fornecedores_ativos?: number;
|
|
||||||
contratos_vigentes?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Demanda {
|
export interface Demanda {
|
||||||
id: number;
|
id: string;
|
||||||
|
numero: number;
|
||||||
titulo: string;
|
titulo: string;
|
||||||
descricao: string;
|
descricao: string;
|
||||||
|
local_id: string;
|
||||||
|
centro_custo_id: string;
|
||||||
|
categoria_id: string;
|
||||||
|
subcategoria_id: string;
|
||||||
|
criticidade: string;
|
||||||
|
data_desejada: string;
|
||||||
status: string;
|
status: string;
|
||||||
prioridade: string;
|
solicitante_id: string | number;
|
||||||
solicitante_id: number;
|
gestor_id: string;
|
||||||
|
valor_estimado: number | null;
|
||||||
|
documentos: any[];
|
||||||
|
itens_linha: any[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
// legacy compat
|
||||||
solicitante_nome?: string;
|
solicitante_nome?: string;
|
||||||
data_criacao: string;
|
prioridade?: string;
|
||||||
data_atualizacao?: string;
|
data_criacao?: string;
|
||||||
|
impacto_ambiental_demanda?: string;
|
||||||
|
justificativa_manutencao_emergencial?: string;
|
||||||
|
// computed
|
||||||
|
_documentos_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subcategoria {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
categoria_id: string;
|
||||||
|
ativo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentoFile {
|
||||||
|
id: string;
|
||||||
|
demanda_id: string;
|
||||||
|
nome_arquivo: string;
|
||||||
|
tipo: string;
|
||||||
|
caminho: string;
|
||||||
|
tamanho: number;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrdemServico {
|
export interface OrdemServico {
|
||||||
id: number;
|
id: string;
|
||||||
numero: string;
|
numero: number | string;
|
||||||
demanda_id: number;
|
demanda_id: string;
|
||||||
fornecedor_id: number;
|
proposta_id: string;
|
||||||
fornecedor_nome?: string;
|
fornecedor_id: string;
|
||||||
|
valor: number;
|
||||||
status: string;
|
status: string;
|
||||||
valor?: number;
|
data: string;
|
||||||
data_criacao: string;
|
data_inicio: string;
|
||||||
data_conclusao?: string;
|
data_conclusao: string;
|
||||||
|
valor_bruto: number;
|
||||||
|
valor_liquido: number;
|
||||||
|
iss: number;
|
||||||
|
inss: number;
|
||||||
|
pcc: number;
|
||||||
|
condicao_pagamento: string;
|
||||||
|
prazo_execucao: string;
|
||||||
|
data_estimada_entrega: string;
|
||||||
|
observacoes: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
// legacy compat
|
||||||
|
fornecedor_nome?: string;
|
||||||
descricao?: string;
|
descricao?: string;
|
||||||
|
data_criacao?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentoVersao {
|
||||||
|
id: string;
|
||||||
|
ordem_servico_id: string;
|
||||||
|
fornecedor_id: string;
|
||||||
|
nome_arquivo: string;
|
||||||
|
caminho: string;
|
||||||
|
tamanho: number;
|
||||||
|
versao: number;
|
||||||
|
valor_bruto: number;
|
||||||
|
valor_liquido: number;
|
||||||
|
iss: number;
|
||||||
|
inss: number;
|
||||||
|
pcc: number;
|
||||||
|
condicao_pagamento: string;
|
||||||
|
prazo_execucao: string;
|
||||||
|
data_estimada_entrega: string;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Fornecedor {
|
export interface Fornecedor {
|
||||||
id: number;
|
id: string;
|
||||||
|
tipo_pessoa: string;
|
||||||
|
cpf_cnpj: string;
|
||||||
razao_social: string;
|
razao_social: string;
|
||||||
cnpj: string;
|
nome_fantasia: string;
|
||||||
email: string;
|
email: string;
|
||||||
telefone: string;
|
telefone: string;
|
||||||
endereco?: string;
|
endereco: string;
|
||||||
|
categorias_atendidas: string[];
|
||||||
|
rating: number;
|
||||||
|
usuario_id: string;
|
||||||
|
nome_contato: string;
|
||||||
|
possui_politica_ambiental: boolean;
|
||||||
|
possui_politica_sst: boolean;
|
||||||
|
declara_uso_epi: boolean;
|
||||||
|
equipe_treinada: boolean;
|
||||||
|
classificacao_esg: string;
|
||||||
ativo: boolean;
|
ativo: boolean;
|
||||||
|
certidoes: any[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
// legacy compat
|
||||||
|
cnpj?: string;
|
||||||
especialidades?: string[];
|
especialidades?: string[];
|
||||||
avaliacao?: number;
|
avaliacao?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Orcamento {
|
export interface Orcamento {
|
||||||
id: number;
|
id: string;
|
||||||
ano: number;
|
ano: number;
|
||||||
mes: number;
|
mes: number;
|
||||||
categoria: string;
|
centro_custo_id: string;
|
||||||
valor_previsto: number;
|
categoria_id: string;
|
||||||
|
valor_planejado: number;
|
||||||
|
valor_comprometido: number;
|
||||||
valor_realizado: number;
|
valor_realizado: number;
|
||||||
status: string;
|
tipo_periodo: string;
|
||||||
|
valor_anual: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
// legacy compat
|
||||||
|
categoria?: string;
|
||||||
|
valor_previsto?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Categoria {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
subcategoria?: string;
|
||||||
|
criticidade_padrao?: string;
|
||||||
|
sla_dias?: number;
|
||||||
|
categoria_pai_id?: string;
|
||||||
|
tipo_investimento?: string;
|
||||||
|
tipo_manutencao?: string;
|
||||||
|
impacto_ambiental_esperado?: string;
|
||||||
|
potencial_geracao_residuos?: string;
|
||||||
|
ativo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CentroCusto {
|
||||||
|
id: string;
|
||||||
|
codigo: string;
|
||||||
|
nome: string;
|
||||||
|
responsavel_id?: string;
|
||||||
|
ativo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Local {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
endereco?: string;
|
||||||
|
centro_custo_id?: string;
|
||||||
|
responsavel_id?: string;
|
||||||
|
tipo_operacao_local?: string;
|
||||||
|
classificacao_impacto_ambiental?: string;
|
||||||
|
praticas_sustentaveis?: string[];
|
||||||
|
ativo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Contrato {
|
export interface Contrato {
|
||||||
|
|||||||
Reference in New Issue
Block a user