Compare commits
2 Commits
c52bc905ca
...
544bbf38a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 544bbf38a9 | |||
| 43959056f8 |
Generated
+384
@@ -20,6 +20,7 @@
|
|||||||
"astro": "^7.2.2",
|
"astro": "^7.2.2",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"tailwindcss": "^4.3.3"
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1896,6 +1897,32 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
||||||
@@ -2244,6 +2271,18 @@
|
|||||||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.112.3",
|
"version": "2.112.3",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.3.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.3.tgz",
|
||||||
@@ -2951,6 +2990,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -3017,6 +3119,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
|
||||||
@@ -3535,6 +3643,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -3552,6 +3781,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/defu": {
|
"node_modules/defu": {
|
||||||
"version": "6.1.7",
|
"version": "6.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
@@ -3747,6 +3982,18 @@
|
|||||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.51.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz",
|
||||||
|
"integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks",
|
||||||
|
"tests/types",
|
||||||
|
"tests/browser-compat"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
@@ -4149,12 +4396,31 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
||||||
|
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iron-webcrypto": {
|
"node_modules/iron-webcrypto": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
|
||||||
@@ -5039,6 +5305,36 @@
|
|||||||
"react": "^19.2.8"
|
"react": "^19.2.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
|
||||||
|
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -5061,6 +5357,51 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^11.1.8",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.2.0",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/regex": {
|
"node_modules/regex": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||||
@@ -5085,6 +5426,12 @@
|
|||||||
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
|
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve-pkg-maps": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
@@ -5429,6 +5776,12 @@
|
|||||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyclip": {
|
"node_modules/tinyclip": {
|
||||||
"version": "0.1.15",
|
"version": "0.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz",
|
||||||
@@ -5749,6 +6102,15 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vfile": {
|
"node_modules/vfile": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
@@ -5791,6 +6153,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.2.1",
|
"version": "8.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"astro": "^7.2.2",
|
"astro": "^7.2.2",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"tailwindcss": "^4.3.3"
|
"tailwindcss": "^4.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { browserClient } from '@/lib/supabase';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
function setBadge(n: number) {
|
||||||
|
const v = Math.max(0, n | 0);
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
|
||||||
|
el.textContent = String(v);
|
||||||
|
if (v > 0) el.removeAttribute('hidden');
|
||||||
|
else el.setAttribute('hidden', '');
|
||||||
|
el.setAttribute('aria-label', `${v} solicitudes pendientes`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBadge(): number {
|
||||||
|
const el = document.querySelector<HTMLElement>('[data-badge="pendientes"]');
|
||||||
|
const n = parseInt(el?.textContent ?? '0', 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BadgeSolicitudes() {
|
||||||
|
useEffect(() => {
|
||||||
|
const supabase = browserClient();
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('solicitudes')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('estado', 'pendiente');
|
||||||
|
setBadge(count ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel('solicitudes-pendientes')
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'INSERT', schema: 'prestamos', table: 'solicitudes', filter: 'estado=eq.pendiente' },
|
||||||
|
() => {
|
||||||
|
setBadge(readBadge() + 1);
|
||||||
|
toast({ title: 'Nueva solicitud pendiente', kind: 'info' });
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.on(
|
||||||
|
// ponytail: refetch en UPDATE porque payload.old no trae estado en replica identity default;
|
||||||
|
// upgrade a delta cuando la tabla tenga replica identity full.
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'UPDATE', schema: 'prestamos', table: 'solicitudes' },
|
||||||
|
() => { refetch(); },
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LabelList,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
type Barras = { tipo: 'barras'; datos: { label: string; valor: number }[]; alto?: number };
|
||||||
|
type Linea = { tipo: 'linea'; datos: { fecha: string; valor: number }[]; alto?: number };
|
||||||
|
type Donut = { tipo: 'donut'; datos: { label: string; valor: number }[]; alto?: number };
|
||||||
|
type Props = Barras | Linea | Donut;
|
||||||
|
|
||||||
|
const INK = '#383838';
|
||||||
|
const PENCIL = '#a1a1a1';
|
||||||
|
const PRIMARY = '#00723F';
|
||||||
|
const SECONDARY = '#DD971A';
|
||||||
|
const DANGER = '#a83224';
|
||||||
|
const POSITIVE = '#38c1b0';
|
||||||
|
const ATTENTION = '#ffde00';
|
||||||
|
|
||||||
|
const MONO: React.CSSProperties = {
|
||||||
|
fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace",
|
||||||
|
fontSize: 11,
|
||||||
|
color: INK,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Paleta rotativa para el donut. Coloreado por label cuando aplique.
|
||||||
|
const DONUT_BY_LABEL: Record<string, string> = {
|
||||||
|
pendiente: ATTENTION,
|
||||||
|
aprobado: PRIMARY,
|
||||||
|
activo: PRIMARY,
|
||||||
|
devuelto: POSITIVE,
|
||||||
|
rechazado: PENCIL,
|
||||||
|
vencido: DANGER,
|
||||||
|
};
|
||||||
|
const DONUT_FALLBACK = [PRIMARY, SECONDARY, POSITIVE, ATTENTION, PENCIL, DANGER];
|
||||||
|
|
||||||
|
function donutColor(label: string, i: number): string {
|
||||||
|
return DONUT_BY_LABEL[label.toLowerCase()] ?? DONUT_FALLBACK[i % DONUT_FALLBACK.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipBox({ active, payload, label }: any) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
border: `2px solid ${INK}`,
|
||||||
|
borderRadius: 2,
|
||||||
|
padding: '6px 10px',
|
||||||
|
...MONO,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label !== undefined && <div style={{ fontWeight: 600 }}>{label}</div>}
|
||||||
|
{payload.map((p: any, i: number) => (
|
||||||
|
<div key={i}>
|
||||||
|
{p.name}: <strong>{p.value}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePrefersReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
setReduced(mq.matches);
|
||||||
|
const on = () => setReduced(mq.matches);
|
||||||
|
mq.addEventListener?.('change', on);
|
||||||
|
return () => mq.removeEventListener?.('change', on);
|
||||||
|
}, []);
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFechaCorta(iso: string): string {
|
||||||
|
// 'YYYY-MM-DD' → 'DD/MM'
|
||||||
|
const [, m, d] = iso.split('-');
|
||||||
|
return `${d}/${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chart(props: Props) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const anim = reduced ? 0 : 400;
|
||||||
|
const alto = props.alto ?? 260;
|
||||||
|
|
||||||
|
if (props.tipo === 'barras') {
|
||||||
|
// Horizontal para que quepan nombres largos de material.
|
||||||
|
const datos = useMemo(
|
||||||
|
() => props.datos.slice().sort((a, b) => a.valor - b.valor),
|
||||||
|
[props.datos],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={alto}>
|
||||||
|
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
|
||||||
|
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" horizontal={false} />
|
||||||
|
<XAxis type="number" allowDecimals={false} stroke={INK} tick={MONO as any} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="label"
|
||||||
|
width={140}
|
||||||
|
stroke={INK}
|
||||||
|
tick={{ ...MONO, fontSize: 10 } as any}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<TooltipBox />} cursor={{ fill: 'rgba(56,56,56,0.06)' }} />
|
||||||
|
<Bar dataKey="valor" fill={PRIMARY} stroke={INK} strokeWidth={1.5} animationDuration={anim} name="Solicitado">
|
||||||
|
<LabelList dataKey="valor" position="right" style={MONO as any} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.tipo === 'linea') {
|
||||||
|
const datos = props.datos.map((d) => ({ ...d, fechaCorta: fmtFechaCorta(d.fecha) }));
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={alto}>
|
||||||
|
<LineChart data={datos} margin={{ top: 8, right: 16, left: 0, bottom: 8 }}>
|
||||||
|
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" />
|
||||||
|
<XAxis dataKey="fechaCorta" stroke={INK} tick={MONO as any} interval="preserveStartEnd" />
|
||||||
|
<YAxis allowDecimals={false} stroke={INK} tick={MONO as any} />
|
||||||
|
<Tooltip content={<TooltipBox />} cursor={{ stroke: INK, strokeWidth: 1, strokeDasharray: '2 3' }} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="valor"
|
||||||
|
stroke={PRIMARY}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: PRIMARY, stroke: INK, strokeWidth: 1.5, r: 3 }}
|
||||||
|
activeDot={{ fill: SECONDARY, stroke: INK, strokeWidth: 1.5, r: 5 }}
|
||||||
|
animationDuration={anim}
|
||||||
|
name="Solicitudes"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// donut
|
||||||
|
const datos = props.datos;
|
||||||
|
const total = datos.reduce((s, d) => s + d.valor, 0);
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={alto}>
|
||||||
|
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
|
||||||
|
<Tooltip content={<TooltipBox />} />
|
||||||
|
<Pie
|
||||||
|
data={datos}
|
||||||
|
dataKey="valor"
|
||||||
|
nameKey="label"
|
||||||
|
innerRadius="55%"
|
||||||
|
outerRadius="85%"
|
||||||
|
stroke={INK}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
isAnimationActive={!reduced}
|
||||||
|
animationDuration={anim}
|
||||||
|
label={({ label, valor }: any) => (total ? `${label} (${valor})` : label)}
|
||||||
|
labelLine={{ stroke: PENCIL }}
|
||||||
|
>
|
||||||
|
{datos.map((d, i) => (
|
||||||
|
<Cell key={d.label} fill={donutColor(d.label, i)} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { browserClient } from '@/lib/supabase';
|
||||||
|
import { imgUrl } from '@/lib/materialImg';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import UnidadesManager from './UnidadesManager';
|
||||||
|
|
||||||
type Categoria = { id: number; nombre: string };
|
type Categoria = { id: number; nombre: string };
|
||||||
type Material = {
|
type Material = {
|
||||||
@@ -10,6 +14,8 @@ type Material = {
|
|||||||
cantidad_disponible: number;
|
cantidad_disponible: number;
|
||||||
numero_inventario: string | null;
|
numero_inventario: string | null;
|
||||||
estado: 'disponible' | 'mantenimiento' | 'baja';
|
estado: 'disponible' | 'mantenimiento' | 'baja';
|
||||||
|
imagen_path?: string | null;
|
||||||
|
trackeado_por_unidad?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -18,10 +24,13 @@ type Props = {
|
|||||||
categorias: Categoria[];
|
categorias: Categoria[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BUCKET = 'materiales-fotos';
|
||||||
|
|
||||||
export default function MaterialForm({ mode, material, categorias }: Props) {
|
export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const [nombre, setNombre] = useState(material?.nombre ?? '');
|
const [nombre, setNombre] = useState(material?.nombre ?? '');
|
||||||
const [categoriaId, setCategoriaId] = useState<string>(
|
const [categoriaId, setCategoriaId] = useState<string>(
|
||||||
@@ -33,11 +42,26 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
);
|
);
|
||||||
const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? '');
|
const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? '');
|
||||||
const [estado, setEstado] = useState<Material['estado']>(material?.estado ?? 'disponible');
|
const [estado, setEstado] = useState<Material['estado']>(material?.estado ?? 'disponible');
|
||||||
|
const [trackeado, setTrackeado] = useState<boolean>(material?.trackeado_por_unidad ?? false);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [imagenPath, setImagenPath] = useState<string | null>(material?.imagen_path ?? null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const titleId = `mat-form-${mode}-${material?.id ?? 'new'}`;
|
const titleId = `mat-form-${mode}-${material?.id ?? 'new'}`;
|
||||||
|
|
||||||
|
// preview URL con revoke en cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file) {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setPreviewUrl(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
@@ -47,6 +71,9 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
setCantidadTotal('0');
|
setCantidadTotal('0');
|
||||||
setNumeroInventario('');
|
setNumeroInventario('');
|
||||||
setEstado('disponible');
|
setEstado('disponible');
|
||||||
|
setTrackeado(false);
|
||||||
|
setFile(null);
|
||||||
|
setImagenPath(null);
|
||||||
}
|
}
|
||||||
dialogRef.current?.showModal();
|
dialogRef.current?.showModal();
|
||||||
queueMicrotask(() => firstFieldRef.current?.focus());
|
queueMicrotask(() => firstFieldRef.current?.focus());
|
||||||
@@ -68,49 +95,101 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) errorRef.current?.focus();
|
||||||
errorRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
async function uploadFotoFor(id: number): Promise<string | null> {
|
||||||
|
if (!file) return null;
|
||||||
|
const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const path = `${id}/${Date.now()}.${ext || 'jpg'}`;
|
||||||
|
const { error: upErr } = await browserClient()
|
||||||
|
.storage.from(BUCKET)
|
||||||
|
.upload(path, file, { upsert: true, contentType: file.type || undefined });
|
||||||
|
if (upErr) {
|
||||||
|
toast({ kind: 'error', title: 'La foto no se subió', description: 'El material se guardó sin foto.' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const payload = {
|
const nombreT = nombre.trim();
|
||||||
nombre: nombre.trim(),
|
if (!nombreT) {
|
||||||
categoria_id: categoriaId === '' ? null : Number(categoriaId),
|
|
||||||
descripcion: descripcion.trim() || null,
|
|
||||||
cantidad_total: Number(cantidadTotal),
|
|
||||||
numero_inventario: numeroInventario.trim() || null,
|
|
||||||
estado,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!payload.nombre) {
|
|
||||||
setError('El nombre es obligatorio');
|
setError('El nombre es obligatorio');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(payload.cantidad_total) || payload.cantidad_total < 0) {
|
const cantidad = Number(cantidadTotal);
|
||||||
|
if (!trackeado && (!Number.isInteger(cantidad) || cantidad < 0)) {
|
||||||
setError('La cantidad total debe ser un entero mayor o igual a 0');
|
setError('La cantidad total debe ser un entero mayor o igual a 0');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url =
|
|
||||||
mode === 'create' ? '/api/admin/materiales' : `/api/admin/materiales/${material!.id}`;
|
|
||||||
const method = mode === 'create' ? 'POST' : 'PATCH';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
if (mode === 'create') {
|
||||||
method,
|
// 1) crear material sin imagen
|
||||||
|
const res = await fetch('/api/admin/materiales', {
|
||||||
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({
|
||||||
|
nombre: nombreT,
|
||||||
|
categoria_id: categoriaId === '' ? null : Number(categoriaId),
|
||||||
|
descripcion: descripcion.trim() || null,
|
||||||
|
cantidad_total: trackeado ? 0 : cantidad,
|
||||||
|
numero_inventario: numeroInventario.trim() || null,
|
||||||
|
estado,
|
||||||
|
trackeado_por_unidad: trackeado,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(json?.error ?? 'No se pudo crear');
|
||||||
|
|
||||||
|
// 2) subir foto (si hay) y PATCH imagen_path
|
||||||
|
if (file && json.id) {
|
||||||
|
const path = await uploadFotoFor(json.id);
|
||||||
|
if (path) {
|
||||||
|
const p = await fetch(`/api/admin/materiales/${json.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ imagen_path: path }),
|
||||||
|
});
|
||||||
|
if (!p.ok) {
|
||||||
|
toast({ kind: 'error', title: 'Foto subida pero no vinculada', description: 'Recarga y vuelve a intentar.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// edit — sube nueva foto primero (si hay) y luego PATCH con el path incluido
|
||||||
|
let newImagenPath: string | null | undefined = undefined;
|
||||||
|
if (file && material) {
|
||||||
|
const path = await uploadFotoFor(material.id);
|
||||||
|
if (path) newImagenPath = path;
|
||||||
|
}
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
nombre: nombreT,
|
||||||
|
categoria_id: categoriaId === '' ? null : Number(categoriaId),
|
||||||
|
descripcion: descripcion.trim() || null,
|
||||||
|
numero_inventario: numeroInventario.trim() || null,
|
||||||
|
estado,
|
||||||
|
trackeado_por_unidad: trackeado,
|
||||||
|
};
|
||||||
|
if (!trackeado) patch.cantidad_total = cantidad;
|
||||||
|
if (newImagenPath !== undefined) patch.imagen_path = newImagenPath;
|
||||||
|
const res = await fetch(`/api/admin/materiales/${material!.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
});
|
});
|
||||||
const json = await res.json().catch(() => ({}));
|
const json = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
||||||
|
}
|
||||||
|
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
location.reload();
|
location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -123,6 +202,8 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
|
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
|
||||||
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
|
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
|
||||||
|
|
||||||
|
const currentImg = previewUrl ?? imgUrl(imagenPath);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" className={triggerClass} onClick={open}>
|
<button type="button" className={triggerClass} onClick={open}>
|
||||||
@@ -132,7 +213,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,32rem)]"
|
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(94vw,36rem)]"
|
||||||
>
|
>
|
||||||
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
|
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -151,10 +232,53 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Foto */}
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-nombre`}>
|
<label className="label" htmlFor={`${titleId}-file`}>Foto</label>
|
||||||
Nombre
|
<div className="flex items-start gap-3">
|
||||||
</label>
|
<div
|
||||||
|
className="w-24 h-24 shrink-0 grid place-items-center bg-[color:var(--color-chalk)]"
|
||||||
|
style={{ border: '2px solid var(--color-ink)' }}
|
||||||
|
>
|
||||||
|
{currentImg ? (
|
||||||
|
<img src={currentImg} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||||
|
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
||||||
|
<circle cx="8" cy="9" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
id={`${titleId}-file`}
|
||||||
|
className="input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{(file || imagenPath) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost self-start"
|
||||||
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
|
onClick={() => {
|
||||||
|
setFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
if (imagenPath && mode === 'edit') setImagenPath(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quitar foto
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor={`${titleId}-nombre`}>Nombre</label>
|
||||||
<input
|
<input
|
||||||
ref={firstFieldRef}
|
ref={firstFieldRef}
|
||||||
id={`${titleId}-nombre`}
|
id={`${titleId}-nombre`}
|
||||||
@@ -171,9 +295,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-cat`}>
|
<label className="label" htmlFor={`${titleId}-cat`}>Categoría</label>
|
||||||
Categoría
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
id={`${titleId}-cat`}
|
id={`${titleId}-cat`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -182,17 +304,13 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
>
|
>
|
||||||
<option value="">Sin categoría</option>
|
<option value="">Sin categoría</option>
|
||||||
{categorias.map((c) => (
|
{categorias.map((c) => (
|
||||||
<option key={c.id} value={String(c.id)}>
|
<option key={c.id} value={String(c.id)}>{c.nombre}</option>
|
||||||
{c.nombre}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-inv`}>
|
<label className="label" htmlFor={`${titleId}-inv`}>Nº de inventario</label>
|
||||||
Nº de inventario
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id={`${titleId}-inv`}
|
id={`${titleId}-inv`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -206,9 +324,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-desc`}>
|
<label className="label" htmlFor={`${titleId}-desc`}>Descripción</label>
|
||||||
Descripción
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id={`${titleId}-desc`}
|
id={`${titleId}-desc`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -221,11 +337,24 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trackeado}
|
||||||
|
onChange={(e) => setTrackeado(e.target.checked)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Rastrear cada unidad por separado</span>
|
||||||
|
<span className="block opacity-70 text-xs">
|
||||||
|
Ej. Laptop-01, Laptop-02… El stock se calcula del número de unidades registradas abajo.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-total`}>
|
<label className="label" htmlFor={`${titleId}-total`}>Cantidad total</label>
|
||||||
Cantidad total
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id={`${titleId}-total`}
|
id={`${titleId}-total`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -233,12 +362,17 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={cantidadTotal}
|
value={trackeado ? (material?.cantidad_total ?? 0) : cantidadTotal}
|
||||||
onChange={(e) => setCantidadTotal(e.target.value)}
|
onChange={(e) => setCantidadTotal(e.target.value)}
|
||||||
required
|
required={!trackeado}
|
||||||
|
disabled={trackeado}
|
||||||
|
readOnly={trackeado}
|
||||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||||
/>
|
/>
|
||||||
{mode === 'edit' && material && (
|
{trackeado && (
|
||||||
|
<p className="text-xs opacity-70 mt-1">Se calcula del número de unidades.</p>
|
||||||
|
)}
|
||||||
|
{!trackeado && mode === 'edit' && material && (
|
||||||
<p className="text-xs opacity-60 mt-1">
|
<p className="text-xs opacity-60 mt-1">
|
||||||
Prestados: {material.cantidad_total - material.cantidad_disponible}
|
Prestados: {material.cantidad_total - material.cantidad_disponible}
|
||||||
</p>
|
</p>
|
||||||
@@ -246,9 +380,7 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor={`${titleId}-estado`}>
|
<label className="label" htmlFor={`${titleId}-estado`}>Estado</label>
|
||||||
Estado
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
id={`${titleId}-estado`}
|
id={`${titleId}-estado`}
|
||||||
className="input"
|
className="input"
|
||||||
@@ -284,6 +416,16 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Gestor de unidades — solo edit + trackeado */}
|
||||||
|
{mode === 'edit' && material && material.trackeado_por_unidad && (
|
||||||
|
<div
|
||||||
|
className="border-t-2 p-5 sm:p-6"
|
||||||
|
style={{ borderColor: 'var(--color-ink)', background: 'var(--color-chalk)' }}
|
||||||
|
>
|
||||||
|
<UnidadesManager materialId={material.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</dialog>
|
</dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
type Unidad = {
|
||||||
|
id: number;
|
||||||
|
etiqueta: string;
|
||||||
|
estado: 'disponible' | 'prestado' | 'mantenimiento' | 'baja';
|
||||||
|
notas: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESTADOS = ['disponible', 'mantenimiento', 'baja'] as const;
|
||||||
|
|
||||||
|
export default function UnidadesManager({ materialId }: { materialId: number }) {
|
||||||
|
const [unidades, setUnidades] = useState<Unidad[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [nuevaEtiqueta, setNuevaEtiqueta] = useState('');
|
||||||
|
const [creando, setCreando] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/materiales/${materialId}/unidades`);
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) throw new Error(j?.error ?? 'Error al cargar');
|
||||||
|
setUnidades(j.unidades ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudieron cargar las unidades', description: e instanceof Error ? e.message : '' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [materialId]);
|
||||||
|
|
||||||
|
const crear = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const et = nuevaEtiqueta.trim();
|
||||||
|
if (!et || creando) return;
|
||||||
|
setCreando(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/materiales/${materialId}/unidades`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ etiqueta: et }),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) throw new Error(j?.error ?? 'No se pudo crear');
|
||||||
|
setNuevaEtiqueta('');
|
||||||
|
await load();
|
||||||
|
toast({ kind: 'success', title: 'Unidad agregada', description: et });
|
||||||
|
} catch (e) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudo agregar', description: e instanceof Error ? e.message : '' });
|
||||||
|
} finally {
|
||||||
|
setCreando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = async (u: Unidad, body: Partial<Pick<Unidad, 'etiqueta' | 'estado' | 'notas'>>) => {
|
||||||
|
const res = await fetch(`/api/admin/materiales/unidades/${u.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudo actualizar', description: j?.error });
|
||||||
|
await load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUnidades((prev) => prev.map((x) => (x.id === u.id ? { ...x, ...body } as Unidad : x)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const eliminar = async (u: Unidad) => {
|
||||||
|
if (!confirm(`Eliminar unidad "${u.etiqueta}"?`)) return;
|
||||||
|
const res = await fetch(`/api/admin/materiales/unidades/${u.id}`, { method: 'DELETE' });
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudo eliminar', description: j?.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUnidades((prev) => prev.filter((x) => x.id !== u.id));
|
||||||
|
toast({ kind: 'success', title: 'Unidad eliminada' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3">Unidades individuales</h3>
|
||||||
|
|
||||||
|
<form onSubmit={crear} className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
type="text"
|
||||||
|
placeholder="Etiqueta (ej. Laptop-01)"
|
||||||
|
value={nuevaEtiqueta}
|
||||||
|
onChange={(e) => setNuevaEtiqueta(e.target.value)}
|
||||||
|
maxLength={80}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={creando || !nuevaEtiqueta.trim()}
|
||||||
|
style={{ minHeight: '44px' }}
|
||||||
|
>
|
||||||
|
+ Agregar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm opacity-70">Cargando…</p>
|
||||||
|
) : unidades.length === 0 ? (
|
||||||
|
<p className="text-sm opacity-70">Sin unidades registradas.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead
|
||||||
|
className="text-left"
|
||||||
|
style={{ background: 'color-mix(in oklab, var(--color-ink) 4%, transparent)' }}
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2 font-medium">Etiqueta</th>
|
||||||
|
<th className="px-2 py-2 font-medium">Estado</th>
|
||||||
|
<th className="px-2 py-2 font-medium">Notas</th>
|
||||||
|
<th className="px-2 py-2 font-medium text-right">Acción</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{unidades.map((u) => {
|
||||||
|
const bloqueada = u.estado === 'prestado';
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={u.id}
|
||||||
|
className="border-t"
|
||||||
|
style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 12%, transparent)' }}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
|
defaultValue={u.etiqueta}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const v = e.target.value.trim();
|
||||||
|
if (v && v !== u.etiqueta) patch(u, { etiqueta: v });
|
||||||
|
else e.target.value = u.etiqueta;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
|
value={bloqueada ? 'prestado' : u.estado}
|
||||||
|
disabled={bloqueada}
|
||||||
|
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
|
||||||
|
>
|
||||||
|
{bloqueada && <option value="prestado">Prestado</option>}
|
||||||
|
{ESTADOS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s[0].toUpperCase() + s.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
|
defaultValue={u.notas ?? ''}
|
||||||
|
placeholder="Opcional…"
|
||||||
|
onBlur={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
|
disabled={bloqueada}
|
||||||
|
onClick={() => eliminar(u)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast, toastAfterReload } from '@/lib/toast';
|
||||||
|
|
||||||
|
type Unidad = { id: number; etiqueta: string };
|
||||||
type Item = {
|
type Item = {
|
||||||
|
id: number;
|
||||||
cantidad: number;
|
cantidad: number;
|
||||||
descripcion: string | null;
|
descripcion: string | null;
|
||||||
material: { nombre: string; numero_inventario: string | null } | null;
|
material: { id: number; nombre: string; numero_inventario: string | null; trackeado_por_unidad?: boolean } | null;
|
||||||
|
material_unidad: Unidad | null;
|
||||||
};
|
};
|
||||||
type Detalles = {
|
type Detalles = {
|
||||||
prestamo: {
|
prestamo: {
|
||||||
@@ -23,6 +27,117 @@ type Detalles = {
|
|||||||
|
|
||||||
const fmt = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
const fmt = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
|
||||||
|
const ERROR_TXT: Record<string, string> = {
|
||||||
|
no_autorizado: 'No autorizado',
|
||||||
|
item_no_existe: 'El renglón ya no existe',
|
||||||
|
solicitud_no_activa: 'La solicitud no está activa',
|
||||||
|
item_sin_unidad: 'Este renglón no tiene unidad asignada',
|
||||||
|
unidad_no_existe: 'La unidad seleccionada no existe',
|
||||||
|
unidad_de_otro_material: 'La unidad no corresponde a este material',
|
||||||
|
unidad_no_disponible: 'La unidad ya no está disponible',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReasignarUnidad({
|
||||||
|
solicitudId,
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
solicitudId: number;
|
||||||
|
item: Item;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [unidades, setUnidades] = useState<Unidad[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<string>('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const abrir = async () => {
|
||||||
|
setOpen(true);
|
||||||
|
if (unidades.length || !item.material) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/materiales/${item.material.id}/unidades?estado=disponible`);
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j?.error ?? 'Error');
|
||||||
|
setUnidades(j.unidades ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
toast({ kind: 'error', title: 'No se pudieron cargar unidades', description: e instanceof Error ? e.message : '' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmar = async () => {
|
||||||
|
if (!selected || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/solicitudes/${solicitudId}/reasignar-unidad`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ item_id: item.id, nueva_unidad_id: Number(selected) }),
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) {
|
||||||
|
const key = String(j?.error ?? '');
|
||||||
|
toast({ kind: 'error', title: ERROR_TXT[key] ?? 'No se pudo reasignar' });
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toastAfterReload({ kind: 'success', title: 'Unidad reasignada' });
|
||||||
|
location.reload();
|
||||||
|
} catch {
|
||||||
|
toast({ kind: 'error', title: 'Error inesperado' });
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ minHeight: '30px', paddingBlock: '0.15rem', fontSize: '0.75rem' }}
|
||||||
|
onClick={abrir}
|
||||||
|
>
|
||||||
|
Cambiar unidad
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
||||||
|
value={selected}
|
||||||
|
onChange={(e) => setSelected(e.target.value)}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
<option value="">{loading ? 'Cargando…' : 'Selecciona unidad…'}</option>
|
||||||
|
{unidades.map((u) => (
|
||||||
|
<option key={u.id} value={String(u.id)}>{u.etiqueta}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
||||||
|
onClick={confirmar}
|
||||||
|
disabled={!selected || saving}
|
||||||
|
>
|
||||||
|
{saving ? '…' : 'Confirmar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
const [data, setData] = useState<Detalles | null>(null);
|
const [data, setData] = useState<Detalles | null>(null);
|
||||||
@@ -53,6 +168,8 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canReasignar = data && ['aprobado', 'activo'].includes(data.prestamo.estado);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
|
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
|
||||||
@@ -83,12 +200,21 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
|||||||
<dd className="col-span-2">{data.prestamo.maestro_responsable}</dd>
|
<dd className="col-span-2">{data.prestamo.maestro_responsable}</dd>
|
||||||
<dt className="opacity-70">Materiales</dt>
|
<dt className="opacity-70">Materiales</dt>
|
||||||
<dd className="col-span-2">
|
<dd className="col-span-2">
|
||||||
<ul className="list-disc pl-4">
|
<ul className="list-disc pl-4 space-y-2">
|
||||||
{data.prestamo.items.map((i, idx) => (
|
{data.prestamo.items.map((i) => (
|
||||||
<li key={idx}>
|
<li key={i.id}>
|
||||||
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
|
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
|
||||||
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
|
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
|
||||||
|
{i.material_unidad?.etiqueta && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="opacity-70">Unidad: </span>
|
||||||
|
<span className="font-semibold">{i.material_unidad.etiqueta}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
|
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
|
||||||
|
{canReasignar && i.material_unidad && (
|
||||||
|
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
// ponytail: coordinar via window para evitar Context entre 2 islands hermanas
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__labreFiltrar?: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||||
|
|
||||||
|
function unify() {
|
||||||
|
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
|
||||||
|
let anyVisible = false;
|
||||||
|
cards.forEach((el) => {
|
||||||
|
const hidden = el.dataset.hiddenByCat === 'true' || el.dataset.hiddenBySearch === 'true';
|
||||||
|
el.hidden = hidden;
|
||||||
|
if (!hidden) anyVisible = true;
|
||||||
|
});
|
||||||
|
const empty = document.getElementById('catalogo-empty-filter');
|
||||||
|
if (empty) empty.hidden = anyVisible || cards.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyText(value: string) {
|
||||||
|
const needle = norm(value.trim());
|
||||||
|
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
|
||||||
|
cards.forEach((el) => {
|
||||||
|
if (!needle) {
|
||||||
|
delete el.dataset.hiddenBySearch;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nombre = norm(el.dataset.nombre ?? '');
|
||||||
|
const inv = norm(el.dataset.numeroInventario ?? '');
|
||||||
|
const match = nombre.includes(needle) || (!!inv && inv.includes(needle));
|
||||||
|
if (match) delete el.dataset.hiddenBySearch;
|
||||||
|
else el.dataset.hiddenBySearch = 'true';
|
||||||
|
});
|
||||||
|
unify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuscadorCatalogo({ q: qInitial = '' }: { q?: string }) {
|
||||||
|
const [q, setQ] = useState(qInitial);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.__labreFiltrar = unify;
|
||||||
|
applyText(qInitial);
|
||||||
|
return () => {
|
||||||
|
window.__labreFiltrar = undefined;
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncUrl = (value: string) => {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
const clean = value.trim();
|
||||||
|
if (clean) url.searchParams.set('q', clean);
|
||||||
|
else url.searchParams.delete('q');
|
||||||
|
history.replaceState(null, '', url.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChange = (value: string) => {
|
||||||
|
setQ(value);
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
applyText(value);
|
||||||
|
syncUrl(value);
|
||||||
|
}, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
setQ('');
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||||
|
applyText('');
|
||||||
|
syncUrl('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label sr-only" htmlFor="buscador-catalogo">
|
||||||
|
Buscar material
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="buscador-catalogo"
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Buscar por nombre o número de inventario…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ paddingRight: q ? '2.5rem' : undefined }}
|
||||||
|
/>
|
||||||
|
{q && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
aria-label="Limpiar búsqueda"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-[2px] hover:bg-black/5 transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,12 +10,21 @@ function applyFilter(value: string) {
|
|||||||
cards.forEach((el) => {
|
cards.forEach((el) => {
|
||||||
const cat = el.dataset.cat;
|
const cat = el.dataset.cat;
|
||||||
const show = value === ALL || cat === value;
|
const show = value === ALL || cat === value;
|
||||||
el.hidden = !show;
|
if (show) delete el.dataset.hiddenByCat;
|
||||||
|
else el.dataset.hiddenByCat = 'true';
|
||||||
|
});
|
||||||
|
// El buscador (BuscadorCatalogo, client:load) define este helper y unifica
|
||||||
|
// ambos flags (cat + search). Fallback si aún no montó: aplicar directo.
|
||||||
|
if (typeof window !== 'undefined' && window.__labreFiltrar) {
|
||||||
|
window.__labreFiltrar();
|
||||||
|
} else {
|
||||||
|
let anyVisible = false;
|
||||||
|
cards.forEach((el) => {
|
||||||
|
el.hidden = el.dataset.hiddenByCat === 'true';
|
||||||
|
if (!el.hidden) anyVisible = true;
|
||||||
});
|
});
|
||||||
const empty = document.getElementById('catalogo-empty-filter');
|
const empty = document.getElementById('catalogo-empty-filter');
|
||||||
if (empty) {
|
if (empty) empty.hidden = anyVisible || cards.length === 0;
|
||||||
const anyVisible = Array.from(cards).some((el) => !el.hidden);
|
|
||||||
empty.hidden = anyVisible || cards.length === 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +71,8 @@ export default function FiltroCategorias({ categorias }: Props) {
|
|||||||
aria-selected={active === ALL}
|
aria-selected={active === ALL}
|
||||||
className={chipClass(ALL)}
|
className={chipClass(ALL)}
|
||||||
style={chipStyle(ALL)}
|
style={chipStyle(ALL)}
|
||||||
|
data-cat-value={ALL}
|
||||||
|
data-cat-active={active === ALL ? 'true' : undefined}
|
||||||
onClick={() => pick(ALL)}
|
onClick={() => pick(ALL)}
|
||||||
>
|
>
|
||||||
Todas
|
Todas
|
||||||
@@ -76,6 +87,8 @@ export default function FiltroCategorias({ categorias }: Props) {
|
|||||||
aria-selected={active === v}
|
aria-selected={active === v}
|
||||||
className={chipClass(v)}
|
className={chipClass(v)}
|
||||||
style={chipStyle(v)}
|
style={chipStyle(v)}
|
||||||
|
data-cat-value={v}
|
||||||
|
data-cat-active={active === v ? 'true' : undefined}
|
||||||
onClick={() => pick(v)}
|
onClick={() => pick(v)}
|
||||||
>
|
>
|
||||||
{c.nombre}
|
{c.nombre}
|
||||||
|
|||||||
@@ -174,7 +174,13 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
|||||||
<CartContext.Provider value={value}>
|
<CartContext.Provider value={value}>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{materiales.map((m) => (
|
{materiales.map((m) => (
|
||||||
<article key={m.id} className="card flex flex-col gap-3" data-cat={m.categoria?.id ?? 'sin'}>
|
<article
|
||||||
|
key={m.id}
|
||||||
|
className="card flex flex-col gap-3"
|
||||||
|
data-cat={m.categoria?.id ?? 'sin'}
|
||||||
|
data-nombre={m.nombre}
|
||||||
|
data-numero-inventario={m.numero_inventario ?? ''}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
|
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
|
||||||
{m.categoria && (
|
{m.categoria && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from './Layout.astro';
|
import Layout from './Layout.astro';
|
||||||
import Toaster from '@/components/Toaster.tsx';
|
import Toaster from '@/components/Toaster.tsx';
|
||||||
|
import BadgeSolicitudes from '@/components/admin/BadgeSolicitudes.tsx';
|
||||||
import BrandMark from '@/components/BrandMark.astro';
|
import BrandMark from '@/components/BrandMark.astro';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,6 +11,15 @@ const { title } = Astro.props;
|
|||||||
|
|
||||||
const profile = Astro.locals.profile;
|
const profile = Astro.locals.profile;
|
||||||
const isAdmin = profile?.rol === 'admin';
|
const isAdmin = profile?.rol === 'admin';
|
||||||
|
|
||||||
|
let pendientesCount = 0;
|
||||||
|
if (isAdmin) {
|
||||||
|
const { count } = await Astro.locals.supabase
|
||||||
|
.from('solicitudes')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('estado', 'pendiente');
|
||||||
|
pendientesCount = count ?? 0;
|
||||||
|
}
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const isActive = (href: string) => path === href || path.startsWith(href + '/');
|
const isActive = (href: string) => path === href || path.startsWith(href + '/');
|
||||||
|
|
||||||
@@ -37,7 +47,8 @@ const alumnoNav: NavItem[] = [
|
|||||||
const adminNav: NavItem[] = [
|
const adminNav: NavItem[] = [
|
||||||
{ href: '/admin', label: 'Panel', icon: 'dashboard' },
|
{ href: '/admin', label: 'Panel', icon: 'dashboard' },
|
||||||
{ href: '/admin/solicitudes', label: 'Solicitudes', icon: 'inbox' },
|
{ href: '/admin/solicitudes', label: 'Solicitudes', icon: 'inbox' },
|
||||||
{ href: '/admin/inventario', label: 'Estadísticas', icon: 'stats' },
|
{ href: '/admin/inventario', label: 'Inventario', icon: 'box' },
|
||||||
|
{ href: '/admin/estadisticas', label: 'Estadísticas', icon: 'stats' },
|
||||||
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
|
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
|
||||||
];
|
];
|
||||||
const nav = isAdmin ? adminNav : alumnoNav;
|
const nav = isAdmin ? adminNav : alumnoNav;
|
||||||
@@ -76,15 +87,26 @@ const activeCheck = (item: NavItem) => {
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class:list={[
|
class:list={[
|
||||||
'flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
|
'relative flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
|
||||||
activeCheck(item) ? 'bg-[color:var(--color-primary)] text-white' : 'hover:bg-[color:var(--color-chalk)]'
|
activeCheck(item) ? 'bg-[color:var(--color-primary)] text-white' : 'hover:bg-[color:var(--color-chalk)]'
|
||||||
]}
|
]}
|
||||||
style={activeCheck(item) ? 'border: 2px solid var(--color-ink);' : 'border: 2px solid transparent;'}
|
style={activeCheck(item) ? 'border: 2px solid var(--color-ink);' : 'border: 2px solid transparent;'}
|
||||||
aria-current={activeCheck(item) ? 'page' : undefined}
|
aria-current={activeCheck(item) ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
|
<span class="relative inline-grid place-items-center">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
||||||
</svg>
|
</svg>
|
||||||
|
{item.href === '/admin/solicitudes' && (
|
||||||
|
<span
|
||||||
|
data-badge="pendientes"
|
||||||
|
hidden={pendientesCount === 0}
|
||||||
|
aria-label={`${pendientesCount} solicitudes pendientes`}
|
||||||
|
class="absolute -top-2 -right-3 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
|
||||||
|
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
|
||||||
|
>{pendientesCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -149,11 +171,11 @@ const activeCheck = (item: NavItem) => {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
class="flex-1 grid place-items-center py-2.5"
|
class="relative flex-1 grid place-items-center py-2.5"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class:list={[
|
class:list={[
|
||||||
'grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
|
'relative grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
|
||||||
active ? 'bg-[color:var(--color-primary)] text-white' : ''
|
active ? 'bg-[color:var(--color-primary)] text-white' : ''
|
||||||
]}
|
]}
|
||||||
style={active ? 'border: 2px solid var(--color-ink);' : ''}
|
style={active ? 'border: 2px solid var(--color-ink);' : ''}
|
||||||
@@ -161,6 +183,15 @@ const activeCheck = (item: NavItem) => {
|
|||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
||||||
</svg>
|
</svg>
|
||||||
|
{item.href === '/admin/solicitudes' && (
|
||||||
|
<span
|
||||||
|
data-badge="pendientes"
|
||||||
|
hidden={pendientesCount === 0}
|
||||||
|
aria-label={`${pendientesCount} solicitudes pendientes`}
|
||||||
|
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
|
||||||
|
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
|
||||||
|
>{pendientesCount}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -181,6 +212,7 @@ const activeCheck = (item: NavItem) => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Toaster client:idle />
|
<Toaster client:idle />
|
||||||
|
{isAdmin && <BadgeSolicitudes client:idle />}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// URL publica del bucket 'materiales-fotos' a partir del path guardado.
|
||||||
|
export function imgUrl(path?: string | null): string | null {
|
||||||
|
if (!path) return null;
|
||||||
|
const base =
|
||||||
|
(import.meta.env.PUBLIC_SUPABASE_URL as string | undefined) ??
|
||||||
|
(process.env.PUBLIC_SUPABASE_URL as string | undefined);
|
||||||
|
if (!base) return null;
|
||||||
|
return `${base}/storage/v1/object/public/materiales-fotos/${path}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
|
import Chart from '@/components/admin/estadisticas/Chart.tsx';
|
||||||
|
import { todayMX } from '@/lib/date';
|
||||||
|
|
||||||
|
// El middleware ya garantiza rol admin en /admin/*.
|
||||||
|
const supabase = Astro.locals.supabase;
|
||||||
|
const dia = todayMX();
|
||||||
|
const countHead = { count: 'exact' as const, head: true };
|
||||||
|
const startISO = dia.startUTC.toISOString();
|
||||||
|
const endISO = dia.endUTC.toISOString();
|
||||||
|
|
||||||
|
// KPIs del día — misma lógica que /admin/inventario legacy.
|
||||||
|
const [
|
||||||
|
{ count: nSolicitadas },
|
||||||
|
{ count: nAprobadas },
|
||||||
|
{ count: nRechazadas },
|
||||||
|
{ count: nDevueltas },
|
||||||
|
{ count: nEnPrestamo },
|
||||||
|
{ count: nVencidos },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
|
||||||
|
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
|
||||||
|
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
|
||||||
|
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
|
||||||
|
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
|
||||||
|
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
|
||||||
|
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
|
||||||
|
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
|
||||||
|
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
|
||||||
|
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
|
||||||
|
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toneStyle = (tone: string) => {
|
||||||
|
if (tone === 'primary') return 'color: var(--color-primary);';
|
||||||
|
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
|
||||||
|
if (tone === 'danger') return 'color: var(--color-danger-text);';
|
||||||
|
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ventana 30 días (rango inclusivo del día actual).
|
||||||
|
const DIAS = 30;
|
||||||
|
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
|
||||||
|
|
||||||
|
// (1) Top 10 materiales solicitados últimos 30 días.
|
||||||
|
const { data: topMaterialesRaw } = await supabase
|
||||||
|
.from('solicitud_items')
|
||||||
|
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
|
||||||
|
.gte('solicitud.fecha_solicitud', desde);
|
||||||
|
|
||||||
|
const topMapa = new Map<string, number>();
|
||||||
|
for (const r of (topMaterialesRaw ?? []) as any[]) {
|
||||||
|
const nom = r.material?.nombre ?? '—';
|
||||||
|
topMapa.set(nom, (topMapa.get(nom) ?? 0) + (r.cantidad ?? 0));
|
||||||
|
}
|
||||||
|
const topMateriales = [...topMapa.entries()]
|
||||||
|
.map(([label, valor]) => ({ label, valor }))
|
||||||
|
.sort((a, b) => b.valor - a.valor)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// (2) Solicitudes por día últimos 30 días. Rellena huecos con 0.
|
||||||
|
const { data: solicitudesUlt30 } = await supabase
|
||||||
|
.from('solicitudes')
|
||||||
|
.select('fecha_solicitud')
|
||||||
|
.gte('fecha_solicitud', desde)
|
||||||
|
.order('fecha_solicitud', { ascending: true });
|
||||||
|
|
||||||
|
// Genera todas las fechas de la ventana en formato YYYY-MM-DD (zona MX).
|
||||||
|
function toIsoDateMX(d: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'America/Tijuana',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conteoPorDia = new Map<string, number>();
|
||||||
|
for (const r of (solicitudesUlt30 ?? []) as { fecha_solicitud: string }[]) {
|
||||||
|
const k = toIsoDateMX(new Date(r.fecha_solicitud));
|
||||||
|
conteoPorDia.set(k, (conteoPorDia.get(k) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const solicitudesPorDia: { fecha: string; valor: number }[] = [];
|
||||||
|
for (let i = 0; i < DIAS; i++) {
|
||||||
|
const d = new Date(dia.startUTC.getTime() - (DIAS - 1 - i) * 86400000);
|
||||||
|
const iso = toIsoDateMX(d);
|
||||||
|
solicitudesPorDia.push({ fecha: iso, valor: conteoPorDia.get(iso) ?? 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) Distribución de estados de todas las solicitudes.
|
||||||
|
const { data: estadosRaw } = await supabase.from('solicitudes').select('estado');
|
||||||
|
const estadoLabelMap: Record<string, string> = {
|
||||||
|
pendiente: 'Pendiente',
|
||||||
|
aprobado: 'Aprobado',
|
||||||
|
activo: 'Activo',
|
||||||
|
devuelto: 'Devuelto',
|
||||||
|
rechazado: 'Rechazado',
|
||||||
|
vencido: 'Vencido',
|
||||||
|
};
|
||||||
|
const estadoMapa = new Map<string, number>();
|
||||||
|
for (const r of (estadosRaw ?? []) as { estado: string | null }[]) {
|
||||||
|
if (!r.estado) continue;
|
||||||
|
estadoMapa.set(r.estado, (estadoMapa.get(r.estado) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const distribEstados = [...estadoMapa.entries()].map(([k, v]) => ({
|
||||||
|
label: estadoLabelMap[k] ?? k,
|
||||||
|
valor: v,
|
||||||
|
}));
|
||||||
|
---
|
||||||
|
<AppLayout title="Estadísticas — LabPréstamos">
|
||||||
|
<div class="max-w-6xl">
|
||||||
|
<header class="mb-6">
|
||||||
|
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
||||||
|
<p class="text-sm mt-1 first-letter:uppercase" style="color: var(--color-pencil);">
|
||||||
|
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
||||||
|
{kpis.map((k) => (
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-xs" style="color: var(--color-pencil);">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
|
||||||
|
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
|
||||||
|
{k.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Análisis" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Solicitudes por día · últimos {DIAS}</h3>
|
||||||
|
{solicitudesPorDia.some((d) => d.valor > 0) ? (
|
||||||
|
<Chart tipo="linea" datos={solicitudesPorDia} client:idle />
|
||||||
|
) : (
|
||||||
|
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Sin actividad en los últimos {DIAS} días.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Distribución de estados</h3>
|
||||||
|
{distribEstados.length > 0 ? (
|
||||||
|
<Chart tipo="donut" datos={distribEstados} client:idle />
|
||||||
|
) : (
|
||||||
|
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Aún no hay solicitudes registradas.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card lg:col-span-2">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Top 10 materiales · últimos {DIAS} días</h3>
|
||||||
|
{topMateriales.length > 0 ? (
|
||||||
|
<Chart tipo="barras" datos={topMateriales} alto={320} client:idle />
|
||||||
|
) : (
|
||||||
|
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Ningún material solicitado en los últimos {DIAS} días.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
---
|
---
|
||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
|
import { todayMX } from '@/lib/date';
|
||||||
|
|
||||||
const supabase = Astro.locals.supabase;
|
const supabase = Astro.locals.supabase;
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const dia = todayMX();
|
||||||
|
|
||||||
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
|
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
|
||||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
|
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
|
||||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
|
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
|
||||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', today),
|
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||||
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
|
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
|
||||||
supabase
|
supabase
|
||||||
.from('solicitudes')
|
.from('solicitudes')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
|
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
|
||||||
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
|
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
|
||||||
import { todayMX } from '@/lib/date';
|
import { imgUrl } from '@/lib/materialImg';
|
||||||
|
|
||||||
type Material = {
|
type Material = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,48 +12,11 @@ type Material = {
|
|||||||
cantidad_disponible: number;
|
cantidad_disponible: number;
|
||||||
numero_inventario: string | null;
|
numero_inventario: string | null;
|
||||||
estado: 'disponible' | 'mantenimiento' | 'baja';
|
estado: 'disponible' | 'mantenimiento' | 'baja';
|
||||||
|
imagen_path: string | null;
|
||||||
|
trackeado_por_unidad: boolean;
|
||||||
categoria: { id: number; nombre: string } | null;
|
categoria: { id: number; nombre: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// KPIs del día (fecha automática, timezone MX con DST)
|
|
||||||
const dia = todayMX();
|
|
||||||
const supabase = Astro.locals.supabase;
|
|
||||||
const countHead = { count: 'exact' as const, head: true };
|
|
||||||
const startISO = dia.startUTC.toISOString();
|
|
||||||
const endISO = dia.endUTC.toISOString();
|
|
||||||
|
|
||||||
const [
|
|
||||||
{ count: nSolicitadas },
|
|
||||||
{ count: nAprobadas },
|
|
||||||
{ count: nRechazadas },
|
|
||||||
{ count: nDevueltas },
|
|
||||||
{ count: nEnPrestamo },
|
|
||||||
{ count: nVencidos },
|
|
||||||
] = await Promise.all([
|
|
||||||
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
|
|
||||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
|
|
||||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
|
|
||||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
|
|
||||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
|
|
||||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const kpis = [
|
|
||||||
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
|
|
||||||
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
|
|
||||||
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
|
|
||||||
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
|
|
||||||
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
|
|
||||||
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const toneStyle = (tone: string) => {
|
|
||||||
if (tone === 'primary') return 'color: var(--color-primary);';
|
|
||||||
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
|
|
||||||
if (tone === 'danger') return 'color: var(--color-danger);';
|
|
||||||
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
|
|
||||||
};
|
|
||||||
|
|
||||||
const q = (Astro.url.searchParams.get('q') ?? '').trim();
|
const q = (Astro.url.searchParams.get('q') ?? '').trim();
|
||||||
const catRaw = Astro.url.searchParams.get('cat') ?? '';
|
const catRaw = Astro.url.searchParams.get('cat') ?? '';
|
||||||
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
||||||
@@ -63,7 +26,7 @@ const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? est
|
|||||||
let query = Astro.locals.supabase
|
let query = Astro.locals.supabase
|
||||||
.from('materiales')
|
.from('materiales')
|
||||||
.select(
|
.select(
|
||||||
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, categoria:categorias(id, nombre)',
|
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, imagen_path, trackeado_por_unidad, categoria:categorias(id, nombre)',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
|
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
|
||||||
@@ -96,34 +59,16 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
<AppLayout title="Estadísticas — LabPréstamos">
|
<AppLayout title="Inventario — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-7xl">
|
||||||
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
<h1 class="text-2xl md:text-3xl font-semibold">Inventario</h1>
|
||||||
<p class="text-sm opacity-70 mt-1 first-letter:uppercase">
|
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
|
||||||
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<MaterialForm mode="create" categorias={categorias} client:load />
|
<MaterialForm mode="create" categorias={categorias} client:load />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
|
||||||
{kpis.map((k) => (
|
|
||||||
<div class="card p-4">
|
|
||||||
<div class="text-xs opacity-70">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
|
|
||||||
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
|
|
||||||
{k.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<h2 class="text-lg font-semibold">Inventario</h2>
|
|
||||||
<p class="text-sm opacity-70">Materiales del laboratorio.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
|
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
|
||||||
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
||||||
Materiales
|
Materiales
|
||||||
@@ -133,10 +78,10 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<form method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
|
<form id="inv-filter-form" method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<label class="label" for="filtro-q">Buscar</label>
|
<label class="label" for="filtro-q">Buscar</label>
|
||||||
<input id="filtro-q" name="q" type="search" class="input" value={q} placeholder="Nombre o Nº inventario…" autocomplete="off" />
|
<input id="filtro-q" name="q" type="search" class="input" value={q} placeholder="Buscar por nombre o Nº de inventario" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="label" for="filtro-cat">Categoría</label>
|
<label class="label" for="filtro-cat">Categoría</label>
|
||||||
@@ -164,6 +109,21 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
(() => {
|
||||||
|
const form = document.getElementById('inv-filter-form');
|
||||||
|
if (!form) return;
|
||||||
|
const q = form.querySelector('input[name="q"]');
|
||||||
|
const selects = form.querySelectorAll('select');
|
||||||
|
let t;
|
||||||
|
q && q.addEventListener('input', () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(() => form.submit(), 250);
|
||||||
|
});
|
||||||
|
selects.forEach((s) => s.addEventListener('change', () => form.submit()));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{matError && (
|
{matError && (
|
||||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
||||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
<p class="text-sm" style="color: var(--color-danger-text);">
|
||||||
@@ -184,93 +144,44 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!matError && materiales.length > 0 && (
|
{!matError && materiales.length > 0 && (
|
||||||
<>
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
<div class="hidden md:block card p-0 overflow-hidden">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">Nombre</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Categoría</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Nº inventario</th>
|
|
||||||
<th class="px-4 py-3 font-medium text-right">Stock</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Estado</th>
|
|
||||||
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{materiales.map((m) => {
|
{materiales.map((m) => {
|
||||||
const badge = estadoBadge(m.estado);
|
const badge = estadoBadge(m.estado);
|
||||||
|
const src = imgUrl(m.imagen_path);
|
||||||
return (
|
return (
|
||||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
|
<article class="card p-0 flex flex-col overflow-hidden">
|
||||||
<td class="px-4 py-3">
|
<div
|
||||||
<div class="font-medium">{m.nombre}</div>
|
class="aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
|
||||||
{m.descripcion && (
|
style="border-bottom: 2px solid var(--color-ink);"
|
||||||
<div class="text-xs opacity-60 line-clamp-1">{m.descripcion}</div>
|
>
|
||||||
|
{src ? (
|
||||||
|
<img src={src} alt={m.nombre} class="w-full h-full object-cover" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" stroke-width="1.5" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||||
|
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
||||||
|
<circle cx="8" cy="9" r="1.5" />
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 opacity-80">{m.categoria?.nombre ?? '—'}</td>
|
|
||||||
<td class="px-4 py-3 font-mono text-xs opacity-80">{m.numero_inventario ?? '—'}</td>
|
|
||||||
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
|
|
||||||
{m.cantidad_disponible} / {m.cantidad_total}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap" style={badge.style}>
|
|
||||||
{badge.label}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="flex gap-2 justify-end">
|
|
||||||
<MaterialForm
|
|
||||||
mode="edit"
|
|
||||||
material={{
|
|
||||||
id: m.id,
|
|
||||||
nombre: m.nombre,
|
|
||||||
categoria_id: m.categoria?.id ?? null,
|
|
||||||
descripcion: m.descripcion,
|
|
||||||
cantidad_total: m.cantidad_total,
|
|
||||||
cantidad_disponible: m.cantidad_disponible,
|
|
||||||
numero_inventario: m.numero_inventario,
|
|
||||||
estado: m.estado,
|
|
||||||
}}
|
|
||||||
categorias={categorias}
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div class="p-3 flex flex-col gap-2 flex-1">
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:hidden flex flex-col gap-3">
|
|
||||||
{materiales.map((m) => {
|
|
||||||
const badge = estadoBadge(m.estado);
|
|
||||||
return (
|
|
||||||
<article class="card flex flex-col gap-3">
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div>
|
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
|
||||||
<h3 class="font-semibold leading-snug">{m.nombre}</h3>
|
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shrink-0" style={badge.style}>
|
||||||
{m.categoria && (
|
|
||||||
<p class="text-xs opacity-70 mt-0.5">{m.categoria.nombre}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap" style={badge.style}>
|
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{m.descripcion && <p class="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
|
{m.categoria && (
|
||||||
<dl class="text-xs opacity-70 grid grid-cols-2 gap-1">
|
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
|
||||||
<dt class="opacity-60">Inventario</dt>
|
)}
|
||||||
<dd class="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
|
<p class="text-xs" style="font-variant-numeric: tabular-nums;">
|
||||||
<dt class="opacity-60">Stock</dt>
|
<span class="font-semibold">{m.cantidad_disponible}</span> / {m.cantidad_total} disponibles
|
||||||
<dd class="text-right" style="font-variant-numeric: tabular-nums;">
|
{m.trackeado_por_unidad && <span class="opacity-70"> · por unidad</span>}
|
||||||
{m.cantidad_disponible} / {m.cantidad_total}
|
</p>
|
||||||
</dd>
|
{m.numero_inventario && (
|
||||||
</dl>
|
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p>
|
||||||
<div class="flex gap-2 justify-end mt-auto">
|
)}
|
||||||
|
<div class="flex gap-2 mt-auto pt-2">
|
||||||
<MaterialForm
|
<MaterialForm
|
||||||
mode="edit"
|
mode="edit"
|
||||||
material={{
|
material={{
|
||||||
@@ -282,17 +193,19 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
cantidad_disponible: m.cantidad_disponible,
|
cantidad_disponible: m.cantidad_disponible,
|
||||||
numero_inventario: m.numero_inventario,
|
numero_inventario: m.numero_inventario,
|
||||||
estado: m.estado,
|
estado: m.estado,
|
||||||
|
imagen_path: m.imagen_path,
|
||||||
|
trackeado_por_unidad: m.trackeado_por_unidad,
|
||||||
}}
|
}}
|
||||||
categorias={categorias}
|
categorias={categorias}
|
||||||
client:load
|
client:load
|
||||||
/>
|
/>
|
||||||
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
import CartProvider from '@/components/alumno/SolicitudCart.tsx';
|
import CartProvider from '@/components/alumno/SolicitudCart.tsx';
|
||||||
import FiltroCategorias from '@/components/alumno/FiltroCategorias.tsx';
|
import FiltroCategorias from '@/components/alumno/FiltroCategorias.tsx';
|
||||||
|
import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
|
||||||
|
|
||||||
|
const qInitial = Astro.url.searchParams.get('q') ?? '';
|
||||||
|
|
||||||
type Material = {
|
type Material = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -10,12 +13,13 @@ type Material = {
|
|||||||
cantidad_disponible: number;
|
cantidad_disponible: number;
|
||||||
cantidad_total: number;
|
cantidad_total: number;
|
||||||
numero_inventario: string | null;
|
numero_inventario: string | null;
|
||||||
|
imagen_path: string | null;
|
||||||
categoria: { id: number; nombre: string } | null;
|
categoria: { id: number; nombre: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, error } = await Astro.locals.supabase
|
const { data, error } = await Astro.locals.supabase
|
||||||
.from('materiales')
|
.from('materiales')
|
||||||
.select('id, nombre, descripcion, cantidad_disponible, cantidad_total, numero_inventario, categoria:categorias(id, nombre)')
|
.select('id, nombre, descripcion, cantidad_disponible, cantidad_total, numero_inventario, imagen_path, categoria:categorias(id, nombre)')
|
||||||
.eq('estado', 'disponible')
|
.eq('estado', 'disponible')
|
||||||
.order('nombre');
|
.order('nombre');
|
||||||
|
|
||||||
@@ -57,11 +61,12 @@ const categoriasFiltro = grupoList
|
|||||||
|
|
||||||
{materiales.length > 0 && (
|
{materiales.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
<div class="mb-6 flex flex-col gap-3">
|
||||||
|
<BuscadorCatalogo q={qInitial} client:load />
|
||||||
{categoriasFiltro.length > 0 && (
|
{categoriasFiltro.length > 0 && (
|
||||||
<div class="mb-6">
|
|
||||||
<FiltroCategorias categorias={categoriasFiltro} client:idle />
|
<FiltroCategorias categorias={categoriasFiltro} client:idle />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p id="catalogo-empty-filter" hidden class="text-sm opacity-70 mb-4">
|
<p id="catalogo-empty-filter" hidden class="text-sm opacity-70 mb-4">
|
||||||
Ningún material en esta categoría. Prueba con otra.
|
Ningún material en esta categoría. Prueba con otra.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type Item = {
|
|||||||
cantidad: number;
|
cantidad: number;
|
||||||
descripcion: string | null;
|
descripcion: string | null;
|
||||||
material: { id: number; nombre: string; numero_inventario: string | null } | null;
|
material: { id: number; nombre: string; numero_inventario: string | null } | null;
|
||||||
|
material_unidad: { etiqueta: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Solicitud = {
|
type Solicitud = {
|
||||||
@@ -25,7 +26,7 @@ const user = Astro.locals.user!;
|
|||||||
|
|
||||||
const { data, error } = await Astro.locals.supabase
|
const { data, error } = await Astro.locals.supabase
|
||||||
.from('solicitudes')
|
.from('solicitudes')
|
||||||
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))')
|
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario), material_unidad:material_unidades(etiqueta))')
|
||||||
.eq('alumno_id', user.id)
|
.eq('alumno_id', user.id)
|
||||||
.order('fecha_solicitud', { ascending: false });
|
.order('fecha_solicitud', { ascending: false });
|
||||||
|
|
||||||
@@ -101,6 +102,9 @@ const badgeStyle = (e: Estado) =>
|
|||||||
{it.material?.numero_inventario && (
|
{it.material?.numero_inventario && (
|
||||||
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
|
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
|
||||||
)}
|
)}
|
||||||
|
{it.material_unidad?.etiqueta && (
|
||||||
|
<div class="text-xs opacity-70">Unidad: {it.material_unidad.etiqueta}</div>
|
||||||
|
)}
|
||||||
{it.descripcion && (
|
{it.descripcion && (
|
||||||
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
|
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type Patch = {
|
|||||||
estado?: string;
|
estado?: string;
|
||||||
cantidad_total?: number;
|
cantidad_total?: number;
|
||||||
cantidad_disponible?: number;
|
cantidad_disponible?: number;
|
||||||
|
imagen_path?: string | null;
|
||||||
|
trackeado_por_unidad?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseId = (raw: string | undefined) => {
|
const parseId = (raw: string | undefined) => {
|
||||||
@@ -72,21 +74,74 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
|||||||
patch.estado = estado;
|
patch.estado = estado;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('imagen_path' in body) {
|
||||||
|
if (body.imagen_path === null) {
|
||||||
|
patch.imagen_path = null;
|
||||||
|
} else if (typeof body.imagen_path === 'string' && body.imagen_path.trim()) {
|
||||||
|
patch.imagen_path = body.imagen_path.trim();
|
||||||
|
} else if (typeof body.imagen_path === 'string') {
|
||||||
|
patch.imagen_path = null;
|
||||||
|
} else {
|
||||||
|
return Response.json({ error: 'imagen_path inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle trackeado_por_unidad — cargamos estado actual del material una vez si se pide algo que dependa de él
|
||||||
|
let actual:
|
||||||
|
| { cantidad_total: number; cantidad_disponible: number; trackeado_por_unidad: boolean }
|
||||||
|
| null = null;
|
||||||
|
const necesitaActual = 'cantidad_total' in body || 'trackeado_por_unidad' in body;
|
||||||
|
if (necesitaActual) {
|
||||||
|
const { data, error: selErr } = await locals.supabase
|
||||||
|
.from('materiales')
|
||||||
|
.select('cantidad_total, cantidad_disponible, trackeado_por_unidad')
|
||||||
|
.eq('id', id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (selErr || !data)
|
||||||
|
return Response.json({ error: 'Material no encontrado' }, { status: 404 });
|
||||||
|
actual = data as typeof actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('trackeado_por_unidad' in body) {
|
||||||
|
const nuevo = body.trackeado_por_unidad === true;
|
||||||
|
if (nuevo !== actual!.trackeado_por_unidad) {
|
||||||
|
if (nuevo) {
|
||||||
|
// Solo permitir activar si aún no hay stock — evita perder datos
|
||||||
|
if (actual!.cantidad_total !== 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Para activar rastreo por unidad, la cantidad total debe ser 0. Registra las unidades después.' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Solo desactivar si no hay unidades registradas
|
||||||
|
const { count } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.select('id', { count: 'exact', head: true })
|
||||||
|
.eq('material_id', id);
|
||||||
|
if ((count ?? 0) > 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: 'Para desactivar rastreo por unidad, elimina primero las unidades registradas.' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.trackeado_por_unidad = nuevo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ('cantidad_total' in body) {
|
if ('cantidad_total' in body) {
|
||||||
|
// Si el material ya es (o va a quedar) trackeado, cantidad_total lo maneja el trigger; no aceptamos edición manual.
|
||||||
|
const efectivoTrackeado =
|
||||||
|
patch.trackeado_por_unidad ?? actual!.trackeado_por_unidad;
|
||||||
|
if (efectivoTrackeado) {
|
||||||
|
// ignorar silenciosamente cantidad_total en modo trackeado
|
||||||
|
} else {
|
||||||
const nuevoTotal = Number(body.cantidad_total);
|
const nuevoTotal = Number(body.cantidad_total);
|
||||||
if (!Number.isInteger(nuevoTotal) || nuevoTotal < 0)
|
if (!Number.isInteger(nuevoTotal) || nuevoTotal < 0)
|
||||||
return Response.json({ error: 'Cantidad total inválida' }, { status: 400 });
|
return Response.json({ error: 'Cantidad total inválida' }, { status: 400 });
|
||||||
|
|
||||||
const { data: actual, error: selErr } = await locals.supabase
|
const prestados = actual!.cantidad_total - actual!.cantidad_disponible;
|
||||||
.from('materiales')
|
|
||||||
.select('cantidad_total, cantidad_disponible')
|
|
||||||
.eq('id', id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (selErr || !actual)
|
|
||||||
return Response.json({ error: 'Material no encontrado' }, { status: 404 });
|
|
||||||
|
|
||||||
const prestados = actual.cantidad_total - actual.cantidad_disponible;
|
|
||||||
if (nuevoTotal < prestados) {
|
if (nuevoTotal < prestados) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: `No puede quedar debajo de lo prestado (${prestados})` },
|
{ error: `No puede quedar debajo de lo prestado (${prestados})` },
|
||||||
@@ -94,7 +149,8 @@ export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
patch.cantidad_total = nuevoTotal;
|
patch.cantidad_total = nuevoTotal;
|
||||||
patch.cantidad_disponible = actual.cantidad_disponible + (nuevoTotal - actual.cantidad_total);
|
patch.cantidad_disponible = actual!.cantidad_disponible + (nuevoTotal - actual!.cantidad_total);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(patch).length === 0) {
|
if (Object.keys(patch).length === 0) {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const parseId = (raw: string | undefined) => {
|
||||||
|
const id = Number(raw);
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ locals, params, url }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
const materialId = parseId(params.id);
|
||||||
|
if (!materialId) return json({ error: 'ID inválido' }, 400);
|
||||||
|
|
||||||
|
let q = locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.select('id, etiqueta, estado, notas, created_at')
|
||||||
|
.eq('material_id', materialId)
|
||||||
|
.order('id');
|
||||||
|
|
||||||
|
const estado = url.searchParams.get('estado');
|
||||||
|
if (estado && ['disponible', 'prestado', 'mantenimiento', 'baja'].includes(estado)) {
|
||||||
|
q = q.eq('estado', estado);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await q;
|
||||||
|
if (error) return json({ error: 'No se pudo cargar' }, 500);
|
||||||
|
return json({ unidades: data ?? [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ locals, params, request }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
const materialId = parseId(params.id);
|
||||||
|
if (!materialId) return json({ error: 'ID inválido' }, 400);
|
||||||
|
|
||||||
|
let body: { etiqueta?: unknown; notas?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'JSON inválido' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const etiqueta = typeof body.etiqueta === 'string' ? body.etiqueta.trim() : '';
|
||||||
|
if (!etiqueta) return json({ error: 'La etiqueta es obligatoria' }, 400);
|
||||||
|
const notas =
|
||||||
|
typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
|
||||||
|
|
||||||
|
const { data, error } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.insert({ material_id: materialId, etiqueta, notas })
|
||||||
|
.select('id, etiqueta, estado, notas, created_at')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if ((error as { code?: string }).code === '23505') {
|
||||||
|
return json({ error: 'Etiqueta duplicada para este material' }, 409);
|
||||||
|
}
|
||||||
|
if ((error as { code?: string }).code === '23503') {
|
||||||
|
return json({ error: 'Material inexistente' }, 409);
|
||||||
|
}
|
||||||
|
return json({ error: 'No se pudo crear la unidad' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ unidad: data }, 201);
|
||||||
|
};
|
||||||
@@ -14,6 +14,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
cantidad_total?: unknown;
|
cantidad_total?: unknown;
|
||||||
numero_inventario?: unknown;
|
numero_inventario?: unknown;
|
||||||
estado?: unknown;
|
estado?: unknown;
|
||||||
|
imagen_path?: unknown;
|
||||||
|
trackeado_por_unidad?: unknown;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
@@ -26,8 +28,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cantidad_total = Number(body.cantidad_total);
|
const trackeado_por_unidad = body.trackeado_por_unidad === true;
|
||||||
if (!Number.isInteger(cantidad_total) || cantidad_total < 0) {
|
|
||||||
|
let cantidad_total = Number(body.cantidad_total);
|
||||||
|
if (trackeado_por_unidad) {
|
||||||
|
// trigger material_unidades sube total/disponible conforme se agreguen unidades
|
||||||
|
cantidad_total = 0;
|
||||||
|
} else if (!Number.isInteger(cantidad_total) || cantidad_total < 0) {
|
||||||
return Response.json({ error: 'La cantidad total debe ser un entero mayor o igual a 0' }, { status: 400 });
|
return Response.json({ error: 'La cantidad total debe ser un entero mayor o igual a 0' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +58,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
return Response.json({ error: 'Estado inválido' }, { status: 400 });
|
return Response.json({ error: 'Estado inválido' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imagen_path =
|
||||||
|
typeof body.imagen_path === 'string' && body.imagen_path.trim() ? body.imagen_path.trim() : null;
|
||||||
|
|
||||||
const { data, error } = await locals.supabase
|
const { data, error } = await locals.supabase
|
||||||
.from('materiales')
|
.from('materiales')
|
||||||
.insert({
|
.insert({
|
||||||
@@ -61,6 +71,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
cantidad_disponible: cantidad_total,
|
cantidad_disponible: cantidad_total,
|
||||||
numero_inventario,
|
numero_inventario,
|
||||||
estado: estadoIn,
|
estado: estadoIn,
|
||||||
|
imagen_path,
|
||||||
|
trackeado_por_unidad,
|
||||||
})
|
})
|
||||||
.select('id')
|
.select('id')
|
||||||
.single();
|
.single();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const parseId = (raw: string | undefined) => {
|
||||||
|
const id = Number(raw);
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: APIRoute = async ({ locals, params, request }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
const unidadId = parseId(params.unidadId);
|
||||||
|
if (!unidadId) return json({ error: 'ID inválido' }, 400);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'JSON inválido' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if ('etiqueta' in body) {
|
||||||
|
const etiqueta = typeof body.etiqueta === 'string' ? body.etiqueta.trim() : '';
|
||||||
|
if (!etiqueta) return json({ error: 'La etiqueta no puede quedar vacía' }, 400);
|
||||||
|
patch.etiqueta = etiqueta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('estado' in body) {
|
||||||
|
const estado = typeof body.estado === 'string' ? body.estado : '';
|
||||||
|
if (!['disponible', 'mantenimiento', 'baja'].includes(estado)) {
|
||||||
|
return json({ error: "Estado inválido — 'prestado' lo asigna el sistema" }, 400);
|
||||||
|
}
|
||||||
|
patch.estado = estado;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('notas' in body) {
|
||||||
|
patch.notas =
|
||||||
|
typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length === 0) return json({ error: 'Nada que actualizar' }, 400);
|
||||||
|
|
||||||
|
// Si se cambia estado, no permitir tocarlo si la unidad está actualmente 'prestado'
|
||||||
|
if ('estado' in patch) {
|
||||||
|
const { data: cur, error: selErr } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.select('estado')
|
||||||
|
.eq('id', unidadId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (selErr || !cur) return json({ error: 'Unidad no encontrada' }, 404);
|
||||||
|
if (cur.estado === 'prestado') {
|
||||||
|
return json({ error: 'Unidad prestada — el sistema controla su estado' }, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.update(patch)
|
||||||
|
.eq('id', unidadId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if ((error as { code?: string }).code === '23505') {
|
||||||
|
return json({ error: 'Etiqueta duplicada para este material' }, 409);
|
||||||
|
}
|
||||||
|
return json({ error: 'No se pudo actualizar' }, 500);
|
||||||
|
}
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: APIRoute = async ({ locals, params }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
const unidadId = parseId(params.unidadId);
|
||||||
|
if (!unidadId) return json({ error: 'ID inválido' }, 400);
|
||||||
|
|
||||||
|
const { data: cur, error: selErr } = await locals.supabase
|
||||||
|
.from('material_unidades')
|
||||||
|
.select('estado')
|
||||||
|
.eq('id', unidadId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (selErr || !cur) return json({ error: 'Unidad no encontrada' }, 404);
|
||||||
|
if (cur.estado === 'prestado') {
|
||||||
|
return json({ error: 'Unidad prestada — no puedes eliminarla' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('material_unidades').delete().eq('id', unidadId);
|
||||||
|
if (error) {
|
||||||
|
if ((error as { code?: string }).code === '23503') {
|
||||||
|
return json({ error: 'Tiene renglones asociados — no se puede eliminar' }, 409);
|
||||||
|
}
|
||||||
|
return json({ error: 'No se pudo eliminar' }, 500);
|
||||||
|
}
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
|||||||
const prestamoQ = locals.supabase
|
const prestamoQ = locals.supabase
|
||||||
.from('solicitudes')
|
.from('solicitudes')
|
||||||
.select(
|
.select(
|
||||||
'id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))'
|
'id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(id, cantidad, descripcion, material:materiales(id, nombre, numero_inventario, trackeado_por_unidad), material_unidad:material_unidades(id, etiqueta))'
|
||||||
)
|
)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
// Traduce excepciones nombradas de prestamos.reasignar_unidad a códigos HTTP.
|
||||||
|
const CODE_MAP: Record<string, number> = {
|
||||||
|
no_autorizado: 403,
|
||||||
|
item_no_existe: 404,
|
||||||
|
solicitud_no_activa: 409,
|
||||||
|
item_sin_unidad: 409,
|
||||||
|
unidad_no_existe: 404,
|
||||||
|
unidad_de_otro_material: 409,
|
||||||
|
unidad_no_disponible: 409,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ locals, request }) => {
|
||||||
|
if (locals.profile?.rol !== 'admin') return json({ error: 'No autorizado' }, 403);
|
||||||
|
|
||||||
|
let body: { item_id?: unknown; nueva_unidad_id?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return json({ error: 'JSON inválido' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = Number(body.item_id);
|
||||||
|
const nuevaId = Number(body.nueva_unidad_id);
|
||||||
|
if (!Number.isInteger(itemId) || itemId <= 0) return json({ error: 'item_id inválido' }, 400);
|
||||||
|
if (!Number.isInteger(nuevaId) || nuevaId <= 0)
|
||||||
|
return json({ error: 'nueva_unidad_id inválido' }, 400);
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.rpc('reasignar_unidad', {
|
||||||
|
p_item_id: itemId,
|
||||||
|
p_nueva_unidad_id: nuevaId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const raw = String((error as { message?: string }).message ?? '');
|
||||||
|
for (const key of Object.keys(CODE_MAP)) {
|
||||||
|
if (raw.includes(key)) return json({ error: key }, CODE_MAP[key]);
|
||||||
|
}
|
||||||
|
return json({ error: 'No se pudo reasignar' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
-- 0003_grid_unidades_realtime.sql
|
||||||
|
-- 3 features de BD en una migracion:
|
||||||
|
-- A) imagen_path en materiales (bucket 'materiales-fotos' se crea aparte)
|
||||||
|
-- B) material_unidades (rastreo por unidad para laptops/proyectores) +
|
||||||
|
-- flag trackeado_por_unidad + material_unidad_id en solicitud_items +
|
||||||
|
-- sync_stock/crear_solicitud reescritos + RPC reasignar_unidad(admin)
|
||||||
|
-- C) publication supabase_realtime += prestamos.solicitudes (badge admin)
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- A) Imagen del material (guarda solo el path dentro del bucket)
|
||||||
|
-- ============================================================
|
||||||
|
alter table prestamos.materiales add column imagen_path text;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.1) Flag + tabla de unidades individuales
|
||||||
|
-- ============================================================
|
||||||
|
alter table prestamos.materiales
|
||||||
|
add column trackeado_por_unidad boolean not null default false;
|
||||||
|
|
||||||
|
create table prestamos.material_unidades (
|
||||||
|
id bigserial primary key,
|
||||||
|
material_id int not null references prestamos.materiales(id) on delete cascade,
|
||||||
|
etiqueta text not null,
|
||||||
|
estado text not null default 'disponible'
|
||||||
|
check (estado in ('disponible','prestado','mantenimiento','baja')),
|
||||||
|
notas text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (material_id, etiqueta)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index material_unidades_material_estado_idx
|
||||||
|
on prestamos.material_unidades(material_id, estado);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.2) Ligar renglon de solicitud a unidad especifica (nullable)
|
||||||
|
-- ============================================================
|
||||||
|
alter table prestamos.solicitud_items
|
||||||
|
add column material_unidad_id bigint references prestamos.material_unidades(id);
|
||||||
|
|
||||||
|
create index solicitud_items_unidad_idx
|
||||||
|
on prestamos.solicitud_items(material_unidad_id)
|
||||||
|
where material_unidad_id is not null;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.3) sync_stock reescrito: itera renglones y, si el renglon tiene
|
||||||
|
-- material_unidad_id, tambien alterna el estado de esa unidad.
|
||||||
|
-- cantidad_disponible del material sigue reflejando el count
|
||||||
|
-- real disponible en ambos modos (por unidad o por cantidad).
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function prestamos.sync_stock()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
item record;
|
||||||
|
begin
|
||||||
|
-- sacar stock: pendiente -> aprobado/activo
|
||||||
|
if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then
|
||||||
|
for item in
|
||||||
|
select material_id, cantidad, material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id
|
||||||
|
order by material_id
|
||||||
|
loop
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_disponible = cantidad_disponible - item.cantidad
|
||||||
|
where id = item.material_id;
|
||||||
|
|
||||||
|
if item.material_unidad_id is not null then
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'prestado'
|
||||||
|
where id = item.material_unidad_id;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- devolver stock: aprobado/activo -> devuelto/rechazado
|
||||||
|
if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then
|
||||||
|
for item in
|
||||||
|
select material_id, cantidad, material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id
|
||||||
|
order by material_id
|
||||||
|
loop
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_disponible = cantidad_disponible + item.cantidad
|
||||||
|
where id = item.material_id;
|
||||||
|
|
||||||
|
if item.material_unidad_id is not null then
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'disponible'
|
||||||
|
where id = item.material_unidad_id;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.4) Trigger para mantener cantidad_total y cantidad_disponible
|
||||||
|
-- del material sincronizados con material_unidades cuando
|
||||||
|
-- trackeado_por_unidad=true. Cambios a considerar:
|
||||||
|
-- - INSERT unidad (nueva ficha) -> total += 1, disponible += (1 si 'disponible')
|
||||||
|
-- - DELETE unidad -> total -= 1, disponible -= (1 si 'disponible')
|
||||||
|
-- - UPDATE estado unidad -> disponible +/- 1 segun transicion
|
||||||
|
-- (tomando 'disponible' como el unico estado que cuenta como stock)
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function prestamos.sync_material_desde_unidad()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_delta_total int := 0;
|
||||||
|
v_delta_disp int := 0;
|
||||||
|
v_material_id int;
|
||||||
|
begin
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
v_material_id := new.material_id;
|
||||||
|
v_delta_total := 1;
|
||||||
|
if new.estado = 'disponible' then v_delta_disp := 1; end if;
|
||||||
|
|
||||||
|
elsif tg_op = 'DELETE' then
|
||||||
|
v_material_id := old.material_id;
|
||||||
|
v_delta_total := -1;
|
||||||
|
if old.estado = 'disponible' then v_delta_disp := -1; end if;
|
||||||
|
|
||||||
|
elsif tg_op = 'UPDATE' then
|
||||||
|
v_material_id := new.material_id;
|
||||||
|
if old.estado = 'disponible' and new.estado <> 'disponible' then
|
||||||
|
v_delta_disp := -1;
|
||||||
|
elsif old.estado <> 'disponible' and new.estado = 'disponible' then
|
||||||
|
v_delta_disp := 1;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_delta_total <> 0 or v_delta_disp <> 0 then
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_total = cantidad_total + v_delta_total,
|
||||||
|
cantidad_disponible = cantidad_disponible + v_delta_disp
|
||||||
|
where id = v_material_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists prestamos_sync_material_desde_unidad on prestamos.material_unidades;
|
||||||
|
create trigger prestamos_sync_material_desde_unidad
|
||||||
|
after insert or update of estado or delete on prestamos.material_unidades
|
||||||
|
for each row execute function prestamos.sync_material_desde_unidad();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.5) crear_solicitud reescrito: si el material esta trackeado_por_unidad,
|
||||||
|
-- toma la primera unidad 'disponible' (FOR UPDATE SKIP LOCKED) y la
|
||||||
|
-- liga al renglon; deja que sync_stock marque la unidad al aprobar.
|
||||||
|
-- No descuenta stock en creacion (comportamiento igual al 0002).
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function prestamos.crear_solicitud(
|
||||||
|
p_maestro_responsable text,
|
||||||
|
p_notas text,
|
||||||
|
p_items jsonb
|
||||||
|
) returns bigint
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_alumno uuid := auth.uid();
|
||||||
|
v_solicitud_id bigint;
|
||||||
|
v_item jsonb;
|
||||||
|
v_material_id int;
|
||||||
|
v_cantidad int;
|
||||||
|
v_descripcion text;
|
||||||
|
v_disponible int;
|
||||||
|
v_estado_mat text;
|
||||||
|
v_trackeado boolean;
|
||||||
|
v_unidad_id bigint;
|
||||||
|
v_asignadas int;
|
||||||
|
begin
|
||||||
|
if v_alumno is null then
|
||||||
|
raise exception 'no_autenticado';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_maestro_responsable is null or length(trim(p_maestro_responsable)) = 0 then
|
||||||
|
raise exception 'maestro_responsable_requerido';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_items is null or jsonb_typeof(p_items) <> 'array' or jsonb_array_length(p_items) = 0 then
|
||||||
|
raise exception 'items_requeridos';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- lock por orden ascendente de material_id, evita deadlocks
|
||||||
|
for v_material_id in
|
||||||
|
select distinct (elem->>'material_id')::int
|
||||||
|
from jsonb_array_elements(p_items) elem
|
||||||
|
order by 1
|
||||||
|
loop
|
||||||
|
perform 1 from prestamos.materiales where id = v_material_id for update;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
insert into prestamos.solicitudes (alumno_id, estado, maestro_responsable, notas)
|
||||||
|
values (v_alumno, 'pendiente', trim(p_maestro_responsable), nullif(trim(coalesce(p_notas, '')), ''))
|
||||||
|
returning id into v_solicitud_id;
|
||||||
|
|
||||||
|
for v_item in select * from jsonb_array_elements(p_items)
|
||||||
|
loop
|
||||||
|
v_material_id := (v_item->>'material_id')::int;
|
||||||
|
v_cantidad := (v_item->>'cantidad')::int;
|
||||||
|
v_descripcion := nullif(trim(coalesce(v_item->>'descripcion', '')), '');
|
||||||
|
|
||||||
|
if v_material_id is null or v_cantidad is null or v_cantidad <= 0 then
|
||||||
|
raise exception 'item_invalido';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select cantidad_disponible, estado, trackeado_por_unidad
|
||||||
|
into v_disponible, v_estado_mat, v_trackeado
|
||||||
|
from prestamos.materiales where id = v_material_id;
|
||||||
|
|
||||||
|
if v_estado_mat is null then
|
||||||
|
raise exception 'material_no_existe: %', v_material_id;
|
||||||
|
end if;
|
||||||
|
if v_estado_mat <> 'disponible' then
|
||||||
|
raise exception 'material_no_disponible: %', v_material_id;
|
||||||
|
end if;
|
||||||
|
if v_disponible < v_cantidad then
|
||||||
|
raise exception 'stock_insuficiente: %', v_material_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_trackeado then
|
||||||
|
-- asignar 1 unidad por copia solicitada; N renglones (uno por unidad)
|
||||||
|
v_asignadas := 0;
|
||||||
|
while v_asignadas < v_cantidad loop
|
||||||
|
select id into v_unidad_id
|
||||||
|
from prestamos.material_unidades
|
||||||
|
where material_id = v_material_id and estado = 'disponible'
|
||||||
|
order by id
|
||||||
|
for update skip locked
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_unidad_id is null then
|
||||||
|
raise exception 'sin_unidad_disponible: %', v_material_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into prestamos.solicitud_items
|
||||||
|
(solicitud_id, material_id, cantidad, descripcion, material_unidad_id)
|
||||||
|
values (v_solicitud_id, v_material_id, 1, v_descripcion, v_unidad_id);
|
||||||
|
|
||||||
|
-- marcamos la unidad como reservada usando 'prestado' aqui NO seria
|
||||||
|
-- correcto (aun no aprobada). Usamos un truco simple: dejar la
|
||||||
|
-- unidad como 'disponible' hasta que sync_stock la mueva al aprobar,
|
||||||
|
-- pero excluirla de nuevas asignaciones via join con
|
||||||
|
-- solicitud_items donde solicitud.estado='pendiente'.
|
||||||
|
-- Para evitar carrera con otro alumno pidiendo la misma:
|
||||||
|
-- volvemos a lockear via el WHERE de arriba (FOR UPDATE SKIP LOCKED
|
||||||
|
-- solo aguanta hasta el commit). Solucion pragmatica: dentro de
|
||||||
|
-- ESTA misma transaccion no habra colision porque estamos en un
|
||||||
|
-- unico crear_solicitud. Entre transacciones concurrentes, dos
|
||||||
|
-- alumnos pueden llegar a asignar la misma unidad si ambos hacen
|
||||||
|
-- crear_solicitud simultaneo y la unidad aun esta 'disponible':
|
||||||
|
-- para blindar eso, cambiamos a 'mantenimiento' temporal seria feo.
|
||||||
|
-- La opcion limpia: reflejar la reserva marcando la unidad como
|
||||||
|
-- 'prestado' YA en la creacion. Trade-off: la unidad queda ocupada
|
||||||
|
-- aunque el admin luego rechace. El sync_stock al rechazar
|
||||||
|
-- ('pendiente' -> 'rechazado' no dispara sync porque el OLD ya es
|
||||||
|
-- pendiente); necesitamos liberar la unidad en rechazar tambien.
|
||||||
|
-- Elegimos ESTA ruta (marcar como 'prestado' aqui) por seguridad
|
||||||
|
-- de concurrencia, y extendemos sync_stock/log de rechazo para
|
||||||
|
-- liberar unidades cuando se rechaza desde 'pendiente'.
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'prestado'
|
||||||
|
where id = v_unidad_id;
|
||||||
|
|
||||||
|
v_asignadas := v_asignadas + 1;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- para materiales trackeados, cantidad_disponible se mantiene
|
||||||
|
-- automatico via sync_material_desde_unidad(); nada mas que hacer.
|
||||||
|
else
|
||||||
|
insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad, descripcion)
|
||||||
|
values (v_solicitud_id, v_material_id, v_cantidad, v_descripcion);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return v_solicitud_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function prestamos.crear_solicitud(text, text, jsonb) to authenticated;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.6) sync_stock: cubrir tambien la ruta pendiente->rechazado para
|
||||||
|
-- liberar unidades reservadas (crear_solicitud las marca 'prestado'
|
||||||
|
-- preventivamente para bloquear la carrera).
|
||||||
|
-- Para materiales no-trackeados, el stock nunca se descuenta antes
|
||||||
|
-- de aprobar, asi que este bloque solo importa para material_unidades.
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function prestamos.sync_stock()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
item record;
|
||||||
|
begin
|
||||||
|
-- sacar stock: !aprobado/activo -> aprobado/activo
|
||||||
|
if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then
|
||||||
|
for item in
|
||||||
|
select material_id, cantidad, material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id
|
||||||
|
order by material_id
|
||||||
|
loop
|
||||||
|
-- materiales no-trackeados: descontar cantidad_disponible
|
||||||
|
-- (para trackeados, la unidad ya se marco 'prestado' en crear_solicitud
|
||||||
|
-- y sync_material_desde_unidad ya bajo cantidad_disponible en su momento;
|
||||||
|
-- no volvemos a bajar aqui)
|
||||||
|
if item.material_unidad_id is null then
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_disponible = cantidad_disponible - item.cantidad
|
||||||
|
where id = item.material_id;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- devolver stock: aprobado/activo -> devuelto/rechazado
|
||||||
|
if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then
|
||||||
|
for item in
|
||||||
|
select material_id, cantidad, material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id
|
||||||
|
order by material_id
|
||||||
|
loop
|
||||||
|
if item.material_unidad_id is null then
|
||||||
|
update prestamos.materiales
|
||||||
|
set cantidad_disponible = cantidad_disponible + item.cantidad
|
||||||
|
where id = item.material_id;
|
||||||
|
else
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'disponible'
|
||||||
|
where id = item.material_unidad_id;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- pendiente -> rechazado: liberar unidades reservadas
|
||||||
|
-- (no toca cantidad_disponible de no-trackeados: aun no se descontaba)
|
||||||
|
if new.estado = 'rechazado' and old.estado = 'pendiente' then
|
||||||
|
for item in
|
||||||
|
select material_unidad_id
|
||||||
|
from prestamos.solicitud_items
|
||||||
|
where solicitud_id = new.id and material_unidad_id is not null
|
||||||
|
loop
|
||||||
|
update prestamos.material_unidades
|
||||||
|
set estado = 'disponible'
|
||||||
|
where id = item.material_unidad_id;
|
||||||
|
end loop;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.7) RPC reasignar_unidad: admin cambia la unidad ligada a un renglon.
|
||||||
|
-- Solo permitido si la solicitud esta en aprobado/activo y el item
|
||||||
|
-- ya tiene unidad. La unidad nueva debe ser del mismo material y
|
||||||
|
-- estar 'disponible'. Log en audit_log con accion='reasignar_unidad'.
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function prestamos.reasignar_unidad(
|
||||||
|
p_item_id bigint,
|
||||||
|
p_nueva_unidad_id bigint
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = prestamos, pg_temp
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_solicitud_id bigint;
|
||||||
|
v_estado_sol text;
|
||||||
|
v_material_id int;
|
||||||
|
v_unidad_vieja bigint;
|
||||||
|
v_material_nueva int;
|
||||||
|
v_estado_nueva text;
|
||||||
|
begin
|
||||||
|
if not prestamos.is_admin() then
|
||||||
|
raise exception 'no_autorizado';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select si.solicitud_id, s.estado, si.material_id, si.material_unidad_id
|
||||||
|
into v_solicitud_id, v_estado_sol, v_material_id, v_unidad_vieja
|
||||||
|
from prestamos.solicitud_items si
|
||||||
|
join prestamos.solicitudes s on s.id = si.solicitud_id
|
||||||
|
where si.id = p_item_id;
|
||||||
|
|
||||||
|
if v_solicitud_id is null then
|
||||||
|
raise exception 'item_no_existe';
|
||||||
|
end if;
|
||||||
|
if v_estado_sol not in ('aprobado','activo') then
|
||||||
|
raise exception 'solicitud_no_activa';
|
||||||
|
end if;
|
||||||
|
if v_unidad_vieja is null then
|
||||||
|
raise exception 'item_sin_unidad';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select material_id, estado into v_material_nueva, v_estado_nueva
|
||||||
|
from prestamos.material_unidades
|
||||||
|
where id = p_nueva_unidad_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if v_material_nueva is null then
|
||||||
|
raise exception 'unidad_no_existe';
|
||||||
|
end if;
|
||||||
|
if v_material_nueva <> v_material_id then
|
||||||
|
raise exception 'unidad_de_otro_material';
|
||||||
|
end if;
|
||||||
|
if v_estado_nueva <> 'disponible' then
|
||||||
|
raise exception 'unidad_no_disponible';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update prestamos.material_unidades set estado = 'disponible' where id = v_unidad_vieja;
|
||||||
|
update prestamos.material_unidades set estado = 'prestado' where id = p_nueva_unidad_id;
|
||||||
|
update prestamos.solicitud_items set material_unidad_id = p_nueva_unidad_id where id = p_item_id;
|
||||||
|
|
||||||
|
insert into prestamos.audit_log
|
||||||
|
(solicitud_id, actor_id, accion, estado_anterior, estado_nuevo, payload)
|
||||||
|
values (
|
||||||
|
v_solicitud_id, auth.uid(), 'reasignar_unidad', null, null,
|
||||||
|
jsonb_build_object('item_id', p_item_id, 'unidad_vieja', v_unidad_vieja, 'unidad_nueva', p_nueva_unidad_id)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function prestamos.reasignar_unidad(bigint, bigint) to authenticated;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- B.8) RLS material_unidades: todos leen, solo admin escribe
|
||||||
|
-- ============================================================
|
||||||
|
alter table prestamos.material_unidades enable row level security;
|
||||||
|
|
||||||
|
create policy material_unidades_read_all on prestamos.material_unidades
|
||||||
|
for select to authenticated using (true);
|
||||||
|
|
||||||
|
create policy material_unidades_admin_write on prestamos.material_unidades
|
||||||
|
for all to authenticated
|
||||||
|
using (prestamos.is_admin())
|
||||||
|
with check (prestamos.is_admin());
|
||||||
|
|
||||||
|
grant select, insert, update, delete on prestamos.material_unidades to authenticated, service_role;
|
||||||
|
grant usage, select on prestamos.material_unidades_id_seq to authenticated, service_role;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- C) Publication para Realtime (badge de pendientes)
|
||||||
|
-- replica identity default alcanza para INSERT/DELETE; para UPDATE
|
||||||
|
-- Realtime tambien emite pero solo con columnas de PK. Suficiente
|
||||||
|
-- para el caso de uso (contar cambios a/desde 'pendiente').
|
||||||
|
-- ============================================================
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from pg_publication_tables
|
||||||
|
where pubname = 'supabase_realtime'
|
||||||
|
and schemaname = 'prestamos'
|
||||||
|
and tablename = 'solicitudes'
|
||||||
|
) then
|
||||||
|
alter publication supabase_realtime add table prestamos.solicitudes;
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- D) Bucket publico 'materiales-fotos' + policies de storage
|
||||||
|
-- Lectura anonima (bucket publico); escritura solo si is_admin().
|
||||||
|
-- ============================================================
|
||||||
|
insert into storage.buckets (id, name, public)
|
||||||
|
values ('materiales-fotos', 'materiales-fotos', true)
|
||||||
|
on conflict (id) do update set public = true;
|
||||||
|
|
||||||
|
drop policy if exists "materiales_fotos_read_all" on storage.objects;
|
||||||
|
drop policy if exists "materiales_fotos_admin_write" on storage.objects;
|
||||||
|
|
||||||
|
create policy "materiales_fotos_read_all" on storage.objects
|
||||||
|
for select to anon, authenticated
|
||||||
|
using (bucket_id = 'materiales-fotos');
|
||||||
|
|
||||||
|
create policy "materiales_fotos_admin_write" on storage.objects
|
||||||
|
for all to authenticated
|
||||||
|
using (bucket_id = 'materiales-fotos' and prestamos.is_admin())
|
||||||
|
with check (bucket_id = 'materiales-fotos' and prestamos.is_admin());
|
||||||
|
|
||||||
|
commit;
|
||||||
Reference in New Issue
Block a user