Major update: ESG, KPIs, metas, alertas, auditoria, documentos, importação, relatórios, subcategorias, dashboard orçamentos

This commit is contained in:
bigtux
2026-02-10 18:52:52 -03:00
parent d8ca580acb
commit 90c7a2cacb
92 changed files with 10265 additions and 1238 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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],
}) })

View File

@@ -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!');
} }
} }

View File

@@ -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();

View 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(); }
}

View 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 {}

View 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 };
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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();
}
}

View File

@@ -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;

View File

@@ -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(); }
} }

View File

@@ -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],

View File

@@ -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,
};
}
} }

View File

@@ -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'); }

View File

@@ -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 });

View File

@@ -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;

View 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);
}
}

View 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 {}

View 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);
}
}

View 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;
}

View 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;
}

View 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;
}

View 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); }
}

View 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 {}

View 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 };
}
}

View File

@@ -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;

View File

@@ -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); }

View 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);
}
}

View 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 {}

View 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 };
}
}

View 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;
}

View 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); }
}

View 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 {}

View 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 };
}
}

View File

@@ -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;

View 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;
}

View 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(); }
}

View 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 {}

View 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 };
}
}

View File

@@ -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;

View File

@@ -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); }
} }

View File

@@ -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],

View File

@@ -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);
}
} }

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 };
}
} }

View File

@@ -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],

View File

@@ -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;
}
} }

View File

@@ -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;

View 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(); }
}

View 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 {}

View 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 };
}
}

View File

@@ -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;
}

View File

@@ -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); }
}

View 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 {}

View 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 }); }
}

View 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
View 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

View 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

Binary file not shown.

1099
docs/MANUAL-NEGOCIOS.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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 | 618 meses | 24 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

Binary file not shown.

2194
docs/MANUAL-TECNICO.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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% | 81100% | > 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.03.9 | < 3.0 |
| Taxa Conclusão Demandas | concluídas / total × 100 | ≥ 90% | 7089% | < 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
View 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 &nbsp;|&nbsp; AI Vertice</div>
<div class="date">Fevereiro 2026 &nbsp;·&nbsp; {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']} &nbsp;·&nbsp; Kislanski Industries | AI Vertice &nbsp;·&nbsp; 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()

View File

@@ -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">

View File

@@ -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="/" />} />

View File

@@ -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)}

View 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>
)
}

View 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 }
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
) )
} }

View File

@@ -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
View 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>
)
}

View File

@@ -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>
) )
} }

View 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
View 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>
)
}

View File

@@ -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>

View File

@@ -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>

View 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>
)
}

View File

@@ -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>
) )
} }

View File

@@ -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>
) )
} }

View File

@@ -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>
) )

View File

@@ -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 {