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 @@
+
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}
+
+ )}
+
+ {formatDate(new Date(foto.fechaCaptura))}
+
+ {foto.fase && (
+
+ {foto.fase.nombre}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* Modal de foto */}
+ {selectedPhoto && (
+ setSelectedPhoto(null)}
+ >
+

+
+ )}
+
+ )}
+
+ {/* 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}
+
+ {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
+
+
+
+
+
+
+ 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 */}
+
+
+ {/* Dialog de confirmación de eliminación */}
+ setDeleteId(null)}>
+
+
+ Eliminar registro de asistencia
+
+ ¿Estas seguro de que deseas eliminar este registro? Esta accion
+ no se puede deshacer.
+
+
+
+ Cancelar
+
+ {isDeleting ? "Eliminando..." : "Eliminar"}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/bitacora/bitacora-obra.tsx b/src/components/bitacora/bitacora-obra.tsx
new file mode 100644
index 0000000..c7a8c6f
--- /dev/null
+++ b/src/components/bitacora/bitacora-obra.tsx
@@ -0,0 +1,842 @@
+"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 { Separator } from "@/components/ui/separator";
+import {
+ BookOpen,
+ Plus,
+ Calendar,
+ Users,
+ Thermometer,
+ Sun,
+ Cloud,
+ CloudRain,
+ CloudLightning,
+ Wind,
+ Snowflake,
+ Loader2,
+ ChevronLeft,
+ ChevronRight,
+ Edit,
+ Trash2,
+ AlertTriangle,
+ Shield,
+ ClipboardList,
+ Package,
+ Truck,
+ Eye,
+} from "lucide-react";
+import { toast } from "@/hooks/use-toast";
+import { formatDate } from "@/lib/utils";
+import { CONDICION_CLIMA_LABELS, type CondicionClima } from "@/types";
+
+interface BitacoraEntry {
+ id: string;
+ fecha: string;
+ 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: string;
+}
+
+interface BitacoraObraProps {
+ obraId: string;
+ bitacoras: BitacoraEntry[];
+}
+
+const climaIcons: Record = {
+ SOLEADO: ,
+ NUBLADO: ,
+ PARCIALMENTE_NUBLADO: ,
+ LLUVIA_LIGERA: ,
+ LLUVIA_FUERTE: ,
+ TORMENTA: ,
+ VIENTO_FUERTE: ,
+ FRIO_EXTREMO: ,
+ CALOR_EXTREMO: ,
+};
+
+const initialFormState = {
+ fecha: new Date().toISOString().split("T")[0],
+ clima: "SOLEADO" as CondicionClima,
+ temperaturaMin: "",
+ temperaturaMax: "",
+ condicionesExtra: "",
+ personalPropio: "0",
+ personalSubcontrato: "0",
+ personalDetalle: "",
+ actividadesRealizadas: "",
+ actividadesPendientes: "",
+ materialesUtilizados: "",
+ materialesRecibidos: "",
+ equipoUtilizado: "",
+ incidentes: "",
+ observaciones: "",
+ incidentesSeguridad: "",
+ platicaSeguridad: false,
+ temaSeguridad: "",
+ visitasInspeccion: "",
+};
+
+export function BitacoraObra({ obraId, bitacoras: initialBitacoras }: BitacoraObraProps) {
+ const router = useRouter();
+ const [bitacoras, setBitacoras] = useState(initialBitacoras);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
+ const [selectedEntry, setSelectedEntry] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [deleteId, setDeleteId] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [currentMonth, setCurrentMonth] = useState(new Date());
+ const [form, setForm] = useState(initialFormState);
+ const [editingId, setEditingId] = useState(null);
+
+ // Cargar bitácoras del mes actual
+ useEffect(() => {
+ const fetchBitacoras = async () => {
+ const mes = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, "0")}`;
+ try {
+ const response = await fetch(`/api/bitacora?obraId=${obraId}&mes=${mes}`);
+ if (response.ok) {
+ const data = await response.json();
+ setBitacoras(data);
+ }
+ } catch (error) {
+ console.error("Error fetching bitacoras:", error);
+ }
+ };
+ fetchBitacoras();
+ }, [obraId, currentMonth]);
+
+ const resetForm = () => {
+ setForm(initialFormState);
+ setEditingId(null);
+ };
+
+ const handleSubmit = async () => {
+ if (!form.actividadesRealizadas.trim()) {
+ toast({
+ title: "Error",
+ description: "Las actividades realizadas son requeridas",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const url = editingId ? `/api/bitacora/${editingId}` : "/api/bitacora";
+ const method = editingId ? "PUT" : "POST";
+
+ const response = await fetch(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...form,
+ temperaturaMin: form.temperaturaMin ? parseFloat(form.temperaturaMin) : null,
+ temperaturaMax: form.temperaturaMax ? parseFloat(form.temperaturaMax) : null,
+ personalPropio: parseInt(form.personalPropio) || 0,
+ personalSubcontrato: parseInt(form.personalSubcontrato) || 0,
+ obraId,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || "Error al guardar");
+ }
+
+ const savedEntry = await response.json();
+
+ if (editingId) {
+ setBitacoras((prev) =>
+ prev.map((b) => (b.id === editingId ? savedEntry : b))
+ );
+ toast({ title: "Bitácora actualizada exitosamente" });
+ } else {
+ setBitacoras((prev) => [savedEntry, ...prev]);
+ toast({ title: "Bitácora registrada exitosamente" });
+ }
+
+ setIsDialogOpen(false);
+ resetForm();
+ router.refresh();
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: error instanceof Error ? error.message : "Error al guardar la bitácora",
+ variant: "destructive",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleEdit = (entry: BitacoraEntry) => {
+ setEditingId(entry.id);
+ setForm({
+ fecha: entry.fecha.split("T")[0],
+ clima: entry.clima,
+ temperaturaMin: entry.temperaturaMin?.toString() || "",
+ temperaturaMax: entry.temperaturaMax?.toString() || "",
+ condicionesExtra: entry.condicionesExtra || "",
+ personalPropio: entry.personalPropio.toString(),
+ personalSubcontrato: entry.personalSubcontrato.toString(),
+ personalDetalle: entry.personalDetalle || "",
+ actividadesRealizadas: entry.actividadesRealizadas,
+ actividadesPendientes: entry.actividadesPendientes || "",
+ materialesUtilizados: entry.materialesUtilizados || "",
+ materialesRecibidos: entry.materialesRecibidos || "",
+ equipoUtilizado: entry.equipoUtilizado || "",
+ incidentes: entry.incidentes || "",
+ observaciones: entry.observaciones || "",
+ incidentesSeguridad: entry.incidentesSeguridad || "",
+ platicaSeguridad: entry.platicaSeguridad,
+ temaSeguridad: entry.temaSeguridad || "",
+ visitasInspeccion: entry.visitasInspeccion || "",
+ });
+ setIsDialogOpen(true);
+ };
+
+ const handleDelete = async () => {
+ if (!deleteId) return;
+
+ setIsDeleting(true);
+ try {
+ const response = await fetch(`/api/bitacora/${deleteId}`, {
+ method: "DELETE",
+ });
+
+ if (!response.ok) throw new Error("Error al eliminar");
+
+ setBitacoras((prev) => prev.filter((b) => b.id !== deleteId));
+ toast({ title: "Bitácora eliminada exitosamente" });
+ router.refresh();
+ } catch {
+ toast({
+ title: "Error",
+ description: "No se pudo eliminar la bitácora",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ setDeleteId(null);
+ }
+ };
+
+ const prevMonth = () => {
+ setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1));
+ };
+
+ const nextMonth = () => {
+ setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1));
+ };
+
+ const monthNames = [
+ "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
+ "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"
+ ];
+
+ return (
+
+
+
+
+
+
+ Bitácora de Obra
+
+
+ Registro diario de actividades, personal y condiciones
+
+
+
+
+
+
+ {/* Navegación de mes */}
+
+
+
+ {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
+
+
+
+
+ {/* Lista de bitácoras */}
+ {bitacoras.length === 0 ? (
+
+
+
No hay registros este mes
+
+ Registra la primera entrada de la bitácora
+
+
+ ) : (
+
+ {bitacoras.map((entry) => (
+
+
+
+
+
+
+ {new Date(entry.fecha).getDate()}
+
+
+ {monthNames[new Date(entry.fecha).getMonth()].slice(0, 3)}
+
+
+
+
+
+ {climaIcons[entry.clima]}
+ {CONDICION_CLIMA_LABELS[entry.clima]}
+
+ {(entry.temperaturaMin || entry.temperaturaMax) && (
+
+ {entry.temperaturaMin && `${entry.temperaturaMin}°`}
+ {entry.temperaturaMin && entry.temperaturaMax && " - "}
+ {entry.temperaturaMax && `${entry.temperaturaMax}°`}
+
+ )}
+
+
+ {entry.personalPropio + entry.personalSubcontrato} personas
+
+
+
{entry.actividadesRealizadas}
+
+ {entry.incidentes && (
+
+
+ Incidente
+
+ )}
+ {entry.platicaSeguridad && (
+
+
+ Plática seguridad
+
+ )}
+ {entry.visitasInspeccion && (
+
+
+ Visita
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Registrado por {entry.registradoPor.nombre} {entry.registradoPor.apellido}
+
+
+
+ ))}
+
+ )}
+
+ {/* Dialog de formulario */}
+
+
+ {/* Dialog de vista detallada */}
+
+
+ {/* Dialog de confirmación de eliminación */}
+ setDeleteId(null)}>
+
+
+ Eliminar entrada de bitácora
+
+ ¿Estás seguro de que deseas eliminar esta entrada? Esta acción no se puede deshacer.
+
+
+
+ Cancelar
+
+ {isDeleting ? "Eliminando..." : "Eliminar"}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/clientes/gestionar-acceso-cliente.tsx b/src/components/clientes/gestionar-acceso-cliente.tsx
new file mode 100644
index 0000000..cfff736
--- /dev/null
+++ b/src/components/clientes/gestionar-acceso-cliente.tsx
@@ -0,0 +1,510 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import {
+ UserPlus,
+ Key,
+ Link as LinkIcon,
+ Copy,
+ Check,
+ Trash2,
+ RefreshCw,
+ Eye,
+ EyeOff,
+ Loader2,
+} from "lucide-react";
+import { formatDate } from "@/lib/utils";
+
+interface ClienteAcceso {
+ id: string;
+ email: string;
+ token: string | null;
+ tokenExpira: string | null;
+ activo: boolean;
+ ultimoAcceso: string | null;
+ tienePassword: boolean;
+ verFotos: boolean;
+ verAvances: boolean;
+ verGastos: boolean;
+ verDocumentos: boolean;
+ descargarPDF: boolean;
+ cliente: {
+ id: string;
+ nombre: string;
+ };
+}
+
+interface Props {
+ clienteId: string;
+ clienteNombre: string;
+ clienteEmail?: string | null;
+}
+
+export function GestionarAccesoCliente({ clienteId, clienteNombre, clienteEmail }: Props) {
+ const [accesos, setAccesos] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [copied, setCopied] = useState(null);
+ const [showPassword, setShowPassword] = useState(false);
+ const [newAccessUrl, setNewAccessUrl] = useState(null);
+
+ const [formData, setFormData] = useState({
+ email: clienteEmail || "",
+ password: "",
+ usarToken: true,
+ tokenExpiraDias: 30,
+ verFotos: true,
+ verAvances: true,
+ verGastos: false,
+ verDocumentos: true,
+ descargarPDF: true,
+ });
+
+ useEffect(() => {
+ fetchAccesos();
+ }, [clienteId]);
+
+ const fetchAccesos = async () => {
+ try {
+ const res = await fetch(`/api/clientes-acceso?clienteId=${clienteId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setAccesos(data);
+ }
+ } catch (error) {
+ console.error("Error:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreateAcceso = async () => {
+ setSaving(true);
+ try {
+ const res = await fetch("/api/clientes-acceso", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ clienteId,
+ ...formData,
+ }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ alert(data.error || "Error al crear acceso");
+ return;
+ }
+
+ if (data.accessUrl) {
+ setNewAccessUrl(data.accessUrl);
+ }
+
+ await fetchAccesos();
+ setDialogOpen(false);
+ setFormData({
+ email: "",
+ password: "",
+ usarToken: true,
+ tokenExpiraDias: 30,
+ verFotos: true,
+ verAvances: true,
+ verGastos: false,
+ verDocumentos: true,
+ descargarPDF: true,
+ });
+ } catch (error) {
+ console.error("Error:", error);
+ alert("Error al crear acceso");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleToggleActivo = async (acceso: ClienteAcceso) => {
+ try {
+ const res = await fetch(`/api/clientes-acceso/${acceso.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ activo: !acceso.activo }),
+ });
+
+ if (res.ok) {
+ await fetchAccesos();
+ }
+ } catch (error) {
+ console.error("Error:", error);
+ }
+ };
+
+ const handleRegenerarToken = async (accesoId: string) => {
+ try {
+ const res = await fetch(`/api/clientes-acceso/${accesoId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ regenerarToken: true, tokenExpiraDias: 30 }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok && data.accessUrl) {
+ setNewAccessUrl(data.accessUrl);
+ await fetchAccesos();
+ }
+ } catch (error) {
+ console.error("Error:", error);
+ }
+ };
+
+ const handleDeleteAcceso = async (accesoId: string) => {
+ try {
+ const res = await fetch(`/api/clientes-acceso/${accesoId}`, {
+ method: "DELETE",
+ });
+
+ if (res.ok) {
+ await fetchAccesos();
+ }
+ } catch (error) {
+ console.error("Error:", error);
+ }
+ };
+
+ const copyToClipboard = (text: string, id: string) => {
+ navigator.clipboard.writeText(text);
+ setCopied(id);
+ setTimeout(() => setCopied(null), 2000);
+ };
+
+ const getAccessUrl = (token: string) => {
+ const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
+ return `${baseUrl}/portal?token=${token}`;
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Acceso al Portal
+
+ Gestiona el acceso de {clienteNombre} al portal de cliente
+
+
+
+
+
+
+ {accesos.length === 0 ? (
+
+ Este cliente no tiene acceso al portal
+
+ ) : (
+
+ {accesos.map((acceso) => (
+
+
+
+ {acceso.email}
+
+ {acceso.activo ? "Activo" : "Inactivo"}
+
+
+
+ {acceso.token && (
+
+
+ Acceso con enlace
+
+ )}
+ {acceso.tienePassword && (
+
+
+ Acceso con contrasena
+
+ )}
+ {acceso.ultimoAcceso && (
+
+ Ultimo acceso: {formatDate(new Date(acceso.ultimoAcceso))}
+
+ )}
+
+
+
+ {acceso.token && (
+
+ )}
+
+
+
+
+
+
+
+
+ Eliminar acceso
+
+ Esta accion eliminara permanentemente el acceso de{" "}
+ {acceso.email} al portal.
+
+
+
+ Cancelar
+ handleDeleteAcceso(acceso.id)}
+ className="bg-red-600 hover:bg-red-700"
+ >
+ Eliminar
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Modal para mostrar URL de acceso */}
+
+
+ );
+}
diff --git a/src/components/fotos/galeria-fotos.tsx b/src/components/fotos/galeria-fotos.tsx
new file mode 100644
index 0000000..5f16c20
--- /dev/null
+++ b/src/components/fotos/galeria-fotos.tsx
@@ -0,0 +1,615 @@
+"use client";
+
+import { useState, useCallback, useRef } from "react";
+import Image from "next/image";
+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 {
+ Camera,
+ Upload,
+ X,
+ MapPin,
+ Calendar,
+ User,
+ Trash2,
+ ZoomIn,
+ Loader2,
+ ImageIcon,
+ ChevronLeft,
+ ChevronRight,
+} from "lucide-react";
+import { toast } from "@/hooks/use-toast";
+import { formatDate } from "@/lib/utils";
+
+interface Fase {
+ id: string;
+ nombre: string;
+}
+
+interface Foto {
+ id: string;
+ url: string;
+ thumbnail: string | null;
+ titulo: string | null;
+ descripcion: string | null;
+ fechaCaptura: string;
+ latitud: number | null;
+ longitud: number | null;
+ direccionGeo: string | null;
+ subidoPor: {
+ nombre: string;
+ apellido: string;
+ };
+ fase: {
+ nombre: string;
+ } | null;
+}
+
+interface GaleriaFotosProps {
+ obraId: string;
+ fotos: Foto[];
+ fases: Fase[];
+}
+
+export function GaleriaFotos({ obraId, fotos: initialFotos, fases }: GaleriaFotosProps) {
+ const router = useRouter();
+ const fileInputRef = useRef(null);
+ const [fotos, setFotos] = useState(initialFotos);
+ const [isUploading, setIsUploading] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
+ const [lightboxOpen, setLightboxOpen] = useState(false);
+ const [currentFotoIndex, setCurrentFotoIndex] = useState(0);
+ const [deleteId, setDeleteId] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // Estado para el formulario de subida
+ const [uploadForm, setUploadForm] = useState({
+ file: null as File | null,
+ titulo: "",
+ descripcion: "",
+ faseId: "",
+ latitud: null as number | null,
+ longitud: null as number | null,
+ });
+ const [preview, setPreview] = useState(null);
+
+ // Obtener ubicación actual
+ const getLocation = useCallback(() => {
+ if (!navigator.geolocation) {
+ toast({
+ title: "Error",
+ description: "Geolocalización no disponible en este navegador",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ setUploadForm((prev) => ({
+ ...prev,
+ latitud: position.coords.latitude,
+ longitud: position.coords.longitude,
+ }));
+ toast({
+ title: "Ubicación obtenida",
+ description: `${position.coords.latitude.toFixed(6)}, ${position.coords.longitude.toFixed(6)}`,
+ });
+ },
+ (error) => {
+ toast({
+ title: "Error de ubicación",
+ description: error.message,
+ variant: "destructive",
+ });
+ }
+ );
+ }, []);
+
+ // Manejar selección de archivo
+ const handleFileSelect = useCallback((file: File) => {
+ if (!file.type.startsWith("image/")) {
+ toast({
+ title: "Error",
+ description: "Solo se permiten imágenes",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ if (file.size > 10 * 1024 * 1024) {
+ toast({
+ title: "Error",
+ description: "El archivo es muy grande. Máximo 10MB",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setUploadForm((prev) => ({ ...prev, file }));
+ setPreview(URL.createObjectURL(file));
+ setUploadDialogOpen(true);
+
+ // Intentar obtener ubicación automáticamente
+ getLocation();
+ }, [getLocation]);
+
+ // Drag & Drop handlers
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ }, []);
+
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ const file = e.dataTransfer.files[0];
+ if (file) {
+ handleFileSelect(file);
+ }
+ }, [handleFileSelect]);
+
+ // Subir foto
+ const handleUpload = async () => {
+ if (!uploadForm.file) return;
+
+ setIsUploading(true);
+ try {
+ const formData = new FormData();
+ formData.append("file", uploadForm.file);
+ formData.append("obraId", obraId);
+ if (uploadForm.titulo) formData.append("titulo", uploadForm.titulo);
+ if (uploadForm.descripcion) formData.append("descripcion", uploadForm.descripcion);
+ if (uploadForm.faseId) formData.append("faseId", uploadForm.faseId);
+ if (uploadForm.latitud) formData.append("latitud", uploadForm.latitud.toString());
+ if (uploadForm.longitud) formData.append("longitud", uploadForm.longitud.toString());
+
+ const response = await fetch("/api/fotos", {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || "Error al subir la foto");
+ }
+
+ const newFoto = await response.json();
+ setFotos((prev) => [newFoto, ...prev]);
+
+ toast({ title: "Foto subida exitosamente" });
+ resetUploadForm();
+ router.refresh();
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: error instanceof Error ? error.message : "Error al subir la foto",
+ variant: "destructive",
+ });
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // Eliminar foto
+ const handleDelete = async () => {
+ if (!deleteId) return;
+
+ setIsDeleting(true);
+ try {
+ const response = await fetch(`/api/fotos/${deleteId}`, {
+ method: "DELETE",
+ });
+
+ if (!response.ok) {
+ throw new Error("Error al eliminar la foto");
+ }
+
+ setFotos((prev) => prev.filter((f) => f.id !== deleteId));
+ toast({ title: "Foto eliminada exitosamente" });
+ router.refresh();
+ } catch {
+ toast({
+ title: "Error",
+ description: "No se pudo eliminar la foto",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ setDeleteId(null);
+ }
+ };
+
+ // Reset formulario
+ const resetUploadForm = () => {
+ setUploadForm({
+ file: null,
+ titulo: "",
+ descripcion: "",
+ faseId: "",
+ latitud: null,
+ longitud: null,
+ });
+ setPreview(null);
+ setUploadDialogOpen(false);
+ };
+
+ // Navegación en lightbox
+ const nextFoto = () => {
+ setCurrentFotoIndex((prev) => (prev + 1) % fotos.length);
+ };
+
+ const prevFoto = () => {
+ setCurrentFotoIndex((prev) => (prev - 1 + fotos.length) % fotos.length);
+ };
+
+ const openLightbox = (index: number) => {
+ setCurrentFotoIndex(index);
+ setLightboxOpen(true);
+ };
+
+ return (
+
+
+
+
+
+
+ Fotos de Avance
+
+
+ {fotos.length} {fotos.length === 1 ? "foto" : "fotos"} del proyecto
+
+
+
+
e.target.files?.[0] && handleFileSelect(e.target.files[0])}
+ />
+
+
+
+ {/* Zona de Drag & Drop */}
+
+
+
+ Arrastra una imagen aquí o{" "}
+
+
+
+ JPG, PNG o WebP. Máximo 10MB
+
+
+
+ {/* Galería */}
+ {fotos.length === 0 ? (
+
+
+
No hay fotos registradas
+
+ Sube la primera foto del avance de la obra
+
+
+ ) : (
+
+ {fotos.map((foto, index) => (
+
openLightbox(index)}
+ >
+
+
+
+
+
+ {foto.latitud && foto.longitud && (
+
+
+
+ )}
+
+ {foto.titulo && (
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Dialog de subida */}
+
+
+ {/* Lightbox */}
+
+
+ {/* Dialog de confirmación de eliminación */}
+ setDeleteId(null)}>
+
+
+ Eliminar foto
+
+ ¿Estás seguro de que deseas eliminar esta foto? Esta acción no se
+ puede deshacer.
+
+
+
+ Cancelar
+
+ {isDeleting ? "Eliminando..." : "Eliminar"}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/gantt/diagrama-gantt.tsx b/src/components/gantt/diagrama-gantt.tsx
new file mode 100644
index 0000000..5d2a65e
--- /dev/null
+++ b/src/components/gantt/diagrama-gantt.tsx
@@ -0,0 +1,359 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { Gantt, Task, ViewMode } from "gantt-task-react";
+import "gantt-task-react/dist/index.css";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Calendar, ZoomIn, ZoomOut, ListTree } from "lucide-react";
+import { formatDate } from "@/lib/utils";
+
+interface Fase {
+ id: string;
+ nombre: string;
+ descripcion: string | null;
+ orden: number;
+ fechaInicio: string | null;
+ fechaFin: string | null;
+ porcentajeAvance: number;
+ tareas: Tarea[];
+}
+
+interface Tarea {
+ id: string;
+ nombre: string;
+ descripcion: string | null;
+ estado: string;
+ fechaInicio: string | null;
+ fechaFin: string | null;
+ porcentajeAvance: number;
+}
+
+interface Props {
+ obraId: string;
+ obraNombre: string;
+ fechaInicioObra: string | null;
+ fechaFinObra: string | null;
+ fases: Fase[];
+ onTaskUpdate?: (taskId: string, start: Date, end: Date) => void;
+}
+
+const ESTADO_COLORS: Record = {
+ PENDIENTE: "#9CA3AF",
+ EN_PROGRESO: "#3B82F6",
+ COMPLETADA: "#22C55E",
+ BLOQUEADA: "#EF4444",
+};
+
+export function DiagramaGantt({
+ obraId,
+ obraNombre,
+ fechaInicioObra,
+ fechaFinObra,
+ fases,
+ onTaskUpdate,
+}: Props) {
+ const [viewMode, setViewMode] = useState(ViewMode.Week);
+ const [showTaskList, setShowTaskList] = useState(true);
+
+ // Convertir fases y tareas a formato del Gantt
+ const tasks: Task[] = useMemo(() => {
+ const result: Task[] = [];
+ const defaultStart = new Date();
+ const defaultEnd = new Date();
+ defaultEnd.setDate(defaultEnd.getDate() + 7);
+
+ // Agregar proyecto principal
+ result.push({
+ start: fechaInicioObra ? new Date(fechaInicioObra) : defaultStart,
+ end: fechaFinObra ? new Date(fechaFinObra) : defaultEnd,
+ name: obraNombre,
+ id: `obra-${obraId}`,
+ type: "project",
+ progress: fases.length > 0
+ ? fases.reduce((acc, f) => acc + f.porcentajeAvance, 0) / fases.length
+ : 0,
+ hideChildren: false,
+ styles: {
+ backgroundColor: "#6366F1",
+ backgroundSelectedColor: "#4F46E5",
+ progressColor: "#4338CA",
+ progressSelectedColor: "#3730A3",
+ },
+ });
+
+ // Agregar fases y tareas
+ fases.forEach((fase) => {
+ const faseStart = fase.fechaInicio
+ ? new Date(fase.fechaInicio)
+ : defaultStart;
+ const faseEnd = fase.fechaFin ? new Date(fase.fechaFin) : defaultEnd;
+
+ // Si la fase tiene tareas, usar las fechas de las tareas
+ let minStart = faseStart;
+ let maxEnd = faseEnd;
+
+ if (fase.tareas.length > 0) {
+ const tareaStarts = fase.tareas
+ .filter((t) => t.fechaInicio)
+ .map((t) => new Date(t.fechaInicio!));
+ const tareaEnds = fase.tareas
+ .filter((t) => t.fechaFin)
+ .map((t) => new Date(t.fechaFin!));
+
+ if (tareaStarts.length > 0) {
+ minStart = new Date(Math.min(...tareaStarts.map((d) => d.getTime())));
+ }
+ if (tareaEnds.length > 0) {
+ maxEnd = new Date(Math.max(...tareaEnds.map((d) => d.getTime())));
+ }
+ }
+
+ // Agregar fase
+ result.push({
+ start: minStart,
+ end: maxEnd,
+ name: fase.nombre,
+ id: `fase-${fase.id}`,
+ type: "project",
+ progress: fase.porcentajeAvance,
+ project: `obra-${obraId}`,
+ hideChildren: false,
+ styles: {
+ backgroundColor: "#8B5CF6",
+ backgroundSelectedColor: "#7C3AED",
+ progressColor: "#6D28D9",
+ progressSelectedColor: "#5B21B6",
+ },
+ });
+
+ // Agregar tareas de la fase
+ fase.tareas.forEach((tarea, index) => {
+ const tareaStart = tarea.fechaInicio
+ ? new Date(tarea.fechaInicio)
+ : new Date(minStart.getTime() + index * 86400000);
+ const tareaEnd = tarea.fechaFin
+ ? new Date(tarea.fechaFin)
+ : new Date(tareaStart.getTime() + 7 * 86400000);
+
+ const color = ESTADO_COLORS[tarea.estado] || "#9CA3AF";
+
+ result.push({
+ start: tareaStart,
+ end: tareaEnd,
+ name: tarea.nombre,
+ id: `tarea-${tarea.id}`,
+ type: "task",
+ progress: tarea.porcentajeAvance,
+ project: `fase-${fase.id}`,
+ styles: {
+ backgroundColor: color,
+ backgroundSelectedColor: color,
+ progressColor: "#1F2937",
+ progressSelectedColor: "#111827",
+ },
+ });
+ });
+ });
+
+ return result;
+ }, [obraId, obraNombre, fechaInicioObra, fechaFinObra, fases]);
+
+ // Manejar cambios de fecha en tareas
+ const handleTaskChange = (task: Task) => {
+ if (onTaskUpdate && task.id.startsWith("tarea-")) {
+ const tareaId = task.id.replace("tarea-", "");
+ onTaskUpdate(tareaId, task.start, task.end);
+ }
+ };
+
+ // Manejar doble click para expandir/contraer
+ const handleDoubleClick = (task: Task) => {
+ console.log("Double clicked on:", task.name);
+ };
+
+ // Calcular estadísticas
+ const stats = useMemo(() => {
+ const totalTareas = fases.reduce((acc, f) => acc + f.tareas.length, 0);
+ const completadas = fases.reduce(
+ (acc, f) => acc + f.tareas.filter((t) => t.estado === "COMPLETADA").length,
+ 0
+ );
+ const enProgreso = fases.reduce(
+ (acc, f) => acc + f.tareas.filter((t) => t.estado === "EN_PROGRESO").length,
+ 0
+ );
+ const bloqueadas = fases.reduce(
+ (acc, f) => acc + f.tareas.filter((t) => t.estado === "BLOQUEADA").length,
+ 0
+ );
+ return { totalTareas, completadas, enProgreso, bloqueadas };
+ }, [fases]);
+
+ if (fases.length === 0) {
+ return (
+
+
+
+
+ No hay fases definidas para mostrar el diagrama de Gantt
+
+
+ Crea fases y tareas para visualizar el cronograma
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Estadísticas */}
+
+
+
+ {stats.totalTareas}
+ Total Tareas
+
+
+
+
+
+ {stats.completadas}
+
+ Completadas
+
+
+
+
+
+ {stats.enProgreso}
+
+ En Progreso
+
+
+
+
+
+ {stats.bloqueadas}
+
+ Bloqueadas
+
+
+
+
+ {/* Controles */}
+
+
+
+
+ Diagrama de Gantt
+
+ Visualiza el cronograma del proyecto
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Leyenda */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx
index 1a91a16..db01133 100644
--- a/src/components/layout/header.tsx
+++ b/src/components/layout/header.tsx
@@ -11,9 +11,10 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
-import { Bell, LogOut, User, Settings } from "lucide-react";
+import { LogOut, User, Settings } from "lucide-react";
import { getInitials } from "@/lib/utils";
import { ROLES_LABELS } from "@/types";
+import { NotificationBell } from "@/components/notifications/notification-bell";
export function Header() {
const { data: session } = useSession();
@@ -31,12 +32,7 @@ export function Header() {
-
+
diff --git a/src/components/notifications/notification-bell.tsx b/src/components/notifications/notification-bell.tsx
new file mode 100644
index 0000000..cc33ddc
--- /dev/null
+++ b/src/components/notifications/notification-bell.tsx
@@ -0,0 +1,286 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Bell,
+ Check,
+ CheckCheck,
+ ClipboardList,
+ DollarSign,
+ Package,
+ TrendingUp,
+ AlertTriangle,
+ MessageSquare,
+} from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import { es } from "date-fns/locale";
+
+interface Notification {
+ id: string;
+ tipo: string;
+ titulo: string;
+ mensaje: string;
+ url: string | null;
+ leida: boolean;
+ createdAt: string;
+}
+
+const TIPO_ICONS: Record = {
+ TAREA_ASIGNADA: ,
+ TAREA_COMPLETADA: ,
+ GASTO_PENDIENTE: ,
+ GASTO_APROBADO: ,
+ ORDEN_APROBADA: ,
+ AVANCE_REGISTRADO: ,
+ ALERTA_INVENTARIO: ,
+ GENERAL: ,
+ RECORDATORIO: ,
+};
+
+export function NotificationBell() {
+ const [notifications, setNotifications] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ fetchNotifications();
+ // Polling cada 30 segundos
+ const interval = setInterval(fetchNotifications, 30000);
+ return () => clearInterval(interval);
+ }, []);
+
+ // Registrar service worker y suscribirse a push
+ useEffect(() => {
+ if ("serviceWorker" in navigator && "PushManager" in window) {
+ registerServiceWorker();
+ }
+ }, []);
+
+ const registerServiceWorker = async () => {
+ try {
+ const registration = await navigator.serviceWorker.register("/sw.js");
+ console.log("Service Worker registrado:", registration);
+
+ // Solicitar permiso para notificaciones
+ const permission = await Notification.requestPermission();
+ if (permission === "granted") {
+ await subscribeToPush(registration);
+ }
+ } catch (error) {
+ console.error("Error registrando Service Worker:", error);
+ }
+ };
+
+ const subscribeToPush = async (registration: ServiceWorkerRegistration) => {
+ try {
+ const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
+ if (!vapidPublicKey) {
+ console.warn("VAPID public key not configured");
+ return;
+ }
+
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
+ });
+
+ // Enviar suscripción al servidor
+ const p256dhKey = subscription.getKey("p256dh");
+ const authKey = subscription.getKey("auth");
+
+ if (p256dhKey && authKey) {
+ const p256dhArray = new Uint8Array(p256dhKey);
+ const authArray = new Uint8Array(authKey);
+
+ await fetch("/api/notifications/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ endpoint: subscription.endpoint,
+ keys: {
+ p256dh: arrayBufferToBase64(p256dhArray),
+ auth: arrayBufferToBase64(authArray),
+ },
+ }),
+ });
+ }
+
+ console.log("Suscrito a push notifications");
+ } catch (error) {
+ console.error("Error suscribiendo a push:", error);
+ }
+ };
+
+ const fetchNotifications = async () => {
+ try {
+ const res = await fetch("/api/notifications?limit=10");
+ if (res.ok) {
+ const data = await res.json();
+ setNotifications(data.notificaciones);
+ setUnreadCount(data.unreadCount);
+ }
+ } catch (error) {
+ console.error("Error fetching notifications:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const markAsRead = async (notificationId: string) => {
+ try {
+ await fetch("/api/notifications", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ notificationIds: [notificationId] }),
+ });
+ setNotifications((prev) =>
+ prev.map((n) => (n.id === notificationId ? { ...n, leida: true } : n))
+ );
+ setUnreadCount((prev) => Math.max(0, prev - 1));
+ } catch (error) {
+ console.error("Error marking as read:", error);
+ }
+ };
+
+ const markAllAsRead = async () => {
+ try {
+ await fetch("/api/notifications", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ markAllRead: true }),
+ });
+ setNotifications((prev) => prev.map((n) => ({ ...n, leida: true })));
+ setUnreadCount(0);
+ } catch (error) {
+ console.error("Error marking all as read:", error);
+ }
+ };
+
+ const handleNotificationClick = (notification: Notification) => {
+ if (!notification.leida) {
+ markAsRead(notification.id);
+ }
+ if (notification.url) {
+ window.location.href = notification.url;
+ }
+ setIsOpen(false);
+ };
+
+ return (
+
+
+
+
+
+
+ Notificaciones
+ {unreadCount > 0 && (
+
+ )}
+
+
+
+ {loading ? (
+
+ Cargando...
+
+ ) : notifications.length === 0 ? (
+
+
+ No tienes notificaciones
+
+ ) : (
+ notifications.map((notification) => (
+ handleNotificationClick(notification)}
+ >
+
+ {TIPO_ICONS[notification.tipo] || TIPO_ICONS.GENERAL}
+
+
+
+ {notification.titulo}
+
+
+ {notification.mensaje}
+
+
+ {formatDistanceToNow(new Date(notification.createdAt), {
+ addSuffix: true,
+ locale: es,
+ })}
+
+
+ {!notification.leida && (
+
+ )}
+
+ ))
+ )}
+
+
+
+ );
+}
+
+// Utilidad para convertir VAPID key
+function urlBase64ToUint8Array(base64String: string): Uint8Array {
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding)
+ .replace(/-/g, "+")
+ .replace(/_/g, "/");
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
+
+// Utilidad para convertir ArrayBuffer a Base64
+function arrayBufferToBase64(buffer: Uint8Array): string {
+ let binary = "";
+ for (let i = 0; i < buffer.byteLength; i++) {
+ binary += String.fromCharCode(buffer[i]);
+ }
+ return btoa(binary);
+}
diff --git a/src/components/ordenes/ordenes-compra.tsx b/src/components/ordenes/ordenes-compra.tsx
new file mode 100644
index 0000000..44f88f1
--- /dev/null
+++ b/src/components/ordenes/ordenes-compra.tsx
@@ -0,0 +1,747 @@
+"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 { Separator } from "@/components/ui/separator";
+import {
+ ShoppingCart,
+ Plus,
+ Trash2,
+ Edit,
+ Eye,
+ Loader2,
+ Package,
+ Send,
+ CheckCircle,
+ XCircle,
+ Clock,
+ FileText,
+} from "lucide-react";
+import { toast } from "@/hooks/use-toast";
+import { formatCurrency, formatDate } from "@/lib/utils";
+import {
+ ESTADO_ORDEN_COMPRA_LABELS,
+ ESTADO_ORDEN_COMPRA_COLORS,
+ PRIORIDAD_ORDEN_LABELS,
+ PRIORIDAD_ORDEN_COLORS,
+ UNIDAD_MEDIDA_LABELS,
+ type EstadoOrdenCompra,
+ type PrioridadOrden,
+ type UnidadMedida,
+} from "@/types";
+
+interface ItemOrden {
+ id?: string;
+ codigo: string;
+ descripcion: string;
+ unidad: UnidadMedida;
+ cantidad: number;
+ precioUnitario: number;
+ descuento: number;
+ subtotal: number;
+ materialId?: string | null;
+}
+
+interface OrdenCompra {
+ id: string;
+ numero: string;
+ estado: EstadoOrdenCompra;
+ prioridad: PrioridadOrden;
+ fechaEmision: string;
+ fechaRequerida: string | null;
+ fechaAprobacion: string | null;
+ fechaEnvio: string | null;
+ fechaRecepcion: string | null;
+ proveedorNombre: string;
+ proveedorRfc: string | null;
+ proveedorContacto: string | null;
+ proveedorTelefono: string | null;
+ proveedorEmail: string | null;
+ subtotal: number;
+ iva: number;
+ total: number;
+ condicionesPago: string | null;
+ tiempoEntrega: string | null;
+ lugarEntrega: string | null;
+ notas: string | null;
+ items: ItemOrden[];
+ creadoPor: { nombre: string; apellido: string };
+ aprobadoPor?: { nombre: string; apellido: string } | null;
+}
+
+interface OrdenesCompraProps {
+ obraId: string;
+ obraDireccion: string;
+}
+
+const initialItemState: ItemOrden = {
+ codigo: "",
+ descripcion: "",
+ unidad: "UNIDAD",
+ cantidad: 1,
+ precioUnitario: 0,
+ descuento: 0,
+ subtotal: 0,
+};
+
+export function OrdenesCompra({ obraId, obraDireccion }: OrdenesCompraProps) {
+ const router = useRouter();
+ const [ordenes, setOrdenes] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
+ const [selectedOrden, setSelectedOrden] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [deleteId, setDeleteId] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [filterEstado, setFilterEstado] = useState("all");
+
+ const [form, setForm] = useState({
+ prioridad: "NORMAL" as PrioridadOrden,
+ fechaRequerida: "",
+ proveedorNombre: "",
+ proveedorRfc: "",
+ proveedorContacto: "",
+ proveedorTelefono: "",
+ proveedorEmail: "",
+ proveedorDireccion: "",
+ condicionesPago: "",
+ tiempoEntrega: "",
+ lugarEntrega: obraDireccion,
+ notas: "",
+ });
+
+ const [items, setItems] = useState([{ ...initialItemState }]);
+
+ // Cargar órdenes
+ useEffect(() => {
+ const fetchOrdenes = async () => {
+ setIsLoading(true);
+ try {
+ const url = filterEstado === "all"
+ ? `/api/ordenes-compra?obraId=${obraId}`
+ : `/api/ordenes-compra?obraId=${obraId}&estado=${filterEstado}`;
+ const response = await fetch(url);
+ if (response.ok) {
+ const data = await response.json();
+ setOrdenes(data);
+ }
+ } catch (error) {
+ console.error("Error fetching ordenes:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchOrdenes();
+ }, [obraId, filterEstado]);
+
+ const resetForm = () => {
+ setForm({
+ prioridad: "NORMAL",
+ fechaRequerida: "",
+ proveedorNombre: "",
+ proveedorRfc: "",
+ proveedorContacto: "",
+ proveedorTelefono: "",
+ proveedorEmail: "",
+ proveedorDireccion: "",
+ condicionesPago: "",
+ tiempoEntrega: "",
+ lugarEntrega: obraDireccion,
+ notas: "",
+ });
+ setItems([{ ...initialItemState }]);
+ };
+
+ const updateItem = (index: number, field: keyof ItemOrden, value: unknown) => {
+ const newItems = [...items];
+ newItems[index] = { ...newItems[index], [field]: value };
+ // Recalcular subtotal
+ const cantidad = field === "cantidad" ? (value as number) : newItems[index].cantidad;
+ const precioUnitario = field === "precioUnitario" ? (value as number) : newItems[index].precioUnitario;
+ const descuento = field === "descuento" ? (value as number) : newItems[index].descuento;
+ newItems[index].subtotal = cantidad * precioUnitario - descuento;
+ setItems(newItems);
+ };
+
+ const addItem = () => {
+ setItems([...items, { ...initialItemState }]);
+ };
+
+ const removeItem = (index: number) => {
+ if (items.length > 1) {
+ setItems(items.filter((_, i) => i !== index));
+ }
+ };
+
+ const calculateTotals = () => {
+ const subtotal = items.reduce((acc, item) => acc + item.subtotal, 0);
+ const iva = subtotal * 0.16;
+ const total = subtotal + iva;
+ return { subtotal, iva, total };
+ };
+
+ const handleSubmit = async () => {
+ if (!form.proveedorNombre.trim()) {
+ toast({ title: "Error", description: "El proveedor es requerido", variant: "destructive" });
+ return;
+ }
+
+ const invalidItems = items.filter((item) => !item.descripcion.trim() || item.cantidad <= 0);
+ if (invalidItems.length > 0) {
+ toast({ title: "Error", description: "Todos los items deben tener descripción y cantidad válida", variant: "destructive" });
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const response = await fetch("/api/ordenes-compra", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...form,
+ obraId,
+ items: items.map((item) => ({
+ ...item,
+ cantidad: Number(item.cantidad),
+ precioUnitario: Number(item.precioUnitario),
+ descuento: Number(item.descuento),
+ })),
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || "Error al crear la orden");
+ }
+
+ const newOrden = await response.json();
+ setOrdenes((prev) => [newOrden, ...prev]);
+ toast({ title: "Orden de compra creada exitosamente" });
+ setIsDialogOpen(false);
+ resetForm();
+ router.refresh();
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: error instanceof Error ? error.message : "Error al crear la orden",
+ variant: "destructive",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleUpdateStatus = async (id: string, newEstado: EstadoOrdenCompra) => {
+ try {
+ const response = await fetch(`/api/ordenes-compra/${id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ estado: newEstado }),
+ });
+
+ if (!response.ok) throw new Error("Error al actualizar");
+
+ const updatedOrden = await response.json();
+ setOrdenes((prev) => prev.map((o) => (o.id === id ? updatedOrden : o)));
+ toast({ title: `Orden ${ESTADO_ORDEN_COMPRA_LABELS[newEstado].toLowerCase()}` });
+ router.refresh();
+ } catch {
+ toast({ title: "Error", description: "No se pudo actualizar el estado", variant: "destructive" });
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!deleteId) return;
+ setIsDeleting(true);
+ try {
+ const response = await fetch(`/api/ordenes-compra/${deleteId}`, { method: "DELETE" });
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error);
+ }
+ setOrdenes((prev) => prev.filter((o) => o.id !== deleteId));
+ toast({ title: "Orden eliminada" });
+ router.refresh();
+ } catch (error) {
+ toast({ title: "Error", description: error instanceof Error ? error.message : "No se pudo eliminar", variant: "destructive" });
+ } finally {
+ setIsDeleting(false);
+ setDeleteId(null);
+ }
+ };
+
+ const totals = calculateTotals();
+
+ // Estadísticas
+ const stats = {
+ total: ordenes.length,
+ pendientes: ordenes.filter((o) => o.estado === "PENDIENTE").length,
+ aprobadas: ordenes.filter((o) => o.estado === "APROBADA" || o.estado === "ENVIADA").length,
+ recibidas: ordenes.filter((o) => o.estado === "RECIBIDA" || o.estado === "RECIBIDA_PARCIAL").length,
+ montoTotal: ordenes.reduce((acc, o) => acc + o.total, 0),
+ };
+
+ return (
+
+
+
+
+
+
+ Ordenes de Compra
+
+
+ Gestiona las ordenes de compra de materiales
+
+
+
+
+
+
+ {/* Estadísticas */}
+
+
+
+
+ {stats.total}
+ Total
+
+
+
+
+
+ {stats.pendientes}
+ Pendientes
+
+
+
+
+
+ {stats.aprobadas}
+ En proceso
+
+
+
+
+
+ {stats.recibidas}
+ Recibidas
+
+
+
+
+
+ {formatCurrency(stats.montoTotal)}
+ Monto total
+
+
+
+
+ {/* Filtro */}
+
+
+
+
+ {/* Tabla de órdenes */}
+ {isLoading ? (
+
+
+
+ ) : ordenes.length === 0 ? (
+
+
+
No hay ordenes de compra
+
+ ) : (
+
+
+
+ Numero
+ Proveedor
+ Estado
+ Prioridad
+ Fecha
+ Total
+ Acciones
+
+
+
+ {ordenes.map((orden) => (
+
+ {orden.numero}
+ {orden.proveedorNombre}
+
+
+ {ESTADO_ORDEN_COMPRA_LABELS[orden.estado]}
+
+
+
+
+ {PRIORIDAD_ORDEN_LABELS[orden.prioridad]}
+
+
+ {new Date(orden.fechaEmision).toLocaleDateString("es-MX")}
+ {formatCurrency(orden.total)}
+
+
+
+ {orden.estado === "BORRADOR" && (
+
+ )}
+ {orden.estado === "PENDIENTE" && (
+
+ )}
+ {(orden.estado === "BORRADOR" || orden.estado === "CANCELADA") && (
+
+ )}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Dialog de nueva orden */}
+
+
+ {/* Dialog de vista detallada */}
+
+
+ {/* Dialog de confirmación de eliminación */}
+ setDeleteId(null)}>
+
+
+ Eliminar orden de compra
+
+ ¿Estas seguro de que deseas eliminar esta orden? Esta accion no se puede deshacer.
+
+
+
+ Cancelar
+
+ {isDeleting ? "Eliminando..." : "Eliminar"}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/pdf/bitacora-pdf.tsx b/src/components/pdf/bitacora-pdf.tsx
new file mode 100644
index 0000000..2a389f7
--- /dev/null
+++ b/src/components/pdf/bitacora-pdf.tsx
@@ -0,0 +1,237 @@
+"use client";
+
+import {
+ Document,
+ Page,
+ Text,
+ View,
+} from "@react-pdf/renderer";
+import { styles, formatDatePDF } from "./styles";
+import { CONDICION_CLIMA_LABELS, type CondicionClima } from "@/types";
+
+interface BitacoraEntry {
+ id: string;
+ fecha: Date | string;
+ 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 };
+}
+
+interface BitacoraPDFProps {
+ obra: {
+ nombre: string;
+ direccion: string;
+ };
+ bitacoras: BitacoraEntry[];
+ mes?: string;
+ empresaNombre?: string;
+}
+
+export function BitacoraPDF({ obra, bitacoras, mes, empresaNombre = "Mexus App" }: BitacoraPDFProps) {
+ // Calcular estadisticas
+ const totalPersonal = bitacoras.reduce((acc, b) => acc + b.personalPropio + b.personalSubcontrato, 0);
+ const diasConIncidentes = bitacoras.filter(b => b.incidentes || b.incidentesSeguridad).length;
+ const platicasSeguridad = bitacoras.filter(b => b.platicaSeguridad).length;
+
+ return (
+
+
+ {/* Header */}
+
+ Bitacora de Obra
+ {obra.nombre}
+
+ {mes ? `Mes: ${mes}` : `Generado el ${formatDatePDF(new Date())}`} | {empresaNombre}
+
+
+
+ {/* Info de la Obra */}
+
+
+ Direccion:
+ {obra.direccion}
+
+
+
+ {/* Resumen del Periodo */}
+
+ Resumen del Periodo
+
+
+ Dias Registrados
+ {bitacoras.length}
+
+
+ Personal Total
+ {totalPersonal}
+ jornadas-hombre
+
+ 0 ? "#fee2e2" : "#dcfce7" }]}>
+ Dias con Incidentes
+ 0 ? styles.textDanger : styles.textSuccess]}>
+ {diasConIncidentes}
+
+
+
+ Platicas Seguridad
+ {platicasSeguridad}
+
+
+
+
+ {/* Entradas de Bitacora */}
+ {bitacoras.map((entry, index) => (
+
+ {/* Encabezado de la entrada */}
+
+
+
+ {formatDatePDF(entry.fecha)}
+
+
+ Registrado por: {entry.registradoPor.nombre} {entry.registradoPor.apellido}
+
+
+
+
+ {CONDICION_CLIMA_LABELS[entry.clima]}
+
+ {(entry.temperaturaMin || entry.temperaturaMax) && (
+
+ {entry.temperaturaMin && `${entry.temperaturaMin}°`}
+ {entry.temperaturaMin && entry.temperaturaMax && " - "}
+ {entry.temperaturaMax && `${entry.temperaturaMax}°`}
+
+ )}
+
+
+
+ {/* Personal */}
+
+ PERSONAL EN OBRA
+
+ Propio: {entry.personalPropio} | Subcontrato: {entry.personalSubcontrato} |
+ Total: {entry.personalPropio + entry.personalSubcontrato}
+
+ {entry.personalDetalle && (
+ {entry.personalDetalle}
+ )}
+
+
+ {/* Actividades */}
+
+ ACTIVIDADES REALIZADAS
+ {entry.actividadesRealizadas}
+
+
+ {entry.actividadesPendientes && (
+
+ ACTIVIDADES PENDIENTES
+ {entry.actividadesPendientes}
+
+ )}
+
+ {/* Materiales */}
+ {(entry.materialesUtilizados || entry.materialesRecibidos) && (
+
+ {entry.materialesUtilizados && (
+
+ MATERIALES UTILIZADOS
+ {entry.materialesUtilizados}
+
+ )}
+ {entry.materialesRecibidos && (
+
+ MATERIALES RECIBIDOS
+ {entry.materialesRecibidos}
+
+ )}
+
+ )}
+
+ {/* Equipo */}
+ {entry.equipoUtilizado && (
+
+ EQUIPO/MAQUINARIA
+ {entry.equipoUtilizado}
+
+ )}
+
+ {/* Seguridad */}
+ {(entry.platicaSeguridad || entry.incidentesSeguridad) && (
+
+
+ SEGURIDAD
+
+ {entry.platicaSeguridad && (
+
+ Platica de seguridad: {entry.temaSeguridad || "Si"}
+
+ )}
+ {entry.incidentesSeguridad && (
+
+ Incidente: {entry.incidentesSeguridad}
+
+ )}
+
+ )}
+
+ {/* Incidentes generales */}
+ {entry.incidentes && (
+
+
+ INCIDENTES
+
+ {entry.incidentes}
+
+ )}
+
+ {/* Observaciones */}
+ {entry.observaciones && (
+
+ OBSERVACIONES
+ {entry.observaciones}
+
+ )}
+
+ {/* Visitas */}
+ {entry.visitasInspeccion && (
+
+ VISITAS DE INSPECCION
+ {entry.visitasInspeccion}
+
+ )}
+
+ ))}
+
+ {bitacoras.length === 0 && (
+
+ No hay entradas de bitacora para este periodo
+
+ )}
+
+ {/* Footer */}
+
+ {empresaNombre} - Sistema de Gestion de Obras
+ `Pagina ${pageNumber} de ${totalPages}`} />
+
+
+
+ );
+}
diff --git a/src/components/pdf/export-pdf-button.tsx b/src/components/pdf/export-pdf-button.tsx
new file mode 100644
index 0000000..a6bcdb7
--- /dev/null
+++ b/src/components/pdf/export-pdf-button.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useState } from "react";
+import { pdf } from "@react-pdf/renderer";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { FileDown, Loader2 } from "lucide-react";
+import { toast } from "@/hooks/use-toast";
+
+interface ExportPDFButtonProps {
+ document: React.ReactElement;
+ fileName: string;
+ variant?: "default" | "outline" | "ghost" | "secondary";
+ size?: "default" | "sm" | "lg" | "icon";
+ className?: string;
+ children?: React.ReactNode;
+}
+
+export function ExportPDFButton({
+ document,
+ fileName,
+ variant = "outline",
+ size = "sm",
+ className,
+ children,
+}: ExportPDFButtonProps) {
+ const [isGenerating, setIsGenerating] = useState(false);
+
+ const handleDownload = async () => {
+ setIsGenerating(true);
+ try {
+ const blob = await pdf(document).toBlob();
+ const url = URL.createObjectURL(blob);
+ const link = window.document.createElement("a");
+ link.href = url;
+ link.download = `${fileName}.pdf`;
+ window.document.body.appendChild(link);
+ link.click();
+ window.document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ toast({
+ title: "PDF generado exitosamente",
+ description: `El archivo ${fileName}.pdf se ha descargado`,
+ });
+ } catch (error) {
+ console.error("Error generating PDF:", error);
+ toast({
+ title: "Error al generar PDF",
+ description: "No se pudo generar el documento. Intente de nuevo.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+interface ExportMenuProps {
+ options: {
+ label: string;
+ document: React.ReactElement;
+ fileName: string;
+ }[];
+ variant?: "default" | "outline" | "ghost" | "secondary";
+ size?: "default" | "sm" | "lg" | "icon";
+}
+
+export function ExportPDFMenu({ options, variant = "outline", size = "sm" }: ExportMenuProps) {
+ const [isGenerating, setIsGenerating] = useState(null);
+
+ const handleDownload = async (option: typeof options[0]) => {
+ setIsGenerating(option.fileName);
+ try {
+ const blob = await pdf(option.document).toBlob();
+ const url = URL.createObjectURL(blob);
+ const link = window.document.createElement("a");
+ link.href = url;
+ link.download = `${option.fileName}.pdf`;
+ window.document.body.appendChild(link);
+ link.click();
+ window.document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ toast({
+ title: "PDF generado exitosamente",
+ description: `El archivo ${option.fileName}.pdf se ha descargado`,
+ });
+ } catch (error) {
+ console.error("Error generating PDF:", error);
+ toast({
+ title: "Error al generar PDF",
+ description: "No se pudo generar el documento. Intente de nuevo.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsGenerating(null);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {options.map((option) => (
+ handleDownload(option)}
+ disabled={!!isGenerating}
+ >
+ {isGenerating === option.fileName ? (
+
+ ) : (
+
+ )}
+ {option.label}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/pdf/gastos-pdf.tsx b/src/components/pdf/gastos-pdf.tsx
new file mode 100644
index 0000000..862c2d3
--- /dev/null
+++ b/src/components/pdf/gastos-pdf.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import {
+ Document,
+ Page,
+ Text,
+ View,
+} from "@react-pdf/renderer";
+import { styles, formatCurrencyPDF, formatDatePDF } from "./styles";
+import {
+ ESTADO_GASTO_LABELS,
+ CATEGORIA_GASTO_LABELS,
+ type EstadoGasto,
+ type CategoriaGasto,
+} from "@/types";
+
+interface Gasto {
+ id: string;
+ concepto: string;
+ monto: number;
+ fecha: Date | string;
+ categoria: CategoriaGasto;
+ estado: EstadoGasto;
+ proveedor: string | null;
+ factura: string | null;
+ notas: string | null;
+ creadoPor: { nombre: string; apellido: string };
+}
+
+interface GastosPDFProps {
+ obra: {
+ nombre: string;
+ direccion: string;
+ presupuestoTotal: number;
+ };
+ gastos: Gasto[];
+ periodo?: { inicio: string; fin: string };
+ empresaNombre?: string;
+}
+
+export function GastosPDF({ obra, gastos, periodo, empresaNombre = "Mexus App" }: GastosPDFProps) {
+ // Calcular totales por categoria
+ const totalesPorCategoria = gastos.reduce((acc, gasto) => {
+ if (!acc[gasto.categoria]) {
+ acc[gasto.categoria] = 0;
+ }
+ acc[gasto.categoria] += gasto.monto;
+ return acc;
+ }, {} as Record);
+
+ // Calcular totales por estado
+ const totalesPorEstado = gastos.reduce((acc, gasto) => {
+ if (!acc[gasto.estado]) {
+ acc[gasto.estado] = 0;
+ }
+ acc[gasto.estado] += gasto.monto;
+ return acc;
+ }, {} as Record);
+
+ const totalGastos = gastos.reduce((acc, g) => acc + g.monto, 0);
+ const gastosAprobados = totalesPorEstado["APROBADO"] || 0;
+ const gastosPagados = totalesPorEstado["PAGADO"] || 0;
+ const gastosPendientes = totalesPorEstado["PENDIENTE"] || 0;
+
+ const porcentajePresupuesto = obra.presupuestoTotal > 0
+ ? (totalGastos / obra.presupuestoTotal) * 100
+ : 0;
+
+ return (
+
+
+ {/* Header */}
+
+ Reporte de Gastos
+ {obra.nombre}
+
+ {periodo
+ ? `Periodo: ${formatDatePDF(periodo.inicio)} - ${formatDatePDF(periodo.fin)}`
+ : `Generado el ${formatDatePDF(new Date())}`
+ } | {empresaNombre}
+
+
+
+ {/* Resumen */}
+
+ Resumen de Gastos
+
+
+ Total Gastos
+ {formatCurrencyPDF(totalGastos)}
+ {gastos.length} registros
+
+
+ Presupuesto
+ {formatCurrencyPDF(obra.presupuestoTotal)}
+ {porcentajePresupuesto.toFixed(1)}% utilizado
+
+
+ Pagados
+ {formatCurrencyPDF(gastosPagados)}
+
+
+ Pendientes
+ {formatCurrencyPDF(gastosPendientes)}
+
+
+
+
+ {/* Gastos por Categoria */}
+
+ Gastos por Categoria
+
+
+ Categoria
+ Monto
+ % del Total
+
+ {Object.entries(totalesPorCategoria)
+ .sort(([, a], [, b]) => b - a)
+ .map(([categoria, monto], index) => (
+
+
+ {CATEGORIA_GASTO_LABELS[categoria as CategoriaGasto]}
+
+
+ {formatCurrencyPDF(monto)}
+
+
+ {totalGastos > 0 ? ((monto / totalGastos) * 100).toFixed(1) : 0}%
+
+
+ ))}
+
+ TOTAL
+
+ {formatCurrencyPDF(totalGastos)}
+
+
+ 100%
+
+
+
+
+
+ {/* Detalle de Gastos */}
+
+ Detalle de Gastos
+
+
+ Fecha
+ Concepto
+ Categoria
+ Estado
+ Monto
+ Responsable
+
+ {gastos.map((gasto, index) => (
+
+
+ {new Date(gasto.fecha).toLocaleDateString("es-MX")}
+
+ {gasto.concepto}
+
+ {CATEGORIA_GASTO_LABELS[gasto.categoria]}
+
+
+ {ESTADO_GASTO_LABELS[gasto.estado]}
+
+
+ {formatCurrencyPDF(gasto.monto)}
+
+
+ {gasto.creadoPor.nombre} {gasto.creadoPor.apellido.charAt(0)}.
+
+
+ ))}
+
+
+
+ {/* Footer */}
+
+ {empresaNombre} - Sistema de Gestion de Obras
+ `Pagina ${pageNumber} de ${totalPages}`} />
+
+
+
+ );
+}
diff --git a/src/components/pdf/index.ts b/src/components/pdf/index.ts
new file mode 100644
index 0000000..a6f18eb
--- /dev/null
+++ b/src/components/pdf/index.ts
@@ -0,0 +1,6 @@
+export { ReporteObraPDF } from "./reporte-obra-pdf";
+export { PresupuestoPDF } from "./presupuesto-pdf";
+export { GastosPDF } from "./gastos-pdf";
+export { BitacoraPDF } from "./bitacora-pdf";
+export { ExportPDFButton, ExportPDFMenu } from "./export-pdf-button";
+export * from "./styles";
diff --git a/src/components/pdf/presupuesto-pdf.tsx b/src/components/pdf/presupuesto-pdf.tsx
new file mode 100644
index 0000000..cabf61b
--- /dev/null
+++ b/src/components/pdf/presupuesto-pdf.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import {
+ Document,
+ Page,
+ Text,
+ View,
+} from "@react-pdf/renderer";
+import { styles, formatCurrencyPDF, formatDatePDF } from "./styles";
+
+interface PartidaPresupuesto {
+ codigo: string;
+ descripcion: string;
+ unidad: string;
+ cantidad: number;
+ precioUnitario: number;
+ total: number;
+}
+
+interface Presupuesto {
+ nombre: string;
+ total: number;
+ aprobado: boolean;
+ createdAt: Date | string;
+ partidas: PartidaPresupuesto[];
+}
+
+interface PresupuestoPDFProps {
+ obra: {
+ nombre: string;
+ direccion: string;
+ cliente: { nombre: string } | null;
+ };
+ presupuesto: Presupuesto;
+ empresaNombre?: string;
+}
+
+export function PresupuestoPDF({ obra, presupuesto, empresaNombre = "Mexus App" }: PresupuestoPDFProps) {
+ // Agrupar partidas por categoria (primeros 2 caracteres del codigo)
+ const partidasAgrupadas = presupuesto.partidas.reduce((acc, partida) => {
+ const categoria = partida.codigo.slice(0, 2);
+ if (!acc[categoria]) {
+ acc[categoria] = [];
+ }
+ acc[categoria].push(partida);
+ return acc;
+ }, {} as Record);
+
+ return (
+
+
+ {/* Header */}
+
+ Presupuesto de Obra
+ {presupuesto.nombre}
+
+ Generado el {formatDatePDF(new Date())} | {empresaNombre}
+
+
+
+ {/* Info de la Obra */}
+
+
+
+
+ Obra:
+ {obra.nombre}
+
+
+ Direccion:
+ {obra.direccion}
+
+
+
+
+ Cliente:
+ {obra.cliente?.nombre || "Sin cliente"}
+
+
+ Estado:
+
+ {presupuesto.aprobado ? "APROBADO" : "PENDIENTE"}
+
+
+
+
+
+
+ {/* Resumen */}
+
+ Resumen del Presupuesto
+
+
+ Total Partidas
+ {presupuesto.partidas.length}
+
+
+ Fecha Creacion
+ {formatDatePDF(presupuesto.createdAt)}
+
+
+ Total Presupuesto
+ {formatCurrencyPDF(presupuesto.total)}
+
+
+
+
+ {/* Tabla de Partidas */}
+
+ Detalle de Partidas
+
+
+ Codigo
+ Descripcion
+ Unidad
+ Cant.
+ P. Unit.
+ Total
+
+ {presupuesto.partidas.map((partida, index) => (
+
+
+ {partida.codigo}
+
+ {partida.descripcion}
+ {partida.unidad}
+
+ {partida.cantidad.toFixed(2)}
+
+
+ {formatCurrencyPDF(partida.precioUnitario)}
+
+
+ {formatCurrencyPDF(partida.total)}
+
+
+ ))}
+
+
+ {/* Total */}
+
+ TOTAL:
+
+ {formatCurrencyPDF(presupuesto.total)}
+
+
+
+
+ {/* Notas */}
+
+
+ Notas: Este presupuesto tiene validez de 30 dias a partir de la fecha de emision.
+ Los precios no incluyen IVA salvo que se indique lo contrario.
+ Sujeto a cambios segun disponibilidad de materiales.
+
+
+
+ {/* Footer */}
+
+ {empresaNombre} - Sistema de Gestion de Obras
+ `Pagina ${pageNumber} de ${totalPages}`} />
+
+
+
+ );
+}
diff --git a/src/components/pdf/reporte-obra-pdf.tsx b/src/components/pdf/reporte-obra-pdf.tsx
new file mode 100644
index 0000000..17e3088
--- /dev/null
+++ b/src/components/pdf/reporte-obra-pdf.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import {
+ Document,
+ Page,
+ Text,
+ View,
+} from "@react-pdf/renderer";
+import { styles, formatCurrencyPDF, formatDatePDF, formatPercentagePDF } from "./styles";
+import {
+ ESTADO_OBRA_LABELS,
+ ESTADO_TAREA_LABELS,
+ CATEGORIA_GASTO_LABELS,
+ type EstadoObra,
+ type EstadoTarea,
+ type CategoriaGasto,
+} from "@/types";
+
+interface ObraReportData {
+ nombre: string;
+ descripcion: string | null;
+ direccion: string;
+ estado: EstadoObra;
+ porcentajeAvance: number;
+ presupuestoTotal: number;
+ gastoTotal: number;
+ fechaInicio: Date | string | null;
+ fechaFinPrevista: Date | string | null;
+ cliente: { nombre: string } | null;
+ supervisor: { nombre: string; apellido: string } | null;
+ fases: {
+ nombre: string;
+ porcentajeAvance: number;
+ tareas: {
+ nombre: string;
+ estado: EstadoTarea;
+ }[];
+ }[];
+ gastos: {
+ concepto: string;
+ monto: number;
+ fecha: Date | string;
+ categoria: CategoriaGasto;
+ }[];
+}
+
+interface ReporteObraPDFProps {
+ obra: ObraReportData;
+ empresaNombre?: string;
+}
+
+export function ReporteObraPDF({ obra, empresaNombre = "Mexus App" }: ReporteObraPDFProps) {
+ const variacion = obra.presupuestoTotal - obra.gastoTotal;
+ const porcentajeGastado = obra.presupuestoTotal > 0
+ ? (obra.gastoTotal / obra.presupuestoTotal) * 100
+ : 0;
+
+ const tareasCompletadas = obra.fases.reduce(
+ (acc, fase) => acc + fase.tareas.filter((t) => t.estado === "COMPLETADA").length,
+ 0
+ );
+ const tareasTotal = obra.fases.reduce((acc, fase) => acc + fase.tareas.length, 0);
+
+ return (
+
+
+ {/* Header */}
+
+ {obra.nombre}
+ Reporte General de Obra
+
+ Generado el {formatDatePDF(new Date())} | {empresaNombre}
+
+
+
+ {/* Stats Grid */}
+
+
+ Avance
+ {formatPercentagePDF(obra.porcentajeAvance)}
+
+
+
+
+
+ Presupuesto
+ {formatCurrencyPDF(obra.presupuestoTotal)}
+ Total aprobado
+
+
+ Gastado
+ {formatCurrencyPDF(obra.gastoTotal)}
+ {formatPercentagePDF(porcentajeGastado)} del presupuesto
+
+
+ Variacion
+ = 0 ? styles.textSuccess : styles.textDanger]}>
+ {variacion >= 0 ? "+" : ""}{formatCurrencyPDF(variacion)}
+
+ {variacion >= 0 ? "Bajo presupuesto" : "Sobre presupuesto"}
+
+
+
+ {/* Info General */}
+
+ Informacion General
+
+
+
+ Estado:
+ {ESTADO_OBRA_LABELS[obra.estado]}
+
+
+ Direccion:
+ {obra.direccion}
+
+
+ Cliente:
+ {obra.cliente?.nombre || "Sin asignar"}
+
+
+
+
+ Supervisor:
+
+ {obra.supervisor ? `${obra.supervisor.nombre} ${obra.supervisor.apellido}` : "Sin asignar"}
+
+
+
+ Fecha Inicio:
+
+ {obra.fechaInicio ? formatDatePDF(obra.fechaInicio) : "No definida"}
+
+
+
+ Fecha Fin Prevista:
+
+ {obra.fechaFinPrevista ? formatDatePDF(obra.fechaFinPrevista) : "No definida"}
+
+
+
+
+ {obra.descripcion && (
+
+ Descripcion:
+ {obra.descripcion}
+
+ )}
+
+
+ {/* Fases y Avance */}
+
+
+ Fases del Proyecto ({tareasCompletadas}/{tareasTotal} tareas completadas)
+
+ {obra.fases.length === 0 ? (
+ No hay fases definidas
+ ) : (
+ obra.fases.map((fase, index) => (
+
+
+ {fase.nombre}
+ {formatPercentagePDF(fase.porcentajeAvance)}
+
+
+
+
+ {fase.tareas.length > 0 && (
+
+ {fase.tareas.slice(0, 5).map((tarea, idx) => (
+
+
+ {tarea.estado === "COMPLETADA" ? "✓" : "○"}
+
+
+ {tarea.nombre} - {ESTADO_TAREA_LABELS[tarea.estado]}
+
+
+ ))}
+ {fase.tareas.length > 5 && (
+
+ +{fase.tareas.length - 5} tareas mas...
+
+ )}
+
+ )}
+
+ ))
+ )}
+
+
+ {/* Gastos Recientes */}
+ {obra.gastos.length > 0 && (
+
+ Ultimos Gastos
+
+
+ Concepto
+ Categoria
+ Fecha
+ Monto
+
+ {obra.gastos.slice(0, 10).map((gasto, index) => (
+
+ {gasto.concepto}
+ {CATEGORIA_GASTO_LABELS[gasto.categoria]}
+
+ {new Date(gasto.fecha).toLocaleDateString("es-MX")}
+
+
+ {formatCurrencyPDF(gasto.monto)}
+
+
+ ))}
+
+
+ )}
+
+ {/* Footer */}
+
+ {empresaNombre} - Sistema de Gestion de Obras
+ `Pagina ${pageNumber} de ${totalPages}`} />
+
+
+
+ );
+}
diff --git a/src/components/pdf/styles.ts b/src/components/pdf/styles.ts
new file mode 100644
index 0000000..79654f4
--- /dev/null
+++ b/src/components/pdf/styles.ts
@@ -0,0 +1,228 @@
+import { StyleSheet } from "@react-pdf/renderer";
+
+export const styles = StyleSheet.create({
+ page: {
+ padding: 40,
+ fontSize: 10,
+ fontFamily: "Helvetica",
+ },
+ header: {
+ marginBottom: 20,
+ borderBottomWidth: 2,
+ borderBottomColor: "#2563eb",
+ paddingBottom: 10,
+ },
+ headerTitle: {
+ fontSize: 24,
+ fontWeight: "bold",
+ color: "#1e3a8a",
+ marginBottom: 5,
+ },
+ headerSubtitle: {
+ fontSize: 12,
+ color: "#64748b",
+ },
+ headerDate: {
+ fontSize: 9,
+ color: "#94a3b8",
+ marginTop: 5,
+ },
+ section: {
+ marginBottom: 15,
+ },
+ sectionTitle: {
+ fontSize: 14,
+ fontWeight: "bold",
+ color: "#1e40af",
+ marginBottom: 8,
+ paddingBottom: 4,
+ borderBottomWidth: 1,
+ borderBottomColor: "#e2e8f0",
+ },
+ row: {
+ flexDirection: "row",
+ marginBottom: 4,
+ },
+ label: {
+ width: "30%",
+ color: "#64748b",
+ fontSize: 9,
+ },
+ value: {
+ width: "70%",
+ fontSize: 10,
+ },
+ table: {
+ width: "100%",
+ marginTop: 10,
+ },
+ tableHeader: {
+ flexDirection: "row",
+ backgroundColor: "#f1f5f9",
+ borderBottomWidth: 1,
+ borderBottomColor: "#e2e8f0",
+ paddingVertical: 6,
+ paddingHorizontal: 4,
+ },
+ tableHeaderCell: {
+ fontSize: 9,
+ fontWeight: "bold",
+ color: "#475569",
+ },
+ tableRow: {
+ flexDirection: "row",
+ borderBottomWidth: 1,
+ borderBottomColor: "#f1f5f9",
+ paddingVertical: 6,
+ paddingHorizontal: 4,
+ },
+ tableRowAlt: {
+ backgroundColor: "#fafafa",
+ },
+ tableCell: {
+ fontSize: 9,
+ color: "#334155",
+ },
+ statsGrid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ marginBottom: 15,
+ },
+ statBox: {
+ width: "25%",
+ padding: 10,
+ backgroundColor: "#f8fafc",
+ borderRadius: 4,
+ marginRight: 10,
+ marginBottom: 10,
+ },
+ statLabel: {
+ fontSize: 8,
+ color: "#64748b",
+ marginBottom: 4,
+ },
+ statValue: {
+ fontSize: 16,
+ fontWeight: "bold",
+ color: "#1e293b",
+ },
+ statSubtext: {
+ fontSize: 8,
+ color: "#94a3b8",
+ marginTop: 2,
+ },
+ badge: {
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 10,
+ fontSize: 8,
+ fontWeight: "bold",
+ },
+ badgeGreen: {
+ backgroundColor: "#dcfce7",
+ color: "#166534",
+ },
+ badgeBlue: {
+ backgroundColor: "#dbeafe",
+ color: "#1e40af",
+ },
+ badgeYellow: {
+ backgroundColor: "#fef9c3",
+ color: "#854d0e",
+ },
+ badgeRed: {
+ backgroundColor: "#fee2e2",
+ color: "#991b1b",
+ },
+ badgeGray: {
+ backgroundColor: "#f1f5f9",
+ color: "#475569",
+ },
+ progressBar: {
+ height: 8,
+ backgroundColor: "#e2e8f0",
+ borderRadius: 4,
+ marginTop: 4,
+ },
+ progressFill: {
+ height: 8,
+ backgroundColor: "#2563eb",
+ borderRadius: 4,
+ },
+ footer: {
+ position: "absolute",
+ bottom: 30,
+ left: 40,
+ right: 40,
+ flexDirection: "row",
+ justifyContent: "space-between",
+ borderTopWidth: 1,
+ borderTopColor: "#e2e8f0",
+ paddingTop: 10,
+ },
+ footerText: {
+ fontSize: 8,
+ color: "#94a3b8",
+ },
+ pageNumber: {
+ fontSize: 8,
+ color: "#64748b",
+ },
+ textMuted: {
+ color: "#64748b",
+ fontSize: 9,
+ },
+ textSuccess: {
+ color: "#16a34a",
+ },
+ textDanger: {
+ color: "#dc2626",
+ },
+ textBold: {
+ fontWeight: "bold",
+ },
+ divider: {
+ borderBottomWidth: 1,
+ borderBottomColor: "#e2e8f0",
+ marginVertical: 10,
+ },
+ card: {
+ backgroundColor: "#f8fafc",
+ borderRadius: 4,
+ padding: 12,
+ marginBottom: 10,
+ },
+ cardTitle: {
+ fontSize: 11,
+ fontWeight: "bold",
+ marginBottom: 6,
+ color: "#1e293b",
+ },
+ twoColumn: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ },
+ column: {
+ width: "48%",
+ },
+});
+
+export const formatCurrencyPDF = (value: number): string => {
+ return new Intl.NumberFormat("es-MX", {
+ style: "currency",
+ currency: "MXN",
+ }).format(value);
+};
+
+export const formatDatePDF = (date: Date | string): string => {
+ const d = typeof date === "string" ? new Date(date) : date;
+ return d.toLocaleDateString("es-MX", {
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ });
+};
+
+export const formatPercentagePDF = (value: number): string => {
+ return `${value.toFixed(1)}%`;
+};
diff --git a/src/components/pwa/pwa-install-prompt.tsx b/src/components/pwa/pwa-install-prompt.tsx
new file mode 100644
index 0000000..881971d
--- /dev/null
+++ b/src/components/pwa/pwa-install-prompt.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Download, X, Smartphone, Share, Plus } from "lucide-react";
+
+interface BeforeInstallPromptEvent extends Event {
+ prompt: () => Promise;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
+}
+
+export function PWAInstallPrompt() {
+ const [deferredPrompt, setDeferredPrompt] = useState(null);
+ const [showInstallDialog, setShowInstallDialog] = useState(false);
+ const [showIOSInstructions, setShowIOSInstructions] = useState(false);
+ const [isStandalone, setIsStandalone] = useState(false);
+ const [isIOS, setIsIOS] = useState(false);
+
+ useEffect(() => {
+ // Verificar si ya está instalada como PWA
+ const checkStandalone = () => {
+ const standalone = window.matchMedia("(display-mode: standalone)").matches ||
+ (window.navigator as Navigator & { standalone?: boolean }).standalone === true;
+ setIsStandalone(standalone);
+ };
+
+ // Detectar iOS
+ const checkIOS = () => {
+ const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as typeof window & { MSStream?: unknown }).MSStream;
+ setIsIOS(isIOSDevice);
+ };
+
+ checkStandalone();
+ checkIOS();
+
+ // Capturar el evento beforeinstallprompt
+ const handleBeforeInstallPrompt = (e: Event) => {
+ e.preventDefault();
+ setDeferredPrompt(e as BeforeInstallPromptEvent);
+
+ // Mostrar el diálogo después de un pequeño delay
+ const dismissed = localStorage.getItem("pwa-install-dismissed");
+ const dismissedTime = dismissed ? parseInt(dismissed) : 0;
+ const oneWeek = 7 * 24 * 60 * 60 * 1000;
+
+ if (!dismissed || Date.now() - dismissedTime > oneWeek) {
+ setTimeout(() => setShowInstallDialog(true), 3000);
+ }
+ };
+
+ // Escuchar cuando la app es instalada
+ const handleAppInstalled = () => {
+ setDeferredPrompt(null);
+ setShowInstallDialog(false);
+ console.log("PWA instalada exitosamente");
+ };
+
+ window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
+ window.addEventListener("appinstalled", handleAppInstalled);
+
+ // Registrar el Service Worker
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.register("/sw.js").then((registration) => {
+ console.log("Service Worker registrado:", registration.scope);
+ }).catch((error) => {
+ console.error("Error al registrar Service Worker:", error);
+ });
+ }
+
+ return () => {
+ window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
+ window.removeEventListener("appinstalled", handleAppInstalled);
+ };
+ }, []);
+
+ const handleInstallClick = async () => {
+ if (!deferredPrompt) return;
+
+ deferredPrompt.prompt();
+ const { outcome } = await deferredPrompt.userChoice;
+
+ if (outcome === "accepted") {
+ console.log("Usuario aceptó instalar la PWA");
+ } else {
+ console.log("Usuario rechazó instalar la PWA");
+ }
+
+ setDeferredPrompt(null);
+ setShowInstallDialog(false);
+ };
+
+ const handleDismiss = () => {
+ localStorage.setItem("pwa-install-dismissed", Date.now().toString());
+ setShowInstallDialog(false);
+ };
+
+ // Si ya está instalada, no mostrar nada
+ if (isStandalone) {
+ return null;
+ }
+
+ // Mostrar instrucciones para iOS
+ if (isIOS && !deferredPrompt) {
+ return (
+ <>
+ {/* Botón flotante para iOS */}
+
+
+
+ >
+ );
+ }
+
+ // Diálogo de instalación para Android/Desktop
+ return (
+
+ );
+}
diff --git a/src/components/pwa/pwa-provider.tsx b/src/components/pwa/pwa-provider.tsx
new file mode 100644
index 0000000..1120df5
--- /dev/null
+++ b/src/components/pwa/pwa-provider.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { useEffect } from "react";
+import { PWAInstallPrompt } from "./pwa-install-prompt";
+
+export function PWAProvider({ children }: { children: React.ReactNode }) {
+ useEffect(() => {
+ // Registrar el Service Worker al cargar
+ if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker.register("/sw.js")
+ .then((registration) => {
+ console.log("Service Worker registrado con scope:", registration.scope);
+
+ // Verificar actualizaciones
+ registration.addEventListener("updatefound", () => {
+ const newWorker = registration.installing;
+ if (newWorker) {
+ newWorker.addEventListener("statechange", () => {
+ if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
+ // Hay una nueva versión disponible
+ console.log("Nueva versión disponible");
+ // Aquí podrías mostrar un toast o notificación
+ }
+ });
+ }
+ });
+ })
+ .catch((error) => {
+ console.error("Error registrando Service Worker:", error);
+ });
+ });
+
+ // Manejar actualizaciones del service worker
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
+ console.log("Service Worker actualizado");
+ });
+ }
+ }, []);
+
+ return (
+ <>
+ {children}
+
+ >
+ );
+}
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
new file mode 100644
index 0000000..5f4117f
--- /dev/null
+++ b/src/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }
diff --git a/src/lib/activity-log.ts b/src/lib/activity-log.ts
new file mode 100644
index 0000000..daa24ae
--- /dev/null
+++ b/src/lib/activity-log.ts
@@ -0,0 +1,262 @@
+import { prisma } from "@/lib/prisma";
+import { TipoActividad } from "@prisma/client";
+
+interface LogActivityParams {
+ tipo: TipoActividad;
+ descripcion: string;
+ detalles?: Record;
+ entidadTipo?: string;
+ entidadId?: string;
+ entidadNombre?: string;
+ obraId?: string;
+ userId?: string;
+ empresaId: string;
+ ipAddress?: string;
+ userAgent?: string;
+}
+
+// Registrar una actividad
+export async function logActivity({
+ tipo,
+ descripcion,
+ detalles,
+ entidadTipo,
+ entidadId,
+ entidadNombre,
+ obraId,
+ userId,
+ empresaId,
+ ipAddress,
+ userAgent,
+}: LogActivityParams) {
+ try {
+ const actividad = await prisma.actividadLog.create({
+ data: {
+ tipo,
+ descripcion,
+ detalles: detalles ? JSON.stringify(detalles) : null,
+ entidadTipo,
+ entidadId,
+ entidadNombre,
+ obraId,
+ userId,
+ empresaId,
+ ipAddress,
+ userAgent,
+ },
+ });
+ return actividad;
+ } catch (error) {
+ console.error("Error logging activity:", error);
+ return null;
+ }
+}
+
+// Templates de actividades comunes
+export const ActivityTemplates = {
+ obraCreada: (obraNombre: string, obraId: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "OBRA_CREADA",
+ descripcion: `Obra "${obraNombre}" creada`,
+ entidadTipo: "obra",
+ entidadId: obraId,
+ entidadNombre: obraNombre,
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ obraActualizada: (obraNombre: string, obraId: string, userId: string, empresaId: string, cambios?: Record) =>
+ logActivity({
+ tipo: "OBRA_ACTUALIZADA",
+ descripcion: `Obra "${obraNombre}" actualizada`,
+ detalles: cambios,
+ entidadTipo: "obra",
+ entidadId: obraId,
+ entidadNombre: obraNombre,
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ tareaCreada: (tareaNombre: string, tareaId: string, obraId: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "TAREA_CREADA",
+ descripcion: `Tarea "${tareaNombre}" creada`,
+ entidadTipo: "tarea",
+ entidadId: tareaId,
+ entidadNombre: tareaNombre,
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ tareaCompletada: (tareaNombre: string, tareaId: string, obraId: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "TAREA_COMPLETADA",
+ descripcion: `Tarea "${tareaNombre}" completada`,
+ entidadTipo: "tarea",
+ entidadId: tareaId,
+ entidadNombre: tareaNombre,
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ gastoCreado: (concepto: string, monto: number, gastoId: string, obraId: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "GASTO_CREADO",
+ descripcion: `Gasto "${concepto}" por $${monto.toLocaleString()} registrado`,
+ detalles: { monto },
+ entidadTipo: "gasto",
+ entidadId: gastoId,
+ entidadNombre: concepto,
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ gastoAprobado: (concepto: string, gastoId: string, obraId: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "GASTO_APROBADO",
+ descripcion: `Gasto "${concepto}" aprobado`,
+ entidadTipo: "gasto",
+ entidadId: gastoId,
+ entidadNombre: concepto,
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ ordenCreada: (numero: string, ordenId: string, obraId: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "ORDEN_CREADA",
+ descripcion: `Orden de compra ${numero} creada`,
+ entidadTipo: "orden",
+ entidadId: ordenId,
+ entidadNombre: numero,
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ ordenAprobada: (numero: string, ordenId: string, obraId: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "ORDEN_APROBADA",
+ descripcion: `Orden de compra ${numero} aprobada`,
+ entidadTipo: "orden",
+ entidadId: ordenId,
+ entidadNombre: numero,
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ avanceRegistrado: (porcentaje: number, obraId: string, obraNombre: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "AVANCE_REGISTRADO",
+ descripcion: `Avance de ${porcentaje}% registrado en ${obraNombre}`,
+ detalles: { porcentaje },
+ entidadTipo: "obra",
+ entidadId: obraId,
+ entidadNombre: obraNombre,
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ fotoSubida: (titulo: string | null, fotoId: string, obraId: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "FOTO_SUBIDA",
+ descripcion: titulo ? `Foto "${titulo}" subida` : "Foto subida",
+ entidadTipo: "foto",
+ entidadId: fotoId,
+ entidadNombre: titulo || "Foto",
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ bitacoraRegistrada: (fecha: string, obraId: string, obraNombre: string, userId: string, empresaId: string) =>
+ logActivity({
+ tipo: "BITACORA_REGISTRADA",
+ descripcion: `Bitácora del ${fecha} registrada para ${obraNombre}`,
+ entidadTipo: "bitacora",
+ obraId,
+ userId,
+ empresaId,
+ }),
+
+ materialMovimiento: (
+ materialNombre: string,
+ tipo: "ENTRADA" | "SALIDA" | "AJUSTE",
+ cantidad: number,
+ materialId: string,
+ obraId: string | null,
+ userId: string,
+ empresaId: string
+ ) =>
+ logActivity({
+ tipo: "MATERIAL_MOVIMIENTO",
+ descripcion: `${tipo === "ENTRADA" ? "Entrada" : tipo === "SALIDA" ? "Salida" : "Ajuste"} de ${cantidad} unidades de "${materialNombre}"`,
+ detalles: { tipoMovimiento: tipo, cantidad },
+ entidadTipo: "material",
+ entidadId: materialId,
+ entidadNombre: materialNombre,
+ obraId: obraId || undefined,
+ userId,
+ empresaId,
+ }),
+};
+
+// Obtener el icono según el tipo de actividad
+export const ACTIVIDAD_ICONS: Record = {
+ OBRA_CREADA: "building-2",
+ OBRA_ACTUALIZADA: "pencil",
+ OBRA_ESTADO_CAMBIADO: "refresh-cw",
+ FASE_CREADA: "layers",
+ TAREA_CREADA: "clipboard-list",
+ TAREA_ASIGNADA: "user-plus",
+ TAREA_COMPLETADA: "check-circle",
+ TAREA_ESTADO_CAMBIADO: "refresh-cw",
+ GASTO_CREADO: "dollar-sign",
+ GASTO_APROBADO: "check",
+ GASTO_RECHAZADO: "x",
+ ORDEN_CREADA: "package",
+ ORDEN_APROBADA: "check",
+ ORDEN_ENVIADA: "send",
+ ORDEN_RECIBIDA: "package-check",
+ AVANCE_REGISTRADO: "trending-up",
+ FOTO_SUBIDA: "camera",
+ BITACORA_REGISTRADA: "book-open",
+ MATERIAL_MOVIMIENTO: "boxes",
+ USUARIO_ASIGNADO: "user-plus",
+ COMENTARIO_AGREGADO: "message-square",
+ DOCUMENTO_SUBIDO: "file-text",
+};
+
+// Colores por tipo
+export const ACTIVIDAD_COLORS: Record = {
+ OBRA_CREADA: "text-blue-500",
+ OBRA_ACTUALIZADA: "text-gray-500",
+ OBRA_ESTADO_CAMBIADO: "text-purple-500",
+ FASE_CREADA: "text-indigo-500",
+ TAREA_CREADA: "text-blue-500",
+ TAREA_ASIGNADA: "text-cyan-500",
+ TAREA_COMPLETADA: "text-green-500",
+ TAREA_ESTADO_CAMBIADO: "text-yellow-500",
+ GASTO_CREADO: "text-orange-500",
+ GASTO_APROBADO: "text-green-500",
+ GASTO_RECHAZADO: "text-red-500",
+ ORDEN_CREADA: "text-purple-500",
+ ORDEN_APROBADA: "text-green-500",
+ ORDEN_ENVIADA: "text-blue-500",
+ ORDEN_RECIBIDA: "text-green-500",
+ AVANCE_REGISTRADO: "text-teal-500",
+ FOTO_SUBIDA: "text-pink-500",
+ BITACORA_REGISTRADA: "text-amber-500",
+ MATERIAL_MOVIMIENTO: "text-gray-500",
+ USUARIO_ASIGNADO: "text-cyan-500",
+ COMENTARIO_AGREGADO: "text-blue-500",
+ DOCUMENTO_SUBIDO: "text-gray-500",
+};
diff --git a/src/lib/portal-auth.ts b/src/lib/portal-auth.ts
new file mode 100644
index 0000000..e41f233
--- /dev/null
+++ b/src/lib/portal-auth.ts
@@ -0,0 +1,68 @@
+import { cookies } from "next/headers";
+import { jwtVerify } from "jose";
+import { prisma } from "@/lib/prisma";
+
+const SECRET = new TextEncoder().encode(
+ process.env.NEXTAUTH_SECRET || "portal-cliente-secret"
+);
+
+export interface PortalSession {
+ clienteAccesoId: string;
+ clienteId: string;
+ permisos: {
+ verFotos: boolean;
+ verAvances: boolean;
+ verGastos: boolean;
+ verDocumentos: boolean;
+ descargarPDF: boolean;
+ };
+}
+
+export async function getPortalSession(): Promise {
+ try {
+ const cookieStore = await cookies();
+ const token = cookieStore.get("portal-token")?.value;
+
+ if (!token) {
+ return null;
+ }
+
+ const { payload } = await jwtVerify(token, SECRET);
+
+ if (payload.type !== "portal") {
+ return null;
+ }
+
+ const acceso = await prisma.clienteAcceso.findUnique({
+ where: { id: payload.clienteAccesoId as string },
+ select: {
+ id: true,
+ clienteId: true,
+ activo: true,
+ verFotos: true,
+ verAvances: true,
+ verGastos: true,
+ verDocumentos: true,
+ descargarPDF: true,
+ },
+ });
+
+ if (!acceso || !acceso.activo) {
+ return null;
+ }
+
+ return {
+ clienteAccesoId: acceso.id,
+ clienteId: acceso.clienteId,
+ permisos: {
+ verFotos: acceso.verFotos,
+ verAvances: acceso.verAvances,
+ verGastos: acceso.verGastos,
+ verDocumentos: acceso.verDocumentos,
+ descargarPDF: acceso.descargarPDF,
+ },
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts
new file mode 100644
index 0000000..7bd0f4a
--- /dev/null
+++ b/src/lib/push-notifications.ts
@@ -0,0 +1,221 @@
+import webpush from "web-push";
+import { prisma } from "@/lib/prisma";
+import { TipoNotificacion } from "@prisma/client";
+
+// Configurar VAPID (generar claves con: npx web-push generate-vapid-keys)
+const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
+const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || "";
+const VAPID_SUBJECT = process.env.VAPID_SUBJECT || "mailto:admin@mexusapp.com";
+
+if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
+ webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
+}
+
+interface NotificationPayload {
+ title: string;
+ body: string;
+ icon?: string;
+ badge?: string;
+ url?: string;
+ tag?: string;
+}
+
+interface CreateNotificationParams {
+ userId: string;
+ tipo: TipoNotificacion;
+ titulo: string;
+ mensaje: string;
+ url?: string;
+ metadata?: Record;
+ sendPush?: boolean;
+}
+
+// Crear una notificación y opcionalmente enviar push
+export async function createNotification({
+ userId,
+ tipo,
+ titulo,
+ mensaje,
+ url,
+ metadata,
+ sendPush = true,
+}: CreateNotificationParams) {
+ // Guardar notificación en BD
+ const notificacion = await prisma.notificacion.create({
+ data: {
+ tipo,
+ titulo,
+ mensaje,
+ url,
+ metadata: metadata ? JSON.stringify(metadata) : null,
+ userId,
+ },
+ });
+
+ // Enviar push si está habilitado
+ if (sendPush && VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
+ await sendPushToUser(userId, {
+ title: titulo,
+ body: mensaje,
+ url: url || "/",
+ tag: `notification-${notificacion.id}`,
+ });
+
+ // Marcar como enviada
+ await prisma.notificacion.update({
+ where: { id: notificacion.id },
+ data: { enviada: true },
+ });
+ }
+
+ return notificacion;
+}
+
+// Enviar push a un usuario específico
+export async function sendPushToUser(userId: string, payload: NotificationPayload) {
+ if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
+ console.warn("VAPID keys not configured, skipping push notification");
+ return { sent: 0, failed: 0 };
+ }
+
+ const subscriptions = await prisma.pushSubscription.findMany({
+ where: {
+ userId,
+ activo: true,
+ },
+ });
+
+ let sent = 0;
+ let failed = 0;
+
+ for (const sub of subscriptions) {
+ try {
+ await webpush.sendNotification(
+ {
+ endpoint: sub.endpoint,
+ keys: {
+ p256dh: sub.p256dh,
+ auth: sub.auth,
+ },
+ },
+ JSON.stringify(payload)
+ );
+ sent++;
+ } catch (error: any) {
+ console.error("Error sending push:", error);
+ failed++;
+
+ // Si la suscripción expiró o es inválida, desactivarla
+ if (error.statusCode === 404 || error.statusCode === 410) {
+ await prisma.pushSubscription.update({
+ where: { id: sub.id },
+ data: { activo: false },
+ });
+ }
+ }
+ }
+
+ return { sent, failed };
+}
+
+// Enviar push a todos los usuarios de una empresa
+export async function sendPushToEmpresa(
+ empresaId: string,
+ payload: NotificationPayload,
+ options?: {
+ excludeUserId?: string;
+ roles?: string[];
+ }
+) {
+ if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
+ console.warn("VAPID keys not configured, skipping push notification");
+ return { sent: 0, failed: 0 };
+ }
+
+ const subscriptions = await prisma.pushSubscription.findMany({
+ where: {
+ activo: true,
+ user: {
+ empresaId,
+ ...(options?.excludeUserId && { id: { not: options.excludeUserId } }),
+ ...(options?.roles && { role: { in: options.roles as any } }),
+ },
+ },
+ });
+
+ let sent = 0;
+ let failed = 0;
+
+ for (const sub of subscriptions) {
+ try {
+ await webpush.sendNotification(
+ {
+ endpoint: sub.endpoint,
+ keys: {
+ p256dh: sub.p256dh,
+ auth: sub.auth,
+ },
+ },
+ JSON.stringify(payload)
+ );
+ sent++;
+ } catch (error: any) {
+ console.error("Error sending push:", error);
+ failed++;
+
+ if (error.statusCode === 404 || error.statusCode === 410) {
+ await prisma.pushSubscription.update({
+ where: { id: sub.id },
+ data: { activo: false },
+ });
+ }
+ }
+ }
+
+ return { sent, failed };
+}
+
+// Notificaciones predefinidas
+export const NotificationTemplates = {
+ tareaAsignada: (tareaName: string, obraName: string) => ({
+ tipo: "TAREA_ASIGNADA" as TipoNotificacion,
+ titulo: "Nueva tarea asignada",
+ mensaje: `Se te ha asignado la tarea "${tareaName}" en ${obraName}`,
+ }),
+
+ tareaCompletada: (tareaName: string, userName: string) => ({
+ tipo: "TAREA_COMPLETADA" as TipoNotificacion,
+ titulo: "Tarea completada",
+ mensaje: `${userName} ha completado la tarea "${tareaName}"`,
+ }),
+
+ gastoPendiente: (concepto: string, monto: number) => ({
+ tipo: "GASTO_PENDIENTE" as TipoNotificacion,
+ titulo: "Nuevo gasto pendiente",
+ mensaje: `Hay un nuevo gasto por aprobar: ${concepto} ($${monto.toLocaleString()})`,
+ }),
+
+ gastoAprobado: (concepto: string) => ({
+ tipo: "GASTO_APROBADO" as TipoNotificacion,
+ titulo: "Gasto aprobado",
+ mensaje: `Tu gasto "${concepto}" ha sido aprobado`,
+ }),
+
+ ordenAprobada: (numero: string) => ({
+ tipo: "ORDEN_APROBADA" as TipoNotificacion,
+ titulo: "Orden de compra aprobada",
+ mensaje: `La orden ${numero} ha sido aprobada`,
+ }),
+
+ avanceRegistrado: (obraName: string, porcentaje: number) => ({
+ tipo: "AVANCE_REGISTRADO" as TipoNotificacion,
+ titulo: "Nuevo avance registrado",
+ mensaje: `Se ha registrado un avance de ${porcentaje}% en ${obraName}`,
+ }),
+
+ alertaInventario: (materialName: string, stockActual: number) => ({
+ tipo: "ALERTA_INVENTARIO" as TipoNotificacion,
+ titulo: "Alerta de inventario",
+ mensaje: `El material "${materialName}" tiene stock bajo (${stockActual} unidades)`,
+ }),
+};
diff --git a/src/types/index.ts b/src/types/index.ts
index c1d7c23..af4d49c 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -8,6 +8,12 @@ import {
EstadoFactura,
TipoMovimiento,
UnidadMedida,
+ CondicionClima,
+ TipoAsistencia,
+ EstadoOrdenCompra,
+ PrioridadOrden,
+ TipoNotificacion,
+ TipoActividad,
} from "@prisma/client";
export type {
@@ -20,6 +26,12 @@ export type {
EstadoFactura,
TipoMovimiento,
UnidadMedida,
+ CondicionClima,
+ TipoAsistencia,
+ EstadoOrdenCompra,
+ PrioridadOrden,
+ TipoNotificacion,
+ TipoActividad,
};
export interface DashboardStats {
@@ -149,3 +161,79 @@ export const TIPO_FACTURA_LABELS: Record = {
EMITIDA: "Emitida",
RECIBIDA: "Recibida",
};
+
+export const CONDICION_CLIMA_LABELS: Record = {
+ SOLEADO: "Soleado",
+ NUBLADO: "Nublado",
+ PARCIALMENTE_NUBLADO: "Parcialmente Nublado",
+ LLUVIA_LIGERA: "Lluvia Ligera",
+ LLUVIA_FUERTE: "Lluvia Fuerte",
+ TORMENTA: "Tormenta",
+ VIENTO_FUERTE: "Viento Fuerte",
+ FRIO_EXTREMO: "Frio Extremo",
+ CALOR_EXTREMO: "Calor Extremo",
+};
+
+export const CONDICION_CLIMA_ICONS: Record = {
+ SOLEADO: "sun",
+ NUBLADO: "cloud",
+ PARCIALMENTE_NUBLADO: "cloud-sun",
+ LLUVIA_LIGERA: "cloud-drizzle",
+ LLUVIA_FUERTE: "cloud-rain",
+ TORMENTA: "cloud-lightning",
+ VIENTO_FUERTE: "wind",
+ FRIO_EXTREMO: "snowflake",
+ CALOR_EXTREMO: "thermometer",
+};
+
+export const TIPO_ASISTENCIA_LABELS: Record = {
+ PRESENTE: "Presente",
+ AUSENTE: "Ausente",
+ RETARDO: "Retardo",
+ PERMISO: "Permiso",
+ INCAPACIDAD: "Incapacidad",
+ VACACIONES: "Vacaciones",
+};
+
+export const TIPO_ASISTENCIA_COLORS: Record = {
+ PRESENTE: "bg-green-100 text-green-800",
+ AUSENTE: "bg-red-100 text-red-800",
+ RETARDO: "bg-yellow-100 text-yellow-800",
+ PERMISO: "bg-blue-100 text-blue-800",
+ INCAPACIDAD: "bg-purple-100 text-purple-800",
+ VACACIONES: "bg-cyan-100 text-cyan-800",
+};
+
+export const ESTADO_ORDEN_COMPRA_LABELS: Record = {
+ BORRADOR: "Borrador",
+ PENDIENTE: "Pendiente",
+ APROBADA: "Aprobada",
+ ENVIADA: "Enviada",
+ RECIBIDA_PARCIAL: "Recibida Parcial",
+ RECIBIDA: "Recibida",
+ CANCELADA: "Cancelada",
+};
+
+export const ESTADO_ORDEN_COMPRA_COLORS: Record = {
+ BORRADOR: "bg-gray-100 text-gray-800",
+ PENDIENTE: "bg-yellow-100 text-yellow-800",
+ APROBADA: "bg-blue-100 text-blue-800",
+ ENVIADA: "bg-purple-100 text-purple-800",
+ RECIBIDA_PARCIAL: "bg-orange-100 text-orange-800",
+ RECIBIDA: "bg-green-100 text-green-800",
+ CANCELADA: "bg-red-100 text-red-800",
+};
+
+export const PRIORIDAD_ORDEN_LABELS: Record = {
+ BAJA: "Baja",
+ NORMAL: "Normal",
+ ALTA: "Alta",
+ URGENTE: "Urgente",
+};
+
+export const PRIORIDAD_ORDEN_COLORS: Record = {
+ BAJA: "bg-gray-100 text-gray-800",
+ NORMAL: "bg-blue-100 text-blue-800",
+ ALTA: "bg-orange-100 text-orange-800",
+ URGENTE: "bg-red-100 text-red-800",
+};