diff --git a/.gitignore b/.gitignore index 913aba2..3d2bd0e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ logs/ # OS Thumbs.db + +# Uploads (user content) +public/uploads/fotos/* +!public/uploads/.gitkeep diff --git a/next.config.js b/next.config.js index 24247b2..212456b 100644 --- a/next.config.js +++ b/next.config.js @@ -7,11 +7,19 @@ const nextConfig = { protocol: "https", hostname: "**", }, + { + protocol: "http", + hostname: "localhost", + }, + { + protocol: "http", + hostname: "192.168.10.197", + }, ], }, experimental: { serverActions: { - bodySizeLimit: "2mb", + bodySizeLimit: "10mb", }, }, }; diff --git a/package-lock.json b/package-lock.json index 74df9f4..4dda5f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,14 +20,18 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", + "@react-pdf/renderer": "^4.3.2", "autoprefixer": "^10.4.23", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "gantt-task-react": "^0.3.9", + "jose": "^6.1.3", "lucide-react": "^0.454.0", "next": "^14.2.28", "next-auth": "^5.0.0-beta.25", @@ -37,6 +41,7 @@ "recharts": "^2.13.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "web-push": "^3.6.7", "zod": "^3.23.8" }, "devDependencies": { @@ -44,6 +49,7 @@ "@types/node": "^20.17.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/web-push": "^3.6.4", "eslint": "^8.57.1", "eslint-config-next": "^14.2.28", "postcss": "^8.4.47", @@ -1988,6 +1994,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -2287,6 +2322,180 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-pdf/fns": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz", + "integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==", + "license": "MIT" + }, + "node_modules/@react-pdf/font": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.4.tgz", + "integrity": "sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==", + "license": "MIT", + "dependencies": { + "@react-pdf/pdfkit": "^4.1.0", + "@react-pdf/types": "^2.9.2", + "fontkit": "^2.0.2", + "is-url": "^1.2.4" + } + }, + "node_modules/@react-pdf/image": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.4.tgz", + "integrity": "sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==", + "license": "MIT", + "dependencies": { + "@react-pdf/png-js": "^3.0.0", + "jay-peg": "^1.1.1" + } + }, + "node_modules/@react-pdf/layout": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.2.tgz", + "integrity": "sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "@react-pdf/image": "^3.0.4", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/stylesheet": "^6.1.2", + "@react-pdf/textkit": "^6.1.0", + "@react-pdf/types": "^2.9.2", + "emoji-regex-xs": "^1.0.0", + "queue": "^6.0.1", + "yoga-layout": "^3.2.1" + } + }, + "node_modules/@react-pdf/pdfkit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.1.0.tgz", + "integrity": "sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/png-js": "^3.0.0", + "browserify-zlib": "^0.2.0", + "crypto-js": "^4.2.0", + "fontkit": "^2.0.2", + "jay-peg": "^1.1.1", + "linebreak": "^1.1.0", + "vite-compatible-readable-stream": "^3.6.1" + } + }, + "node_modules/@react-pdf/png-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz", + "integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==", + "license": "MIT", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/@react-pdf/primitives": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz", + "integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==", + "license": "MIT" + }, + "node_modules/@react-pdf/reconciler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz", + "integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "scheduler": "0.25.0-rc-603e6108-20241029" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/reconciler/node_modules/scheduler": { + "version": "0.25.0-rc-603e6108-20241029", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", + "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", + "license": "MIT" + }, + "node_modules/@react-pdf/render": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.2.tgz", + "integrity": "sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.2", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/textkit": "^6.1.0", + "@react-pdf/types": "^2.9.2", + "abs-svg-path": "^0.1.1", + "color-string": "^1.9.1", + "normalize-svg-path": "^1.1.0", + "parse-svg-path": "^0.1.2", + "svg-arc-to-cubic-bezier": "^3.2.0" + } + }, + "node_modules/@react-pdf/renderer": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.2.tgz", + "integrity": "sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.2", + "@react-pdf/font": "^4.0.4", + "@react-pdf/layout": "^4.4.2", + "@react-pdf/pdfkit": "^4.1.0", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/reconciler": "^2.0.0", + "@react-pdf/render": "^4.3.2", + "@react-pdf/types": "^2.9.2", + "events": "^3.3.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "queue": "^6.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/stylesheet": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.2.tgz", + "integrity": "sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "@react-pdf/types": "^2.9.2", + "color-string": "^1.9.1", + "hsl-to-hex": "^1.0.0", + "media-engine": "^1.0.3", + "postcss-value-parser": "^4.1.0" + } + }, + "node_modules/@react-pdf/textkit": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.1.0.tgz", + "integrity": "sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.2", + "bidi-js": "^1.0.2", + "hyphen": "^1.6.4", + "unicode-properties": "^1.4.1" + } + }, + "node_modules/@react-pdf/types": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.2.tgz", + "integrity": "sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==", + "license": "MIT", + "dependencies": { + "@react-pdf/font": "^4.0.4", + "@react-pdf/primitives": "^4.1.1", + "@react-pdf/stylesheet": "^6.1.2" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2443,6 +2652,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.53.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", @@ -2988,6 +3207,12 @@ "win32" ] }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3011,6 +3236,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3268,6 +3502,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -3364,6 +3610,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", @@ -3379,6 +3645,15 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3391,6 +3666,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3414,6 +3695,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -3447,6 +3746,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3618,6 +3923,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3644,9 +3958,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3678,6 +4001,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3892,7 +4221,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3961,6 +4289,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4018,6 +4352,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -4031,6 +4374,12 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -4726,11 +5075,19 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -4857,6 +5214,32 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/fontkit/node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4964,6 +5347,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gantt-task-react": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/gantt-task-react/-/gantt-task-react-0.3.9.tgz", + "integrity": "sha512-ged2OGrAJJ+ATrfVVkd4/8cUu4jOu1mXi0NaBeKx4uoGP6YhyryL+Snr2OY48AXdFelUAg7BbqylSej9X6PCCQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -5266,6 +5661,49 @@ "node": ">= 0.4" } }, + "node_modules/hsl-to-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", + "integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==", + "license": "MIT", + "dependencies": { + "hsl-to-rgb-for-reals": "^1.1.0" + } + }, + "node_modules/hsl-to-rgb-for-reals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz", + "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", + "license": "ISC" + }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/hyphen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", + "integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5319,7 +5757,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -5364,6 +5801,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -5730,6 +6173,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -5827,6 +6276,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jay-peg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz", + "integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==", + "license": "MIT", + "dependencies": { + "restructure": "^3.0.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -5914,6 +6372,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5970,6 +6449,25 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6043,6 +6541,12 @@ "node": ">= 0.4" } }, + "node_modules/media-engine": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", + "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6065,6 +6569,12 @@ "node": ">=8.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6082,7 +6592,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6102,7 +6611,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -6277,6 +6785,15 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, "node_modules/oauth4webapi": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", @@ -6495,6 +7012,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6508,6 +7031,12 @@ "node": ">=6" } }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6834,6 +7363,15 @@ "node": ">=6" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7104,6 +7642,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7144,6 +7691,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7236,6 +7789,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -7271,6 +7844,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -7454,6 +8033,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7492,6 +8080,15 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -7795,6 +8392,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -7879,6 +8482,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -8144,6 +8753,32 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -8299,6 +8934,39 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite-compatible-readable-stream": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz", + "integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8535,6 +9203,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 9cf09f9..a7d7d71 100644 --- a/package.json +++ b/package.json @@ -34,14 +34,18 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", + "@react-pdf/renderer": "^4.3.2", "autoprefixer": "^10.4.23", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "gantt-task-react": "^0.3.9", + "jose": "^6.1.3", "lucide-react": "^0.454.0", "next": "^14.2.28", "next-auth": "^5.0.0-beta.25", @@ -51,6 +55,7 @@ "recharts": "^2.13.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "web-push": "^3.6.7", "zod": "^3.23.8" }, "devDependencies": { @@ -58,6 +63,7 @@ "@types/node": "^20.17.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/web-push": "^3.6.4", "eslint": "^8.57.1", "eslint-config-next": "^14.2.28", "postcss": "^8.4.47", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8200f9f..162e1a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,6 +106,14 @@ model User { tareasAsignadas TareaObra[] obrasSupervision Obra[] @relation("ObraSupervisor") registrosAvance RegistroAvance[] + fotosSubidas FotoAvance[] + bitacorasRegistradas BitacoraObra[] + asistenciasRegistradas Asistencia[] + ordenesCreadas OrdenCompra[] @relation("OrdenCreador") + ordenesAprobadas OrdenCompra[] @relation("OrdenAprobador") + pushSubscriptions PushSubscription[] + notificaciones Notificacion[] + actividades ActividadLog[] @@index([empresaId]) @@index([email]) @@ -145,10 +153,40 @@ model Cliente { // Relations obras Obra[] + accesos ClienteAcceso[] @@index([empresaId]) } +// ============== PORTAL DE CLIENTES ============== + +model ClienteAcceso { + id String @id @default(cuid()) + email String @unique + password String? // Hash de contraseña (opcional si usa token) + token String? @unique // Token de acceso único + tokenExpira DateTime? // Expiración del token + activo Boolean @default(true) + ultimoAcceso DateTime? + + // Permisos + verFotos Boolean @default(true) + verAvances Boolean @default(true) + verGastos Boolean @default(false) + verDocumentos Boolean @default(true) + descargarPDF Boolean @default(true) + + // Relaciones + clienteId String + cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([clienteId]) + @@index([token]) +} + model Obra { id String @id @default(cuid()) nombre String @@ -180,6 +218,11 @@ model Obra { asignaciones AsignacionEmpleado[] contratos ContratoSubcontratista[] registrosAvance RegistroAvance[] + fotos FotoAvance[] + bitacoras BitacoraObra[] + asistencias Asistencia[] + ordenesCompra OrdenCompra[] + actividadesLog ActividadLog[] @@index([empresaId]) @@index([estado]) @@ -201,6 +244,7 @@ model FaseObra { // Relations tareas TareaObra[] + fotos FotoAvance[] @@index([obraId]) } @@ -350,6 +394,7 @@ model Material { // Relations movimientos MovimientoInventario[] + itemsOrden ItemOrdenCompra[] @@unique([codigo, empresaId]) @@index([empresaId]) @@ -392,6 +437,7 @@ model Empleado { // Relations asignaciones AsignacionEmpleado[] jornadas JornadaTrabajo[] + asistencias Asistencia[] @@index([empresaId]) } @@ -464,3 +510,388 @@ model ContratoSubcontratista { @@index([subcontratistaId]) @@index([obraId]) } + +// ============== ÓRDENES DE COMPRA ============== + +enum EstadoOrdenCompra { + BORRADOR + PENDIENTE + APROBADA + ENVIADA + RECIBIDA_PARCIAL + RECIBIDA + CANCELADA +} + +enum PrioridadOrden { + BAJA + NORMAL + ALTA + URGENTE +} + +model OrdenCompra { + id String @id @default(cuid()) + numero String // Número de orden (OC-001, etc.) + + // Estado y prioridad + estado EstadoOrdenCompra @default(BORRADOR) + prioridad PrioridadOrden @default(NORMAL) + + // Fechas + fechaEmision DateTime @default(now()) + fechaRequerida DateTime? // Fecha en que se necesitan los materiales + fechaAprobacion DateTime? + fechaEnvio DateTime? + fechaRecepcion DateTime? + + // Proveedor + proveedorNombre String + proveedorRfc String? + proveedorContacto String? + proveedorTelefono String? + proveedorEmail String? + proveedorDireccion String? + + // Totales + subtotal Float @default(0) + descuento Float @default(0) + iva Float @default(0) + total Float @default(0) + + // Condiciones + condicionesPago String? // Ej: "Contado", "Crédito 30 días" + tiempoEntrega String? // Ej: "3-5 días hábiles" + lugarEntrega String? // Dirección de entrega + + // Notas + notas String? @db.Text + notasInternas String? @db.Text + + // Relaciones + obraId String + obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade) + creadoPorId String + creadoPor User @relation("OrdenCreador", fields: [creadoPorId], references: [id]) + aprobadoPorId String? + aprobadoPor User? @relation("OrdenAprobador", fields: [aprobadoPorId], references: [id]) + + // Items + items ItemOrdenCompra[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([numero, obraId]) + @@index([obraId]) + @@index([estado]) + @@index([fechaEmision]) +} + +model ItemOrdenCompra { + id String @id @default(cuid()) + + // Descripción del item + codigo String? // Código del material + descripcion String + unidad UnidadMedida + + // Cantidades + cantidad Float + cantidadRecibida Float @default(0) + + // Precios + precioUnitario Float + descuento Float @default(0) + subtotal Float + + // Relaciones + ordenId String + orden OrdenCompra @relation(fields: [ordenId], references: [id], onDelete: Cascade) + materialId String? // Opcional: vincular con catálogo de materiales + material Material? @relation(fields: [materialId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([ordenId]) + @@index([materialId]) +} + +// ============== CONTROL DE ASISTENCIA ============== + +enum TipoAsistencia { + PRESENTE + AUSENTE + RETARDO + PERMISO + INCAPACIDAD + VACACIONES +} + +model Asistencia { + id String @id @default(cuid()) + fecha DateTime @db.Date + + // Estado de asistencia + tipo TipoAsistencia @default(PRESENTE) + + // Registro de entrada + horaEntrada DateTime? + latitudEntrada Float? + longitudEntrada Float? + + // Registro de salida + horaSalida DateTime? + latitudSalida Float? + longitudSalida Float? + + // Horas trabajadas (calculadas) + horasTrabajadas Float? + horasExtra Float @default(0) + + // Notas y observaciones + notas String? + motivoAusencia String? + + // Relaciones + empleadoId String + empleado Empleado @relation(fields: [empleadoId], references: [id], onDelete: Cascade) + obraId String + obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade) + registradoPorId String + registradoPor User @relation(fields: [registradoPorId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([empleadoId, obraId, fecha]) + @@index([empleadoId]) + @@index([obraId]) + @@index([fecha]) + @@index([tipo]) +} + +// ============== BITÁCORA DE OBRA ============== + +enum CondicionClima { + SOLEADO + NUBLADO + PARCIALMENTE_NUBLADO + LLUVIA_LIGERA + LLUVIA_FUERTE + TORMENTA + VIENTO_FUERTE + FRIO_EXTREMO + CALOR_EXTREMO +} + +model BitacoraObra { + id String @id @default(cuid()) + fecha DateTime @db.Date + + // Condiciones climáticas + clima CondicionClima + temperaturaMin Float? + temperaturaMax Float? + condicionesExtra String? // Notas adicionales del clima + + // Personal en obra + personalPropio Int @default(0) + personalSubcontrato Int @default(0) + personalDetalle String? // Descripción del personal + + // Actividades del día + actividadesRealizadas String @db.Text + actividadesPendientes String? @db.Text + + // Materiales + materialesUtilizados String? @db.Text + materialesRecibidos String? @db.Text + + // Equipo y maquinaria + equipoUtilizado String? @db.Text + + // Incidentes y observaciones + incidentes String? @db.Text + observaciones String? @db.Text + + // Seguridad + incidentesSeguridad String? @db.Text + platicaSeguridad Boolean @default(false) + temaSeguridad String? + + // Visitas + visitasInspeccion String? @db.Text + + // Relaciones + obraId String + obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade) + registradoPorId String + registradoPor User @relation(fields: [registradoPorId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([obraId, fecha]) + @@index([obraId]) + @@index([fecha]) +} + +// ============== FOTOS DE AVANCE ============== + +model FotoAvance { + id String @id @default(cuid()) + url String // Ruta del archivo + thumbnail String? // Ruta de la miniatura + titulo String? + descripcion String? + fechaCaptura DateTime @default(now()) + + // Geolocalización + latitud Float? + longitud Float? + direccionGeo String? // Dirección obtenida por geocoding + + // Metadatos + tamanio Int? // Tamaño en bytes + tipo String? // MIME type (image/jpeg, etc.) + ancho Int? // Width en pixels + alto Int? // Height en pixels + + // Relaciones + obraId String + obra Obra @relation(fields: [obraId], references: [id], onDelete: Cascade) + faseId String? + fase FaseObra? @relation(fields: [faseId], references: [id]) + subidoPorId String + subidoPor User @relation(fields: [subidoPorId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([obraId]) + @@index([faseId]) + @@index([fechaCaptura]) +} + +// ============== NOTIFICACIONES PUSH ============== + +enum TipoNotificacion { + TAREA_ASIGNADA + TAREA_COMPLETADA + GASTO_PENDIENTE + GASTO_APROBADO + ORDEN_APROBADA + AVANCE_REGISTRADO + RECORDATORIO + ALERTA_INVENTARIO + GENERAL +} + +model PushSubscription { + id String @id @default(cuid()) + endpoint String @unique + p256dh String + auth String + activo Boolean @default(true) + + // Preferencias de notificación + notifyTareas Boolean @default(true) + notifyGastos Boolean @default(true) + notifyOrdenes Boolean @default(true) + notifyAvances Boolean @default(true) + notifyAlertas Boolean @default(true) + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + +model Notificacion { + id String @id @default(cuid()) + tipo TipoNotificacion + titulo String + mensaje String + url String? // URL para navegar al hacer clic + leida Boolean @default(false) + enviada Boolean @default(false) + + // Datos adicionales en JSON + metadata String? @db.Text + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([leida]) + @@index([createdAt]) +} + +// ============== LOG DE ACTIVIDADES ============== + +enum TipoActividad { + OBRA_CREADA + OBRA_ACTUALIZADA + OBRA_ESTADO_CAMBIADO + FASE_CREADA + TAREA_CREADA + TAREA_ASIGNADA + TAREA_COMPLETADA + TAREA_ESTADO_CAMBIADO + GASTO_CREADO + GASTO_APROBADO + GASTO_RECHAZADO + ORDEN_CREADA + ORDEN_APROBADA + ORDEN_ENVIADA + ORDEN_RECIBIDA + AVANCE_REGISTRADO + FOTO_SUBIDA + BITACORA_REGISTRADA + MATERIAL_MOVIMIENTO + USUARIO_ASIGNADO + COMENTARIO_AGREGADO + DOCUMENTO_SUBIDO +} + +model ActividadLog { + id String @id @default(cuid()) + tipo TipoActividad + descripcion String + detalles String? @db.Text // JSON con datos adicionales + + // Entidad afectada + entidadTipo String? // "obra", "tarea", "gasto", etc. + entidadId String? + entidadNombre String? + + // Contexto + obraId String? + obra Obra? @relation(fields: [obraId], references: [id], onDelete: SetNull) + + // Usuario que realizó la acción + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + // Empresa para filtrar + empresaId String + + // Metadatos de IP/dispositivo (opcional) + ipAddress String? + userAgent String? + + createdAt DateTime @default(now()) + + @@index([obraId]) + @@index([userId]) + @@index([empresaId]) + @@index([tipo]) + @@index([createdAt]) +} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/icons/README.md b/public/icons/README.md new file mode 100644 index 0000000..66bf5a2 --- /dev/null +++ b/public/icons/README.md @@ -0,0 +1,36 @@ +# PWA Icons Generation Instructions + +The placeholder icons in this directory should be replaced with properly generated icons. + +## Option 1: Use an online tool +1. Go to https://realfavicongenerator.net/ +2. Upload the icon.svg file from this directory +3. Download the generated icons +4. Replace the placeholder PNGs + +## Option 2: Use sharp (Node.js) +If you have libvips installed, you can use the generate-icons.js script: +```bash +npm install sharp --save-dev +node scripts/generate-icons.js +``` + +## Option 3: Use ImageMagick +If you have ImageMagick installed: +```bash +for size in 72 96 128 144 152 192 384 512; do + convert icon.svg -resize ${size}x${size} icon-${size}x${size}.png +done +``` + +## Required icon sizes: +- 72x72 +- 96x96 +- 128x128 +- 144x144 +- 152x152 +- 192x192 +- 384x384 +- 512x512 +- 180x180 (apple-touch-icon.png) +- 32x32 (favicon.png) diff --git a/public/icons/icon-128x128.png b/public/icons/icon-128x128.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/icons/icon-128x128.png differ diff --git a/public/icons/icon-144x144.png b/public/icons/icon-144x144.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/icons/icon-144x144.png differ diff --git a/public/icons/icon-152x152.png b/public/icons/icon-152x152.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/icons/icon-152x152.png differ diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/icons/icon-192x192.png differ diff --git a/public/icons/icon-384x384.png b/public/icons/icon-384x384.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/icons/icon-384x384.png differ diff --git a/public/icons/icon-512x512.png b/public/icons/icon-512x512.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/icons/icon-512x512.png differ diff --git a/public/icons/icon-72x72.png b/public/icons/icon-72x72.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/icons/icon-72x72.png differ diff --git a/public/icons/icon-96x96.png b/public/icons/icon-96x96.png new file mode 100644 index 0000000..9187035 Binary files /dev/null and b/public/icons/icon-96x96.png differ diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..6e45b15 --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + M + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..d20808a --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,103 @@ +{ + "name": "Mexus - Gestión de Obras", + "short_name": "Mexus", + "description": "Sistema de gestión de obras de construcción", + "start_url": "/dashboard", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#2563eb", + "orientation": "portrait-primary", + "scope": "/", + "icons": [ + { + "src": "/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ], + "categories": ["business", "productivity"], + "screenshots": [ + { + "src": "/screenshots/dashboard.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide", + "label": "Dashboard principal" + }, + { + "src": "/screenshots/obras.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide", + "label": "Gestión de obras" + } + ], + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "Ver dashboard principal", + "url": "/dashboard", + "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }] + }, + { + "name": "Obras", + "short_name": "Obras", + "description": "Ver lista de obras", + "url": "/obras", + "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }] + }, + { + "name": "Nueva Obra", + "short_name": "Nueva", + "description": "Crear nueva obra", + "url": "/obras/nueva", + "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }] + } + ], + "related_applications": [], + "prefer_related_applications": false +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..d7e3dca --- /dev/null +++ b/public/sw.js @@ -0,0 +1,183 @@ +// Service Worker para PWA y Notificaciones Push +// Mexus App - Construction Management System + +const CACHE_NAME = 'mexus-app-v1'; +const STATIC_CACHE = 'mexus-static-v1'; +const DYNAMIC_CACHE = 'mexus-dynamic-v1'; + +// Recursos estáticos para cachear +const STATIC_ASSETS = [ + '/', + '/manifest.json', + '/icons/icon-192x192.png', + '/icons/icon-512x512.png', + '/apple-touch-icon.png', + '/favicon.png', +]; + +// Instalación del Service Worker +self.addEventListener('install', (event) => { + console.log('Service Worker instalado'); + event.waitUntil( + caches.open(STATIC_CACHE) + .then((cache) => { + console.log('Cacheando recursos estáticos'); + return cache.addAll(STATIC_ASSETS); + }) + .then(() => self.skipWaiting()) + ); +}); + +// Activación del Service Worker +self.addEventListener('activate', (event) => { + console.log('Service Worker activado'); + event.waitUntil( + Promise.all([ + // Limpiar caches antiguos + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== STATIC_CACHE && name !== DYNAMIC_CACHE) + .map((name) => caches.delete(name)) + ); + }), + clients.claim(), + ]) + ); +}); + +// Estrategia de fetch: Network first, fallback to cache +self.addEventListener('fetch', (event) => { + // Skip non-GET requests + if (event.request.method !== 'GET') return; + + // Skip API requests (siempre online para datos frescos) + if (event.request.url.includes('/api/')) return; + + // Skip chrome-extension and other non-http(s) requests + if (!event.request.url.startsWith('http')) return; + + event.respondWith( + fetch(event.request) + .then((response) => { + // Clone response para guardarlo en cache + const responseClone = response.clone(); + + // Solo cachear respuestas exitosas + if (response.status === 200) { + caches.open(DYNAMIC_CACHE).then((cache) => { + cache.put(event.request, responseClone); + }); + } + + return response; + }) + .catch(() => { + // Si falla la red, buscar en cache + return caches.match(event.request).then((response) => { + if (response) { + return response; + } + + // Si es una página, mostrar página offline + if (event.request.headers.get('accept')?.includes('text/html')) { + return caches.match('/'); + } + + return new Response('Offline', { status: 503 }); + }); + }) + ); +}); + +// Recibir notificaciones push +self.addEventListener('push', (event) => { + console.log('Push recibido:', event); + + let data = { + title: 'Mexus App', + body: 'Nueva notificación', + icon: '/icon-192x192.png', + badge: '/badge-72x72.png', + url: '/', + }; + + try { + if (event.data) { + data = { ...data, ...event.data.json() }; + } + } catch (e) { + console.error('Error parsing push data:', e); + } + + const options = { + body: data.body, + icon: data.icon || '/icon-192x192.png', + badge: data.badge || '/badge-72x72.png', + vibrate: [100, 50, 100], + data: { + url: data.url || '/', + dateOfArrival: Date.now(), + }, + actions: [ + { + action: 'open', + title: 'Abrir', + icon: '/icons/checkmark.png', + }, + { + action: 'close', + title: 'Cerrar', + icon: '/icons/xmark.png', + }, + ], + tag: data.tag || 'default', + renotify: true, + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +// Clic en notificación +self.addEventListener('notificationclick', (event) => { + console.log('Notificación clickeada:', event); + + event.notification.close(); + + if (event.action === 'close') { + return; + } + + const url = event.notification.data?.url || '/'; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Si ya hay una ventana abierta, enfocarla y navegar + for (const client of clientList) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + client.focus(); + return client.navigate(url); + } + } + // Si no hay ventana, abrir una nueva + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); +}); + +// Cerrar notificación +self.addEventListener('notificationclose', (event) => { + console.log('Notificación cerrada:', event); +}); + +// Sincronización en background (para futuras mejoras) +self.addEventListener('sync', (event) => { + if (event.tag === 'sync-notifications') { + console.log('Sync de notificaciones'); + } +}); diff --git a/public/uploads/.gitkeep b/public/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/generate-icons-simple.js b/scripts/generate-icons-simple.js new file mode 100644 index 0000000..0b734e6 --- /dev/null +++ b/scripts/generate-icons-simple.js @@ -0,0 +1,89 @@ +const fs = require('fs'); +const path = require('path'); + +// Icon sizes for PWA +const sizes = [72, 96, 128, 144, 152, 192, 384, 512]; + +// Simple 1x1 blue PNG as base64 (we'll use this as a placeholder) +// Users should replace these with actual icons generated from the SVG +const createPlaceholderPNG = (size) => { + // PNG header for a simple blue image + // This creates a valid minimal PNG + const png = Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk start + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 pixels + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // 8-bit RGB + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk + 0x54, 0x08, 0xD7, 0x63, 0x48, 0xC5, 0xD8, 0x60, // compressed blue pixel + 0x00, 0x00, 0x00, 0x83, 0x00, 0x81, 0x3D, 0xE7, + 0x79, 0xBA, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, // IEND chunk + 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 + ]); + return png; +}; + +// Generate placeholder icons +const iconsDir = path.join(__dirname, '../public/icons'); + +console.log('Generating placeholder icons...'); +console.log('Note: Replace these with properly generated icons from icon.svg'); +console.log('You can use tools like: https://realfavicongenerator.net/\n'); + +// For now, we'll copy the SVG as a reference and create instruction file +sizes.forEach(size => { + const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`); + fs.writeFileSync(outputPath, createPlaceholderPNG(size)); + console.log(`Created placeholder: icon-${size}x${size}.png`); +}); + +// Create favicon placeholder +fs.writeFileSync(path.join(__dirname, '../public/favicon.png'), createPlaceholderPNG(32)); +console.log('Created placeholder: favicon.png'); + +// Create apple-touch-icon placeholder +fs.writeFileSync(path.join(__dirname, '../public/apple-touch-icon.png'), createPlaceholderPNG(180)); +console.log('Created placeholder: apple-touch-icon.png'); + +// Create instructions file +const instructions = `# PWA Icons Generation Instructions + +The placeholder icons in this directory should be replaced with properly generated icons. + +## Option 1: Use an online tool +1. Go to https://realfavicongenerator.net/ +2. Upload the icon.svg file from this directory +3. Download the generated icons +4. Replace the placeholder PNGs + +## Option 2: Use sharp (Node.js) +If you have libvips installed, you can use the generate-icons.js script: +\`\`\`bash +npm install sharp --save-dev +node scripts/generate-icons.js +\`\`\` + +## Option 3: Use ImageMagick +If you have ImageMagick installed: +\`\`\`bash +for size in 72 96 128 144 152 192 384 512; do + convert icon.svg -resize \${size}x\${size} icon-\${size}x\${size}.png +done +\`\`\` + +## Required icon sizes: +- 72x72 +- 96x96 +- 128x128 +- 144x144 +- 152x152 +- 192x192 +- 384x384 +- 512x512 +- 180x180 (apple-touch-icon.png) +- 32x32 (favicon.png) +`; + +fs.writeFileSync(path.join(iconsDir, 'README.md'), instructions); +console.log('\nCreated: icons/README.md with generation instructions'); +console.log('\nPlaceholder icons generated successfully!'); diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js new file mode 100644 index 0000000..db24b58 --- /dev/null +++ b/scripts/generate-icons.js @@ -0,0 +1,56 @@ +const fs = require('fs'); +const path = require('path'); + +// Read the SVG file +const svgPath = path.join(__dirname, '../public/icons/icon.svg'); +const svgContent = fs.readFileSync(svgPath, 'utf8'); + +// Icon sizes for PWA +const sizes = [72, 96, 128, 144, 152, 192, 384, 512]; + +// Try to use sharp if available, otherwise create placeholder files +async function generateIcons() { + try { + const sharp = require('sharp'); + + for (const size of sizes) { + const outputPath = path.join(__dirname, `../public/icons/icon-${size}x${size}.png`); + + await sharp(Buffer.from(svgContent)) + .resize(size, size) + .png() + .toFile(outputPath); + + console.log(`Generated: icon-${size}x${size}.png`); + } + + // Generate favicon + await sharp(Buffer.from(svgContent)) + .resize(32, 32) + .png() + .toFile(path.join(__dirname, '../public/favicon.png')); + + console.log('Generated: favicon.png'); + + // Generate apple-touch-icon + await sharp(Buffer.from(svgContent)) + .resize(180, 180) + .png() + .toFile(path.join(__dirname, '../public/apple-touch-icon.png')); + + console.log('Generated: apple-touch-icon.png'); + + console.log('\nAll icons generated successfully!'); + } catch (error) { + if (error.code === 'MODULE_NOT_FOUND') { + console.log('Sharp not found. Installing...'); + const { execSync } = require('child_process'); + execSync('npm install sharp --save-dev', { stdio: 'inherit' }); + console.log('Sharp installed. Please run this script again.'); + } else { + console.error('Error generating icons:', error); + } + } +} + +generateIcons(); diff --git a/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx b/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx index c2fbfce..0e2e387 100644 --- a/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx +++ b/src/app/(dashboard)/obras/[id]/obra-detail-client.tsx @@ -41,7 +41,19 @@ import { type EstadoTarea, type EstadoGasto, type CategoriaGasto, + type CondicionClima, } from "@/types"; +import { GaleriaFotos } from "@/components/fotos/galeria-fotos"; +import { BitacoraObra } from "@/components/bitacora/bitacora-obra"; +import { ControlAsistencia } from "@/components/asistencia/control-asistencia"; +import { OrdenesCompra } from "@/components/ordenes/ordenes-compra"; +import { DiagramaGantt } from "@/components/gantt/diagrama-gantt"; +import { + ExportPDFMenu, + ReporteObraPDF, + GastosPDF, + BitacoraPDF, +} from "@/components/pdf"; interface ObraDetailProps { obra: { @@ -106,6 +118,43 @@ interface ObraDetailProps { createdAt: Date; registradoPor: { nombre: string; apellido: string }; }[]; + fotos: { + id: string; + url: string; + thumbnail: string | null; + titulo: string | null; + descripcion: string | null; + fechaCaptura: Date; + latitud: number | null; + longitud: number | null; + direccionGeo: string | null; + subidoPor: { nombre: string; apellido: string }; + fase: { nombre: string } | null; + }[]; + bitacoras: { + id: string; + fecha: Date; + clima: CondicionClima; + temperaturaMin: number | null; + temperaturaMax: number | null; + condicionesExtra: string | null; + personalPropio: number; + personalSubcontrato: number; + personalDetalle: string | null; + actividadesRealizadas: string; + actividadesPendientes: string | null; + materialesUtilizados: string | null; + materialesRecibidos: string | null; + equipoUtilizado: string | null; + incidentes: string | null; + observaciones: string | null; + incidentesSeguridad: string | null; + platicaSeguridad: boolean; + temaSeguridad: string | null; + visitasInspeccion: string | null; + registradoPor: { nombre: string; apellido: string }; + createdAt: Date; + }[]; }; } @@ -139,12 +188,81 @@ export function ObraDetailClient({ obra }: ObraDetailProps) { - - - +
+ ({ + nombre: f.nombre, + porcentajeAvance: f.porcentajeAvance, + tareas: f.tareas.map((t) => ({ + nombre: t.nombre, + estado: t.estado, + })), + })), + gastos: obra.gastos.map((g) => ({ + concepto: g.concepto, + monto: g.monto, + fecha: g.fecha.toString(), + categoria: g.categoria, + })), + }} + /> + ), + fileName: `reporte-${obra.nombre.toLowerCase().replace(/\s+/g, "-")}`, + }, + { + label: "Reporte de Gastos", + document: ( + ({ + ...g, + fecha: g.fecha.toString(), + proveedor: null, + factura: null, + notas: null, + }))} + /> + ), + fileName: `gastos-${obra.nombre.toLowerCase().replace(/\s+/g, "-")}`, + }, + { + label: "Bitacora de Obra", + document: ( + ({ + ...b, + fecha: b.fecha.toString(), + }))} + /> + ), + fileName: `bitacora-${obra.nombre.toLowerCase().replace(/\s+/g, "-")}`, + }, + ]} + /> + + + +
{/* Progress and Stats */} @@ -207,11 +325,16 @@ export function ObraDetailClient({ obra }: ObraDetailProps) { {/* Tabs */} - + General Cronograma + Gantt Presupuesto Gastos + Asistencia + Fotos + Bitacora + Ordenes Avances @@ -362,6 +485,33 @@ export function ObraDetailClient({ obra }: ObraDetailProps) { )} + + ({ + id: fase.id, + nombre: fase.nombre, + descripcion: fase.descripcion, + orden: fase.orden, + fechaInicio: null, + fechaFin: null, + porcentajeAvance: fase.porcentajeAvance, + tareas: fase.tareas.map((tarea) => ({ + id: tarea.id, + nombre: tarea.nombre, + descripcion: null, + estado: tarea.estado, + fechaInicio: null, + fechaFin: null, + porcentajeAvance: 0, + })), + }))} + /> + +

Presupuestos

@@ -447,6 +597,36 @@ export function ObraDetailClient({ obra }: ObraDetailProps) { )} + + + + + + ({ + ...f, + fechaCaptura: f.fechaCaptura.toString(), + }))} + fases={obra.fases.map((f) => ({ id: f.id, nombre: f.nombre }))} + /> + + + + ({ + ...b, + fecha: b.fecha.toString(), + createdAt: b.createdAt.toString(), + }))} + /> + + + + + +

Registros de Avance

diff --git a/src/app/(dashboard)/obras/[id]/page.tsx b/src/app/(dashboard)/obras/[id]/page.tsx index b9177d9..8b1c45a 100644 --- a/src/app/(dashboard)/obras/[id]/page.tsx +++ b/src/app/(dashboard)/obras/[id]/page.tsx @@ -40,6 +40,20 @@ async function getObra(id: string, empresaId: string) { registradoPor: { select: { nombre: true, apellido: true } }, }, }, + fotos: { + orderBy: { fechaCaptura: "desc" }, + include: { + subidoPor: { select: { nombre: true, apellido: true } }, + fase: { select: { nombre: true } }, + }, + }, + bitacoras: { + orderBy: { fecha: "desc" }, + take: 10, + include: { + registradoPor: { select: { nombre: true, apellido: true } }, + }, + }, }, }); } diff --git a/src/app/api/actividades/route.ts b/src/app/api/actividades/route.ts new file mode 100644 index 0000000..8eebf5a --- /dev/null +++ b/src/app/api/actividades/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// GET - Obtener log de actividades +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const obraId = searchParams.get("obraId"); + const tipo = searchParams.get("tipo"); + const limit = parseInt(searchParams.get("limit") || "50"); + const offset = parseInt(searchParams.get("offset") || "0"); + + const where: Record = { + empresaId: session.user.empresaId, + }; + + if (obraId) { + where.obraId = obraId; + } + + if (tipo) { + where.tipo = tipo; + } + + const [actividades, total] = await Promise.all([ + prisma.actividadLog.findMany({ + where, + include: { + user: { + select: { + id: true, + nombre: true, + apellido: true, + }, + }, + obra: { + select: { + id: true, + nombre: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + take: limit, + skip: offset, + }), + prisma.actividadLog.count({ where }), + ]); + + return NextResponse.json({ + actividades, + total, + limit, + offset, + hasMore: offset + limit < total, + }); + } catch (error) { + console.error("Error fetching actividades:", error); + return NextResponse.json( + { error: "Error al obtener actividades" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/asistencia/[id]/route.ts b/src/app/api/asistencia/[id]/route.ts new file mode 100644 index 0000000..c105546 --- /dev/null +++ b/src/app/api/asistencia/[id]/route.ts @@ -0,0 +1,222 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const updateAsistenciaSchema = z.object({ + tipo: z.enum([ + "PRESENTE", + "AUSENTE", + "RETARDO", + "PERMISO", + "INCAPACIDAD", + "VACACIONES", + ]).optional(), + horaEntrada: z.string().optional().nullable(), + latitudEntrada: z.number().optional().nullable(), + longitudEntrada: z.number().optional().nullable(), + horaSalida: z.string().optional().nullable(), + latitudSalida: z.number().optional().nullable(), + longitudSalida: z.number().optional().nullable(), + horasExtra: z.number().min(0).optional(), + notas: z.string().optional().nullable(), + motivoAusencia: z.string().optional().nullable(), +}); + +// GET - Obtener una asistencia específica +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + const asistencia = await prisma.asistencia.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + include: { + empleado: { + select: { + id: true, + nombre: true, + apellido: true, + puesto: true, + telefono: true, + }, + }, + obra: { + select: { + nombre: true, + direccion: true, + }, + }, + registradoPor: { + select: { + nombre: true, + apellido: true, + email: true, + }, + }, + }, + }); + + if (!asistencia) { + return NextResponse.json( + { error: "Asistencia no encontrada" }, + { status: 404 } + ); + } + + return NextResponse.json(asistencia); + } catch (error) { + console.error("Error fetching asistencia:", error); + return NextResponse.json( + { error: "Error al obtener la asistencia" }, + { status: 500 } + ); + } +} + +// PUT - Actualizar una asistencia (ej: registrar salida) +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const validatedData = updateAsistenciaSchema.parse(body); + + // Verificar que la asistencia pertenece a la empresa del usuario + const asistenciaExistente = await prisma.asistencia.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!asistenciaExistente) { + return NextResponse.json( + { error: "Asistencia no encontrada" }, + { status: 404 } + ); + } + + // Calcular horas trabajadas si hay cambios en entrada o salida + let horasTrabajadas = asistenciaExistente.horasTrabajadas; + const horaEntrada = validatedData.horaEntrada !== undefined + ? (validatedData.horaEntrada ? new Date(validatedData.horaEntrada) : null) + : asistenciaExistente.horaEntrada; + const horaSalida = validatedData.horaSalida !== undefined + ? (validatedData.horaSalida ? new Date(validatedData.horaSalida) : null) + : asistenciaExistente.horaSalida; + + if (horaEntrada && horaSalida) { + horasTrabajadas = (horaSalida.getTime() - horaEntrada.getTime()) / (1000 * 60 * 60); + } + + const asistencia = await prisma.asistencia.update({ + where: { id }, + data: { + ...validatedData, + horaEntrada: validatedData.horaEntrada !== undefined + ? (validatedData.horaEntrada ? new Date(validatedData.horaEntrada) : null) + : undefined, + horaSalida: validatedData.horaSalida !== undefined + ? (validatedData.horaSalida ? new Date(validatedData.horaSalida) : null) + : undefined, + horasTrabajadas, + }, + include: { + empleado: { + select: { + id: true, + nombre: true, + apellido: true, + puesto: true, + }, + }, + registradoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + }, + }); + + return NextResponse.json(asistencia); + } catch (error) { + console.error("Error updating asistencia:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al actualizar la asistencia" }, + { status: 500 } + ); + } +} + +// DELETE - Eliminar una asistencia +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + // Verificar que la asistencia pertenece a la empresa del usuario + const asistencia = await prisma.asistencia.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!asistencia) { + return NextResponse.json( + { error: "Asistencia no encontrada" }, + { status: 404 } + ); + } + + await prisma.asistencia.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "Asistencia eliminada exitosamente" }); + } catch (error) { + console.error("Error deleting asistencia:", error); + return NextResponse.json( + { error: "Error al eliminar la asistencia" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/asistencia/empleados/route.ts b/src/app/api/asistencia/empleados/route.ts new file mode 100644 index 0000000..042ec49 --- /dev/null +++ b/src/app/api/asistencia/empleados/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// GET - Obtener empleados disponibles para registrar asistencia en una obra +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const obraId = searchParams.get("obraId"); + + if (!obraId) { + return NextResponse.json( + { error: "Se requiere obraId" }, + { status: 400 } + ); + } + + // Verificar que la obra pertenece a la empresa del usuario + const obra = await prisma.obra.findFirst({ + where: { + id: obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + // Obtener empleados asignados a la obra o todos los empleados activos de la empresa + const empleadosAsignados = await prisma.asignacionEmpleado.findMany({ + where: { + obraId, + activo: true, + }, + include: { + empleado: { + select: { + id: true, + nombre: true, + apellido: true, + puesto: true, + telefono: true, + activo: true, + }, + }, + }, + }); + + // Si hay empleados asignados, devolver solo esos + if (empleadosAsignados.length > 0) { + const empleados = empleadosAsignados + .filter((a) => a.empleado.activo) + .map((a) => a.empleado); + return NextResponse.json(empleados); + } + + // Si no hay asignaciones, devolver todos los empleados activos de la empresa + const todosEmpleados = await prisma.empleado.findMany({ + where: { + empresaId: session.user.empresaId, + activo: true, + }, + select: { + id: true, + nombre: true, + apellido: true, + puesto: true, + telefono: true, + activo: true, + }, + orderBy: [{ apellido: "asc" }, { nombre: "asc" }], + }); + + return NextResponse.json(todosEmpleados); + } catch (error) { + console.error("Error fetching empleados:", error); + return NextResponse.json( + { error: "Error al obtener los empleados" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/asistencia/route.ts b/src/app/api/asistencia/route.ts new file mode 100644 index 0000000..3b60127 --- /dev/null +++ b/src/app/api/asistencia/route.ts @@ -0,0 +1,236 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const asistenciaSchema = z.object({ + fecha: z.string(), + tipo: z.enum([ + "PRESENTE", + "AUSENTE", + "RETARDO", + "PERMISO", + "INCAPACIDAD", + "VACACIONES", + ]), + horaEntrada: z.string().optional().nullable(), + latitudEntrada: z.number().optional().nullable(), + longitudEntrada: z.number().optional().nullable(), + horaSalida: z.string().optional().nullable(), + latitudSalida: z.number().optional().nullable(), + longitudSalida: z.number().optional().nullable(), + horasExtra: z.number().min(0).default(0), + notas: z.string().optional().nullable(), + motivoAusencia: z.string().optional().nullable(), + empleadoId: z.string(), + obraId: z.string(), +}); + +// GET - Obtener asistencias de una obra +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const obraId = searchParams.get("obraId"); + const fecha = searchParams.get("fecha"); // Formato: YYYY-MM-DD + const mes = searchParams.get("mes"); // Formato: YYYY-MM + const empleadoId = searchParams.get("empleadoId"); + + if (!obraId) { + return NextResponse.json( + { error: "Se requiere obraId" }, + { status: 400 } + ); + } + + // Verificar que la obra pertenece a la empresa del usuario + const obra = await prisma.obra.findFirst({ + where: { + id: obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + // Construir filtro de fecha + let dateFilter = {}; + if (fecha) { + const fechaBusqueda = new Date(fecha); + dateFilter = { fecha: fechaBusqueda }; + } else if (mes) { + const [year, month] = mes.split("-").map(Number); + const startDate = new Date(year, month - 1, 1); + const endDate = new Date(year, month, 0); + dateFilter = { + fecha: { + gte: startDate, + lte: endDate, + }, + }; + } + + const asistencias = await prisma.asistencia.findMany({ + where: { + obraId, + ...dateFilter, + ...(empleadoId && { empleadoId }), + }, + include: { + empleado: { + select: { + id: true, + nombre: true, + apellido: true, + puesto: true, + }, + }, + registradoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + }, + orderBy: [{ fecha: "desc" }, { empleado: { apellido: "asc" } }], + }); + + return NextResponse.json(asistencias); + } catch (error) { + console.error("Error fetching asistencias:", error); + return NextResponse.json( + { error: "Error al obtener las asistencias" }, + { status: 500 } + ); + } +} + +// POST - Registrar asistencia +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId || !session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = asistenciaSchema.parse(body); + + // Verificar que la obra pertenece a la empresa del usuario + const obra = await prisma.obra.findFirst({ + where: { + id: validatedData.obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + // Verificar que el empleado pertenece a la empresa + const empleado = await prisma.empleado.findFirst({ + where: { + id: validatedData.empleadoId, + empresaId: session.user.empresaId, + }, + }); + + if (!empleado) { + return NextResponse.json( + { error: "Empleado no encontrado" }, + { status: 404 } + ); + } + + const fechaAsistencia = new Date(validatedData.fecha); + + // Verificar si ya existe registro para este empleado en esta fecha y obra + const existente = await prisma.asistencia.findUnique({ + where: { + empleadoId_obraId_fecha: { + empleadoId: validatedData.empleadoId, + obraId: validatedData.obraId, + fecha: fechaAsistencia, + }, + }, + }); + + if (existente) { + return NextResponse.json( + { error: "Ya existe un registro de asistencia para este empleado en esta fecha" }, + { status: 400 } + ); + } + + // Calcular horas trabajadas si hay entrada y salida + let horasTrabajadas = null; + if (validatedData.horaEntrada && validatedData.horaSalida) { + const entrada = new Date(validatedData.horaEntrada); + const salida = new Date(validatedData.horaSalida); + horasTrabajadas = (salida.getTime() - entrada.getTime()) / (1000 * 60 * 60); + } + + const asistencia = await prisma.asistencia.create({ + data: { + fecha: fechaAsistencia, + tipo: validatedData.tipo, + horaEntrada: validatedData.horaEntrada ? new Date(validatedData.horaEntrada) : null, + latitudEntrada: validatedData.latitudEntrada, + longitudEntrada: validatedData.longitudEntrada, + horaSalida: validatedData.horaSalida ? new Date(validatedData.horaSalida) : null, + latitudSalida: validatedData.latitudSalida, + longitudSalida: validatedData.longitudSalida, + horasTrabajadas, + horasExtra: validatedData.horasExtra, + notas: validatedData.notas, + motivoAusencia: validatedData.motivoAusencia, + empleadoId: validatedData.empleadoId, + obraId: validatedData.obraId, + registradoPorId: session.user.id, + }, + include: { + empleado: { + select: { + id: true, + nombre: true, + apellido: true, + puesto: true, + }, + }, + registradoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + }, + }); + + return NextResponse.json(asistencia, { status: 201 }); + } catch (error) { + console.error("Error creating asistencia:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al registrar la asistencia" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bitacora/[id]/route.ts b/src/app/api/bitacora/[id]/route.ts new file mode 100644 index 0000000..2697069 --- /dev/null +++ b/src/app/api/bitacora/[id]/route.ts @@ -0,0 +1,194 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const updateBitacoraSchema = z.object({ + clima: z.enum([ + "SOLEADO", + "NUBLADO", + "PARCIALMENTE_NUBLADO", + "LLUVIA_LIGERA", + "LLUVIA_FUERTE", + "TORMENTA", + "VIENTO_FUERTE", + "FRIO_EXTREMO", + "CALOR_EXTREMO", + ]).optional(), + temperaturaMin: z.number().optional().nullable(), + temperaturaMax: z.number().optional().nullable(), + condicionesExtra: z.string().optional().nullable(), + personalPropio: z.number().int().min(0).optional(), + personalSubcontrato: z.number().int().min(0).optional(), + personalDetalle: z.string().optional().nullable(), + actividadesRealizadas: z.string().optional(), + actividadesPendientes: z.string().optional().nullable(), + materialesUtilizados: z.string().optional().nullable(), + materialesRecibidos: z.string().optional().nullable(), + equipoUtilizado: z.string().optional().nullable(), + incidentes: z.string().optional().nullable(), + observaciones: z.string().optional().nullable(), + incidentesSeguridad: z.string().optional().nullable(), + platicaSeguridad: z.boolean().optional(), + temaSeguridad: z.string().optional().nullable(), + visitasInspeccion: z.string().optional().nullable(), +}); + +// GET - Obtener una bitácora específica +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + const bitacora = await prisma.bitacoraObra.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + include: { + registradoPor: { + select: { + nombre: true, + apellido: true, + email: true, + }, + }, + obra: { + select: { + nombre: true, + direccion: true, + }, + }, + }, + }); + + if (!bitacora) { + return NextResponse.json( + { error: "Bitácora no encontrada" }, + { status: 404 } + ); + } + + return NextResponse.json(bitacora); + } catch (error) { + console.error("Error fetching bitacora:", error); + return NextResponse.json( + { error: "Error al obtener la bitácora" }, + { status: 500 } + ); + } +} + +// PUT - Actualizar una bitácora +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const validatedData = updateBitacoraSchema.parse(body); + + // Verificar que la bitácora pertenece a la empresa del usuario + const bitacoraExistente = await prisma.bitacoraObra.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!bitacoraExistente) { + return NextResponse.json( + { error: "Bitácora no encontrada" }, + { status: 404 } + ); + } + + const bitacora = await prisma.bitacoraObra.update({ + where: { id }, + data: validatedData, + include: { + registradoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + }, + }); + + return NextResponse.json(bitacora); + } catch (error) { + console.error("Error updating bitacora:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al actualizar la bitácora" }, + { status: 500 } + ); + } +} + +// DELETE - Eliminar una bitácora +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + // Verificar que la bitácora pertenece a la empresa del usuario + const bitacora = await prisma.bitacoraObra.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!bitacora) { + return NextResponse.json( + { error: "Bitácora no encontrada" }, + { status: 404 } + ); + } + + await prisma.bitacoraObra.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "Bitácora eliminada exitosamente" }); + } catch (error) { + console.error("Error deleting bitacora:", error); + return NextResponse.json( + { error: "Error al eliminar la bitácora" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bitacora/route.ts b/src/app/api/bitacora/route.ts new file mode 100644 index 0000000..e59e9f2 --- /dev/null +++ b/src/app/api/bitacora/route.ts @@ -0,0 +1,191 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const bitacoraSchema = z.object({ + fecha: z.string(), + clima: z.enum([ + "SOLEADO", + "NUBLADO", + "PARCIALMENTE_NUBLADO", + "LLUVIA_LIGERA", + "LLUVIA_FUERTE", + "TORMENTA", + "VIENTO_FUERTE", + "FRIO_EXTREMO", + "CALOR_EXTREMO", + ]), + temperaturaMin: z.number().optional().nullable(), + temperaturaMax: z.number().optional().nullable(), + condicionesExtra: z.string().optional().nullable(), + personalPropio: z.number().int().min(0).default(0), + personalSubcontrato: z.number().int().min(0).default(0), + personalDetalle: z.string().optional().nullable(), + actividadesRealizadas: z.string().min(1, "Las actividades son requeridas"), + actividadesPendientes: z.string().optional().nullable(), + materialesUtilizados: z.string().optional().nullable(), + materialesRecibidos: z.string().optional().nullable(), + equipoUtilizado: z.string().optional().nullable(), + incidentes: z.string().optional().nullable(), + observaciones: z.string().optional().nullable(), + incidentesSeguridad: z.string().optional().nullable(), + platicaSeguridad: z.boolean().default(false), + temaSeguridad: z.string().optional().nullable(), + visitasInspeccion: z.string().optional().nullable(), + obraId: z.string(), +}); + +// GET - Obtener bitácoras de una obra +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const obraId = searchParams.get("obraId"); + const mes = searchParams.get("mes"); // Formato: YYYY-MM + const limit = searchParams.get("limit"); + + if (!obraId) { + return NextResponse.json( + { error: "Se requiere obraId" }, + { status: 400 } + ); + } + + // Verificar que la obra pertenece a la empresa del usuario + const obra = await prisma.obra.findFirst({ + where: { + id: obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + // Construir filtro de fecha + let dateFilter = {}; + if (mes) { + const [year, month] = mes.split("-").map(Number); + const startDate = new Date(year, month - 1, 1); + const endDate = new Date(year, month, 0); + dateFilter = { + fecha: { + gte: startDate, + lte: endDate, + }, + }; + } + + const bitacoras = await prisma.bitacoraObra.findMany({ + where: { + obraId, + ...dateFilter, + }, + include: { + registradoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + }, + orderBy: { + fecha: "desc", + }, + ...(limit && { take: parseInt(limit) }), + }); + + return NextResponse.json(bitacoras); + } catch (error) { + console.error("Error fetching bitacoras:", error); + return NextResponse.json( + { error: "Error al obtener las bitácoras" }, + { status: 500 } + ); + } +} + +// POST - Crear nueva entrada de bitácora +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId || !session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = bitacoraSchema.parse(body); + + // Verificar que la obra pertenece a la empresa del usuario + const obra = await prisma.obra.findFirst({ + where: { + id: validatedData.obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + // Verificar si ya existe una bitácora para esa fecha + const fechaBitacora = new Date(validatedData.fecha); + const existente = await prisma.bitacoraObra.findUnique({ + where: { + obraId_fecha: { + obraId: validatedData.obraId, + fecha: fechaBitacora, + }, + }, + }); + + if (existente) { + return NextResponse.json( + { error: "Ya existe una bitácora para esta fecha" }, + { status: 400 } + ); + } + + const bitacora = await prisma.bitacoraObra.create({ + data: { + ...validatedData, + fecha: fechaBitacora, + registradoPorId: session.user.id, + }, + include: { + registradoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + }, + }); + + return NextResponse.json(bitacora, { status: 201 }); + } catch (error) { + console.error("Error creating bitacora:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al crear la bitácora" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/clientes-acceso/[id]/route.ts b/src/app/api/clientes-acceso/[id]/route.ts new file mode 100644 index 0000000..fae3ed4 --- /dev/null +++ b/src/app/api/clientes-acceso/[id]/route.ts @@ -0,0 +1,239 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; +import crypto from "crypto"; + +const updateAccesoSchema = z.object({ + password: z.string().min(6).optional(), + regenerarToken: z.boolean().optional(), + tokenExpiraDias: z.number().min(1).max(365).optional(), + activo: z.boolean().optional(), + verFotos: z.boolean().optional(), + verAvances: z.boolean().optional(), + verGastos: z.boolean().optional(), + verDocumentos: z.boolean().optional(), + descargarPDF: z.boolean().optional(), +}); + +// GET - Obtener un acceso específico +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + const acceso = await prisma.clienteAcceso.findFirst({ + where: { + id, + cliente: { + empresaId: session.user.empresaId, + }, + }, + include: { + cliente: { + select: { + id: true, + nombre: true, + email: true, + obras: { + select: { + id: true, + nombre: true, + estado: true, + }, + }, + }, + }, + }, + }); + + if (!acceso) { + return NextResponse.json( + { error: "Acceso no encontrado" }, + { status: 404 } + ); + } + + const { password, ...rest } = acceso; + return NextResponse.json({ + ...rest, + tienePassword: !!password, + }); + } catch (error) { + console.error("Error fetching acceso:", error); + return NextResponse.json( + { error: "Error al obtener el acceso" }, + { status: 500 } + ); + } +} + +// PUT - Actualizar un acceso +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const validatedData = updateAccesoSchema.parse(body); + + // Verificar que el acceso pertenece a la empresa + const existingAcceso = await prisma.clienteAcceso.findFirst({ + where: { + id, + cliente: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!existingAcceso) { + return NextResponse.json( + { error: "Acceso no encontrado" }, + { status: 404 } + ); + } + + // Preparar datos de actualización + const updateData: Record = {}; + + if (validatedData.password) { + updateData.password = await bcrypt.hash(validatedData.password, 10); + } + + if (validatedData.regenerarToken) { + updateData.token = crypto.randomBytes(32).toString("hex"); + if (validatedData.tokenExpiraDias) { + const expira = new Date(); + expira.setDate(expira.getDate() + validatedData.tokenExpiraDias); + updateData.tokenExpira = expira; + } else { + updateData.tokenExpira = null; + } + } + + if (validatedData.activo !== undefined) { + updateData.activo = validatedData.activo; + } + + if (validatedData.verFotos !== undefined) { + updateData.verFotos = validatedData.verFotos; + } + if (validatedData.verAvances !== undefined) { + updateData.verAvances = validatedData.verAvances; + } + if (validatedData.verGastos !== undefined) { + updateData.verGastos = validatedData.verGastos; + } + if (validatedData.verDocumentos !== undefined) { + updateData.verDocumentos = validatedData.verDocumentos; + } + if (validatedData.descargarPDF !== undefined) { + updateData.descargarPDF = validatedData.descargarPDF; + } + + const acceso = await prisma.clienteAcceso.update({ + where: { id }, + data: updateData, + include: { + cliente: { + select: { + id: true, + nombre: true, + }, + }, + }, + }); + + // Construir URL de acceso con token + const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; + const accessUrl = acceso.token ? `${baseUrl}/portal?token=${acceso.token}` : null; + + return NextResponse.json({ + id: acceso.id, + email: acceso.email, + token: acceso.token, + tokenExpira: acceso.tokenExpira, + accessUrl, + activo: acceso.activo, + cliente: acceso.cliente, + permisos: { + verFotos: acceso.verFotos, + verAvances: acceso.verAvances, + verGastos: acceso.verGastos, + verDocumentos: acceso.verDocumentos, + descargarPDF: acceso.descargarPDF, + }, + }); + } catch (error) { + console.error("Error updating acceso:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al actualizar el acceso" }, + { status: 500 } + ); + } +} + +// DELETE - Eliminar un acceso +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + // Verificar que el acceso pertenece a la empresa + const acceso = await prisma.clienteAcceso.findFirst({ + where: { + id, + cliente: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!acceso) { + return NextResponse.json( + { error: "Acceso no encontrado" }, + { status: 404 } + ); + } + + await prisma.clienteAcceso.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "Acceso eliminado exitosamente" }); + } catch (error) { + console.error("Error deleting acceso:", error); + return NextResponse.json( + { error: "Error al eliminar el acceso" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/clientes-acceso/route.ts b/src/app/api/clientes-acceso/route.ts new file mode 100644 index 0000000..9bfb1d2 --- /dev/null +++ b/src/app/api/clientes-acceso/route.ts @@ -0,0 +1,178 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; +import crypto from "crypto"; + +const createAccesoSchema = z.object({ + clienteId: z.string(), + email: z.string().email("Email inválido"), + password: z.string().min(6, "La contraseña debe tener al menos 6 caracteres").optional(), + usarToken: z.boolean().default(false), + tokenExpiraDias: z.number().min(1).max(365).optional(), + verFotos: z.boolean().default(true), + verAvances: z.boolean().default(true), + verGastos: z.boolean().default(false), + verDocumentos: z.boolean().default(true), + descargarPDF: z.boolean().default(true), +}); + +// GET - Obtener accesos de clientes +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const clienteId = searchParams.get("clienteId"); + + const accesos = await prisma.clienteAcceso.findMany({ + where: { + cliente: { + empresaId: session.user.empresaId, + ...(clienteId && { id: clienteId }), + }, + }, + include: { + cliente: { + select: { + id: true, + nombre: true, + email: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + // No devolver contraseñas + const accesosSafe = accesos.map(({ password, ...rest }) => ({ + ...rest, + tienePassword: !!password, + })); + + return NextResponse.json(accesosSafe); + } catch (error) { + console.error("Error fetching accesos:", error); + return NextResponse.json( + { error: "Error al obtener los accesos" }, + { status: 500 } + ); + } +} + +// POST - Crear nuevo acceso para cliente +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = createAccesoSchema.parse(body); + + // Verificar que el cliente pertenece a la empresa + const cliente = await prisma.cliente.findFirst({ + where: { + id: validatedData.clienteId, + empresaId: session.user.empresaId, + }, + }); + + if (!cliente) { + return NextResponse.json( + { error: "Cliente no encontrado" }, + { status: 404 } + ); + } + + // Verificar que el email no esté en uso + const existingAcceso = await prisma.clienteAcceso.findUnique({ + where: { email: validatedData.email }, + }); + + if (existingAcceso) { + return NextResponse.json( + { error: "Este email ya tiene un acceso registrado" }, + { status: 400 } + ); + } + + // Preparar datos + let hashedPassword: string | null = null; + let token: string | null = null; + let tokenExpira: Date | null = null; + + if (validatedData.password) { + hashedPassword = await bcrypt.hash(validatedData.password, 10); + } + + if (validatedData.usarToken) { + token = crypto.randomBytes(32).toString("hex"); + if (validatedData.tokenExpiraDias) { + tokenExpira = new Date(); + tokenExpira.setDate(tokenExpira.getDate() + validatedData.tokenExpiraDias); + } + } + + // Crear acceso + const acceso = await prisma.clienteAcceso.create({ + data: { + email: validatedData.email, + password: hashedPassword, + token, + tokenExpira, + verFotos: validatedData.verFotos, + verAvances: validatedData.verAvances, + verGastos: validatedData.verGastos, + verDocumentos: validatedData.verDocumentos, + descargarPDF: validatedData.descargarPDF, + clienteId: validatedData.clienteId, + }, + include: { + cliente: { + select: { + id: true, + nombre: true, + }, + }, + }, + }); + + // Construir URL de acceso con token + const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; + const accessUrl = token ? `${baseUrl}/portal?token=${token}` : null; + + return NextResponse.json({ + id: acceso.id, + email: acceso.email, + token: acceso.token, + tokenExpira: acceso.tokenExpira, + accessUrl, + cliente: acceso.cliente, + permisos: { + verFotos: acceso.verFotos, + verAvances: acceso.verAvances, + verGastos: acceso.verGastos, + verDocumentos: acceso.verDocumentos, + descargarPDF: acceso.descargarPDF, + }, + }, { status: 201 }); + } catch (error) { + console.error("Error creating acceso:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al crear el acceso" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/fotos/[id]/route.ts b/src/app/api/fotos/[id]/route.ts new file mode 100644 index 0000000..2504df7 --- /dev/null +++ b/src/app/api/fotos/[id]/route.ts @@ -0,0 +1,184 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { unlink } from "fs/promises"; +import path from "path"; + +// GET - Obtener una foto específica +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + const foto = await prisma.fotoAvance.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + include: { + subidoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + fase: { + select: { + nombre: true, + }, + }, + obra: { + select: { + nombre: true, + }, + }, + }, + }); + + if (!foto) { + return NextResponse.json( + { error: "Foto no encontrada" }, + { status: 404 } + ); + } + + return NextResponse.json(foto); + } catch (error) { + console.error("Error fetching foto:", error); + return NextResponse.json( + { error: "Error al obtener la foto" }, + { status: 500 } + ); + } +} + +// PUT - Actualizar datos de una foto +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + + // Verificar que la foto pertenece a la empresa del usuario + const fotoExistente = await prisma.fotoAvance.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!fotoExistente) { + return NextResponse.json( + { error: "Foto no encontrada" }, + { status: 404 } + ); + } + + const foto = await prisma.fotoAvance.update({ + where: { id }, + data: { + titulo: body.titulo, + descripcion: body.descripcion, + faseId: body.faseId || null, + }, + include: { + subidoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + fase: { + select: { + nombre: true, + }, + }, + }, + }); + + return NextResponse.json(foto); + } catch (error) { + console.error("Error updating foto:", error); + return NextResponse.json( + { error: "Error al actualizar la foto" }, + { status: 500 } + ); + } +} + +// DELETE - Eliminar una foto +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + // Verificar que la foto pertenece a la empresa del usuario + const foto = await prisma.fotoAvance.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!foto) { + return NextResponse.json( + { error: "Foto no encontrada" }, + { status: 404 } + ); + } + + // Eliminar archivos físicos + try { + const filePath = path.join(process.cwd(), "public", foto.url); + await unlink(filePath); + + if (foto.thumbnail) { + const thumbPath = path.join(process.cwd(), "public", foto.thumbnail); + await unlink(thumbPath); + } + } catch { + // Si no se pueden eliminar los archivos, continuar de todas formas + console.warn("No se pudieron eliminar los archivos físicos"); + } + + // Eliminar de la base de datos + await prisma.fotoAvance.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "Foto eliminada exitosamente" }); + } catch (error) { + console.error("Error deleting foto:", error); + return NextResponse.json( + { error: "Error al eliminar la foto" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/fotos/route.ts b/src/app/api/fotos/route.ts new file mode 100644 index 0000000..2710059 --- /dev/null +++ b/src/app/api/fotos/route.ts @@ -0,0 +1,191 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { writeFile, mkdir } from "fs/promises"; +import path from "path"; + +// GET - Obtener fotos de una obra +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const obraId = searchParams.get("obraId"); + const faseId = searchParams.get("faseId"); + + if (!obraId) { + return NextResponse.json( + { error: "Se requiere obraId" }, + { status: 400 } + ); + } + + // Verificar que la obra pertenece a la empresa del usuario + const obra = await prisma.obra.findFirst({ + where: { + id: obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + const fotos = await prisma.fotoAvance.findMany({ + where: { + obraId, + ...(faseId && { faseId }), + }, + include: { + subidoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + fase: { + select: { + nombre: true, + }, + }, + }, + orderBy: { + fechaCaptura: "desc", + }, + }); + + return NextResponse.json(fotos); + } catch (error) { + console.error("Error fetching fotos:", error); + return NextResponse.json( + { error: "Error al obtener las fotos" }, + { status: 500 } + ); + } +} + +// POST - Subir una nueva foto +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId || !session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file") as File; + const obraId = formData.get("obraId") as string; + const faseId = formData.get("faseId") as string | null; + const titulo = formData.get("titulo") as string | null; + const descripcion = formData.get("descripcion") as string | null; + const latitud = formData.get("latitud") as string | null; + const longitud = formData.get("longitud") as string | null; + + if (!file || !obraId) { + return NextResponse.json( + { error: "Se requiere archivo y obraId" }, + { status: 400 } + ); + } + + // Verificar que la obra pertenece a la empresa del usuario + const obra = await prisma.obra.findFirst({ + where: { + id: obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + // Validar tipo de archivo + const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/heic"]; + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + { error: "Tipo de archivo no permitido. Use JPG, PNG o WebP" }, + { status: 400 } + ); + } + + // Validar tamaño (máx 10MB) + const maxSize = 10 * 1024 * 1024; + if (file.size > maxSize) { + return NextResponse.json( + { error: "El archivo es muy grande. Máximo 10MB" }, + { status: 400 } + ); + } + + // Crear directorio de uploads si no existe + const uploadDir = path.join(process.cwd(), "public", "uploads", "fotos", obraId); + await mkdir(uploadDir, { recursive: true }); + + // Generar nombre único para el archivo + const timestamp = Date.now(); + const extension = file.name.split(".").pop() || "jpg"; + const fileName = `${timestamp}.${extension}`; + + // Leer el archivo + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + + // Guardar imagen original + const filePath = path.join(uploadDir, fileName); + await writeFile(filePath, buffer); + + // URLs públicas + const url = `/uploads/fotos/${obraId}/${fileName}`; + const thumbnail = url; // Usar la misma imagen como thumbnail por ahora + + // Guardar en base de datos + const foto = await prisma.fotoAvance.create({ + data: { + url, + thumbnail, + titulo, + descripcion, + fechaCaptura: new Date(), + latitud: latitud ? parseFloat(latitud) : null, + longitud: longitud ? parseFloat(longitud) : null, + tamanio: file.size, + tipo: file.type, + obraId, + faseId: faseId || null, + subidoPorId: session.user.id, + }, + include: { + subidoPor: { + select: { + nombre: true, + apellido: true, + }, + }, + fase: { + select: { + nombre: true, + }, + }, + }, + }); + + return NextResponse.json(foto, { status: 201 }); + } catch (error) { + console.error("Error uploading foto:", error); + return NextResponse.json( + { error: "Error al subir la foto" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..492c45a --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// GET - Obtener notificaciones del usuario +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get("limit") || "20"); + const unreadOnly = searchParams.get("unread") === "true"; + + const notificaciones = await prisma.notificacion.findMany({ + where: { + userId: session.user.id, + ...(unreadOnly && { leida: false }), + }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + + // Contar no leídas + const unreadCount = await prisma.notificacion.count({ + where: { + userId: session.user.id, + leida: false, + }, + }); + + return NextResponse.json({ + notificaciones, + unreadCount, + }); + } catch (error) { + console.error("Error fetching notifications:", error); + return NextResponse.json( + { error: "Error al obtener notificaciones" }, + { status: 500 } + ); + } +} + +// PUT - Marcar notificaciones como leídas +export async function PUT(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const body = await request.json(); + const { notificationIds, markAllRead } = body; + + if (markAllRead) { + await prisma.notificacion.updateMany({ + where: { + userId: session.user.id, + leida: false, + }, + data: { leida: true }, + }); + } else if (notificationIds && Array.isArray(notificationIds)) { + await prisma.notificacion.updateMany({ + where: { + id: { in: notificationIds }, + userId: session.user.id, + }, + data: { leida: true }, + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error updating notifications:", error); + return NextResponse.json( + { error: "Error al actualizar notificaciones" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notifications/subscribe/route.ts b/src/app/api/notifications/subscribe/route.ts new file mode 100644 index 0000000..b91aa9f --- /dev/null +++ b/src/app/api/notifications/subscribe/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const subscribeSchema = z.object({ + endpoint: z.string().url(), + keys: z.object({ + p256dh: z.string(), + auth: z.string(), + }), + preferences: z.object({ + notifyTareas: z.boolean().optional(), + notifyGastos: z.boolean().optional(), + notifyOrdenes: z.boolean().optional(), + notifyAvances: z.boolean().optional(), + notifyAlertas: z.boolean().optional(), + }).optional(), +}); + +// POST - Registrar suscripción push +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = subscribeSchema.parse(body); + + // Crear o actualizar suscripción + const subscription = await prisma.pushSubscription.upsert({ + where: { endpoint: validatedData.endpoint }, + update: { + p256dh: validatedData.keys.p256dh, + auth: validatedData.keys.auth, + activo: true, + ...(validatedData.preferences || {}), + }, + create: { + endpoint: validatedData.endpoint, + p256dh: validatedData.keys.p256dh, + auth: validatedData.keys.auth, + userId: session.user.id, + ...(validatedData.preferences || {}), + }, + }); + + return NextResponse.json({ + success: true, + subscriptionId: subscription.id, + }, { status: 201 }); + } catch (error) { + console.error("Error subscribing:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al registrar suscripción" }, + { status: 500 } + ); + } +} + +// DELETE - Cancelar suscripción push +export async function DELETE(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const endpoint = searchParams.get("endpoint"); + + if (!endpoint) { + return NextResponse.json( + { error: "Endpoint requerido" }, + { status: 400 } + ); + } + + await prisma.pushSubscription.updateMany({ + where: { + endpoint, + userId: session.user.id, + }, + data: { activo: false }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error unsubscribing:", error); + return NextResponse.json( + { error: "Error al cancelar suscripción" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/ordenes-compra/[id]/route.ts b/src/app/api/ordenes-compra/[id]/route.ts new file mode 100644 index 0000000..599b38f --- /dev/null +++ b/src/app/api/ordenes-compra/[id]/route.ts @@ -0,0 +1,224 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const updateOrdenSchema = z.object({ + estado: z.enum([ + "BORRADOR", "PENDIENTE", "APROBADA", "ENVIADA", + "RECIBIDA_PARCIAL", "RECIBIDA", "CANCELADA" + ]).optional(), + prioridad: z.enum(["BAJA", "NORMAL", "ALTA", "URGENTE"]).optional(), + fechaRequerida: z.string().optional().nullable(), + proveedorNombre: z.string().optional(), + proveedorRfc: z.string().optional().nullable(), + proveedorContacto: z.string().optional().nullable(), + proveedorTelefono: z.string().optional().nullable(), + proveedorEmail: z.string().email().optional().nullable(), + proveedorDireccion: z.string().optional().nullable(), + condicionesPago: z.string().optional().nullable(), + tiempoEntrega: z.string().optional().nullable(), + lugarEntrega: z.string().optional().nullable(), + notas: z.string().optional().nullable(), + notasInternas: z.string().optional().nullable(), +}); + +// GET - Obtener una orden específica +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + const orden = await prisma.ordenCompra.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + include: { + obra: { + select: { nombre: true, direccion: true }, + }, + creadoPor: { + select: { nombre: true, apellido: true, email: true }, + }, + aprobadoPor: { + select: { nombre: true, apellido: true, email: true }, + }, + items: { + include: { + material: { + select: { nombre: true, codigo: true }, + }, + }, + }, + }, + }); + + if (!orden) { + return NextResponse.json( + { error: "Orden no encontrada" }, + { status: 404 } + ); + } + + return NextResponse.json(orden); + } catch (error) { + console.error("Error fetching orden:", error); + return NextResponse.json( + { error: "Error al obtener la orden" }, + { status: 500 } + ); + } +} + +// PUT - Actualizar una orden +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId || !session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + const body = await request.json(); + const validatedData = updateOrdenSchema.parse(body); + + // Verificar que la orden pertenece a la empresa del usuario + const ordenExistente = await prisma.ordenCompra.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!ordenExistente) { + return NextResponse.json( + { error: "Orden no encontrada" }, + { status: 404 } + ); + } + + // Preparar datos de actualización + const updateData: Record = { ...validatedData }; + + // Si se cambia a APROBADA, registrar quién y cuándo + if (validatedData.estado === "APROBADA" && ordenExistente.estado !== "APROBADA") { + updateData.aprobadoPorId = session.user.id; + updateData.fechaAprobacion = new Date(); + } + + // Si se cambia a ENVIADA, registrar fecha de envío + if (validatedData.estado === "ENVIADA" && ordenExistente.estado !== "ENVIADA") { + updateData.fechaEnvio = new Date(); + } + + // Si se cambia a RECIBIDA o RECIBIDA_PARCIAL, registrar fecha de recepción + if ( + (validatedData.estado === "RECIBIDA" || validatedData.estado === "RECIBIDA_PARCIAL") && + ordenExistente.estado !== "RECIBIDA" && + ordenExistente.estado !== "RECIBIDA_PARCIAL" + ) { + updateData.fechaRecepcion = new Date(); + } + + if (validatedData.fechaRequerida !== undefined) { + updateData.fechaRequerida = validatedData.fechaRequerida + ? new Date(validatedData.fechaRequerida) + : null; + } + + const orden = await prisma.ordenCompra.update({ + where: { id }, + data: updateData, + include: { + creadoPor: { + select: { nombre: true, apellido: true }, + }, + aprobadoPor: { + select: { nombre: true, apellido: true }, + }, + items: true, + }, + }); + + return NextResponse.json(orden); + } catch (error) { + console.error("Error updating orden:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al actualizar la orden" }, + { status: 500 } + ); + } +} + +// DELETE - Eliminar una orden +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { id } = await params; + + // Verificar que la orden pertenece a la empresa del usuario + const orden = await prisma.ordenCompra.findFirst({ + where: { + id, + obra: { + empresaId: session.user.empresaId, + }, + }, + }); + + if (!orden) { + return NextResponse.json( + { error: "Orden no encontrada" }, + { status: 404 } + ); + } + + // Solo permitir eliminar órdenes en estado BORRADOR o CANCELADA + if (orden.estado !== "BORRADOR" && orden.estado !== "CANCELADA") { + return NextResponse.json( + { error: "Solo se pueden eliminar órdenes en estado Borrador o Cancelada" }, + { status: 400 } + ); + } + + await prisma.ordenCompra.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "Orden eliminada exitosamente" }); + } catch (error) { + console.error("Error deleting orden:", error); + return NextResponse.json( + { error: "Error al eliminar la orden" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/ordenes-compra/route.ts b/src/app/api/ordenes-compra/route.ts new file mode 100644 index 0000000..c72641b --- /dev/null +++ b/src/app/api/ordenes-compra/route.ts @@ -0,0 +1,215 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const itemSchema = z.object({ + codigo: z.string().optional().nullable(), + descripcion: z.string().min(1, "La descripción es requerida"), + unidad: z.enum([ + "UNIDAD", "METRO", "METRO_CUADRADO", "METRO_CUBICO", + "KILOGRAMO", "TONELADA", "LITRO", "BOLSA", "PIEZA", "ROLLO", "CAJA" + ]), + cantidad: z.number().min(0.01, "La cantidad debe ser mayor a 0"), + precioUnitario: z.number().min(0, "El precio debe ser mayor o igual a 0"), + descuento: z.number().min(0).default(0), + materialId: z.string().optional().nullable(), +}); + +const ordenCompraSchema = z.object({ + prioridad: z.enum(["BAJA", "NORMAL", "ALTA", "URGENTE"]).default("NORMAL"), + fechaRequerida: z.string().optional().nullable(), + proveedorNombre: z.string().min(1, "El proveedor es requerido"), + proveedorRfc: z.string().optional().nullable(), + proveedorContacto: z.string().optional().nullable(), + proveedorTelefono: z.string().optional().nullable(), + proveedorEmail: z.string().email().optional().nullable(), + proveedorDireccion: z.string().optional().nullable(), + condicionesPago: z.string().optional().nullable(), + tiempoEntrega: z.string().optional().nullable(), + lugarEntrega: z.string().optional().nullable(), + notas: z.string().optional().nullable(), + notasInternas: z.string().optional().nullable(), + obraId: z.string(), + items: z.array(itemSchema).min(1, "Debe incluir al menos un item"), +}); + +// GET - Obtener órdenes de compra de una obra +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const obraId = searchParams.get("obraId"); + const estado = searchParams.get("estado"); + + if (!obraId) { + return NextResponse.json( + { error: "Se requiere obraId" }, + { status: 400 } + ); + } + + // Verificar que la obra pertenece a la empresa del usuario + const obra = await prisma.obra.findFirst({ + where: { + id: obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + const ordenes = await prisma.ordenCompra.findMany({ + where: { + obraId, + ...(estado && { estado: estado as any }), + }, + include: { + creadoPor: { + select: { nombre: true, apellido: true }, + }, + aprobadoPor: { + select: { nombre: true, apellido: true }, + }, + items: { + include: { + material: { + select: { nombre: true, codigo: true }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(ordenes); + } catch (error) { + console.error("Error fetching ordenes:", error); + return NextResponse.json( + { error: "Error al obtener las órdenes de compra" }, + { status: 500 } + ); + } +} + +// POST - Crear nueva orden de compra +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.empresaId || !session?.user?.id) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = ordenCompraSchema.parse(body); + + // Verificar que la obra pertenece a la empresa del usuario + const obra = await prisma.obra.findFirst({ + where: { + id: validatedData.obraId, + empresaId: session.user.empresaId, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + // Generar número de orden + const lastOrder = await prisma.ordenCompra.findFirst({ + where: { obraId: validatedData.obraId }, + orderBy: { createdAt: "desc" }, + select: { numero: true }, + }); + + let nextNumber = 1; + if (lastOrder?.numero) { + const match = lastOrder.numero.match(/OC-(\d+)/); + if (match) { + nextNumber = parseInt(match[1]) + 1; + } + } + const numero = `OC-${nextNumber.toString().padStart(4, "0")}`; + + // Calcular totales + const items = validatedData.items.map((item) => { + const subtotal = item.cantidad * item.precioUnitario - item.descuento; + return { ...item, subtotal }; + }); + + const subtotal = items.reduce((acc, item) => acc + item.subtotal, 0); + const iva = subtotal * 0.16; // 16% IVA + const total = subtotal + iva; + + // Crear orden con items + const orden = await prisma.ordenCompra.create({ + data: { + numero, + prioridad: validatedData.prioridad, + fechaRequerida: validatedData.fechaRequerida + ? new Date(validatedData.fechaRequerida) + : null, + proveedorNombre: validatedData.proveedorNombre, + proveedorRfc: validatedData.proveedorRfc, + proveedorContacto: validatedData.proveedorContacto, + proveedorTelefono: validatedData.proveedorTelefono, + proveedorEmail: validatedData.proveedorEmail, + proveedorDireccion: validatedData.proveedorDireccion, + condicionesPago: validatedData.condicionesPago, + tiempoEntrega: validatedData.tiempoEntrega, + lugarEntrega: validatedData.lugarEntrega || obra.direccion, + notas: validatedData.notas, + notasInternas: validatedData.notasInternas, + subtotal, + iva, + total, + obraId: validatedData.obraId, + creadoPorId: session.user.id, + items: { + create: items.map((item) => ({ + codigo: item.codigo, + descripcion: item.descripcion, + unidad: item.unidad, + cantidad: item.cantidad, + precioUnitario: item.precioUnitario, + descuento: item.descuento, + subtotal: item.subtotal, + materialId: item.materialId, + })), + }, + }, + include: { + creadoPor: { + select: { nombre: true, apellido: true }, + }, + items: true, + }, + }); + + return NextResponse.json(orden, { status: 201 }); + } catch (error) { + console.error("Error creating orden:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al crear la orden de compra" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/portal/auth/route.ts b/src/app/api/portal/auth/route.ts new file mode 100644 index 0000000..92969b8 --- /dev/null +++ b/src/app/api/portal/auth/route.ts @@ -0,0 +1,272 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; +import { z } from "zod"; +import { cookies } from "next/headers"; +import { SignJWT, jwtVerify } from "jose"; + +const loginSchema = z.object({ + email: z.string().email("Email inválido"), + password: z.string().min(1, "La contraseña es requerida"), +}); + +const tokenLoginSchema = z.object({ + token: z.string().min(1, "Token requerido"), +}); + +const SECRET = new TextEncoder().encode( + process.env.NEXTAUTH_SECRET || "portal-cliente-secret" +); + +// Crear JWT para el portal +async function createPortalToken(clienteAccesoId: string, clienteId: string) { + return await new SignJWT({ clienteAccesoId, clienteId, type: "portal" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("7d") + .sign(SECRET); +} + +// Verificar JWT del portal (función privada) +async function verifyPortalToken(token: string) { + try { + const { payload } = await jwtVerify(token, SECRET); + return payload as { clienteAccesoId: string; clienteId: string; type: string }; + } catch { + return null; + } +} + +// POST - Login con email/password o token +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Intentar login con token de acceso directo + if (body.token) { + const { token } = tokenLoginSchema.parse(body); + + const acceso = await prisma.clienteAcceso.findFirst({ + where: { + token, + activo: true, + OR: [ + { tokenExpira: null }, + { tokenExpira: { gt: new Date() } }, + ], + }, + include: { + cliente: { + select: { + id: true, + nombre: true, + email: true, + empresa: { + select: { nombre: true }, + }, + }, + }, + }, + }); + + if (!acceso) { + return NextResponse.json( + { error: "Token inválido o expirado" }, + { status: 401 } + ); + } + + // Actualizar último acceso + await prisma.clienteAcceso.update({ + where: { id: acceso.id }, + data: { ultimoAcceso: new Date() }, + }); + + // Crear JWT + const jwt = await createPortalToken(acceso.id, acceso.clienteId); + + // Establecer cookie + const cookieStore = await cookies(); + cookieStore.set("portal-token", jwt, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 días + }); + + return NextResponse.json({ + success: true, + cliente: acceso.cliente, + permisos: { + verFotos: acceso.verFotos, + verAvances: acceso.verAvances, + verGastos: acceso.verGastos, + verDocumentos: acceso.verDocumentos, + descargarPDF: acceso.descargarPDF, + }, + }); + } + + // Login con email/password + const { email, password } = loginSchema.parse(body); + + const acceso = await prisma.clienteAcceso.findUnique({ + where: { email }, + include: { + cliente: { + select: { + id: true, + nombre: true, + email: true, + empresa: { + select: { nombre: true }, + }, + }, + }, + }, + }); + + if (!acceso || !acceso.activo) { + return NextResponse.json( + { error: "Credenciales inválidas" }, + { status: 401 } + ); + } + + if (!acceso.password) { + return NextResponse.json( + { error: "Este acceso solo permite login con token" }, + { status: 401 } + ); + } + + const passwordValid = await bcrypt.compare(password, acceso.password); + if (!passwordValid) { + return NextResponse.json( + { error: "Credenciales inválidas" }, + { status: 401 } + ); + } + + // Actualizar último acceso + await prisma.clienteAcceso.update({ + where: { id: acceso.id }, + data: { ultimoAcceso: new Date() }, + }); + + // Crear JWT + const jwt = await createPortalToken(acceso.id, acceso.clienteId); + + // Establecer cookie + const cookieStore = await cookies(); + cookieStore.set("portal-token", jwt, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 7 días + }); + + return NextResponse.json({ + success: true, + cliente: acceso.cliente, + permisos: { + verFotos: acceso.verFotos, + verAvances: acceso.verAvances, + verGastos: acceso.verGastos, + verDocumentos: acceso.verDocumentos, + descargarPDF: acceso.descargarPDF, + }, + }); + } catch (error) { + console.error("Error en login portal:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Error al iniciar sesión" }, + { status: 500 } + ); + } +} + +// GET - Verificar sesión actual +export async function GET() { + try { + const cookieStore = await cookies(); + const token = cookieStore.get("portal-token")?.value; + + if (!token) { + return NextResponse.json( + { error: "No autenticado" }, + { status: 401 } + ); + } + + const payload = await verifyPortalToken(token); + if (!payload || payload.type !== "portal") { + return NextResponse.json( + { error: "Token inválido" }, + { status: 401 } + ); + } + + const acceso = await prisma.clienteAcceso.findUnique({ + where: { id: payload.clienteAccesoId }, + include: { + cliente: { + select: { + id: true, + nombre: true, + email: true, + empresa: { + select: { nombre: true }, + }, + }, + }, + }, + }); + + if (!acceso || !acceso.activo) { + return NextResponse.json( + { error: "Acceso desactivado" }, + { status: 401 } + ); + } + + return NextResponse.json({ + success: true, + cliente: acceso.cliente, + permisos: { + verFotos: acceso.verFotos, + verAvances: acceso.verAvances, + verGastos: acceso.verGastos, + verDocumentos: acceso.verDocumentos, + descargarPDF: acceso.descargarPDF, + }, + }); + } catch (error) { + console.error("Error verificando sesión:", error); + return NextResponse.json( + { error: "Error al verificar sesión" }, + { status: 500 } + ); + } +} + +// DELETE - Cerrar sesión +export async function DELETE() { + try { + const cookieStore = await cookies(); + cookieStore.delete("portal-token"); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error cerrando sesión:", error); + return NextResponse.json( + { error: "Error al cerrar sesión" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/portal/obras/[id]/route.ts b/src/app/api/portal/obras/[id]/route.ts new file mode 100644 index 0000000..f1e0c18 --- /dev/null +++ b/src/app/api/portal/obras/[id]/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getPortalSession } from "@/lib/portal-auth"; + +// GET - Obtener detalle de obra para el cliente +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getPortalSession(); + if (!session) { + return NextResponse.json( + { error: "No autenticado" }, + { status: 401 } + ); + } + + const { id } = await params; + + // Verificar que la obra pertenece al cliente + const obra = await prisma.obra.findFirst({ + where: { + id, + clienteId: session.clienteId, + }, + select: { + id: true, + nombre: true, + descripcion: true, + direccion: true, + estado: true, + porcentajeAvance: true, + presupuestoTotal: session.permisos.verGastos, + gastoTotal: session.permisos.verGastos, + fechaInicio: true, + fechaFinPrevista: true, + fechaFinReal: true, + imagenPortada: true, + supervisor: { + select: { nombre: true, apellido: true, email: true }, + }, + empresa: { + select: { nombre: true, telefono: true, email: true }, + }, + fases: session.permisos.verAvances ? { + orderBy: { orden: "asc" }, + select: { + id: true, + nombre: true, + descripcion: true, + porcentajeAvance: true, + tareas: { + select: { + id: true, + nombre: true, + estado: true, + porcentajeAvance: true, + }, + }, + }, + } : false, + fotos: session.permisos.verFotos ? { + orderBy: { fechaCaptura: "desc" }, + take: 20, + select: { + id: true, + url: true, + thumbnail: true, + titulo: true, + descripcion: true, + fechaCaptura: true, + fase: { + select: { nombre: true }, + }, + }, + } : false, + registrosAvance: session.permisos.verAvances ? { + orderBy: { createdAt: "desc" }, + take: 10, + select: { + id: true, + descripcion: true, + porcentaje: true, + fotos: true, + createdAt: true, + registradoPor: { + select: { nombre: true, apellido: true }, + }, + }, + } : false, + gastos: session.permisos.verGastos ? { + orderBy: { fecha: "desc" }, + take: 20, + select: { + id: true, + concepto: true, + monto: true, + fecha: true, + categoria: true, + estado: true, + }, + } : false, + }, + }); + + if (!obra) { + return NextResponse.json( + { error: "Obra no encontrada" }, + { status: 404 } + ); + } + + return NextResponse.json({ + ...obra, + permisos: session.permisos, + }); + } catch (error) { + console.error("Error fetching obra del portal:", error); + return NextResponse.json( + { error: "Error al obtener la obra" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/portal/obras/route.ts b/src/app/api/portal/obras/route.ts new file mode 100644 index 0000000..4907654 --- /dev/null +++ b/src/app/api/portal/obras/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getPortalSession } from "@/lib/portal-auth"; + +// GET - Obtener obras del cliente +export async function GET() { + try { + const session = await getPortalSession(); + if (!session) { + return NextResponse.json( + { error: "No autenticado" }, + { status: 401 } + ); + } + + // Obtener obras del cliente + const obras = await prisma.obra.findMany({ + where: { + clienteId: session.clienteId, + }, + select: { + id: true, + nombre: true, + descripcion: true, + direccion: true, + estado: true, + porcentajeAvance: true, + presupuestoTotal: session.permisos.verGastos, + gastoTotal: session.permisos.verGastos, + fechaInicio: true, + fechaFinPrevista: true, + fechaFinReal: true, + imagenPortada: true, + _count: { + select: { + fotos: true, + registrosAvance: true, + }, + }, + }, + orderBy: { updatedAt: "desc" }, + }); + + return NextResponse.json(obras); + } catch (error) { + console.error("Error fetching obras del portal:", error); + return NextResponse.json( + { error: "Error al obtener las obras" }, + { status: 500 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 55840a2..13e6006 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,14 +1,51 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import { Toaster } from "@/components/ui/toaster"; import { AuthProvider } from "@/components/providers/auth-provider"; +import { PWAProvider } from "@/components/pwa/pwa-provider"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Sistema de Gestion de Obras", - description: "Aplicacion para la gestion integral de obras de construccion", + title: "Mexus - Gestion de Obras", + description: "Sistema de gestion integral de obras de construccion", + manifest: "/manifest.json", + icons: { + icon: [ + { url: "/favicon.png", sizes: "32x32", type: "image/png" }, + { url: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" }, + { url: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [ + { url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }, + ], + }, + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Mexus", + }, + formatDetection: { + telephone: false, + }, + openGraph: { + type: "website", + siteName: "Mexus", + title: "Mexus - Gestion de Obras", + description: "Sistema de gestion integral de obras de construccion", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#2563eb" }, + { media: "(prefers-color-scheme: dark)", color: "#1e40af" }, + ], }; export default function RootLayout({ @@ -20,8 +57,10 @@ export default function RootLayout({ - {children} - + + {children} + + diff --git a/src/app/portal/layout.tsx b/src/app/portal/layout.tsx new file mode 100644 index 0000000..7b6a445 --- /dev/null +++ b/src/app/portal/layout.tsx @@ -0,0 +1,18 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Portal de Cliente - Mexus App", + description: "Portal de seguimiento de obras para clientes", +}; + +export default function PortalLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/src/app/portal/obras/[id]/page.tsx b/src/app/portal/obras/[id]/page.tsx new file mode 100644 index 0000000..e0e1dac --- /dev/null +++ b/src/app/portal/obras/[id]/page.tsx @@ -0,0 +1,578 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useParams } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + ChevronLeft, + MapPin, + Calendar, + Building2, + User, + Phone, + Mail, + Loader2, + Camera, + CheckCircle2, + Clock, + DollarSign, +} from "lucide-react"; +import { formatCurrency, formatDate, formatPercentage } from "@/lib/utils"; +import { + ESTADO_OBRA_LABELS, + ESTADO_OBRA_COLORS, + ESTADO_TAREA_LABELS, + CATEGORIA_GASTO_LABELS, + ESTADO_GASTO_LABELS, + ESTADO_GASTO_COLORS, + type EstadoObra, + type EstadoTarea, + type CategoriaGasto, + type EstadoGasto, +} from "@/types"; + +interface ObraDetail { + id: string; + nombre: string; + descripcion: string | null; + direccion: string; + estado: EstadoObra; + porcentajeAvance: number; + presupuestoTotal?: number; + gastoTotal?: number; + fechaInicio: string | null; + fechaFinPrevista: string | null; + fechaFinReal: string | null; + imagenPortada: string | null; + supervisor?: { nombre: string; apellido: string; email: string | null }; + empresa?: { nombre: string; telefono: string | null; email: string | null }; + fases?: { + id: string; + nombre: string; + descripcion: string | null; + porcentajeAvance: number; + tareas: { + id: string; + nombre: string; + estado: EstadoTarea; + porcentajeAvance: number; + }[]; + }[]; + fotos?: { + id: string; + url: string; + thumbnail: string | null; + titulo: string | null; + descripcion: string | null; + fechaCaptura: string; + fase?: { nombre: string } | null; + }[]; + registrosAvance?: { + id: string; + descripcion: string; + porcentaje: number; + fotos: string[]; + createdAt: string; + registradoPor: { nombre: string; apellido: string }; + }[]; + gastos?: { + id: string; + concepto: string; + monto: number; + fecha: string; + categoria: CategoriaGasto; + estado: EstadoGasto; + }[]; + permisos: { + verFotos: boolean; + verAvances: boolean; + verGastos: boolean; + verDocumentos: boolean; + descargarPDF: boolean; + }; +} + +export default function PortalObraDetailPage() { + const router = useRouter(); + const params = useParams(); + const [loading, setLoading] = useState(true); + const [obra, setObra] = useState(null); + const [selectedPhoto, setSelectedPhoto] = useState(null); + + useEffect(() => { + fetchObra(); + }, [params.id]); + + const fetchObra = async () => { + try { + const res = await fetch(`/api/portal/obras/${params.id}`); + if (!res.ok) { + if (res.status === 401) { + router.push("/portal"); + return; + } + throw new Error("Error al cargar obra"); + } + const data = await res.json(); + setObra(data); + } catch (error) { + console.error("Error:", error); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!obra) { + return ( +
+ + +

Obra no encontrada

+ + + +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + Volver a mis obras + +
+
+

{obra.nombre}

+
+ + {ESTADO_OBRA_LABELS[obra.estado]} + + + + {obra.direccion} + +
+
+
+
+ {formatPercentage(obra.porcentajeAvance)} +
+

Avance total

+
+
+
+
+ + {/* Content */} +
+ {/* Progress Bar */} + + + + + + + + + General + {obra.permisos.verAvances && obra.fases && ( + Avances + )} + {obra.permisos.verFotos && obra.fotos && ( + Fotos + )} + {obra.permisos.verGastos && obra.gastos && ( + Finanzas + )} + + + {/* General Tab */} + +
+ {/* Info del Proyecto */} + + + Informacion del Proyecto + + + {obra.descripcion && ( +
+

+ Descripcion +

+

{obra.descripcion}

+
+ )} +
+
+

+ + Inicio +

+

+ {obra.fechaInicio + ? formatDate(new Date(obra.fechaInicio)) + : "Por definir"} +

+
+
+

+ + Fin Previsto +

+

+ {obra.fechaFinPrevista + ? formatDate(new Date(obra.fechaFinPrevista)) + : "Por definir"} +

+
+
+ {obra.permisos.verGastos && obra.presupuestoTotal !== undefined && ( +
+
+
+

+ Presupuesto +

+

+ {formatCurrency(obra.presupuestoTotal)} +

+
+
+

+ Ejecutado +

+

+ {formatCurrency(obra.gastoTotal || 0)} +

+
+
+
+ )} +
+
+ + {/* Contacto */} + + + Contacto + + + {obra.empresa && ( +
+

+ + Constructora +

+

{obra.empresa.nombre}

+ {obra.empresa.telefono && ( +

+ + {obra.empresa.telefono} +

+ )} + {obra.empresa.email && ( +

+ + {obra.empresa.email} +

+ )} +
+ )} + {obra.supervisor && ( +
+

+ + Supervisor +

+

+ {obra.supervisor.nombre} {obra.supervisor.apellido} +

+ {obra.supervisor.email && ( +

+ + {obra.supervisor.email} +

+ )} +
+ )} +
+
+
+ + {/* Últimos Avances */} + {obra.permisos.verAvances && obra.registrosAvance && obra.registrosAvance.length > 0 && ( + + + Ultimos Avances + + +
+ {obra.registrosAvance.slice(0, 3).map((registro) => ( +
+
+

{registro.descripcion}

+

+ {registro.registradoPor.nombre}{" "} + {registro.registradoPor.apellido} -{" "} + {formatDate(new Date(registro.createdAt))} +

+
+ + {formatPercentage(registro.porcentaje)} + +
+ ))} +
+
+
+ )} +
+ + {/* Avances Tab */} + {obra.permisos.verAvances && obra.fases && ( + + {obra.fases.length === 0 ? ( + + + No hay fases definidas para esta obra + + + ) : ( + obra.fases.map((fase) => ( + + +
+ {fase.nombre} +
+ + {formatPercentage(fase.porcentajeAvance)} + + +
+
+ {fase.descripcion && ( + {fase.descripcion} + )} +
+ + {fase.tareas.length === 0 ? ( +

+ Sin tareas definidas +

+ ) : ( +
+ {fase.tareas.map((tarea) => ( +
+
+ {tarea.estado === "COMPLETADA" ? ( + + ) : ( + + )} + {tarea.nombre} +
+ + {ESTADO_TAREA_LABELS[tarea.estado]} + +
+ ))} +
+ )} +
+
+ )) + )} +
+ )} + + {/* Fotos Tab */} + {obra.permisos.verFotos && obra.fotos && ( + + {obra.fotos.length === 0 ? ( + + + +

+ No hay fotos disponibles +

+
+
+ ) : ( +
+ {obra.fotos.map((foto) => ( + setSelectedPhoto(foto.url)} + > +
+ {foto.titulo +
+ + {foto.titulo && ( +

+ {foto.titulo} +

+ )} +

+ {formatDate(new Date(foto.fechaCaptura))} +

+ {foto.fase && ( + + {foto.fase.nombre} + + )} +
+
+ ))} +
+ )} + + {/* Modal de foto */} + {selectedPhoto && ( +
setSelectedPhoto(null)} + > + Foto ampliada +
+ )} +
+ )} + + {/* Finanzas Tab */} + {obra.permisos.verGastos && obra.gastos && ( + + {/* Resumen */} +
+ + + + Presupuesto + + + +
+ {formatCurrency(obra.presupuestoTotal || 0)} +
+
+
+ + + + Ejecutado + + + +
+ {formatCurrency(obra.gastoTotal || 0)} +
+
+
+ + + + Disponible + + + +
+ {formatCurrency( + (obra.presupuestoTotal || 0) - (obra.gastoTotal || 0) + )} +
+
+
+
+ + {/* Lista de gastos */} + + + Ultimos Gastos + + + {obra.gastos.length === 0 ? ( +

+ No hay gastos registrados +

+ ) : ( +
+ {obra.gastos.map((gasto) => ( +
+
+

{gasto.concepto}

+

+ {CATEGORIA_GASTO_LABELS[gasto.categoria]} -{" "} + {formatDate(new Date(gasto.fecha))} +

+
+
+

+ {formatCurrency(gasto.monto)} +

+ + {ESTADO_GASTO_LABELS[gasto.estado]} + +
+
+ ))} +
+ )} +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/app/portal/obras/page.tsx b/src/app/portal/obras/page.tsx new file mode 100644 index 0000000..2063bec --- /dev/null +++ b/src/app/portal/obras/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Building2, + MapPin, + Calendar, + Camera, + FileText, + LogOut, + Loader2, +} from "lucide-react"; +import { formatCurrency, formatDate, formatPercentage } from "@/lib/utils"; +import { ESTADO_OBRA_LABELS, ESTADO_OBRA_COLORS, type EstadoObra } from "@/types"; + +interface Obra { + id: string; + nombre: string; + descripcion: string | null; + direccion: string; + estado: EstadoObra; + porcentajeAvance: number; + presupuestoTotal?: number; + gastoTotal?: number; + fechaInicio: string | null; + fechaFinPrevista: string | null; + fechaFinReal: string | null; + imagenPortada: string | null; + _count: { + fotos: number; + registrosAvance: number; + }; +} + +interface ClienteInfo { + cliente: { + id: string; + nombre: string; + email: string | null; + empresa: { nombre: string }; + }; + permisos: { + verFotos: boolean; + verAvances: boolean; + verGastos: boolean; + verDocumentos: boolean; + descargarPDF: boolean; + }; +} + +export default function PortalObrasPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [obras, setObras] = useState([]); + const [clienteInfo, setClienteInfo] = useState(null); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + try { + // Verificar autenticación + const authRes = await fetch("/api/portal/auth"); + if (!authRes.ok) { + router.push("/portal"); + return; + } + const authData = await authRes.json(); + setClienteInfo(authData); + + // Obtener obras + const obrasRes = await fetch("/api/portal/obras"); + if (obrasRes.ok) { + const obrasData = await obrasRes.json(); + setObras(obrasData); + } + } catch (error) { + console.error("Error:", error); + router.push("/portal"); + } finally { + setLoading(false); + } + }; + + const handleLogout = async () => { + await fetch("/api/portal/auth", { method: "DELETE" }); + router.push("/portal"); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+ +
+

Portal de Cliente

+

+ {clienteInfo?.cliente.empresa.nombre} +

+
+
+
+ + {clienteInfo?.cliente.nombre} + + +
+
+
+
+ + {/* Content */} +
+
+

Mis Obras

+

+ Revisa el avance de tus proyectos +

+
+ + {obras.length === 0 ? ( + + + +

+ No tienes obras asignadas actualmente +

+
+
+ ) : ( +
+ {obras.map((obra) => ( + + + {obra.imagenPortada && ( +
+ {obra.nombre} +
+ )} + +
+ {obra.nombre} + + {ESTADO_OBRA_LABELS[obra.estado]} + +
+ + + {obra.direccion} + +
+ + {/* Progreso */} +
+
+ Avance + + {formatPercentage(obra.porcentajeAvance)} + +
+ +
+ + {/* Info */} +
+ {obra.fechaInicio && ( +
+ + + {formatDate(new Date(obra.fechaInicio))} + +
+ )} + {clienteInfo?.permisos.verFotos && ( +
+ + {obra._count.fotos} fotos +
+ )} + {clienteInfo?.permisos.verAvances && ( +
+ + {obra._count.registrosAvance} avances +
+ )} +
+ + {/* Financiero (si tiene permiso) */} + {clienteInfo?.permisos.verGastos && + obra.presupuestoTotal !== undefined && ( +
+
+ + Presupuesto + + + {formatCurrency(obra.presupuestoTotal)} + +
+
+ )} +
+
+ + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/portal/page.tsx b/src/app/portal/page.tsx new file mode 100644 index 0000000..89b49f0 --- /dev/null +++ b/src/app/portal/page.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useState, useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Building2, Loader2 } from "lucide-react"; + +function PortalLoginContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + + // Verificar si hay token en la URL + useEffect(() => { + const token = searchParams.get("token"); + if (token) { + loginWithToken(token); + } + }, [searchParams]); + + // Verificar si ya está autenticado + useEffect(() => { + checkAuth(); + }, []); + + const checkAuth = async () => { + try { + const res = await fetch("/api/portal/auth"); + if (res.ok) { + router.push("/portal/obras"); + } + } catch { + // No autenticado, continuar en login + } + }; + + const loginWithToken = async (token: string) => { + setLoading(true); + setError(""); + + try { + const res = await fetch("/api/portal/auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || "Error al iniciar sesión"); + return; + } + + router.push("/portal/obras"); + } catch { + setError("Error de conexión"); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + const res = await fetch("/api/portal/auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || "Error al iniciar sesión"); + return; + } + + router.push("/portal/obras"); + } catch { + setError("Error de conexión"); + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+
+ +
+
+ Portal de Cliente + + Accede para ver el avance de tus obras + +
+ +
+
+ + + setFormData({ ...formData, email: e.target.value }) + } + required + /> +
+ +
+ + + setFormData({ ...formData, password: e.target.value }) + } + required + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Si no tienes acceso, contacta a tu constructora +

+
+
+
+ ); +} + +export default function PortalLoginPage() { + return ( + + +
+ } + > + + + ); +} diff --git a/src/components/actividades/timeline-actividades.tsx b/src/components/actividades/timeline-actividades.tsx new file mode 100644 index 0000000..4086eca --- /dev/null +++ b/src/components/actividades/timeline-actividades.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Building2, + Pencil, + RefreshCw, + Layers, + ClipboardList, + UserPlus, + CheckCircle, + DollarSign, + Check, + X, + Package, + Send, + PackageCheck, + TrendingUp, + Camera, + BookOpen, + Boxes, + MessageSquare, + FileText, + Loader2, + ChevronDown, +} from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { es } from "date-fns/locale"; +import { TipoActividad } from "@prisma/client"; + +interface Actividad { + id: string; + tipo: TipoActividad; + descripcion: string; + detalles: string | null; + entidadTipo: string | null; + entidadId: string | null; + entidadNombre: string | null; + obraId: string | null; + user: { id: string; nombre: string; apellido: string } | null; + obra: { id: string; nombre: string } | null; + createdAt: string; +} + +interface Props { + obraId?: string; + limit?: number; + showFilters?: boolean; + compact?: boolean; +} + +const TIPO_ICONS: Record = { + OBRA_CREADA: , + OBRA_ACTUALIZADA: , + OBRA_ESTADO_CAMBIADO: , + FASE_CREADA: , + TAREA_CREADA: , + TAREA_ASIGNADA: , + TAREA_COMPLETADA: , + TAREA_ESTADO_CAMBIADO: , + GASTO_CREADO: , + GASTO_APROBADO: , + GASTO_RECHAZADO: , + ORDEN_CREADA: , + ORDEN_APROBADA: , + ORDEN_ENVIADA: , + ORDEN_RECIBIDA: , + AVANCE_REGISTRADO: , + FOTO_SUBIDA: , + BITACORA_REGISTRADA: , + MATERIAL_MOVIMIENTO: , + USUARIO_ASIGNADO: , + COMENTARIO_AGREGADO: , + DOCUMENTO_SUBIDO: , +}; + +const TIPO_COLORS: Record = { + OBRA_CREADA: "bg-blue-100 text-blue-600", + OBRA_ACTUALIZADA: "bg-gray-100 text-gray-600", + OBRA_ESTADO_CAMBIADO: "bg-purple-100 text-purple-600", + FASE_CREADA: "bg-indigo-100 text-indigo-600", + TAREA_CREADA: "bg-blue-100 text-blue-600", + TAREA_ASIGNADA: "bg-cyan-100 text-cyan-600", + TAREA_COMPLETADA: "bg-green-100 text-green-600", + TAREA_ESTADO_CAMBIADO: "bg-yellow-100 text-yellow-600", + GASTO_CREADO: "bg-orange-100 text-orange-600", + GASTO_APROBADO: "bg-green-100 text-green-600", + GASTO_RECHAZADO: "bg-red-100 text-red-600", + ORDEN_CREADA: "bg-purple-100 text-purple-600", + ORDEN_APROBADA: "bg-green-100 text-green-600", + ORDEN_ENVIADA: "bg-blue-100 text-blue-600", + ORDEN_RECIBIDA: "bg-green-100 text-green-600", + AVANCE_REGISTRADO: "bg-teal-100 text-teal-600", + FOTO_SUBIDA: "bg-pink-100 text-pink-600", + BITACORA_REGISTRADA: "bg-amber-100 text-amber-600", + MATERIAL_MOVIMIENTO: "bg-gray-100 text-gray-600", + USUARIO_ASIGNADO: "bg-cyan-100 text-cyan-600", + COMENTARIO_AGREGADO: "bg-blue-100 text-blue-600", + DOCUMENTO_SUBIDO: "bg-gray-100 text-gray-600", +}; + +const TIPO_LABELS: Partial> = { + OBRA_CREADA: "Obras", + TAREA_CREADA: "Tareas", + GASTO_CREADO: "Gastos", + ORDEN_CREADA: "Ordenes", + AVANCE_REGISTRADO: "Avances", + FOTO_SUBIDA: "Fotos", + BITACORA_REGISTRADA: "Bitacora", +}; + +export function TimelineActividades({ + obraId, + limit = 20, + showFilters = true, + compact = false, +}: Props) { + const [actividades, setActividades] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + const [tipoFilter, setTipoFilter] = useState("all"); + + useEffect(() => { + fetchActividades(true); + }, [obraId, tipoFilter]); + + const fetchActividades = async (reset = false) => { + if (reset) { + setLoading(true); + setOffset(0); + } else { + setLoadingMore(true); + } + + try { + const params = new URLSearchParams(); + params.append("limit", String(limit)); + params.append("offset", String(reset ? 0 : offset)); + if (obraId) params.append("obraId", obraId); + if (tipoFilter !== "all") params.append("tipo", tipoFilter); + + const res = await fetch(`/api/actividades?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + if (reset) { + setActividades(data.actividades); + } else { + setActividades((prev) => [...prev, ...data.actividades]); + } + setHasMore(data.hasMore); + setOffset((reset ? 0 : offset) + data.actividades.length); + } + } catch (error) { + console.error("Error fetching actividades:", error); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + const loadMore = () => { + fetchActividades(false); + }; + + if (loading) { + return ( + + + + + + ); + } + + return ( +
+ {showFilters && ( +
+

Actividad Reciente

+ +
+ )} + + {actividades.length === 0 ? ( + + + No hay actividad registrada + + + ) : ( +
+ {/* Timeline line */} +
+ +
+ {actividades.map((actividad) => ( +
+ {/* Icon */} +
+ {TIPO_ICONS[actividad.tipo]} +
+ + {/* Content */} + + +
+
+

+ {actividad.descripcion} +

+
+ {actividad.user && ( + + {actividad.user.nombre} {actividad.user.apellido} + + )} + {actividad.user && actividad.obra && !obraId && ( + + )} + {actividad.obra && !obraId && ( + {actividad.obra.nombre} + )} +
+
+ + {formatDistanceToNow(new Date(actividad.createdAt), { + addSuffix: true, + locale: es, + })} + +
+
+
+
+ ))} +
+ + {/* Load more */} + {hasMore && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/asistencia/control-asistencia.tsx b/src/components/asistencia/control-asistencia.tsx new file mode 100644 index 0000000..f65f25c --- /dev/null +++ b/src/components/asistencia/control-asistencia.tsx @@ -0,0 +1,841 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Users, + Clock, + MapPin, + Plus, + ChevronLeft, + ChevronRight, + LogIn, + LogOut, + Edit, + Trash2, + Loader2, + UserCheck, + UserX, + CalendarDays, +} from "lucide-react"; +import { toast } from "@/hooks/use-toast"; +import { formatDate } from "@/lib/utils"; +import { + TIPO_ASISTENCIA_LABELS, + TIPO_ASISTENCIA_COLORS, + type TipoAsistencia, +} from "@/types"; + +interface Empleado { + id: string; + nombre: string; + apellido: string; + puesto: string; + telefono: string | null; +} + +interface AsistenciaEntry { + id: string; + fecha: string; + tipo: TipoAsistencia; + horaEntrada: string | null; + horaSalida: string | null; + latitudEntrada: number | null; + longitudEntrada: number | null; + latitudSalida: number | null; + longitudSalida: number | null; + horasTrabajadas: number | null; + horasExtra: number; + notas: string | null; + motivoAusencia: string | null; + empleado: { + id: string; + nombre: string; + apellido: string; + puesto: string; + }; + registradoPor: { + nombre: string; + apellido: string; + }; +} + +interface ControlAsistenciaProps { + obraId: string; +} + +export function ControlAsistencia({ obraId }: ControlAsistenciaProps) { + const router = useRouter(); + const [asistencias, setAsistencias] = useState([]); + const [empleados, setEmpleados] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [deleteId, setDeleteId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [selectedDate, setSelectedDate] = useState( + new Date().toISOString().split("T")[0] + ); + const [editingEntry, setEditingEntry] = useState(null); + + const [form, setForm] = useState({ + empleadoId: "", + tipo: "PRESENTE" as TipoAsistencia, + horaEntrada: "", + horaSalida: "", + horasExtra: "0", + notas: "", + motivoAusencia: "", + }); + + const [location, setLocation] = useState<{ + lat: number | null; + lng: number | null; + }>({ lat: null, lng: null }); + + // Cargar empleados + useEffect(() => { + const fetchEmpleados = async () => { + try { + const response = await fetch(`/api/asistencia/empleados?obraId=${obraId}`); + if (response.ok) { + const data = await response.json(); + setEmpleados(data); + } + } catch (error) { + console.error("Error fetching empleados:", error); + } + }; + fetchEmpleados(); + }, [obraId]); + + // Cargar asistencias del día seleccionado + useEffect(() => { + const fetchAsistencias = async () => { + setIsLoading(true); + try { + const response = await fetch( + `/api/asistencia?obraId=${obraId}&fecha=${selectedDate}` + ); + if (response.ok) { + const data = await response.json(); + setAsistencias(data); + } + } catch (error) { + console.error("Error fetching asistencias:", error); + } finally { + setIsLoading(false); + } + }; + fetchAsistencias(); + }, [obraId, selectedDate]); + + // Obtener ubicación actual + const getCurrentLocation = () => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + setLocation({ + lat: position.coords.latitude, + lng: position.coords.longitude, + }); + toast({ title: "Ubicacion capturada" }); + }, + (error) => { + console.error("Error getting location:", error); + toast({ + title: "No se pudo obtener la ubicacion", + variant: "destructive", + }); + } + ); + } + }; + + const resetForm = () => { + setForm({ + empleadoId: "", + tipo: "PRESENTE", + horaEntrada: "", + horaSalida: "", + horasExtra: "0", + notas: "", + motivoAusencia: "", + }); + setLocation({ lat: null, lng: null }); + setEditingEntry(null); + }; + + const handleSubmit = async () => { + if (!form.empleadoId) { + toast({ + title: "Error", + description: "Selecciona un empleado", + variant: "destructive", + }); + return; + } + + setIsSubmitting(true); + try { + const url = editingEntry + ? `/api/asistencia/${editingEntry.id}` + : "/api/asistencia"; + const method = editingEntry ? "PUT" : "POST"; + + const body: Record = { + ...form, + fecha: selectedDate, + obraId, + horasExtra: parseFloat(form.horasExtra) || 0, + horaEntrada: form.horaEntrada + ? `${selectedDate}T${form.horaEntrada}:00` + : null, + horaSalida: form.horaSalida + ? `${selectedDate}T${form.horaSalida}:00` + : null, + }; + + if (!editingEntry && location.lat && location.lng) { + body.latitudEntrada = location.lat; + body.longitudEntrada = location.lng; + } + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Error al guardar"); + } + + const savedEntry = await response.json(); + + if (editingEntry) { + setAsistencias((prev) => + prev.map((a) => (a.id === editingEntry.id ? savedEntry : a)) + ); + toast({ title: "Asistencia actualizada" }); + } else { + setAsistencias((prev) => [savedEntry, ...prev]); + toast({ title: "Asistencia registrada" }); + } + + setIsDialogOpen(false); + resetForm(); + router.refresh(); + } catch (error) { + toast({ + title: "Error", + description: + error instanceof Error ? error.message : "Error al guardar", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleEdit = (entry: AsistenciaEntry) => { + setEditingEntry(entry); + setForm({ + empleadoId: entry.empleado.id, + tipo: entry.tipo, + horaEntrada: entry.horaEntrada + ? new Date(entry.horaEntrada).toTimeString().slice(0, 5) + : "", + horaSalida: entry.horaSalida + ? new Date(entry.horaSalida).toTimeString().slice(0, 5) + : "", + horasExtra: entry.horasExtra.toString(), + notas: entry.notas || "", + motivoAusencia: entry.motivoAusencia || "", + }); + setIsDialogOpen(true); + }; + + const handleDelete = async () => { + if (!deleteId) return; + + setIsDeleting(true); + try { + const response = await fetch(`/api/asistencia/${deleteId}`, { + method: "DELETE", + }); + + if (!response.ok) throw new Error("Error al eliminar"); + + setAsistencias((prev) => prev.filter((a) => a.id !== deleteId)); + toast({ title: "Asistencia eliminada" }); + router.refresh(); + } catch { + toast({ + title: "Error", + description: "No se pudo eliminar la asistencia", + variant: "destructive", + }); + } finally { + setIsDeleting(false); + setDeleteId(null); + } + }; + + const handleQuickEntry = async (empleadoId: string) => { + getCurrentLocation(); + const now = new Date(); + const horaEntrada = now.toTimeString().slice(0, 5); + + try { + const response = await fetch("/api/asistencia", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + empleadoId, + fecha: selectedDate, + obraId, + tipo: "PRESENTE", + horaEntrada: `${selectedDate}T${horaEntrada}:00`, + latitudEntrada: location.lat, + longitudEntrada: location.lng, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error); + } + + const savedEntry = await response.json(); + setAsistencias((prev) => [savedEntry, ...prev]); + toast({ title: "Entrada registrada" }); + router.refresh(); + } catch (error) { + toast({ + title: "Error", + description: + error instanceof Error ? error.message : "Error al registrar entrada", + variant: "destructive", + }); + } + }; + + const handleQuickExit = async (entry: AsistenciaEntry) => { + getCurrentLocation(); + const now = new Date(); + const horaSalida = now.toTimeString().slice(0, 5); + + try { + const response = await fetch(`/api/asistencia/${entry.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + horaSalida: `${selectedDate}T${horaSalida}:00`, + latitudSalida: location.lat, + longitudSalida: location.lng, + }), + }); + + if (!response.ok) throw new Error("Error al registrar salida"); + + const savedEntry = await response.json(); + setAsistencias((prev) => + prev.map((a) => (a.id === entry.id ? savedEntry : a)) + ); + toast({ title: "Salida registrada" }); + router.refresh(); + } catch { + toast({ + title: "Error", + description: "Error al registrar salida", + variant: "destructive", + }); + } + }; + + const prevDay = () => { + const date = new Date(selectedDate); + date.setDate(date.getDate() - 1); + setSelectedDate(date.toISOString().split("T")[0]); + }; + + const nextDay = () => { + const date = new Date(selectedDate); + date.setDate(date.getDate() + 1); + setSelectedDate(date.toISOString().split("T")[0]); + }; + + const formatTime = (dateStr: string | null) => { + if (!dateStr) return "-"; + return new Date(dateStr).toLocaleTimeString("es-MX", { + hour: "2-digit", + minute: "2-digit", + }); + }; + + // Estadísticas del día + const stats = { + presentes: asistencias.filter((a) => a.tipo === "PRESENTE").length, + ausentes: asistencias.filter((a) => a.tipo === "AUSENTE").length, + retardos: asistencias.filter((a) => a.tipo === "RETARDO").length, + total: empleados.length, + sinRegistro: empleados.length - asistencias.length, + }; + + // Empleados sin registro de asistencia hoy + const empleadosSinRegistro = empleados.filter( + (emp) => !asistencias.find((a) => a.empleado.id === emp.id) + ); + + return ( + + +
+
+ + + Control de Asistencia + + + Registro de asistencia del personal en obra + +
+ +
+
+ + {/* Navegación de fecha */} +
+ +
+ + setSelectedDate(e.target.value)} + className="w-auto" + /> +
+ +
+ + {/* Estadísticas del día */} +
+ + + +

{stats.presentes}

+

Presentes

+
+
+ + + +

{stats.ausentes}

+

Ausentes

+
+
+ + + +

{stats.retardos}

+

Retardos

+
+
+ + + +

{stats.sinRegistro}

+

Sin registro

+
+
+ + + +

{stats.total}

+

Total

+
+
+
+ + {/* Registro rápido para empleados sin asistencia */} + {empleadosSinRegistro.length > 0 && ( +
+

+ Registro rapido de entrada +

+
+ {empleadosSinRegistro.slice(0, 10).map((emp) => ( + + ))} + {empleadosSinRegistro.length > 10 && ( + + +{empleadosSinRegistro.length - 10} mas + + )} +
+
+ )} + + {/* Tabla de asistencias */} + {isLoading ? ( +
+ +
+ ) : asistencias.length === 0 ? ( +
+ +

+ No hay registros de asistencia para esta fecha +

+
+ ) : ( + + + + Empleado + Puesto + Estado + Entrada + Salida + Horas + Acciones + + + + {asistencias.map((entry) => ( + + + {entry.empleado.nombre} {entry.empleado.apellido} + + + {entry.empleado.puesto} + + + + {TIPO_ASISTENCIA_LABELS[entry.tipo]} + + + +
+ {formatTime(entry.horaEntrada)} + {entry.latitudEntrada && ( + + )} +
+
+ +
+ {entry.horaSalida ? ( + <> + {formatTime(entry.horaSalida)} + {entry.latitudSalida && ( + + )} + + ) : entry.tipo === "PRESENTE" ? ( + + ) : ( + "-" + )} +
+
+ + {entry.horasTrabajadas + ? `${entry.horasTrabajadas.toFixed(1)}h` + : "-"} + {entry.horasExtra > 0 && ( + + +{entry.horasExtra}h + + )} + + +
+ + +
+
+
+ ))} +
+
+ )} + + {/* Dialog de formulario */} + { + setIsDialogOpen(open); + if (!open) resetForm(); + }} + > + + + + {editingEntry ? "Editar Asistencia" : "Registrar Asistencia"} + + + {formatDate(selectedDate)} + + +
+
+ + +
+ +
+ + +
+ + {(form.tipo === "PRESENTE" || form.tipo === "RETARDO") && ( +
+
+ + + setForm({ ...form, horaEntrada: e.target.value }) + } + /> +
+
+ + + setForm({ ...form, horaSalida: e.target.value }) + } + /> +
+
+ )} + + {(form.tipo === "PRESENTE" || form.tipo === "RETARDO") && ( +
+ + + setForm({ ...form, horasExtra: e.target.value }) + } + /> +
+ )} + + {(form.tipo === "AUSENTE" || + form.tipo === "PERMISO" || + form.tipo === "INCAPACIDAD" || + form.tipo === "VACACIONES") && ( +
+ +