diff --git a/package-lock.json b/package-lock.json index 8d43d98..c53a699 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "1.0.1", "license": "ISC", "dependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.8", "prop-types": "^15.8.1", "react": "^18.2.0", + "react-bootstrap": "^2.10.10", "react-dom": "^18.2.0", "react-router-dom": "^6.18.0" }, @@ -290,6 +293,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", @@ -943,6 +955,31 @@ "node": ">= 8" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@remix-run/router": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.22.0.tgz", @@ -952,6 +989,69 @@ "node": ">=14.0.0" } }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1009,14 +1109,12 @@ "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1033,6 +1131,21 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -1264,6 +1377,25 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1395,6 +1527,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1420,7 +1558,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -1537,6 +1674,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1549,6 +1695,16 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2630,6 +2786,15 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -3452,6 +3617,19 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -3493,6 +3671,37 @@ "node": ">=0.10.0" } }, + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -3511,6 +3720,12 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -3553,6 +3768,22 @@ "react-dom": ">=16.8" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4109,6 +4340,12 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4232,6 +4469,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", @@ -4328,6 +4580,15 @@ } } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4651,6 +4912,11 @@ "@babel/helper-plugin-utils": "^7.25.9" } }, + "@babel/runtime": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==" + }, "@babel/template": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", @@ -4998,11 +5264,72 @@ "fastq": "^1.6.0" } }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" + }, + "@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "requires": { + "@swc/helpers": "^0.5.0" + } + }, "@remix-run/router": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.22.0.tgz", "integrity": "sha512-MBOl8MeOzpK0HQQQshKB7pABXbmyHizdTpqnrIseTbsv0nAepwC2ENZa1aaBExNQcpLoXmWthhak8SABLzvGPw==" }, + "@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "requires": { + "dequal": "^2.0.3" + } + }, + "@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "requires": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "dependencies": { + "@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "requires": { + "dequal": "^2.0.3" + } + }, + "uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "requires": {} + } + } + }, + "@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "requires": { + "tslib": "^2.8.0" + } + }, "@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5055,14 +5382,12 @@ "@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "dev": true + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "@types/react": { "version": "18.3.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", - "dev": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5075,6 +5400,17 @@ "dev": true, "requires": {} }, + "@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "requires": {} + }, + "@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==" + }, "@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -5230,6 +5566,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "requires": {} + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -5304,6 +5646,11 @@ "integrity": "sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==", "dev": true }, + "classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5324,8 +5671,7 @@ "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "data-view-buffer": { "version": "1.0.2", @@ -5397,6 +5743,11 @@ "object-keys": "^1.1.1" } }, + "dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -5406,6 +5757,15 @@ "esutils": "^2.0.2" } }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6164,6 +6524,14 @@ "side-channel": "^1.1.0" } }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6686,6 +7054,15 @@ "react-is": "^16.13.1" } }, + "prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "requires": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + } + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -6706,6 +7083,26 @@ "loose-envify": "^1.1.0" } }, + "react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "requires": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + } + }, "react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -6720,6 +7117,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, "react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -6743,6 +7145,17 @@ "react-router": "6.29.0" } }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7124,6 +7537,11 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7204,6 +7622,17 @@ "which-boxed-primitive": "^1.1.1" } }, + "uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "requires": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + } + }, "update-browserslist-db": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", @@ -7235,6 +7664,14 @@ "rollup": "^3.27.1" } }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 0caab10..50d32d2 100755 --- a/package.json +++ b/package.json @@ -8,10 +8,10 @@ "main": "index.js", "scripts": { "dev": "vite", - "start": "vite", - "build": "vite build", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "start": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" }, "author": { "name": "Alejandro Sanchez", @@ -30,13 +30,13 @@ "license": "ISC", "devDependencies": { "@types/react": "^18.2.18", - "@types/react-dom": "^18.2.7", - "@vitejs/plugin-react": "^4.0.4", - "eslint": "^8.46.0", - "eslint-plugin-react": "^7.33.1", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.3", - "vite": "^4.4.8" + "@types/react-dom": "^18.2.7", + "@vitejs/plugin-react": "^4.0.4", + "eslint": "^8.46.0", + "eslint-plugin-react": "^7.33.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "vite": "^4.4.8" }, "babel": { "presets": [ @@ -54,9 +54,12 @@ ] }, "dependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.8", "prop-types": "^15.8.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.18.0" + "react": "^18.2.0", + "react-bootstrap": "^2.10.10", + "react-dom": "^18.2.0", + "react-router-dom": "^6.18.0" } } diff --git a/src/api/commands.py b/src/api/commands.py index 1980616..463acc9 100644 --- a/src/api/commands.py +++ b/src/api/commands.py @@ -1,34 +1,441 @@ - +# api/commands.py import click -from api.models import db, User +from decimal import Decimal +from datetime import date, datetime +from flask import current_app as app + +from api.models import ( + db, User, Rol, Profile, AccountSettings, + Task, TaskOffered, TaskDealed, Payment, Review, Message +) """ -In this file, you can add as many commands as you want using the @app.cli.command decorator -Flask commands are usefull to run cronjobs or tasks outside of the API but sill in integration -with youy database, for example: Import the price of bitcoin every night as 12am +Comandos CLI de Flask para poblar datos de prueba. + +Uso: + pipenv run flask insert-test-users 5 + pipenv run flask insert-test-data + pipenv run flask simulate-offer-accept --task-id 1 --client-id 1 --tasker-id 2 --amount 60000 + pipenv run flask fix-assigned-tasker --task-id 1 """ + + def setup_commands(app): - - """ - This is an example command "insert-test-users" that you can run from the command line - by typing: $ flask insert-test-users 5 - Note: 5 is the number of users to add - """ - @app.cli.command("insert-test-users") # name of our command - @click.argument("count") # argument of out command + # ----------------------------- + # 1) Crear usuarios mínimos + # ----------------------------- + # $ pipenv run flask insert-test-users 5 + @app.cli.command("insert-test-users") + @click.argument("count") def insert_test_users(count): - print("Creating test users") + """ + Crea N usuarios (email/username/password). + """ + print(f"Creating {count} test users") + created = [] for x in range(1, int(count) + 1): - user = User() - user.email = "test_user" + str(x) + "@test.com" - user.password = "123456" - user.is_active = True - db.session.add(user) - db.session.commit() - print("User: ", user.email, " created.") - + u = User( + email=f"test_user{x}@test.com", + username=f"test_user{x}", + password="123456" + ) + db.session.add(u) + created.append(u) + db.session.commit() + for u in created: + print("User:", u.email, "created.") print("All test users created") + # ----------------------------- + # 2) Semilla end-to-end simple + # ----------------------------- + # $ pipenv run flask insert-test-data @app.cli.command("insert-test-data") def insert_test_data(): - pass \ No newline at end of file + """ + Crea: roles -> usuarios (con rol) -> perfiles/ajustes -> task -> offer -> deal + -> payment -> (1) message -> review + Todo respeta FKs y tipos actuales. + """ + with app.app_context(): + print("→ Insertando datos de prueba...") + + # --- ROLES --- + r_client = Rol(type="client") + r_tasker = Rol(type="tasker") + db.session.add_all([r_client, r_tasker]) + db.session.flush() + + # --- USERS --- + u_client = User(email="poster1@test.com", + username="poster1", password="x") + u_tasker = User(email="tasker1@test.com", + username="tasker1", password="x") + db.session.add_all([u_client, u_tasker]) + db.session.flush() + + # Asignar roles (M2M) + u_client.roles.append(r_client) + u_tasker.roles.append(r_tasker) + + # --- PROFILE / SETTINGS --- + now = datetime.utcnow() + p_client = Profile( + user_id=u_client.id, name="Paula", last_name="Poster", + avatar=None, city="Santiago", birth_date=None, bio="Cliente demo", + skills=None, rating_avg=None, created_at=now, modified_at=now + ) + p_tasker = Profile( + user_id=u_tasker.id, name="Tania", last_name="Tasker", + avatar=None, city="Santiago", birth_date=None, bio="Tasker demo", + skills="pintura,taladro", rating_avg=4.7, created_at=now, modified_at=now + ) + s_client = AccountSettings( + user_id=u_client.id, phone=None, billing_info=None, + language="es", marketing_emails=True, created_at=now, modified_at=now + ) + s_tasker = AccountSettings( + user_id=u_tasker.id, phone=None, billing_info=None, + language="es", marketing_emails=True, created_at=now, modified_at=now + ) + db.session.add_all([p_client, p_tasker, s_client, s_tasker]) + db.session.flush() + + # --- TASK (publicada por el client) --- + t = Task( + title="Pintar dormitorio 3x3", + description="Necesito pintura blanca, cubrir marcos.", + location="Ñuñoa", + price=Decimal("60000.00"), + due_at=None, + posted_at=date.today(), # explícito (aunque tengas server_default) + assigned_at=None, + completed_at=None, + status="open", + publisher_id=u_client.id + ) + db.session.add(t) + db.session.flush() + + # --- OFFER --- + off = TaskOffered( + task_id=t.id, + tasker_id=u_tasker.id, + status="pending", # acorde a tu modelo actual + amount=Decimal("400.00"), + message="¿Te sirve hacerlo hoy?" + ) + db.session.add(off) + db.session.flush() + + # --- DEAL --- + deal = TaskDealed( + task_id=t.id, + offer_id=off.id, + client_id=u_client.id, + tasker_id=u_tasker.id, + fixed_price=Decimal("55000.00"), + status="in_progress", + accepted_at=date.today(), + delivered_at=None, + cancelled_at=None + ) + db.session.add(deal) + db.session.flush() + + # reflejar en Task + t.assigned_at = date.today() + t.status = "assigned" + + # --- PAYMENT --- + pay = Payment( + dealed_id=deal.id, + amount=Decimal("55000.00"), + status="held" # held/paid/refunded, etc. + ) + db.session.add(pay) + db.session.flush() + + # --- MESSAGE --- + existing_msg = Message.query.filter_by(dealer_id=deal.id).first() + if not existing_msg: + msg1 = Message( + body="Hola, mañana a las 10 está bien?", + dealer_id=deal.id, + sender_id=u_client.id + ) + db.session.add(msg1) + db.session.flush() + else: + msg1 = existing_msg + + # --- REVIEW --- + rev = Review( + review="Excelente trabajo, muy puntual.", + rate=Decimal("4.50"), + created_at=datetime.utcnow(), + publisher_id=u_client.id, + worker_id=u_tasker.id, + task_dealed_id=deal.id, # UNIQUE + task_id=t.id + ) + db.session.add(rev) + + db.session.commit() + + print("✓ Roles:", [r_client.type, r_tasker.type]) + print("✓ Users:", u_client.id, u_tasker.id) + print("✓ Task:", t.id) + print("✓ Offer:", off.id) + print("✓ Deal:", deal.id) + print("✓ Payment:", pay.id) + print("✓ Message:", msg1.id) + print("✓ Review:", rev.id) + print("✓ Datos de prueba creados con éxito.") + + # ----------------------------- + # simulate-offer-accept + # ----------------------------- + @app.cli.command("simulate-offer-accept") + @click.option("--task-id", default=1, show_default=True, type=int) + @click.option("--client-id", default=1, show_default=True, type=int) + @click.option("--tasker-id", default=2, show_default=True, type=int) + @click.option("--amount", default=60000, show_default=True, type=float) + def simulate_offer_accept(task_id, client_id, tasker_id, amount): + """ + Simula el flujo de la compañera: + - Task publicada por client (status 'open' o 'pending') + - Tasker hace/actualiza offer + - Client acepta -> crea Deal y task pasa a 'assigned' + Idempotente: reutiliza registros si ya existen. + """ + from decimal import Decimal + from datetime import date + + # 1) validar entidades base + task = Task.query.get(task_id) + if not task: + raise click.ClickException(f"Task {task_id} no existe") + if task.publisher_id != client_id: + raise click.ClickException( + f"La task {task_id} no pertenece al client {client_id}") + + if not User.query.get(client_id): + raise click.ClickException(f"Client {client_id} no existe") + if not User.query.get(tasker_id): + raise click.ClickException(f"Tasker {tasker_id} no existe") + + # 2) asegurar offer (task_id, tasker_id) + offer = TaskOffered.query.filter_by( + task_id=task.id, tasker_id=tasker_id).first() + if not offer: + offer = TaskOffered( + task_id=task.id, + tasker_id=tasker_id, + status="pending", + amount=Decimal(str(amount)), + message="Simulación: puedo mañana" + ) + db.session.add(offer) + db.session.flush() + click.echo( + f"✓ Offer {offer.id} creada (task={task.id}, tasker={tasker_id}, amount={offer.amount})") + else: + offer.amount = Decimal(str(amount)) + if not offer.message: + offer.message = "Simulación: puedo mañana" + click.echo(f"↺ Offer {offer.id} reutilizada") + + # 3) crear/reutilizar deal (1 por task) + deal = TaskDealed.query.filter_by(task_id=task.id).first() + if deal: + click.echo( + f"↺ Deal {deal.id} ya existía para task {task.id}, asegurando consistencia…") + deal.offer_id = offer.id + deal.client_id = client_id + deal.tasker_id = tasker_id + if not deal.fixed_price: + deal.fixed_price = offer.amount + deal.status = "accepted" + if not deal.accepted_at: + deal.accepted_at = date.today() + else: + deal = TaskDealed( + task_id=task.id, + offer_id=offer.id, + client_id=client_id, + tasker_id=tasker_id, + fixed_price=offer.amount, + status="accepted", + accepted_at=date.today() + ) + db.session.add(deal) + db.session.flush() + click.echo(f"✓ Deal {deal.id} creado para task {task.id}") + + # 4) estados coherentes + offer.status = "accepted" + task.status = "assigned" # consistente con tu seed + if not task.assigned_at: + task.assigned_at = date.today() + # CLAVE: dejar el tasker asignado para habilitar chat en el front + task.assigned_tasker_id = tasker_id + + # 5) commit + db.session.commit() + + # 6) salida + click.echo("--- RESUMEN ---") + click.echo( + f"Task: id={task.id} status={task.status} publisher_id={task.publisher_id} assigned_tasker_id={getattr(task, 'assigned_tasker_id', None)}") + click.echo( + f"Offer: id={offer.id} status={offer.status} tasker_id={offer.tasker_id} amount={offer.amount}") + click.echo( + f"Deal: id={deal.id} client_id={deal.client_id} tasker_id={deal.tasker_id} fixed_price={deal.fixed_price}") + click.echo( + f"Fechas: assigned_at={task.assigned_at} accepted_at={deal.accepted_at}") + + # ----------------------------- + # fix-assigned-tasker (backfill) + # ----------------------------- + @app.cli.command("fix-assigned-tasker") + @click.option("--task-id", required=False, type=int, help="Si no se pasa, intenta todas las tasks") + def fix_assigned_tasker(task_id): + """ + Para cada task 'assigned/in_progress/completed' sin assigned_tasker_id, + toma el último deal y copia su tasker_id a la task. + """ + from api.models import TaskDealed, Task + from datetime import date + + qs = Task.query + if task_id: + qs = qs.filter_by(id=task_id) + tasks = qs.all() + + n = 0 + for t in tasks: + if t.status in ("assigned", "in_progress", "completed") and not getattr(t, "assigned_tasker_id", None): + deal = TaskDealed.query.filter_by( + task_id=t.id).order_by(TaskDealed.id.desc()).first() + if deal: + t.assigned_tasker_id = deal.tasker_id + if not t.assigned_at: + t.assigned_at = date.today() + n += 1 + + db.session.commit() + print(f"✓ actualizadas {n} tasks") + + @app.cli.command("delete-review") + @click.option("--task-id", required=True, type=int) + def delete_review(task_id): + """ + Borra la review asociada al ÚLTIMO deal de la task. + Útil para reintentar el POST desde el front (regla 1 review por deal). + """ + from api.models import TaskDealed, Review + deal = TaskDealed.query.filter_by(task_id=task_id).order_by(TaskDealed.id.desc()).first() + if not deal: + raise click.ClickException(f"No hay deal para task {task_id}") + + rev = Review.query.filter_by(task_dealed_id=deal.id).first() + if not rev: + click.echo(f"No hay review para deal {deal.id} (task {task_id})") + return + + db.session.delete(rev) + db.session.commit() + click.echo(f"✓ Review {rev.id} borrada (task {task_id}, deal {deal.id})") + + # ----------------------------- + # delete-offer + # ----------------------------- + @app.cli.command("delete-offer") + @click.option("--task-id", required=True, type=int, help="ID de la tarea") + @click.option("--tasker-id", required=True, type=int, help="ID del tasker") + def delete_offer(task_id, tasker_id): + """ + Borra la oferta de un tasker específico en una task. + Útil para resetear pruebas y permitir que el tasker vuelva a ofertar. + """ + from api.models import TaskOffered + + offer = TaskOffered.query.filter_by(task_id=task_id, tasker_id=tasker_id).first() + if not offer: + click.echo(f"No existe oferta para task {task_id} del tasker {tasker_id}") + return + + db.session.delete(offer) + db.session.commit() + click.echo(f"✓ Oferta {offer.id} borrada (task {task_id}, tasker {tasker_id})") + + # ----------------------------- + # accept-offer + # ----------------------------- + @app.cli.command("accept-offer") + @click.option("--offer-id", required=True, type=int) + @click.option("--publisher-id", required=True, type=int) + @click.option("--tasker-id", required=True, type=int) + def accept_offer(offer_id, publisher_id, tasker_id): + """ + Acepta una oferta existente: + - Cambia offer.status = 'accepted' + - Crea o actualiza deal con status 'accepted' + - Actualiza la task a 'assigned' con assigned_tasker_id + """ + + from api.models import Task, TaskOffered, TaskDealed + from datetime import date + + offer = TaskOffered.query.get(offer_id) + if not offer: + raise click.ClickException(f"No existe la offer {offer_id}") + + task = Task.query.get(offer.task_id) + if not task: + raise click.ClickException(f"No existe la task {offer.task_id}") + + # Validar publisher y tasker + if task.publisher_id != publisher_id: + raise click.ClickException(f"La task {task.id} no pertenece al publisher {publisher_id}") + if offer.tasker_id != tasker_id: + raise click.ClickException(f"La offer {offer.id} no pertenece al tasker {tasker_id}") + + # 1) actualizar la offer + offer.status = "accepted" + + # 2) crear/reutilizar deal + deal = TaskDealed.query.filter_by(task_id=task.id, tasker_id=tasker_id).first() + if not deal: + deal = TaskDealed( + task_id=task.id, + offer_id=offer.id, + client_id=publisher_id, + tasker_id=tasker_id, + fixed_price=offer.amount, + status="accepted", + accepted_at=date.today() + ) + db.session.add(deal) + else: + deal.offer_id = offer.id + deal.client_id = publisher_id + deal.tasker_id = tasker_id + deal.status = "accepted" + if not deal.fixed_price: + deal.fixed_price = offer.amount + if not deal.accepted_at: + deal.accepted_at = date.today() + + # 3) actualizar task + task.status = "assigned" + task.assigned_tasker_id = tasker_id + if not task.assigned_at: + task.assigned_at = date.today() + + db.session.commit() + + click.echo(f"✓ Offer {offer.id} aceptada") + click.echo(f"✓ Deal {deal.id} {'creado' if deal else 'actualizado'}") + click.echo(f"✓ Task {task.id} → status={task.status}, assigned_tasker_id={task.assigned_tasker_id}") \ No newline at end of file diff --git a/src/api/routes.py b/src/api/routes.py index 4f4e9ae..713d0b5 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,10 +1,35 @@ +# src/api/routes.py +import os +from decimal import Decimal +from datetime import datetime, date + from flask import Blueprint, jsonify, request from flask_cors import CORS -from datetime import datetime -from api.models import db, User, Task, Profile +from api.models import ( + db, User, Task, Profile, + TaskOffered, TaskDealed, Review, Message +) +from api.statuses import TaskStatus, OfferStatus, DealStatus, statuses_as_dict + +# ========================= +# Blueprint + CORS (solo en API) +# ========================= api = Blueprint("api", __name__) -CORS(api, supports_credentials=True) + +FRONT = os.getenv( + "FRONTEND_ORIGIN", + # default para Codespaces (puerto 3000 del front) + "https://urban-space-cod-gj7pgr6p66rhv959-3000.app.github.dev" +) + +# Habilita CORS para todas las rutas de este blueprint +CORS( + api, + resources={r"/*": {"origins": [FRONT]}}, + supports_credentials=False, # no estás usando cookies + expose_headers=["Content-Type"] +) # ========================= # HEALTH @@ -137,7 +162,13 @@ def update_profile(user_id): @api.route("/tasks", methods=["GET"]) def list_tasks(): tasks = Task.query.all() - return jsonify([t.serialize() for t in tasks]), 200 + out = [] + for t in tasks: + d = t.serialize_all_data() + deal = _latest_deal(t.id) + d["assigned_tasker_id"] = deal.tasker_id if deal else None + out.append(d) + return jsonify(out), 200 @api.route("/tasks", methods=["POST"]) @@ -151,11 +182,12 @@ def create_task(): publisher_id=data["publisher_id"], location=data.get("location"), price=data.get("price"), - status=data.get("status", "pending"), + status=data.get("status", TaskStatus.OPEN.value if hasattr( + TaskStatus, "OPEN") else "open"), ) db.session.add(t) db.session.commit() - return jsonify(t.serialize()), 201 + return jsonify(t.serialize_all_data()), 201 @api.route("/tasks/", methods=["GET"]) @@ -163,7 +195,14 @@ def get_task(task_id): t = Task.query.get(task_id) if not t: return jsonify({"error": "Tarea no encontrada"}), 404 - return jsonify(t.serialize()), 200 + + data = t.serialize_all_data() + + # ← AÑADIDO: asignado desde el último deal (fuente de verdad) + deal = _latest_deal(task_id) + data["assigned_tasker_id"] = deal.tasker_id if deal else None + + return jsonify(data), 200 @api.route("/tasks/", methods=["DELETE"]) @@ -174,3 +213,313 @@ def delete_task(task_id): db.session.delete(t) db.session.commit() return jsonify({"message": "Tarea eliminada"}), 200 + +# ========================= +# OFFERS (en Task) +# ========================= + + +@api.route("/tasks//offers", methods=["POST"]) +def create_offer(task_id): + task = Task.query.get(task_id) + if not task: + return jsonify({"error": "Tarea no encontrada"}), 404 + + data = request.get_json() or {} + tasker_id = data.get("tasker_id") + amount = data.get("amount") + message = (data.get("message") or "").strip() + + if not tasker_id or amount is None: + return jsonify({"error": "tasker_id y amount son obligatorios"}), 400 + + # upsert por (task_id, tasker_id) + offer = TaskOffered.query.filter_by( + task_id=task_id, tasker_id=tasker_id).first() + if not offer: + offer = TaskOffered(task_id=task_id, tasker_id=tasker_id) + # si deseas forzar estado inicial: + try: + offer.status = OfferStatus.PENDING.value # si tienes Enum + except Exception: + offer.status = "pending" + db.session.add(offer) + + offer.amount = Decimal(str(amount)) + offer.message = message + + try: + db.session.commit() + except Exception: + db.session.rollback() + return jsonify({"error": "No se pudo guardar la oferta"}), 500 + + return jsonify(offer.serialize()), 201 + + +@api.route("/tasks//offers", methods=["GET"]) +def list_offers(task_id): + tasker_id = request.args.get("tasker_id", type=int) + q = TaskOffered.query.filter_by(task_id=task_id) + if tasker_id: + q = q.filter_by(tasker_id=tasker_id) + rows = q.all() + if tasker_id and not rows: + return jsonify({"message": "no offer for this tasker"}), 404 + return jsonify([r.serialize() for r in rows]), 200 + + +@api.route("/tasks//offers/", methods=["PUT"]) +def update_offer(task_id, offer_id): + data = request.get_json() or {} + row = TaskOffered.query.filter_by(id=offer_id, task_id=task_id).first() + if not row: + return jsonify({"message": "offer not found"}), 404 + + task = Task.query.get(task_id) + if not task or task.status != "open": + return jsonify({"message": "task is not open"}), 400 + + amt = data.get("amount", None) + msg = (data.get("message") or "").strip() + + if amt is not None: + row.amount = Decimal(str(amt)) + row.message = msg + + db.session.commit() + return jsonify(row.serialize()), 200 + +# ========================= +# REVIEWS (cliente → tasker) +# ========================= + + +@api.route("/tasks//reviews", methods=["POST"]) +def create_review(task_id): + task = Task.query.get(task_id) + if not task: + return jsonify({"error": "Tarea no encontrada"}), 404 + + data = request.get_json() or {} + rating = data.get("rating") # 1..5 (float/int) + comment = (data.get("comment") or "").strip() + + # Inferimos cliente y tasker desde el deal más reciente + publisher_id = getattr(task, "publisher_id", None) + deal = TaskDealed.query.filter_by( + task_id=task_id).order_by(TaskDealed.id.desc()).first() + worker_id = data.get("worker_id") or (deal.tasker_id if deal else None) + + if rating is None or worker_id is None or deal is None: + return jsonify({"error": "rating y worker_id/deal requeridos (no se pudo inferir)"}), 400 + + # 1 review por deal + existing = Review.query.filter_by(task_dealed_id=deal.id).first() + if existing: + return jsonify({"error": "Ya existe una review para este deal"}), 409 + + review = Review( + review=comment, + rate=rating, + created_at=datetime.utcnow(), + publisher_id=publisher_id, + worker_id=worker_id, + task_dealed_id=deal.id, + task_id=task_id, + ) + db.session.add(review) + + try: + db.session.commit() + except Exception: + db.session.rollback() + return jsonify({"error": "No se pudo guardar la review"}), 500 + + return jsonify(review.serialize()), 201 + + +@api.route("/tasks//reviews", methods=["GET"]) +def get_reviews(task_id): + task = Task.query.get(task_id) + if not task: + return jsonify({"error": "Tarea no encontrada"}), 404 + + reviews = Review.query.filter_by(task_id=task_id).all() + return jsonify([r.serialize() for r in reviews]), 200 + +# ========================= +# CHAT (mensajes por último deal) +# ========================= + + +def _latest_deal(task_id): + return TaskDealed.query.filter_by(task_id=task_id).order_by(TaskDealed.id.desc()).first() + + +@api.route("/tasks//messages", methods=["GET"]) +def list_messages(task_id): + task = Task.query.get(task_id) + if not task: + return jsonify([]), 200 + + deal = _latest_deal(task_id) + if not deal: + return jsonify([]), 200 + + msgs = Message.query.filter_by(dealer_id=deal.id) \ + .order_by(Message.created_at.asc(), Message.id.asc()).all() + return jsonify([m.serialize() for m in msgs]), 200 + + +@api.route("/tasks//messages", methods=["POST"]) +def create_message(task_id): + data = request.get_json() or {} + body = (data.get("body") or "").strip() + sender_id = data.get("sender_id") # ⚠ tu front debe enviarlo + + if not body or not sender_id: + return jsonify({"error": "body y sender_id son obligatorios"}), 400 + + task = Task.query.get(task_id) + if not task: + return jsonify({"error": "Tarea no encontrada"}), 404 + + deal = _latest_deal(task_id) + if not deal: + return jsonify({"error": "No hay deal para esta tarea"}), 404 + + if sender_id not in (deal.client_id, deal.tasker_id): + return jsonify({"error": "sender_id no pertenece a este deal"}), 403 + + msg = Message( + body=body, + created_at=datetime.utcnow(), + dealer_id=deal.id, + sender_id=sender_id + ) + db.session.add(msg) + + try: + db.session.commit() + except Exception: + db.session.rollback() + return jsonify({"error": "No se pudo crear el mensaje"}), 500 + + return jsonify(msg.serialize()), 201 + +# ========================= +# DEALS +# ========================= + + +@api.route("/tasks//deals", methods=["POST"]) +def create_deal(task_id): + task = Task.query.get(task_id) + if not task: + return jsonify({"error": "Tarea no encontrada"}), 404 + + data = request.get_json() or {} + tasker_id = data.get("tasker_id") + offer_id = data.get("offer_id") + fixed_price = data.get("fixed_price") # opcional + + if not tasker_id: + return jsonify({"error": "tasker_id es obligatorio"}), 400 + if not offer_id: + return jsonify({"error": "offer_id es obligatorio"}), 400 + + offer = TaskOffered.query.filter_by(id=offer_id, task_id=task_id).first() + if not offer: + return jsonify({"error": "Offer no encontrada para esta tarea"}), 400 + if offer.tasker_id != tasker_id: + return jsonify({"error": "offer_id no corresponde al tasker indicado"}), 400 + + # ¿ya hay deal? (solo 1 por task) + deal = TaskDealed.query.filter_by(task_id=task_id).first() + if not deal: + # inferir FKs y precio desde la offer/task + deal = TaskDealed( + task_id=task.id, + offer_id=offer.id, + client_id=task.publisher_id, + tasker_id=offer.tasker_id, + fixed_price=Decimal( + str(fixed_price)) if fixed_price is not None else offer.amount, + status="accepted", + accepted_at=date.today() + ) + db.session.add(deal) + else: + # actualizar por consistencia si ya había deal + deal.offer_id = offer.id + deal.client_id = task.publisher_id + deal.tasker_id = offer.tasker_id + if not deal.fixed_price: + deal.fixed_price = offer.amount + deal.status = "accepted" + if not deal.accepted_at: + deal.accepted_at = date.today() + + # reflejar en Task → CLAVE para canChat + # tu app usa "assigned" post-aceptación + task.status = "assigned" + task.assigned_at = task.assigned_at or date.today() + task.assigned_tasker_id = offer.tasker_id # <<=== IMPORTANTE + + # marcar offer como aceptada + offer.status = "accepted" + + try: + db.session.commit() + except Exception as e: + db.session.rollback() + return jsonify({ + "error": "No se pudo guardar el deal", + "detail": str(getattr(e, "orig", e)) + }), 500 + + return jsonify(deal.serialize()), 201 + + +@api.route("/tasks//deal", methods=["GET"]) +def get_latest_deal_for_task(task_id): + deal = TaskDealed.query.filter_by( + task_id=task_id).order_by(TaskDealed.id.desc()).first() + if not deal: + return jsonify({"error": "No hay deals para esta tarea"}), 404 + return jsonify(deal.serialize()), 200 + + +@api.get("/meta/statuses") +def meta_statuses(): + return jsonify(statuses_as_dict()), 200 + +@api.route("/tasks//complete", methods=["PUT"]) +def complete_task(task_id): + task = Task.query.get(task_id) + if not task: + return jsonify({"error": "Tarea no encontrada"}), 404 + + deal = _latest_deal(task_id) + if not deal: + return jsonify({"error": "No hay deal para esta tarea"}), 400 + + # marcar estados + task.status = "completed" + task.completed_at = date.today() + + deal.status = "completed" + if not deal.delivered_at: + deal.delivered_at = date.today() + + try: + db.session.commit() + except Exception: + db.session.rollback() + return jsonify({"error": "No se pudo completar la tarea"}), 500 + + # enriquecer respuesta con assigned_tasker_id desde el deal (como ya haces) + data = task.serialize_all_data() + data["assigned_tasker_id"] = deal.tasker_id + return jsonify(data), 200 \ No newline at end of file diff --git a/src/front/components/AdminCard.jsx b/src/front/components/AdminCard.jsx index 92e9ca2..9376a46 100644 --- a/src/front/components/AdminCard.jsx +++ b/src/front/components/AdminCard.jsx @@ -1,20 +1,66 @@ -export const AdminCard = () => ( -
+import { useEffect, useState } from "react"; + +export const AdminCard = ({user_id}) =>{ + const [user, setUser] = useState("") + const [admUser, setAdmUser]= useState("") + const [date, setDate]= useState("") + + const getAdminProfile = async (user_id) => { + try { + const BASE = (import.meta.env.VITE_BACKEND_URL || "").replace(/\/$/, ""); + const r = await fetch(`${BASE}/api/users/${user_id}/profile`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }) + const data = await r.json() + setUser(data) + setDate(data["created_at"].substring(8, 16)) + + } catch (err) { + console.error(err); + // TODO: mostrar error en UI + } + }; + + const getAdminUser = async (user_id) => { + try { + const BASE = (import.meta.env.VITE_BACKEND_URL || "").replace(/\/$/, ""); + const r = await fetch(`${BASE}/api/users/${user_id}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }) + const data = await r.json() + setAdmUser(data) + } catch (err) { + console.error(err); + // TODO: mostrar error en UI + } + }; + + const full_name = user["name"] + " " + user["last_name"] + const created = "Miembro desde " + date + console.log(user) + console.log(admUser) + + useEffect(() => { + getAdminProfile(user_id) + getAdminUser(user_id) + }, []) + + return ( +
- ... + ...
-
Nombre Apellido
+
{full_name}

Administrador principal

-

lol@email.com

-

Miembro desde xd

-
-
-

Ultima conexión
3 mins ago

+

{admUser.email}

+

{created}

-); \ No newline at end of file +);} \ No newline at end of file diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx index 84c31c3..d2c5e92 100644 --- a/src/front/components/Navbar.jsx +++ b/src/front/components/Navbar.jsx @@ -1,6 +1,6 @@ -// src/front/components/Navbar.jsx +// src/components/Navbar.jsx import { useEffect, useRef, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { NavLink, Link, useNavigate, useLocation, useMatch } from "react-router-dom"; import { useStore } from "../hooks/useGlobalReducer"; export function Navbar() { @@ -12,174 +12,115 @@ export function Navbar() { const ref = useRef(null); useEffect(() => { - function onDoc(e) { - if (ref.current && !ref.current.contains(e.target)) setOpen(false); - } + + const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; + document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, []); + // destino de My tasks + const myHref = user ? (user.role === "tasker" ? "/tasker" : "/client") : "/login"; + + // activo si estás en /client/* o /tasker/* + const matchClient = useMatch("/client/*"); + const matchTasker = useMatch("/tasker/*"); + const myActive = Boolean(matchClient || matchTasker); + function onLogout() { actions.logout?.(); setOpen(false); nav("/", { replace: true }); } - const username = - user?.username || - (user?.name ? user.name.toLowerCase().replace(/\s+/g, "") : "me"); + + + return ( -