Feat: Cambios Varios

This commit is contained in:
2025-12-23 15:12:57 -03:00
parent 32663e6324
commit 8bc1308bc5
58 changed files with 4080 additions and 663 deletions

View File

@@ -15,6 +15,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.10.1",
"recharts": "^3.6.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},
@@ -1026,6 +1027,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.0.tgz",
"integrity": "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -1341,6 +1378,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -1642,6 +1691,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.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/@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",
@@ -1686,6 +1798,12 @@
"@types/react": "^19.2.0"
}
},
"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.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
@@ -2306,6 +2424,127 @@
"devOptional": true,
"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.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2324,6 +2563,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",
@@ -2428,6 +2673,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -2677,6 +2932,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",
@@ -3008,6 +3269,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"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",
@@ -3035,6 +3306,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-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3731,6 +4011,36 @@
"react": "^19.2.3"
}
},
"node_modules/react-is": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
"license": "MIT",
"peer": true
},
"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.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -3779,6 +4089,57 @@
"react-dom": ">=18"
}
},
"node_modules/recharts": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
"license": "MIT",
"workspaces": [
"www"
],
"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-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3940,6 +4301,12 @@
"url": "https://opencollective.com/webpack"
}
},
"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.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4069,6 +4436,37 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",

View File

@@ -17,6 +17,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.10.1",
"recharts": "^3.6.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},

View File

@@ -2,12 +2,16 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import ProtectedLayout from './layouts/ProtectedLayout';
import CategoryManager from './pages/Categories/CategoryManager';
import UserManager from './pages/Users/UserManager';
import DiagramPage from './pages/Diagram/DiagramPage';
import PricingManager from './pages/Pricing/PricingManager';
import PromotionsManager from './pages/Pricing/PromotionsManager';
import SalesByCategory from './pages/Reports/SalesByCategory';
import ModerationPage from './pages/Moderation/ModerationPage';
import ListingExplorer from './pages/Listings/ListingExplorer';
import AuditTimeline from './pages/Audit/AuditTimeline';
import ClientManager from './pages/Clients/ClientManager';
function App() {
return (
@@ -17,11 +21,16 @@ function App() {
<Route element={<ProtectedLayout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/moderation" element={<ModerationPage />} />
<Route path="/listings" element={<ListingExplorer />} />
<Route path="/categories" element={<CategoryManager />} />
<Route path="/clients" element={<ClientManager />} />
<Route path="/users" element={<UserManager />} />
<Route path="/diagram" element={<DiagramPage />} />
<Route path="/pricing" element={<PricingManager />} />
<Route path="/promotions" element={<PromotionsManager />} />
<Route path="/reports/categories" element={<SalesByCategory />} />
<Route path="/audit" element={<AuditTimeline />} />
</Route>
</Routes>
</BrowserRouter>

View File

@@ -0,0 +1,144 @@
import { X, Printer, Globe, Tag, Image as ImageIcon, Info, User } from 'lucide-react';
export default function ListingDetailModal({ isOpen, onClose, detail }: any) {
if (!isOpen || !detail) return null;
const { listing, attributes, images } = detail;
// Helper para la URL de imagen (asegura que no haya doble barra)
const getImageUrl = (url: string) => {
const base = import.meta.env.VITE_API_URL.replace('/api', '');
return `${base}${url}`;
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col animate-in fade-in zoom-in duration-200">
{/* HEADER */}
<div className="p-6 border-b flex justify-between items-center bg-gray-50">
<div>
<div className="flex items-center gap-2 text-xs font-bold text-blue-600 uppercase mb-1">
<Tag size={14} /> {listing.categoryName || 'Aviso Clasificado'}
</div>
<h2 className="text-2xl font-black text-gray-800">#{listing.id} - {listing.title}</h2>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors"><X size={24} /></button>
</div>
<div className="flex-1 overflow-y-auto p-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* COLUMNA IZQUIERDA */}
<div className="lg:col-span-2 space-y-8">
{/* DATOS DEL CLIENTE (NUEVO) */}
<section className="bg-blue-50/50 p-4 rounded-xl border border-blue-100 flex items-center gap-6">
<div className="w-12 h-12 bg-blue-600 text-white rounded-full flex items-center justify-center shadow-md">
<User size={24} />
</div>
<div className="flex-1 grid grid-cols-2 gap-4">
<div>
<span className="block text-[10px] font-bold text-blue-400 uppercase">Solicitante</span>
<span className="font-bold text-gray-700">{listing.clientName || 'Consumidor Final'}</span>
</div>
<div>
<span className="block text-[10px] font-bold text-blue-400 uppercase">DNI / CUIT</span>
<span className="font-mono text-sm text-gray-600">{listing.clientDni || 'N/A'}</span>
</div>
</div>
</section>
{/* FOTOS */}
<section>
<h3 className="text-sm font-bold text-gray-400 uppercase mb-4 flex items-center gap-2"><ImageIcon size={16} /> Galería de Fotos</h3>
{images.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{images.map((img: any) => (
<img
key={img.id}
src={getImageUrl(img.url)}
className="w-full h-56 object-cover rounded-xl border shadow-sm hover:opacity-90 transition-opacity cursor-pointer"
alt="Aviso"
/>
))}
</div>
) : (
<div className="bg-gray-100 h-32 rounded-xl flex items-center justify-center text-gray-400 italic border-2 border-dashed">
El usuario no cargó imágenes
</div>
)}
</section>
{/* ATRIBUTOS */}
<section>
<h3 className="text-sm font-bold text-gray-400 uppercase mb-4 flex items-center gap-2"><Info size={16} /> Ficha Técnica</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{attributes.map((attr: any) => (
<div key={attr.id} className="bg-white p-3 rounded-lg border border-gray-100 shadow-sm">
<span className="block text-[10px] font-bold text-gray-400 uppercase">{attr.attributeName}</span>
<span className="font-bold text-gray-700">{attr.value}</span>
</div>
))}
</div>
</section>
</div>
{/* COLUMNA DERECHA (DATOS TÉCNICOS) */}
<div className="space-y-6">
<div className="bg-gray-900 text-white p-6 rounded-2xl shadow-xl">
<div className="text-xs opacity-50 uppercase font-bold mb-1">Total Cobrado</div>
<div className="text-4xl font-black text-green-400 mb-6">${listing.adFee?.toLocaleString()}</div>
<div className="space-y-3 pt-4 border-t border-white/10">
<div className="flex justify-between text-sm">
<span className="opacity-60">Valor Producto</span>
<span className="font-bold text-blue-300">${listing.price?.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="opacity-60">Estado</span>
<span className="font-bold uppercase text-xs tracking-widest">{listing.status}</span>
</div>
</div>
</div>
{/* IMPRESIÓN MEJORADA */}
<div className="border border-gray-200 rounded-2xl p-6 space-y-4 bg-white shadow-sm">
<h3 className="text-sm font-bold text-gray-800 flex items-center gap-2 border-b pb-2">
<Printer size={18} className="text-gray-400" /> Publicación Impresa
</h3>
{listing.printText ? (
<>
<div className={`p-4 rounded-lg border-2 ${listing.isFrame ? 'border-black' : 'border-gray-100'} bg-gray-50`}>
<p className={`text-sm leading-relaxed ${listing.isBold ? 'font-bold' : ''}`}>
{listing.printText}
</p>
</div>
<div className="grid grid-cols-2 gap-2 text-[10px] font-bold text-gray-500 uppercase">
<div className="bg-gray-100 p-2 rounded text-center">Días: {listing.printDaysCount}</div>
<div className="bg-gray-100 p-2 rounded text-center">Negrita: {listing.isBold ? 'SI' : 'NO'}</div>
</div>
</>
) : (
<div className="py-4 text-center">
<span className="text-xs font-bold text-orange-500 bg-orange-50 px-3 py-1 rounded-full uppercase">
Aviso sólo para Web
</span>
</div>
)}
</div>
{/* TEXTO WEB */}
<div className="bg-gray-50 rounded-2xl p-6 space-y-2 border border-gray-100">
<h3 className="text-sm font-bold text-gray-500 flex items-center gap-2">
<Globe size={18} /> Descripción Web
</h3>
<p className="text-sm text-gray-600 italic">"{listing.description || 'Sin descripción adicional.'}"</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,77 +1,69 @@
import { Navigate, Outlet } from 'react-router-dom';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { LogOut, LayoutDashboard, FolderTree, Users, FileText, DollarSign, Percent } from 'lucide-react';
import {
LogOut, LayoutDashboard, FolderTree, Users,
FileText, DollarSign, Eye, History, User as ClientIcon, Search
} from 'lucide-react';
export default function ProtectedLayout() {
const { isAuthenticated, logout } = useAuthStore();
const { isAuthenticated, role, logout } = useAuthStore();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
// Definición de permisos por ruta
const menuItems = [
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={20} />, roles: ['Admin', 'Cajero'] },
{ label: 'Moderación', href: '/moderation', icon: <Eye size={20} />, roles: ['Admin', 'Moderador'] },
{ label: 'Explorador', href: '/listings', icon: <Search size={20} />, roles: ['Admin', 'Cajero', 'Moderador'] },
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={20} />, roles: ['Admin', 'Cajero'] },
{ label: 'Categorías', href: '/categories', icon: <FolderTree size={20} />, roles: ['Admin'] },
{ label: 'Usuarios', href: '/users', icon: <Users size={20} />, roles: ['Admin'] },
{ label: 'Tarifas', href: '/pricing', icon: <DollarSign size={20} />, roles: ['Admin'] },
{ label: 'Diagramación', href: '/diagram', icon: <FileText size={20} />, roles: ['Admin', 'Diagramador'] },
{ label: 'Auditoría', href: '/audit', icon: <History size={20} />, roles: ['Admin'] },
];
return (
<div className="min-h-screen bg-gray-100 flex">
{/* Sidebar */}
<aside className="w-64 bg-gray-900 text-white flex flex-col transition-all duration-300">
<div className="p-4 text-xl font-bold border-b border-gray-800 flex items-center gap-2">
<LayoutDashboard className="text-blue-500" />
SIG-CM
<div className="flex h-screen bg-gray-100 overflow-hidden">
<aside className="w-64 bg-gray-900 text-white flex flex-col shadow-xl">
<div className="p-6 text-xl font-black border-b border-gray-800 tracking-tighter italic text-blue-500">
SIG-CM <span className="text-[10px] text-gray-500 not-italic font-medium">ADMIN</span>
</div>
<nav className="flex-1 p-4">
<ul className="space-y-1">
<li>
<a href="/" className="flex items-center gap-3 p-3 hover:bg-gray-800 rounded transition-colors text-gray-300 hover:text-white">
<LayoutDashboard size={20} />
<span>Dashboard</span>
<nav className="flex-1 p-4 space-y-1">
{menuItems.map((item) => {
// FILTRO DE SEGURIDAD UI: Solo mostrar si el rol del usuario está permitido
if (!item.roles.includes(role || '')) return null;
return (
<a
key={item.href}
href={item.href}
className={`flex items-center gap-3 p-3 rounded-xl transition-all ${location.pathname === item.href
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
{item.icon}
<span className="font-medium text-sm">{item.label}</span>
</a>
</li>
<li>
<a href="/categories" className="flex items-center gap-3 p-3 hover:bg-gray-800 rounded transition-colors text-gray-300 hover:text-white">
<FolderTree size={20} />
<span>Categorías</span>
</a>
</li>
<li>
<a href="/users" className="flex items-center gap-3 p-3 hover:bg-gray-800 rounded transition-colors text-gray-300 hover:text-white">
<Users size={20} />
<span>Usuarios</span>
</a>
</li>
<li>
<a href="/pricing" className="flex items-center gap-3 p-3 hover:bg-gray-800 rounded transition-colors text-gray-300 hover:text-white">
<DollarSign size={20} />
<span>Tarifas y Reglas</span>
</a>
</li>
<li>
<a href="/promotions" className="flex items-center gap-3 p-3 hover:bg-gray-800 rounded transition-colors text-gray-300 hover:text-white">
<Percent size={20} />
<span>Promociones</span>
</a>
</li>
<li>
<a href="/diagram" className="flex items-center gap-3 p-3 hover:bg-gray-800 rounded transition-colors text-gray-300 hover:text-white">
<FileText size={20} />
<span>Diagramación</span>
</a>
</li>
</ul>
);
})}
</nav>
<div className="p-4 border-t border-gray-800">
<button
onClick={logout}
className="flex items-center gap-3 text-red-400 hover:text-red-300 w-full p-2 transition-colors"
>
<LogOut size={20} />
<span>Cerrar Sesión</span>
<div className="mb-4 px-3 py-2 bg-gray-800/50 rounded-lg">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Sesión actual</p>
<p className="text-xs font-bold text-blue-400">{role}</p>
</div>
<button onClick={logout} className="flex items-center gap-3 text-red-400 hover:text-red-300 w-full p-2 transition-colors text-sm font-bold">
<LogOut size={18} /> Cerrar Sesión
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 p-8 overflow-y-auto">
<main className="flex-1 overflow-y-auto p-8 relative">
<Outlet />
</main>
</div>

View File

@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import api from '../../services/api';
import { History, User as UserIcon, CheckCircle, XCircle, FileText, Clock } from 'lucide-react';
export default function AuditTimeline() {
const [logs, setLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/reports/audit').then(res => {
setLogs(res.data);
setLoading(false);
});
}, []);
const getIcon = (action: string) => {
if (action === 'Aprobar') return <CheckCircle className="text-green-500" size={18} />;
if (action === 'Rechazar') return <XCircle className="text-red-500" size={18} />;
return <FileText className="text-blue-500" size={18} />;
};
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center gap-2">
<History className="text-blue-600" />
<h2 className="text-2xl font-bold text-gray-800">Auditoría de Actividad</h2>
</div>
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
<div className="p-4 bg-gray-50 border-b font-bold text-gray-600 text-sm">
Últimas acciones realizadas por el equipo
</div>
<div className="divide-y">
{loading ? (
<div className="p-10 text-center text-gray-400 italic">Cargando historial...</div>
) : logs.map(log => (
<div key={log.id} className="p-4 hover:bg-gray-50 transition flex items-start gap-4">
<div className="mt-1">{getIcon(log.action)}</div>
<div className="flex-1">
<div className="flex justify-between">
<span className="font-bold text-gray-800 flex items-center gap-1 italic">
<UserIcon size={14} className="text-gray-400" /> {log.username}
</span>
<span className="text-xs text-gray-400 flex items-center gap-1">
<Clock size={12} /> {new Date(log.createdAt).toLocaleString()}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">{log.details}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -5,10 +5,11 @@ import { categoryService } from '../../services/categoryService';
import { attributeService } from '../../services/attributeService';
import { type AttributeDefinition } from '../../types/AttributeDefinition';
import Modal from '../../components/Modal';
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Folder, FolderOpen, Settings, LayoutList } from 'lucide-react';
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Folder, FolderOpen, Settings, LayoutList, GitMerge, Move, ArrowRightCircle } from 'lucide-react';
import clsx from 'clsx';
import { operationService } from '../../services/operationService';
import { type Operation } from '../../types/Operation';
import api from '../../services/api';
// Type helper for the tree structure
interface CategoryNode extends Category {
@@ -18,9 +19,14 @@ interface CategoryNode extends Category {
export default function CategoryManager() {
const [categories, setCategories] = useState<CategoryNode[]>([]);
const [flatCategories, setFlatCategories] = useState<Category[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
// Drag & Drop State
const [draggedNode, setDraggedNode] = useState<CategoryNode | null>(null);
const [dragOverNodeId, setDragOverNodeId] = useState<number | null>(null);
// Form state
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [formData, setFormData] = useState<Partial<Category>>({ name: '', slug: '', active: true, parentId: null });
@@ -31,7 +37,7 @@ export default function CategoryManager() {
const [configuringCategory, setConfiguringCategory] = useState<Category | null>(null);
const [isOpsModalOpen, setIsOpsModalOpen] = useState(false);
// State for Attributes (ESTO FALTABA)
// State for Attributes
const [isAttrModalOpen, setIsAttrModalOpen] = useState(false);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [newAttrData, setNewAttrData] = useState({
@@ -40,6 +46,13 @@ export default function CategoryManager() {
required: false
});
// State for Merge
const [isMergeModalOpen, setIsMergeModalOpen] = useState(false);
const [targetMergeId, setTargetMergeId] = useState<number>(0);
// State for Move Content (Nueva funcionalidad)
const [isMoveContentModalOpen, setIsMoveContentModalOpen] = useState(false);
useEffect(() => {
loadCategories();
}, []);
@@ -48,6 +61,7 @@ export default function CategoryManager() {
setIsLoading(true);
try {
const data = await categoryService.getAll();
setFlatCategories(data);
const tree = buildTree(data);
setCategories(tree);
} catch (error) {
@@ -67,6 +81,106 @@ export default function CategoryManager() {
}));
};
// --- LÓGICA DRAG & DROP ---
const handleDragStart = (e: React.DragEvent, node: CategoryNode) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify(node));
setTimeout(() => {
setDraggedNode(node);
}, 0);
};
const handleDragEnd = (e: React.DragEvent) => {
e.preventDefault();
setDraggedNode(null);
setDragOverNodeId(null);
};
const handleDragOver = (e: React.DragEvent, targetId: number) => {
e.preventDefault();
e.stopPropagation();
if (!draggedNode || draggedNode.id === targetId) return;
if (isDescendant(draggedNode, targetId)) return;
setDragOverNodeId(targetId);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = async (e: React.DragEvent, targetNode: CategoryNode | null) => {
e.preventDefault();
e.stopPropagation();
setDragOverNodeId(null);
if (!draggedNode) return;
const newParentId = targetNode ? targetNode.id : null;
// 1. Validación: No mover si es el mismo padre
if (draggedNode.parentId === newParentId) {
setDraggedNode(null);
return;
}
// 2. Validación: No mover dentro de sus propios hijos (Ciclo)
if (targetNode && isDescendant(draggedNode, targetNode.id)) {
alert("Operación inválida: No puedes mover una categoría dentro de sus propios hijos.");
setDraggedNode(null);
return;
}
const actionText = targetNode ? `dentro de "${targetNode.name}"` : "al nivel Raíz";
if (!confirm(`¿Mover "${draggedNode.name}" ${actionText}?`)) {
setDraggedNode(null);
return;
}
try {
await categoryService.update(draggedNode.id, {
id: draggedNode.id,
name: draggedNode.name,
slug: draggedNode.slug,
active: draggedNode.active,
parentId: newParentId
});
loadCategories(); // Recargar árbol si tuvo éxito
} catch (error: any) {
console.error("Error detallado en Drop:", error);
let errorMessage = "Error al mover la categoría";
// Caso 1: BadRequest simple (string plano desde .NET)
if (typeof error.response?.data === 'string') {
errorMessage = error.response.data;
}
// Caso 2: Objeto JSON con propiedad 'message' o 'title' (ProblemDetails)
else if (error.response?.data) {
errorMessage = error.response.data.message || error.response.data.title || JSON.stringify(error.response.data);
}
// Caso 3: Error de red sin respuesta
else if (error.message) {
errorMessage = error.message;
}
alert(`❌ Operación Rechazada:\n${errorMessage}`);
} finally {
setDraggedNode(null);
}
};
const isDescendant = (sourceNode: CategoryNode, targetId: number): boolean => {
if (sourceNode.children.some(child => child.id === targetId)) return true;
return sourceNode.children.some(child => isDescendant(child, targetId));
};
// --- FIN LÓGICA DRAG & DROP ---
const handleCreate = (parentId: number | null = null) => {
setEditingCategory(null);
setFormData({ name: '', slug: '', active: true, parentId });
@@ -100,21 +214,20 @@ export default function CategoryManager() {
}
setIsModalOpen(false);
loadCategories();
} catch (error) {
} catch (error: any) {
console.error('Error saving category:', error);
// Mostrar validación del backend (ej: Padre con avisos)
alert(error.response?.data || 'Error al guardar la categoría. Verifique las reglas.');
}
};
const handleConfigureOps = async (category: Category) => {
setConfiguringCategory(category);
setIsOpsModalOpen(true);
// Load data in parallel
const [ops, catOps] = await Promise.all([
operationService.getAll(),
categoryService.getOperations(category.id)
]);
setAllOperations(ops);
setSelectedCategoryOps(catOps);
};
@@ -133,18 +246,15 @@ export default function CategoryManager() {
const handleCreateAttribute = async (e: React.FormEvent) => {
e.preventDefault();
if (!configuringCategory || !newAttrData.name) return;
try {
await attributeService.create({
...newAttrData,
categoryId: configuringCategory.id,
dataType: newAttrData.dataType as any // Type assertion for simplicity
dataType: newAttrData.dataType as any
});
setNewAttrData({ name: '', dataType: 'text', required: false });
loadAttributes(configuringCategory.id);
} catch (error) {
console.error(error);
}
} catch (error) { console.error(error); }
};
const handleDeleteAttribute = async (id: number) => {
@@ -152,108 +262,133 @@ export default function CategoryManager() {
try {
await attributeService.delete(id);
if (configuringCategory) loadAttributes(configuringCategory.id);
} catch (error) {
console.error(error);
}
} catch (error) { console.error(error); }
};
const toggleOperation = async (opId: number, isChecked: boolean) => {
if (!configuringCategory) return;
try {
if (isChecked) {
await categoryService.addOperation(configuringCategory.id, opId);
// Optimistic update
const opToAdd = allOperations.find(o => o.id === opId);
if (opToAdd) setSelectedCategoryOps([...selectedCategoryOps, opToAdd]);
} else {
await categoryService.removeOperation(configuringCategory.id, opId);
setSelectedCategoryOps(selectedCategoryOps.filter(o => o.id !== opId));
}
} catch (error) {
console.error(error);
alert("Error actualizando operación");
}
} catch (error) { console.error(error); alert("Error actualizando operación"); }
};
// Recursive component for rendering specific tree items
// --- MERGE LOGIC ---
const handleOpenMerge = (cat: Category) => {
setEditingCategory(cat);
setTargetMergeId(0);
setIsMergeModalOpen(true);
}
const handleMerge = async () => {
if (!editingCategory || !targetMergeId) return;
if (confirm(`¿CONFIRMAR FUSIÓN?\n\nTodo el contenido de "${editingCategory.name}" pasará a la categoría destino.\n"${editingCategory.name}" será ELIMINADA permanentemente.`)) {
try {
await api.post('/categories/merge', { sourceId: editingCategory.id, targetId: targetMergeId });
setIsMergeModalOpen(false);
loadCategories();
alert("Fusión completada.");
} catch (e) { console.error(e); alert("Error al fusionar"); }
}
}
// --- MOVE CONTENT LOGIC (Nuevo) ---
const handleOpenMoveContent = (cat: Category) => {
setEditingCategory(cat);
setTargetMergeId(0);
setIsMoveContentModalOpen(true);
}
const handleMoveContent = async () => {
if (!editingCategory || !targetMergeId) return;
try {
// Usamos el endpoint para mover solo avisos
await api.post('/categories/move-content', {
sourceId: editingCategory.id,
targetId: targetMergeId
});
setIsMoveContentModalOpen(false);
alert("Avisos movidos correctamente. Ahora puede agregar subcategorías.");
} catch (e) {
console.error(e);
alert("Error al mover avisos");
}
}
// Componente Recursivo de Nodo
const CategoryItem = ({ node }: { node: CategoryNode }) => {
const [isExpanded, setIsExpanded] = useState(true);
const isDragOver = dragOverNodeId === node.id;
const isBeingDragged = draggedNode?.id === node.id;
return (
<div className="mb-1">
<div
className={clsx("mb-1 transition-all duration-200", isBeingDragged && "opacity-40 grayscale")}
style={{ marginLeft: `${node.level * 20}px` }}
>
<div
draggable="true"
onDragStart={(e) => handleDragStart(e, node)}
onDragOver={(e) => handleDragOver(e, node.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}
className={clsx(
"flex items-center p-2 rounded hover:bg-gray-100 transition-colors group border-b border-gray-100",
node.level === 0 && "font-semibold bg-gray-50 border-gray-200"
"flex items-center p-2 rounded transition-all group border-b border-gray-100 cursor-grab active:cursor-grabbing select-none",
node.level === 0 && "font-semibold bg-gray-50 border-gray-200",
isDragOver
? "bg-blue-100 border-2 border-blue-500 shadow-md transform scale-[1.01] z-10"
: "hover:bg-gray-100 border-transparent"
)}
style={{ marginLeft: `${node.level * 20}px` }}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className={clsx("mr-2 text-gray-500", node.children.length === 0 && "opacity-0")}
>
<div className="mr-2 text-gray-300 group-hover:text-gray-500 cursor-grab">
<Move size={14} />
</div>
<button onClick={() => setIsExpanded(!isExpanded)} className={clsx("mr-2 text-gray-500", node.children.length === 0 && "opacity-0")}>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<span className="text-blue-500 mr-2">
<span className={clsx("mr-2", isDragOver ? "text-blue-700" : "text-blue-500")}>
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
</span>
<span className="flex-1">{node.name}</span>
<span className="flex-1 font-medium text-gray-700">{node.name}</span>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{/* Attributes Button */}
<button
onClick={() => handleConfigureAttributes(node)}
className="p-1 text-orange-600 hover:bg-orange-100 rounded"
title="Atributos Dinámicos"
>
<LayoutList size={16} />
</button>
<button onClick={() => handleConfigureAttributes(node)} className="p-1 text-orange-600 hover:bg-orange-100 rounded" title="Atributos"><LayoutList size={16} /></button>
<button onClick={() => handleConfigureOps(node)} className="p-1 text-purple-600 hover:bg-purple-100 rounded" title="Operaciones"><Settings size={16} /></button>
<button
onClick={() => handleConfigureOps(node)}
className="p-1 text-purple-600 hover:bg-purple-100 rounded"
title="Configurar Operaciones"
>
<Settings size={16} />
</button>
<button
onClick={() => handleCreate(node.id)}
className="p-1 text-green-600 hover:bg-green-100 rounded"
title="Agregar Subcategoría"
>
<Plus size={16} />
</button>
<button
onClick={() => handleEdit(node)}
className="p-1 text-blue-600 hover:bg-blue-100 rounded"
title="Editar"
>
<Edit size={16} />
</button>
<button
onClick={() => handleDelete(node.id)}
className="p-1 text-red-600 hover:bg-red-100 rounded"
title="Eliminar"
>
<Trash2 size={16} />
</button>
{/* Botón Mover Contenido (Nuevo) */}
<button onClick={() => handleOpenMoveContent(node)} className="p-1 text-teal-600 hover:bg-teal-100 rounded" title="Mover Avisos (Vaciar)"><ArrowRightCircle size={16} /></button>
<button onClick={() => handleOpenMerge(node)} className="p-1 text-indigo-600 hover:bg-indigo-100 rounded" title="Fusionar (Merge)"><GitMerge size={16} /></button>
<button onClick={() => handleCreate(node.id)} className="p-1 text-green-600 hover:bg-green-100 rounded" title="Subcategoría"><Plus size={16} /></button>
<button onClick={() => handleEdit(node)} className="p-1 text-blue-600 hover:bg-blue-100 rounded" title="Editar"><Edit size={16} /></button>
<button onClick={() => handleDelete(node.id)} className="p-1 text-red-600 hover:bg-red-100 rounded" title="Eliminar"><Trash2 size={16} /></button>
</div>
</div>
{isExpanded && node.children.length > 0 && (
<div className="border-l border-gray-200 ml-4 py-1">
{node.children.map(child => (
<CategoryItem key={child.id} node={child} />
))}
<div className="border-l-2 border-gray-200 ml-5 pl-1 py-1">
{node.children.map(child => <CategoryItem key={child.id} node={child} />)}
</div>
)}
</div>
);
};
// Helper para saber si una categoría es padre (tiene hijos)
const isParentCategory = (id: number) => {
return flatCategories.some(c => c.parentId === id);
};
return (
<div>
<div className="flex justify-between items-center mb-6">
@@ -267,14 +402,22 @@ export default function CategoryManager() {
</button>
</div>
{/* Operation Manager Section */}
<OperationManager />
<div className="bg-white rounded shadow p-6 min-h-[500px]">
<div
className={clsx(
"bg-white rounded shadow p-6 min-h-[500px] border-2 transition-all duration-200",
draggedNode && dragOverNodeId === null
? "border-dashed border-blue-400 bg-blue-50/50"
: "border-transparent"
)}
onDragOver={(e) => { e.preventDefault(); setDragOverNodeId(null); }}
onDrop={(e) => handleDrop(e, null)}
>
{isLoading ? (
<div className="text-center text-gray-500 py-10">Cargando taxonomía...</div>
) : categories.length === 0 ? (
<div className="text-center text-gray-400 py-10">No hay categorías definidas. Crea la primera.</div>
<div className="text-center text-gray-400 py-10">No hay categorías definidas. Crea la primera o arrastra aquí para mover a la raíz.</div>
) : (
<div className="space-y-1">
{categories.map(node => (
@@ -282,8 +425,20 @@ export default function CategoryManager() {
))}
</div>
)}
{draggedNode && (
<div className={clsx(
"mt-8 py-8 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center text-gray-400 pointer-events-none transition-opacity duration-300",
draggedNode ? "opacity-100" : "opacity-0 h-0 py-0 mt-0 overflow-hidden"
)}>
<Move className="mr-2" /> Soltar aquí para convertir en Categoría Raíz
</div>
)}
</div>
{/* --- MODALES --- */}
{/* Main Modal (Create/Edit) */}
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
@@ -370,7 +525,7 @@ export default function CategoryManager() {
</div>
</Modal>
{/* Attributes Configuration Modal (ESTO FALTABA EN EL JSX) */}
{/* Attributes Configuration Modal */}
<Modal
isOpen={isAttrModalOpen}
onClose={() => setIsAttrModalOpen(false)}
@@ -439,6 +594,88 @@ export default function CategoryManager() {
</form>
</div>
</Modal>
{/* Merge Modal */}
<Modal isOpen={isMergeModalOpen} onClose={() => setIsMergeModalOpen(false)} title="Fusionar Categorías">
<div className="space-y-4">
<p className="text-sm text-gray-600 bg-yellow-50 p-3 rounded border border-yellow-200">
Está a punto de mover todo el contenido de <strong>{editingCategory?.name}</strong> a otra categoría.
<br />
<span className="text-red-600 font-bold">¡La categoría original será eliminada!</span>
</p>
<div>
<label className="block text-sm font-bold mb-2">Seleccione Categoría Destino:</label>
<select
className="w-full border p-2 rounded focus:ring-2 focus:ring-indigo-500 outline-none"
onChange={e => setTargetMergeId(Number(e.target.value))}
value={targetMergeId}
>
<option value="0">-- Seleccionar Destino --</option>
{flatCategories
.filter(c => c.id !== editingCategory?.id)
.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setIsMergeModalOpen(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded">Cancelar</button>
<button
onClick={handleMerge}
disabled={!targetMergeId}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Confirmar Fusión
</button>
</div>
</div>
</Modal>
{/* MOVE CONTENT MODAL */}
<Modal isOpen={isMoveContentModalOpen} onClose={() => setIsMoveContentModalOpen(false)} title="Mover Avisos (Vaciar)">
<div className="space-y-4">
<div className="bg-blue-50 p-3 rounded border border-blue-200 text-sm text-gray-700">
<p className="font-bold mb-1">Mover avisos de: {editingCategory?.name}</p>
<p>Seleccione un destino para los avisos. <br />
<span className="text-xs text-blue-600">* Solo se permiten categorías finales (sin hijos).</span></p>
</div>
<div>
<label className="block text-sm font-bold mb-2">Destino de los avisos:</label>
<select
className="w-full border p-2 rounded focus:ring-2 focus:ring-teal-500 outline-none"
onChange={e => setTargetMergeId(Number(e.target.value))}
value={targetMergeId}
>
<option value="0">-- Seleccionar Destino --</option>
{flatCategories
.filter(c =>
c.id !== editingCategory?.id && // No el mismo
!isParentCategory(c.id) // No permitir mover a Padres
)
.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{targetMergeId === 0 && (
<p className="text-xs text-red-500 mt-1">Debe seleccionar un destino válido.</p>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setIsMoveContentModalOpen(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded">Cancelar</button>
<button
onClick={handleMoveContent}
disabled={!targetMergeId}
className="bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Mover Avisos
</button>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useState, useEffect } from 'react';
import { clientService } from '../../services/clientService';
import { User, Search, Phone, Mail, FileText, CreditCard } from 'lucide-react';
export default function ClientManager() {
const [clients, setClients] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => {
clientService.getAll().then(res => {
setClients(res);
setLoading(false);
});
}, []);
const filteredClients = clients.filter(c =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.dniOrCuit.includes(search)
);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
<User className="text-blue-600" />
Directorio de Clientes
</h2>
</div>
{/* Buscador */}
<div className="bg-white p-4 rounded-xl border shadow-sm relative">
<Search className="absolute left-7 top-7 text-gray-400" size={20} />
<input
type="text"
placeholder="Buscar por nombre o DNI/CUIT..."
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Grid de Clientes */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
<p className="col-span-full text-center text-gray-400 py-10">Cargando base de datos...</p>
) : filteredClients.map(client => (
<div key={client.id} className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-all overflow-hidden group">
<div className="p-5 border-b border-gray-50 bg-gray-50/50">
<div className="flex justify-between items-start">
<div className="w-10 h-10 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-bold">
{client.name.charAt(0)}
</div>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest bg-white px-2 py-1 rounded-full border">
ID: #{client.id}
</span>
</div>
<h3 className="mt-3 font-bold text-gray-800 text-lg line-clamp-1">{client.name}</h3>
<div className="flex items-center gap-1.5 text-xs text-gray-500 mt-1">
<CreditCard size={14} />
{client.dniOrCuit}
</div>
</div>
<div className="p-5 space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail size={16} className="text-gray-400" />
{client.email || 'Sin correo'}
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone size={16} className="text-gray-400" />
{client.phone || 'Sin teléfono'}
</div>
<div className="grid grid-cols-2 gap-2 mt-4 pt-4 border-t border-gray-100">
<div className="text-center border-r border-gray-100">
<div className="text-xs text-gray-400 font-bold uppercase">Avisos</div>
<div className="text-lg font-black text-gray-800">{client.totalAds}</div>
</div>
<div className="text-center">
<div className="text-xs text-gray-400 font-bold uppercase">Invertido</div>
<div className="text-lg font-black text-green-600">${client.totalSpent?.toLocaleString() || 0}</div>
</div>
</div>
</div>
<button className="w-full py-3 bg-white text-blue-600 text-xs font-bold uppercase tracking-tighter hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center gap-2 border-t">
<FileText size={14} /> Ver Historial Completo
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,8 +1,362 @@
import { useState, useEffect } from 'react';
import {
DollarSign, FileText, Printer, TrendingUp,
Download, PieChart as PieIcon,
Clock, RefreshCw, UserCheck, ListChecks, ChevronRight, Users, ArrowLeft,
History, CheckCircle, XCircle
} from 'lucide-react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer,
} from 'recharts';
import { dashboardService, type DashboardData } from '../services/dashboardService';
import { useAuthStore } from '../store/authStore';
import api from '../services/api';
import { reportService } from '../services/reportService';
const getLocalDateString = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export default function Dashboard() {
const { role } = useAuthStore();
const [data, setData] = useState<DashboardData | any>(null);
const [cashiers, setCashiers] = useState<any[]>([]);
const [recentTransactions, setRecentTransactions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// --- NUEVOS ESTADOS PARA MONITOR INTEGRADO ---
const [selectedCashier, setSelectedCashier] = useState<{ id: number, name: string } | null>(null);
const [selectedCashierLogs, setSelectedCashierLogs] = useState<any[]>([]);
const [loadingLogs, setLoadingLogs] = useState(false);
const todayStr = getLocalDateString(new Date());
const [dates, setDates] = useState({ from: todayStr, to: todayStr });
const loadDashboardData = async () => {
if (new Date(dates.from) > new Date(dates.to)) return;
try {
setLoading(true);
if (role === 'Admin') {
if (selectedCashier) {
const res = await api.get(`/reports/cashier-detail/${selectedCashier.id}`, {
params: { from: dates.from, to: dates.to }
});
setData(res.data);
// Cargar logs del cajero seleccionado
loadCashierLogs(selectedCashier.id);
} else {
const stats = await dashboardService.getStats(dates.from, dates.to);
setData(stats);
const usersRes = await api.get('/users');
setCashiers(usersRes.data.filter((u: any) => u.role === 'Cajero'));
}
} else {
const res = await api.get('/reports/cashier', {
params: { from: dates.from, to: dates.to }
});
setData(res.data);
}
const params: any = {};
if (selectedCashier) params.userId = selectedCashier.id;
const recentRes = await api.get('/listings', { params });
setRecentTransactions(recentRes.data.slice(0, 5));
} catch (error) {
console.error("Error cargando dashboard:", error);
} finally {
setLoading(false);
}
};
const loadCashierLogs = async (userId: number) => {
setLoadingLogs(true);
try {
const res = await api.get(`/reports/audit/user/${userId}`);
setSelectedCashierLogs(res.data);
} catch (e) {
console.error("Error cargando logs");
} finally {
setLoadingLogs(false);
}
};
useEffect(() => {
loadDashboardData();
}, [dates, role, selectedCashier]);
const setQuickRange = (days: number) => {
const now = new Date();
const start = new Date();
start.setDate(now.getDate() - days);
setDates({ from: getLocalDateString(start), to: getLocalDateString(now) });
};
const handleExport = async () => {
try {
await reportService.exportCierre(dates.from, dates.to, selectedCashier?.id);
} catch (e) {
alert("Error al generar el reporte");
}
};
const getAuditIcon = (action: string) => {
if (action === 'Aprobar') return <CheckCircle className="text-green-500" size={14} />;
if (action === 'Rechazar') return <XCircle className="text-red-500" size={14} />;
return <Clock className="text-blue-500" size={14} />;
};
if (loading && !data) {
return (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p className="font-medium">Sincronizando información...</p>
</div>
);
}
if (!data) return null;
const isInvalidRange = new Date(dates.from) > new Date(dates.to);
return (
<div>
<h1 className="text-3xl font-bold text-gray-800 mb-4">Bienvenido al Panel de Administración</h1>
<p className="text-gray-600">Seleccione una opción del menú para comenzar.</p>
<div className="space-y-6">
{/* HEADER UNIFICADO */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-white p-4 rounded-xl border border-gray-200 shadow-sm transition-all">
<div className="flex items-center gap-4">
{selectedCashier && (
<button
onClick={() => setSelectedCashier(null)}
className="p-2 hover:bg-gray-100 rounded-full text-gray-500 transition-colors"
title="Volver a Vista Global"
>
<ArrowLeft size={20} />
</button>
)}
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-gray-800">
{role === 'Admin'
? (selectedCashier ? `Auditoría: ${selectedCashier.name}` : 'Resumen Gerencial')
: 'Mi Rendimiento'}
</h1>
{loading && <RefreshCw size={14} className="animate-spin text-blue-500" />}
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Clock size={14} />
<span>{new Date(dates.from).toLocaleDateString()} - {new Date(dates.to).toLocaleDateString()}</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex bg-gray-100 p-1 rounded-lg">
<button onClick={() => setQuickRange(0)} className={`px-3 py-1 text-xs font-bold rounded transition-all ${dates.from === todayStr ? 'bg-white shadow text-blue-600' : 'text-gray-500'}`}>HOY</button>
<button onClick={() => setQuickRange(7)} className={`px-3 py-1 text-xs font-bold rounded transition-all ${dates.from !== todayStr ? 'bg-white shadow text-blue-600' : 'text-gray-500'}`}>7D</button>
</div>
<div className="flex items-center gap-2 border-l pl-3 border-gray-200">
<input type="date" value={dates.from} onChange={(e) => setDates({ ...dates, from: e.target.value })} className={`text-xs border ${isInvalidRange ? 'border-red-500 bg-red-50' : 'border-gray-300 bg-gray-50'} rounded p-1.5 outline-none focus:ring-2 focus:ring-blue-500/20`} />
<span className="text-gray-400 text-xs">-</span>
<input type="date" value={dates.to} onChange={(e) => setDates({ ...dates, to: e.target.value })} className={`text-xs border ${isInvalidRange ? 'border-red-500 bg-red-50' : 'border-gray-300 bg-gray-50'} rounded p-1.5 outline-none focus:ring-2 focus:ring-blue-500/20`} />
</div>
<button
onClick={handleExport}
className="flex items-center gap-2 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-gray-700 transition shadow-md"
>
<Download size={16} />
{role === 'Admin' ? (selectedCashier ? 'DESCARGAR CAJA' : 'DESCARGAR CIERRE GLOBAL') : 'MI ACTIVIDAD'}
</button>
</div>
</div>
{/* KPIs */}
{(role === 'Cajero' || selectedCashier) ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<KpiCard title="Recaudación Caja" value={`$${(data.myRevenue ?? 0).toLocaleString()}`} trend="Ingresos" icon={<DollarSign />} color="bg-green-50 text-green-600" />
<KpiCard title="Avisos Procesados" value={(data.myAdsCount ?? 0).toString()} trend="Cargas" icon={<UserCheck />} color="bg-blue-50 text-blue-700" />
<KpiCard title="En Revisión" value={(data.myPendingAds ?? 0).toString()} trend="Pendientes" icon={<ListChecks />} color="bg-orange-50 text-orange-700" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard title="Recaudación" value={`$${data.revenueToday.toLocaleString()}`} trend="Total" icon={<DollarSign />} color="bg-green-50 text-green-600" />
<KpiCard title="Avisos Vendidos" value={data.adsToday.toString()} trend="Aprobados" icon={<FileText />} color="bg-blue-50 text-blue-600" />
<KpiCard title="Ocupación Papel" value={`${Math.round(data.paperOccupation)}%`} trend="Mañana" progress={data.paperOccupation} icon={<Printer />} color="bg-orange-50 text-orange-600" />
<KpiCard title="Ticket Promedio" value={`$${Math.round(data.ticketAverage).toLocaleString()}`} trend="Promedio" icon={<TrendingUp />} color="bg-purple-50 text-purple-600" />
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
<h3 className="font-bold text-gray-700 mb-6 flex items-center gap-2"><TrendingUp size={18} className="text-blue-500" /> Evolución de Ingresos</h3>
<div className="h-72 relative">
{role === 'Admin' && !selectedCashier ? (
<ResponsiveContainer width="100%" height="100%" minWidth={0} debounce={50}>
<LineChart data={data.weeklyTrend}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#F3F4F6" />
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 11 }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 11 }} />
<Tooltip formatter={(val: any) => [`$${Number(val).toLocaleString()}`, "Ventas"]} contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1)' }} />
<Line type="monotone" dataKey="amount" stroke="#3B82F6" strokeWidth={4} dot={{ r: 4, fill: '#3B82F6', strokeWidth: 2, stroke: '#fff' }} activeDot={{ r: 6, strokeWidth: 0 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gray-50/50 rounded-xl text-gray-400 italic text-sm">
Gráfico de tendencia disponible en vista gerencial global
</div>
)}
</div>
</div>
{/* MONITOR INTEGRADO (LISTA O AUDITORÍA) */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm flex flex-col overflow-hidden">
{role === 'Admin' && !selectedCashier ? (
<>
<div className="p-4 border-b bg-gray-50/50 flex justify-between items-center">
<h3 className="font-bold text-gray-700 flex items-center gap-2">
<Users size={18} className="text-blue-500" /> Monitor de Cajeros
</h3>
</div>
<div className="p-4 space-y-3 overflow-y-auto flex-1">
{cashiers.map(c => (
<div
key={c.id}
onClick={() => setSelectedCashier({ id: c.id, name: c.username })}
className="flex items-center justify-between p-3 rounded-xl border border-gray-100 bg-white hover:bg-blue-50 hover:border-blue-200 transition-all cursor-pointer group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center text-xs font-bold text-gray-500 group-hover:text-blue-600 transition-colors">
{c.username.charAt(0).toUpperCase()}
</div>
<span className="text-sm font-bold text-gray-700">{c.username}</span>
</div>
<ChevronRight size={16} className="text-gray-300 group-hover:text-blue-500" />
</div>
))}
</div>
</>
) : role === 'Admin' && selectedCashier ? (
<>
<div className="p-4 border-b bg-blue-600 text-white flex justify-between items-center">
<div className="flex items-center gap-2">
<History size={18} />
<h3 className="font-bold text-sm truncate">Logs: {selectedCashier.name}</h3>
</div>
<button
onClick={() => setSelectedCashier(null)}
className="text-[10px] bg-white/20 hover:bg-white/30 px-2 py-1 rounded font-bold transition-colors"
>
CERRAR
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-white">
{loadingLogs ? (
<div className="h-full flex items-center justify-center text-gray-400 animate-pulse text-xs italic">Consultando actividad...</div>
) : selectedCashierLogs.length === 0 ? (
<div className="h-full flex items-center justify-center text-gray-400 text-xs italic">Sin actividad registrada</div>
) : (
selectedCashierLogs.map(log => (
<div key={log.id} className="relative pl-6 pb-4 border-l border-gray-100 last:pb-0">
<div className="absolute left-[-7px] top-1 bg-white p-0.5">
{getAuditIcon(log.action)}
</div>
<div className="text-[11px] font-bold text-gray-700 flex justify-between">
<span>{log.action}</span>
<span className="font-normal text-gray-400 font-mono">
{new Date(log.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<p className="text-[10px] text-gray-500 mt-0.5 line-clamp-2 leading-relaxed">{log.details}</p>
</div>
))
)}
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-center space-y-4 p-6">
<div className="p-4 bg-blue-50 rounded-full text-blue-600"><PieIcon size={32} /></div>
<div>
<h4 className="font-bold text-gray-700">Mi Puesto</h4>
<p className="text-xs text-gray-400">Visualizando tus métricas de hoy.</p>
</div>
</div>
)}
</div>
</div>
{/* TABLA DE ÚLTIMOS MOVIMIENTOS */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="font-bold text-gray-700">
{role === 'Admin' ? (selectedCashier ? `Cargas de ${selectedCashier.name}` : 'Últimos Avisos Procesados') : 'Mis Últimas Cargas'}
</h3>
<a href="/listings" className="text-sm text-blue-600 font-semibold hover:underline">Ver todo</a>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-sm">
<thead className="text-xs font-bold text-gray-400 uppercase tracking-wider bg-gray-50/50">
<tr>
<th className="p-4 border-b border-gray-100">Fecha / Hora</th>
<th className="p-4 border-b border-gray-100">Rubro / Título</th>
<th className="p-4 border-b border-gray-100">Monto (Fee)</th>
<th className="p-4 border-b border-gray-100">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{recentTransactions.map(t => (
<tr key={t.id} className="hover:bg-gray-50/80 transition-colors">
<td className="p-4 text-gray-500 font-medium">
<div>{new Date(t.createdAt).toLocaleDateString()}</div>
<div className="text-[10px] opacity-60 font-mono">{new Date(t.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
</td>
<td className="p-4">
<div className="font-bold text-gray-700">{t.title}</div>
<div className="text-[11px] text-gray-400 uppercase tracking-tighter">{t.categoryName || 'General'}</div>
</td>
<td className="p-4 font-black text-blue-600">${t.adFee?.toLocaleString() || "0"}</td>
<td className="p-4">
<span className={`flex items-center gap-1.5 font-bold ${t.status === 'Published' ? 'text-green-600' : 'text-orange-500'}`}>
<div className={`w-1.5 h-1.5 rounded-full ${t.status === 'Published' ? 'bg-green-600' : 'bg-orange-500'}`}></div>
{t.status}
</span>
</td>
</tr>
))}
{recentTransactions.length === 0 && (
<tr><td colSpan={4} className="p-8 text-center text-gray-400 italic">No hay transacciones para mostrar.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
function KpiCard({ title, value, trend, icon, color, progress }: any) {
return (
<div className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-all group">
<div className="flex justify-between items-center mb-4">
<div className={`p-2.5 rounded-lg transition-colors ${color}`}>{icon}</div>
<div className="text-[10px] font-black px-2 py-1 rounded-full uppercase bg-gray-100 text-gray-500 group-hover:bg-gray-200 transition-colors">
{trend}
</div>
</div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">{title}</p>
<h4 className="text-2xl font-black text-gray-800 mt-1">{value}</h4>
{progress !== undefined && (
<div className="mt-4 w-full bg-gray-100 rounded-full h-2">
<div className={`h-2 rounded-full transition-all duration-700 ${progress > 90 ? 'bg-red-500' : 'bg-orange-500'}`} style={{ width: `${progress}%` }}></div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { useState, useEffect } from 'react';
import api from '../../services/api';
import ListingDetailModal from '../../components/Listings/ListingDetailModal';
import { listingService } from '../../services/listingService';
import { Search, ExternalLink, Calendar, Tag, User as UserIcon } from 'lucide-react';
export default function ListingExplorer() {
const [listings, setListings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [selectedDetail, setSelectedDetail] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleOpenDetail = async (id: number) => {
const detail = await listingService.getById(id);
setSelectedDetail(detail);
setIsModalOpen(true);
};
const loadListings = async () => {
setLoading(true);
try {
// Usamos el endpoint de búsqueda que ya tiene el backend
const res = await api.get('/listings', {
params: { q: query }
});
let data = res.data;
if (statusFilter) {
data = data.filter((l: any) => l.status === statusFilter);
}
setListings(data);
} catch (error) {
console.error("Error cargando avisos:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadListings();
}, [statusFilter]);
return (
<div className="space-y-6">
<div className="flex justify-between items-center text-gray-800">
<div>
<h2 className="text-2xl font-bold">Explorador de Avisos</h2>
<p className="text-sm text-gray-500">Gestión y búsqueda histórica de publicaciones</p>
</div>
</div>
{/* BARRA DE HERRAMIENTAS */}
<div className="bg-white p-4 rounded-xl border shadow-sm flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3 text-gray-400" size={18} />
<input
type="text"
placeholder="Buscar por título, contenido o ID..."
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && loadListings()}
/>
</div>
<div className="flex gap-2">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-4 py-2 text-sm font-medium text-gray-600 outline-none"
>
<option value="">Todos los estados</option>
<option value="Published">Publicados</option>
<option value="Pending">Pendientes</option>
<option value="Rejected">Rechazados</option>
<option value="Draft">Borradores</option>
</select>
<button
onClick={loadListings}
className="bg-gray-900 text-white px-6 py-2 rounded-lg font-bold hover:bg-gray-800 transition shadow-md"
>
BUSCAR
</button>
</div>
</div>
{/* RESULTADOS */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-gray-50 text-gray-400 text-xs uppercase font-bold tracking-wider">
<tr>
<th className="p-4 border-b">ID</th>
<th className="p-4 border-b">Aviso / Título</th>
<th className="p-4 border-b">Ingreso (Aviso)</th>
<th className="p-4 border-b">Valor Producto</th>
<th className="p-4 border-b">Creado</th>
<th className="p-4 border-b">Vía</th>
<th className="p-4 border-b text-center">Estado</th>
<th className="p-4 border-b text-right">Detalles</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm">
{loading ? (
<tr><td colSpan={7} className="p-10 text-center text-gray-400">Buscando en la base de datos...</td></tr>
) : listings.length === 0 ? (
<tr><td colSpan={7} className="p-10 text-center text-gray-400 italic">No se encontraron avisos con esos criterios.</td></tr>
) : (
listings.map((l) => (
<tr key={l.id} className="hover:bg-gray-50 transition-colors group">
<td className="p-4 font-mono text-gray-400">#{l.id}</td>
<td className="p-4">
<div className="font-bold text-gray-800 group-hover:text-blue-600 transition-colors">{l.title}</div>
<div className="text-xs text-gray-400 flex items-center gap-1 mt-0.5">
<Tag size={12} /> {l.categoryName || 'Sin Rubro'}
</div>
</td>
<td className="p-4">
<div className="font-bold text-green-700">
${l.adFee?.toLocaleString() ?? "0"}
</div>
</td>
<td className="p-4">
<div className="text-gray-500 text-xs">${l.price?.toLocaleString()}</div>
</td>
<td className="p-4">
<div className="text-gray-600 flex items-center gap-1.5">
<Calendar size={14} className="text-gray-300" />
{new Date(l.createdAt).toLocaleDateString()}
</div>
</td>
<td className="p-4">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-black uppercase ${l.userId === null ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'
}`}>
<UserIcon size={10} />
{l.userId === null ? 'Web' : 'Mostrador'}
</span>
</td>
<td className="p-4 text-center">
<span className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase border ${l.status === 'Published' ? 'bg-green-50 text-green-700 border-green-200' :
l.status === 'Pending' ? 'bg-yellow-50 text-yellow-700 border-yellow-200' :
'bg-gray-50 text-gray-600 border-gray-200'
}`}>
{l.status}
</span>
</td>
<td className="p-4 text-right">
<button
onClick={() => handleOpenDetail(l.id)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
title="Ver Detalles"
>
<ExternalLink size={18} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<ListingDetailModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
detail={selectedDetail}
/>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useState, useEffect } from 'react';
import api from '../../services/api';
import { Check, X, Printer, Globe, MessageSquare } from 'lucide-react';
interface Listing {
id: number;
title: string;
description: string;
price: number;
currency: string;
createdAt: string;
status: string;
printText: string;
isBold: boolean;
isFrame: boolean;
printDaysCount: number;
}
export default function ModerationPage() {
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadPending(); }, []);
const loadPending = async () => {
setLoading(true);
try {
// Este endpoint ya lo tenemos en el ListingsController que hicimos al inicio
const res = await api.get('/listings/pending');
setListings(res.data);
} catch (error) { console.error(error); }
finally { setLoading(false); }
};
const handleAction = async (id: number, action: 'Published' | 'Rejected') => {
try {
await api.put(`/listings/${id}/status`, JSON.stringify(action), {
headers: { 'Content-Type': 'application/json' }
});
setListings(listings.filter(l => l.id !== id));
} catch (e) { alert("Error al procesar el aviso"); }
};
if (loading) return <div className="p-10 text-center text-gray-500">Cargando avisos para revisar...</div>;
return (
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-800">Panel de Moderación</h2>
<p className="text-gray-500">Revisión de avisos entrantes para Web y Diario Papel</p>
</div>
{listings.length === 0 ? (
<div className="bg-white p-12 rounded-xl border-2 border-dashed text-center">
<Check className="mx-auto text-green-500 mb-4" size={48} />
<h3 className="text-lg font-bold text-gray-700">¡Bandeja vacía!</h3>
<p className="text-gray-500">No hay avisos pendientes de moderación en este momento.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-6">
{listings.map(listing => (
<div key={listing.id} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col md:flex-row">
{/* Info del Aviso */}
<div className="flex-1 p-6">
<div className="flex justify-between items-start mb-4">
<div>
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded uppercase tracking-wider">
ID: #{listing.id}
</span>
<h3 className="text-xl font-bold text-gray-900 mt-1">{listing.title}</h3>
</div>
<div className="text-right">
<p className="text-lg font-black text-gray-900">{listing.currency} ${listing.price.toLocaleString()}</p>
<p className="text-xs text-gray-400">{new Date(listing.createdAt).toLocaleString()}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Versión Web */}
<div className="bg-gray-50 p-4 rounded-lg border border-gray-100">
<div className="flex items-center gap-2 mb-2 text-gray-700 font-bold text-sm">
<Globe size={16} /> VERSIÓN WEB / MOBILE
</div>
<p className="text-sm text-gray-600 italic line-clamp-3">"{listing.description}"</p>
</div>
{/* Versión Impresa - CRÍTICO */}
<div className={`p-4 rounded-lg border-2 ${listing.isFrame ? 'border-black' : 'border-gray-200'} bg-white`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-gray-700 font-bold text-sm">
<Printer size={16} /> VERSIÓN IMPRESA
</div>
<span className="text-[10px] bg-gray-100 px-2 py-0.5 rounded font-bold">
{listing.printDaysCount} DÍAS
</span>
</div>
<div className={`text-sm p-2 bg-gray-50 rounded border border-dashed border-gray-300 ${listing.isBold ? 'font-bold' : ''}`}>
{listing.printText || "Sin texto de impresión definido"}
</div>
</div>
</div>
</div>
{/* Acciones Laterales */}
<div className="bg-gray-50 border-t md:border-t-0 md:border-l border-gray-200 p-6 flex flex-row md:flex-col gap-3 justify-center min-w-[200px]">
<button
onClick={() => handleAction(listing.id, 'Published')}
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition shadow-sm"
>
<Check size={20} /> APROBAR
</button>
<button
onClick={() => handleAction(listing.id, 'Rejected')}
className="flex-1 bg-white hover:bg-red-50 text-red-600 border border-red-200 font-bold py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
>
<X size={20} /> RECHAZAR
</button>
<button className="p-3 text-gray-400 hover:text-gray-600 flex items-center justify-center gap-2 text-xs font-medium">
<MessageSquare size={16} /> Enviar Nota
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import api from '../../services/api';
import { Save, DollarSign } from 'lucide-react';
import { Save, DollarSign, FileText, Type, AlertCircle } from 'lucide-react';
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
interface PricingConfig {
basePrice: number;
@@ -12,129 +13,202 @@ interface PricingConfig {
frameSurcharge: number;
}
interface Category { id: number; name: string; }
export default function PricingManager() {
const [categories, setCategories] = useState<Category[]>([]);
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
const [selectedCat, setSelectedCat] = useState<number | null>(null);
const [config, setConfig] = useState<PricingConfig>({
// Configuración por defecto
const defaultConfig: PricingConfig = {
basePrice: 0, baseWordCount: 15, extraWordPrice: 0,
specialChars: '!', specialCharPrice: 0, boldSurcharge: 0, frameSurcharge: 0
});
};
const [config, setConfig] = useState<PricingConfig>(defaultConfig);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
// Cargar categorías
api.get('/categories').then(res => setCategories(res.data));
// Cargar categorías y procesar árbol
api.get('/categories').then(res => {
const processed = processCategories(res.data);
setFlatCategories(processed);
});
}, []);
useEffect(() => {
if (selectedCat) {
setLoading(true);
// Cargar config existente
api.get(`/pricing/${selectedCat}`).then(res => {
if (res.data) setConfig(res.data);
else setConfig({ // Default si no existe
basePrice: 0, baseWordCount: 15, extraWordPrice: 0,
specialChars: '!', specialCharPrice: 0, boldSurcharge: 0, frameSurcharge: 0
});
});
api.get(`/pricing/${selectedCat}`)
.then(res => {
if (res.data) setConfig(res.data);
else setConfig(defaultConfig); // Reset si es nuevo
})
.finally(() => setLoading(false));
}
}, [selectedCat]);
const handleSave = async () => {
if (!selectedCat) return;
setLoading(true);
setSaving(true);
try {
await api.post('/pricing', { ...config, categoryId: selectedCat });
alert('Configuración guardada correctamente.');
} catch (e) {
alert('Error al guardar.');
} finally {
setLoading(false);
setSaving(false);
}
};
// Helper para el nombre del rubro seleccionado
const selectedCatName = flatCategories.find(c => c.id === selectedCat)?.path;
return (
<div className="max-w-4xl mx-auto">
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2">
<div className="max-w-5xl mx-auto">
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2 text-gray-800">
<DollarSign className="text-green-600" />
Gestor de Tarifas y Reglas
</h2>
<div className="bg-white p-6 rounded shadow mb-6">
<label className="block text-sm font-medium mb-2">Seleccionar Rubro a Configurar</label>
<select
className="w-full border p-2 rounded"
onChange={e => setSelectedCat(Number(e.target.value))}
value={selectedCat || ''}
>
<option value="">-- Seleccione --</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
{/* SELECTOR DE RUBRO */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<label className="block text-sm font-bold text-gray-700 mb-2">Seleccionar Rubro a Configurar</label>
<div className="relative">
<select
className="w-full border border-gray-300 p-3 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none font-medium appearance-none bg-white"
onChange={e => setSelectedCat(Number(e.target.value) || null)}
value={selectedCat || ''}
>
<option value="">-- Seleccione un Rubro --</option>
{flatCategories.map(cat => (
<option
key={cat.id}
value={cat.id}
disabled={!cat.isSelectable} // Bloqueamos padres para forzar config en hojas
className={cat.isSelectable ? "text-gray-900 font-medium" : "text-gray-400 font-bold bg-gray-50"}
>
{'\u00A0\u00A0'.repeat(cat.level)}
{cat.hasChildren ? `📂 ${cat.name}` : `${cat.name}`}
</option>
))}
</select>
{/* Flecha custom para estilo */}
<div className="absolute right-4 top-3.5 pointer-events-none text-gray-500"></div>
</div>
{selectedCat && (
<p className="mt-2 text-sm text-blue-600 bg-blue-50 p-2 rounded inline-block">
Editando: <strong>{selectedCatName}</strong>
</p>
)}
</div>
{selectedCat && (
<div className="bg-white p-6 rounded shadow border border-gray-200">
<h3 className="font-bold text-lg mb-4 border-b pb-2">Reglas de Precio</h3>
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 gap-6">
{/* Tarifa Base */}
<div className="space-y-4">
<h4 className="font-semibold text-blue-600">Base</h4>
<div>
<label className="block text-sm text-gray-600">Precio Mínimo ($)</label>
<input type="number" className="border p-2 rounded w-full"
value={config.basePrice} onChange={e => setConfig({ ...config, basePrice: parseFloat(e.target.value) })} />
</div>
<div>
<label className="block text-sm text-gray-600">Palabras Incluidas (Cant.)</label>
<input type="number" className="border p-2 rounded w-full"
value={config.baseWordCount} onChange={e => setConfig({ ...config, baseWordCount: parseInt(e.target.value) })} />
</div>
<div>
<label className="block text-sm text-gray-600">Costo Palabra Extra ($)</label>
<input type="number" className="border p-2 rounded w-full"
value={config.extraWordPrice} onChange={e => setConfig({ ...config, extraWordPrice: parseFloat(e.target.value) })} />
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Extras y Estilos */}
<div className="space-y-4">
<h4 className="font-semibold text-purple-600">Extras y Recargos</h4>
<div>
<label className="block text-sm text-gray-600">Caracteres Especiales (ej: !$%)</label>
<input type="text" className="border p-2 rounded w-full"
value={config.specialChars} onChange={e => setConfig({ ...config, specialChars: e.target.value })} />
</div>
<div>
<label className="block text-sm text-gray-600">Costo por Caracter Especial ($)</label>
<input type="number" className="border p-2 rounded w-full"
value={config.specialCharPrice} onChange={e => setConfig({ ...config, specialCharPrice: parseFloat(e.target.value) })} />
</div>
<div className="grid grid-cols-2 gap-2">
{/* TARIFA BASE */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
<FileText size={20} className="text-blue-500" /> Tarifa Base (Texto)
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-600">Recargo Negrita ($)</label>
<input type="number" className="border p-2 rounded w-full"
value={config.boldSurcharge} onChange={e => setConfig({ ...config, boldSurcharge: parseFloat(e.target.value) })} />
<label className="block text-sm font-medium text-gray-600 mb-1">Precio Mínimo ($)</label>
<div className="relative">
<span className="absolute left-3 top-2 text-gray-500">$</span>
<input type="number" className="pl-6 border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none"
value={config.basePrice} onChange={e => setConfig({ ...config, basePrice: parseFloat(e.target.value) })} />
</div>
<p className="text-xs text-gray-400 mt-1">Costo por el aviso básico por día.</p>
</div>
<div>
<label className="block text-sm text-gray-600">Recargo Recuadro ($)</label>
<input type="number" className="border p-2 rounded w-full"
value={config.frameSurcharge} onChange={e => setConfig({ ...config, frameSurcharge: parseFloat(e.target.value) })} />
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">Palabras Incluidas</label>
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none"
value={config.baseWordCount} onChange={e => setConfig({ ...config, baseWordCount: parseInt(e.target.value) })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">Costo Palabra Extra ($)</label>
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none"
value={config.extraWordPrice} onChange={e => setConfig({ ...config, extraWordPrice: parseFloat(e.target.value) })} />
</div>
</div>
</div>
</div>
{/* CONTENIDO ESPECIAL */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
<AlertCircle size={20} className="text-orange-500" /> Caracteres Especiales
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">Caracteres a cobrar (ej: !$%)</label>
<input type="text" className="border p-2 rounded w-full font-mono tracking-widest focus:ring-2 focus:ring-orange-500 outline-none"
value={config.specialChars} onChange={e => setConfig({ ...config, specialChars: e.target.value })} />
<p className="text-xs text-gray-400 mt-1">Cada uno de estos símbolos se cobrará aparte.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">Costo por Símbolo ($)</label>
<div className="relative">
<span className="absolute left-3 top-2 text-gray-500">$</span>
<input type="number" className="pl-6 border p-2 rounded w-full focus:ring-2 focus:ring-orange-500 outline-none"
value={config.specialCharPrice} onChange={e => setConfig({ ...config, specialCharPrice: parseFloat(e.target.value) })} />
</div>
</div>
</div>
</div>
{/* ESTILOS VISUALES */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 lg:col-span-2">
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
<Type size={20} className="text-purple-500" /> Estilos Visuales (Recargos)
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex items-center gap-4 bg-gray-50 p-4 rounded border border-gray-200">
<div className="font-bold text-xl px-3 py-1 border-2 border-transparent bg-white shadow-sm">N</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-600 mb-1">Recargo Negrita ($)</label>
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-purple-500 outline-none"
value={config.boldSurcharge} onChange={e => setConfig({ ...config, boldSurcharge: parseFloat(e.target.value) })} />
</div>
</div>
<div className="flex items-center gap-4 bg-gray-50 p-4 rounded border border-gray-200">
<div className="font-bold text-xl px-3 py-1 border-2 border-black bg-white shadow-sm">A</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-600 mb-1">Recargo Recuadro ($)</label>
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-purple-500 outline-none"
value={config.frameSurcharge} onChange={e => setConfig({ ...config, frameSurcharge: parseFloat(e.target.value) })} />
</div>
</div>
</div>
</div>
</div>
<div className="mt-8 flex justify-end">
{/* BARRA DE ACCIÓN FLOTANTE */}
<div className="sticky bottom-4 bg-gray-900 text-white p-4 rounded-lg shadow-lg flex justify-between items-center z-10">
<div className="text-sm">
Configurando tarifas para: <span className="font-bold text-green-400">{selectedCatName}</span>
</div>
<button
onClick={handleSave}
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 flex items-center gap-2"
disabled={saving}
className="bg-green-600 text-white px-8 py-2 rounded font-bold hover:bg-green-500 disabled:opacity-50 transition flex items-center gap-2"
>
<Save size={18} /> Guardar Configuración
<Save size={20} /> {saving ? 'Guardando...' : 'GUARDAR CAMBIOS'}
</button>
</div>
</div>
)}
</div>

View File

@@ -0,0 +1,105 @@
import { useState, useEffect } from 'react';
import { reportService, type CategorySales } from '../../services/reportService';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { LayoutGrid, Calendar, ArrowRight } from 'lucide-react';
const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
export default function SalesByCategory() {
const [data, setData] = useState<CategorySales[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const res = await reportService.getSalesByCategory();
setData(res);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
<LayoutGrid className="text-blue-600" />
Rendimiento por Rubro
</h2>
<div className="flex gap-2 items-center bg-white p-2 rounded-lg border text-sm">
<Calendar size={16} className="text-gray-400" />
<span className="text-gray-600">Últimos 30 días</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Gráfico de Barras */}
<div className="lg:col-span-2 bg-white p-6 rounded-xl border shadow-sm">
<h3 className="font-bold text-gray-700 mb-6">Ingresos Totales por Rubro (Consolidado)</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ left: 30, right: 30 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} stroke="#F3F4F6" />
<XAxis type="number" hide />
<YAxis
dataKey="categoryName"
type="category"
axisLine={false}
tickLine={false}
width={100}
tick={{ fill: '#4B5563', fontSize: 12, fontWeight: 600 }}
/>
<Tooltip
formatter={(value: number | undefined) => value ? [`$${value.toLocaleString()}`, 'Ventas'] : ['-', 'Ventas']}
cursor={{ fill: '#F9FAFB' }}
/>
<Bar dataKey="totalSales" radius={[0, 4, 4, 0]} barSize={30}>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Resumen Lateral */}
<div className="bg-white p-6 rounded-xl border shadow-sm flex flex-col">
<h3 className="font-bold text-gray-700 mb-4">Top Participación</h3>
<div className="space-y-4 flex-1">
{data.map((item, index) => (
<div key={item.categoryId} className="group cursor-default">
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-gray-600">{item.categoryName}</span>
<span className="font-bold text-gray-900">{item.percentage}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div
className="h-2 rounded-full transition-all duration-500"
style={{
width: `${item.percentage}%`,
backgroundColor: COLORS[index % COLORS.length]
}}
></div>
</div>
<div className="flex justify-between mt-1">
<span className="text-[10px] text-gray-400 uppercase">{item.adCount} avisos</span>
<span className="text-[10px] font-bold text-gray-500">${item.totalSales.toLocaleString()}</span>
</div>
</div>
))}
</div>
<button className="mt-6 w-full py-2 bg-gray-50 text-gray-600 text-xs font-bold rounded-lg border border-gray-100 hover:bg-gray-100 flex items-center justify-center gap-2">
DESCARGAR DETALLE <ArrowRight size={14} />
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL, // Usa la variable de entorno
baseURL: import.meta.env.VITE_API_URL,
});
// 1. Interceptor de Solicitud (Request): Pega el token antes de salir
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
@@ -12,4 +13,30 @@ api.interceptors.request.use((config) => {
return config;
});
// 2. Interceptor de Respuesta (Response): Atrapa errores al volver
api.interceptors.response.use(
(response) => {
// Si la respuesta es exitosa (200-299), la dejamos pasar
return response;
},
(error) => {
// Si hay un error de respuesta
if (error.response && error.response.status === 401) {
console.warn("Sesión expirada o no autorizada. Redirigiendo al login...");
// Borramos el token vencido/inválido
localStorage.removeItem('token');
// Forzamos la redirección al Login
// Usamos window.location.href para asegurar una recarga limpia fuera del Router de React si es necesario
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
// Rechazamos la promesa para que el componente sepa que hubo error (si necesita mostrar alerta)
return Promise.reject(error);
}
);
export default api;

View File

@@ -0,0 +1,12 @@
import api from './api';
export const clientService = {
getAll: async () => {
const res = await api.get('/clients');
return res.data;
},
getHistory: async (id: number) => {
const res = await api.get(`/clients/${id}/history`);
return res.data;
}
};

View File

@@ -0,0 +1,19 @@
import api from './api';
export interface DashboardData {
revenueToday: number;
adsToday: number;
ticketAverage: number;
paperOccupation: number;
weeklyTrend: { day: string; amount: number }[];
channelMix: { name: string; value: number }[];
}
export const dashboardService = {
getStats: async (from?: string, to?: string): Promise<DashboardData> => {
const response = await api.get<DashboardData>('/reports/dashboard', {
params: { from, to }
});
return response.data;
}
};

View File

@@ -0,0 +1,12 @@
import api from './api';
export const listingService = {
getPendingCount: async (): Promise<number> => {
const response = await api.get<number>('/listings/pending/count');
return response.data;
},
getById: async (id: number) => {
const res = await api.get(`/listings/${id}`);
return res.data;
},
};

View File

@@ -0,0 +1,32 @@
import api from './api';
export interface CategorySales {
categoryId: number;
categoryName: string;
totalSales: number;
adCount: number;
percentage: number;
}
export const reportService = {
getSalesByCategory: async (from?: string, to?: string): Promise<CategorySales[]> => {
const response = await api.get<CategorySales[]>('/reports/sales-by-category', {
params: { from, to }
});
return response.data;
},
exportCierre: async (from: string, to: string, userId?: number) => {
const response = await api.get(`/reports/export-cierre`, {
params: { from, to, userId },
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/pdf' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', userId ? `Caja_Cajero_${userId}.pdf` : `Cierre_Global.pdf`);
document.body.appendChild(link);
link.click();
link.remove();
}
};

View File

@@ -2,20 +2,36 @@ import { create } from 'zustand';
interface AuthState {
token: string | null;
role: string | null;
isAuthenticated: boolean;
setToken: (token: string) => void;
logout: () => void;
}
// Helper para decodificar el JWT sin librerías externas
const parseJwt = (token: string) => {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
};
export const useAuthStore = create<AuthState>((set) => ({
token: localStorage.getItem('token'),
role: localStorage.getItem('role'),
isAuthenticated: !!localStorage.getItem('token'),
setToken: (token: string) => {
const payload = parseJwt(token);
const role = payload["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"];
localStorage.setItem('token', token);
set({ token, isAuthenticated: true });
localStorage.setItem('role', role);
set({ token, role, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem('token');
set({ token: null, isAuthenticated: false });
localStorage.removeItem('role');
set({ token: null, role: null, isAuthenticated: false });
},
}));
}));

View File

@@ -0,0 +1,75 @@
import { type Category } from '../types/Category';
export interface FlatCategory {
id: number;
name: string;
level: number;
parentId: number | null;
path: string;
isSelectable: boolean; // True si no tiene hijos
hasChildren: boolean;
}
interface CategoryNode extends Category {
children: CategoryNode[];
}
export const buildTree = (categories: Category[]): CategoryNode[] => {
const map = new Map<number, CategoryNode>();
const roots: CategoryNode[] = [];
// 1. Inicializar nodos
categories.forEach(cat => {
// @ts-ignore
map.set(cat.id, { ...cat, children: [] });
});
// 2. Construir relaciones
categories.forEach(cat => {
const node = map.get(cat.id);
if (node) {
if (cat.parentId && map.has(cat.parentId)) {
map.get(cat.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
});
return roots;
};
export const flattenCategoriesForSelect = (
nodes: CategoryNode[],
level = 0,
parentPath = ""
): FlatCategory[] => {
let result: FlatCategory[] = [];
const sortedNodes = [...nodes].sort((a, b) => a.name.localeCompare(b.name));
for (const node of sortedNodes) {
const currentPath = parentPath ? `${parentPath} > ${node.name}` : node.name;
const hasChildren = node.children && node.children.length > 0;
result.push({
id: node.id,
name: node.name,
level: level,
parentId: node.parentId || null,
path: currentPath,
hasChildren: hasChildren,
isSelectable: !hasChildren
});
if (hasChildren) {
const childrenFlat = flattenCategoriesForSelect(node.children, level + 1, currentPath);
result = [...result, ...childrenFlat];
}
}
return result;
};
export const processCategories = (rawCategories: Category[]): FlatCategory[] => {
const tree = buildTree(rawCategories);
return flattenCategoriesForSelect(tree);
};

View File

@@ -1,13 +1,22 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import CounterLayout from './layouts/CounterLayout';
import FastEntryPage from './pages/FastEntryPage';
import CashRegisterPage from './pages/CashRegisterPage';
import LoginPage from './pages/LoginPage';
// Componente simple de protección
const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
const token = localStorage.getItem('token');
return token ? <>{children}</> : <Navigate to="/login" />;
};
function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<CounterLayout />}>
<Route path="/login" element={<LoginPage />} />
<Route element={<PrivateRoute><CounterLayout /></PrivateRoute>}>
<Route path="/" element={<FastEntryPage />} />
<Route path="/cash-register" element={<CashRegisterPage />} />
</Route>

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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -6,11 +6,16 @@ export default function CounterLayout() {
const navigate = useNavigate();
const location = useLocation();
const handleLogout = () => {
if (confirm('¿Cerrar sesión de caja?')) {
localStorage.removeItem('token');
localStorage.removeItem('user');
navigate('/login');
}
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Evitar conflictos si el usuario está escribiendo en un input,
// excepto para F-Keys que suelen ser globales
switch (e.key) {
case 'F2':
e.preventDefault();
@@ -20,8 +25,13 @@ export default function CounterLayout() {
e.preventDefault();
navigate('/cash-register');
break;
case 'F10':
// F10 se maneja en el componente hijo (FastEntryPage),
// pero prevenimos el default del navegador
e.preventDefault();
break;
case 'Escape':
// Lógica para cancelar o volver
// Opcional: Cancelar o Logout rápido
break;
}
};
@@ -32,7 +42,7 @@ export default function CounterLayout() {
return (
<div className="flex flex-col h-screen bg-gray-100">
{/* Header tipo Sistema Legacy pero moderno */}
{/* Header */}
<header className="bg-slate-800 text-white p-3 flex justify-between items-center shadow-md">
<div className="flex items-center gap-4">
<div className="font-bold text-xl tracking-wider flex items-center gap-2">
@@ -45,7 +55,11 @@ export default function CounterLayout() {
</div>
</div>
<button className="text-red-400 hover:text-red-300 flex items-center gap-1 text-sm font-bold">
{/* BOTÓN SALIR CONECTADO */}
<button
onClick={handleLogout}
className="text-red-400 hover:text-red-300 flex items-center gap-1 text-sm font-bold transition-colors cursor-pointer"
>
<LogOut size={16} /> SALIR
</button>
</header>
@@ -57,10 +71,18 @@ export default function CounterLayout() {
{/* Footer con Barra de Atajos */}
<footer className="bg-slate-900 text-white p-2 flex gap-4 text-sm font-mono border-t-4 border-blue-600">
<ShortcutKey k="F2" label="Nuevo Aviso" active={location.pathname === '/'} />
<ShortcutKey k="F4" label="Caja Diaria" active={location.pathname === '/cash-register'} />
<ShortcutKey k="F10" label="Cobrar/Imprimir" />
<ShortcutKey k="ESC" label="Cancelar" />
<button onClick={() => navigate('/')} className="hover:bg-slate-800 p-1 rounded transition focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer">
<ShortcutKey k="F2" label="Nuevo Aviso" active={location.pathname === '/'} />
</button>
<button onClick={() => navigate('/cash-register')} className="hover:bg-slate-800 p-1 rounded transition focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer">
<ShortcutKey k="F4" label="Caja Diaria" active={location.pathname === '/cash-register'} />
</button>
<div className="opacity-75 cursor-help" title="Presione F10 en la pantalla de carga">
<ShortcutKey k="F10" label="Cobrar/Imprimir" />
</div>
<div className="opacity-75">
<ShortcutKey k="ESC" label="Cancelar" />
</div>
</footer>
</div>
);

View File

@@ -1,38 +1,126 @@
import { useState, useEffect } from 'react';
import { cashRegisterService } from '../services/cashRegisterService';
import { Printer, Download, Clock, CheckCircle, RefreshCw } from 'lucide-react';
export default function CashRegisterPage() {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const loadData = async () => {
setLoading(true);
try {
const result = await cashRegisterService.getDailyStatus();
setData(result);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleCerrarCaja = async () => {
if (!confirm("¿Desea finalizar el turno y descargar el comprobante de cierre?")) return;
try {
await cashRegisterService.downloadClosurePdf();
alert("Cierre generado con éxito. Entregue el reporte junto con el efectivo.");
} catch (e) {
alert("Error al generar el PDF");
}
};
if (loading && !data) return <div className="p-10 text-center text-gray-500">Sincronizando caja...</div>;
return (
<div className="p-6 w-full">
<h2 className="text-2xl font-bold text-slate-800 mb-6">Caja Diaria</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white p-6 rounded shadow border-l-4 border-green-500">
<div className="text-gray-500 text-sm font-bold uppercase">Total Efectivo</div>
<div className="text-3xl font-mono mt-2">$ 15,400.00</div>
<div className="p-8 w-full flex flex-col gap-6 bg-gray-100 h-full overflow-y-auto">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-black text-slate-800 tracking-tight">MI CAJA DIARIA</h2>
<p className="text-gray-500 text-sm flex items-center gap-1">
<Clock size={14} /> Turno actual: {new Date().toLocaleDateString()}
</p>
</div>
<div className="bg-white p-6 rounded shadow border-l-4 border-blue-500">
<div className="text-gray-500 text-sm font-bold uppercase">Avisos Cobrados</div>
<div className="text-3xl font-mono mt-2">12</div>
<div className="flex gap-3">
<button onClick={loadData} className="p-2 text-gray-400 hover:text-blue-600 transition-colors">
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={handleCerrarCaja}
className="bg-gray-900 text-white px-6 py-2 rounded-lg font-bold flex items-center gap-2 hover:bg-black transition-all shadow-lg active:scale-95"
>
<Printer size={20} /> FINALIZAR Y CERRAR (F4)
</button>
</div>
</div>
<div className="bg-white rounded shadow overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="p-3">Hora</th>
<th className="p-3">Concepto</th>
<th className="p-3">Usuario</th>
<th className="p-3 text-right">Monto</th>
</tr>
</thead>
<tbody>
<tr className="border-b">
<td className="p-3 font-mono">09:15</td>
<td className="p-3">Aviso Automotores x3 días</td>
<td className="p-3">cajero1</td>
<td className="p-3 text-right font-mono">$ 4,500.00</td>
</tr>
{/* Más filas mock */}
</tbody>
</table>
{/* KPIs DE CAJA */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-8 rounded-2xl shadow-sm border-b-4 border-green-500 flex justify-between items-center">
<div>
<div className="text-gray-400 text-xs font-bold uppercase tracking-widest">Efectivo a Rendir</div>
<div className="text-4xl font-black text-slate-800 mt-1">$ {data?.totalRevenue?.toLocaleString('es-AR', { minimumFractionDigits: 2 })}</div>
</div>
<div className="p-4 bg-green-50 rounded-full text-green-600">
<Download size={32} />
</div>
</div>
<div className="bg-white p-8 rounded-2xl shadow-sm border-b-4 border-blue-500 flex justify-between items-center">
<div>
<div className="text-gray-400 text-xs font-bold uppercase tracking-widest">Avisos Procesados</div>
<div className="text-4xl font-black text-slate-800 mt-1">{data?.totalAds} <span className="text-lg text-gray-300">Clasificados</span></div>
</div>
<div className="p-4 bg-blue-50 rounded-full text-blue-600">
<CheckCircle size={32} />
</div>
</div>
</div>
{/* TABLA DE MOVIMIENTOS */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden flex-1 flex flex-col">
<div className="p-4 bg-gray-50 border-b font-bold text-slate-600 text-sm uppercase tracking-wider">
Detalle de transacciones del turno
</div>
<div className="overflow-y-auto flex-1">
<table className="w-full text-left border-collapse">
<thead className="bg-gray-50 text-gray-400 text-[10px] uppercase font-bold sticky top-0 z-10">
<tr>
<th className="p-4 border-b">ID</th>
<th className="p-4 border-b">Hora</th>
<th className="p-4 border-b">Aviso / Título</th>
<th className="p-4 border-b">Rubro</th>
<th className="p-4 border-b text-right">Monto</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm">
{data?.items.map((item: any) => (
<tr key={item.id} className="hover:bg-blue-50/50 transition-colors">
<td className="p-4 font-mono text-gray-400">#{item.id}</td>
<td className="p-4 text-gray-600">{new Date(item.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
<td className="p-4 font-bold text-slate-700">{item.title}</td>
<td className="p-4">
<span className="bg-gray-100 px-2 py-1 rounded text-[10px] font-bold text-gray-500 uppercase">{item.category}</span>
</td>
<td className="p-4 text-right font-black text-slate-900">$ {item.amount.toLocaleString()}</td>
</tr>
))}
{data?.items.length === 0 && (
<tr>
<td colSpan={5} className="p-20 text-center text-gray-400 italic">No se han registrado cobros en este turno todavía.</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Footer de la tabla con el total */}
<div className="p-4 bg-slate-900 text-white flex justify-between items-center">
<span className="text-xs font-bold uppercase opacity-60">Total en Caja</span>
<span className="text-xl font-mono font-bold text-green-400">$ {data?.totalRevenue?.toLocaleString()}</span>
</div>
</div>
</div>
);

View File

@@ -1,45 +1,37 @@
import { useState, useEffect, useRef } from 'react';
import api from '../services/api';
import { useDebounce } from '../hooks/useDebounce';
import { processCategories, type FlatCategory } from '../utils/categoryTreeUtils';
import {
Printer, Save, CheckSquare, Square, Tag,
Printer, Save,
AlignLeft, AlignCenter, AlignRight, AlignJustify,
Type
Type, Search, ChevronDown, Bold, Square as FrameIcon
} from 'lucide-react';
import clsx from 'clsx';
interface Category { id: number; name: string; }
// Interfaces
interface Operation { id: number; name: string; }
interface Client { id: number; name: string; dniOrCuit: string; }
interface PricingResult {
totalPrice: number;
baseCost: number;
extraCost: number;
surcharges: number;
discount: number;
wordCount: number;
specialCharCount: number;
details: string;
appliedPromotion: string;
totalPrice: number; baseCost: number; extraCost: number;
surcharges: number; discount: number; wordCount: number;
specialCharCount: number; details: string; appliedPromotion: string;
}
export default function FastEntryPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
const [operations, setOperations] = useState<Operation[]>([]);
const [categorySearch, setCategorySearch] = useState("");
const [isCatDropdownOpen, setIsCatDropdownOpen] = useState(false);
const catWrapperRef = useRef<HTMLDivElement>(null);
const [formData, setFormData] = useState({
categoryId: '',
operationId: '',
text: '',
days: 3,
clientName: '',
categoryId: '', operationId: '', text: '', days: 3, clientName: '', clientDni: '',
});
const [options, setOptions] = useState({
isBold: false,
isFrame: false,
fontSize: 'normal', // 'small', 'normal', 'large', 'extra'
alignment: 'left' // 'left', 'center', 'right', 'justify'
isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left'
});
const [pricing, setPricing] = useState<PricingResult>({
@@ -47,337 +39,386 @@ export default function FastEntryPage() {
wordCount: 0, specialCharCount: 0, details: '', appliedPromotion: ''
});
const [clientSuggestions, setClientSuggestions] = useState<Client[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const debouncedClientSearch = useDebounce(formData.clientName, 300);
const clientWrapperRef = useRef<HTMLDivElement>(null);
const textInputRef = useRef<HTMLTextAreaElement>(null);
const debouncedText = useDebounce(formData.text, 500);
const filteredCategories = flatCategories.filter(cat =>
cat.path.toLowerCase().includes(categorySearch.toLowerCase()) ||
cat.name.toLowerCase().includes(categorySearch.toLowerCase())
);
const selectedCategoryName = flatCategories.find(c => c.id === parseInt(formData.categoryId))?.name;
const printCourtesyTicket = (data: any, priceInfo: PricingResult) => {
const printWindow = window.open('', '_blank', 'width=300,height=600');
if (!printWindow) return;
const html = `
<html>
<body style="font-family: 'Courier New', monospace; width: 280px; padding: 10px; font-size: 12px;">
<div style="text-align: center; border-bottom: 1px dashed #000; padding-bottom: 10px; margin-bottom: 10px;">
<h2 style="margin: 0;">DIARIO EL DIA</h2>
<p>Comprobante de Recepción</p>
<p>${new Date().toLocaleString()}</p>
</div>
<div>
<b>CLIENTE:</b> ${data.clientName || 'Consumidor Final'}<br/>
<b>RUBRO:</b> ${selectedCategoryName}<br/>
<b>DÍAS:</b> ${data.days}<br/>
</div>
<div style="background: #f0f0f0; padding: 8px; margin: 10px 0; border: 1px solid #000;">
${data.text.toUpperCase()}
</div>
<div style="text-align: right; border-top: 1px dashed #000; padding-top: 5px;">
<b style="font-size: 16px;">TOTAL: $${priceInfo.totalPrice.toLocaleString()}</b>
</div>
</body>
</html>
`;
printWindow.document.write(html);
printWindow.document.close();
printWindow.focus();
setTimeout(() => { printWindow.print(); printWindow.close(); }, 250);
};
useEffect(() => {
const fetchData = async () => {
try {
const [catRes, opRes] = await Promise.all([
api.get('/categories'),
api.get('/operations')
]);
setCategories(catRes.data);
const [catRes, opRes] = await Promise.all([api.get('/categories'), api.get('/operations')]);
setFlatCategories(processCategories(catRes.data));
setOperations(opRes.data);
} catch (error) {
console.error("Error cargando datos base", error);
}
} catch (error) { console.error(error); }
};
fetchData();
}, []);
useEffect(() => {
if (!formData.categoryId || !formData.text) {
setPricing(prev => ({ ...prev, totalPrice: 0, details: '' }));
return;
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'F10') {
e.preventDefault();
handleSubmit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [formData, pricing, options]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (catWrapperRef.current && !catWrapperRef.current.contains(event.target as Node)) setIsCatDropdownOpen(false);
if (clientWrapperRef.current && !clientWrapperRef.current.contains(event.target as Node)) setShowSuggestions(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (!formData.categoryId) return;
const calculatePrice = async () => {
try {
const res = await api.post('/pricing/calculate', {
categoryId: parseInt(formData.categoryId),
text: formData.text,
text: debouncedText || "",
days: formData.days,
isBold: options.isBold,
isFrame: options.isFrame,
startDate: new Date().toISOString()
});
setPricing(res.data);
} catch (error) {
console.error("Error calculando precio", error);
}
} catch (error) { console.error(error); }
};
calculatePrice();
}, [debouncedText, formData.categoryId, formData.days, options]);
useEffect(() => {
if (debouncedClientSearch.length > 2 && showSuggestions) {
api.get(`/clients/search?q=${debouncedClientSearch}`)
.then(res => setClientSuggestions(res.data))
.catch(() => setClientSuggestions([]));
}
}, [debouncedClientSearch, showSuggestions]);
const handleSubmit = async () => {
if (!formData.categoryId || !formData.operationId || !formData.text) {
alert("Faltan datos obligatorios.");
alert("⚠️ Error: Complete Rubro, Operación y Texto.");
return;
}
if (!confirm(`¿Confirmar cobro de $${pricing.totalPrice.toLocaleString()}?`)) return;
try {
const startDate = new Date();
startDate.setDate(startDate.getDate() + 1);
const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
await api.post('/listings', {
categoryId: parseInt(formData.categoryId),
operationId: parseInt(formData.operationId),
title: formData.text.substring(0, 40) + (formData.text.length > 40 ? '...' : ''),
title: formData.text.substring(0, 40) + '...',
description: formData.text,
price: 0,
currency: 'ARS',
price: 0, adFee: pricing.totalPrice,
status: 'Published',
userId: null,
printText: formData.text,
printStartDate: startDate.toISOString(),
printStartDate: tomorrow.toISOString(),
printDaysCount: formData.days,
isBold: options.isBold,
isFrame: options.isFrame,
printFontSize: options.fontSize,
printAlignment: options.alignment
isBold: options.isBold, isFrame: options.isFrame,
printFontSize: options.fontSize, printAlignment: options.alignment,
clientName: formData.clientName, clientDni: formData.clientDni
});
alert('✅ Aviso guardado y ticket generado.');
setFormData({ ...formData, text: '', clientName: '' });
printCourtesyTicket(formData, pricing);
setFormData({ ...formData, text: '', clientName: '', clientDni: '' });
setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' });
textInputRef.current?.focus();
} catch (err) {
console.error(err);
alert('❌ Error al procesar el cobro.');
}
alert('✅ Aviso procesado correctamente.');
} catch (err) { alert('❌ Error al procesar el cobro.'); }
};
// Helper para clases de Tailwind según estado
const getFontSizeClass = (size: string) => {
switch (size) {
case 'small': return 'text-xs';
case 'large': return 'text-lg';
case 'extra': return 'text-xl';
default: return 'text-sm';
}
};
const getAlignClass = (align: string) => {
switch (align) {
case 'center': return 'text-center';
case 'right': return 'text-right';
case 'justify': return 'text-justify';
default: return 'text-left';
}
const handleSelectClient = (client: Client) => {
setFormData(prev => ({ ...prev, clientName: client.name, clientDni: client.dniOrCuit }));
setShowSuggestions(false);
};
return (
<div className="w-full h-full p-4 flex gap-4 bg-gray-100">
<div className="w-full h-full p-4 flex gap-4 bg-gray-100 overflow-hidden max-h-screen">
{/* --- COLUMNA IZQUIERDA: INPUTS --- */}
<div className="flex-1 bg-white rounded-lg shadow-sm border border-gray-200 p-6 flex flex-col">
<div className="flex justify-between items-center mb-6 border-b pb-2">
<h2 className="text-xl font-bold text-slate-800">Nueva Publicación (F2)</h2>
<span className="text-xs font-mono text-slate-400">ID TERMINAL: T-01</span>
{/* PANEL IZQUIERDO: FORMULARIO */}
<div className="flex-[7] bg-white rounded-2xl shadow-sm border border-gray-200 p-6 flex flex-col min-h-0">
<div className="flex justify-between items-center mb-6 border-b border-gray-100 pb-3">
<div>
<h2 className="text-xl font-black text-slate-800 tracking-tight uppercase">Nueva Publicación</h2>
<p className="text-[10px] text-slate-400 font-mono">ID TERMINAL: T-01 | CAJA: 01</p>
</div>
<Printer size={24} className="text-slate-300" />
</div>
<form className="flex-1 flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">Rubro</label>
<select
className="w-full p-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 bg-gray-50 outline-none"
value={formData.categoryId}
onChange={e => setFormData({ ...formData, categoryId: e.target.value })}
autoFocus
<div className="flex flex-col gap-6 flex-1 min-h-0">
<div className="grid grid-cols-2 gap-6">
<div className="relative" ref={catWrapperRef}>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 tracking-wider">Rubro</label>
<div
className={clsx(
"w-full p-3 border rounded-xl bg-gray-50 flex justify-between items-center cursor-pointer transition-all",
isCatDropdownOpen ? "border-blue-500 ring-4 ring-blue-500/10" : "border-gray-200 hover:border-gray-300"
)}
onClick={() => setIsCatDropdownOpen(!isCatDropdownOpen)}
>
<option value="">-- Seleccionar --</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<span className={clsx("font-bold text-sm", !formData.categoryId && "text-gray-400")}>
{selectedCategoryName || "-- Seleccionar Rubro --"}
</span>
<ChevronDown size={18} className={clsx("text-gray-400 transition-transform", isCatDropdownOpen && "rotate-180")} />
</div>
{isCatDropdownOpen && (
<div className="absolute top-full left-0 right-0 bg-white border border-gray-200 shadow-2xl rounded-xl mt-2 z-[100] flex flex-col max-h-[300px] overflow-hidden">
<div className="p-3 bg-gray-50 border-b flex items-center gap-2">
<Search size={16} className="text-gray-400" />
<input autoFocus type="text" placeholder="Filtrar rubros..." className="bg-transparent w-full outline-none text-sm font-medium"
value={categorySearch} onChange={(e) => setCategorySearch(e.target.value)} />
</div>
<div className="overflow-y-auto flex-1 py-2">
{filteredCategories.map(cat => (
<div key={cat.id} className={clsx("px-4 py-2 text-sm cursor-pointer border-l-4 transition-all",
!cat.isSelectable ? "text-gray-400 font-bold bg-gray-50/50 italic pointer-events-none" : "hover:bg-blue-50 border-transparent hover:border-blue-600",
parseInt(formData.categoryId) === cat.id && "bg-blue-100 border-blue-600 font-bold"
)}
style={{ paddingLeft: `${(cat.level * 16) + 16}px` }}
onClick={() => { setFormData({ ...formData, categoryId: cat.id.toString() }); setIsCatDropdownOpen(false); }}
>
{cat.name}
</div>
))}
</div>
</div>
)}
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">Operación</label>
<select
className="w-full p-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 bg-gray-50 outline-none"
value={formData.operationId}
onChange={e => setFormData({ ...formData, operationId: e.target.value })}
>
<option value="">-- Seleccionar --</option>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 tracking-wider">Operación</label>
<select className="w-full p-3 border border-gray-200 rounded-xl bg-gray-50 outline-none focus:border-blue-500 font-bold text-sm"
value={formData.operationId} onChange={e => setFormData({ ...formData, operationId: e.target.value })}>
<option value="">-- Operación --</option>
{operations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
</div>
<div className="flex-1 flex flex-col">
<label className="block text-sm font-bold text-slate-700 mb-1">Texto del Aviso</label>
{/* --- Textarea limpio sin estilos condicionales --- */}
<div className="flex flex-col flex-1 min-h-0">
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 tracking-wider">Cuerpo del Aviso (Texto para Imprenta)</label>
<textarea
ref={textInputRef}
className="flex-1 w-full p-4 border border-gray-300 rounded resize-none focus:ring-2 focus:ring-blue-500 outline-none font-mono text-lg text-slate-700 leading-relaxed"
className="flex-1 w-full p-5 border border-gray-300 rounded-2xl resize-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none font-mono text-xl text-slate-700 leading-relaxed shadow-inner bg-gray-50/30"
placeholder="ESCRIBA EL TEXTO AQUÍ..."
value={formData.text}
onChange={e => setFormData({ ...formData, text: e.target.value })}
></textarea>
<div className="flex justify-between items-center mt-2 text-sm text-slate-500">
<div className="flex gap-2">
<span>Palabras: <b>{pricing.wordCount}</b></span>
{pricing.specialCharCount > 0 && <span className="text-orange-600">Signos (+$$): <b>{pricing.specialCharCount}</b></span>}
<div className="flex justify-between items-center mt-3 bg-slate-50 p-2 rounded-lg border border-slate-100">
<div className="flex gap-4 text-xs font-bold uppercase tracking-widest text-slate-400">
<span className={pricing.wordCount > 0 ? "text-blue-600" : ""}>Palabras: {pricing.wordCount}</span>
{pricing.specialCharCount > 0 && <span className="text-orange-600">Signos: {pricing.specialCharCount}</span>}
</div>
<span className="text-xs uppercase">Escribir en Mayúsculas recomendado</span>
<span className="text-[10px] text-slate-300 font-bold italic uppercase">Uso de mayúsculas recomendado</span>
</div>
</div>
<div className="grid grid-cols-3 gap-4 bg-gray-50 p-4 rounded border border-gray-200">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase">Días</label>
<input
type="number"
className="w-full p-2 border rounded font-mono text-center font-bold"
value={formData.days}
onChange={e => setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })}
min={1}
/>
<div className="grid grid-cols-4 gap-4 bg-slate-50 p-4 rounded-2xl border border-slate-100">
<div className="col-span-1">
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">Días</label>
<input type="number" className="w-full p-2.5 border border-gray-200 rounded-lg font-black text-center text-blue-600 outline-none"
value={formData.days} onChange={e => setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} />
</div>
<div className="col-span-2">
<label className="block text-xs font-bold text-slate-500 uppercase">Cliente / Razón Social</label>
<input
type="text"
className="w-full p-2 border rounded"
placeholder="Consumidor Final"
value={formData.clientName}
onChange={e => setFormData({ ...formData, clientName: e.target.value })}
/>
<div className="col-span-2 relative" ref={clientWrapperRef}>
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">Cliente / Razón Social</label>
<div className="relative">
<Search className="absolute left-3 top-2.5 text-gray-300" size={16} />
<input type="text" className="w-full pl-9 p-2.5 border border-gray-200 rounded-lg outline-none focus:border-blue-500 font-bold text-sm"
placeholder="Buscar o crear..." value={formData.clientName}
onFocus={() => setShowSuggestions(true)}
onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }}
/>
</div>
{showSuggestions && clientSuggestions.length > 0 && (
<div className="absolute bottom-full mb-2 left-0 right-0 bg-white border border-gray-200 shadow-2xl rounded-xl overflow-hidden z-[110]">
{clientSuggestions.map(client => (
<div key={client.id} className="p-3 hover:bg-blue-50 cursor-pointer border-b border-gray-50 flex justify-between items-center transition-colors"
onClick={() => handleSelectClient(client)}>
<div>
<div className="font-bold text-slate-800 text-xs">{client.name}</div>
<div className="text-[10px] text-slate-400 font-mono">{client.dniOrCuit}</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="col-span-1">
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">DNI / CUIT</label>
<input type="text" className="w-full p-2.5 border border-gray-200 rounded-lg font-mono text-center font-bold text-sm"
placeholder="Documento" value={formData.clientDni} onChange={e => setFormData({ ...formData, clientDni: e.target.value })} />
</div>
</div>
</form>
</div>
</div>
{/* --- COLUMNA DERECHA: TOTALES Y OPCIONES --- */}
<div className="w-96 flex flex-col gap-4">
{/* PANEL DERECHO: TOTALES Y VISTA PREVIA */}
<div className="flex-[3] flex flex-col gap-4 min-h-0 overflow-hidden">
{/* Panel de Totales */}
<div className="bg-slate-900 text-white rounded-xl p-6 shadow-xl relative overflow-hidden">
<div className="relative z-10">
<div className="text-xs text-slate-400 uppercase tracking-widest mb-1 font-semibold">Total a Cobrar</div>
<div className="text-5xl font-mono font-bold text-green-400 mb-4">
${pricing.totalPrice.toLocaleString()}
{/* TOTALES (FIJO ARRIBA) */}
<div className="bg-slate-900 text-white rounded-3xl p-6 shadow-xl border-b-4 border-blue-600 flex-shrink-0">
<div className="text-[10px] text-blue-400 uppercase tracking-widest mb-1 font-black">Total a Cobrar</div>
<div className="text-5xl font-mono font-black text-green-400 flex items-start gap-1">
<span className="text-xl mt-1.5 opacity-50">$</span>
{pricing.totalPrice.toLocaleString()}
</div>
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2 text-[11px] font-bold uppercase tracking-tighter">
<div className="flex justify-between text-slate-400 italic">
<span>Tarifa Base</span>
<span className="text-white">${pricing.baseCost.toLocaleString()}</span>
</div>
<div className="space-y-2 text-sm border-t border-slate-700 pt-3">
<div className="flex justify-between text-slate-300">
<span>Base ({formData.days} días)</span>
<span>${pricing.baseCost.toLocaleString()}</span>
{pricing.extraCost > 0 && (
<div className="flex justify-between text-orange-400">
<span>Recargos por texto</span>
<span>+${pricing.extraCost.toLocaleString()}</span>
</div>
{pricing.extraCost > 0 && (
<div className="flex justify-between text-yellow-300">
<span>Excedentes</span>
<span>+${pricing.extraCost.toLocaleString()}</span>
</div>
)}
{pricing.surcharges > 0 && (
<div className="flex justify-between text-blue-300">
<span>Adicionales (Estilo)</span>
<span>+${pricing.surcharges.toLocaleString()}</span>
</div>
)}
{pricing.discount > 0 && (
<div className="flex justify-between text-green-400 font-bold bg-green-900/30 p-1 rounded px-2 -mx-2">
<span className="flex items-center gap-1"><Tag size={12} /> Descuento</span>
)}
{pricing.surcharges > 0 && (
<div className="flex justify-between text-blue-400">
<span>Estilos visuales</span>
<span>+${pricing.surcharges.toLocaleString()}</span>
</div>
)}
{pricing.discount > 0 && (
<div className="mt-2 p-2 bg-green-500/10 rounded-lg text-green-400 flex flex-col border border-green-500/20 animate-pulse">
<div className="flex justify-between">
<span>Descuento Aplicado</span>
<span>-${pricing.discount.toLocaleString()}</span>
</div>
)}
</div>
{pricing.appliedPromotion && (
<div className="mt-3 text-xs text-center bg-green-600 text-white py-1 rounded">
¡{pricing.appliedPromotion}!
<span className="text-[9px] opacity-70 italic">{pricing.appliedPromotion}</span>
</div>
)}
</div>
</div>
{/* --- NUEVA BARRA DE HERRAMIENTAS (TOOLBAR) --- */}
<div className="bg-white rounded-lg border border-gray-200 p-2 flex justify-between items-center shadow-sm">
{/* CONTENEDOR CENTRAL SCROLLABLE (Toolbar + Preview) */}
<div className="flex-1 overflow-y-auto min-h-0 flex flex-col gap-4 pr-1 custom-scrollbar">
{/* Alineación */}
<div className="flex bg-gray-100 rounded p-1 gap-1">
<button onClick={() => setOptions({ ...options, alignment: 'left' })}
className={clsx("p-1.5 rounded hover:bg-white transition", options.alignment === 'left' && "bg-white shadow text-blue-600")}>
<AlignLeft size={18} />
</button>
<button onClick={() => setOptions({ ...options, alignment: 'center' })}
className={clsx("p-1.5 rounded hover:bg-white transition", options.alignment === 'center' && "bg-white shadow text-blue-600")}>
<AlignCenter size={18} />
</button>
<button onClick={() => setOptions({ ...options, alignment: 'right' })}
className={clsx("p-1.5 rounded hover:bg-white transition", options.alignment === 'right' && "bg-white shadow text-blue-600")}>
<AlignRight size={18} />
</button>
<button onClick={() => setOptions({ ...options, alignment: 'justify' })}
className={clsx("p-1.5 rounded hover:bg-white transition", options.alignment === 'justify' && "bg-white shadow text-blue-600")}>
<AlignJustify size={18} />
</button>
{/* TOOLBAR ESTILOS */}
<div className="bg-white rounded-2xl border border-gray-200 p-2 flex flex-col gap-2 shadow-sm flex-shrink-0">
<div className="flex justify-between items-center px-1">
<div className="flex bg-gray-100 rounded-lg p-1 gap-1">
{['left', 'center', 'right', 'justify'].map(align => (
<button key={align} onClick={() => setOptions({ ...options, alignment: align })}
className={clsx("p-1.5 rounded-md hover:bg-white transition-all shadow-sm", options.alignment === align && "bg-white text-blue-600")}>
{align === 'left' && <AlignLeft size={16} />}
{align === 'center' && <AlignCenter size={16} />}
{align === 'right' && <AlignRight size={16} />}
{align === 'justify' && <AlignJustify size={16} />}
</button>
))}
</div>
<div className="flex bg-gray-100 rounded-lg p-1 gap-1">
{['small', 'normal', 'large'].map(size => (
<button key={size} onClick={() => setOptions({ ...options, fontSize: size })}
className={clsx("p-1.5 rounded-md hover:bg-white transition-all flex items-end", options.fontSize === size && "bg-white shadow-sm text-blue-600")}>
<Type size={size === 'small' ? 12 : size === 'normal' ? 16 : 20} />
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-2 mt-1">
<button
type="button"
onClick={() => setOptions({ ...options, isBold: !options.isBold })}
className={clsx(
"p-2 rounded-lg border text-[10px] font-black transition-all flex items-center justify-center gap-2",
options.isBold ? "bg-blue-600 border-blue-600 text-white shadow-inner" : "bg-gray-50 border-gray-200 text-gray-400 hover:bg-gray-100"
)}
>
<Bold size={14} /> NEGRITA
</button>
<button
type="button"
onClick={() => setOptions({ ...options, isFrame: !options.isFrame })}
className={clsx(
"p-2 rounded-lg border text-[10px] font-black transition-all flex items-center justify-center gap-2",
options.isFrame ? "bg-slate-800 border-slate-800 text-white shadow-inner" : "bg-gray-50 border-gray-200 text-gray-400 hover:bg-gray-100"
)}
>
<FrameIcon size={14} /> RECUADRO
</button>
</div>
</div>
<div className="w-px h-6 bg-gray-300 mx-1"></div>
{/* Tamaño de Fuente */}
<div className="flex bg-gray-100 rounded p-1 gap-1">
<button onClick={() => setOptions({ ...options, fontSize: 'small' })} title="Pequeño"
className={clsx("p-1.5 rounded hover:bg-white transition flex items-end", options.fontSize === 'small' && "bg-white shadow text-blue-600")}>
<Type size={14} />
</button>
<button onClick={() => setOptions({ ...options, fontSize: 'normal' })} title="Normal"
className={clsx("p-1.5 rounded hover:bg-white transition flex items-end", options.fontSize === 'normal' && "bg-white shadow text-blue-600")}>
<Type size={18} />
</button>
<button onClick={() => setOptions({ ...options, fontSize: 'large' })} title="Grande"
className={clsx("p-1.5 rounded hover:bg-white transition flex items-end", options.fontSize === 'large' && "bg-white shadow text-blue-600")}>
<Type size={22} />
</button>
</div>
</div>
{/* Vista Previa Papel */}
<div className="flex-1 bg-[#fffdf0] border border-yellow-200 rounded-lg p-4 shadow-sm overflow-y-auto flex flex-col min-h-[150px]">
<h3 className="text-[10px] font-bold text-yellow-800 uppercase mb-2 flex items-center gap-1 opacity-70">
<Printer size={12} /> Vista Previa Impresión (Papel)
</h3>
<div className={clsx(
"font-mono leading-tight whitespace-pre-wrap break-words p-2 border-2 transition-all",
// Aplicar Clases Dinámicas
options.isBold ? "font-bold" : "font-normal",
options.isFrame ? "border-slate-900" : "border-transparent",
getFontSizeClass(options.fontSize),
getAlignClass(options.alignment)
)}>
{formData.text || "(Texto vacío)"}
{/* VISTA PREVIA (DINÁMICA AL ALTO DEL TEXTO) */}
<div className="bg-[#fffef5] border border-yellow-200 rounded-2xl p-5 shadow-sm min-h-[180px] h-auto flex flex-col relative group flex-shrink-0">
<div className="absolute top-0 left-0 w-full h-1 bg-yellow-400 opacity-20"></div>
<h3 className="text-[9px] font-black text-yellow-700 uppercase mb-4 flex items-center gap-1.5 opacity-40 tracking-widest">
<Printer size={12} /> Previsualización Real
</h3>
<div className="p-2">
<div className={clsx(
"w-full leading-tight whitespace-pre-wrap break-words transition-all duration-300",
options.isBold ? "font-bold text-gray-900" : "font-medium text-gray-700",
options.isFrame ? "border-2 border-gray-900 p-4 bg-white shadow-md" : "border-none",
options.fontSize === 'small' ? 'text-xs' : options.fontSize === 'large' ? 'text-lg' : 'text-sm',
options.alignment === 'center' ? 'text-center' : options.alignment === 'right' ? 'text-right' : options.alignment === 'justify' ? 'text-justify' : 'text-left'
)}>
{formData.text || "(Aviso vacío)"}
</div>
</div>
</div>
</div>
{/* Botones Estilo Rápido (Negrita/Recuadro) */}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOptions({ ...options, isBold: !options.isBold })}
className={clsx(
"px-2 py-3 rounded-lg border flex items-center justify-center gap-2 transition-all text-sm",
options.isBold
? "border-blue-600 bg-blue-50 text-blue-700 font-bold"
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300"
)}
>
{options.isBold ? <CheckSquare size={18} /> : <Square size={18} />}
Negrita
</button>
<button
type="button"
onClick={() => setOptions({ ...options, isFrame: !options.isFrame })}
className={clsx(
"px-2 py-3 rounded-lg border flex items-center justify-center gap-2 transition-all text-sm",
options.isFrame
? "border-slate-900 bg-slate-100 text-slate-900 font-bold"
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300"
)}
>
{options.isFrame ? <CheckSquare size={18} /> : <Square size={18} />}
Recuadro
</button>
</div>
{/* ACCIÓN PRINCIPAL (FIJO ABAJO) */}
<button
onClick={handleSubmit}
className="bg-blue-600 hover:bg-blue-700 text-white py-4 rounded-lg font-bold shadow-lg shadow-blue-200 flex items-center justify-center gap-2 transition-transform active:scale-95 text-lg mt-2"
className="bg-blue-600 hover:bg-blue-700 text-white py-5 rounded-2xl font-black shadow-2xl flex flex-col items-center justify-center gap-0.5 transition-all active:scale-95 group relative overflow-hidden flex-shrink-0"
>
<Save size={24} />
COBRAR (F10)
<div className="absolute inset-0 bg-white/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
<div className="flex items-center gap-3 text-xl relative z-10">
<Save size={24} /> COBRAR E IMPRIMIR
</div>
<span className="text-[9px] opacity-60 tracking-[0.3em] relative z-10 font-mono">SHORTCUT: F10</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,44 @@
import { useState } from 'react';
import api from '../services/api';
import { useNavigate } from 'react-router-dom';
import { Monitor } from 'lucide-react';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const navigate = useNavigate();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await api.post('/auth/login', { username, password });
localStorage.setItem('token', res.data.token);
localStorage.setItem('user', username); // Guardar usuario para mostrar en header
navigate('/');
} catch (e) {
alert('Credenciales inválidas');
}
};
return (
<div className="h-screen bg-slate-900 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-2xl w-96">
<div className="flex justify-center mb-4 text-slate-800">
<Monitor size={48} />
</div>
<h2 className="text-2xl font-bold text-center mb-6 text-slate-800">Acceso Mostrador</h2>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-bold mb-1">Usuario</label>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} className="w-full border p-2 rounded" autoFocus />
</div>
<div>
<label className="block text-sm font-bold mb-1">Contraseña</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className="w-full border p-2 rounded" />
</div>
<button type="submit" className="w-full bg-blue-600 text-white py-2 rounded font-bold hover:bg-blue-700">INGRESAR</button>
</form>
</div>
</div>
);
}

View File

@@ -1,9 +1,11 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL, // Usa la variable de entorno
// Asegúrate de usar la variable de entorno o la URL correcta
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5176/api',
});
// 1. Interceptor de REQUEST: Pega el token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
@@ -12,4 +14,27 @@ api.interceptors.request.use((config) => {
return config;
});
// 2. Interceptor de RESPONSE: Maneja errores globales (como 401)
api.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// Si el error es 401 (Token vencido o inválido)
if (error.response && error.response.status === 401) {
console.warn("Sesión de caja expirada. Redirigiendo...");
// Limpiar almacenamiento
localStorage.removeItem('token');
localStorage.removeItem('user');
// Redirigir al Login (forzado fuera de React Router para limpiar estado)
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default api;

View File

@@ -0,0 +1,26 @@
import api from './api';
export const cashRegisterService = {
// Obtener resumen y lista de transacciones de hoy
getDailyStatus: async () => {
const res = await api.get('/reports/cashier-transactions');
return res.data; // Devuelve el GlobalReportDto
},
// Disparar la descarga del PDF de cierre
downloadClosurePdf: async () => {
const today = new Date().toISOString().split('T')[0];
const res = await api.get('/reports/export-cierre', {
params: { from: today, to: today },
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `Cierre_Caja_${today}.pdf`);
document.body.appendChild(link);
link.click();
link.remove();
}
};

View File

@@ -0,0 +1,6 @@
export interface Category {
id: number;
name: string;
parentId?: number;
subcategories?: Category[];
}

View File

@@ -0,0 +1,92 @@
import { type Category } from '../types/Category';
// Interfaz para la categoría "Aplanada" y enriquecida para UI
export interface FlatCategory {
id: number;
name: string;
level: number;
parentId: number | null;
path: string; // "Vehículos > Autos"
isSelectable: boolean; // True si no tiene hijos (es hoja)
hasChildren: boolean;
}
// Interfaz auxiliar para el árbol
interface CategoryNode extends Category {
children: CategoryNode[];
}
/**
* Convierte una lista plana de BD en un árbol recursivo
*/
export const buildTree = (categories: Category[]): CategoryNode[] => {
const map = new Map<number, CategoryNode>();
const roots: CategoryNode[] = [];
// 1. Inicializar nodos
categories.forEach(cat => {
map.set(cat.id, { ...cat, children: [] });
});
// 2. Construir relaciones
categories.forEach(cat => {
const node = map.get(cat.id);
if (node) {
if (cat.parentId && map.has(cat.parentId)) {
map.get(cat.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
});
return roots;
};
/**
* Aplana el árbol para usarlo en Selects/Autocompletes
* Calcula breadcrumbs y deshabilita padres
*/
export const flattenCategoriesForSelect = (
nodes: CategoryNode[],
level = 0,
parentPath = ""
): FlatCategory[] => {
let result: FlatCategory[] = [];
// Ordenar alfabéticamente para facilitar búsqueda visual
const sortedNodes = [...nodes].sort((a, b) => a.name.localeCompare(b.name));
for (const node of sortedNodes) {
const currentPath = parentPath ? `${parentPath} > ${node.name}` : node.name;
const hasChildren = node.children && node.children.length > 0;
// Agregamos el nodo actual
result.push({
id: node.id,
name: node.name,
level: level,
parentId: node.parentId || null,
path: currentPath,
hasChildren: hasChildren,
// REGLA DE ORO: Solo seleccionable si NO tiene hijos
isSelectable: !hasChildren
});
// Recursión para los hijos
if (hasChildren) {
const childrenFlat = flattenCategoriesForSelect(node.children, level + 1, currentPath);
result = [...result, ...childrenFlat];
}
}
return result;
};
/**
* Función Helper que hace todo el proceso desde la respuesta de la API
*/
export const processCategories = (rawCategories: Category[]): FlatCategory[] => {
const tree = buildTree(rawCategories);
return flattenCategoriesForSelect(tree);
};

View File

@@ -15,8 +15,9 @@ function App() {
</nav>
<div>
<a
href="http://localhost:5174"
href="http://localhost:5177"
className="bg-primary-600 text-white px-5 py-2 rounded-full font-medium hover:bg-primary-700 transition"
target='_blank'
>
Publicar Aviso
</a>

View File

@@ -3,12 +3,23 @@ import SearchBar from '../components/SearchBar';
import ListingCard from '../components/ListingCard';
import { publicService } from '../services/publicService';
import type { Listing, Category } from '../types';
import { Filter, X } from 'lucide-react';
import { processCategoriesForSelect, type FlatCategory } from '../utils/categoryTreeUtils';
export default function HomePage() {
const [listings, setListings] = useState<Listing[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
// Usamos FlatCategory para el renderizado
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
const [rawCategories, setRawCategories] = useState<Category[]>([]); // Guardamos raw para los botones del home
const [loading, setLoading] = useState(true);
// Estado de Búsqueda
const [searchText, setSearchText] = useState('');
const [selectedCatId, setSelectedCatId] = useState<number | null>(null);
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string>>({});
useEffect(() => {
loadInitialData();
}, []);
@@ -21,72 +32,136 @@ export default function HomePage() {
publicService.getCategories()
]);
setListings(latestListings);
setCategories(cats);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
setRawCategories(cats);
// Procesamos el árbol para el select
const processed = processCategoriesForSelect(cats);
setFlatCategories(processed);
} catch (e) { console.error(e); }
finally { setLoading(false); }
};
const handleSearch = async (query: string) => {
const performSearch = async () => {
setLoading(true);
try {
const results = await publicService.searchListings(query);
setListings(results);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
const response = await import('../services/api').then(m => m.default.post('/listings/search', {
query: searchText,
categoryId: selectedCatId,
filters: dynamicFilters
}));
setListings(response.data);
} catch (e) { console.error(e); }
finally { setLoading(false); }
};
const mainCategories = categories.filter(c => !c.parentId);
useEffect(() => {
if (searchText || selectedCatId || Object.keys(dynamicFilters).length > 0) {
performSearch();
}
}, [selectedCatId, dynamicFilters]);
const handleSearchText = (q: string) => {
setSearchText(q);
performSearch();
};
const clearFilters = () => {
setDynamicFilters({});
setSelectedCatId(null);
setSearchText('');
publicService.getLatestListings().then(setListings); // Reset list
}
// Para los botones del Home, solo mostramos los Raíz
const rootCategories = rawCategories.filter(c => !c.parentId);
return (
<div className="min-h-screen bg-gray-50 pb-20">
{/* Hero Section */}
<div className="bg-primary-900 text-white py-20 px-4 relative overflow-hidden">
{/* Hero */}
<div className="bg-primary-900 text-white py-16 px-4 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary-900 to-gray-900 opacity-90"></div>
<div className="max-w-4xl mx-auto text-center relative z-10">
<h1 className="text-4xl md:text-5xl font-bold mb-6">Encuentra tu próximo objetivo</h1>
<p className="text-xl text-primary-100 mb-10">Clasificados verificados de Autos, Propiedades y más.</p>
<h1 className="text-3xl md:text-5xl font-bold mb-6">Encuentra tu próximo objetivo</h1>
<div className="flex justify-center">
<SearchBar onSearch={handleSearch} />
<SearchBar onSearch={handleSearchText} />
</div>
</div>
</div>
{/* Categories Quick Links */}
<div className="max-w-6xl mx-auto px-4 -mt-8 relative z-20">
<div className="bg-white rounded-xl shadow-lg p-6 flex flex-wrap justify-center gap-4 md:gap-8 border border-gray-100">
{mainCategories.map(cat => (
<button key={cat.id} className="flex flex-col items-center gap-2 group">
<div className="w-12 h-12 rounded-full bg-primary-50 flex items-center justify-center text-primary-600 group-hover:bg-primary-600 group-hover:text-white transition">
<div className="font-bold text-lg">{cat.name.charAt(0)}</div>
<div className="max-w-7xl mx-auto px-4 mt-8 flex flex-col lg:flex-row gap-8">
{/* SIDEBAR DE FILTROS */}
<div className="w-full lg:w-64 flex-shrink-0 space-y-6">
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-100">
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-gray-800 flex items-center gap-2">
<Filter size={18} /> Filtros
</h3>
{(selectedCatId || Object.keys(dynamicFilters).length > 0) && (
<button onClick={clearFilters} className="text-xs text-red-500 hover:underline flex items-center">
<X size={12} /> Limpiar
</button>
)}
</div>
{/* Filtro Categoría (MEJORADO) */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-600 mb-2">Categoría</label>
<select
className="w-full border border-gray-300 rounded p-2 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
value={selectedCatId || ''}
onChange={(e) => setSelectedCatId(Number(e.target.value) || null)}
>
<option value="">Todas las categorías</option>
{flatCategories.map(cat => (
<option
key={cat.id}
value={cat.id}
className={cat.level === 0 ? "font-bold text-gray-900" : "text-gray-600"}
>
{cat.label}
</option>
))}
</select>
</div>
{/* Filtros Dinámicos */}
{selectedCatId && (
<div className="space-y-3 pt-3 border-t">
<p className="text-xs font-bold text-gray-400 uppercase">Atributos</p>
<div>
<input
type="text"
placeholder="Filtrar por Kilometraje..."
className="w-full border p-2 rounded text-sm"
onChange={(e) => setDynamicFilters({ ...dynamicFilters, 'Kilometraje': e.target.value })}
/>
</div>
</div>
<span className="text-sm font-medium text-gray-600 group-hover:text-primary-700">{cat.name}</span>
</button>
))}
</div>
</div>
{/* Latest Listings */}
<div className="max-w-6xl mx-auto px-4 mt-16">
<h2 className="text-2xl font-bold text-gray-900 mb-8">
{loading ? 'Cargando...' : 'Resultados Recientes'}
</h2>
{!loading && listings.length === 0 && (
<div className="text-center text-gray-500 py-10">
No se encontraron avisos con esos criterios.
)}
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{listings.map(listing => (
<ListingCard key={listing.id} listing={listing} />
))}
{/* LISTADO */}
<div className="flex-1">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
{loading ? 'Cargando...' : selectedCatId
? `${listings.length} Resultados en ${flatCategories.find(c => c.id === selectedCatId)?.name}`
: 'Resultados Recientes'}
</h2>
{!loading && listings.length === 0 && (
<div className="text-center text-gray-500 py-20 bg-white rounded-lg border border-dashed border-gray-300">
No se encontraron avisos con esos criterios.
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{listings.map(listing => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,71 @@
export interface Category {
id: number;
name: string;
parentId?: number | null;
}
export interface FlatCategory {
id: number;
name: string;
level: number;
parentId: number | null;
label: string; // Para mostrar en el select con indentación
}
interface CategoryNode extends Category {
children: CategoryNode[];
}
/**
* Construye el árbol y luego lo aplana ordenadamente con niveles
*/
export const processCategoriesForSelect = (rawCategories: Category[]): FlatCategory[] => {
const map = new Map<number, CategoryNode>();
const roots: CategoryNode[] = [];
// 1. Map
rawCategories.forEach(cat => {
// @ts-ignore
map.set(cat.id, { ...cat, children: [] });
});
// 2. Tree
rawCategories.forEach(cat => {
const node = map.get(cat.id);
if (node) {
if (cat.parentId && map.has(cat.parentId)) {
map.get(cat.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
});
// 3. Flatten Recursive
const flatten = (nodes: CategoryNode[], level = 0): FlatCategory[] => {
let result: FlatCategory[] = [];
// Ordenar alfabéticamente
const sorted = nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const node of sorted) {
// Creamos el label visual con espacios
// Usamos caracteres unicode especiales para que se note la jerarquía
const prefix = level === 0 ? '' : '\u00A0\u00A0'.repeat(level) + '↳ ';
result.push({
id: node.id,
name: node.name,
parentId: node.parentId || null,
level: level,
label: prefix + node.name
});
if (node.children.length > 0) {
result = [...result, ...flatten(node.children, level + 1)];
}
}
return result;
};
return flatten(roots);
};

View File

@@ -3,11 +3,13 @@ import { useWizardStore } from '../../store/wizardStore';
import { wizardService } from '../../services/wizardService';
import { StepWrapper } from '../../components/StepWrapper';
import type { AttributeDefinition } from '../../types';
import { CreditCard, Wallet } from 'lucide-react';
export default function SummaryStep({ definitions }: { definitions: AttributeDefinition[] }) {
const { selectedCategory, selectedOperation, attributes, setStep } = useWizardStore();
const { selectedCategory, selectedOperation, attributes, photos, setStep } = useWizardStore();
const [isSubmitting, setIsSubmitting] = useState(false);
const [createdId, setCreatedId] = useState<number | null>(null);
const [paymentMethod, setPaymentMethod] = useState<'mercadopago' | 'stripe'>('mercadopago');
const handlePublish = async () => {
if (!selectedCategory || !selectedOperation) return;
@@ -15,37 +17,36 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
setIsSubmitting(true);
try {
const attributePayload: Record<number, string> = {};
// Ideally we should have stored definitions in store or passed them.
// For now assuming 'definitions' prop contains current category definitions
// For now assuming 'definitions' prop contains current category definitions
definitions.forEach(def => {
if (attributes[def.name]) {
attributePayload[def.id] = attributes[def.name].toString();
}
});
// Crear aviso con estado PENDING (Esperando pago)
const payload = {
categoryId: selectedCategory.id,
operationId: selectedOperation.id,
title: attributes['title'],
description: 'Generated via Wizard', // Todo: Add description field
description: 'Generated via Wizard',
price: parseFloat(attributes['price']),
currency: 'ARS',
status: 'Pending', // <-- Importante para moderación
attributes: attributePayload
};
const result = await wizardService.createListing(payload);
// Upload Images
const { photos } = useWizardStore.getState();
if (photos.length > 0) {
for (const photo of photos) {
await wizardService.uploadImage(result.id, photo);
}
}
// Simulación de Pago
await new Promise(resolve => setTimeout(resolve, 2000)); // Fake network delay
setCreatedId(result.id);
} catch (error) {
console.error(error);
@@ -58,11 +59,16 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
if (createdId) {
return (
<StepWrapper>
<div className="text-center py-10">
<div className="text-4xl text-green-500 mb-4"></div>
<h2 className="text-2xl font-bold mb-2">¡Aviso Publicado!</h2>
<p className="text-gray-500">ID de referencia: #{createdId}</p>
<button onClick={() => window.location.reload()} className="mt-8 text-brand-600 underline">Publicar otro</button>
<div className="text-center py-10 bg-white rounded-xl shadow p-8 border border-green-100">
<div className="text-6xl text-green-500 mb-4 animate-bounce"></div>
<h2 className="text-3xl font-bold mb-2 text-gray-800">¡Pago Exitoso!</h2>
<p className="text-gray-500 text-lg">Tu aviso #{createdId} ha sido enviado a moderación.</p>
<div className="mt-8 p-4 bg-gray-50 rounded text-sm text-gray-600">
Comprobante de pago: {paymentMethod === 'mercadopago' ? 'MP-123456789' : 'ST-987654321'}
</div>
<button onClick={() => window.location.reload()} className="mt-8 bg-brand-600 text-white px-6 py-3 rounded-lg font-bold hover:bg-brand-700 transition">
Publicar otro aviso
</button>
</div>
</StepWrapper>
);
@@ -70,48 +76,62 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
return (
<StepWrapper>
<h2 className="text-2xl font-bold mb-6 text-brand-900">Resumen y Confirmación</h2>
<h2 className="text-2xl font-bold mb-6 text-brand-900">Resumen y Pago</h2>
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm mb-6">
<div className="mb-4">
<span className="block text-xs text-slate-500 uppercase tracking-wide">Categoría</span>
<span className="font-semibold text-lg">{selectedCategory?.name}</span>
</div>
<div className="mb-4">
<span className="block text-xs text-slate-500 uppercase tracking-wide">Operación</span>
<span className="font-semibold text-lg">{selectedOperation?.name}</span>
</div>
<div className="border-t pt-4 mt-4">
<h3 className="font-bold mb-2">{attributes['title']}</h3>
<div className="text-2xl font-bold text-green-600 mb-4">$ {attributes['price']}</div>
<div className="grid grid-cols-2 gap-2 text-sm">
{definitions.map(def => attributes[def.name] && (
<div key={def.id}>
<span className="text-slate-500">{def.name}:</span> <span className="font-medium">{attributes[def.name]}</span>
</div>
))}
<div className="flex justify-between items-center mb-4 border-b pb-4">
<div>
<span className="block text-xs text-slate-500 uppercase tracking-wide">Categoría</span>
<span className="font-semibold text-lg">{selectedCategory?.name}</span>
</div>
<div className="text-right">
<span className="block text-xs text-slate-500 uppercase tracking-wide">Operación</span>
<span className="font-semibold text-lg">{selectedOperation?.name}</span>
</div>
</div>
<h3 className="font-bold mb-2 text-xl">{attributes['title']}</h3>
<div className="text-3xl font-bold text-green-600 mb-6">$ {attributes['price']}</div>
<div className="grid grid-cols-2 gap-3 text-sm bg-gray-50 p-4 rounded-lg">
{definitions.map(def => attributes[def.name] && (
<div key={def.id}>
<span className="text-slate-500 font-medium">{def.name}:</span> <span className="text-slate-800">{attributes[def.name]}</span>
</div>
))}
</div>
</div>
{/* Selector de Pago */}
<div className="mb-8">
<h3 className="font-bold text-gray-800 mb-3 text-lg">Selecciona Método de Pago</h3>
<div className="grid grid-cols-2 gap-4">
<label className={`flex items-center gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${paymentMethod === 'mercadopago' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white hover:border-blue-200'}`}>
<input type="radio" name="payment" value="mercadopago" checked={paymentMethod === 'mercadopago'} onChange={() => setPaymentMethod('mercadopago')} className="hidden" />
<div className="bg-blue-100 p-2 rounded-full text-blue-600"><Wallet size={24} /></div>
<div>
<div className="font-bold text-gray-800">Mercado Pago</div>
<div className="text-xs text-gray-500">QR, Débito, Crédito</div>
</div>
</label>
<label className={`flex items-center gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${paymentMethod === 'stripe' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 bg-white hover:border-indigo-200'}`}>
<input type="radio" name="payment" value="stripe" checked={paymentMethod === 'stripe'} onChange={() => setPaymentMethod('stripe')} className="hidden" />
<div className="bg-indigo-100 p-2 rounded-full text-indigo-600"><CreditCard size={24} /></div>
<div>
<div className="font-bold text-gray-800">Tarjeta Crédito</div>
<div className="text-xs text-gray-500">Procesado por Stripe</div>
</div>
</label>
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => setStep(3)}
className="flex-1 py-3 text-slate-600 hover:bg-slate-100 rounded-lg"
disabled={isSubmitting}
>
Volver
</button>
<button
onClick={handlePublish}
disabled={isSubmitting}
className="flex-1 py-3 bg-brand-600 text-white font-bold rounded-lg hover:bg-brand-700 disabled:opacity-50"
>
{isSubmitting ? 'Publicando...' : 'Confirmar y Publicar'}
<button onClick={() => setStep(4)} className="px-6 py-3 text-slate-600 hover:bg-slate-100 rounded-lg font-medium" disabled={isSubmitting}>Volver</button>
<button onClick={handlePublish} disabled={isSubmitting} className="flex-1 py-3 bg-brand-600 text-white font-bold rounded-xl hover:bg-brand-700 disabled:opacity-70 disabled:cursor-not-allowed shadow-lg shadow-brand-200 transition-all text-lg">
{isSubmitting ? 'Procesando pago...' : 'Pagar y Publicar'}
</button>
</div>
</StepWrapper>
);
}
}

View File

@@ -9,10 +9,12 @@ namespace SIGCM.API.Controllers;
public class CategoriesController : ControllerBase
{
private readonly ICategoryRepository _repository;
private readonly IListingRepository _listingRepo;
public CategoriesController(ICategoryRepository repository)
public CategoriesController(ICategoryRepository repository, IListingRepository listingRepo)
{
_repository = repository;
_listingRepo = listingRepo;
}
[HttpGet]
@@ -33,6 +35,14 @@ public class CategoriesController : ControllerBase
[HttpPost]
public async Task<IActionResult> Create(Category category)
{
// Regla: No crear hijos en padres con avisos
if (category.ParentId.HasValue)
{
int adsCount = await _listingRepo.CountByCategoryIdAsync(category.ParentId.Value);
if (adsCount > 0)
return BadRequest($"El rubro padre contiene {adsCount} avisos. Muévalos antes de crear sub-rubros.");
}
var id = await _repository.AddAsync(category);
category.Id = id;
return CreatedAtAction(nameof(GetById), new { id }, category);
@@ -42,6 +52,15 @@ public class CategoriesController : ControllerBase
public async Task<IActionResult> Update(int id, Category category)
{
if (id != category.Id) return BadRequest();
// Regla: Drag & Drop (Mover categoría dentro de otra)
if (category.ParentId.HasValue)
{
int adsCount = await _listingRepo.CountByCategoryIdAsync(category.ParentId.Value);
if (adsCount > 0)
return BadRequest($"El destino contiene {adsCount} avisos. No puede aceptar sub-rubros.");
}
await _repository.UpdateAsync(category);
return NoContent();
}
@@ -53,6 +72,7 @@ public class CategoriesController : ControllerBase
return NoContent();
}
// --- Endpoints de Operaciones ---
[HttpGet("{id}/operations")]
public async Task<IActionResult> GetOperations(int id)
{
@@ -73,5 +93,32 @@ public class CategoriesController : ControllerBase
await _repository.RemoveOperationAsync(id, operationId);
return NoContent();
}
}
// --- Endpoints Avanzados ---
[HttpPost("merge")]
public async Task<IActionResult> Merge([FromBody] MergeRequest request)
{
if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales.");
await _repository.MergeCategoriesAsync(request.SourceId, request.TargetId);
return Ok(new { message = "Fusión completada." });
}
[HttpPost("move-content")]
public async Task<IActionResult> MoveContent([FromBody] MergeRequest request)
{
if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales.");
// Regla: No mover avisos a una categoría que tiene hijos (Padre)
bool targetHasChildren = await _repository.HasChildrenAsync(request.TargetId);
if (targetHasChildren)
{
return BadRequest("El destino tiene sub-rubros. No puede contener avisos directos.");
}
await _listingRepo.MoveListingsAsync(request.SourceId, request.TargetId);
return Ok(new { message = "Avisos movidos correctamente." });
}
public class MergeRequest { public int SourceId { get; set; } public int TargetId { get; set; } }
}

View File

@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ClientsController : ControllerBase
{
private readonly ClientRepository _repo;
public ClientsController(ClientRepository repo)
{
_repo = repo;
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] string q)
{
if (string.IsNullOrWhiteSpace(q)) return Ok(new List<object>());
var clients = await _repo.SearchAsync(q);
return Ok(clients);
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var clients = await _repo.GetAllWithStatsAsync();
return Ok(clients);
}
[HttpGet("{id}/history")]
public async Task<IActionResult> GetHistory(int id)
{
var history = await _repo.GetClientHistoryAsync(id);
return Ok(history);
}
}

View File

@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
@@ -10,15 +12,28 @@ namespace SIGCM.API.Controllers;
public class ListingsController : ControllerBase
{
private readonly IListingRepository _repository;
public ListingsController(IListingRepository repository)
private readonly ClientRepository _clientRepo;
private readonly AuditRepository _auditRepo;
public ListingsController(IListingRepository repository, ClientRepository clientRepo, AuditRepository auditRepo)
{
_repository = repository;
_clientRepo = clientRepo;
_auditRepo = auditRepo;
}
[HttpPost]
public async Task<IActionResult> Create(CreateListingDto dto)
{
int? clientId = null;
// Si viene información de cliente, aseguramos que exista en BD
if (!string.IsNullOrWhiteSpace(dto.ClientDni))
{
// Usamos "Consumidor Final" si no hay nombre pero hay DNI, o el nombre provisto
string clientName = string.IsNullOrWhiteSpace(dto.ClientName) ? "Consumidor Final" : dto.ClientName;
clientId = await _clientRepo.EnsureClientExistsAsync(clientName, dto.ClientDni);
}
var listing = new Listing
{
CategoryId = dto.CategoryId,
@@ -60,4 +75,63 @@ public class ListingsController : ControllerBase
if (listingDetail == null) return NotFound();
return Ok(listingDetail);
}
// Búsqueda Facetada (POST para enviar diccionario complejo)
[HttpPost("search")]
public async Task<IActionResult> Search([FromBody] SearchRequest request)
{
var results = await _repository.SearchFacetedAsync(request.Query, request.CategoryId, request.Filters);
return Ok(results);
}
// Moderación: Obtener pendientes
[HttpGet("pending")]
[Authorize(Roles = "Admin,Moderador")]
public async Task<IActionResult> GetPending()
{
var pending = await _repository.GetPendingModerationAsync();
return Ok(pending);
}
// Moderación: Cambiar estado
[HttpPut("{id}/status")]
[Authorize(Roles = "Admin,Moderador")]
public async Task<IActionResult> UpdateStatus(int id, [FromBody] string status)
{
// 1. Obtener ID del usuario desde el Token JWT
var userIdStr = User.FindFirst("Id")?.Value;
int? currentUserId = !string.IsNullOrEmpty(userIdStr) ? int.Parse(userIdStr) : null;
// 2. Actualizar el estado del aviso
await _repository.UpdateStatusAsync(id, status);
// 3. Registrar en Auditoría (Si tenemos el repositorio inyectado)
if (currentUserId.HasValue)
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = currentUserId.Value,
Action = status == "Published" ? "Aprobar" : "Rechazar",
EntityId = id,
EntityType = "Listing",
Details = $"El usuario cambió el estado del aviso #{id} a {status}"
});
}
return Ok();
}
public class SearchRequest
{
public string? Query { get; set; }
public int? CategoryId { get; set; }
public Dictionary<string, string>? Filters { get; set; }
}
[HttpGet("pending/count")]
public async Task<IActionResult> GetPendingCount()
{
var count = await _repository.GetPendingCountAsync();
return Ok(count);
}
}

View File

@@ -0,0 +1,122 @@
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
using SIGCM.Infrastructure.Services;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Cajero,Admin")]
public class ReportsController : ControllerBase
{
private readonly IListingRepository _listingRepo;
private readonly AuditRepository _auditRepo;
public ReportsController(IListingRepository listingRepo, AuditRepository auditRepo)
{
_listingRepo = listingRepo;
_auditRepo = auditRepo;
}
[HttpGet("dashboard")]
public async Task<IActionResult> GetDashboard([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var start = from ?? DateTime.UtcNow.Date;
var end = to ?? DateTime.UtcNow.Date;
var stats = await _listingRepo.GetDashboardStatsAsync(start, end);
return Ok(stats);
}
[HttpGet("sales-by-category")]
public async Task<IActionResult> GetSalesByCategory([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var start = from ?? DateTime.UtcNow.AddMonths(-1);
var end = to ?? DateTime.UtcNow;
var data = await _listingRepo.GetSalesByRootCategoryAsync(start, end);
var totalAmount = data.Sum(x => x.TotalSales);
if (totalAmount > 0)
{
foreach (var item in data)
item.Percentage = Math.Round((item.TotalSales / totalAmount) * 100, 2);
}
return Ok(data);
}
[HttpGet("audit")]
public async Task<IActionResult> GetAuditLogs()
{
// Obtenemos los últimos 100 eventos
var logs = await _auditRepo.GetRecentLogsAsync(100);
return Ok(logs);
}
[HttpGet("cashier")]
[Authorize(Roles = "Cajero,Admin")]
public async Task<IActionResult> GetCashierDashboard([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdClaim)) return Unauthorized();
int userId = int.Parse(userIdClaim);
// Si no vienen fechas, usamos hoy por defecto
var start = from ?? DateTime.UtcNow.Date;
var end = to ?? DateTime.UtcNow.Date;
var stats = await _listingRepo.GetCashierStatsAsync(userId, start, end);
return Ok(stats);
}
[HttpGet("export-cierre")]
[Authorize(Roles = "Admin,Cajero")]
public async Task<IActionResult> ExportCierre([FromQuery] DateTime from, [FromQuery] DateTime to, [FromQuery] int? userId) // <--- Agregamos userId opcional
{
var userIdClaim = User.FindFirst("Id")?.Value;
var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
// SEGURIDAD:
// Si es Cajero, ignoramos lo que envíe y forzamos su propio ID.
// Si es Admin, usamos el userId que venga en la URL (si viene).
int? targetUserId = (userRole == "Cajero") ? int.Parse(userIdClaim!) : userId;
// 1. Obtener datos filtrados
var data = await _listingRepo.GetDetailedReportAsync(from, to, targetUserId);
// 2. Título Dinámico: Si hay un filtro de usuario, no es un cierre global.
string title = (targetUserId.HasValue)
? $"REPORTE DE ACTIVIDAD: {data.Items.FirstOrDefault()?.Cashier ?? "Usuario Sin Cargas"}"
: "CIERRE GLOBAL DE JORNADA";
// 3. Generar PDF
var pdfBytes = ReportGenerator.GenerateSalesPdf(data, title);
string fileName = targetUserId.HasValue ? $"Actividad_Caja_{targetUserId}" : "Cierre_Global";
return File(pdfBytes, "application/pdf", $"{fileName}_{from:yyyyMMdd}.pdf");
}
[HttpGet("audit/user/{userId}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetAuditLogsByUser(int userId)
{
var logs = await _auditRepo.GetLogsByUserAsync(userId);
return Ok(logs);
}
[HttpGet("cashier-transactions")]
[Authorize(Roles = "Cajero,Admin")]
public async Task<IActionResult> GetCashierTransactions()
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdClaim)) return Unauthorized();
int userId = int.Parse(userIdClaim);
// Usamos el repositorio para traer los avisos de hoy de este usuario
var transactions = await _listingRepo.GetDetailedReportAsync(DateTime.UtcNow, DateTime.UtcNow, userId);
return Ok(transactions);
}
}

View File

@@ -3,9 +3,12 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using SIGCM.Infrastructure;
using SIGCM.Infrastructure.Data;
using QuestPDF.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
QuestPDF.Settings.License = LicenseType.Community;
// 1. Agregar servicios al contenedor.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="QuestPDF" Version="2025.12.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
</ItemGroup>

View File

@@ -14,6 +14,8 @@ public class CreateListingDto
public string? PrintFontSize { get; set; }
public string? PrintAlignment { get; set; }
public int PrintDaysCount { get; set; }
public string? ClientName { get; set; }
public string? ClientDni { get; set; }
}
public class ListingDto : CreateListingDto

View File

@@ -0,0 +1,14 @@
namespace SIGCM.Domain.Entities;
public class AuditLog
{
public int Id { get; set; }
public int UserId { get; set; }
public required string Action { get; set; }
public int? EntityId { get; set; }
public string? EntityType { get; set; }
public string? Details { get; set; }
public DateTime CreatedAt { get; set; }
// Propiedad auxiliar para el Join
public string? Username { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM.Domain.Entities;
public class Client
{
public int Id { get; set; }
public required string Name { get; set; }
public required string DniOrCuit { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
}

View File

@@ -8,19 +8,23 @@ public class Listing
public required string Title { get; set; }
public string? Description { get; set; }
public decimal Price { get; set; }
public decimal AdFee { get; set; }
public string Currency { get; set; } = "ARS";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string Status { get; set; } = "Draft";
public int? UserId { get; set; }
public int? ClientId { get; set; }
// Propiedades para impresión
public string? PrintText { get; set; }
public DateTime? PrintStartDate { get; set; }
public int PrintDaysCount { get; set; }
public bool IsBold { get; set; }
public bool IsFrame { get; set; }
public string PrintFontSize { get; set; } = "normal";
public string PrintAlignment { get; set; } = "left";
// Propiedades auxiliares (no están en la tabla Listings, vienen de Joins/Subqueries)
// Propiedades auxiliares
public string? CategoryName { get; set; }
public string? MainImageUrl { get; set; }
}

View File

@@ -1,6 +1,7 @@
namespace SIGCM.Domain.Interfaces;
using SIGCM.Domain.Entities;
namespace SIGCM.Domain.Interfaces;
public interface ICategoryRepository
{
Task<IEnumerable<Category>> GetAllAsync();
@@ -12,4 +13,6 @@ public interface ICategoryRepository
Task<IEnumerable<Operation>> GetOperationsAsync(int categoryId);
Task AddOperationAsync(int categoryId, int operationId);
Task RemoveOperationAsync(int categoryId, int operationId);
}
Task MergeCategoriesAsync(int sourceId, int targetId);
Task<bool> HasChildrenAsync(int categoryId);
}

View File

@@ -1,5 +1,6 @@
using SIGCM.Domain.Models;
using SIGCM.Domain.Entities;
using SIGCM.Application.DTOs;
namespace SIGCM.Domain.Interfaces;
@@ -9,6 +10,24 @@ public interface IListingRepository
Task<Listing?> GetByIdAsync(int id);
Task<ListingDetail?> GetDetailByIdAsync(int id);
Task<IEnumerable<Listing>> GetAllAsync();
Task<int> CountByCategoryIdAsync(int categoryId);
Task MoveListingsAsync(int sourceCategoryId, int targetCategoryId);
// Búsqueda Simple y Facetada
Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId);
Task<IEnumerable<Listing>> SearchFacetedAsync(string? query, int? categoryId, Dictionary<string, string>? attributes);
// Impresión
Task<IEnumerable<Listing>> GetListingsForPrintAsync(DateTime date);
// Moderación
Task<IEnumerable<Listing>> GetPendingModerationAsync();
Task UpdateStatusAsync(int id, string status);
Task<int> GetPendingCountAsync();
// Estadísticas
Task<IEnumerable<CategorySalesReportDto>> GetSalesByRootCategoryAsync(DateTime startDate, DateTime endDate);
Task<DashboardStats> GetDashboardStatsAsync(DateTime startDate, DateTime endDate);
Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate);
Task<GlobalReportDto> GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null);
}

View File

@@ -0,0 +1,8 @@
namespace SIGCM.Domain.Models;
public class CashierDashboardDto
{
public decimal MyRevenue { get; set; }
public int MyAdsCount { get; set; }
public int MyPendingAds { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM.Application.DTOs;
public class CategorySalesReportDto
{
public int CategoryId { get; set; }
public required string CategoryName { get; set; }
public decimal TotalSales { get; set; }
public int AdCount { get; set; }
public decimal Percentage { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace SIGCM.Domain.Models;
public class DashboardStats
{
public decimal RevenueToday { get; set; }
public int AdsToday { get; set; }
public decimal TicketAverage { get; set; }
public double PaperOccupation { get; set; }
public List<DailyRevenue> WeeklyTrend { get; set; } = new();
public List<ChannelStat> ChannelMix { get; set; } = new();
}
public class DailyRevenue
{
public string Day { get; set; } = "";
public decimal Amount { get; set; }
}
public class ChannelStat
{
public string Name { get; set; } = "";
public int Value { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace SIGCM.Domain.Models;
public class GlobalReportDto
{
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
public DateTime FromDate { get; set; }
public DateTime ToDate { get; set; }
public decimal TotalRevenue { get; set; }
public int TotalAds { get; set; }
public List<ReportItemDto> Items { get; set; } = new();
}
public class ReportItemDto
{
public int Id { get; set; }
public DateTime Date { get; set; }
public string Title { get; set; } = "";
public string Category { get; set; } = "";
public string Cashier { get; set; } = "";
public decimal Amount { get; set; }
}

View File

@@ -23,6 +23,8 @@ public static class DependencyInjection
services.AddScoped<IImageRepository, ImageRepository>();
services.AddScoped<PricingRepository>();
services.AddScoped<PricingService>();
services.AddScoped<ClientRepository>();
services.AddScoped<AuditRepository>();
return services;
}
}

View File

@@ -0,0 +1,40 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class AuditRepository
{
private readonly IDbConnectionFactory _db;
public AuditRepository(IDbConnectionFactory db) => _db = db;
public async Task AddLogAsync(AuditLog log)
{
using var conn = _db.CreateConnection();
var sql = @"INSERT INTO AuditLogs (UserId, Action, EntityId, EntityType, Details)
VALUES (@UserId, @Action, @EntityId, @EntityType, @Details)";
await conn.ExecuteAsync(sql, log);
}
public async Task<IEnumerable<AuditLog>> GetRecentLogsAsync(int limit = 50)
{
using var conn = _db.CreateConnection();
var sql = @"SELECT TOP (@Limit) a.*, u.Username
FROM AuditLogs a
JOIN Users u ON a.UserId = u.Id
ORDER BY a.CreatedAt DESC";
return await conn.QueryAsync<AuditLog>(sql, new { Limit = limit });
}
public async Task<IEnumerable<AuditLog>> GetLogsByUserAsync(int userId, int limit = 20)
{
using var conn = _db.CreateConnection();
var sql = @"SELECT TOP (@Limit) a.*, u.Username
FROM AuditLogs a
JOIN Users u ON a.UserId = u.Id
WHERE a.UserId = @UserId
ORDER BY a.CreatedAt DESC";
return await conn.QueryAsync<AuditLog>(sql, new { UserId = userId, Limit = limit });
}
}

View File

@@ -74,11 +74,11 @@ public class CategoryRepository : ICategoryRepository
{
using var conn = _connectionFactory.CreateConnection();
var sql = "INSERT INTO CategoryOperations (CategoryId, OperationId) VALUES (@CategoryId, @OperationId)";
try
try
{
await conn.ExecuteAsync(sql, new { CategoryId = categoryId, OperationId = operationId });
}
catch (SqlException)
catch (SqlException)
{
// Ignore duplicate key errors if it already exists
}
@@ -88,8 +88,59 @@ public class CategoryRepository : ICategoryRepository
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync(
"DELETE FROM CategoryOperations WHERE CategoryId = @CategoryId AND OperationId = @OperationId",
"DELETE FROM CategoryOperations WHERE CategoryId = @CategoryId AND OperationId = @OperationId",
new { CategoryId = categoryId, OperationId = operationId });
}
public async Task MergeCategoriesAsync(int sourceId, int targetId)
{
using var conn = _connectionFactory.CreateConnection();
conn.Open();
using var transaction = conn.BeginTransaction();
try
{
// 1. Mover Avisos
await conn.ExecuteAsync(
"UPDATE Listings SET CategoryId = @TargetId WHERE CategoryId = @SourceId",
new { SourceId = sourceId, TargetId = targetId }, transaction);
// 2. Mover Subcategorías (Hijos)
await conn.ExecuteAsync(
"UPDATE Categories SET ParentId = @TargetId WHERE ParentId = @SourceId",
new { SourceId = sourceId, TargetId = targetId }, transaction);
// 3. Mover Definiciones de Atributos
await conn.ExecuteAsync(
"UPDATE AttributeDefinitions SET CategoryId = @TargetId WHERE CategoryId = @SourceId",
new { SourceId = sourceId, TargetId = targetId }, transaction);
// 4. Mover Operaciones (evitar duplicados con try/catch o lógica compleja, aquí simplificamos moviendo y borrando)
// Borramos las del source de la tabla intermedia
await conn.ExecuteAsync(
"DELETE FROM CategoryOperations WHERE CategoryId = @SourceId",
new { SourceId = sourceId }, transaction);
// 5. Borrar Categoría Fuente
await conn.ExecuteAsync(
"DELETE FROM Categories WHERE Id = @SourceId",
new { SourceId = sourceId }, transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
public async Task<bool> HasChildrenAsync(int categoryId)
{
using var conn = _connectionFactory.CreateConnection();
// SELECT 1 es más rápido que COUNT
var result = await conn.ExecuteScalarAsync<int?>("SELECT TOP 1 1 FROM Categories WHERE ParentId = @Id", new { Id = categoryId });
return result.HasValue;
}
}

View File

@@ -0,0 +1,71 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class ClientRepository
{
private readonly IDbConnectionFactory _db;
public ClientRepository(IDbConnectionFactory db)
{
_db = db;
}
// Búsqueda inteligente por Nombre O DNI
public async Task<IEnumerable<Client>> SearchAsync(string query)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT TOP 10 * FROM Clients
WHERE Name LIKE @Query OR DniOrCuit LIKE @Query
ORDER BY Name";
return await conn.QueryAsync<Client>(sql, new { Query = $"%{query}%" });
}
// Buscar o Crear (Upsert) al guardar el aviso
public async Task<int> EnsureClientExistsAsync(string name, string dni)
{
using var conn = _db.CreateConnection();
// 1. Buscamos por DNI/CUIT (es el identificador único más fiable)
var existingId = await conn.ExecuteScalarAsync<int?>(
"SELECT Id FROM Clients WHERE DniOrCuit = @Dni", new { Dni = dni });
if (existingId.HasValue)
{
// Opcional: Actualizar nombre si cambió
await conn.ExecuteAsync("UPDATE Clients SET Name = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
return existingId.Value;
}
else
{
// Crear nuevo
var sql = @"
INSERT INTO Clients (Name, DniOrCuit) VALUES (@Name, @Dni);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, new { Name = name, Dni = dni });
}
}
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT c.*,
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as TotalAds,
(SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id) as TotalSpent
FROM Clients c
ORDER BY c.Name";
return await conn.QueryAsync(sql);
}
public async Task<IEnumerable<Listing>> GetClientHistoryAsync(int clientId)
{
using var conn = _db.CreateConnection();
return await conn.QueryAsync<Listing>(
"SELECT * FROM Listings WHERE ClientId = @Id ORDER BY CreatedAt DESC",
new { Id = clientId });
}
}

View File

@@ -3,6 +3,7 @@ using Dapper;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Domain.Models;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
@@ -28,12 +29,12 @@ public class ListingRepository : IListingRepository
INSERT INTO Listings (
CategoryId, OperationId, Title, Description, Price, Currency,
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
IsBold, IsFrame, PrintFontSize, PrintAlignment
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee
)
VALUES (
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee
);
SELECT CAST(SCOPE_IDENTITY() as int);";
@@ -67,30 +68,56 @@ public class ListingRepository : IListingRepository
return await conn.QuerySingleOrDefaultAsync<Listing>("SELECT * FROM Listings WHERE Id = @Id", new { Id = id });
}
public async Task<SIGCM.Domain.Models.ListingDetail?> GetDetailByIdAsync(int id)
public async Task<ListingDetail?> GetDetailByIdAsync(int id)
{
using var conn = _connectionFactory.CreateConnection();
// Mejoramos el SQL para asegurar que los nulos se conviertan en 0 (false) desde el motor
var sql = @"
SELECT * FROM Listings WHERE Id = @Id;
SELECT lav.*, ad.Name as AttributeName
FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = @Id;
SELECT
l.Id, l.CategoryId, l.OperationId, l.Title, l.Description, l.Price, l.AdFee,
l.Currency, l.CreatedAt, l.Status, l.UserId, l.PrintText, l.PrintDaysCount,
ISNULL(l.IsBold, 0) as IsBold,
ISNULL(l.IsFrame, 0) as IsFrame,
l.PrintFontSize, l.PrintAlignment, l.ClientId,
c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni
FROM Listings l
LEFT JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Clients cl ON l.ClientId = cl.Id
WHERE l.Id = @Id;
SELECT lav.*, ad.Name as AttributeName
FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = @Id;
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
";
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
";
using var multi = await conn.QueryMultipleAsync(sql, new { Id = id });
var listing = await multi.ReadSingleOrDefaultAsync<Listing>();
var listing = await multi.ReadSingleOrDefaultAsync<dynamic>();
if (listing == null) return null;
var attributes = await multi.ReadAsync<SIGCM.Domain.Models.ListingAttributeValueWithName>();
var attributes = await multi.ReadAsync<ListingAttributeValueWithName>();
var images = await multi.ReadAsync<ListingImage>();
return new SIGCM.Domain.Models.ListingDetail
return new ListingDetail
{
Listing = listing,
Listing = new Listing
{
Id = (int)listing.Id,
Title = listing.Title,
Description = listing.Description,
Price = listing.Price,
AdFee = listing.AdFee,
Status = listing.Status,
CreatedAt = listing.CreatedAt,
PrintText = listing.PrintText,
IsBold = Convert.ToBoolean(listing.IsBold),
IsFrame = Convert.ToBoolean(listing.IsFrame),
PrintDaysCount = listing.PrintDaysCount,
CategoryName = listing.CategoryName
},
Attributes = attributes,
Images = images
};
@@ -99,37 +126,86 @@ public class ListingRepository : IListingRepository
public async Task<IEnumerable<Listing>> GetAllAsync()
{
using var conn = _connectionFactory.CreateConnection();
// Subquery para obtener la imagen principal
var sql = @"
SELECT TOP 20 l.*,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
WHERE l.Status = 'Published'
ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql);
}
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
{
return await SearchFacetedAsync(query, categoryId, null);
}
// Búsqueda Avanzada Facetada
public async Task<IEnumerable<Listing>> SearchFacetedAsync(string? query, int? categoryId, Dictionary<string, string>? attributes)
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
SELECT l.*,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
WHERE 1=1";
var parameters = new DynamicParameters();
string sql;
// Construcción Dinámica de la Query con CTE
if (categoryId.HasValue && categoryId.Value > 0)
{
sql = @"
WITH CategoryTree AS (
SELECT Id FROM Categories WHERE Id = @CategoryId
UNION ALL
SELECT c.Id FROM Categories c
INNER JOIN CategoryTree ct ON c.ParentId = ct.Id
)
SELECT l.*,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
WHERE l.Status = 'Published'
AND l.CategoryId IN (SELECT Id FROM CategoryTree)";
parameters.Add("CategoryId", categoryId);
}
else
{
// Sin filtro de categoría (o todas)
sql = @"
SELECT l.*,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
WHERE l.Status = 'Published'";
}
// Filtro de Texto
if (!string.IsNullOrEmpty(query))
{
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query)";
parameters.Add("Query", $"%{query}%");
}
if (categoryId.HasValue)
// Filtros de Atributos (Igual que antes)
if (attributes != null && attributes.Any())
{
sql += " AND l.CategoryId = @CategoryId";
parameters.Add("CategoryId", categoryId);
int i = 0;
foreach (var attr in attributes)
{
if (string.IsNullOrWhiteSpace(attr.Value)) continue;
string paramName = $"@Val{i}";
string paramKey = $"@Key{i}";
sql += $@" AND EXISTS (
SELECT 1 FROM ListingAttributeValues lav
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
WHERE lav.ListingId = l.Id
AND ad.Name = {paramKey}
AND lav.Value LIKE {paramName}
)";
parameters.Add($"Val{i}", $"%{attr.Value}%");
parameters.Add($"Key{i}", attr.Key);
i++;
}
}
sql += " ORDER BY l.CreatedAt DESC";
@@ -140,8 +216,6 @@ public class ListingRepository : IListingRepository
public async Task<IEnumerable<Listing>> GetListingsForPrintAsync(DateTime targetDate)
{
using var conn = _connectionFactory.CreateConnection();
// La lógica: El aviso debe haber empezado antes o en la fecha target
// Y la fecha target debe ser menor a la fecha de inicio + duración
var sql = @"
SELECT l.*, c.Name as CategoryName
FROM Listings l
@@ -149,8 +223,190 @@ public class ListingRepository : IListingRepository
WHERE l.PrintStartDate IS NOT NULL
AND @TargetDate >= CAST(l.PrintStartDate AS DATE)
AND @TargetDate < DATEADD(day, l.PrintDaysCount, CAST(l.PrintStartDate AS DATE))
ORDER BY c.Name, l.Title"; // Ordenado por Rubro y luego alfabético
ORDER BY c.Name, l.Title";
return await conn.QueryAsync<Listing>(sql, new { TargetDate = targetDate.Date });
}
public async Task<IEnumerable<Listing>> GetPendingModerationAsync()
{
using var conn = _connectionFactory.CreateConnection();
// Avisos que NO están publicados ni rechazados ni borrados.
// Asumimos 'Pending' o 'Draft' si vienen del Wizard y requieren revisión.
// Para este ejemplo, buscamos 'Pending'.
return await conn.QueryAsync<Listing>("SELECT * FROM Listings WHERE Status = 'Pending' ORDER BY CreatedAt ASC");
}
public async Task UpdateStatusAsync(int id, string status)
{
using var conn = _connectionFactory.CreateConnection();
await conn.ExecuteAsync("UPDATE Listings SET Status = @Status WHERE Id = @Id", new { Id = id, Status = status });
}
public async Task<int> CountByCategoryIdAsync(int categoryId)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Listings WHERE CategoryId = @Id", new { Id = categoryId });
}
public async Task MoveListingsAsync(int sourceCategoryId, int targetCategoryId)
{
using var conn = _connectionFactory.CreateConnection();
// Solo movemos los avisos, no tocamos la estructura de categorías
await conn.ExecuteAsync(
"UPDATE Listings SET CategoryId = @TargetId WHERE CategoryId = @SourceId",
new { SourceId = sourceCategoryId, TargetId = targetCategoryId });
}
public async Task<IEnumerable<CategorySalesReportDto>> GetSalesByRootCategoryAsync(DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
// SQL con CTE Recursiva:
// 1. Mapeamos TODA la jerarquía para saber cuál es el ID raíz de cada subcategoría.
// 2. Unimos con Listings y sumamos.
var sql = @"
WITH CategoryHierarchy AS (
-- Caso base: Categorías Raíz (donde ParentId es NULL)
SELECT Id, Id as RootId, Name
FROM Categories
WHERE ParentId IS NULL
UNION ALL
-- Caso recursivo: Hijos que se vinculan a su padre
SELECT c.Id, ch.RootId, ch.Name
FROM Categories c
INNER JOIN CategoryHierarchy ch ON c.ParentId = ch.Id
)
SELECT
ch.RootId as CategoryId,
ch.Name as CategoryName,
SUM(l.Price) as TotalSales,
COUNT(l.Id) as AdCount
FROM Listings l
INNER JOIN CategoryHierarchy ch ON l.CategoryId = ch.Id
WHERE l.CreatedAt >= @StartDate AND l.CreatedAt <= @EndDate
AND l.Status = 'Published'
GROUP BY ch.RootId, ch.Name
ORDER BY TotalSales DESC";
return await conn.QueryAsync<CategorySalesReportDto>(sql, new { StartDate = startDate, EndDate = endDate });
}
public async Task<int> GetPendingCountAsync()
{
using var conn = _connectionFactory.CreateConnection();
// Contamos solo avisos en estado 'Pending'
return await conn.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Listings WHERE Status = 'Pending'");
}
public async Task<DashboardStats> GetDashboardStatsAsync(DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
var stats = new DashboardStats();
// 1. KPIs del periodo seleccionado
var kpisSql = @"
SELECT
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as RevenueToday,
COUNT(Id) as AdsToday
FROM Listings
WHERE CAST(CreatedAt AS DATE) >= @StartDate
AND CAST(CreatedAt AS DATE) <= @EndDate
AND Status = 'Published'";
var kpis = await conn.QueryFirstOrDefaultAsync(kpisSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
stats.RevenueToday = kpis != null ? (decimal)kpis.RevenueToday : 0;
stats.AdsToday = kpis != null ? (int)kpis.AdsToday : 0;
stats.TicketAverage = stats.AdsToday > 0 ? stats.RevenueToday / stats.AdsToday : 0;
// 2. Ocupación (basada en el último día del rango)
stats.PaperOccupation = Math.Min(100, (stats.AdsToday * 100.0) / 100.0);
// 3. Tendencia del periodo
// Si el rango es mayor a 10 días, agrupamos diferente, pero por ahora mantenemos ddd
var trendSql = @"
SELECT
FORMAT(CreatedAt, 'dd/MM') as Day,
SUM(AdFee) as Amount
FROM Listings
WHERE CAST(CreatedAt AS DATE) >= @StartDate
AND CAST(CreatedAt AS DATE) <= @EndDate
AND Status = 'Published'
GROUP BY FORMAT(CreatedAt, 'dd/MM'), CAST(CreatedAt AS DATE)
ORDER BY CAST(CreatedAt AS DATE) ASC";
var trendResult = await conn.QueryAsync<DailyRevenue>(trendSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
stats.WeeklyTrend = trendResult.ToList();
// 4. Mix de Canales del periodo
var channelsSql = @"
SELECT
CASE WHEN UserId IS NULL THEN 'Web' ELSE 'Mostrador' END as Name,
COUNT(Id) as Value
FROM Listings
WHERE CAST(CreatedAt AS DATE) >= @StartDate
AND CAST(CreatedAt AS DATE) <= @EndDate
AND Status = 'Published'
GROUP BY CASE WHEN UserId IS NULL THEN 'Web' ELSE 'Mostrador' END";
var channelResult = await conn.QueryAsync<ChannelStat>(channelsSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
stats.ChannelMix = channelResult.ToList();
return stats;
}
public async Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate)
{
using var conn = _connectionFactory.CreateConnection();
// Filtramos tanto la recaudación como los pendientes por el rango seleccionado
var sql = @"
SELECT
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as MyRevenue,
COUNT(Id) as MyAdsCount,
(SELECT COUNT(1) FROM Listings
WHERE UserId = @UserId AND Status = 'Pending'
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End) as MyPendingAds
FROM Listings
WHERE UserId = @UserId
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
AND Status = 'Published'";
return await conn.QueryFirstOrDefaultAsync<CashierDashboardDto>(sql,
new { UserId = userId, Start = startDate.Date, End = endDate.Date });
}
public async Task<GlobalReportDto> GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null)
{
using var conn = _connectionFactory.CreateConnection();
var report = new GlobalReportDto { FromDate = start, ToDate = end };
// Filtro inteligente: Si @UserId es NULL, devuelve todo. Si no, filtra por ese usuario.
var sql = @"
SELECT
l.Id, l.CreatedAt as Date, l.Title,
c.Name as Category, u.Username as Cashier, l.AdFee as Amount
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
LEFT JOIN Users u ON l.UserId = u.Id
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
AND l.Status = 'Published'
AND (@UserId IS NULL OR l.UserId = @UserId) -- <--- FILTRO DINÁMICO
ORDER BY l.CreatedAt ASC";
var items = await conn.QueryAsync<ReportItemDto>(sql, new
{
Start = start.Date,
End = end.Date,
UserId = userId // Dapper pasará null si el parámetro es null
});
report.Items = items.ToList();
report.TotalRevenue = report.Items.Sum(x => x.Amount);
report.TotalAds = report.Items.Count;
return report;
}
}

View File

@@ -10,6 +10,7 @@
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="QuestPDF" Version="2025.12.0" />
</ItemGroup>
<PropertyGroup>

View File

@@ -1,4 +1,4 @@
using SIGCM.Application.DTOs; // Asegúrate de crear este DTO (ver abajo)
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Repositories;
using System.Text.RegularExpressions;
@@ -27,14 +27,19 @@ public class PricingService
};
// 2. Análisis del Texto
var words = request.Text.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
int realWordCount = words.Length;
// Contar caracteres especiales configurados en BD (ej: "!")
// Escapamos los caracteres por seguridad en Regex
// A. Contar caracteres especiales PRIMERO (antes de borrarlos)
string escapedSpecialChars = Regex.Escape(pricing.SpecialChars ?? "!");
int specialCharCount = Regex.Matches(request.Text, $"[{escapedSpecialChars}]").Count;
// B. Normalizar el texto para contar palabras
// Reemplazamos los caracteres especiales por espacios para que no cuenten como palabras,
// ni unan palabras (ej: "Hola!Chau" -> "Hola Chau")
string cleanText = Regex.Replace(request.Text, $"[{escapedSpecialChars}]", " ");
// C. Contar palabras reales (ignorando los signos que ahora son espacios)
var words = cleanText.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
int realWordCount = words.Length;
// 3. Costo Base y Excedente
decimal currentCost = pricing.BasePrice; // Precio base incluye N palabras
@@ -99,13 +104,13 @@ public class PricingService
return new CalculatePriceResponse
{
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
BaseCost = pricing.BasePrice,
ExtraCost = extraWordCost + specialCharCost,
Surcharges = (request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0),
BaseCost = pricing.BasePrice * request.Days,
ExtraCost = (extraWordCost + specialCharCost) * request.Days,
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0)) * request.Days,
Discount = totalDiscount,
WordCount = realWordCount,
SpecialCharCount = specialCharCount,
Details = $"Base: ${pricing.BasePrice} | Extras: ${extraWordCost + specialCharCost} | Desc: -${totalDiscount} ({string.Join(", ", appliedPromos)})"
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost})"
};
}
}

View File

@@ -0,0 +1,109 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using SIGCM.Domain.Models;
namespace SIGCM.Infrastructure.Services;
public static class ReportGenerator
{
public static byte[] GenerateSalesPdf(GlobalReportDto data, string title)
{
return Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(1, Unit.Centimetre);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Helvetica"));
// --- ENCABEZADO ---
page.Header().Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("DIARIO EL DIA").FontSize(20).SemiBold().FontColor(Colors.Blue.Medium);
col.Item().Text("Sistema de Gestión de Avisos").FontSize(9).Italic();
});
row.RelativeItem().AlignRight().Column(col =>
{
col.Item().Text(title).FontSize(14).SemiBold();
col.Item().Text($"Periodo: {data.FromDate:dd/MM/yyyy} - {data.ToDate:dd/MM/yyyy}").FontSize(9);
col.Item().Text($"Generado: {DateTime.Now:dd/MM/yyyy HH:mm}").FontSize(8).FontColor(Colors.Grey.Medium);
});
});
// --- CONTENIDO ---
page.Content().PaddingVertical(20).Column(col =>
{
// Resumen de KPIs en el PDF
col.Item().Row(row =>
{
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(c =>
{
c.Item().Text("TOTAL RECAUDADO").FontSize(8).SemiBold();
c.Item().Text($"${data.TotalRevenue:N2}").FontSize(16).Black();
});
row.ConstantItem(10);
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(c =>
{
c.Item().Text("CANTIDAD DE AVISOS").FontSize(8).SemiBold();
c.Item().Text($"{data.TotalAds}").FontSize(16).Black();
});
});
col.Item().PaddingTop(20);
// Tabla de detalles
col.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.ConstantColumn(40); // ID
columns.ConstantColumn(80); // Fecha
columns.RelativeColumn(); // Titulo
columns.RelativeColumn(); // Rubro
columns.RelativeColumn(); // Cajero
columns.ConstantColumn(70); // Monto
});
// Encabezado de tabla
table.Header(header =>
{
header.Cell().Element(CellStyle).Text("ID");
header.Cell().Element(CellStyle).Text("FECHA");
header.Cell().Element(CellStyle).Text("TITULO");
header.Cell().Element(CellStyle).Text("RUBRO");
header.Cell().Element(CellStyle).Text("CAJERO");
header.Cell().Element(CellStyle).AlignRight().Text("MONTO");
static IContainer CellStyle(IContainer container) => container.DefaultTextStyle(x => x.SemiBold()).PaddingVertical(5).BorderBottom(1).BorderColor(Colors.Black);
});
// Filas
foreach (var item in data.Items)
{
table.Cell().Element(RowStyle).Text(item.Id.ToString());
table.Cell().Element(RowStyle).Text(item.Date.ToString("dd/MM HH:mm"));
table.Cell().Element(RowStyle).Text(item.Title);
table.Cell().Element(RowStyle).Text(item.Category);
table.Cell().Element(RowStyle).Text(item.Cashier);
table.Cell().Element(RowStyle).AlignRight().Text($"${item.Amount:N2}");
static IContainer RowStyle(IContainer container) => container.PaddingVertical(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten4);
}
});
});
// --- PIE DE PAGINA ---
page.Footer().AlignCenter().Text(x =>
{
x.Span("Página ");
x.CurrentPageNumber();
});
});
}).GeneratePdf();
}
}