diff --git a/package-lock.json b/package-lock.json index dde8cc3..4d965ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "astro": "^7.2.2", "react": "^19.2.8", "react-dom": "^19.2.8", + "recharts": "^3.10.1", "tailwindcss": "^4.3.3" }, "engines": { @@ -1896,6 +1897,32 @@ "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": { "version": "1.2.4", "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==", "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": { "version": "2.112.3", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.3.tgz", @@ -2951,6 +2990,69 @@ "@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": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3017,6 +3119,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "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": { "version": "1.3.3", "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==", "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": { "version": "4.4.3", "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": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", @@ -3747,6 +3982,18 @@ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "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": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -4149,12 +4396,31 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -5039,6 +5305,36 @@ "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": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -5061,6 +5357,51 @@ "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": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -5085,6 +5426,12 @@ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "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": { "version": "1.0.0", "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==", "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": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", @@ -5749,6 +6102,15 @@ "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": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -5791,6 +6153,28 @@ "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": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", diff --git a/package.json b/package.json index 39e6406..039963a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "astro": "^7.2.2", "react": "^19.2.8", "react-dom": "^19.2.8", + "recharts": "^3.10.1", "tailwindcss": "^4.3.3" } } diff --git a/src/components/admin/BadgeSolicitudes.tsx b/src/components/admin/BadgeSolicitudes.tsx new file mode 100644 index 0000000..5531bf0 --- /dev/null +++ b/src/components/admin/BadgeSolicitudes.tsx @@ -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('[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('[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; +} diff --git a/src/components/admin/estadisticas/Chart.tsx b/src/components/admin/estadisticas/Chart.tsx new file mode 100644 index 0000000..9c25dde --- /dev/null +++ b/src/components/admin/estadisticas/Chart.tsx @@ -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 = { + 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 ( +
+ {label !== undefined &&
{label}
} + {payload.map((p: any, i: number) => ( +
+ {p.name}: {p.value} +
+ ))} +
+ ); +} + +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 ( + + + + + + } cursor={{ fill: 'rgba(56,56,56,0.06)' }} /> + + + + + + ); + } + + if (props.tipo === 'linea') { + const datos = props.datos.map((d) => ({ ...d, fechaCorta: fmtFechaCorta(d.fecha) })); + return ( + + + + + + } cursor={{ stroke: INK, strokeWidth: 1, strokeDasharray: '2 3' }} /> + + + + ); + } + + // donut + const datos = props.datos; + const total = datos.reduce((s, d) => s + d.valor, 0); + return ( + + + } /> + (total ? `${label} (${valor})` : label)} + labelLine={{ stroke: PENCIL }} + > + {datos.map((d, i) => ( + + ))} + + + + ); +} diff --git a/src/components/admin/inventario/MaterialForm.tsx b/src/components/admin/inventario/MaterialForm.tsx index e649d71..a6114ac 100644 --- a/src/components/admin/inventario/MaterialForm.tsx +++ b/src/components/admin/inventario/MaterialForm.tsx @@ -1,4 +1,8 @@ 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 Material = { @@ -10,6 +14,8 @@ type Material = { cantidad_disponible: number; numero_inventario: string | null; estado: 'disponible' | 'mantenimiento' | 'baja'; + imagen_path?: string | null; + trackeado_por_unidad?: boolean; }; type Props = { @@ -18,10 +24,13 @@ type Props = { categorias: Categoria[]; }; +const BUCKET = 'materiales-fotos'; + export default function MaterialForm({ mode, material, categorias }: Props) { const dialogRef = useRef(null); const firstFieldRef = useRef(null); const errorRef = useRef(null); + const fileInputRef = useRef(null); const [nombre, setNombre] = useState(material?.nombre ?? ''); const [categoriaId, setCategoriaId] = useState( @@ -33,11 +42,26 @@ export default function MaterialForm({ mode, material, categorias }: Props) { ); const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? ''); const [estado, setEstado] = useState(material?.estado ?? 'disponible'); + const [trackeado, setTrackeado] = useState(material?.trackeado_por_unidad ?? false); + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [imagenPath, setImagenPath] = useState(material?.imagen_path ?? null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); 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 = () => { setError(null); if (mode === 'create') { @@ -47,6 +71,9 @@ export default function MaterialForm({ mode, material, categorias }: Props) { setCantidadTotal('0'); setNumeroInventario(''); setEstado('disponible'); + setTrackeado(false); + setFile(null); + setImagenPath(null); } dialogRef.current?.showModal(); queueMicrotask(() => firstFieldRef.current?.focus()); @@ -68,49 +95,101 @@ export default function MaterialForm({ mode, material, categorias }: Props) { }); useEffect(() => { - if (error) { - errorRef.current?.focus(); - } + if (error) errorRef.current?.focus(); }, [error]); + async function uploadFotoFor(id: number): Promise { + 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) => { e.preventDefault(); if (loading) return; setLoading(true); setError(null); - const payload = { - nombre: nombre.trim(), - categoria_id: categoriaId === '' ? null : Number(categoriaId), - descripcion: descripcion.trim() || null, - cantidad_total: Number(cantidadTotal), - numero_inventario: numeroInventario.trim() || null, - estado, - }; - - if (!payload.nombre) { + const nombreT = nombre.trim(); + if (!nombreT) { setError('El nombre es obligatorio'); setLoading(false); 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'); setLoading(false); return; } - const url = - mode === 'create' ? '/api/admin/materiales' : `/api/admin/materiales/${material!.id}`; - const method = mode === 'create' ? 'POST' : 'PATCH'; - try { - const res = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const json = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar'); + if (mode === 'create') { + // 1) crear material sin imagen + const res = await fetch('/api/admin/materiales', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + 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 = { + 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(() => ({})); + if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar'); + } + dialogRef.current?.close(); location.reload(); } catch (err) { @@ -123,6 +202,8 @@ export default function MaterialForm({ mode, material, categorias }: Props) { const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar'; const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost'; + const currentImg = previewUrl ?? imgUrl(imagenPath); + return ( <> + {/* Foto */}
- + +
+
+ {currentImg ? ( + + ) : ( + + )} +
+
+ setFile(e.target.files?.[0] ?? null)} + /> + {(file || imagenPath) && ( + + )} +
+
+
+ +
+
- +
- +
- +