feat: Add visual summary cards for Agro/Grains and implement 24h time format

This commit is contained in:
2025-07-01 16:05:26 -03:00
parent c4eead1033
commit ab9e77fa81
22 changed files with 908 additions and 125 deletions

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/eldia.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Mercadors - El Día</title>
</head>
<body>
<div id="root"></div>

View File

@@ -12,9 +12,12 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0",
"@mui/material": "^7.2.0",
"@types/recharts": "^1.8.29",
"axios": "^1.10.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"recharts": "^3.0.2"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
@@ -1451,6 +1454,32 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
"integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"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/pluginutils": {
"version": "1.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
@@ -1738,6 +1767,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"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/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1783,6 +1824,69 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
"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": "1.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz",
"integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==",
"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": "1.3.12",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz",
"integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "^1"
}
},
"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.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1837,6 +1941,22 @@
"@types/react": "*"
}
},
"node_modules/@types/recharts": {
"version": "1.8.29",
"resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.29.tgz",
"integrity": "sha512-ulKklaVsnFIIhTQsQw226TnOibrddW1qUQNFVhoQEyY1Z7FRQrNecFCGt7msRuJseudzE9czVawZb17dK/aPXw==",
"license": "MIT",
"dependencies": {
"@types/d3-shape": "^1",
"@types/react": "*"
}
},
"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/@typescript-eslint/eslint-plugin": {
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
@@ -2448,6 +2568,127 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"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.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"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.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -2465,6 +2706,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/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2566,6 +2813,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.39.5",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.5.tgz",
"integrity": "sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
@@ -2807,6 +3064,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3164,6 +3427,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3190,6 +3463,15 @@
"node": ">=0.8.19"
}
},
"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/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -3760,12 +4042,44 @@
"react": "^19.1.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
"integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+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.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3792,6 +4106,54 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/recharts": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.0.2.tgz",
"integrity": "sha512-eDc3ile9qJU9Dp/EekSthQPhAVPG48/uM47jk+PF7VBQngxeW3cwQpPHb/GHC1uqwyCRWXcIrDzuHRVrnRryoQ==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"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/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -3998,6 +4360,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"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/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -4160,6 +4528,46 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"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/victory-vendor/node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/vite": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz",

View File

@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
@@ -14,9 +14,12 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0",
"@mui/material": "^7.2.0",
"@types/recharts": "^1.8.29",
"axios": "^1.10.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"recharts": "^3.0.2"
},
"devDependencies": {
"@eslint/js": "^9.29.0",

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="89" height="69">
<path d="M0 0 C29.37 0 58.74 0 89 0 C89 22.77 89 45.54 89 69 C59.63 69 30.26 69 0 69 C0 46.23 0 23.46 0 0 Z " fill="#008FBD" transform="translate(0,0)"/>
<path d="M0 0 C3.3 0 6.6 0 10 0 C13.04822999 3.04822999 12.29337257 6.08805307 12.32226562 10.25390625 C12.31904297 11.32511719 12.31582031 12.39632812 12.3125 13.5 C12.32861328 14.57121094 12.34472656 15.64242187 12.36132812 16.74609375 C12.36197266 17.76832031 12.36261719 18.79054688 12.36328125 19.84375 C12.36775269 21.25430664 12.36775269 21.25430664 12.37231445 22.69335938 C12.1880188 23.83514648 12.1880188 23.83514648 12 25 C9 27 9 27 0 27 C0 18.09 0 9.18 0 0 Z " fill="#000000" transform="translate(0,21)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C17 3.3 17 6.6 17 10 C25.58 10 34.16 10 43 10 C43 10.99 43 11.98 43 13 C34.42 13 25.84 13 17 13 C17 17.29 17 21.58 17 26 C11.39 26 5.78 26 0 26 C0 24.68 0 23.36 0 22 C4.62 22 9.24 22 14 22 C14 16.06 14 10.12 14 4 C9.38 4 4.76 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#000000" transform="translate(46,21)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -3,6 +3,9 @@ import { BolsaLocalWidget } from './components/BolsaLocalWidget';
import { MercadoAgroWidget } from './components/MercadoAgroWidget';
import { GranosWidget } from './components/GranosWidget';
import { BolsaUsaWidget } from './components/BolsaUsaWidget';
import { MercadoAgroCardWidget } from './components/MercadoAgroCardWidget';
import { GranosCardWidget } from './components/GranosCardWidget';
import { Divider } from '@mui/material';
function App() {
return (
@@ -11,7 +14,7 @@ function App() {
<AppBar position="static" sx={{ backgroundColor: '#028fbe' }}>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Mercados Modernos - Demo
Mercados - El Día
</Typography>
</Toolbar>
</AppBar>
@@ -27,16 +30,36 @@ function App() {
<MercadoAgroWidget />
</Box>
{/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */}
<Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Mercado Agroganadero de Cañuelas</Typography>
<MercadoAgroCardWidget />
<Divider sx={{ my: 3 }}>
<Typography variant="caption" color="text.secondary">Datos Detallados</Typography>
</Divider>
<MercadoAgroWidget />
</Box>
{/* --- Sección 2: Granos - Bolsa de Comercio de Rosario --- */}
<Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Granos - Bolsa de Comercio de Rosario</Typography>
<GranosWidget />
<GranosCardWidget />
<Divider sx={{ my: 3 }}>
<Typography variant="caption" color="text.secondary">Datos Detallados</Typography>
</Divider>
<GranosWidget />
</Box>
{/* --- Sección 2: Granos - Bolsa de Comercio de Rosario --- */}
<Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Granos - Bolsa de Comercio de Rosario</Typography>
<GranosWidget />
</Box>
{/* --- Sección 3: Mercado de Valores de Estados Unidos --- */}
<Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Mercado de Valores de Estados Unidos</Typography>
<BolsaUsaWidget />
<BolsaUsaWidget />
</Box>
{/* --- Sección 4: Mercado de Valores Local --- */}
@@ -47,9 +70,9 @@ function App() {
</Container>
<Box component="footer" sx={{ p: 2, mt: 'auto', backgroundColor: '#f5f5f5', textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Desarrollado por El Día - {new Date().getFullYear()}
</Typography>
<Typography variant="body2" color="text.secondary">
Desarrollado por El Día - {new Date().getFullYear()}
</Typography>
</Box>
</>
);

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
// Durante el desarrollo, nuestra API corre en un puerto específico (ej. 5045).
// En producción, esto debería apuntar a la URL real del servidor donde se despliegue la API.
const API_BASE_URL = 'http://localhost:5045/api';
const API_BASE_URL = 'http://192.168.10.78:5045/api';
const apiClient = axios.create({
baseURL: API_BASE_URL,

View File

@@ -1,38 +1,42 @@
import { useState } from 'react';
import {
Box, CircularProgress, Alert, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
DialogContent, IconButton
} from '@mui/material';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import CloseIcon from '@mui/icons-material/Close';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove'; // Para cambios neutros
import RemoveIcon from '@mui/icons-material/Remove';
import { formatFullDateTime } from '../utils/formatters';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { HistoricalChartWidget } from './HistoricalChartWidget';
// Función para formatear números
const formatNumber = (num: number) => {
return new Intl.NumberFormat('es-AR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
};
const formatNumber = (num: number) => new Intl.NumberFormat('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num);
// Componente para mostrar la variación con color e icono
const Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
return (
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>
{formatNumber(value)}%
</Typography>
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatNumber(value)}%</Typography>
</Box>
);
};
export const BolsaLocalWidget = () => {
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
const handleRowClick = (ticker: string) => {
setSelectedTicker(ticker);
};
const handleCloseDialog = () => {
setSelectedTicker(null);
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -47,38 +51,52 @@ export const BolsaLocalWidget = () => {
}
return (
<TableContainer component={Paper}>
<Table size="small" aria-label="tabla bolsa local">
<TableHead>
<TableRow>
<TableCell>Símbolo</TableCell>
<TableCell align="right">Precio Actual</TableCell>
<TableCell align="right">Apertura</TableCell>
<TableCell align="right">Cierre Anterior</TableCell>
<TableCell align="center">% Cambio</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.map((row) => (
<TableRow key={row.ticker} hover>
<TableCell component="th" scope="row">
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
</TableCell>
<TableCell align="right">${formatNumber(row.precioActual)}</TableCell>
<TableCell align="right">${formatNumber(row.apertura)}</TableCell>
<TableCell align="right">${formatNumber(row.cierreAnterior)}</TableCell>
<TableCell align="center">
<Variacion value={row.porcentajeCambio} />
</TableCell>
<>
<TableContainer component={Paper}>
<Box sx={{ p: 1, m: 0 }}>
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
</Typography>
</Box>
<Table size="small" aria-label="tabla bolsa local">
<TableHead>
<TableRow>
<TableCell>Símbolo</TableCell>
<TableCell align="right">Precio Actual</TableCell>
<TableCell align="right">Apertura</TableCell>
<TableCell align="right">Cierre Anterior</TableCell>
<TableCell align="center">% Cambio</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}>
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
Fuente: Yahoo Finance
</Typography>
</Tooltip>
</TableContainer>
</TableHead>
<TableBody>
{data.map((row) => (
<TableRow key={row.ticker} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(row.ticker)}>
<TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
<TableCell align="right">${formatNumber(row.precioActual)}</TableCell>
<TableCell align="right">${formatNumber(row.apertura)}</TableCell>
<TableCell align="right">${formatNumber(row.cierreAnterior)}</TableCell>
<TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle sx={{ m: 0, p: 2 }}>
Historial de 30 días para: {selectedTicker}
<IconButton
aria-label="close"
onClick={handleCloseDialog}
sx={{ position: 'absolute', right: 8, top: 8, color: (theme) => theme.palette.grey[500] }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" />}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -1,36 +1,44 @@
import { useState } from 'react';
import {
Box, CircularProgress, Alert, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
DialogContent, IconButton
} from '@mui/material';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import CloseIcon from '@mui/icons-material/Close';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove';
import { formatFullDateTime } from '../utils/formatters';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { HistoricalChartWidget } from './HistoricalChartWidget';
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US', { // Usamos formato de EEUU
style: 'currency',
currency: 'USD',
}).format(num);
};
// Usamos el formato de EEUU para los precios en dólares
const formatCurrency = (num: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num);
const formatPercentage = (num: number) => num.toFixed(2);
const Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
return (
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>
{value.toFixed(2)}%
</Typography>
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatPercentage(value)}%</Typography>
</Box>
);
};
export const BolsaUsaWidget = () => {
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
const handleRowClick = (ticker: string) => {
setSelectedTicker(ticker);
};
const handleCloseDialog = () => {
setSelectedTicker(null);
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -40,43 +48,58 @@ export const BolsaUsaWidget = () => {
return <Alert severity="error">{error}</Alert>;
}
// Recordatorio de que el fetcher puede estar desactivado
if (!data || data.length === 0) {
return <Alert severity="info">No hay datos disponibles para el mercado de EEUU. (El fetcher puede estar desactivado)</Alert>;
return <Alert severity="info">No hay datos disponibles para el mercado de EEUU. (El fetcher podría estar desactivado en el Worker).</Alert>;
}
return (
<TableContainer component={Paper}>
<Table size="small" aria-label="tabla bolsa eeuu">
<TableHead>
<TableRow>
<TableCell>Símbolo</TableCell>
<TableCell align="right">Precio Actual</TableCell>
<TableCell align="right">Apertura</TableCell>
<TableCell align="right">Cierre Anterior</TableCell>
<TableCell align="center">% Cambio</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.map((row) => (
<TableRow key={row.ticker} hover>
<TableCell component="th" scope="row">
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
</TableCell>
<TableCell align="right">{formatNumber(row.precioActual)}</TableCell>
<TableCell align="right">{formatNumber(row.apertura)}</TableCell>
<TableCell align="right">{formatNumber(row.cierreAnterior)}</TableCell>
<TableCell align="center">
<Variacion value={row.porcentajeCambio} />
</TableCell>
<>
<TableContainer component={Paper}>
<Box sx={{ p: 1, pb: 0 }}>
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
</Typography>
</Box>
<Table size="small" aria-label="tabla bolsa eeuu">
<TableHead>
<TableRow>
<TableCell>Símbolo</TableCell>
<TableCell align="right">Precio Actual</TableCell>
<TableCell align="right">Apertura</TableCell>
<TableCell align="right">Cierre Anterior</TableCell>
<TableCell align="center">% Cambio</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}>
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
Fuente: Finnhub
</Typography>
</Tooltip>
</TableContainer>
</TableHead>
<TableBody>
{data.map((row) => (
<TableRow key={row.ticker} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(row.ticker)}>
<TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
<TableCell align="right">{formatCurrency(row.precioActual)}</TableCell>
<TableCell align="right">{formatCurrency(row.apertura)}</TableCell>
<TableCell align="right">{formatCurrency(row.cierreAnterior)}</TableCell>
<TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle sx={{ m: 0, p: 2 }}>
Historial de 30 días para: {selectedTicker}
<IconButton
aria-label="close"
onClick={handleCloseDialog}
sx={{ position: 'absolute', right: 8, top: 8, color: (theme) => theme.palette.grey[500] }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" />}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,102 @@
import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove';
// Iconos de react-icons para cada grano
import { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi";
import { TbGrain } from "react-icons/tb";
import type { CotizacionGrano } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { formatCurrency, formatDateOnly } from '../utils/formatters';
// Función para elegir el icono según el nombre del grano
const getGrainIcon = (nombre: string) => {
switch (nombre.toLowerCase()) {
case 'girasol':
return <GiSunflower size={28} color="#fbc02d" />;
case 'trigo':
return <GiWheat size={28} color="#fbc02d" />;
case 'sorgo':
return <TbGrain size={28} color="#fbc02d" />;
case 'maiz':
return <GiCorn size={28} color="#fbc02d" />;
default:
return <GiGrain size={28} color="#fbc02d" />;
}
};
// Subcomponente para una única tarjeta de grano
const GranoCard = ({ grano }: { grano: CotizacionGrano }) => {
const isPositive = grano.variacionPrecio > 0;
const isNegative = grano.variacionPrecio < 0;
const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary';
const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon;
return (
<Paper
elevation={2}
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
flex: '1 1 180px',
minWidth: '180px',
maxWidth: '220px',
height: '160px',
borderTop: `4px solid ${isPositive ? '#2e7d32' : isNegative ? '#d32f2f' : '#bdbdbd'}`
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
{getGrainIcon(grano.nombre)}
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', ml: 1 }}>
{grano.nombre}
</Typography>
</Box>
<Box sx={{ textAlign: 'center', my: 1 }}>
<Typography variant="h5" component="p" sx={{ fontWeight: 'bold' }}>
${formatCurrency(grano.precio)}
</Typography>
<Typography variant="caption" color="text.secondary">
por Tonelada
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Icon sx={{ fontSize: '1.1rem', mr: 0.5 }} />
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{formatCurrency(grano.variacionPrecio)}
</Typography>
</Box>
<Typography variant="caption" align="center" sx={{ mt: 1, color: 'text.secondary' }}>
Operación: {formatDateOnly(grano.fechaOperacion)}
</Typography>
</Paper>
);
};
export const GranosCardWidget = () => {
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos');
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
if (!data || data.length === 0) {
return <Alert severity="info">No hay datos de granos disponibles.</Alert>;
}
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}>
{data.map((grano) => (
<GranoCard key={grano.nombre} grano={grano} />
))}
</Box>
);
};

View File

@@ -0,0 +1,45 @@
import { Box, CircularProgress, Alert } from '@mui/material';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
interface HistoricalChartWidgetProps {
ticker: string;
mercado: 'Local' | 'EEUU';
}
// Formateador para el eje X (muestra DD/MM)
const formatXAxis = (tickItem: string) => {
const date = new Date(tickItem);
return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
};
export const HistoricalChartWidget = ({ ticker, mercado }: HistoricalChartWidgetProps) => {
// Usamos el hook para obtener los datos del historial de los últimos 30 días
const { data, loading, error } = useApiData<CotizacionBolsa[]>(`/mercados/bolsa/history/${ticker}?mercado=${mercado}&dias=30`);
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4, height: 300 }}><CircularProgress /></Box>;
}
if (error) {
return <Alert severity="error" sx={{height: 300}}>{error}</Alert>;
}
if (!data || data.length < 2) {
return <Alert severity="info" sx={{height: 300}}>No hay suficientes datos históricos para graficar.</Alert>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} />
<YAxis domain={['dataMin - 1', 'dataMax + 1']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} />
<Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio']} />
<Legend />
<Line type="monotone" dataKey="precioActual" name="Precio de Cierre" stroke="#8884d8" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
};

View File

@@ -0,0 +1,79 @@
import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material';
import { PiCow } from "react-icons/pi"; // Un icono divertido para "cabezas"
import ScaleIcon from '@mui/icons-material/Scale'; // Para kilos
import type { CotizacionGanado } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { formatCurrency, formatInteger } from '../utils/formatters';
const AgroCard = ({ categoria }: { categoria: CotizacionGanado }) => {
return (
<Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px' }}>
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2 }}>
{categoria.categoria}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">Precio Máximo:</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: 'success.main' }}>${formatCurrency(categoria.maximo)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: 'error.main' }}>${formatCurrency(categoria.minimo)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="body2" color="text.secondary">Precio Mediano:</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(categoria.mediano)}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, pt: 1, borderTop: 1, borderColor: 'divider' }}>
<Box sx={{ textAlign: 'center' }}>
<PiCow size={28}/>
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(categoria.cabezas)}</Typography>
<Typography variant="caption" color="text.secondary">Cabezas</Typography>
</Box>
<Box sx={{ textAlign: 'center' }}>
<ScaleIcon color="action" />
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(categoria.kilosTotales)}</Typography>
<Typography variant="caption" color="text.secondary">Kilos</Typography>
</Box>
</Box>
</Paper>
);
};
// Este widget agrupa los datos por categoría para un resumen más limpio.
export const MercadoAgroCardWidget = () => {
const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
if (!data || data.length === 0) {
return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>;
}
// Agrupamos y sumamos los datos por categoría principal
const resumenPorCategoria = data.reduce((acc, item) => {
if (!acc[item.categoria]) {
acc[item.categoria] = { ...item };
} else {
acc[item.categoria].cabezas += item.cabezas;
acc[item.categoria].kilosTotales += item.kilosTotales;
acc[item.categoria].importeTotal += item.importeTotal;
acc[item.categoria].maximo = Math.max(acc[item.categoria].maximo, item.maximo);
acc[item.categoria].minimo = Math.min(acc[item.categoria].minimo, item.minimo);
}
return acc;
}, {} as Record<string, CotizacionGanado>);
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}>
{Object.values(resumenPorCategoria).map(categoria => (
<AgroCard key={categoria.categoria} categoria={categoria} />
))}
</Box>
);
};

View File

@@ -53,7 +53,7 @@ export const MercadoAgroWidget = () => {
<TableCell align="right">${formatNumber(row.minimo)}</TableCell>
<TableCell align="right">${formatNumber(row.mediano)}</TableCell>
<TableCell align="right">{formatNumber(row.cabezas, 0)}</TableCell>
<TableCell align="right">{formatNumber(row.kilosTotales, 0)} Kg</TableCell>
<TableCell align="right">{formatNumber(row.kilosTotales, 0)}</TableCell>
<TableCell align="right">${formatNumber(row.importeTotal)}</TableCell>
</TableRow>
))}

View File

@@ -0,0 +1,38 @@
// Formateadores numéricos
export const formatCurrency = (num: number, currency = 'ARS') => {
const style = currency === 'USD' ? 'currency' : 'decimal';
const locale = currency === 'USD' ? 'en-US' : 'es-AR';
return new Intl.NumberFormat(locale, {
style: style,
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
};
export const formatInteger = (num: number) => {
return new Intl.NumberFormat('es-AR').format(num);
};
// Formateadores de fecha y hora
export const formatFullDateTime = (dateString: string) => {
return new Date(dateString).toLocaleString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23', // <--- LA CLAVE PARA EL FORMATO 24HS
timeZone: 'America/Argentina/Buenos_Aires',
});
};
export const formatDateOnly = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'America/Argentina/Buenos_Aires',
});
};

View File

@@ -4,4 +4,8 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: true, // o "0.0.0.0"
port: 5173 // el puerto que uses, opcional
}
})

View File

@@ -96,5 +96,22 @@ namespace Mercados.Api.Controllers
return StatusCode(500, "Ocurrió un error interno en el servidor.");
}
}
[HttpGet("bolsa/history/{ticker}")]
[ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GetBolsaHistory(string ticker, [FromQuery] string mercado = "Local", [FromQuery] int dias = 30)
{
try
{
var data = await _bolsaRepo.ObtenerHistorialPorTickerAsync(ticker, mercado, dias);
return Ok(data);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener historial para el ticker {Ticker}.", ticker);
return StatusCode(500, "Ocurrió un error interno en el servidor.");
}
}
}
}

View File

@@ -1,6 +1,5 @@
@Mercados.Api_HostAddress = http://localhost:5045
@Mercados.Api_HostAddress = http://192.168.10.78:5045
GET {{Mercados.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -16,7 +16,7 @@ builder.Services.AddCors(options =>
options.AddPolicy(name: MyAllowSpecificOrigins,
policy =>
{
policy.WithOrigins("http://localhost:5173")
policy.WithOrigins("http://localhost:5173", "http://192.168.10.78:5173")
.AllowAnyHeader()
.AllowAnyMethod();
});
@@ -47,7 +47,6 @@ builder.Services
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

View File

@@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5045",
"applicationUrl": "http://0.0.0.0:5045",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7256;http://localhost:5045",
"applicationUrl": "https://0.0.0.0:7256;http://0.0.0.0:5045",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@@ -28,7 +28,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
{
using IDbConnection connection = _connectionFactory.CreateConnection();
// Esta consulta SQL es un poco más avanzada. Usa una "Common Table Expression" (CTE)
// Esta consulta usa una "Common Table Expression" (CTE)
// y la función ROW_NUMBER() para obtener el registro más reciente para cada Ticker
// dentro del mercado especificado. Es extremadamente eficiente.
const string sql = @"
@@ -50,5 +50,24 @@ namespace Mercados.Infrastructure.Persistence.Repositories
return await connection.QueryAsync<CotizacionBolsa>(sql, new { Mercado = mercado });
}
public async Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias)
{
using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"
SELECT
Id, Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro
FROM
CotizacionesBolsa
WHERE
Ticker = @Ticker
AND Mercado = @Mercado
AND FechaRegistro >= DATEADD(day, -@Dias, GETUTCDATE())
ORDER BY
FechaRegistro ASC;"; // ASC es importante para dibujar la línea del gráfico
return await connection.QueryAsync<CotizacionBolsa>(sql, new { Ticker = ticker, Mercado = mercado, Dias = dias });
}
}
}

View File

@@ -6,5 +6,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
{
Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones);
Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado);
Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias);
}
}

View File

@@ -35,7 +35,7 @@ IHost host = Host.CreateDefaultBuilder(args)
// que todos implementan la interfaz IDataFetcher.
services.AddScoped<IDataFetcher, MercadoAgroFetcher>();
services.AddScoped<IDataFetcher, BcrDataFetcher>();
//services.AddScoped<IDataFetcher, FinnhubDataFetcher>();
services.AddScoped<IDataFetcher, FinnhubDataFetcher>();
services.AddScoped<IDataFetcher, YahooFinanceDataFetcher>();
// El cliente HTTP es fundamental para hacer llamadas a APIs externas.