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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Mercadors - El Día</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -12,9 +12,12 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0", "@mui/icons-material": "^7.2.0",
"@mui/material": "^7.2.0", "@mui/material": "^7.2.0",
"@types/recharts": "^1.8.29",
"axios": "^1.10.0", "axios": "^1.10.0",
"react": "^19.1.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": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
@@ -1451,6 +1454,32 @@
"url": "https://opencollective.com/popperjs" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.19", "version": "1.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
@@ -1738,6 +1767,18 @@
"win32" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1783,6 +1824,69 @@
"@babel/types": "^7.20.7" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1837,6 +1941,22 @@
"@types/react": "*" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.35.1", "version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
@@ -2448,6 +2568,127 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"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.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": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2566,6 +2813,16 @@
"node": ">= 0.4" "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": { "node_modules/esbuild": {
"version": "0.25.5", "version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
@@ -2807,6 +3064,12 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3164,6 +3427,16 @@
"node": ">= 4" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3190,6 +3463,15 @@
"node": ">=0.8.19" "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": { "node_modules/is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -3760,12 +4042,44 @@
"react": "^19.1.0" "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": { "node_modules/react-is": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
"integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==",
"license": "MIT" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3792,6 +4106,54 @@
"react-dom": ">=16.6.0" "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": { "node_modules/resolve": {
"version": "1.22.10", "version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -3998,6 +4360,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -4160,6 +4528,46 @@
"punycode": "^2.1.0" "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": { "node_modules/vite": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz",

View File

@@ -4,7 +4,7 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
@@ -14,9 +14,12 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0", "@mui/icons-material": "^7.2.0",
"@mui/material": "^7.2.0", "@mui/material": "^7.2.0",
"@types/recharts": "^1.8.29",
"axios": "^1.10.0", "axios": "^1.10.0",
"react": "^19.1.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": { "devDependencies": {
"@eslint/js": "^9.29.0", "@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 { MercadoAgroWidget } from './components/MercadoAgroWidget';
import { GranosWidget } from './components/GranosWidget'; import { GranosWidget } from './components/GranosWidget';
import { BolsaUsaWidget } from './components/BolsaUsaWidget'; import { BolsaUsaWidget } from './components/BolsaUsaWidget';
import { MercadoAgroCardWidget } from './components/MercadoAgroCardWidget';
import { GranosCardWidget } from './components/GranosCardWidget';
import { Divider } from '@mui/material';
function App() { function App() {
return ( return (
@@ -11,7 +14,7 @@ function App() {
<AppBar position="static" sx={{ backgroundColor: '#028fbe' }}> <AppBar position="static" sx={{ backgroundColor: '#028fbe' }}>
<Toolbar> <Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Mercados Modernos - Demo Mercados - El Día
</Typography> </Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
@@ -27,16 +30,36 @@ function App() {
<MercadoAgroWidget /> <MercadoAgroWidget />
</Box> </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 --- */} {/* --- Sección 2: Granos - Bolsa de Comercio de Rosario --- */}
<Box component="section" sx={{ mb: 5 }}> <Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Granos - Bolsa de Comercio de Rosario</Typography> <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> </Box>
{/* --- Sección 3: Mercado de Valores de Estados Unidos --- */} {/* --- Sección 3: Mercado de Valores de Estados Unidos --- */}
<Box component="section" sx={{ mb: 5 }}> <Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Mercado de Valores de Estados Unidos</Typography> <Typography variant="h5" gutterBottom>Mercado de Valores de Estados Unidos</Typography>
<BolsaUsaWidget /> <BolsaUsaWidget />
</Box> </Box>
{/* --- Sección 4: Mercado de Valores Local --- */} {/* --- Sección 4: Mercado de Valores Local --- */}
@@ -47,9 +70,9 @@ function App() {
</Container> </Container>
<Box component="footer" sx={{ p: 2, mt: 'auto', backgroundColor: '#f5f5f5', textAlign: 'center' }}> <Box component="footer" sx={{ p: 2, mt: 'auto', backgroundColor: '#f5f5f5', textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Desarrollado por El Día - {new Date().getFullYear()} Desarrollado por El Día - {new Date().getFullYear()}
</Typography> </Typography>
</Box> </Box>
</> </>
); );

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
// Durante el desarrollo, nuestra API corre en un puerto específico (ej. 5045). // 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. // 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({ const apiClient = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,

View File

@@ -1,38 +1,42 @@
import { useState } from 'react';
import { import {
Box, CircularProgress, Alert, Table, TableBody, TableCell, Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
DialogContent, IconButton
} from '@mui/material'; } from '@mui/material';
import type { CotizacionBolsa } from '../models/mercadoModels'; import CloseIcon from '@mui/icons-material/Close';
import { useApiData } from '../hooks/useApiData';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; 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) => new Intl.NumberFormat('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num);
const formatNumber = (num: number) => {
return 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 Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
return ( return (
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> <Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}> <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatNumber(value)}%</Typography>
{formatNumber(value)}%
</Typography>
</Box> </Box>
); );
}; };
export const BolsaLocalWidget = () => { export const BolsaLocalWidget = () => {
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); 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) { if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -47,38 +51,52 @@ export const BolsaLocalWidget = () => {
} }
return ( return (
<TableContainer component={Paper}> <>
<Table size="small" aria-label="tabla bolsa local"> <TableContainer component={Paper}>
<TableHead> <Box sx={{ p: 1, m: 0 }}>
<TableRow> <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
<TableCell>Símbolo</TableCell> Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
<TableCell align="right">Precio Actual</TableCell> </Typography>
<TableCell align="right">Apertura</TableCell> </Box>
<TableCell align="right">Cierre Anterior</TableCell> <Table size="small" aria-label="tabla bolsa local">
<TableCell align="center">% Cambio</TableCell> <TableHead>
</TableRow> <TableRow>
</TableHead> <TableCell>Símbolo</TableCell>
<TableBody> <TableCell align="right">Precio Actual</TableCell>
{data.map((row) => ( <TableCell align="right">Apertura</TableCell>
<TableRow key={row.ticker} hover> <TableCell align="right">Cierre Anterior</TableCell>
<TableCell component="th" scope="row"> <TableCell align="center">% Cambio</TableCell>
<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> </TableRow>
))} </TableHead>
</TableBody> <TableBody>
</Table> {data.map((row) => (
<Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}> <TableRow key={row.ticker} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(row.ticker)}>
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}> <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
Fuente: Yahoo Finance <TableCell align="right">${formatNumber(row.precioActual)}</TableCell>
</Typography> <TableCell align="right">${formatNumber(row.apertura)}</TableCell>
</Tooltip> <TableCell align="right">${formatNumber(row.cierreAnterior)}</TableCell>
</TableContainer> <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 { import {
Box, CircularProgress, Alert, Table, TableBody, TableCell, Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
DialogContent, IconButton
} from '@mui/material'; } from '@mui/material';
import type { CotizacionBolsa } from '../models/mercadoModels'; import CloseIcon from '@mui/icons-material/Close';
import { useApiData } from '../hooks/useApiData';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove'; 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) => { // Usamos el formato de EEUU para los precios en dólares
return new Intl.NumberFormat('en-US', { // Usamos formato de EEUU const formatCurrency = (num: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num);
style: 'currency', const formatPercentage = (num: number) => num.toFixed(2);
currency: 'USD',
}).format(num);
};
const Variacion = ({ value }: { value: number }) => { const Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
return ( return (
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> <Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}> <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatPercentage(value)}%</Typography>
{value.toFixed(2)}%
</Typography>
</Box> </Box>
); );
}; };
export const BolsaUsaWidget = () => { export const BolsaUsaWidget = () => {
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); 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) { if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -40,43 +48,58 @@ export const BolsaUsaWidget = () => {
return <Alert severity="error">{error}</Alert>; return <Alert severity="error">{error}</Alert>;
} }
// Recordatorio de que el fetcher puede estar desactivado
if (!data || data.length === 0) { 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 ( return (
<TableContainer component={Paper}> <>
<Table size="small" aria-label="tabla bolsa eeuu"> <TableContainer component={Paper}>
<TableHead> <Box sx={{ p: 1, pb: 0 }}>
<TableRow> <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
<TableCell>Símbolo</TableCell> Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
<TableCell align="right">Precio Actual</TableCell> </Typography>
<TableCell align="right">Apertura</TableCell> </Box>
<TableCell align="right">Cierre Anterior</TableCell> <Table size="small" aria-label="tabla bolsa eeuu">
<TableCell align="center">% Cambio</TableCell> <TableHead>
</TableRow> <TableRow>
</TableHead> <TableCell>Símbolo</TableCell>
<TableBody> <TableCell align="right">Precio Actual</TableCell>
{data.map((row) => ( <TableCell align="right">Apertura</TableCell>
<TableRow key={row.ticker} hover> <TableCell align="right">Cierre Anterior</TableCell>
<TableCell component="th" scope="row"> <TableCell align="center">% Cambio</TableCell>
<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> </TableRow>
))} </TableHead>
</TableBody> <TableBody>
</Table> {data.map((row) => (
<Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}> <TableRow key={row.ticker} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(row.ticker)}>
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}> <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
Fuente: Finnhub <TableCell align="right">{formatCurrency(row.precioActual)}</TableCell>
</Typography> <TableCell align="right">{formatCurrency(row.apertura)}</TableCell>
</Tooltip> <TableCell align="right">{formatCurrency(row.cierreAnterior)}</TableCell>
</TableContainer> <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.minimo)}</TableCell>
<TableCell align="right">${formatNumber(row.mediano)}</TableCell> <TableCell align="right">${formatNumber(row.mediano)}</TableCell>
<TableCell align="right">{formatNumber(row.cabezas, 0)}</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> <TableCell align="right">${formatNumber(row.importeTotal)}</TableCell>
</TableRow> </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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], 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."); 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 Accept: application/json
### ###

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); 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 // y la función ROW_NUMBER() para obtener el registro más reciente para cada Ticker
// dentro del mercado especificado. Es extremadamente eficiente. // dentro del mercado especificado. Es extremadamente eficiente.
const string sql = @" const string sql = @"
@@ -50,5 +50,24 @@ namespace Mercados.Infrastructure.Persistence.Repositories
return await connection.QueryAsync<CotizacionBolsa>(sql, new { Mercado = mercado }); 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 GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones);
Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado); 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. // que todos implementan la interfaz IDataFetcher.
services.AddScoped<IDataFetcher, MercadoAgroFetcher>(); services.AddScoped<IDataFetcher, MercadoAgroFetcher>();
services.AddScoped<IDataFetcher, BcrDataFetcher>(); services.AddScoped<IDataFetcher, BcrDataFetcher>();
//services.AddScoped<IDataFetcher, FinnhubDataFetcher>(); services.AddScoped<IDataFetcher, FinnhubDataFetcher>();
services.AddScoped<IDataFetcher, YahooFinanceDataFetcher>(); services.AddScoped<IDataFetcher, YahooFinanceDataFetcher>();
// El cliente HTTP es fundamental para hacer llamadas a APIs externas. // El cliente HTTP es fundamental para hacer llamadas a APIs externas.